gpui-shell 官方示例全解析:从 Todo 应用到原生动效,掌握完整脚本运行时
2026/9/15 11:44:22 网站建设 项目流程

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_todolist

examples/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导出了labelmutedtitlebuttoniconButtoncheckboxfieldrowsurfaceruleemptyState等一系列函数,让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.jswindow.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开启checkJsgpui-kit.d.tsgpui-shell types生成,types.d.ts存放应用自己的形状——TodoFilterVariantButtonOptions。编辑器补全和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_dock

examples/js_dock/是一个可停靠的工作区——左侧文件列表、中央文档、以及一个“你离开时什么样回来还是什么样”的布局。文件只有两个:

main.js 工作区:面板、停靠、持久化 ui.js 界面装饰:标签页、停靠框、拖放提示

三个要点值得记住。

base 不画任何装饰,所以装饰全在 ui.js

标签栏、停靠框的标题条、折叠控件、调整大小手柄、拖放提示,全部是用普通样式面写成的普通元素(ui.js)。没有任何装饰的区域依然能停靠、拖拽、调整大小并持久化——它只是除了面板什么都不画。装饰层约定了BAR = 30的高度、每块面板的panelBorder描边、dockTab标签页、dockBar标题条、dockHandle调整手柄、emptyGroup空组提示与dropHint落点提示;main.js通过dock_area(...)tab_barempty_groupdrop_indicatordock四个槽位注入这些皮肤(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 -- shell

Gallery 的 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.rsinstall_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精简回一个带initrenderView,保留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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询