Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

前言

为什么写这本书

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 友好度
ReactJavaScript/TSXJSX(编译时转换)快(HMR)Web + RN中——JSX 需要编译
FlutterDartWidget 树(代码即 UI)快(有状态热重载)移动+桌面+Web低——Dart 编译链路长
SwiftUISwift声明式 DSL(编译时宏)预览(Xcode)Apple 全家桶低——Swift 编译+Apple 专属
Qt/QMLC++ / QMLQML(运行时解释)有(QML 热重载)全平台中——QML 是运行时的
eguiRust即时模式(每帧重建)无(需重编译)桌面+Web低——Rust 编译
MakepadRust + SplashSplash(运行时 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章:流式求值)。

跨平台覆盖

框架macOSWindowsLinuxAndroidiOSWeb
Reactvia Electronvia Electronvia ElectronReact NativeReact Native原生
Flutter
SwiftUI原生原生
Qt有(WASM)
egui实验性实验性WASM
MakepadMetalD3D11OpenGLWASM

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 IISplash 语言ch06-ch11想深入 Splash 的开发者
Part IIIWidget 体系ch12-ch17构建复杂 UI 的开发者
Part IV渲染与 Shaderch18-ch21对 GPU 渲染感兴趣的开发者
Part V架构深度ch22-ch26想理解内核的贡献者
Part VIAI-Nativech27-ch32AI 工具开发者

推荐阅读路径

  • 应用开发者: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(platformdrawwidgetsexamples)都在这个仓库中。你不需要单独安装 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++ 工作负载):

  1. 下载 Visual Studio Build Tools
  2. 安装时选择"使用 C++ 的桌面开发"工作负载
  3. 确保包含 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 foundLinux安装 build-essential(Ubuntu)或 gcc(Fedora)
fatal error: 'X11/Xlib.h' not foundLinux安装 X11 开发库(见上面的 apt/dnf 命令)
error: linking with 'link.exe' failedWindows安装 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/


本章小结

步骤命令
安装 Rustcurl --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
运行 Androidcargo makepad android run -p makepad-example-counter --release
运行 iOScargo makepad apple ios run -p makepad-example-counter --release
运行 WASMcargo 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

这一行做了两件事:

  1. 生成跨平台的 main 函数(在 macOS 上是标准的 fn main(),在 Android 上是 JNI 入口,在 WASM 上是 wasm_bindgen 入口)
  2. 初始化 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 应用中都是一样的——它们是"样板代码"。应用的个性化逻辑在 MatchEventscript_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 → Splashscript_eval!(cx, {...})修改状态、触发渲染
Splash → Rust#(RustType::method(vm))注册 Rust 组件到 Splash
Splash → Rustids!(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 AppRust 应用结构体main.rs:43-47
MatchEvent事件处理逻辑main.rs:49-58
AppMain应用生命周期(样板代码)main.rs:60-70

核心要点:

  1. script_mod! 是运行时的——Splash 代码在 VM 中执行,不是在 Rust 编译器中展开
  2. UI 是声明式的——你描述 UI 应该长什么样,不是命令式地构建它
  3. Rust 和 Splash 双向通信——script_eval! 从 Rust 到 Splash,ids! 从 Splash 到 Rust

下一章将在这个基础上,给 counter 应用加入更丰富的交互——多个按钮、状态显示、条件渲染(详见第4章:Counter:状态与事件)。

第4章:Counter——状态与事件

为什么这很重要

上一章展示了 Makepad 应用的骨架结构——app_main!script_mod!MatchEventAppMain。但那个 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: 屏幕刷新

流程很直接:

  1. 状态定义let state = { counter: 0 } 创建一个 Splash 对象
  2. 事件处理on_click: ||{...} 在按钮点击时执行——修改 state.counter,然后调用 refresh() 更新 UI
  3. 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 版本的关键区别:

  1. 事件在 Rust 中被捕获self.ui.button(cx, ids!(increment_button)).clicked(actions) 是 Rust 代码,在 Rust 的类型系统保护下运行
  2. 状态修改通过 script_eval! 桥接:Rust 代码通过 script_eval! 向 Splash VM 发送脚本执行
  3. 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 管理"。注册后,AppMatchEvent 实现就能接收这个 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

数据流形成一个循环:

  1. 用户交互产生事件 → Makepad 分发到 Rust 的 MatchEvent
  2. Rust 通过 ids! 查找 Widget → 判断是哪个按钮被点击
  3. Rust 通过 script_eval! 修改 Splash 状态 → 触发 render()
  4. Splash 的 on_render 回调执行 → 根据新状态更新 Widget → 屏幕刷新

两种模式的选择

维度纯 SplashRust + 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

本章小结

概念纯 SplashRust + Splash
状态定义let state = {...}let state = {...} + mod.state
事件处理on_click: ||{...}MatchEvent::handle_actions
UI 更新ui.name.set_text(...)script_eval!{...render()}
适用场景AI 生成、简单工具生产应用、团队协作

核心要点:

  1. Makepad 的状态管理是灵活的光谱——从纯 Splash 到纯 Rust,根据应用复杂度选择
  2. script_eval! 是 Rust→Splash 的桥梁ids! 是 Splash→Rust 的桥梁
  3. 状态是唯一真相来源——修改状态后触发更新,不要直接操作 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_texton_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 的核心模式:

  1. 数据在 stateitems 数组持有所有 Todo 文本
  2. on_render 根据数据生成 UI:用 while 循环为每一项创建一个 RoundedView
  3. 用户操作修改数据on_click 向数组添加新项
  4. 修改后触发重新渲染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(简化)

TodoRowlet 定义了列表项的模板(详见第8章)。每一行包含:一个复选框(check :=)、一个文字标签(label :=)、一个分类标签(tag :=)、一个删除按钮(delete :=)。所有需要从 Rust 侧动态设置内容的子组件都用 := 命名。

注意这个模板使用了 theme. 变量(如 theme.color_bg_containertheme.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 的虚拟化列表——它只渲染可视区域内的项目。ItemEmpty 是两种行模板:有数据时用 Item(包含 TodoRow),没有数据时显示 Empty(空状态提示)。

CachedView 包裹 TodoRow,启用纹理缓存——已渲染的行被缓存为 GPU 纹理,滚动时直接使用缓存而不重新绘制。这是 PortalList 高性能的关键(详见第15章:列表与虚拟化)。

渲染桥梁:draw_walk 中的数据绑定

连接 Rust 数据和 Splash 模板的关键代码在 TodoListdraw_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(简化,省略空列表处理)

逐步分析这段代码:

  1. TODOS.read().unwrap():获取数据的读锁
  2. list.set_item_range(cx, 0, todos.len()):告诉 PortalList 有多少项数据
  3. list.next_visible_item(cx):获取下一个需要渲染的可见项 ID(PortalList 只返回可视区域内的项)
  4. list.item(cx, item_id, id!(Item)):为这个 item_id 创建或复用一个 Item 模板实例
  5. item.check_box(...).set_active(cx, todo.done):把 Rust 数据绑定到 Splash Widget
  6. item.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_walkArray.map() + virtual DOM diff
虚拟化原生支持(PortalList)需要 react-window 等三方库
运行时修改 UI可以(修改 Splash 后可直接重新求值)需要重新编译/打包
AI 修改 UISplash 代码可以被 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()

核心要点:

  1. 数据驱动 UI = 修改数据 → 触发重绘 → UI 自动反映新状态
  2. PortalList 提供原生虚拟化列表,性能远超 DOM 列表
  3. 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 的语法设计服务于三个目标:

  1. 人类可读性:去掉所有不承载语义的标点符号,让 UI 描述清晰直白
  2. 流式解析:代码可以逐字符到达,解析器无需等待完整输入即可开始工作
  3. 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
        }
    }
}

语法对比矩阵

维度SplashJSONXMLJSXQML
属性分隔符空格逗号 ,空格空格换行/分号
赋值符::== / {}:
嵌套标记{} [] (){} []<> </><> </> {}{}
关闭标记}} ]</Tag></Tag>}
嵌套属性点路径嵌套对象属性或嵌套嵌套对象子组件嵌套
类型声明组件名"type": 字段标签名标签名组件名
流式解析友好度
闭合匹配复杂度}} ] "标签名匹配标签名匹配}

关于"流式解析友好度"的评分说明:

  • Splash(高):tokenizer 和 parser 都原生支持增量输入,无需分隔符确认属性边界
  • JSON(低):严格依赖括号和逗号匹配,部分 JSON 无法被确定性解析
  • XML/JSX(中):标签结构可以增量解析,但关闭标签需要名字匹配;JSX 中的 {expression} 需要完整的 JavaScript 表达式求值
  • QML(中):花括号嵌套可以增量解析,但属性值可以包含 JavaScript 表达式

字符效率对比

直接对比上述五个代码片段的字符数:

语法字符数相对 Splash主要开销来源
Splash~1551.0x
XML~1951.26x关闭标签 </View>
JSX~2101.35x关闭标签 + {{}} 嵌套
QML~2401.55x子组件 Rectangle{} 嵌套
JSON~3202.06x引号、逗号、"type": 字段、"children": 数组

Splash 的字符效率主要来自三个方面:

  1. 无冗余标点:没有逗号、引号(对非字符串值)、闭合标签
  2. 点路径draw_bg.color: #x51cf66 vs JSON 的 "draw_bg": {"color": "#51cf66"}
  3. 隐式类型:组件名即类型,不需要 "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 个主状态:WhitespaceIdentifierOperatorRustValueNumberColor。一个能描述完整 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.stateself.unfinishedself.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_bgdraw_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_bgdraw_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_bgdraw_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.radiusSolidView 上无效(它没有圆角 shader),shadow_radius 在非 Shadow 容器上无效。如果你在错误的容器上设置了这些属性,Splash 不会报错——属性会被静默忽略,这是初学者困惑的常见来源。

下表总结了哪些属性在哪些容器上有效:

属性ViewSolidViewRoundedViewShadowViewCircleView
draw_bg.color有效有效有效有效
draw_bg.radius有效有效
draw_bg.border_size有效有效
draw_bg.shadow_radius有效
draw_bg.color_2

(GradientXView/GradientYView 支持 colorcolor_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

每个组件都有 widthheight,值是 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:组件尽可能大,占满父容器给它的空间。widthheight 的默认值都是 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.radiusshadow_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,左右 20px
  • flow: 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}(垂直居中)。

模式三:属性检查清单

每次写一个新的容器组件时,按这个顺序检查:

  1. height: Fit — 是否设置了?(最常见的遗漏)
  2. width: Fill — 根容器是否填满宽度?
  3. flow: DownRight — 子组件排列方向正确吗?
  4. draw_bg.color — 是否用了正确的容器类型(不是 View)?
  5. new_batch: true — 有背景+文字时是否设置了?
  6. #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.textbody.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 有三个命名子组件:checklabeltag。每个实例可以独立覆写任意一个。注意 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 覆写
  • 所有实例共享的样式 → 在模板中固定,不暴露

模式二:递进式模板构建

问题:一开始不确定模板需要多复杂。

方案:从最简单的模板开始(只有一两个 :=),随着需求增长逐步添加命名子组件。不要一开始就设计"万能模板"。

步骤

  1. 先写 2-3 个具体实例(不用模板)
  2. 找出重复的结构
  3. 提取为 let 模板,把差异部分标记为 :=
  4. 用模板替换原来的重复代码

这正是我们在"实战重构"中做的事情——先看到了 token-dashboard 的四段重复代码,再提取为 StatCard 模板。

模式三:AI 安全的模板结构

问题:AI 生成的模板在升级时可能破坏已有实例。

方案:遵循"接口不变"原则——模板的 := 命名是"接口合约"。修改模板内部结构时,保持所有已有的 := 名称和路径不变。新增功能用新的 := 名称,不修改已有的名称。


本章小结

概念语法作用
模板定义let Name = Widget{...}定义可复用的组件结构
模板实例化Name{...}创建模板的实例
命名子组件id := Widget{...}声明可从外部覆写的子组件
实例覆写Name{id.prop: value}修改实例中命名子组件的属性
嵌套路径Name{outer.inner.prop: value}穿透多层命名容器覆写

三个关键规则:

  1. := 是接口——只有 := 声明的子组件才能被覆写
  2. 路径必须连续——从模板根到目标子组件,每层容器都要有 := 名称
  3. 先定义后使用——let 必须在使用位置的上方

下一章将讲解 Splash 的事件系统——on_clickon_return 等回调如何让 UI 具有交互能力(详见第9章:事件与交互)。

第9章:事件与交互

为什么这很重要

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

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

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

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

四种事件回调

on_click:按钮点击

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

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

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

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

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

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

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

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

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

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

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

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

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

on_return:TextInput 回车

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

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

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

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

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

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

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

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

on_change:Slider 值变化

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

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

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

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

on_render:渲染回调

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

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

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

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

on_render 的典型用途

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

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

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

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

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

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

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


闭包语法详解

无参数闭包

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

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

有参数闭包

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

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

闭包中的变量访问

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

let state = { counter: 0 }

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

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

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

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

不能在闭包内做的事:

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

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

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


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

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

let state = { seconds: 0 running: true }

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

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

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

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

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

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

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

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

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

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

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

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


net.http_request:网络请求

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

let req = net.HttpRequest{
    url: "https://api.example.com/data"
    method: net.HttpMethod.GET
    headers: {"User-Agent": "MakepadApp/1.0"}
}
net.http_request(req) do net.HttpEvents{
    on_response: |res|{
        let data = res.body.parse_json()
        ui.result_label.set_text("" + data.title)
    }
    on_error: |e|{
        ui.result_label.set_text("Error: " + e.message)
    }
}

支持的方法:GETPOSTPUTDELETEHEADPATCHOPTIONS

POST 请求可以发送 JSON body:

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

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

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

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

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

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


实战:pomodoro 的完整事件地图

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

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

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

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

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


模式提炼

模式一:事件 → 状态 → UI

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

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

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

模式二:on_return → on_click 委托

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

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

模式三:on_render 条件渲染

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

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

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


本章小结

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

核心规则:

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

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

第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

三个层级:

  1. 状态组hover):独立的动画轨道。多个组可以同时运行互不干扰
  2. 状态offon):组内的具体状态。每个状态有过渡方式和目标值
  3. 过渡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章:属性与容器)。hoverdownfocusactive 是最常用的 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 秒内从透明过渡到半透明白色,移出时再过渡回去。

工作原理

  1. 鼠标进入时,Makepad 自动将 hover 组切换到 on 状态
  2. Animator 开始将 draw_bg.hover 从 0.0 插值到 1.0(0.15 秒,Forward)
  3. shader 的 pixel 函数用 self.hover 做颜色混合——hover=0 时显示 color(透明),hover=1 时显示 color_hover(半透明白)
  4. 鼠标移出时,hover 组切换回 offhover 从 1.0 插值回 0.0

重要限制:不是所有 Widget 都支持 Animator。

支持 Animator不支持 Animator
View, SolidView, RoundedViewLabel, H1-H4, P, TextBox
Button, ButtonFlat, ButtonFlatterImage, Icon, Slider
CheckBox, Toggle, RadioButtonMarkdown, Html, DropDown
TextInput, ScrollViewSplitter, 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 内置了 hoverdown 两个 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 效果
视觉状态Animatorhover, 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
视觉效果Animatorshader instance 变量自动插值

不要试图用 Animator 管理应用数据(如"当前页面"),也不要用 Splash 变量手动实现 hover 动画。两套系统各有分工。

模式三:过渡类型选择

场景过渡类型duration
hover 效果Forward0.1-0.2s
按钮按下Snap(或 Forward 0.01s)即时
展开/折叠Forward0.2-0.4s
加载动画Loop1.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

函数文档本身就是流式求值的完整说明。关键点:

  1. 增量 tokenize:每次调用只 tokenize 新增的字符片段
  2. checkpoint 恢复:恢复上一次的 parser 状态,撤销之前的自动关闭
  3. 自动关闭:为不完整的代码补上缺失的 },让 VM 可以执行
  4. 完全重新执行:每次从 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 + bodyAppend
POST /splash/endEnd

深入源码:增量求值的实现

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_checkpointparse_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 需要完整输入
Parsercheckpoint + auto-close需要完整 AST 才能输出
VMsilence_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() — 增量追加 + 增量渲染
三阶段协议StreamBeginStreamAppend × N → StreamEnd
checkpointParser 状态快照,支持撤销 auto-close
auto-close自动补全未关闭的结构,让不完整代码可执行
silence_errors静默跳过不完整代码导致的错误

核心要点:

  1. 流式求值是语法设计的结果——Splash 的无分隔符、花括号嵌套设计使 auto-close 成为可能
  2. 用户体验是"观看构建"而非"等待结果"——相同的总时间,不同的感知
  3. 这为 AI 生成 UI 提供了更短的反馈链路——Canvas 的 Agent-to-App 管线的技术基础

Part II(Splash 语言篇)到此完成。下一步进入 Part III(Widget 体系篇),系统讲解 Makepad 的布局引擎、组件库和自定义 Widget 机制。

第12章:布局引擎 Turtle

为什么这很重要

前面的章节中,我们反复使用 flow: Downwidth: Fillalign: 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 的子组件时,它不知道该给它多少空间——因为后面可能还有其他子组件。

解决方案:延迟计算

  1. Turtle 先跳过所有 Fill 子组件,只放置 FitFixed 子组件
  2. 所有非 Fill 子组件放置完毕后,计算剩余空间
  3. 将剩余空间按权重分配给 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)
SizeFill(填满)/ Fit(收缩)/ Fixed(固定)
FlowRight / 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 不支持 animatorcursor。要做可 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 APIRust API
内容变化用户编辑文本on_change: |text|{...}.changed(actions)
回车提交用户按 Enteron_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_iconsUI 图标

来源: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输入框
MarkdownMarkdown 渲染选择
HtmlHTML 渲染选择

下一章讲解交互组件——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_hovercolor_down 是 Animator 驱动的变体——鼠标悬停时过渡到 hover 色,按下时过渡到 down 色(详见第10章)。

按钮事件

模式SplashRust
点击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章)。TextInputon_change / on_return 在运行时也会传入当前文本。


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 的核心概念:

  1. 模板定义Item :=Empty := 定义两种行模板
  2. 虚拟化:只创建可见区域内的行
  3. CachedView:每行被缓存为 GPU 纹理,滚动时直接使用缓存
  4. 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(简化)

三步:

  1. set_item_range(0, N) 告诉列表有 N 项数据
  2. next_visible_item() 返回下一个需要渲染的可见项 ID
  3. list.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

特性PortalListFlatList
虚拟化是(只渲染可见项)否(渲染所有项)
性能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

属性说明
axisHorizontal(左右分割)/ Vertical(上下分割)
alignFromA(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_walkTODOS 数据源读取数据,遍历 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_walkhandle_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

CxDrawDrop 时自动提交字体纹理更新——如果纹理未准备好,则触发全量重绘。

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_texturecolor_texturemsdf_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-28draw/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: 帧缓冲 → 屏幕
  1. DrawEvent 触发——平台层请求重绘
  2. CxDraw/Cx2d 创建——初始化字体纹理、Turtle 布局栈
  3. Widget 树遍历——每个 Widget 的 draw_walk 写入绘制原语
  4. 合批优化——同类绘制实例合并
  5. GPU 提交——每个 DrawPass 提交到平台 GPU API(Metal/D3D11/WebGL)
  6. 字体纹理更新——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 在每帧中被跳过。


本章小结

概念说明
Cx2d2D 绘制上下文,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 结构与变量类型

DrawQuadfragment 调用 pixel()pixel() 返回 vec4 颜色。Shader 中有四种变量类型:

类型声明说明
instanceinstance(default)每个实例不同(rect_pos、color),Splash 可直接设置
uniformuniform(type)同一 draw call 共享,系统自动填充
varyingvarying(type)vertex → fragment 插值传递
texturetexture_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) 程序化渐变色。

GaussShadowGaussShadow.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_widthborder_radiuscolor)都是 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) 定义内部坐标系。

形状元素

形状关键属性
Rectx, y, w, h, rx, ry
Circlecx, cy, r
Ellipsecx, cy, rx, ry
Linex1, y1, x2, y2
Polyline / Polygonpoints: "x,y x,y ..."
Pathd: "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 端通过纹理行实现——DrawVectorgradient_texture 纹理在渲染前由 add_gradient_row 填充。

来源:draw/src/shader/draw_vector.rs:18draw/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_svgtime 参数驱动。

来源: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 + AnimatorTween 属性动画
渐变需手动 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 链委托到 CxDrawCx

#![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: 提交绘制命令
  1. begin_scene_3d 设置相机和投影
  2. set_scene_world_transform_3d 设置模型矩阵(返回旧矩阵用于恢复)
  3. 调用 DrawCube/DrawPbr 等绘制原语
  4. 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

支持的基础网格:CubeSphereCapsuleSurfaceRoundedCube,通过 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

Cx2dCx3d 从同一个 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 事件系统可以定位它们,避免实时射线相交测试。


本章小结

概念说明
Cx3d3D 绘制上下文,与 Cx2d 并行
SceneState3D相机、视图/投影矩阵
DrawCube立方体,Lambert 光照
DrawPbrPBR 材质,完整纹理集
SceneDrawCallAnchor3D 拾取锚点

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_responsehandle_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

整个流程分两个阶段:

  1. 事件阶段:OS 事件向下分发到 Widget 树,Widget 通过 hits() 判断是否命中
  2. 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_clickon_render 等脚本事件的说明。 Rust 层的 Event/Action 系统与 Splash 脚本事件通过 ScriptHook 桥接:

Rust 层Splash 层桥接方式
Event::MouseDown -> Hit::FingerDownon_click 回调ScriptHook 在 handle_event 中触发脚本求值
cx.action()mod.state 更新脚本状态变更后触发 redraw()
ui.view.render() 脚本调用on_render 回调View::script_call(render) 异步执行脚本并以 reload 方式应用结果

模式提炼

模式描述源码位置
类型擦除 ActionBox<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/extendmem::swap 实现零拷贝 Action 流截获platform/src/action.rs
跨线程 post_actionMutex<Sender> + SignalToUI 唤醒主线程platform/src/action.rs

本章小结

Makepad 的事件系统遵循"向下分发、向上反馈"的单向数据流:

  • Event 枚举统一了 60+ 种平台事件,通过 hits() 转化为 Widget 可处理的 Hit
  • ActionTrait 利用 Rust 的 blanket impl 实现零样板的类型擦除通信
  • MatchEvent trait 将庞大的事件枚举分解为独立回调,降低认知负担
  • 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~1250VM 主体: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 31NEED_NIL_FLAG - 需要 nil 检查
bit 30POP_TO_ME_FLAG - 弹栈至 me
bit 29-28TYPE_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 类型里同时定义了 ModStreaming 变体;但当前 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 Boxing64 位值编码,数值/指针共用一个 wordvalue.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:带代际检查的向量

GenVecgen_index.rs)是一个带代际号的向量。每次 free_slot 递增槽位代际, 使旧引用的索引操作会因代际不匹配而 panic,从而检测 use-after-free。

24.3 对象标记系统

每个堆对象有一个 Tag,编码多种状态位:

标记位含义
alloced已分配(区分空槽与活跃对象)
markedGC 标记阶段已标记
static永久对象,不参与 GC
reffedScriptObjectRef 持有
frozen不可变对象

Static 对象通过 heap.set_static(value) 递归标记整个对象图为永久存在, GC 时直接跳过。适合全局模块和内建原型。

24.4 GC 根

标记阶段的起点包括:

根类型说明
root_objects/arrays/handlesRust 侧持有的 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_reusegc.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 },
}
}

注意 IfBodyphi 字段: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 类型MetalHLSLGLSLWGSL
RUST_INSTANCE_io.i->输入结构attributestorage buffer
DYN_UNIFORM_io.u->cbufferuniform bufferuniform
VARYING_iov.v->语义varyingstruct 字段
TEXTURE_2D_io.texturesampler2Dtexture_2d
VERTEX_POSITION_iov.v->_positionSV_Positiongl_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 类型GLSLMetalHLSLWGSL
vec2vec2float2float2vec2<f32>
vec4vec4float4float4vec4<f32>
mat4mat4float4x4float4x4mat4x4<f32>
u32uintuintuintu32

浮点常量输出有安全处理,防止极大/极小值破坏 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 语言窗口系统
macOSMetalMSLNSWindow
iOS / tvOSMetalMSLUIWindow
WindowsD3D11HLSLWin32 HWND
LinuxOpenGL / VulkanGLSLX11 / Wayland / Direct
AndroidOpenGL ESGLSLNativeActivity
WebWebGLGLSLCanvas

详见第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.rsav_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.rsweb_midi.rsweb_socket.rsweb_network.rs(Fetch API)。

26.7 事件统一层

各平台将 OS 原始事件转换为统一 Event 枚举(详见第22章):

Makepad EventmacOSWindowsAndroidWeb
StartupdidFinishLaunchingWM_CREATEonCreateDOMContentLoaded
ForegrounddidBecomeActiveWM_ACTIVATEAPPonStartvisibilitychange
BackgroundwillResignActiveWM_ACTIVATEAPP(0)onStopvisibilitychange
Resume(同 Foreground)WM_SETFOCUSonResumefocus
Pause(同 Background)WM_KILLFOCUSonPauseblur
ShutdownwillTerminateWM_DESTROYonDestroybeforeunload

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=codeSplashRender
POST /splash/stream 空 bodySplashStreamBegin
POST /splash/stream body=chunkSplashStreamAppend
POST /splash/endSplashStreamEnd
POST /clear清空画布
POST /audio/play body=urlAudioPlay
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_toggleplay_btnaudio_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。这看起来"浪费",但实际上:

  1. Splash 的 Widget 创建和替换路径相对直接,中小型树的成本通常可控
  2. 这避免了"增量更新"的复杂性——不需要 diff 算法
  3. 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 是最轻量的唤醒机制。


本章小结

组件文件职责
Appapp.rs主 Widget,处理 Signal、渲染 Splash、路由事件
StdioBridgews/stdio_bridge.rsTCP 监听,WS/HTTP 解析,命令入队
CanvasCommandws/types.rs命令协议枚举(11 个变体)
AudioPlaybackStateaudio.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 代码。关键约束:

  1. 遵守 Splash 语法——属性间通常省略逗号、height: Fit#x 颜色前缀(详见第6-8章)
  2. 包含完整应用逻辑——状态、事件处理,以及在 Canvas 中按需使用 fn tick()fn on_audio() 等环境约定
  3. 谨慎依赖外部资源——当前已经有 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 + CanvasHTML + 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 / WSHTTP/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颜色解析错误#1e1e2ee 被当指数使用 #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.rsAudioPlaybackState — 全局共享播放状态
解码器audio.rsSymphonia 解码 MP3/AAC,输出交错 PCM
频谱分析spectrum.rsHann 窗 + rustfft,输出 16 个对数频段
可视化visualizer.rsDrawVisualizer + 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 频谱分析

SpectrumAnalyzerspectrum.rs)对 2048 点采样做 FFT,输出 16 个频段。三个关键步骤:

  1. Hann 窗w(i) = 0.5 * (1 - cos(2PI * i / (N-1))),减少频谱泄漏
  2. 对数频段映射lo = (band/16)^2 * nyquist,低频段占更多 bin,符合听觉特性
  3. 非对称平滑:上升快(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)、timeamplitude 作为 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") }
}

关键设计模式

  1. 约定命名触发宿主控制:按钮命名为 play_btnaudio_stop 时,Canvas 的 handle_actions 会把点击路由到 AudioPlaybackState::toggle/stop
  2. on_audio 回调:Canvas 以约 10Hz 频率将播放状态(_pos_dur_playing)注入 Splash VM 全局变量,并调用 on_audio 函数更新 UI。
  3. 示例里的 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.b15self.timeself.amplitude 变量在着色器中都可用。


本章小结

  • Canvas 音频管线由四层组成:状态单例、Symphonia 解码、FFT 频谱分析、GPU 可视化
  • AudioPlaybackState 使用原子变量和 try_lock 实现音频线程与 UI 线程的无阻塞通信
  • SpectrumAnalyzer 对 2048 点 FFT 做 Hann 窗、对数频段映射和非对称平滑
  • SpectrumBarsSpectrumCircular 在 GPU shader 中实时渲染 16 频段数据
  • Splash 脚本通过约定命名(play_btnaudio_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-widgetstokio(rt-multi-thread + net)、tokio-tungsteniteserde_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.x2.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 定义运行时求值方案
MakepadSplash (script_mod!)已实现
FlutterDart 编译Dart VM + hot reload(部分实现)
SwiftUISwift 编译Swift Playground(受限)
ComposeKotlin 编译尚无运行时方案
WebHTML/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,缺少结构化的错误报告机制。


对开发者的建议

  1. 现在开始用 Splash 构建原型:运行时求值的优势在原型阶段最明显——修改即见效,无需编译等待。
  2. 复杂逻辑留在 Rust 端:Splash 适合 UI 布局和简单交互,性能敏感或复杂业务逻辑应在 Rust 中实现,通过 ScriptHook 桥接(详见第17章自定义 Widget)。
  3. 关注 Canvas 模式:即使你不用 Canvas 本身,"WS + Splash + 事件回传"的架构模式可以应用于任何需要动态 UI 的场景(详见第31章)。
  4. 参与社区: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

尺寸 / 布局 / 对齐

属性说明
widthFill / Fit / 200默认 Fill
heightFill / Fit / 200必须设 Fit
flowRight / Down / Overlay排列方向
spacing10子组件间距
padding15 / Inset{top: 5 left: 10}内边距
alignCenter / 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")

八条必记规则

  1. height: Fit — 每个容器必须设置
  2. width: Fill — 根容器必须用 Fill(不用固定宽度)
  3. #x 前缀 — 所有颜色统一使用
  4. new_batch: true — 有背景+文字时必须加
  5. := 命名 — 需要覆写的子组件用 :=
  6. SolidView/RoundedView — 需要背景色时用(不用 View
  7. 尾部点号 — 浮点数写 8. 不写 8
  8. draw_bg +: — 修改子属性用 +:(不用 :,会替换全部)

附录B:Widget 属性参考

容器

Widget背景形状用途
View纯布局
SolidView纯色矩形页面背景
RoundedView纯色圆角卡片
RoundedShadowView色+阴影圆角悬浮卡片
CircleView纯色头像/指示器
GradientXView / GradientYView渐变矩形装饰
CachedView纹理缓存性能优化
ScrollYView / ScrollXView滚动区域

容器属性

属性默认
widthFill / Fit / 数字Fill
heightFill / Fit / 数字Fill
flowRight / Down / OverlayRight
spacing数字0
padding数字 / Inset{}0
alignCenter / Align{x: y:}TopLeft
show_bgtrue / falsefalse (View) / true (SolidView等)
new_batchtrue / falsefalse
visibletrue / falsetrue
cursorMouseCursor.Hand 等默认指针

draw_bg 属性

属性类型适用容器
draw_bg.color颜色SolidView, RoundedView 等
draw_bg.radiusf32RoundedView 系列
draw_bg.border_sizef32RoundedView, RectView
draw_bg.border_color颜色RoundedView, RectView
draw_bg.shadow_radiusf32ShadowView 系列
draw_bg.shadow_color颜色ShadowView 系列
draw_bg.color_2vec4GradientView 系列

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事件样式变体
Buttonon_click / on_press / .clicked() / .pressed()ButtonFlat, ButtonFlatter
CheckBoxon_click / .changed()CheckBoxFlat
Toggleon_click / .changed()ToggleFlat
RadioButton.clicked() / RadioButtonSet::selected()RadioButtonFlat
Slideron_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.posvec2归一化坐标 (0-1)
self.rect_sizevec2Widget 像素尺寸
self.rect_posvec2Widget 屏幕位置

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.xMakepad 2.0
UI 宏live_design!{...}script_mod!{...}
UI 语言声明式数据格式Splash 脚本语言
求值方式宏提取 + 运行时解析完整 VM 执行
动态能力静态属性声明变量、函数、闭包、条件渲染
Widget 语法<Widget> 尖括号Widget{} 花括号
属性赋值Key = ValueKey: value
主题引用(THEME_COLOR)theme.color
生命周期LiveHookScriptHook
属性覆写apply_overscript_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.x2.0说明
id!(name)ids!(name)Widget 查找
apply_over + live!{}script_eval!{}运行时修改 UI
LiveHook traitScriptHook derive生命周期钩子
#[live] 属性#[live] 属性不变
逗号分隔属性空格分隔(逗号仍可用)逗号被当作空白,新代码推荐省略
<Widget> 尖括号Widget{} 花括号统一为花括号

迁移步骤

  1. 替换宏live_design!script_mod!
  2. 替换语法<Widget>Widget{},属性间通常省略逗号,=:
  3. 替换命名name =name :=
  4. 替换主题(THEME_COLOR)theme.color
  5. 替换事件apply_over + live!script_eval!
  6. 替换查找id!(name)ids!(name)
  7. 添加脚本能力:利用 on_clickon_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 时,输出顺序影响流式渲染效果:

  1. 先输出容器结构(用户看到布局框架)
  2. 再输出文字内容(用户看到信息)
  3. 最后输出样式细节(颜色、字号、圆角)

这样用户在前几百毫秒就能看到有意义的 UI 结构,而不是等到所有细节都输出完才看到东西(详见第11章:流式求值,第29章:自愈循环)。