gpui-shell 官方示例全解析:从 Todo 应用到原生动效,掌握完整脚本运行时
【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit
本篇指南以 gpui-kit 仓库website/shell/examples.md为核心,逐一拆解随仓库发布的四个示例——独立 Todo 应用、可停靠工作区、Gallery 中的行情看板以及原生动效演示。读完你可以掌握:如何用 JavaScript 编写一个完整、带类型检查、支持持久化的 GPUI 应用,如何让脚本层与 Rust 宿主共享同一份数据,以及如何让动画帧完全脱离 JavaScript 在 GPUI 侧本地采样。
示例总览:一条覆盖全部脚本面
仓库自带的示例遵循“一个示例只讲一件事”的原则,四个示例拼起来正好覆盖了脚本运行时的全部表面:
| 示例 | 运行形态 | 演示内容 |
|---|---|---|
| Todo list(待办列表) | 独立应用 | 完整脚本面:保留输入、对话框、Toast、受限存储、资源、类型 |
| Workspace(工作区) | 独立应用 | 可停靠布局:重启后依旧存活的面板,全部界面装饰由脚本绘制 |
| Quote board(行情看板) | Gallery 内的一个面板 | 宿主侧:HostModule 注册、一个实体被两种语言读取、实时开销计数 |
| Native motion(原生动效) | 独立的 Gallery 脚本视图 | 像素级目标过渡与弹簧,由 GPUI 保留并采样 |
文档还特别指出,如果你想看“一个产品级应用”的完整形态——OAuth、WebSocket 实时行情、虚拟化自选列表、价格图的保留嵌套视图、自带 Rust 宿主二进制——可以参考longbridge/longbridge-lite,它是一个数千行 JavaScript 的只读桌面客户端,也是目前针对该运行时编写的最大的项目。
完整应用:The todo list
运行方式(在仓库根目录):
cargo run -p gpui-shell -- examples/js_todolistexamples/js_todolist/的定位是“锻炼整个运行时”而非“最小化”——gpui-shell里任何一环坏了,这里最先暴露出来。它的文件构成如下:
main.js 视图:状态、过滤、全部事件处理器 ui.js 表现层,以函数形式导出 storage.js 持久化,以及未获授权时的处理 confirm.js 确认对话框,一个独立的视图 icons/ 四个 SVG,按应用根目录解析 gpui-kit.d.ts 生成产物;jsconfig.json 和 types.d.ts 负责接线类型其中有四个值得抄走的做法。
ui.js 是一个由函数构成的组件库
ui.js导出了label、muted、title、button、iconButton、checkbox、field、row、surface、rule、emptyState等一系列函数,让main.js读起来就像在使用组件库:
export const label = (value, cx) => div().text_size(12).line_height(1).text_color(cx.theme().colors.foreground).child(value); export const surface = (cx) => v_flex().flex_1().bg(cx.theme().colors.surface).border(1).border_color(cx.theme().colors.border).overflow_hidden();main.js把当前的cx传给这些辅助函数,它们直接通过cx.theme()读取语义化 token。这在性能上毫无代价,因为“一个全新的描述(description)正是函数调用产生的东西”,参见 Elements 文档。同时这正是对“基础层不提供任何带样式的控件”这一事实的回应:把带样式的层写一次、写在自己的文件里,之后就不用反复复制了。
从源码可以看到ui.js的完整约定(ui.js):
- 间距遵循语义刻度
SPACE = { xxs: 2, xs: 4, sm: 8, md: 12, lg: 16, xl: 24, xxl: 32 },字号保持在 12/13/16/20; - 视觉语言对齐
crates/base/examples/showcase:中性灰阶、1px 边框、方角、小字号、28px 高控件;但 showcase 只能写死颜色(base 不带调色板),这里改读 shell 的语义 token,因此同一份代码能跟随主题; - 按钮只有两个变体:实心 primary 与描边 secondary,外加 ghost 和 danger 两个安静变体,共四种
variant; - 图标按钮必须携带无障碍标签(
accessibility_label(description)),因为单独的图标对屏幕阅读器毫无信息量; - 输入框由运行时框定为一个点击聚焦的居中行,高度、内边距与颜色仍归应用自己控制。
存储吸收拒绝,而不是检查权限
store(这里的localStorage)在宿主未授权时会直接抛错,这是关于宿主的事实,而不是应用的错误。所以storage.js在边界处吸收异常:
export function load() { try { const saved = localStorage.getItem(KEY); if (saved === null) return []; const items = JSON.parse(saved); return Array.isArray(items) ? /** @type {Todo[]} */ (/** @type {unknown} */ (items)) : []; } catch (/** @type {any} */ error) { console.warn(`todolist: storage unavailable, starting empty (${error.message})`); return []; } }save()返回写入是否落盘,而页脚会如实告知用户——未授权时显示 “Not saved — this host did not grant storage, so the list lasts for this run only”。做法是:在边界吸收拒绝,然后把真相告诉用户。main.js中的commit()正是用this.persisted = save(this.items)记录保存结果并cx.notify()触发重绘。
对话框是函数,不是元素
confirm.js默认导出一个“返回内容函数”的函数;main.js用window.open_dialog(confirmClear(count, cx, onConfirm))打开它。计数和回调通过闭包捕获,而不是作为props对象跨通道传递——因为元素属于构建它的那次渲染,而对话框会活得比那次调用更久。参见 Overlays。关闭则调用window.close_dialog(),确认后的清理动作(删除已完成项、推送 Toast)都收在回调里:
window.push_toast({ title: `Deleted ${count} ${count === 1 ? "item" : "items"}`, level: "info", id: "cleared" });类型就位,而且只用了三个文件
jsconfig.json开启checkJs,gpui-kit.d.ts由gpui-shell types生成,types.d.ts存放应用自己的形状——Todo、Filter、Variant、ButtonOptions。编辑器补全和checkJs报错由此生效,且没有任何构建步骤。三份配置的要点(jsconfig.json):
noImplicitAny开启:每个参数与状态都带类型,以 JSDoc 而非 TypeScript 书写——编辑文件后窗口立刻变化、无需中间编译,这正是脚本层的全部意义;strictNullChecks关闭,且这是运行时的形态而非偏好:视图在init中赋值状态,TypeScript 无法像看待构造函数那样认定其已确定赋值,开启只会带来无意义的?.噪音;gpui-kit.d.ts之外的形状手写放在 types.d.ts,让调用点的标注压缩到每个名字一个词。
可停靠工作区:The workspace
cargo run -p gpui-shell -- examples/js_dockexamples/js_dock/是一个可停靠的工作区——左侧文件列表、中央文档、以及一个“你离开时什么样回来还是什么样”的布局。文件只有两个:
main.js 工作区:面板、停靠、持久化 ui.js 界面装饰:标签页、停靠框、拖放提示三个要点值得记住。
base 不画任何装饰,所以装饰全在 ui.js
标签栏、停靠框的标题条、折叠控件、调整大小手柄、拖放提示,全部是用普通样式面写成的普通元素(ui.js)。没有任何装饰的区域依然能停靠、拖拽、调整大小并持久化——它只是除了面板什么都不画。装饰层约定了BAR = 30的高度、每块面板的panelBorder描边、dockTab标签页、dockBar标题条、dockHandle调整手柄、emptyGroup空组提示与dropHint落点提示;main.js通过dock_area(...)的tab_bar、empty_group、drop_indicator、dock四个槽位注入这些皮肤(main.js)。
标签携带命令,而不是处理器
chrome 描述在其原生状态变化前是缓存的,因此内部的脚本事件处理器没有可靠的生存期。select_tab(group, tab.index)、close_panel(group, tab.id)、drag_tab(group, tab.index)完全不携带脚本值——它们只是指名一个容器以及要向它请求什么。拖拽标签时携带的是 base 自己的面板负载,所以把标签丢到另一个组就是移动面板。
面板是带两个额外方法的 View
Document是普通视图,serialize()返回标题与编辑次数,deserialize(data)在重启后把它们取回:
serialize() { return { caption: this.caption, edits: this.edits }; }关于面板的其他一切——它坐落在哪、是否显示——都是布局的事,永远不会触及脚本。首次启动时,Workspace.init通过DockArea.register_panel("document", Document)等注册面板类(必须先注册再加载,否则保存的布局无法找回类),并用DockArea.new("workspace", { version: 1 })建 dock;随后:
this.dock.add_panel(cx.new(Files), { name: "files", placement: "left", size: 200 }); this.dock.add_panel(cx.new(Document, { caption: "main.js" }), { name: "document" }); this.dock.add_panel(cx.new(Document, { caption: "ui.js" }), { name: "document" }); this.dock.add_panel(cx.new(Outline), { name: "outline", placement: "right", size: 220 });左右两个侧边 dock 都是有意的:它们排在同一行的两侧,只有左侧的示例无法区分“放对位置的 dock”和“碰巧排在第一的 dock”。持久化走layout_changed事件:事件在拖拽的每一步都会触发,所以写入落在 500ms 定时器上而不是事件上——this.dock.dump()序列化整棵树、dock 尺寸和每个面板自己的负载,this.dock.load(JSON.parse(saved))一次性还原。完整停靠表面见 Dock and Panels。
行情看板:The quote board
cargo run -- shellGallery 的 Shell story 并排运行两个面板:左侧由shell_story.rs用 Rust 绘制,右侧由crates/story/js/quotes/main.js用 JavaScript 绘制,读取同一份数据。
脚本不拥有任何状态。看板是 Rust 的Entity<Market>,从 story 在运行时启动前注册的 HostModule 导入:
import { quotes, ticks, watch, watch_all } from "market";主题值来自调用作用域的cx.theme()Snapshot,而不是第二个 HostModule。从 Rust 侧看,crates/story/src/stories/shell_story.rs的install_host_modules()调用gpui_shell::export_module(...),注册名为"market"的HostModule——注释写明“只授予脚本 market 模块,此外一无所有”,未注册模块的导入会直接失败并提示该宿主未授予。main.js中的读取都是同步快照(readQuotes()、readTicks()),只有summary()是异步的:它返回 Promise,其工作在离主线程处执行,表现为请求在途时看板依旧在跳动;调用侧用cx.spawn(async (cx) => {...})等待结果并cx.notify()。
因为两个面板读取同一个实体,两者任何不一致都会立刻暴露——这正是它作为“测试”而非“演示”的原因。编辑main.js会改变右侧面板,且中间不需要cargo build:story 在面板旁放了一个 “Reload script” 按钮。其下就是本文档反复引用的计数器读数:脚本每秒运行次数对比每秒帧数,并带一个 feed 选择器让两者互不干扰——这就是 性能声明 在运行窗口中的可视化。渲染侧同样克制:rows()不做任何cx.notify(),因为宿主调用让 Rust 修改看板,Rust 通知其观察者,两半从同一次变更重新渲染。
原生动效:Native motion
crates/story/js/motion/main.js刻意做成一个与行情基准相互独立的ScriptView,这样动画活动不会污染渲染频率的测量。它让你在.transition(...)与.spring(...)之间切换,然后重设 opacity 与像素值的 width、height、left、top 目标。
motion(element) { if (this.policy === "spring") { return element .spring("left", { response: 360, damping: 0.72 }) .spring("width", { response: 300, damping: 0.8 }) .spring("opacity", { response: 220, damping: 1 }); } return element .transition("left", { duration: 340, easing: "ease-in-out" }) .transition("width", { duration: 260, easing: "ease-out" }) .transition("opacity", { duration: 180, easing: "ease-out" }); }脚本只运行一次来发布新目标。之后的每一个动画帧都由 GPUI 在本地调度与采样,没有 JavaScript 重新进入。示例只用数值型像素目标——没有rem、百分比或auto——并采用稳定 id("motion-runner"),这样保留的通道在描述重建后依然存活。舞台使用overflow_hidden裁剪,配合relative()定位与固定的REST_LEFT/TRAVEL行程常量(远边落在 348px,适配最窄的面板),落点之间由刻度线标注 OPEN 与 LIVE TICK 两站。
从哪里开始
把examples/js_todolist复制到你自己的目录里直接运行——它是一个完整应用,类型已经接好线。把main.js精简回一个带init和render的View,保留ui.js,然后在此基础上逐步搭建。
如果你要做宿主侧,crates/story/src/stories/shell_story.rs是可参考的实现:它构建运行时、导出 HostModule 注册、挂载ScriptView、并按需重载。对应的调用说明见 Hosting。
【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考