Getting Started

本指南创建一个带 GPUI Kit 按钮的小型桌面窗口。你需要 Rust、Cargo,以及对应平台的系统库;macOS、Windows 和 Linux 的要求见安装。学习这里的视图模型后,如需浏览器目标可继续阅读 WebAssembly。

#创建项目

cargo new gpui-hello
cd gpui-hello

在生成的 Cargo.toml 中加入 GPUI Kit:

[dependencies]
gpui-kit = "0.7.0"

只需这一个依赖,即可使用 GPUI、GPUI Base、带样式的 GPUI Component 和默认图标资源。应用代码通过 use gpui_kit::*; 使用 GPUI,通过 gpui_kit::component 使用组件。以后可以调整 feature 选择,详见图标与资源。

#添加视图

将 src/main.rs 替换为:

use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::*;

struct HelloWorld;

impl Render for HelloWorld {
    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
        div()
            .flex()
            .flex_col()
            .size_full()
            .items_center()
            .justify_center()
            .gap_2()
            .child("Hello, World!")
            .child(
                Button::new("hello")
                    .primary()
                    .label("Click me")
                    .on_click(|_, _, _| println!("Clicked!")),
            )
    }
}

fn main() {
    application()
        .with_assets(assets::Assets)
        .run(|cx| {
            init(cx);

            open_window(WindowOptions::default(), cx, |_, cx| {
                cx.new(|_| HelloWorld)
            })
            .expect("Failed to open window");
        });
}

在项目目录执行 cargo run。窗口会显示文字和按钮;点击按钮后,终端会输出 Clicked!。

启动过程分为三步:

  1. gpui_kit::application() 创建桌面应用;.with_assets(...) 注册默认图标资源。
  2. gpui_kit::init(cx) 初始化启用的 Kit 层,包括组件主题。只调用一次,并且要先于打开应用窗口或构造组件。
  3. gpui_kit::open_window(...) 从闭包创建 Entity<HelloWorld>,再用 Root 包裹它。Root 管理窗口的浮层,包括对话框、抽屉和通知。闭包返回内容视图,不要再自行返回一个 Root。

HelloWorld 实现 GPUI 的 Render trait。GPUI 渲染视图时,render 返回元素树:一个包含文字和 Button 的 div。按钮是在本次渲染中构建的值;如果控件需要持久状态,比如输入框文字,所属视图应保存对应的状态 Entity,不要在 render 中重新创建。

#一个简短的心智模型

Entity 保存跨帧状态。它可以持有不参与绘制的 model;当 T 实现 Render 且被挂载时,这个 Entity 就是持久的 View,每次渲染都会生成新的元素树。RenderOnce 组件则把输入作为一个值,描述树中可复用的一部分。调用方提供当前状态和 handler 时适合用它;它仍可使用少量带 key 的元素状态。复杂状态、订阅和任务则需要持久的 owner。

Application entry → feature units (model, command, view)
                    ├─ Entity<Model>       retains state
                    └─ Entity<View>        persistent view; View implements Render
                          └─ Element tree   rebuilt on each render
                               └─ RenderOnce values compose reusable parts

应用增长后,拥有独立流程的功能可以把 model 和 View 放在同一个 feature crate 内;只有需要真正面向整个应用的状态时,才在其中使用私有 Global。功能之间通过小型公开接口、event 或 Entity handle 协作。这样可复用部分容易接入,团队成员或 AI 代理并行修改时也有清晰边界。何时拆分以及如何确定所有权和依赖方向,详见编码指南。

#接下来读什么

随着应用扩展,可按顺序阅读:

  1. 阅读 Entity、Context 和 Render:持有一个值,在按钮回调中修改它,并确认窗口显示新值。再读 Window,了解 Root 如何承载视图和浮层。
  2. 阅读 Element 和 RenderOnce:区分每次重建的元素树与持久状态。跟着 Paint 运行 Brush 练习,确认按下指针会改变绘制结果。
  3. 阅读 Focus、Action 和 Event:用 Tab 到达交互目标,触发一个命令并观察状态变化。再按 Task 运行流式示例;连续点击两次 Replay,确认旧分片不会重新出现。
  4. 阅读无障碍和测试:用键盘完成 Save 流程,检查焦点与可见结果,再运行文档中的 UI 测试,确认渲染状态和保存的模型值。在目标平台上另行检查辅助技术的实际表现。
  5. 从组件目录选择应用需要的控件;按界面需求继续阅读图标与资源和字体。

如需了解持久输入状态与订阅,请看应用示例中的测试用例。编码指南说明了这些示例遵循的约定。

#完整且经过测试的 View

以下设置界面展示如何持有 InputState 并管理订阅。代码会与对应的 Rust 源文件保持同步。仓库中的 gpui-kit-recipes 默认可执行程序只打开一个简单的 bootstrap 视图,不会显示这个设置界面。设置界面交互测试会在 GPUI 测试窗口中挂载 Settings。在仓库根目录执行:

cargo test -p gpui-kit-recipes --test settings

测试通过时,输入后预览值先变为 a;在一次无关的重新绘制后继续输入,预览值变为 ab,变更次数依次为 1 和 2。这验证了测试窗口内的输入订阅和状态生命周期,不等同于原生界面的视觉验收。

use gpui_kit::component::{
    ActiveTheme, IconName, WindowExt,
    button::Button,
    checkbox::Checkbox,
    form::{Field, Form},
    input::{Input, InputEvent, InputState},
    radio::RadioGroup,
    switch::Switch,
};
use gpui_kit::{
    AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
    Styled as _, Subscription, Window, div,
};

pub struct Settings {
    name: Entity<InputState>,
    preview: SharedString,
    changes: usize,
    enabled: bool,
    remember: bool,
    delivery: Option<usize>,
    _subscriptions: Vec<Subscription>,
}

impl Settings {
    pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
        let name = cx.new(|cx| InputState::new(window, cx).placeholder("Name"));
        let subscription = cx.subscribe_in(&name, window, |this, state, event, _, cx| {
            if matches!(event, InputEvent::Change) {
                this.preview = state.read(cx).value().to_string().into();
                this.changes += 1;
                cx.notify();
            }
        });
        Self {
            name,
            preview: "".into(),
            changes: 0,
            enabled: false,
            remember: false,
            delivery: Some(0),
            _subscriptions: vec![subscription],
        }
    }

    pub fn input(&self) -> Entity<InputState> {
        self.name.clone()
    }

    pub fn preview(&self) -> &SharedString {
        &self.preview
    }

    pub fn changes(&self) -> usize {
        self.changes
    }
}

impl Render for Settings {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        div()
            .flex()
            .flex_col()
            .size_full()
            .p_4()
            .gap_3()
            .bg(cx.theme().background)
            .text_color(cx.theme().foreground)
            .child("Profile")
            .child(
                Form::new()
                    .child(Field::new().label("Name").child(Input::new(&self.name)))
                    .child(Field::new().label("Preview").child(self.preview.clone()))
                    .child(
                        Field::new().label_indent(false).child(
                            Checkbox::new("remember")
                                .label("Remember name")
                                .checked(self.remember)
                                .on_change(cx.listener(|this, value, _, cx| {
                                    this.remember = *value;
                                    cx.notify();
                                })),
                        ),
                    )
                    .child(
                        Field::new().label_indent(false).child(
                            Switch::new("enabled")
                                .label("Enable notifications")
                                .checked(self.enabled)
                                .on_change(cx.listener(|this, value, _, cx| {
                                    this.enabled = *value;
                                    cx.notify();
                                })),
                        ),
                    )
                    .child(
                        Field::new().label("Delivery").child(
                            RadioGroup::new("delivery")
                                .children(["Immediately", "Daily summary"])
                                .selected_index(self.delivery)
                                .on_change(cx.listener(|this, value, _, cx| {
                                    this.delivery = Some(*value);
                                    cx.notify();
                                })),
                        ),
                    )
                    .footer(
                        Button::new("about")
                            .label("About…")
                            .icon(IconName::Info)
                            .on_click(|_, window, cx| {
                                window.open_dialog(cx, |dialog, _, _| {
                                    dialog.title("About").child("A complete GPUI Kit window")
                                });
                            }),
                    ),
            )
    }
}

CC BY 4.0 · GPUI Kit 有权授权的原创文档正文与图示可在注明署名后复制或改编。 许可条款。请署名 GPUI Kit,链接到 本页原文及 CC BY 4.0 许可,并注明是否修改。 代码示例与软件源码仍适用 Apache-2.0。既有 Apache 权利不受影响;第三方材料保留各自许可。