# GPUI Kit > A comprehensive Rust framework for building fantastic, high-performance desktop apps with GPUI. Documentation license: original prose and illustrations for which GPUI Kit holds licensing rights are also offered under CC BY 4.0. When copying or adapting, credit GPUI Kit, link the source (https://gpui-kit.com) and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples and software source use Apache-2.0; third-party material retains its terms; existing Apache-2.0 permissions remain. --- # Task Source: /versions/v0.7.1/docs/task License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/task and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. In GPUI, a [`Task`](https://docs.rs/gpui-pre/0.3.8/gpui/struct.Task.html) is the handle to work scheduled by a GPUI executor. Its most important property is **ownership**: dropping the handle cancels unfinished work. A task runs only while its handle is stored, awaited, or explicitly detached. This makes the task's lifetime part of the View's state design, not just a detail of Rust's `Future` trait. The **spawn API** chooses where work runs; the returned `Task` controls its lifetime. A foreground task can re-enter GPUI through an async [Context](./context) and update an [Entity]. A background task runs away from the UI thread and returns owned data; it cannot mutate Entity state there. | Start from | Runs on | Async callback receives | Use for | | --- | --- | --- | --- | | `cx.spawn(...)` in `Context` | Foreground thread | `WeakEntity`, `&mut AsyncApp` | Awaiting I/O, timers, then updating an Entity | | `cx.spawn_in(window, ...)` | Foreground thread | `WeakEntity`, `&mut AsyncWindowContext` | Work whose completion needs the same [Window](./window) | | `cx.spawn(...)` in `App` | Foreground thread | `&mut AsyncApp` | Application-level work without a current Entity | | `cx.background_spawn(...)` | Background executor | No GPUI context | Expensive parsing or computation on owned `Send` data | ## Run the repository example From the repository root, after installing the platform prerequisites in [Installation](/versions/v0.7.1/docs/installation), run: ```sh cargo run -p example-stream-markdown ``` The window opens with a **Replay** button and a **Fade in streamed text** switch. Click Replay: the text clears, then appears in chunks. Click it again before the stream finishes to start a new replay; only chunks tagged with the current replay ID are displayed. Closing the window drops the View's task handles, but a producer already running its synchronous loop can finish that loop before stopping. The complete source is [`examples/stream-markdown/src/main.rs`](https://github.com/longbridge/gpui-kit/blob/main/examples/stream-markdown/src/main.rs); its package and dependencies are in [`examples/stream-markdown/Cargo.toml`](https://github.com/longbridge/gpui-kit/blob/main/examples/stream-markdown/Cargo.toml). This is a runnable workspace example; for a one-dependency app skeleton, start with [Getting Started](/versions/v0.7.1/docs/getting-started). Follow one click through the source: `main` creates the window; `Example::new` creates the channel and saves a foreground receiver `Task`; the button calls `Example::replay`, which increments `replay_id`, clears the text and replaces the background producer `Task`; the receiver checks that ID before updating `TextViewState`. The example uses a worker to simulate incoming chunks. In an actual network stream, await the source's asynchronous read and await a bounded channel send so a slow UI cannot cause unbounded queued data. ## Build a task you can start, cancel, and fail The streaming example shows task ownership but has no failure control. This small app makes all three outcomes visible. In the existing repository checkout, save the following as `examples/hello_world/src/bin/task_lab.rs` (create the `bin` directory if needed), then run `cargo run -p hello_world --bin task_lab` from the repository root. It uses the existing `hello_world` package and adds no dependency or workspace member. ```rust use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::*; use std::time::Duration; struct TaskLab { status: String, request_id: u64, task: Option>, } impl TaskLab { fn start(&mut self, fail: bool, cx: &mut Context) { self.request_id = self.request_id.wrapping_add(1); let request_id = self.request_id; self.task = None; // Drop the previous handle before starting again. self.status = format!("Loading request {request_id}..."); cx.notify(); self.task = Some(cx.spawn(async move |this, cx| { let result: Result = cx .background_spawn(async move { // Stand in for expensive work; this blocks a worker, not the UI thread. std::thread::sleep(Duration::from_millis(800)); if fail { Err("Simulated failure".into()) } else { Ok(format!("Completed request {request_id}")) } }) .await; _ = this.update(cx, |view, cx| { if view.request_id != request_id { return; // A newer start or cancel owns the displayed state. } view.status = match result { Ok(value) => value, Err(error) => format!("Failed: {error}"), }; cx.notify(); }); })); } fn cancel(&mut self, cx: &mut Context) { self.request_id = self.request_id.wrapping_add(1); self.task = None; // Dropping the handle prevents future polls. self.status = "Cancelled".into(); cx.notify(); } } impl Render for TaskLab { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .flex() .flex_col() .size_full() .items_center() .justify_center() .gap_2() .child(self.status.clone()) .child( Button::new("start") .primary() .label("Start") .on_click(cx.listener(|view, _, _, cx| view.start(false, cx))), ) .child( Button::new("fail") .label("Fail") .on_click(cx.listener(|view, _, _, cx| view.start(true, cx))), ) .child( Button::new("cancel") .label("Cancel") .on_click(cx.listener(|view, _, _, cx| view.cancel(cx))), ) } } fn main() { application().with_assets(assets::Assets).run(|cx| { init(cx); open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| TaskLab { status: "Idle".into(), request_id: 0, task: None, }) }) .expect("Failed to open window"); }); } ``` Click **Start** and watch **Loading** become **Completed** after about 800 ms; the window should still accept clicks during the wait. Click **Fail** to see a deterministic **Failed** state. Click **Start**, then **Cancel** before it completes: **Cancelled** should remain visible. Click **Start** and immediately **Fail**: only the latest request may update the label. A cancelled worker that is already inside `sleep` may finish that blocking call; the dropped `Task` and request ID prevent its result from changing the View. This sleep is deliberately confined to a background worker for the exercise. Real I/O should use an async API, and long CPU jobs need bounded steps or another cancellation mechanism if prompt stopping matters. After the exercise, delete only `examples/hello_world/src/bin/task_lab.rs`; keep any other files in `src/bin`. In an unmodified checkout, this leaves only the original `hello_world` binary, so the `cargo run -p hello_world` commands in other guides work again. If you already have other binaries in that package, specify `--bin hello_world` when running its original example. The View owns the foreground `Task`. That task awaits a background `Task>`, then updates the View through the weak Entity supplied by `cx.spawn`. `cx.notify()` makes each status transition visible. Keep the completed handle in the field until the next start or cancel; dropping a completed task is harmless. The `Result` branch shows the error in the UI instead of silently discarding it. ## How execution moves between threads On a desktop app, GPUI's **foreground executor** polls `cx.spawn` and `cx.spawn_in` futures on the main/UI thread. Several foreground tasks can be *concurrent*: when one awaits an unfinished operation, its poll returns `Pending`, and the UI thread can handle input, render, or poll another task. They do not run in parallel with each other on separate UI threads. An `await` whose value is already ready may continue in the same poll; an expensive synchronous function inside a foreground task still blocks the UI until it returns. The **background executor** schedules `Send` work through the platform's background dispatch queue or worker pool. On desktop, worker polls may run in parallel with the UI thread and with other background tasks, subject to available workers. GPUI does **not** create an OS thread for every `Task`. The actual worker count and scheduling depend on the platform; test executors may simulate the scheduling without parallel OS threads. A background future may be polled on different workers over its lifetime, so do not rely on worker thread affinity.
GPUI foreground and background task flow Two columns show the main UI thread and the background executor. An event queues a foreground task. It dispatches Send work, yields while awaiting a pending result, and resumes on the UI thread to update an Entity and request a later render. Main / UI thread Background executor 1 · User eventcx.spawn(...) 2 · Foreground pollcx.background_spawn(work) Send future queuedPlatform workers / dispatch queue 3 · Await background TaskIf Pending, UI can handle input/render Worker polls / computesMay run in parallel with UI work 4 · Resume on UI threadWeakEntity::update · cx.notify() Send result readyWake the foreground Task GPUI task flow on a narrow screen The same flow stacked vertically: the UI thread starts and polls a task, background workers compute owned Send data, and the UI thread resumes to update an Entity and request rendering. Main / UI thread 1 · User eventcx.spawn(...) 2 · Foreground pollbackground_spawn(work) 3 · await TaskPending → UI free Background executor Queue Send futurePlatform workers / dispatch queue Worker poll / compute Send result → wake UI Main / UI thread 4 · Resume and update EntityWeakEntity::update · cx.notify()
A typical pending path. The worker returns owned data; only the foreground update mutates GPUI state. Colors and text adapt to the site's light and dark themes.
`cx.spawn` accepts a foreground future that need not be `Send`, so it can hold main-thread-only GPUI handles, but it still must be `'static`: move owned inputs into it instead of borrowing `self`. `background_spawn` requires both its future and its output to be `Send + 'static`. Move owned data into the worker and return a `Send` result; keep `Entity`, `Window`, and `Context` updates on the foreground side. Awaiting the background `Task` from the foreground task schedules the **continuation** back on the foreground executor when the result is ready. `cx.notify()` then invalidates the View for a later render; it does not synchronously draw a frame. An async GPUI context is a way back into GPUI after `await`, not permission to keep a mutable `Context` or `Window` borrowed across suspension. Capture owned inputs before spawning. Use `WeakEntity::update` for a short, synchronous state change after the result arrives; use `update_in` when the same window is required. No UI mutation belongs inside a `background_spawn` future. ## Start work from an owner Start a task in a named method, event handler, or lifecycle hook. Do not start one unconditionally in [`render`](./render): every render could launch another copy. Extract the input before spawning, so no borrow of `self` or `cx` crosses an `await`. ```rust struct SearchView { query: String, results: Vec, _search_task: Option>, } impl SearchView { fn search(&mut self, cx: &mut Context) { let query = self.query.clone(); self._search_task = Some(cx.spawn(async move |this, cx| { let results = search_index(query).await; _ = this.update(cx, |view, cx| { view.results = results; cx.notify(); }); })); } } ``` The callback gets a `WeakEntity` named `this`. It does not keep the View alive. After the `await`, `this.update` reacquires the Entity on the foreground thread and returns an error if the View has gone away. Handle that case with `?`, `if let`, or an intentional `_ =` when disappearance is normal. Call `cx.notify()` after changing View state so dependent UI renders again. Assigning a new `Task` to `_search_task` drops the old handle and cancels the previous search. The field is an `Option` because this View has no task until the user starts one. A View that starts work during construction can store a plain `Task<()>` instead. `search_index` and `SearchResult` above stand for application code; this snippet illustrates ownership, rather than defining a complete application. For a complete buildable workflow, run the repository example above. Cancellation is cooperative with async execution. Dropping a task prevents further polling; it cannot undo an external side effect that already happened or stop a blocking function in the middle of a call. For results that may arrive after a newer request, also check a request ID or revision before applying them. ## Keep, await, or detach Choose the lifetime when you create the task: - **Store it** on the owning Entity/View or a window-scoped owner when the work should end with that owner. Replacing an `Option>` is useful for search, refresh, debounce, and ongoing streams. - **Await it** from another task when the next step needs its result. The awaiting task owns the handle until completion. - **Detach it** with `.detach()` for one-off work that should finish independently of the current owner. This consumes the handle and lets the task run to completion; the owner can no longer cancel it by dropping a field. Give its callback a weak Entity and handle failed updates if the View may close first. Calling `cx.spawn(...);` as a bare statement drops the returned handle at the end of the statement and may cancel the task before it does useful work. Detaching a task that returns `Result` also discards that result unless the task itself reports an error. `Task::detach()` is different from `Subscription::detach()`: the former lets work continue independently until it completes; the latter leaves a callback subscribed until its source Entity is dropped. For user-facing operations, keep loading and failure state in the View and update it on completion. Repeatedly spawning and detaching from a render path or recurring callback can leave many independent tasks running at once; a finite detached task completes normally, so `.detach()` alone is not a leak. Keep recurring or long-lived work under an owner-held `Task`, and replace or drop that handle when the work should stop. If the owner stores the Task while its future captures a strong handle back to that same Entity, they form a retention cycle. Use the `WeakEntity` supplied by `Context::spawn`, or capture `cx.weak_entity()`, when owner release should cancel the work. See [Entity ownership cycles](./entity#use-a-weakentity-for-back-references-and-callbacks). ## Re-enter GPUI after `await` From `Context`, `spawn` supplies `AsyncApp`, which has application access but no current `&mut Window`. Use `spawn_in` when the completion must change Focus, show a prompt, or otherwise use the originating Window: ```rust fn submit(&mut self, window: &mut Window, cx: &mut Context) { let draft = self.draft.clone(); self._submit_task = Some(cx.spawn_in(window, async move |this, cx| { let message = send_message(draft).await; _ = this.update_in(cx, |view, window, cx| { view.messages.push(message); view.input_focus.focus(window, cx); cx.notify(); }); })); } ``` `update_in` restores `&mut Self`, `&mut Window`, and `&mut Context` for one synchronous update. The Window or Entity might be gone by then, so handle its result. Use `update` when the Window is irrelevant. From an application-level `App::spawn`, the callback receives only `AsyncApp`; call `cx.update(|cx| { ... })` for a short application mutation after awaiting. ## Move heavy work off the UI thread A foreground task can await nonblocking I/O without occupying the UI thread while pending, but CPU-intensive work inside a poll still occupies it. Move owned, `Send` input into `background_spawn`, await its `Task` from a foreground task, and apply the result back on the UI thread: ```rust struct DocumentView { source: String, // Mutable editing buffer. revision: u64, parsed: Option, _parse_task: Option>, } impl DocumentView { fn parse(&mut self, cx: &mut Context) { self.revision = self.revision.wrapping_add(1); let revision = self.revision; let source = self.source.clone(); self._parse_task = Some(cx.spawn(async move |this, cx| { let parsed = cx.background_spawn(async move { parse_document(source) }).await; _ = this.update(cx, |view, cx| { if view.revision != revision { return; // An older result must not replace newer content. } view.parsed = Some(parsed); cx.notify(); }); })); } } ``` The worker receives the `String` and returns a `Send` `ParsedDocument`; it has no `App`, `Window`, or `Context`. Clone only the input it needs before leaving the Entity update. Replacing `_parse_task` cancels the previous outer task and its awaited worker task, but cannot interrupt a synchronous parse already executing inside one poll. The revision check also rejects a result that became stale before the foreground update. ## Handle completion, failure, and cancellation For a user-visible operation, keep an explicit state such as idle, loading, loaded, or failed in the owning View. Set loading and notify before spawning. Have the worker return `Result` as owned, `Send` data. After `await`, first reject an outdated request ID, then handle `Ok` by storing data or `Err` by storing a readable error; clear loading and notify once for the complete change. Keep useful previous data visible during a refresh if the UI allows it. A failure that is only logged or dropped by `.detach()` leaves users looking at a perpetual spinner. Treat release of the `WeakEntity` or originating window as an ordinary cancellation path: a failed `update` or `update_in` means there is no UI left to change. Dropping a task handle prevents future polls but does not undo I/O or stop synchronous worker code already running. If the operation has an external side effect, decide separately whether it must finish and how to report its outcome. A request ID still guards against queued messages or a late result after replacement. Never wait for another task with `while !ready {}` or repeated immediate polls. Such a loop occupies the UI thread and can prevent the awaited work from advancing. Await the `Task`, a timer, an I/O future, or a channel receive. For periodic work, await a timer between iterations and keep its handle with the owner. Large CPU work belongs on the background executor; break very long computation into bounded units if prompt cancellation matters. Avoid blocking sleep or blocking I/O in a foreground task. ## Check and diagnose task behavior Run the example above and check these observable cases: Replay streams text, and a second Replay clears it and excludes old chunks. Closing the window drops the View and its task handles; it does not prove that an already running producer stops immediately. To distinguish a frozen UI from a slow worker, inspect whether the foreground future reaches a pending `await`; code before the first pending point runs on the UI thread. If nothing appears, check that the `Task` handle was retained, that the receiver is still alive, and that an update failure is handled. If stale text appears, inspect the request ID at the point of the UI update. If loading never ends, inspect both the success and error branches and whether the awaited operation can complete. For app tests, put pure parsing and request ordering in ordinary Rust tests; use GPUI context tests for the owner lifecycle and stale-result guard. A UI interaction test should trigger the action and observe loading, success, and failure through rendered state. Drive async work with the test executor rather than sleeps or a busy loop. The repository example itself can be checked with `cargo check -p example-stream-markdown` and manually exercised with the run command above. ## A GPUI Kit streaming example GPUI Kit's [streaming Markdown example](https://github.com/longbridge/gpui-kit/blob/main/examples/stream-markdown/src/main.rs) uses two owned tasks and a channel. A background producer generates text chunks. A foreground receiver owns the Entity update, checks a replay ID, and pushes accepted chunks into `TextViewState`. The View keeps both `Task<()>` handles; closing it drops those handles, and another replay replaces the producer handle. The producer's async block contains no `await`, so once its synchronous loop is being polled, dropping its handle cannot interrupt that poll. The replay ID rejects chunks from an older producer, including ones queued before replacement. ```rust // Condensed from Example::new; the returned Task is stored as _task. let _task = cx.spawn(async move |weak_self, cx| { while let Ok((replay_id, chunk)) = rx.recv().await { _ = weak_self.update(cx, |this, cx| { if replay_id != this.replay_id { return; } this.markdown_state.update(cx, |state, cx| { state.push_str(&chunk, cx); }); this.scroll_handle.scroll_to_bottom(); }); } }); // Condensed from Example::replay; replacement drops the prior handle. self._update_task = cx.background_executor().spawn(async move { let chars: Vec = EXAMPLE.chars().collect(); while current < chars.len() { let chunk_size = (5 + rand::random::() % 15).min(chars.len() - current); let chunk: String = chars[current..current + chunk_size].iter().collect(); _ = tx.try_send((replay_id, chunk)); current += chunk_size; std::thread::sleep(std::time::Duration::from_millis(50)); } }); ``` The excerpt shows the ownership and update points; see the linked source for setup and rendering. The example uses `std::thread::sleep` in its background producer to simulate pacing and an **unbounded channel** with `try_send` only for this demonstration. An unbounded channel does not provide backpressure and can grow when a producer outpaces the UI. Its producer can continue sending after replacement until the synchronous loop returns; the replay ID keeps those chunks out of the UI. For a real stream, use asynchronous waiting and a bounded channel with backpressure, handle send and update errors, and end the receiver when its View is gone. `WeakEntity` protects the View lifetime, and the channel crosses executors. See [Entity](./entity) for Entity ownership and updates. [Entity]: ./entity.md --- # Mobile Source: /versions/v0.7.1/docs/mobile License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/mobile and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. **Current scope** GPUI Kit's primary target remains the desktop. Mobile support exists so that some components can be reused inside iOS and Android applications, for example rendering rich content natively with TextView inside a native screen. GPUI Kit does not currently plan to make mobile a primary target or to become a full mobile application framework in the way Flutter is. Mobile support builds on [gpui-mobile](https://github.com/itsbalamurali/gpui-mobile), created by [itsbalamurali](https://github.com/itsbalamurali) and developed with the community. Credit for the original mobile platform belongs to that project and its contributors. The platform supplies the [Window](./window), touch input, [text system](./text-system), and GPU surface; GPUI and GPUI Kit still own the Rust view tree and components. GPUI Kit currently uses `gpui-pre-mobile`, a temporary compatibility package maintained in a [compatibility fork](https://github.com/longbridge/gpui-mobile). It adapts the original project for crate packaging and publication alongside `gpui-pre`, and tracks newer GPUI versions to keep the integration compatible. Once the community `gpui-mobile` completes the integration and GPUI is published as a crate, we plan to switch this guide and its dependencies to the community `gpui-mobile`. The current integration is experimental. The Swift-hosted iOS example has been built and exercised in the iOS simulator. Beyond this guide, GPUI Kit has been validated on iOS and Android in a limited scope: an AI chat area built from TextView, Button, Menu, Popover, Scrollbar, Input, Textarea and text selection passed functional and performance testing on both platforms, and the fixes from that work are in GPUI Kit. TextView is covered completely in that scenario. Other components and complete application layouts have not been validated on mobile yet. The fork's Android activity example is a different host path that this guide does not cover. Treat the iOS simulator path below as the documented target, not a general mobile support guarantee. Native UI and GPUI can share one screen on both iOS and Android. The native side keeps the parts users expect to behave like the platform, such as the navigation bar and the bottom input field, and GPUI renders as one view between them. Each side keeps its own layout and input; the host places the GPUI view like any other native view. ## Run the iOS example Start with the compatibility fork’s [Swift container example](https://github.com/longbridge/gpui-mobile/tree/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example). It includes a conversation UI with `Message`, `Bubble`, `TextView`, `Input`, thought summaries, and copy actions. Its responses are local sample data; it does not connect to an AI service. On an Apple Silicon Mac, install Xcode with an iOS simulator runtime, Rust, and XcodeGen: ```sh brew install xcodegen rustup target add aarch64-apple-ios-sim git clone https://github.com/longbridge/gpui-mobile.git cd gpui-mobile git checkout 0b882efdac7f524e0bb0b1d4c886b2aa752f9f20 cd example ./build.sh ios --simulator ``` The script builds the Rust static library, generates the Xcode project, and installs and launches the app in a simulator. Add `--no-run` to build only, or `--release` for a release build. At this pinned revision the script builds for `iPhone 16 Pro` with iOS 18.6 by name; if that runtime or device is unavailable, change its Xcode destination to one installed on your Mac. The example targets iOS 16 or later; this is a deployment setting, not a claim that every supported OS version has been tested. Check the simulator used for **installation and launch** as a separate step. In the [pinned build script](https://github.com/longbridge/gpui-mobile/blob/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example/build.sh), `build_ios` sets the Xcode destination, but `_ios_run_simulator` later takes the first available iPhone from `xcrun simctl list devices available`. On a Mac with multiple simulators, changing only the build destination can launch a different device. Run `xcrun simctl list devices available`, compare its first listed iPhone with your chosen build destination, and, if they differ, update the script's `sim_id` selection to the intended simulator UUID before rerunning `./build.sh ios --simulator`. For device development, install the `aarch64-apple-ios` Rust target and configure your own development team and signing in `example/ios/project.yml`. Re-generate the project after changing that file. Simulator execution does not establish device performance or release readiness. ## Dependencies `gpui-pre-mobile` is the Cargo package name; the Rust library is `gpui_mobile`. Use a Git dependency while evaluating this integration. The package's `0.1.0` manifest version does not imply a crates.io release. ```toml [lib] crate-type = ["staticlib", "rlib"] [dependencies] gpui-mobile = { package = "gpui-pre-mobile", git = "https://github.com/longbridge/gpui-mobile", rev = "0b882efdac7f524e0bb0b1d4c886b2aa752f9f20" } gpui = { package = "gpui-pre", version = "=0.3.4", default-features = false } gpui-kit = { git = "https://github.com/longbridge/gpui-kit", rev = "7d9efcd2069f9eaa6eb3ba6345aac4aa7d87c9f7", default-features = false, features = ["component"] } ``` These revisions reproduce the example's dependency baseline. The Kit revision includes mobile platform gating but predates mobile tooltip suppression. The current GPUI Kit checkout uses `gpui-pre 0.3.8`, while this pinned mobile platform and renderer use `0.3.4`. Cargo can select both versions, producing incompatible GPUI types; replacing the Kit dependency with a local path is **not** a working upgrade by itself. First update the mobile platform and renderer to the same GPUI version as Kit and validate that combination. Only then can you use a path dependency such as: ```toml gpui-kit = { path = "../gpui-kit/crates/kit", default-features = false, features = ["component"] } ``` Adjust the path relative to your application's manifest. Keep the GPUI core, renderer, platform, and Kit on one compatible release. Unlike the desktop [Getting Started](/versions/v0.7.1/docs/getting-started) setup, mobile does not use `gpui_kit::application()` or `gpui_kit::platform`. Those desktop platform exports are excluded on iOS and Android. The mobile host initializes GPUI, calls `gpui_kit::init(cx)`, and mounts a single `component::Root` around the application's content. ## Embed a view in UIKit UIKit owns the native window, navigation, safe areas, and keyboard layout. The example's `GPUITextView` is a Swift `UIView` wrapper around the GPUI platform's child `UIViewController`. Despite its name, it hosts a whole Rust conversation view, not just one `TextView` element. Use these files together as the integration reference: | File | Responsibility | | --- | --- | | [App.swift](https://github.com/longbridge/gpui-mobile/blob/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example/ios/App.swift) | Native window, view wrapper, child controller containment, layout, and frame scheduling | | [Embedding.h](https://github.com/longbridge/gpui-mobile/blob/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example/ios/Embedding.h) | Swift bridging declarations for Rust callbacks | | [src/lib.rs](https://github.com/longbridge/gpui-mobile/blob/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example/src/lib.rs) | Application callback, Kit initialization, and Rust root view | | [project.yml](https://github.com/longbridge/gpui-mobile/blob/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example/ios/project.yml) | Rust build phase, static library linkage, frameworks, and bridging header | The startup sequence is: 1. Call `gpui_ios_set_embedded()` before creating the GPUI application so the platform does not create a second native window. 2. Call the example-defined `gpui_ios_register_app()`. It registers a Rust callback with `gpui_mobile::ios::ffi::set_app_callback` that initializes Kit and opens the GPUI root. 3. Call `gpui_ios_run_demo()` to start the embedded application, then obtain its window and child controller with `gpui_ios_get_window()` and `gpui_ios_view_controller()`. 4. Attach the controller using UIKit containment: `addChild`, add its view, then `didMove(toParent:)`. `gpui_ios_register_app()` belongs to the example, not the platform library. Adapt its callback to construct your own Rust view. The `run_demo` name is the current bridge entry point; it runs the registered application callback. Once the example's `GPUITextView` wrapper is included in your app, a native controller can constrain it like any other view: ```swift let content = GPUITextView(frame: .zero) content.translatesAutoresizingMaskIntoConstraints = false view.addSubview(content) content.attach(to: self) NSLayoutConstraint.activate([ content.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), content.leadingAnchor.constraint(equalTo: view.leadingAnchor), content.trailingAnchor.constraint(equalTo: view.trailingAnchor), content.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor), ]) ``` This fragment uses the example wrapper; `GPUITextView` is not an SDK-provided UIKit class. Copy its containment and layout behavior along with the declarations and build settings, rather than copying only the constraints. ### Lifetime, resizing, and frames The platform retains the `ApplicationHandle` returned by `Application::run_embedded`. It must outlive callbacks and rendered views. The current bridge supports one GPUI view for the application's lifetime; it does not provide independently destroyable views, multiple instances, or reusable collection-view cells. In `layoutSubviews`, update the child controller's frame only when nonzero bounds change, call `gpui_ios_layout_view`, then request a frame. The example performs those operations inside a Core Animation transaction with implicit animations disabled, keeping the Metal surface and GPUI viewport in sync during layout changes. The host drives `gpui_ios_request_frame` through a `CADisplayLink` while visible and invalidates the link when the controller disappears. Forward application active/inactive callbacks as shown in `App.swift`. Keep UIKit and bridge calls on the main thread. ### Input, fonts, and assets UIKit determines the embedded view's safe area and keyboard avoidance. The GPUI platform translates native touch and text input for the Rust window, while Kit owns the component interaction inside it. Exercise focus, selection, editing, paste, scrolling, and on-screen keyboard changes in the simulator; a successful build does not establish that every input method works. `window.visual_viewport_bounds()` can change as the keyboard appears, while the layout viewport remains the same. Do not manually shrink both the UIKit container and the Rust content for one keyboard transition. The example's embedded callback initializes Kit and changes the theme but does not install application fonts or an icon `AssetSource`. A font family configured for desktop may be absent on iOS, and glyph coverage for CJK or emoji can differ. For fonts your app requires, bundle the font files, register them with `cx.text_system().add_fonts(...)` before opening the GPUI window, then select those families in the theme. Check the actual rendered text and missing glyphs on each target; see [Fonts](/versions/v0.7.1/docs/fonts) and [TextSystem](/versions/v0.7.1/docs/text-system). The dependency sample enables only Kit's `component` feature. If your app uses named Kit icons, enable its `assets` feature and register a suitable `AssetSource` on the mobile GPUI application before the first window. Native embedded assets and files addressed by runtime paths have different deployment needs: bundle and locate external image files in the app package, or provide an HTTP client for remote images. Do not assume a desktop file path exists in the iOS sandbox. See [Icons & Assets](/versions/v0.7.1/docs/assets). ## Platform-specific behavior `gpui_kit::is_mobile()` is an inline `const fn` that returns `true` for iOS and Android targets. It checks the compilation target, not window width or whether a mouse is connected. ```rust if gpui_kit::is_mobile() { // Use touch-friendly interaction. } ``` ## Design for mobile Share component behavior and content with desktop, while adapting the screen to touch and a narrow viewport: - Let the native container handle navigation, safe areas, and keyboard avoidance. Avoid stacking a second title bar or duplicating safe-area padding inside Rust. - Give each conversation one vertical scroll owner. For a `TextView` within that scroller, use `.w_full().min_w_0().scrollable(false)` so text and images fit the available width. - Keep the composer compact when empty. Use a single-line input when multiline composition is unnecessary, and ensure the keyboard does not cover the send action. - HoverCard opens and closes by tapping its trigger on iOS and Android. Tap outside to dismiss it; moving a finger does not open the card. - A long press or double tap in an `Input` or `Textarea` selects a word with touch handles and an edit menu. For selectable, read-only `TextView` content, use a long press; its touch double tap intentionally does not select a word. These are Kit interaction paths that still need validation through the mobile platform's event translation. - Make actions discoverable by touch. Keep copy actions aligned with the reply and use a brief checkmark after copying. Do not rely on hover text to explain an action. - Prefer short paragraphs and purposeful headings. Let code, tables, and images support the conversation rather than presenting every Markdown format in each reply. - Use Kit theme colors, type sizes, and spacing consistently. Check long replies, wide code, image loading, and Chinese or other scripts at the actual device width. GPUI Base disables its tooltip overlay on iOS and Android. This covers Kit tooltips routed through that overlay, not direct GPUI `.tooltip()` calls. The pinned baseline above predates that change. Do not add native GPUI hover tooltips to mobile views. ## Validation and current limits For an application integration, check launch and return from the background, keyboard show/hide, viewport resizing, text selection and copying, scroll behavior, and touch feedback. Inspect the actual rendered screen rather than relying only on a Rust compile check. Measure rendering on a physical device with a release build and Xcode Instruments before making performance claims. Simulator results are useful for layout and interaction, but are not device frame-time measurements. Android uses a separate activity and surface lifecycle. The repository contains an Android example. GPUI can be embedded as an Android `View` in a native layout, as described above, but this guide documents only the iOS steps. Outside the chat scenario validated above, Android Kit compatibility is not established. Validate the Android host path separately before depending on it. ## Troubleshooting | Symptom | Check | | --- | --- | | Xcode cannot find the simulator destination, or the app launches on another simulator | The pinned `build.sh` builds for iOS 18.6 on an iPhone 16 Pro. Install that runtime or edit its Xcode destination to match `xcodebuild -showdestinations`. Then run `xcrun simctl list devices available`: the script's `_ios_run_simulator` installs on the first available iPhone, independently of the build destination. If that is a different device, change its `sim_id` selection to the intended UUID. | | Device build fails signing or install | Replace the example development team in `example/ios/project.yml`, regenerate the Xcode project, and confirm the device appears in Xcode. The default script target is a physical device; pass `--simulator` explicitly for the documented simulator path. | | Rust reports two versions of GPUI or mismatched `App`/`Window` types | Check the resolved `gpui-pre` packages. The pinned mobile fork uses `0.3.4`; this checkout uses `0.3.8`. Align the entire mobile platform and Kit dependency set before using the local Kit path. | | App launches with a blank or stale GPUI view | Check that the Rust callback opens a window, the child controller is attached, `layoutSubviews` publishes nonzero bounds, and visible frames are requested. Inspect the Xcode console; the example sends Rust logs and panics to `NSLog`. | | Text, icons, or images are missing | Verify the font family and glyph coverage, registered `AssetSource` and exact icon keys, or the image's packaged path and HTTP client. A desktop asset or font configuration does not automatically carry into the mobile host. | --- # Element Source: /versions/v0.7.1/docs/element License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/element and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. An **Element** is a node in the element tree that GPUI builds for one frame. Elements perform layout, prepare hit testing, and [paint](./paint) pixels into a Window. GPUI drops the tree and its frame-local callbacks before the next frame, then builds a new tree from the application's current state. Most application code should compose the elements provided by GPUI and GPUI Kit: ```rust div() .flex() .items_center() .gap_2() .child(Icon::new(IconName::Search)) .child("Search") ``` This code creates an element tree. It does not implement GPUI's low-level `Element` trait. ## `Element`, `IntoElement`, and `AnyElement` These types have different jobs: | Type | Purpose | | --- | --- | | `Element` | Implements the low-level layout and painting lifecycle. | | `IntoElement` | Converts a value into a concrete `Element`, allowing strings, components, Entities, and other values to be passed to `.child(...)`. | | `AnyElement` | Erases the concrete element type, which is useful for heterogeneous collections, conditional branches, slots, and stored children. | Keep the concrete type when every branch has the same type. Erase it only at a boundary that needs different element types: ```rust fn status_icon(online: bool) -> AnyElement { if online { Icon::new(IconName::CircleCheck).into_any_element() } else { div().child("Offline").into_any_element() } } ``` GPUI Kit uses `AnyElement` this way for optional slots, table cells, and functions whose branches return different UI types. `IntoElement` is the usual API boundary when the caller does not need type erasure. ## The three phases GPUI drives an `Element` through three trait methods in order. The layout solver runs between the first and second calls:
Render::render builds this frame's Element tree
  1. 01request_layout

    Register styles and child layouts; return LayoutId and RequestLayoutState.

  2. 02prepaint

    Use resolved bounds to prepare geometry and hitboxes; return PrepaintState.

  3. 03paint

    Draw prepared content and register current-frame input listeners.

Taffy resolves Bounds<Pixels> between request_layout and prepaint.
The tree and frame-local listeners are discarded before the next frame. `RequestLayoutState` and `PrepaintState` move forward within this pass; they are not persistent caches. ### Decide which method owns the work Ask what information the work needs and what it produces: | Work | Put it in | Why | | --- | --- | --- | | Choose width, height, padding, and child layout; measure intrinsic content when its size affects layout | `request_layout` | The layout solver needs these inputs before it can calculate bounds. A measurement closure can use an offered width, but the element's final position is still unknown. | | Turn final bounds into text lines, a path, clipping geometry, or a hitbox; decide which virtualized children are visible | `prepaint` | This is the first phase with the resolved pixel rectangle. Save results needed for drawing or input in `PrepaintState`. | | Submit quads, glyphs, paths, or images; attach pointer, scroll, or keyboard listeners for this frame | `paint` | The scene and dispatch tree are ready to receive drawing commands and listeners. Reuse the prepared geometry. | For example, a clickable chart first requests the chart's size and its child layouts. Once the size is known, it maps data points into pixel coordinates and inserts hitboxes for the interactive regions. Finally, it paints the paths from those coordinates and registers the pointer listeners. If the chart needs a label's width **to determine its own size**, measure that width during `request_layout`; if it only needs the label's final position **inside an already sized chart**, prepare it during `prepaint`. A useful boundary test is: **Would this calculation change the element's requested size?** If yes, it belongs in layout or its measurement closure. If it only needs the resulting bounds, use `prepaint`. If it changes the pixels or current-frame handlers without changing geometry, use `paint`. Long-lived model data and subscriptions are outside all three methods; keep them in an [Entity](./entity). ### `request_layout` Register the element's [Style](./style) and child layout nodes with `window.request_layout`. GPUI's Taffy layout engine resolves their sizes and positions after layout has been requested. Return a `LayoutId` and any `RequestLayoutState` needed by the later phases. Do not assume the final `Bounds` are available here. ### `prepaint` GPUI now provides the resolved `Bounds`. Use this phase to shape text, calculate geometry, insert hitboxes, prepaint children, and prepare data that `paint` needs. Return that data as `PrepaintState`. Hit testing belongs here because GPUI must establish the current frame's spatial and dispatch information before painting. ### `paint` Paint quads, text, paths, or images with the prepared state. Register frame-local input handlers when the custom element requires them. This phase should consume the geometry prepared earlier rather than repeat layout work. `RequestLayoutState` reaches both `prepaint` and `paint`; `PrepaintState` reaches `paint`. Persistent application state belongs in an [Entity](./entity), while small element state that must survive frames can be associated with an [ElementId](./element_id). ### A complete, minimal custom element This invisible event surface fills its parent's bounds. It shows the exact trait signatures and why the hitbox is carried from `prepaint` into `paint`: ```rust use gpui_kit::*; struct EventSurface; impl IntoElement for EventSurface { type Element = Self; fn into_element(self) -> Self::Element { self } } impl Element for EventSurface { type RequestLayoutState = (); type PrepaintState = Hitbox; fn id(&self) -> Option { None } fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { None } fn request_layout( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, window: &mut Window, cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { let mut style = Style::default(); style.size.width = relative(1.).into(); style.size.height = relative(1.).into(); (window.request_layout(style, None, cx), ()) } fn prepaint( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, bounds: Bounds, _: &mut Self::RequestLayoutState, window: &mut Window, _: &mut App, ) -> Self::PrepaintState { window.insert_hitbox(bounds, HitboxBehavior::Normal) } fn paint( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, _: Bounds, _: &mut Self::RequestLayoutState, _: &mut Self::PrepaintState, _: &mut Window, _: &mut App, ) {} } ``` The element is intentionally invisible and has no handler. A hitbox is geometry for input routing, not a click callback. [GPUI Kit's `CarouselScrollMask`](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/carousel/scroll_mask.rs) starts with this pattern, then retains its `Hitbox` and installs frame-local pointer and wheel listeners in `paint`. It uses `Hitbox::should_handle_scroll` to avoid claiming gestures blocked by a surface in front. When an element has children, request their layouts first and pass their `LayoutId`s to `window.request_layout`; then prepaint and paint them in order. For intrinsic measurement, `window.request_measured_layout` takes a measurement closure instead. `HitboxBehavior::Normal` participates in hit testing without hiding hitboxes behind it. `BlockMouse` occludes both mouse and scroll handling behind the hitbox; `BlockMouseExceptScroll` leaves scrolling available. The hitbox also carries the current content mask. In a custom element, coordinate the hitbox with any clipping and paint order; drawing a shape does not create a hitbox automatically. ### Make the surface visible and respond to a press The first example is a trait skeleton, so adding it as a child produces no visible pixels. To run the continuation, replace the contents of the existing [`examples/hello_world/src/main.rs`](https://github.com/longbridge/gpui-kit/blob/main/examples/hello_world/src/main.rs) with the complete file below. The parent gives the surface a definite `240 × 96` pixel area; its relative width and height now have a size to resolve against. This uses the existing workspace example and adds no dependency. ```rust use gpui_kit::component::ActiveTheme as _; use gpui_kit::*; struct SurfaceDemo { presses: usize, } impl Render for SurfaceDemo { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .flex() .flex_col() .gap_2() .child( div() .w(px(240.)) .h(px(96.)) .child(EventSurface { owner: cx.entity().clone(), color: cx.theme().primary, }), ) .child(format!("Pointer presses: {}", self.presses)) } } struct EventSurface { owner: Entity, color: Hsla, } impl IntoElement for EventSurface { type Element = Self; fn into_element(self) -> Self::Element { self } } impl Element for EventSurface { type RequestLayoutState = (); type PrepaintState = Hitbox; fn id(&self) -> Option { None } fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { None } fn request_layout( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, window: &mut Window, cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { let mut style = Style::default(); style.size.width = relative(1.).into(); style.size.height = relative(1.).into(); (window.request_layout(style, None, cx), ()) } fn prepaint( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, bounds: Bounds, _: &mut Self::RequestLayoutState, window: &mut Window, _: &mut App, ) -> Self::PrepaintState { window.insert_hitbox(bounds, HitboxBehavior::Normal) } fn paint( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, bounds: Bounds, _: &mut Self::RequestLayoutState, hitbox: &mut Self::PrepaintState, window: &mut Window, _: &mut App, ) { window.paint_quad(fill(bounds, self.color)); let hitbox = hitbox.clone(); let owner = self.owner.clone(); window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { if phase.bubble() && event.button == MouseButton::Left && hitbox.is_hovered_at(event.position, window) { owner.update(cx, |view, cx| { view.presses += 1; cx.notify(); }); } }); } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| SurfaceDemo { presses: 0 }) }) .expect("failed to open window"); }); } ``` From the repository root, run `cargo run -p hello_world`. The window shows a `240 × 96` theme-colored rectangle and `Pointer presses: 0`. Each left press inside the rectangle changes the label to `1`, `2`, and so on; a press outside it leaves the count unchanged. The rectangle is intentionally a pointer-only teaching example; it does not supply keyboard activation, focus, a cursor style, or an accessibility role. Use a standard [Button](../component/button) for an application control unless the lower-level behavior is essential. Read the example in four steps: 1. **Mount and size.** `Render::render` creates a fresh `EventSurface` each time the view renders. The parent fixes the available width and height; the element requests `relative(1.)` in both directions. If its parent has no usable size, the requested percentage may resolve to a zero-sized or otherwise unexpected surface. 2. **Prepare input geometry.** `prepaint` receives the solved `Bounds` and inserts a hitbox for exactly that rectangle. `PrepaintState = Hitbox` carries the hitbox to `paint`; it belongs to this frame's hit-test data. 3. **Paint and register the listener.** The view reads `cx.theme().primary` and passes that `Hsla` into the element; `paint_quad(fill(bounds, self.color))` makes the same rectangle visible using the current theme. `on_mouse_event` adds a frame-local listener to the window's mouse-listener list, in paint registration order. It is not attached to the current dispatch node. The event type is inferred from `&MouseDownEvent`; the bubble phase, left button, and hitbox test prevent unrelated presses from changing the count. The listener closes over cloned handles because it must outlive this `paint` call. 4. **Change retained state.** The `Entity` survives frame rebuilding. `owner.update` changes `presses`, and `cx.notify()` schedules the view to render again. The next `render` builds a new `EventSurface` and label. Neither associated phase state is used as an application counter. To verify each layer while learning, try these changes in the same file and rerun `cargo run -p hello_world` after each one: 1. Comment out `window.paint_quad(...)`. The rectangle becomes invisible, but presses in its original area still increment the count. The hitbox, not the pixels, controls input routing. 2. Restore painting and comment out `window.insert_hitbox(...)` **only after** changing `PrepaintState` to `()` and making `paint` omit its listener. The rectangle remains visible but does not react. Painting alone provides no hitbox or handler. 3. Restore the complete example. Change the parent's `.w(px(240.))` to `.w(px(120.))`. The drawn and clickable areas shrink together because both use the resolved bounds. | What you observe | Check | | --- | --- | | No colored surface | The parent has a definite size, `request_layout` returns its `LayoutId`, and `paint_quad` is still present. | | Surface appears, but presses do nothing | `prepaint` inserts a hitbox for the same bounds, `paint` registers `on_mouse_event`, and the listener checks the bubble phase and left button. Check whether a front hitbox uses `BlockMouse` or `BlockMouseExceptScroll`; an ordinary `Normal` hitbox does not by itself block the hitbox behind it. | | Presses are received, but the label does not change | The listener updates `Entity` and calls `cx.notify()` inside that update. | The first two changes are experiments, not alternate finished implementations: restore the full code before continuing. The example does not need an `ElementId`: the counter belongs to the Entity, while the hitbox and listener are rebuilt each frame. Add a stable ID only when the low-level element itself needs keyed state or accessibility identity. For a surface with children, the next step is to retain their layout handles in `RequestLayoutState`, prepaint them after the parent has bounds, and paint them in order; see [TextView](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/text/text_view.rs) for a real implementation. A reusable input primitive also needs a deliberate focus, keyboard, and accessibility contract; attaching a mouse listener alone does not provide one. ### Compose one child in a custom Element The pointer surface above has no child layout to forward. This second complete example adds exactly one child. Replace [`examples/hello_world/src/main.rs`](https://github.com/longbridge/gpui-kit/blob/main/examples/hello_world/src/main.rs) with the code below, then run `cargo run -p hello_world` from the repository root. It uses the same package and dependencies as the previous exercise. ```rust use gpui_kit::component::ActiveTheme as _; use gpui_kit::*; struct ChildDemo; impl Render for ChildDemo { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div().flex().flex_col().items_start().gap_2().child( ChildFrame { child: Some(div() .w(px(160.)) .h(px(32.)) .bg(cx.theme().primary) .text_color(cx.theme().primary_foreground) .child("Child: 160 x 32") .into_any_element()), background: cx.theme().secondary, accent: cx.theme().primary, }, ) } } struct ChildFrame { child: Option, background: Hsla, accent: Hsla, } impl IntoElement for ChildFrame { type Element = Self; fn into_element(self) -> Self::Element { self } } impl Element for ChildFrame { type RequestLayoutState = AnyElement; type PrepaintState = Bounds; fn id(&self) -> Option { None } fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { None } fn request_layout( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, window: &mut Window, cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { let mut child = self.child.take().expect("layout requested once per frame"); let child_layout = child.request_layout(window, cx); let mut style = Style::default(); style.padding = Edges::all(px(12.).into()); let layout = window.request_layout(style, [child_layout], cx); (layout, child) } fn prepaint( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, bounds: Bounds, child: &mut Self::RequestLayoutState, window: &mut Window, cx: &mut App, ) -> Self::PrepaintState { child.prepaint(window, cx); Bounds { origin: bounds.origin, size: size(px(4.), bounds.size.height), } } fn paint( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, bounds: Bounds, child: &mut Self::RequestLayoutState, accent_bounds: &mut Self::PrepaintState, window: &mut Window, cx: &mut App, ) { window.paint_quad(fill(bounds, self.background)); window.paint_quad(fill(*accent_bounds, self.accent)); child.paint(window, cx); } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| ChildDemo) }) .expect("failed to open window"); }); } ``` The window shows a colored child labeled `Child: 160 x 32`, surrounded by a 12-pixel inset and a narrow accent strip on the frame's left edge. The outer frame has no fixed height: Taffy measures the 32-pixel child and adds the top and bottom padding. In this unconstrained example that yields a 56-pixel-high frame. Change the child's `.h(px(32.))` to `.h(px(64.))`, run again, and the frame grows to 88 pixels high. If an ancestor constrains height, that ancestor can change the result. Follow the ownership path rather than copying the calls mechanically: 1. `Render::render` creates a `ChildFrame` with one `AnyElement`. The `Option` lets `request_layout` move that child into its returned `RequestLayoutState`; the phase is called once for this element in a frame. A new `ChildFrame` is built on the next render. 2. The child requests its own `LayoutId` first. The parent passes that ID to `window.request_layout(style, [child_layout], cx)`. This connects the nodes in the layout tree. Passing an empty child list would leave the colored child out of the parent's measurement and placement. 3. Once Taffy has resolved the tree, `prepaint` calls `child.prepaint(window, cx)` so the child sees its computed position and can prepare its own hitboxes or geometry. The parent also calculates a 4-pixel strip from its resolved bounds and returns that rectangle as `PrepaintState`. 4. `paint` paints the parent's background and strip, then calls `child.paint(window, cx)`. This order keeps the child visible above the background. `AnyElement` carries its internal phase state; the parent must still call each phase in order. There is no hitbox on the frame because it does not handle input. The child's built-in `div` draws its background and text. Neither paint call can make a missing layout child appear: layout, prepaint, and paint all need to include it. | What you observe | Check | | --- | --- | | Frame appears but child is missing | The child is passed to `window.request_layout`, then receives both `prepaint` and `paint`. Check paint order if the frame background covers it. | | Child draws outside the inset | The parent style has `Edges::all(px(12.).into())` and uses the returned child layout ID. Do not hand-offset the child in `paint`; Taffy places it. | | Frame height does not follow child height | Check for a fixed height or clipping on the frame or an ancestor, and verify that the child layout ID is attached to the parent. | The example keeps the layout child in `RequestLayoutState` because it must survive through the next two phases. It keeps only the calculated accent rectangle in `PrepaintState`. Neither state is persistent application data; use an [Entity](./entity) for that. ### Identity and retained state `Element::id()` returns a local `ElementId`. GPUI combines it with keyed ancestors into a `GlobalElementId` and passes that global ID into the three phases. An element can use it with `window.with_element_state` for small state that survives rebuilding the element tree. GPUI Kit's carousel surface uses that mechanism for ongoing scroll state. The ID must be unique among siblings under the nearest keyed ancestor. Use a domain ID for reorderable items; an index changes meaning after insertion or sorting. An element without an ID receives `None` and cannot use this keyed state channel. Entity state remains the right owner for application data and subscriptions. ### Text is a specialized low-level element The [TextSystem](./text-system) owns font lookup, shaping, glyph metrics, and caches. `cx.text_system()` exposes it; a window also has a window-specific text system. Text width depends on font, size, shaping, and available width, so a custom text element may need `request_measured_layout` or a measured line during layout. Its `prepaint` can then compute line geometry, selections, and hitboxes; `paint` draws the prepared text. Keep the same font parameters through measurement and paint, or caret and selection positions will drift. Use GPUI's `text(...)` and GPUI Kit's text components unless you need selection, inline objects, or a specialized editor. [GPUI Base's TextView](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/text/text_view.rs) shows measured layout, an element-owned hitbox, and painting tied to the resolved bounds. [GPUI Kit's input element](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/input/base/element.rs) shows the same pipeline for editing. ## When to implement `Element` Implement `Element` when existing elements cannot express the work, for example: - a code editor or text input that shapes text and paints selections and cursors; - a chart or canvas with custom geometry; - a custom layout algorithm; - a performance-sensitive primitive that needs direct control over layout, hitboxes, and painting. GPUI Kit's input element uses a custom `Element` because it must shape text, register an input handler, and paint selections and cursors. GPUI's `Svg`, `Img`, lists, and canvas use the same lifecycle. Most application UI can instead compose existing elements and convert differing branches to `AnyElement`. For a reusable UI component, start with [`RenderOnce`](./render-once). For stateful UI owned by an Entity, use [`Render`](./render). Drop down to `Element` only when you need to control the rendering pipeline itself. ### Decisions visible in GPUI Kit source | Example | Why composition alone is insufficient | What the element owns | | --- | --- | --- | | [Input](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/input/base/element.rs) | Caret, selection, wrapping, and pointer-to-text mapping must use the same measured text geometry. | Text layout, hitboxes, input handlers, and paint order. | | [TextView](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/text/text_view.rs) | Rich text and selectable ranges need measurement and clipping tied to resolved bounds. | Text layout state, hitbox, selection surface, and prepared painting. | | [CarouselScrollMask](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/carousel/scroll_mask.rs) | A gesture surface must occupy the viewport independently of moving carousel content. | Full-size layout, hitbox, and pointer/wheel dispatch; it paints nothing. | | [Plot line](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/plot/shape/line.rs) | Data points require direct path tessellation, but do not require custom layout or input. | A path painted inside a higher-level chart; this shape itself does not need a full `Element` implementation. | These examples show that `Element` is about owning a phase boundary, not merely drawing something custom. The carousel surface demonstrates that an Element may paint nothing. Plot demonstrates the opposite: a custom drawing can use a canvas or a containing Element without making each shape an Element. ### Performance follows phase ownership **Make the efficient path the natural API path.** GPUI divides an Element into layout, prepaint, and paint so authors can measure when dimensions are known, prepare geometry once, and reuse it during painting. GPUI Kit follows the same principle in its components: costs that repeat across a large tree should have an owner and an explicit invalidation rule. The goal is for ordinary component use to avoid repeated work by design, rather than requiring every caller to repair it with a cache. Every rendered frame can rebuild the tree and revisit these methods. Keep `request_layout` focused on styles and layout nodes; do not shape text or tessellate paths there unless intrinsic measurement requires it. In `prepaint`, compute geometry once and pass it through `PrepaintState` to `paint`. Avoid rebuilding unchanged paths on every paint; GPUI Kit's Plot uses keyed window state and a shape key to retain tessellation across frames. Put subscriptions and long-lived application data in an Entity instead of a frame-local associated state. Finally, clip and cull before expensive painting: TextView and Input compute visible content from resolved bounds rather than blindly painting the full document. ## How GPUI drives an Element The low-level contract is enforced by a `Drawable` wrapper. It moves through `Start → RequestLayout → LayoutComputed → Prepaint → Painted`; calling the phases out of order is an error. This explains why GPUI passes two associated state values forward instead of asking the Element to rediscover everything during paint. It also explains why a custom Element should not call drawing methods during `request_layout`: the window is not in its paint phase yet. ### Layout tree, dispatch tree, and scene are different structures `request_layout` contributes nodes to the layout engine and returns a `LayoutId`. After Taffy resolves that layout, `prepaint` obtains pixel bounds. GPUI creates a dispatch node for the Element while prepainting; hitboxes are recorded separately for hit testing. `paint` activates the corresponding dispatch node for Action and keyboard listeners registered on that path. Mouse listeners registered with `window.on_mouse_event` instead enter a frame-local window list, called in registration order for capture and reverse order for bubble; their handlers must check the relevant hitbox. Drawing commands go to the scene. A `paint_path` call changes the scene, but does not add a dispatch node or hitbox. Conversely, the carousel mask contributes input geometry and listeners without adding visible drawing. This separation lets a custom Element do precisely one job. A decoration may only need a canvas and scene commands. A focusable control must coordinate dispatch, hitboxes, focus, and accessibility as well. A virtualized list must decide which children to measure and paint, not merely draw a large rectangle. ### Identity is a path, not a pointer to this frame's value When `Element::id()` returns an `ElementId`, GPUI appends it to the current keyed ancestor path and passes the resulting `GlobalElementId` to the three methods. The Rust Element value itself is recreated for a later frame; its address is not a stable identity. `window.with_element_state(global_id, ...)` can carry a small typed value between consecutive painted frames. `window.use_keyed_state(key, cx, init)` builds an Entity at a key in the current Element namespace and observes it so changes can notify the owning view. These mechanisms require stable keys; an item index is unsafe when items can move. ### Accessibility is established with geometry When accessibility is active, GPUI can create an AccessKit node for an Element that has both an ID and `a11y_role()`. During prepaint it sets that node's bounds from the resolved layout, calls `write_a11y_info`, and can add synthetic children. Painting pixels alone supplies no role, name, value, or action. Standard interactive GPUI Kit components already provide these semantics; a custom low-level control must supply its own contract. Continue with [Accessibility](./accessibility) for those APIs. ## Identity and interactivity These concepts sit on separate boundaries: - [`ElementId`](./element_id) identifies an element within its keyed scope. GPUI uses its global form to connect state and retained work across frames. - Calling `.id(...)` on an `InteractiveElement` returns `Stateful`. That wrapper enables APIs whose state must be associated with a stable element identity. - `InteractiveElement` exposes GPUI's standard interaction machinery, including hitboxes, mouse listeners, Focus tracking, Key Context, and Action handlers. A custom `Element` does not gain those behaviors automatically. `InteractiveElement` is implemented by types that expose an `Interactivity` field. Its methods include `on_mouse_down`, `on_key_down`, `track_focus`, `key_context`, `on_action`, and hover styles. Calling `.id(...)` returns `Stateful`, which implements `StatefulInteractiveElement` and exposes state-dependent methods such as accessibility role and label, tooltip, and click handling. The wrapper is a type-level signal: stateful interaction needs a stable identity. Implementing `IntoElement` alone only makes a value acceptable as a child; it does not implement either interactive trait. `#[derive(IntoElement)]` plus `RenderOnce` is the usual component path, while a hand-written low-level `Element` typically implements `IntoElement` by returning itself. There are two useful ways to obtain those APIs. When composing an ordinary surface, use an existing interactive element: ```rust div().id("result-row") .aria_label("Search result") .on_click(|_, _, _| {}) ``` The `.id(...)` call changes the Rust type from `Div` to `Stateful
`, so identity-dependent methods become available. Give each repeated row a domain-derived ID rather than the same literal ID. `on_click` registers a handler; it does not automatically update an Entity's state. When exposing the same fluent methods on a GPUI Kit component, delegate its interaction state to the underlying primitive. The component's [Radio implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/radio.rs) follows this shape (the excerpt omits unrelated fields): ```rust impl InteractiveElement for Radio { fn interactivity(&mut self) -> &mut Interactivity { self.base.interactivity() } } impl StatefulInteractiveElement for Radio {} ``` `StatefulInteractiveElement` is a marker trait: its default methods write into the returned `Interactivity`; the empty implementation does not create an ID or a hitbox. `Radio::new(id)` passes a stable ID to its Base primitive, which renders the actual interactive surface. If your component cannot guarantee that identity and the underlying interactive element, expose only the methods it can support. A raw custom `Element` such as `EventSurface` above has neither trait until you explicitly provide and drive that interactivity or compose an existing interactive element. ## `canvas`: two phase callbacks without a new trait implementation GPUI's `canvas(prepaint, paint)` is itself an `Element`. It requests layout from its `Styled` properties, then invokes a `FnOnce` prepaint callback with resolved bounds. That callback returns any temporary value `T`, which GPUI passes to the `FnOnce` paint callback. This is a small bridge into the Element lifecycle, not an HTML canvas or a retained drawing surface. ```rust canvas( move |bounds, _, _| { let mut line = PathBuilder::stroke(px(1.)); line.move_to(bounds.origin); line.line_to(point(bounds.right(), bounds.top())); line.build().ok() }, move |_, path, window, _| { if let Some(path) = path { window.paint_path(path, color); } }, ) .w_full() .h(px(1.)) ``` The `prepaint` result here is `Option>`; it exists only for this frame. [GPUI Kit's dashed Separator](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/separator.rs) uses `canvas` to draw a path in its resolved bounds, and the [circular Progress](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/progress/progress_circle.rs) returns measured radii from prepaint to paint. Use this for a focused drawing callback inside `Render` or `RenderOnce`. See [Paint](./paint) for `PathBuilder`, SVG path notation, and path caching examples. It has no built-in children, hitbox, focus tracking, or stable Element ID. When those responsibilities need to work together, implement `Element` or compose a standard interactive element around the canvas. When an existing interactive element such as `div()` already provides the behavior you need, compose it. If a custom primitive needs standard interaction, embed or delegate to GPUI's `Interactivity`, as GPUI's built-in elements do. Implementing raw hitboxes and event registration yourself also makes you responsible for dispatch, clipping, cursor behavior, and accessibility. `Element::id()` returning an `ElementId` does more than label pixels: it creates stable identity across frames. Keep IDs unique within their nearest keyed ancestor, and do not add an ID unless the element or an attached behavior needs identity. [Element]: https://docs.rs/gpui-pre/0.3.8/gpui/trait.Element.html [IntoElement]: https://docs.rs/gpui-pre/0.3.8/gpui/trait.IntoElement.html [AnyElement]: https://docs.rs/gpui-pre/0.3.8/gpui/struct.AnyElement.html --- # I18N Source: /versions/v0.7.1/docs/i18n License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/i18n and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. # I18n GPUI Component ships component strings in `crates/component/locales/ui.yml`, including `en`, `zh-CN`, `zh-HK`, `zh-TW`, and `it` entries. Coverage varies by key. Your application owns its own copy, locale policy, and formatting; it can add or override component translations without copying that file. This feature requires [rust-i18n](https://github.com/longbridge/rust-i18n) 4.2 or later. ## Add the dependency Add `rust-i18n` to the application crate that owns your locale files: ```toml [dependencies] gpui-kit = "0.7.1" rust-i18n = "4.2" ``` ## Create an application locale file Create `locales/ui.yml` in your application crate. Put component translations under the `gpui_component` namespace: ```yaml _version: 2 app: save: en: Save fr: Enregistrer gpui_component: Calendar: week.0: fr: Di month.January: fr: Janvier DatePicker: placeholder: fr: Sélectionner une date ``` Keep your own keys outside `gpui_component`. That namespace must match the Rust crate name `gpui-component` with hyphens converted to underscores. Copy key names exactly from [GPUI Component's built-in locale file](https://github.com/longbridge/gpui-kit/blob/main/crates/component/locales/ui.yml); for example, `month.January` is one nested key under `Calendar`. ## Register the extension Initialize the application's locales at the crate root: ```rust rust_i18n::i18n!("locales", fallback = "en"); ``` Register them before initializing GPUI Component: ```rust app.run(move |cx| { use gpui_kit::component as gpui_component; rust_i18n::extend!(gpui_component); gpui_kit::init(cx); // Open windows and initialize the rest of the application. }); ``` Call `extend!` only once during application startup. The local story app uses this alias and ordering in [`crates/story/src/lib.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/story/src/lib.rs). ## Lookup priority The application's translations take priority over the component's built-in translations: ```text Application locales (locales/ui.yml) │ │ key not found ▼ GPUI Component built-in locales ``` This deep-merge behavior means that: - A new locale, such as `fr`, can contain only the keys your application needs. - A translation with the same locale and key overrides the built-in value. - Keys not supplied by the application continue to use the built-in value. - Future GPUI Component translations remain available without being copied into the application. For example, defining only `gpui_component.Calendar.month.January.en` changes January's English label while all other English calendar labels still come from GPUI Component. ### The namespace applies to component lookups only `extend!` changes how GPUI Component resolves _its_ keys. It does not give the application's own `t!` calls access to the built-in translations: ```rust // Inside a GPUI Component component: application value first, built-in second. t!("Calendar.month.February") // -> "February" // In application code: reads the application's locale files only. t!("gpui_component.Calendar.month.February") // -> the key, unless you defined it ``` Read component strings by rendering the component, not by looking the key up yourself. For application copy, call `rust_i18n::t!("app.save")` from the application crate. Keep the value in a complete message key so each language can choose its own word order and punctuation. A component's translated placeholder does not translate the surrounding application title, label, or menu item. ## Select a locale `gpui-component` re-exports the locale accessors, so an application does not have to reach for `rust-i18n` to switch languages: ```rust use gpui_kit::component::{locale, set_locale}; set_locale("fr"); let current = locale(); ``` Components then resolve their translated labels using the selected locale and the priority described above. The active locale is global state outside GPUI's change tracking. Changing it alone does not schedule a repaint. If a view handles its own language picker, notify that view after switching: ```rust use gpui_kit::component::set_locale; set_locale("fr"); cx.notify(); ``` See [Context](./context) for how `cx.notify()` schedules a View update. If an application-wide action changes the locale from `&mut App`, refresh the windows that show translations: ```rust use gpui_kit::{App, component::set_locale}; fn select_locale(language: &str, cx: &mut App) { set_locale(language); cx.refresh_windows(); } ``` Rebuild other cached presentation explicitly. For example, the story app's [`SelectLocale` handler](https://github.com/longbridge/gpui-kit/blob/main/crates/story/src/app_menus.rs) rebuilds its native and in-window menus after changing the language; their checked state is not a live translation lookup. The story's menu labels themselves are currently fixed strings, so the language picker demonstrates component translations and selection state, not a fully localized gallery. ## Text and layout across scripts Translation changes the string; the text system then resolves fonts, shapes glyphs, and measures the result. CJK or mixed Latin/CJK text needs a font with the actual glyphs. A font family that loads can still lack them, and a fallback face can change line breaks or control height. See [Fonts](./fonts) and [TextSystem](./text-system). Check long translations, narrow buttons, input placeholders, and text at application scale on each target platform. Measure custom text with shaped layout rather than character counts. GPUI Kit does not expose an application-wide RTL layout switch through this locale API. Setting an Arabic or Hebrew locale will select strings, but it does not automatically mirror navigation, icon direction, alignment, focus order, or pointer and keyboard behavior. Review those parts of each screen for the target language, and test bidirectional text and editing on the target platform. Treat text shaping and full interface mirroring as separate checks. ## Format data in the application `rust-i18n` selects message strings here; `set_locale` does not format dates, numbers, currencies, units, or plurals for your domain data. Choose a locale-aware formatter appropriate to your application and pass its finished output into your views. Keep variable placement in a whole translated message so a translator can reorder it. Do not assemble a sentence from separately translated fragments or assume `format!("{value}")` applies locale-specific separators. A date picker can translate its own month names while a date shown elsewhere in the app still needs your formatting policy. ## Verify the integration The repository's story crate provides a working extension and French overrides. From the repository root, run `cargo run -p gpui-component-story`, open the **Language** menu, and select **Français**. Open a calendar or date picker story: its month names and date placeholder use the `fr` values in [`crates/story/locales/ui.yml`](https://github.com/longbridge/gpui-kit/blob/main/crates/story/locales/ui.yml). Switch back to English and check the same controls. The menu checkmark should follow the choice. You can also run `cargo test -p gpui-component-story --features test-support extends_component_translations_with_story_locales` to exercise the story's translation layering test. ## Common mistakes | Symptom | Check | | --- | --- | | Component still shows an old language | Call `set_locale`, then notify the owning view or refresh windows; rebuild cached menus or labels. | | Override does not appear | Put the key under `gpui_component`, match the built-in spelling and locale code, and register `extend!` before `gpui_kit::init`. | | Application `t!` returns a key | Define that key in your application locale file; component built-ins are available to component lookups, not application lookups. | | CJK characters are boxes or layout changes | Check font glyph coverage and fallback on the target platform; see [Fonts](./fonts). | | RTL string appears but the screen is awkward | Review layout and interactions explicitly; the locale setter does not mirror the interface. | --- # ElementId Source: /versions/v0.7.1/docs/element_id License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/element_id and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. An `ElementId` is a **local key** for an element in GPUI's rendered tree. GPUI combines that key with the IDs of its keyed ancestors to form a `GlobalElementId`. This path lets GPUI associate interaction and element state with the same logical element when a [View renders](./render) again. It also preserves node identity in the [accessibility tree](./accessibility) when the element has a role. An ID is not a handle to an [Entity] and is not a way to look up an element like an HTML DOM ID. Use an `Entity` for shared application state. Use an `ElementId` for identity in the element tree, including keyed component state, focus or scroll behavior supplied by a component, and state retained by a custom [Element]. ## Assign an ID Calling `.id(...)` on an interactive element such as `div()` returns a `Stateful
`: ```rust let save = div() .id("save-button") .on_click(|_, window, cx| { // Handle the click. }) .child("Save"); ``` The `Stateful` wrapper exposes GPUI's stateful interaction methods and carries the element's ID. A custom `Element` can return `Some(id)` from its `id()` method without using this wrapper. A plain, unkeyed `div()` can still be a layout parent; it contributes no segment to the ID path. Strings, integers, and name plus integer tuples are common `ElementId` inputs: ```rust use gpui_kit::component::button::Button; div().id("search") div().id(("message", message.id)) Button::new(("delete-project", project.id)).label("Delete") ``` Choose a key from the object's identity, not from the text displayed to the user. A translated label, current selection, or freshly generated random value can change while the object stays the same. ## GlobalElementId and the keyed ancestor path GPUI builds a `GlobalElementId` from the IDs on the path to an element. Only keyed ancestors add path segments: ```text div().id("workspace") ├── div().id("inbox") │ └── div().id(("row", 42)) → ["workspace", "inbox", ("row", 42)] └── div().id("archive") └── div().id(("row", 42)) → ["workspace", "archive", ("row", 42)] ``` The two rows may share a local ID because their keyed ancestor paths differ. This diagram shows only IDs written in the example: an entity-backed View also adds its `EntityId` as a path segment, and a [`RenderOnce`](./render-once) component adds a type-name namespace. The path is scoped to the [Window](./window)'s rendered tree; `GlobalElementId` is GPUI's internal path, not a process-wide string you need to construct at call sites. For a custom drawing API that needs a path for its own key, `window.with_global_id(key, |global_id, window| { … })` creates one within that callback. The practical uniqueness rule is: **within the same nearest keyed ancestor, each keyed descendant branch needs a distinct ID**. An unkeyed container does not open a new namespace: ```rust div().id("workspace") .child(div().child(div().id("item"))) .child(div().child(div().id("item"))) // Same keyed path: collision. ``` Give the branches their own stable IDs, or make the item IDs distinct. Duplicate paths can make retained state and interaction attach to the wrong logical element. ## Stable keys in changing lists For a list that can insert, remove, filter, or reorder rows, derive each row ID from a stable domain value: ```rust div().id("messages").children(messages.iter().map(|message| { div() .id(("message", message.id)) .child(message.preview.clone()) })) ``` `("message", message.id)` separates the row's purpose from other controls that may use the same numeric ID. Reordering changes the drawing position, but each message keeps its keyed path. By contrast, `.id(index)` attaches state to a *position*: after an insertion, the old first row's focus, scroll, animation, or other keyed state may be reused for a different message. An index is appropriate only when the position itself is the identity and cannot shift. If a repeated row contains several controls, key the row and give its children distinct local keys such as `"edit"` and `"delete"`. Moving the row then carries its whole keyed subtree with it. ### Worked example: a row and its controls The following uses the actual `div().id(...)` and `Button::new(id)` APIs. `Message::id` is a stable database ID; `preview` is display data that may change. Each row owns a namespace for its controls: ```rust use gpui_kit::*; use gpui_kit::component::button::Button; struct Message { id: u64, preview: SharedString, } fn message_list(messages: &[Message]) -> impl IntoElement { div().id("messages").children(messages.iter().map(|message| { div() .id(("message", message.id)) .child(message.preview.clone()) .child(Button::new("archive").label("Archive")) })) } ``` For message `42`, the written part of the button's path is `"messages" → ("message", 42) → "archive"` (GPUI may add View and component namespaces). Every row can call its button `"archive"` because the row IDs differ. If the list order changes from `[42, 7]` to `[7, 42]`, those paths stay with their messages. If message `42` is removed for a rendered frame, its element-local state ends; inserting it again later creates fresh state. An application-level selection or draft that must survive removal belongs in an owned `Entity` or model. The row key does not automatically key siblings *inside* the row: two `Button::new("archive")` controls under that same row would still collide. Give them distinct local IDs. Also keep the same domain ID when a message's preview or localized label changes; using that text as the key would reset its UI identity. ### Try it: reorder, hide, and restore rows In the existing `examples/hello_world` package, replace `src/main.rs` with the complete example below and run `cargo run -p hello_world`. Click **Add to 42** twice, then **Swap rows**. Record 42 still shows `2` after moving below record 7. Click **Hide 42**, wait until that row is visibly gone, then click **Show 42**. Its count starts again at `0` because the keyed state was absent from a rendered frame. Keep a persistent count in an application-owned Entity if it must survive hiding. ```rust use gpui_kit::base::StyledExt; use gpui_kit::component::button::Button; use gpui_kit::*; struct Example { reversed: bool, show_42: bool, } impl Render for Example { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let order = if self.reversed { [7_u64, 42] } else { [42, 7] }; let rows = order .into_iter() .filter(|id| *id != 42 || self.show_42) .map(|id| { // This call happens while the parent View renders, before the row div is drawn. // Give the state its own domain-derived key; the row ID below keys its subtree. let count = window.use_keyed_state(("row-count", id), cx, |_, _| 0_u32); let value = *count.read(cx); div() .id(("row", id)) .h_flex() .gap_2() .child(format!("Record {id}: {value}")) .child( Button::new("increment") .label(format!("Add to {id}")) .on_click(move |_, _, cx| { count.update(cx, |value, cx| { *value += 1; cx.notify(); }); }), ) }) .collect::>(); div() .v_flex() .gap_2() .p_4() .child(Button::new("swap").label("Swap rows").on_click(cx.listener( |this, _, _, cx| { this.reversed = !this.reversed; cx.notify(); }, ))) .child( Button::new("toggle-42") .label(if self.show_42 { "Hide 42" } else { "Show 42" }) .on_click(cx.listener(|this, _, _, cx| { this.show_42 = !this.show_42; cx.notify(); })), ) .child(div().id("rows").v_flex().gap_2().children(rows)) } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| Example { reversed: false, show_42: true, }) }) .expect("failed to open window"); }); } ``` The row's `("row", id)` path keeps its controls associated with the same record when the order changes. `Button::new("increment")` can use the same local name in both rows because the row paths differ. The counter uses a *separate* `("row-count", id)` key: it is requested while the parent View builds the rows, before the row's element ID is on the path. Both keys must use the stable record ID. Hiding a row is observable only after GPUI renders a frame without that row; clicking Hide and Show before an intervening render may preserve the old state. ## How IDs retain state GPUI reconstructs elements during rendering. The Rust value returned by `div()` or a `RenderOnce` component is temporary; assigning it an ID does not turn it into a persistent `Entity`. The ID gives GPUI a way to reconnect element-local state across consecutive frames. `window.use_keyed_state(key, cx, init)` forms a path from the current keyed ancestors plus `key`. It returns an `Entity` kept for as long as that keyed state is used in consecutive rendered frames. `Window` owns this keyed state; `cx` supplies application access (see [Context](./context) for the different GPUI contexts). `init` runs when that state has no previous entry. GPUI also observes this state entity and notifies the current View when it changes: ```rust let focus_handle = window .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle()) .read(cx) .clone(); ``` GPUI Kit's `Button` uses this pattern for its focus handle. Its public ID gives the recreated button instance the same state key on later frames. The focus handle still owns focus behavior; the element ID identifies where that handle's state belongs. The window retains keyed state only while its path is accessed in successive frames; a [cached View](./view-cache) replays those accesses when it reuses its subtree. A separate strong `Entity` handle can keep that entity alive after its window state entry disappears, but recreating the path will run `init` again. For a custom `Element`, GPUI passes `Option<&GlobalElementId>` into `request_layout`, `prepaint`, and `paint` when its `id()` returns a value. During drawing, `window.with_element_state(global_id, ...)` can read state from the preceding frame and return the value to store for the next: ```rust let state = window.with_element_state( id.expect("this element always has an ID"), |previous: Option, _window| { let state = previous.unwrap_or_default(); (state.clone(), state) }, ); ``` This is the pattern behind GPUI Kit's `ScrollBounce` element, which retains motion state during `prepaint`. `with_element_state` is a drawing-phase API for element authors; ordinary Views should prefer Entity state or a component's documented API. GPUI keys stored element state by global path **and state type** and drops it when the element no longer participates in the rendered frames. `window.use_state(cx, init)` uses the call site's code location as its local key. It works when the *full path* is unique: a call inside each row is safe if every row has a stable keyed ancestor. If repeated calls share the same keyed ancestor path, use `use_keyed_state` with a stable item key or introduce a keyed namespace around each item. ## Identity changes are state changes - Changing an element's ID or a keyed ancestor's ID gives it a new path and resets its associated element state. - Removing an element ends its consecutive-frame state lifetime. Recreating it later initializes that state again. - An ID does not preserve a View's `Entity` by itself; a strong Entity owner controls that lifetime. - Keyed state belongs to the Window's rendering context. Do not rely on a `GlobalElementId` to transfer state between windows. ## Effects beyond element state An ID can be one input to a component's focus, scroll, measurement, or animation state. For example, Kit's `Button` obtains a focus handle with `window.use_keyed_state(self.id.clone(), ...)`, while `ScrollBounce` uses `window.with_element_state(...)` to retain its motion state. Reusing a path for two live controls can therefore mix behavior; changing a path can restart it. A `FocusHandle` or `ScrollHandle` still owns its respective behavior, and changing an `ElementId` does not by itself reset every handle stored elsewhere. Stable paths also matter to cached Views. A cached Entity View reuses work only while its entity and element path still match; on a cache hit, GPUI replays the subtree's element-state accesses. IDs are not a general render cache: adding `.id(...)` to a `div()` does not make its parent skip `render` (see [View Cache](./view-cache)). For accessibility, a custom element needs both an ID and a role to become a node. A stable ID lets GPUI preserve that node's identity across redraws, but it does not supply a role, label, keyboard behavior, or focusability. Use the component's accessibility API for those properties (see [Accessibility](./accessibility)). ## Find a bad ID path 1. Identify the logical object whose focus, scroll position, animation, or other state moved or reset. Write down the object's stable domain ID and every keyed ancestor above it. Check whether a sibling now has the same **full** path, or whether an ancestor key changes when data is reordered. 2. Look for unkeyed wrappers between repeated controls. They do not distinguish paths. Replace a shared literal with a stable object-derived ID, or key the repeated parent. Do not fix a collision with a new random value on every render: that trades state sharing for state loss. 3. If state resets only after an item disappears, check whether it was absent for a rendered frame. If it was, keep long-lived state in an Entity or model. If it resets while still present, check changing ancestor IDs, a newly created View Entity, and cache invalidation separately. A real Kit example is [`DockSkin::render_resize_handle`](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/dock/dock.rs): left, right, and bottom resize handles can live below the same keyed ancestor. Giving every handle the literal `"resize-handle"` would produce one `GlobalElementId`; the dock source notes that a press on one handle could then start another handle's drag. It selects `"resize-handle-left"`, `"resize-handle-right"`, and `"resize-handle-bottom"` from the stable placement instead. In UI integration tests, target IDs are query selectors for observed elements, not a second identity system. Import `gpui_kit::test::TestWindowExt`; `window.find("archive")` requires one matching observed target, so repeated controls need a native scope: ```rust let archive = window.within(("message", 42_u64)).find("archive"); assert!(archive.visible()); ``` `window.within(...)` follows the keyed ancestor path even when the ancestor itself is not observed. An ambiguous `find` or `try_find` asks for a scope; it does not necessarily mean GPUI state collided, since two controls can correctly share a local ID under different row paths. Test the real click or focus outcome as well as the snapshot (see [Testing](./test)). See [Element](./element) for the layout, prepaint, and paint lifecycle, and [Entity](./entity) for state that must outlive an element's presence in the tree. [Element]: ./element.md [Entity]: ./entity.md --- # Entity Source: /versions/v0.7.1/docs/entity License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/entity and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. When several Views, handlers, or async tasks need the same state, put that state in GPUI's [`Entity`][Entity]. A Chat, for example, can keep its messages in an `Entity`; any code holding a clone can access the same Chat through a GPUI [Context](./context). Create the Entity with `cx.new`, read it with `read`, and change it with `update`. If `Chat` implements [`Render`](./render), its `Entity` can also render directly as a View. Otherwise, it works as a shared state model. ```text Entity ├── read(cx) → &Chat ├── update(cx, …) → &mut Chat + Context └── downgrade() → WeakEntity ``` Cloning an Entity copies its handle, not the state inside it. GPUI increments a synchronized strong-handle count; it does not clone `T` or collections inside it. A handle clone is much smaller than a deep model clone, but it is not free, and every retained clone extends the Entity's lifetime. Entity access always goes through a GPUI context, allowing GPUI to coordinate updates, rendering, subscriptions, and the Entity lifecycle. ## Data model or persistent View `Entity` is a state and identity handle; `T` does **not** need to implement `Render`. A data-only Entity can hold a model or component state, and several owners can read or update it. When `T` **does** implement `Render`, its `Entity` can also be placed in the element tree as a persistent View. The View's Entity survives while the elements returned by `render` are rebuilt for drawing. ```text Entity (model; no Render) ↑ read/observe Entity (Render View) ↓ render element tree ``` ```rust use gpui_kit::*; struct MessageStore { messages: Vec, } struct MessagePanel { store: Entity, _subscription: Subscription, } impl Render for MessagePanel { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let count = self.store.read(cx).messages.len(); div().child(format!("{count} messages")) } } // In a GPUI context, outside render: let store = cx.new(|_| MessageStore { messages: vec![] }); let panel = cx.new(|cx| { let _subscription = cx.observe(&store, |_, _, cx| cx.notify()); MessagePanel { store: store.clone(), _subscription } }); store.update(cx, |store, cx| { store.messages.push("Hello".into()); cx.notify(); }); // Add `panel` as a child View; `store` is data, not an element. ``` The model's `notify()` reaches `MessagePanel` through the stored observation, which notifies the View in turn. This explicit connection also keeps the View current if it is later placed behind a cache boundary. `RenderOnce` is another route to elements: it describes a value-like component consumed during rendering and does not, by itself, create or require an Entity. See [RenderOnce](./render-once) for that lifecycle. ### Try it: one model, one observing View From the repository root, replace `examples/hello_world/src/main.rs` with this complete example, then run `cargo run -p hello_world`. It uses the existing example package and requires no new dependency. Save the original file first if you want to restore the Hello World example afterward. ```rust use gpui_kit::base::StyledExt; use gpui_kit::component::button::Button; use gpui_kit::*; struct MessageStore { count: usize, } struct MessagePanel { store: Entity, _subscription: Subscription, } impl MessagePanel { fn new(cx: &mut Context) -> Self { let store = cx.new(|_| MessageStore { count: 0 }); let _subscription = cx.observe(&store, |_, _, cx| cx.notify()); Self { store, _subscription, } } } impl Render for MessagePanel { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let count = self.store.read(cx).count; div() .v_flex() .gap_2() .size_full() .items_center() .justify_center() .child(format!("Messages: {count}")) .child( Button::new("add-message") .label("Add message") .on_click(cx.listener(|this, _, _, cx| { this.store.update(cx, |store, cx| { store.count += 1; cx.notify(); }); })), ) } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(MessagePanel::new) }) .expect("failed to open window"); }); } ``` The window starts at **Messages: 0**. Each click changes the model Entity through `update`; its `notify()` schedules the stored observer, which calls `notify()` on the panel. The next render reads the same model handle, so the label becomes **Messages: 1**, then **Messages: 2**. The model has no `Render` implementation and is never added to the element tree. The panel retains both the model handle and the `Subscription`; recreating either in `render` would lose the stable relationship. If the count stays at zero, check both notification calls and the `_subscription` field. Removing the model's `notify()` leaves the observer without a signal; dropping the subscription after `new` cancels the observation. When changing the example, keep `store.update(...)` in the click handler and `store.read(cx)` in `render`, outside any active update of that same store. ## Create an Entity Use `cx.new` in any GPUI context: ```rs use gpui_kit::*; struct Chat { messages: Vec, } let chat: Entity = cx.new(|_cx| Chat { messages: Vec::new(), }); let same_chat = chat.clone(); assert_eq!(chat.entity_id(), same_chat.entity_id()); same_chat.update(cx, |chat, cx| { chat.messages.push("Hello".into()); cx.notify(); }); assert_eq!(chat.read(cx).messages.len(), 1); ``` The closure receives [`Context`](./context), so initialization can also create child entities or register subscriptions. Both handles in the example refer to the same `Chat`: an update through one is visible through the other. Cloning the handle performs synchronized ownership bookkeeping, but avoids copying the model and its messages. Reuse a handle when ownership or a callback needs it; avoid cloning it repeatedly inside a hot loop without a reason. [`SharedString`](./shared-string) also makes sharing text inexpensive, through a different storage mechanism. An owner keeps a strong `Entity` when the child should live as long as the owner: ```rs struct Workspace { chat: Entity, } impl Workspace { fn new(cx: &mut Context) -> Self { let chat = cx.new(|_cx| Chat { messages: Vec::new(), }); Self { chat } } } ``` This strong ownership pattern appears throughout GPUI Kit: a parent View owns the child Views or models it renders and coordinates. ### Pass a handle across a feature A feature can keep its business data in one model Entity and pass cloned `Entity` handles to its panels, commands, and tasks. Those owners refer to **the same state**; they do not each receive a copy of a large message list or document. This makes a typed Entity handle a useful boundary between modules of one feature. A clone still updates GPUI's strong-handle count and extends the model's lifetime, so use `WeakEntity` for back references or work that should not retain it. Keep document, workspace, and other feature-specific state owned by that feature's Entity. Use a [`Global`](./global) for a value or service whose scope really is the whole application, such as a shared preference read by multiple features or windows. A Global is one app-wide slot for its type; it is not a shortcut for passing a per-document Entity. Views also need an explicit global observation when changes must update their output. Across crates, expose the feature's intended handle, command, or event through its public boundary rather than making sibling features depend on its internal model fields. Move a capability into a shared crate only when it has a coherent contract and more than one real owner. The [Coding Guides](./coding-guides) cover the full feature and crate layout. ### Retain component state outside `render` GPUI Kit's stateful components follow the same rule. Construct an input's state once, store its Entity, and pass the handle to the value-like `Input` element on each render: ```rust use gpui_kit::*; use gpui_kit::component::input::{Input, InputState}; struct SearchPane { query: Entity, } impl SearchPane { fn new(window: &mut Window, cx: &mut Context) -> Self { let query = cx.new(|cx| InputState::new(window, cx)); Self { query } } } impl Render for SearchPane { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div().child(Input::new(&self.query)) } } ``` Creating `InputState` inside `render` would reset the draft, selection, and focus-related behavior as the View redraws. The `Input` value can be rebuilt; its state Entity should survive. ## Read state Use `read` for direct, synchronous access: ```rs let message_count = chat.read(cx).messages.len(); ``` The returned reference is tied to `cx`; copy or clone the value you need instead of trying to store the reference. Use `read_with` when code has a generic `AppContext`, or when a closure makes the read boundary clearer: ```rs let last_message = chat.read_with(cx, |chat, _cx| { chat.messages.last().cloned() }); ``` `read` takes `&App` (a `Context` dereferences to `App`). `read_with` accepts any `AppContext` and returns what its closure produces. Neither creates a second copy of the Entity. Keep either read short: an Entity already leased for an update or render cannot be read again until that access ends. Do not keep an `&T` across a later update or an `await`; extract an owned value before leaving the read. ## Update state Use `update` to obtain mutable state and its `Context`: ```rs chat.update(cx, |chat, cx| { chat.messages.push("Hello".into()); cx.notify(); }); ``` `cx.notify()` reports that this Entity changed. Views that rendered or observed it can then update. Mutation alone does not imply a notification, so call it when the new state should be reflected by observers or rendering. An `update` closure may return a result. This lets a handler change one Entity, finish that borrow, and then use the result to update another without creating a callback cycle: ```rust let new_count = chat.update(cx, |chat, cx| { chat.messages.push("Hello".into()); cx.notify(); chat.messages.len() }); // The Chat update has ended here; `new_count` is an ordinary usize. ``` Always use the inner `cx` passed to the update closure. It is the `Context` for the Entity currently being updated. Do not call `read` or `update` on an Entity while that **same** Entity is already being updated or rendered. GPUI prevents re-entrant access and will panic. Updating a different Entity inside an update can work, but an observer or callback that reaches back to the first Entity creates the same problem indirectly. Use the `&mut T` already provided by the callback, copy out a small result, or finish the first update before starting the next. This also applies to deferred callbacks that run with their owning Entity already borrowed. ## Use a WeakEntity for back references and callbacks Cloning `Entity` creates another strong handle and keeps the Entity alive. That matters when a parent View owns a child Entity and passes its **strong** `Entity` to the child. If the child stores it, both sides own each other: ```text outside owner → ParentView ──strong──→ ChildView ↑ │ └──────strong────────┘ ``` Dropping the outside owner does not remove either remaining strong handle, so neither Entity is released. Passing a parent handle to a child is safe when the child only uses it temporarily; the cycle appears when the child retains that strong handle while the parent retains the child. Store a [`WeakEntity`][WeakEntity] for the back reference instead: ```rust use gpui_kit::*; struct ParentView { child: Option>, clicks: usize, } struct ChildView { parent: WeakEntity, } impl Render for ParentView { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div() .child(format!("Clicks: {}", self.clicks)) .children(self.child.clone()) } } impl Render for ChildView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div().child("Notify parent").on_click(cx.listener(|this, _, _, cx| { let Some(parent) = this.parent.upgrade() else { return; }; parent.update(cx, |parent, cx| { parent.clicks += 1; cx.notify(); }); })) } } // In a GPUI context, outside render: let parent = cx.new(|_| ParentView { child: None, clicks: 0 }); let child = cx.new(|_| ChildView { parent: parent.downgrade() }); parent.update(cx, |parent, cx| { parent.child = Some(child); cx.notify(); }); ``` Now the parent owns the child, but the child's link back does not keep the parent alive: `ParentView ──strong──→ ChildView ──weak──→ ParentView`. `upgrade()` returns `Option>`; when the parent has gone away, the click handler simply returns. The handler runs after rendering, so it can update the parent without re-entering the parent's `render` borrow. GPUI Kit's nested popup menus use the same strong child, weak parent ownership direction. A weak handle may outlive its target. Upgrade it, or use its fallible access methods: ```rs let workspace = cx.weak_entity(); cx.spawn(async move |_, cx| { let conversations = load_conversations().await; workspace .update(cx, |workspace, cx| { workspace.set_conversations(conversations); cx.notify(); }) .ok(); }) .detach(); ``` `WeakEntity::upgrade` returns `Option>`; `read_with` and `update` return a `Result` because the Entity may already have been released. GPUI Kit uses this pattern for async tasks, callbacks, delegates, and parent references so those relationships do not accidentally keep a View alive. `Context::spawn` already supplies a `WeakEntity` as the first async argument. Hold the returned [`Task`](./task) in the owning Entity when dropping that owner should cancel the work; call `.detach()` when it should continue independently. In either case, handle a failed weak update after an `await` as normal cancellation, since the View may have closed while the work was running. Retained closures can close an ownership cycle too, but only when their owner chain leads back to a strongly captured Entity. GPUI's `cx.observe`, `cx.subscribe`, and `cx.listener` use weak subscriber/View handles internally; storing one of their `Subscription`s does not by itself make a strong Entity cycle. An additional `move` capture of a strong Entity can still complete that cycle. `cx.processor` differs from `cx.listener`: it captures a strong handle to its View, so do not store its closure back in that same View without breaking the ownership loop. When investigating retained state, check both strong Entity cycles and subscriptions or tasks whose lifetime was detached from their intended owner; these are common ownership paths to inspect, not an exhaustive list of causes. ## Observe changes and subscribe to Events An Entity can coordinate with another Entity in two related ways: - `cx.observe(&entity, ...)` runs when that Entity calls `cx.notify()`. Use it when only “this state changed” matters. - `cx.subscribe(&entity, ...)` receives a typed [Event]. Use it when the meaning and payload of the change matter. They are separate signals: `notify()` does not emit an Event, and `emit(event)` does not by itself notify renderers. A state change that needs both a redraw and a semantic event can do both deliberately, usually once each. An observer can inspect the observed Entity with the handle it receives; it must still avoid re-entering an Entity already borrowed by the callback chain. GPUI delivers these callbacks through its effect cycle, after the current update's borrow has ended; do not rely on the callback having run inside the `update` closure. Store the [`Subscription`](https://docs.rs/gpui-pre/0.3.8/gpui/struct.Subscription.html) returned by `observe` or `subscribe` on the subscribing Entity, in a `_subscription` field or a `_subscriptions: Vec` field: ```rs enum ChatEvent { MessageSent, } impl EventEmitter for Chat {} impl Chat { fn send_message(&mut self, cx: &mut Context) { self.messages.push("Hello".into()); cx.notify(); cx.emit(ChatEvent::MessageSent); } } struct Workspace { chat: Entity, sent_count: usize, _subscriptions: Vec, } impl Workspace { fn new(cx: &mut Context) -> Self { let chat = cx.new(|_cx| Chat { messages: Vec::new(), }); let _subscriptions = vec![ cx.observe(&chat, |_workspace, _chat, cx| { cx.notify(); }), cx.subscribe(&chat, Self::on_chat_event), ]; Self { chat, sent_count: 0, _subscriptions, } } fn on_chat_event( &mut self, _chat: Entity, _event: &ChatEvent, cx: &mut Context, ) { self.sent_count += 1; cx.notify(); } } ``` Calling `chat.update(cx, |chat, cx| chat.send_message(cx))` now sends both signals: the observer invalidates `Workspace` for the model change, while the Event subscriber increments `sent_count`. This is the pattern used by GPUI Kit Views. Dropping `Workspace` also drops `_subscriptions` and cancels its callbacks. Dropping a local `let subscription = ...` at the end of the function **cancels too early**; it is not a leak. `Subscription::detach()` has the opposite effect: it consumes the handle without canceling the registration, which can remain until the subscribed Entity is dropped. Repeatedly detaching subscriptions to a long-lived Entity can accumulate callbacks and captured resources. Detach only when that longer lifetime is intended; it differs from `Task::detach()`, which lets asynchronous work continue independently. A callback's strong Entity capture becomes a cycle only if the ownership chain leads back to the owner. `Context::observe` and `subscribe` use a weak handle to the subscriber, so the registration does not itself own that View. Keep the `Subscription` in the subscriber for an explicit lifetime. If both callbacks above call `cx.notify()` for one operation, GPUI can coalesce invalidations, but decide whether both responses are actually needed. See [Event] for `EventEmitter`, `emit`, and typed subscription design. ## Lifecycle An Entity remains alive while at least one strong `Entity` handle exists. Dropping the final strong handle makes `WeakEntity::upgrade()` fail. GPUI then runs release callbacks and drops the state during its effect cycle; do not depend on the state's destructor or a release callback running synchronously at the `drop(handle)` statement. Most cleanup should follow normal ownership: - own child entities with `Entity`; - use `WeakEntity` for non-owning links; - keep View-level subscriptions in the same View's `_subscription` or `_subscriptions` field; - let dropping the View release its subscriptions and captured resources. For integration code that needs access to state before GPUI drops it, [Context](./context) also provides `cx.on_release(...)` for the current Entity and `cx.observe_release(...)` for another Entity. Both callbacks run when GPUI processes the release; `observe_release` runs only while its subscriber still exists. Store the returned subscriptions for exactly as long as the release callback is needed. ### Try it: shared handles and a weak lifetime Add this test to a package that enables `gpui-kit`'s `test-support` feature (see [Testing](./test)). It needs no window. The assertions check that a clone shares state, one remaining strong handle keeps it alive, and the final drop invalidates the weak handle: ```rust use gpui_kit::{AppContext, TestAppContext}; struct Counter { value: usize, } #[gpui_kit::test] fn handles_share_state_and_control_lifetime(cx: &mut TestAppContext) { let counter = cx.new(|_| Counter { value: 0 }); let another_owner = counter.clone(); let weak = counter.downgrade(); another_owner.update(cx, |counter, cx| { counter.value += 1; cx.notify(); }); assert_eq!(counter.read(cx).value, 1); drop(counter); assert!(weak.upgrade().is_some()); drop(another_owner); assert!(weak.upgrade().is_none()); } ``` As a second check, use the `Chat`/`Workspace` example above: update Chat once with `notify()` alone and once with `emit(ChatEvent::MessageSent)` alone. Predict which call changes `sent_count`, then assert it in a `#[gpui_kit::test]`. Retain `_subscriptions` on `Workspace`; otherwise the test checks an already canceled listener. ## Entity identity and view caching An `Entity` can be embedded directly as a child View. Its `EntityId` gives that View a stable identity across frames; `cx.notify()` invalidates the views that display it. The state persists in the Entity, while the ordinary element tree is rebuilt for a draw. Keeping an Entity is therefore different from caching its rendered subtree. For an expensive child that often stays unchanged while its parent redraws, GPUI also exposes `child.clone().cached(style)` and the equivalent `AnyView::cached(style)`. The parent must retain the same child Entity, and `style` must provide a definite outer size because GPUI can skip rendering the contents during layout. A clean cached child may replay its previous subtree; notifications, changed bounds or inherited drawing context cause a rebuild. See [View Cache](./view-cache) for the exact boundary and how it differs from element state and virtualization. [Entity]: https://docs.rs/gpui-pre/0.3.8/gpui/struct.Entity.html [WeakEntity]: https://docs.rs/gpui-pre/0.3.8/gpui/struct.WeakEntity.html [Event]: ./event.md --- # Design Guides Source: /versions/v0.7.1/docs/design-guides License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/design-guides and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Use this guide before choosing components or writing layout code. It records the product judgment accumulated through years of GPUI Kit desktop work: an interface should feel native, restrained, precise, and understandable without guesswork. This is a normative guide. **Must** identifies a correctness or ecosystem constraint, **should** is the default that needs a concrete reason to override, and **may** is an optional technique. Component API documentation remains the authority for individual methods. The rules build on behavior in `gpui-base`, the GPUI Component theme and component system, and familiar desktop interaction. shadcn/ui contributes useful methods—open code, composition, and dependable defaults—but does not determine how a GPUI application should look. When influences conflict, preserve GPUI's lifecycle constraints and the interaction people already understand. ## Design thesis Build interfaces that feel native, quiet, and precise. Let content, hierarchy, and interaction carry the experience; decoration should support them rather than compete with them. 1. **Clarity before personality.** Make the primary task and next action clear before adding brand expression. 2. **Composition before invention.** Start with established components and compose them into product-specific workflows. Create a new primitive only when its behavior is genuinely new. 3. **Tokens before values.** Colors, radii, typography, and spacing should form a system. Avoid isolated literals that cannot respond to themes. See [Style](./style) for GPUI's styling API. 4. **Desktop before web convention.** Preserve keyboard access, window chrome, menus, dense data views, resizable regions, and persistent navigation where the task benefits from them. 5. **State must be visible.** Hover, focus, selection, disabled, loading, validation, and destructive states need distinct and consistent treatment. 6. **Geometry explains containment.** Radii, insets, and boundaries should describe one coherent hierarchy, even where surfaces overlap. 7. **Feedback reflects reality.** Loading, tooltips, and motion should clarify a real state or transition, not add ceremony to an immediate action. ## Learning from Shadcn Shadcn's most useful contribution is not a particular border color. It is a way of building a system: - own the top layer of the interface instead of fighting a sealed abstraction; - compose small, predictable parts into product-specific components; - provide defaults that already form one visual language; - keep the code and composition legible to both people and AI; - separate behavior primitives from the styled layer. GPUI Kit applies those ideas through a Rust library and the split between `gpui-base` and `gpui-component`. Applications normally compose or wrap the published components; contributors move genuinely reusable behavior into Base and keep visual policy above it. Do not copy these web assumptions blindly: | Web habit | Native GPUI default | | --- | --- | | Pointing-hand cursor on every button | Default arrow cursor; pointing hand for links | | Page navigation as the main structure | Persistent windows, panes, sidebars, tabs, and menus | | Browser focus and scrolling as a fallback | Explicit focus ownership and region-owned scrolling | | Mobile-first single column | Resizable desktop shell with a defined minimum window size | | Hover-revealed critical actions | Keyboard- and pointer-reachable actions that do not depend on hover | | A row of hover-only icon buttons | A visible primary action plus `DropdownMenu` or `ContextMenu` for secondary commands | | Link-styled text for application commands | `Button`, `outline`, or `ghost`; Link only for URLs, web resources, or email addresses | | Large touch density everywhere | Medium density by default; compact only where information work benefits | | CSS overrides across descendants | Typed builders, semantic parts, and application composition | ## Start from the task Before drawing a screen, write down: - the user's primary task; - the object being viewed or changed; - the actions that must remain immediately available; - the information required to make a decision; - empty, loading, error, offline, read-only, and permission-denied states; - the keyboard path through the workflow. Organize the window around those answers. Do not begin with a dashboard grid or a component catalogue. A good desktop interface exposes the user's mental model: documents, accounts, projects, messages, settings, or another stable object—not the internal service architecture. Design the primary task before distributing controls. Its visual weight, location, and information depth should match its importance to the product. A core result must not be reduced to a small count, an icon in a corner, or a weak footer action while secondary content consumes the page. When a result set is the product's main value, consider a summary region or card that exposes the count, representative results, meaningful state, and a clear next action. For every proposed action, name its visible object, current state, scope, and result. If the interface does not show or clearly imply those things, the action is premature. Do not expose a capability merely because the backend has it; first design how the object enters the user's mental model. ## Visual language ### Hierarchy Prefer a small number of clear levels: - **window or page title** identifies the current object or workspace; - **section title** separates meaningful regions; - **body text** carries the work; - **muted text** provides secondary metadata and help; - **labels** identify controls and values. Use size, weight, spacing, and separators before adding color or containers. Avoid nesting cards inside cards: most desktop regions need only a background, a hairline boundary, and intentional spacing. Evaluate hierarchy across the whole feature, not component by component. Hide accent color and decoration during review: the primary task, current selection, result summary, and next action should still be obvious from structure. A screen that contains individually plausible controls can still fail when they do not form one reading order and one decision path. Treat emphasis as a limited budget. A local surface needs one clear focal point, not a field of competing highlights. If everything is colored, badged, bold, boxed, or promoted to an alert, nothing reads as important. Establish priority with structure and proximity first; spend stronger color and components only where a distinction changes what the user notices or does. ### Color and themes Read colors from `cx.theme()` and use them by semantic role: - `background` and `foreground` for the main surface and text; - `group_box`, `popover`, `sidebar`, and their foreground tokens for their named surfaces; - `muted` and `muted_foreground` for supporting information; - `primary` for the principal action or selection emphasis; - `danger`, `warning`, `success`, and `info` only for their meanings; - `border`, `input`, and focus-ring tokens for structure and interaction. Do not use a semantic status color as decoration. Do not encode meaning by color alone. Verify every custom surface in light and dark themes and with custom theme values; never assume that foreground is black or background is white. Use Badge for a short state, count, or classification that benefits from rapid scanning—not for every label, metadata value, filter, or section title. Keep most badges neutral; reserve semantic variants for states that truly carry success, warning, danger, or informational meaning. A row of multicolored badges is usually a missing hierarchy or grouping decision. Application UI should not contain raw hex, `rgb`/`rgba`, or `hsla` colors. Resolve colors from `cx.theme()` by semantic role. If the required role does not exist, define it in the product's theme/token layer rather than embedding a palette value at the call site. Raw colors belong only inside theme definitions or in audited data/raster content whose color is itself the data. ### Radius, spacing, and density Use a finite radius scale, as with type and control sizes. Choose from the theme's named tiers (`sm`, `md`, `lg`, `xl`, and larger surface tiers) instead of inventing a radius for each element. Large containers may use a softer tier than the controls they contain; compact controls and overlays use tighter tiers. For an ordinary rectangle, the radius should remain visibly smaller than half its shorter side. Use `radius_full()` only for intentional circles and pills; a tooltip must not become a capsule by accident. Nested surfaces should read as concentric shapes. Use `inner radius ≈ max(0, outer radius − gap)` to choose the closest suitable theme tier; the visible band should remain even through the turn. Add a named application token if a repeated composition needs an intermediate value. A segmented control is one silhouette: its end segments follow the container's inner corners, its middle segments remain square, and each boundary has one divider of consistent thickness. Light theme chat composer: the inset surface, both buttons, and placeholder spacing change together; red guide circles show their shared corner center. Dark theme chat composer: the inset surface, both buttons, and placeholder spacing change together; red guide circles show their shared corner center. A radius must govern the entire visible surface, not just its border. Exposed corners remain transparent to the surface behind them; child backgrounds, selection fills, and scrolling content must also follow the boundary. In GPUI, do not assume a rounded parent clips its descendants. Give inner surfaces their own radius or an appropriate clip, then inspect the composition against a contrasting background in both themes. Preserve outward focus rings when choosing a clip. Use a compact spacing scale and repeat it. Related label/control pairs should be closer than separate groups; separate groups should be closer than separate sections. Prefer component sizes (`xsmall`, `small`, default medium, `large`) over one-off heights. Use compact variants for toolbars and data-dense screens, not to squeeze an unclear layout into less space. The shared semantic scale is intentionally small: spacing progresses through roughly 2, 4, 8, 12, 16, 24, and 32 pixels, while typography stays near 12, 14, 16, 18, and 20 pixels. Treat these as relationships rather than permission to scatter their current values through feature code. GPUI Component currently projects a fixed default `SpacingTokens` scale from its global `Theme`; unlike colors and radii, `Theme::apply_semantic_tokens` does not persist a custom spacing scale. An application that needs different spacing must own that full token snapshot and use it consistently in its application components. ### Spatial grammar Spacing expresses relationship. Choose a gap from the semantic scale by asking what the two things mean to each other: | Relationship | Typical token | Current scale | Examples | | --- | --- | --- | --- | | Optical correction | `xxs` | 2 px | icon baseline, compact separator | | Parts of one control | `xs` | 4 px | menu icon/label, title/description | | Closely related controls | `sm` | 8 px | button icon/label, dialog actions | | One content group | `md` | 12 px | notification columns, compact form rows | | Separate groups in one section | `lg` | 16 px | panel padding, form groups | | Separate sections | `xl` | 24 px | major blocks in a page or inspector | | Major region boundary | `xxl` | 32 px | empty-state breathing room, page bands | These values describe the current default scale, not literals to repeat. Use `cx.theme().spacing_tokens()` or the corresponding GPUI scale helpers for the ecosystem default. A product-owned scale should preserve the ordering and relationships and must be passed through the application's own design-system context rather than assumed to persist in the global GPUI Component theme. Use these rules when resolving horizontal and vertical space: 1. **Inside before outside.** A component's padding belongs to the component; the gap between components belongs to their parent. 2. **Vertical rhythm shows grouping.** The gap between a title and its description is smaller than the gap from that description to the next section. Equal gaps imply equal relationships. 3. **Horizontal space supports scanning.** Repeated rows keep icons, labels, values, badges, and trailing actions on stable columns. 4. **Leading and trailing are semantic.** Think in reading-order edges even when the current API uses left/right; this keeps future RTL adaptation possible. 5. **Do not double padding.** A card placed in an already padded panel should not automatically add another full panel inset. 6. **Use optical alignment sparingly.** A one- or two-pixel correction is valid for icon or glyph geometry, but document why it differs from the scale. Common compositions in the current system illustrate the relationships: - button contents use 4 px at small sizes and 8 px at normal sizes; - dialog headers and footers use an 8 px internal gap; - compact list and menu rows use 4 px vertical and 8–12 px horizontal padding; - sheet headers use about 16 px leading and 12 px trailing space, leaving room for a close affordance, while footers use 16 px horizontal and 12 px vertical space; - notifications use 16 px horizontal padding and a 12 px column gap because icon, message, and action are distinct groups. Do not treat these as copy-and-paste recipes for every surface. They reveal the system: controls are tighter internally, rows optimize scanning, and containers spend more space at their boundary than between their contents. ### Proportion and layer hierarchy Start with content requirements, then set proportions. Avoid arbitrary halves when one pane has a clearly different role. - A navigation sidebar should be wide enough for stable labels but visibly subordinate to the work area. Give it a minimum, preferred, and maximum width rather than a percentage alone. - In master–detail layouts, let the collection remain scannable and give the detail pane the surplus. A roughly one-third/two-thirds starting point is often useful, but content constraints are authoritative. - Inspectors and auxiliary sheets should not cover the primary object by default. They should be resizable or dismissible when their content grows. - Dialog width comes from the decision: short confirmation, medium form, or a dedicated window/page for complex work. Do not enlarge a dialog simply to create whitespace. - Reserve the strongest elevation for the topmost decision layer. Within a layer, use background and hairlines—not successively larger shadows—to show hierarchy. Define three size constraints for every major region: the minimum at which its task still works, a comfortable default, and how it consumes surplus. Persist user-controlled splits when they represent workflow preference, and clamp restored values against the current window. ### Alignment details Alignment is a structural system, not a final polish pass. Establish a small set of alignment spines for each surface: shared leading and trailing edges, text baselines, center lines, and fixed functional lanes. Elements at the same level should attach to the same spine from top to bottom or leading to trailing, even when they are different component types. Alignment spines across a desktop surface Alignment spines across a desktop surface Vertical red lines sit beside shared edges or control centers for content, status, time, and trailing actions. Horizontal lines sit beneath text baselines or pass through a row center to show bottom and vertical-center alignment. The compact comparison isolates a one-rendered-pixel drift that must be corrected at its structural owner. - Give sibling regions a shared content inset. A heading, toolbar, list row, empty state, and footer that describe the same level should not each invent a slightly different leading edge. - Repeat column geometry through the whole region. Headers, rows, summaries, loading states, and inline editors should reserve the same lanes for identity, metadata, status, numbers, and actions. - Align related controls across rows and sections. Form labels, fields, descriptions, and validation messages should reveal a stable vertical grid when the page is scanned from top to bottom. - Keep horizontal bands coherent. Items sharing a toolbar, title bar, status bar, or row should use one baseline or center line instead of individually tuned offsets. - Introduce indentation only for real hierarchy, containment, or disclosure. Decorative indentation makes siblings look subordinate and breaks the surface's reading line. - When a nested level ends, return exactly to the parent spine. Do not let accumulated padding drift across nested containers. - Preserve the spine through optional content. Missing icons, badges, descriptions, or trailing actions must not move the remaining labels; use intentional slots or lanes when cross-row comparison matters. - Align major regions with one another where their hierarchy matches. Sidebar headers, content titles, split panes, toolbars, and bottom bars need not share every coordinate, but coincident levels should form visible continuous lines. Not every edge should align. A child can indent, a primary value can lead its supporting metadata, and a destructive decision can gain separation. Such exceptions must communicate hierarchy or meaning; they must not result from uncoordinated component padding. Start with the shared spine, then make the exception explicit. Treat exact alignment and repeated gaps as quality invariants. When two edges or spaces are intended to be equal, a one-rendered-pixel difference is a defect, not an acceptable optical approximation. Inspect resolved bounds with a measurement tool at representative window sizes, zoom levels, and display scale factors. Compare coordinates and distances; do not approve alignment only from a casual screenshot. The rendered-pixel tolerance is a verification rule, not permission to patch the code with raw pixel offsets. Equal relationships should resolve from the same `rem` helper, spacing token, grid definition, or shared component inset. Fix the common owner when they differ. Account for fractional layout and device rounding so intended spines land on the same physical pixel instead of drifting at particular zoom levels. - Align text by baselines, not by bounding-box centers, when mixed sizes share a row. - Center icons in a fixed slot so labels do not move when icons differ in intrinsic width. - Right-align comparable numbers; left-align prose and identifiers unless the locale requires otherwise. - Keep trailing row actions and disclosure indicators in fixed-width lanes. - Align form controls by their interactive frame, not by help text below them. - Use `justify_between` only when the two sides truly own opposite edges; it should not disguise missing structure in the middle. - Hairlines belong on the boundary owner. Two adjacent regions must not each draw the same separator. - A scrollbar belongs to the region that scrolls and sits against that panel, editor, or window's trailing edge. Content padding may inset text and rows; it must not pull the scrollbar into the middle of the surface. Reserve a deliberate scrollbar gutter when content needs clearance. ### Density tiers Medium is the ecosystem default. Change density for the whole local context, not one isolated control: - **comfortable / large:** onboarding, sparse forms, prominent decisions; - **standard / medium:** most application chrome and workflows; - **compact / small:** toolbars, menus, tables, and repeated professional data; - **extra compact / xsmall:** exceptional high-density utilities, never the automatic choice for an entire application. The current controls demonstrate a bounded scale rather than arbitrary sizing: buttons commonly move through approximately 20, 24, and 32 px frames; input and data controls may extend to about 44 px at large size; table rows use about 26, 30, 32, and 40 px. Use the component's `Size` API so typography, icon, padding, and hit target change together. A custom height that changes only the outer box is usually incomplete. ### Zoom, base font, and `rem` A well-designed `rem` system preserves hierarchy while the interface zooms. Zoom is successful when the relationship between title and body, control and icon, inner and outer spacing, primary and secondary regions still feels the same at every scale—not merely when every object becomes larger. GPUI Component adopts the relative-scale idea familiar from Tailwind CSS. The theme's base `font_size` becomes the window's `rem` through `Root`, and GPUI scale helpers such as `text_sm()`, `gap_2()`, `p_4()`, `h_8()`, and `size_4()` resolve against it. This gives typography, spacing, controls, and icons one shared zoom axis. Design in ratios: - type steps keep the same hierarchy around the base body size; - spacing steps keep the same grouping relationships around the type; - control frames, icons, and hit targets scale with their labels; - pane minima and comfortable widths account for the scaled content; - corner radii and focus treatment remain optically consistent with the control frame. Do not implement zoom by changing text size alone. A larger label inside a fixed-height button, a larger document inside fixed pane minima, or larger rows inside a stale virtual-list measurement destroys the original rhythm and can clip content. Conversely, multiplying every physical pixel—including hairlines—can make the interface visually heavy. As a rule, application layout should not call `px(...)` directly. Use GPUI's rem-based scale helpers (`p_2`, `gap_3`, `w_64`, `text_sm`, and related builders) or semantic component sizes. Use fixed pixels only when the value represents a physical or raster boundary: a one-device-pixel hairline, platform window inset, bitmap dimension, minimum hit-test tolerance, or geometry that must match an external surface. These are audited, documented exceptions. Product spacing, typography, icon size, and ordinary control geometry stay on the relative scale. Test interface zoom at several base-font values, not just the default. Verify hierarchy, wrapping, truncation, minimum window size, pane resizing, focus-ring clearance, popup placement, and virtualized row measurement. Also distinguish interface zoom from Dock's panel zoom: Dock zoom makes one container fill its area while retaining its chrome; it does not change `rem` or application scale. ### Surfaces and elevation Use elevation to explain stacking, not importance. The base window surface is flat; separators and background contrast define its regions. Popovers, menus, dialogs, and notifications may use progressively stronger shadows because they sit above other content. Do not put a shadow on every card. All surfaces of the same kind should share one treatment. GPUI Component, for example, deliberately gives popup families one themed popover surface so Popover, Select, Combobox, DatePicker, and menus do not drift apart. When an application invents another anchored surface, reuse that semantic treatment instead of approximating it with unrelated border and shadow literals. ### Typography and icons Use the platform UI font for interface text and monospace only for code, identifiers, shortcuts, and aligned numeric data. Keep body text readable and avoid excessive uppercase or letter spacing, especially for CJK text. Use one icon family in a product. Icons supplement labels; they should not replace unfamiliar actions with guesswork. Icon-only buttons always need an accessible name. Their visual explanation follows the tooltip policy below. Use filled or colored icons to communicate a state, not merely to make a toolbar lively. ## Layout patterns ### Choose a stable shell Most applications should use one of these shells: - **single workspace:** toolbar or title bar above one primary view; - **sidebar workspace:** persistent navigation beside a changing detail view; - **master–detail:** resizable collection and detail panes; - **document workspace:** tabs or a dock area for multiple long-lived objects; - **utility window:** one focused task with a short, fixed action path. Keep global navigation stable while content changes. Give the primary work area the remaining space with `flex_1()` and `min_w_0()` / `min_h_0()` where overflowing children must shrink. Use `Scrollable`, `VirtualList`, `Table`, or `DockArea` for their intended behavior instead of rebuilding scrolling or pane management from nested `div`s. The title bar is window chrome first. Preserve its drag region and avoid binding ordinary title clicks to infrequent editing commands. Rename belongs behind an explicit object command, with a keyboard path where appropriate. ### Responsive desktop windows Desktop does not mean fixed-size. Decide what happens as a window narrows: 1. preserve the primary task; 2. allow resizable regions to reach a documented minimum; 3. collapse secondary labels or inspectors; 4. move low-frequency actions into a menu; 5. scroll only the region whose content actually overflows. Do not hide an action without providing another path to it. Avoid making the entire window scroll when only a list or document body should scroll. GPUI flex layouts have the same intrinsic-size pressure found in other layout systems: a `flex_1()` child may still refuse to shrink around long content. Design and implementation must agree on which panes may shrink, truncate, wrap, or scroll. A clipped region also clips an outward focus ring; never trade away keyboard visibility merely to simplify overflow. ### Forms and settings Use a visible label for each field and place help or validation next to the field it describes. Align related fields, but do not force long labels into a narrow fixed column. Use the appropriate control: `Checkbox` for independent choices, `RadioGroup` for a small visible set, `Select` for a longer set, and `Switch` for a setting that takes effect immediately. Disable submission while an operation is in flight, keep the user's input, and show the result near the action. Reserve dialogs for short, focused decisions; use a full page or sheet for workflows that need exploration or many fields. A loading treatment must represent actual waiting. Switching between settings panels whose content is already available should be immediate; a skeleton is appropriate only while content is genuinely loading and its shape is known. ## Components and composition Follow the Shadcn principle that components are building material rather than a sealed design system. GPUI Component supplies coherent defaults, while the application owns composition and product semantics. - Use component variants by meaning. Primary is reserved for the explicit default commit in a decision area—normally the action invoked by Enter. A lone, frequent, or desirable action is not automatically primary. An `Add` command in a management toolbar normally uses a default Button; a form's default `Create` commit may use primary. Use `danger` for destructive commitment and `ghost` for quiet toolbar actions. - Prefer explicit compound parts and render callbacks over styling arbitrary descendants. - Keep a repeated pattern consistent across the product. Wrap it in an application component when it carries domain language or policy. - Use the standard component for its semantic role. A menu, dropdown menu, popover, select, and command palette are not interchangeable boxes; each owns different selection, focus, keyboard, dismissal, and layout contracts. - Preserve the component family's geometry. Menu rows share vertical and horizontal padding, height, icon and checkmark slots, separators, radius, and state treatment. Do not imitate one menu with a custom popup whose spacing only approximates the system. - Do not wrap a library component merely to rename every method or freeze all of its capabilities. - Move reusable behavior without product styling to `gpui-base`; keep themed, opinionated presentation in GPUI Component or the application. ## Interaction states ### Make the result understandable before the click A control should predict its result. Use familiar desktop controls and placement so people can act without learning the interface first. Its label names the action and object, its state shows availability, and its feedback confirms the same outcome. Do not label a Button `Save` if it opens a configuration flow, or `Delete` if it only removes an item from a group. Name the scope when context does not make it clear. Respond immediately to activation, prevent duplicate submission during longer work, and show the result near the object that changed. Add a success message only when the result itself is not visible. Every interactive control should be designed for: | State | Design requirement | | --- | --- | | Rest | Clear affordance without visual noise | | Hover | Subtle pointer feedback, never the only cue | | Pressed | Immediate press feedback | | Open / pressed | Persistent feedback while an attached popup is open | | Focus visible | High-contrast keyboard focus ring | | Selected / checked | Persistent state distinct from hover | | Disabled | Lower emphasis and no misleading hover/pressed response | | Loading | Preserve context, prevent duplicate action, explain long waits | | Error | State what happened and how to recover | Use GPUI's focus system and Actions for commands that should work from the keyboard. Match familiar desktop shortcuts, expose shortcuts in menus or tooltips, and keep focus in a logical place after opening or dismissing an overlay. Selection is part of the information model, not optional polish. Tabs, segmented choices, selectable rows, filters, and navigation destinations must show a persistent selected state. A Button that owns a dropdown must remain visibly pressed or open until the popup closes; hover alone cannot explain the relationship between trigger and surface. In a segmented control, the selected fill and unselected surface must share the container's silhouette. Neither may square off an end corner or make a boundary appear heavier than its peers. Show a selected navigation item, list row, or tab through the item's own surface: a selected fill, stronger foreground, or heavier weight. Do not add a leading-edge bar or one-sided border as the selection marker. It is a web template habit, not a desktop convention; it breaks the item's rounded silhouette and adds a second, competing edge to a column that already aligns on its text. For destructive actions, distinguish between reversible and irreversible work. Prefer undo or a temporary notification for reversible changes. Use an `AlertDialog` when the consequence is serious and cannot be undone; name the specific object and consequence in the confirmation copy. ### Pointer conventions Use the default arrow cursor for buttons, checkboxes, menu items, tabs, and other native controls. Use a pointing hand for links and content that behaves as a link. Use text, resize, grab, and prohibited cursors only when they describe the active manipulation. A cursor reinforces an affordance; it does not replace the control's visible state or accessible role. Keep hover effects modest because keyboard and accessibility interaction has no hover. Do not reveal the only copy of a destructive or essential action on hover. Contextual row actions may become quieter at rest if the same commands remain available through selection, keyboard, or a context menu. ### Prefer desktop command surfaces over hover toolbars Use command frequency and scope to choose where an action lives: - keep the primary or frequent action visible as a labeled Button or familiar toolbar control; - put secondary actions for the current region behind a visible `DropdownMenu` trigger; - put commands that act on the object under the pointer in a `ContextMenu`; - expose the same important command through an [Action](./action)/[key binding](./keybinding) when it has a natural keyboard form; - use a hover-revealed icon only as a shortcut to a command that remains reachable elsewhere. This is more than a visual preference. GPUI Component's menu system already owns directional keyboard navigation, confirmation and cancellation, disabled items, separators, submenus, shortcut presentation, focus transfer and restoration, and nested-menu dismissal. A custom strip of hover buttons must rebuild those behaviors and is invisible to keyboard-only and many assistive technology workflows. Choose `DropdownMenu` when users need a visible indication that more commands exist—for example a toolbar overflow, document actions, or account menu. Choose `ContextMenu` for selection- or object-scoped commands such as rename, duplicate, reveal, or remove. The context menu must not be the only way to perform an essential command; provide a menu-bar, toolbar, keyboard, or detail view path as appropriate. Do not put every action into a menu to make a screen look minimal. Discovery and speed matter: the main action stays visible, dangerous items remain clearly labeled and separated, and a menu item should use the same verb, icon, shortcut, enabled state, and result everywhere it appears. ### Button means application action; Link means external resource Use a Button when activation changes application state, confirms a decision, opens a tool, submits data, or runs a command. Choose its treatment by local hierarchy: - primary Button for the one emphasized commitment in a decision area; - default Button for ordinary visible actions; - outline Button when an action needs a clear boundary with less emphasis; - ghost Button for familiar, low-emphasis toolbar and inline actions; - icon Button only for a well-known symbol, with an accessible name and a tooltip when meaning or scope is unclear. Do not assign primary because a Button is the only action on screen, because it is placed at the top right, or because the team wants more clicks. Primary communicates default commitment and keyboard behavior. If activation is merely an ordinary command such as adding an item, opening a tool, or refreshing a view, use a default, outline, or ghost Button according to its local hierarchy. Use an underlined Link only for an external resource target: a URL, web page, online documentation, or email address. It uses the pointing-hand cursor because its contract is leaving the current application context for that resource. Do not use Link styling to make a functional command look quiet. A link-shaped Delete, Save, Refresh, Add, Open-menu, or in-app navigation action hides the control's affordance and exposes the wrong accessibility role. “View” does not make an in-app destination a Link. A full report, analysis, details panel, or local record still opens through a Button, row, card, tab, or disclosure control. Use concise context-aware labels such as `Full analysis` when the containing card already establishes what opens; reserve underlining for a resource that actually opens in a browser or mail client. All internal navigation—sidebar rows, tabs, breadcrumbs, list items, opening a local view, or switching workspaces—must use the corresponding native component or a Button/Action. Visual emphasis is chosen through Button variant or the navigation component's selected state, never by lying about semantics. ## Feedback and overlays Choose the smallest surface that fits the decision: - tooltip: a short explanation or shortcut; - popover: contextual controls that do not interrupt the task; - menu: a compact list of actions; - notification: asynchronous status that does not require a decision; - dialog: a focused decision or short form; - alert dialog: explicit confirmation of a consequential action; - sheet: supplementary work that benefits from more persistent space. A tooltip supplies missing meaning; it does not restate an obvious action. Reserve it for an unfamiliar symbol, ambiguous scope, or a useful shortcut that is otherwise hidden. Repeated, recognizable commands such as Copy and Download in a message footer need accessible names and keyboard access, but their hover labels add interruption rather than clarity. An Alert interrupts the visual hierarchy even when it does not open a modal. Use it for important, exceptional information that needs attention in the current task, not as a decorated container for ordinary descriptions, tips, or empty space. Prefer inline help, muted text, or a normal section when the content does not require immediate notice or action. Avoid stacking overlays. Escape should dismiss the topmost dismissible layer, and focus should return to the trigger or the next logical target. An overlay action must refer to an object or state the overlay actually shows. For example, expose `Clear history` only when a distinct recent-history section is visible and contains entries. Search results, recent items, and favorites are different collections; label and separate them instead of merging them into one unexplained list. Hide an inapplicable action or disable it with a useful reason—do not park an ambiguous trash icon in a footer. Footer space is not a catch-all for capabilities that lacked a place in the design. A footer may present shortcuts, status, or actions that apply to the whole surface, but each item must answer: what is its object, why is it available now, what scope does it affect, and what visible state changes after activation? ## Motion Motion explains change; it is not ambient decoration. Use short transitions for appearance, dismissal, expansion, and spatial continuity. Avoid animating large layout changes when opacity or transform communicates the same relationship. Honor reduced-motion preferences, never require animation to understand state, and do not add a default animation to every component. Motion policy belongs to the styled or application layer. Base may own the lifecycle mechanism or geometry needed for a transition, but it should not decide that every product fades or slides. Give independently animated values stable identity, and make interruption reverse smoothly from the currently sampled value rather than restarting from an old endpoint. Motion follows the continuity of the interaction. While a pointer or scroll position moves among related targets, keep the preview surface stable and update its content or position in place. Dismiss it only after the interaction ends; repeated fade-out and re-entry on each update reads as flicker. ## Designing data-heavy interfaces Dense does not mean cramped. In tables, trees, command palettes, editors, and docks: - keep headers and primary row identity visually stable; - align comparable values and use tabular numerals where appropriate; - distinguish focus, hover, active row, and multi-selection; - keep sorting and filtering visible and reversible; - preserve selection by domain identity across filtering and reordering; - virtualize large collections without changing keyboard semantics; - use progressive disclosure for secondary columns and inspectors; - provide a useful empty state that explains the next action. Choose a table for comparison across consistent fields, a list for scanning heterogeneous items, a tree for real hierarchy, and a dock only when users need to arrange long-lived tools or documents. Do not use a complex data component as a visual style. ## Interface language Words are part of the interface architecture. Write the vocabulary for a feature as a system—destinations, objects, commands, states, and outcomes—not as isolated translations of implementation features. Prefer the shortest wording that remains accurate in its actual context. ### Let context carry context Do not repeat information that the surrounding surface already establishes. A sidebar destination is usually the object or domain itself: use `Users`, not `User Management`; `Shortcuts`, not `Shortcut Configuration Management`. A column whose rows already contain actions can omit a generic `Operation` heading. A dialog titled `Delete “Roadmap”?` does not need body text that asks the same question again. This is context economy, not deletion for its own sake. Add text when it changes the decision: identify the affected scope, an irreversible consequence, an unusual prerequisite, or a way to recover. Every extra word should answer a question the current layout does not already answer. Use nouns for destinations and objects (`Users`, `Appearance`, `Orders`), verbs for commands (`Save`, `Duplicate`, `Export`), and adjectives or short phrases for states (`Offline`, `Up to date`, `Pending review`). Avoid wrappers such as `Management`, `Module`, `Page`, `Function`, `Operation`, and `System` unless the word distinguishes a real domain concept. ### Write each language, do not translate its shape Start from shared intent, hierarchy, and terminology, then compose each locale as natural interface language. Do not preserve the source language's word order, number of words, politeness filler, or grammatical category. English `Users` can express a Chinese feature concept that would literally expand to “user management”; fidelity means preserving purpose, not preserving tokens. Remove words supplied by the enclosing information architecture. Inside a `Settings` surface, a destination is often simply `Account`, not `Account Settings` and never the unnatural singular `Account Setting`. The correct English label is chosen from its role and neighbors, not from the standalone source phrase. Maintain a small product lexicon for recurring objects, commands, and states. Use the same term in the toolbar, menu, context menu, dialog, shortcut search, and documentation unless the context genuinely changes its meaning. Review copy in the rendered surface: neighboring labels often reveal repetition or inconsistent scope that a locale file cannot. In localized technical writing, preserve an established framework term when a translation would be less precise. Keep API identifiers in their original form and format them as code. Do not retain ordinary foreign words merely to sound technical. Explain a retained term on first use when needed, then use the same form throughout the interface, documentation, and API examples. In documentation, use the exact English name for a named API, component, source file, or guide in link text, including localized pages. For example, write `Font`, `Render`, `Input element`, and `Plot label` rather than translating their names or adding a redundant `GPUI Kit` prefix. Keep surrounding explanations in the page's language. Link only the named target; place words such as “source” outside the link when they describe why it is cited. In a technical comparison table shared across locales, keep capability names, framework names, API names, and model names in their established English form. Translate the explanation around the table, not its technical labels. Common interface words with an established local name may still be localized outside that comparison context. ### Buttons and confirmation dialogs Button labels are short by default—usually one or two words—and describe the result, not the gesture or the component. Prefer `Save`, `Move`, or `Delete` to `Click to save`, `Perform move`, or `Confirm deletion`. Use `Cancel` consistently for the action that leaves without committing. Reserve `OK` for acknowledging purely informational content. Short is a default, not a character limit. A deliberately longer label is better when its words expose a consequence or distinguish choices that users could otherwise confuse, for example `Delete from this group` versus `Delete everywhere`, or `Restart without saving`. Length must buy decision-critical information; it must not restate the dialog title or body. Use the most specific concise result as the confirmation label when possible: | Context | Weak | Prefer | | --- | --- | --- | | Delete dialog | `Yes`, `Sure`, `Confirm deletion` | `Delete` | | Unsaved changes | `Confirm`, `Yes` | `Discard changes` | | Pure acknowledgement | `Confirm operation` | `OK` or `Done` | | Complex consent whose result has no clear verb | `Yes` | `Confirm` | `Confirm` is a useful fallback when the surrounding dialog fully names a complex commitment and no shorter result verb is accurate. It should not replace a clear command. `Sure` is conversational rather than a stable English command and is too ambiguous for the standard vocabulary. A confirmation dialog should form one compact decision: - title: the decision or condition, such as `Delete “Roadmap”?`; - body: only new scope, consequence, or recovery information; - actions: `Cancel` and the result, such as `Delete`; - destructive styling: applied to the destructive result, not substituted for precise wording. Avoid generic titles such as `Notice`, `Warning`, `Error`, and `Confirmation` when the actual condition can be named. Avoid ritual phrases such as “Are you sure you want to…”, “Would you like to…”, “Please note that…”, and “successfully” when the structure or state already communicates them. Courtesy should come from a calm, respectful tone, not repeated `please`. ### Capitalization, punctuation, and symbols Use sentence case for English UI by default: `Reset layout`, not `Reset Layout` or `RESET LAYOUT`. Preserve proper nouns and established acronyms. Follow a platform convention such as title case for native menu commands only when the platform integration benefits from it, and apply that convention consistently within the component class. ALL CAPS can provide restrained typographic emphasis for very short section labels, eyebrows, statuses, established acronyms, and code-like identifiers. Its compact shape and measured tracking can form a level similar to bold type, but it does not belong on Buttons, long headings, sentences, or dense lists. Do not combine uppercase, strong color, and bold weight in the same region, and do not transform every string automatically: product names, acronyms, and localized content must preserve their intended casing. Labels, buttons, menu items, tabs, headings, placeholders, and short states do not take a final period. Complete explanatory, warning, and error sentences do. Avoid exclamation marks in routine success and failure messages. In Chinese, use full-width punctuation in sentences and omit terminal punctuation from short control labels by the same semantic rule. Use the single ellipsis character (`…`), not three periods. Append it to every Button or MenuItem that opens a dialog, sheet, or separate window, and to a command that requires more input or choices before it can complete, such as `Settings…` or `Export…`. An immediately executed command does not take an ellipsis. Use an indeterminate progress indicator, not decorative dots, to communicate ongoing work. Errors should say what happened and, when useful, the next recovery action. Success feedback should name the resulting state only when that state is not already visible. Prefer `Couldn’t save. Check your connection and try again.` to a technical code or a long apology; omit a `Saved successfully` toast when the document visibly becomes saved. ## Internationalization and platform fit Copy must survive expansion, CJK typography, and different shortcut notation. Do not size a control from one English label. Keep text out of raster assets, avoid concatenating translated fragments, and let labels wrap or truncate only where the product defines a recovery path such as a tooltip. Respect platform differences that carry meaning: Command versus Control, native window decorations, system appearance, scrollbar behavior, menus, and notification capabilities. Keep the product's information architecture stable across platforms, but do not erase familiar platform behavior for superficial pixel equality. ## Guidance for AI-generated interfaces An AI changing a GPUI interface should first inspect the nearest feature, theme tokens, and component documentation. It should state the primary task, state owner, component composition, and keyboard path before generating code. It must not infer an API from React/Shadcn examples or invent a GPUI method because the name seems plausible. AI output is incomplete until a human can explain why the hierarchy, density, component choice, and exceptional literal values belong in this product. A visually plausible screenshot is not proof: keyboard behavior, focus, dynamic content, themes, resizing, and failure states are part of the design. ## Accessibility checklist Before considering a screen complete, verify that: - every action is reachable and operable by keyboard; - focus order follows visual and task order; - focus remains visible and is restored after overlays; - controls have accessible names; icon-only controls use tooltips when their meaning, scope, or shortcut needs explanation; - text and meaningful boundaries have sufficient contrast; - status is not communicated by color alone; - disabled and read-only states are distinguishable; - labels, errors, and descriptions remain near their controls; - content remains usable with longer translations and larger text; - pointer targets are comfortably sized even in a dense layout. ## Design review checklist A review does not inventory components; it judges whether the interface made the right decisions. Ask, in order: 1. **Is the task clear?** Can a new user recognize the purpose, primary action, and next step without learning, guessing, or experimenting? 2. **Does every action keep its promise?** Do the label, control, state, scope, feedback, and result describe one consistent outcome? 3. **Is hierarchy decisive and restrained?** Does the core feature receive the space it deserves while strong color, bold type, badges, alerts, and primary Buttons remain scarce? 4. **Could the interface do less, better?** Can an entry point, option, or state be removed, combined, or deferred without weakening the complete task? 5. **Is the structure exact?** Do peers share alignment spines, equal gaps stay equal to the rendered pixel, and scrollbars sit at the edge of their actual scrolling region? 6. **Does it follow the component system?** Do standard controls retain their geometry, states, keyboard behavior, and dismissal model, with appearance supplied by theme and scale tokens? 7. **Does it remain usable in every state and constraint?** Verify keyboard and focus behavior, empty/loading/failure/permission states, longer translations, zoom, minimum window size, and reduced motion. 8. **Has it been tested in a real window?** Complete the task with real components, copy, and representative content—not only an ideal screenshot. Continue with [Coding Guides](/versions/v0.7.1/docs/coding-guides) to translate these design decisions into GPUI architecture and code. --- # Installation Source: /versions/v0.7.1/docs/installation License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/installation and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Install the native toolchain for your operating system, then add the `gpui-kit` crate to a Rust application. The steps below are for desktop development; [WebAssembly](./webassembly) and [Mobile](./mobile) have separate target setup. ## Platform requirements
  • macOS 15 or later
  • Xcode Command Line Tools, installed with xcode-select --install. Run xcode-select -p afterward; it should print the selected developer directory.
  • Windows 10 or later
  • Visual Studio 2022 Build Tools or Community with the Desktop development with C++ workload, including MSVC and a Windows SDK. Use the MSVC Rust toolchain, not the GNU toolchain.
  • CMake available on PATH; check with cmake --version in the terminal where you will run Cargo.

The following packages are verified on Ubuntu 24.04:

sudo apt update
sudo apt install -y gcc g++ clang libfontconfig-dev libwayland-dev \
  libwebkit2gtk-4.1-dev libxkbcommon-x11-dev libx11-xcb-dev \
  libssl-dev libzstd-dev libasound2-dev vulkan-validationlayers libvulkan1

This matches the repository's script/install-linux.sh for Ubuntu 24.04. Other distributions need equivalent development packages. libasound2-dev (ALSA) is needed only by the speech feature. To display a window, run in a graphical Wayland or X11 session with a working Vulkan driver; installing libvulkan1 alone does not install a GPU driver.

## Rust and Cargo Install Rust and Cargo with the [official Rust installer](https://rust-lang.org/tools/install/). Use Rust 1.92 or later for this repository's current locked dependency graph: its Linux GPUI platform dependency includes `oo7 0.6.0`, which declares Rust 1.92 as its minimum. Then check the tools in the same terminal that will build the app: ```sh rustc --version cargo --version ``` Both commands should print a version. On Windows, rustup show active-toolchain should identify an msvc target. If a terminal cannot find Cargo immediately after installation, open a new terminal and run the checks again. Add GPUI Kit to the application's `Cargo.toml` under `[dependencies]`: ```toml gpui-kit = "0.7.1" ``` The `0.7.1` requirement selects a compatible Kit release. Kit's default features include the styled components and default icon assets. It brings in matching GPUI crates, so an application using this setup does not need to list GPUI separately. `use gpui_kit::*;` imports GPUI's re-exported API; the layers are reachable as `gpui_kit::component`, `gpui_kit::base`, `gpui_kit::assets`, and `gpui_kit::platform`. ### Why the dependency is named `gpui-pre` Throughout these docs, **GPUI** means [Zed's GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui). `gpui-pre` is the crates.io package name used to publish a snapshot of GPUI from a recorded Zed commit, alongside its related GPUI crates. It provides a reproducible publication and version alignment path for GPUI Kit; it is not another rendering implementation. The publication process adjusts package names and dependency manifests for crates.io, so API references in this manual target the GPUI version pinned by this Kit release. In this repository that is `gpui-pre = 0.3.8`; application code normally depends only on `gpui-kit` and imports GPUI through `gpui_kit::*`. A newer `gpui-pre` snapshot does not by itself mean that the current GPUI Kit release supports it. ## Verify the installation If you checked out this repository, run its existing example from the repository root: ```sh cargo run -p hello_world ``` The first build downloads and compiles dependencies and may take several minutes. Once it starts, a window shows “Hello, World!” and a “Let's Go!” button. Clicking the button prints `Clicked!` in the terminal. Close the window to end the process. This checks the build toolchain, window creation, rendering, and input on your machine; it is not a performance benchmark. For a new project instead, follow [Getting Started](./getting-started). It creates an application with the same single dependency and explains each part of the first [View](./render) and [Window](./window). ## Troubleshooting | Symptom | Check | | --- | --- | | `cargo` or `rustc` is not found. | Install Rust with rustup, open a new terminal, and rerun the version checks above. | | A build reports an unsupported Rust version. | Check `rustc --version`; update the active Rust toolchain. A dependency may require a newer compiler than this guide's baseline. | | Windows reports `link.exe` missing or cannot find a Windows SDK. | Confirm the Visual Studio C++ workload and SDK are installed, then build with an MSVC Rust toolchain from a Visual Studio Developer PowerShell if needed. | | Linux reports a missing `pkg-config` executable, X11, Wayland, fontconfig, or WebKit header. | Install the Ubuntu packages above, or their equivalents for your distribution. If `pkg-config` itself is missing, install the `pkg-config` package too. The error identifies the missing tool or system library. | | The program compiles but no window appears on Linux. | Check that the process is running in a graphical Wayland or X11 session and that a Vulkan driver works for that session. A headless shell or Vulkan loader without a driver is insufficient. | | A window opens on macOS, but no text is rendered. | If you depend on GPUI directly, enable `font-kit` on `gpui-pre-platform` and rebuild. `gpui-kit` already enables it. See [Getting Started](/versions/v0.7.1/docs/getting-started#macos-text-rendering-font-kit) for the manifest and feature check. | | Cargo cannot resolve `gpui-pre` or APIs differ from these examples. | Keep the `gpui-kit` requirement and update dependencies together. Kit pins a matching `gpui-pre-*` snapshot; do not override one GPUI package to a different version. In a repository checkout, use the checked-in `Cargo.lock`. | For errors after a window opens, continue with [Getting Started](./getting-started) and inspect the relevant guide for the feature you are using. ## Improve development runtime performance Rust Debug builds leave GPUI, the component library, layout, and text rendering largely unoptimized. As a result, an application started with `cargo run` can render and respond much more slowly than its release build. The profile below optimizes those framework dependencies while your application code remains in Debug mode and keeps its normal debugging workflow. This setting does **not** make compilation faster. Compiling the optimized dependencies can take longer, especially on the first build; the benefit is better runtime performance while developing and running the application. Cargo's [package profile overrides](https://doc.rust-lang.org/cargo/reference/profiles.html#overrides) only take effect in the root `Cargo.toml` of your application or workspace: ```toml [profile.dev.package] gpui-pre = { opt-level = 3 } gpui-component = { opt-level = 3 } gpui-kit = { opt-level = 3 } gpui-kit-assets = { opt-level = 3 } gpui-pre-macros = { opt-level = 3 } gpui-pre-platform = { opt-level = 3 } rustybuzz = { opt-level = 3 } taffy = { opt-level = 3 } ttf-parser = { opt-level = 3 } ``` --- # Multi Window Source: /versions/v0.7.1/docs/multi-window License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/multi-window and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A GPUI application can own several windows. Each window has its own `Window` context, focus, input dispatch, geometry, and GPUI Kit `Root`. Application data can be shared across them. Read [Window](./window) for the single-window API; this guide builds a second window and explains what each window owns. ## Start from an empty project Install the platform requirements in [Installation](/versions/v0.7.1/docs/installation), then create an application: ```sh cargo new gpui-multi-window cd gpui-multi-window ``` In `Cargo.toml`, add the same single dependency used by [Getting Started](/versions/v0.7.1/docs/getting-started): ```toml [dependencies] gpui-kit = "0.7.1" ``` Replace `src/main.rs` with the complete example below. If you already have a single-window application, keep its `application().run(...)` and `init(cx)` calls; create the shared model once in that startup closure, then call `open_window` a second time with a new content view. The loop below performs both calls explicitly, once for each name. ## Open two windows over one model Call `gpui_kit::init` once before either window. Each call to `gpui_kit::open_window` creates a window and wraps the returned view in its own Base `Root`. The function returns `(AnyWindowHandle, Entity)`, where `V` is the application view passed to the builder. Return the content Entity from the builder; do not wrap it in another `Root`. The `Root` supplies window-level overlays, menus, notifications, and focus coordination. This example shares one counter Entity but creates a separate `Workspace` Entity for each window. The observer makes changes to the shared Entity visible in both windows; `local_clicks` remains independent. ```text Application SharedCounter (one Entity) ├── First window → Root → Workspace (first Entity, first observer) └── Second window → Root → Workspace (second Entity, second observer) ``` Each `Workspace` holds a clone of the same Entity handle. Cloning an `Entity` handle does not copy its `T` value. Both observers watch the same model, but each observer belongs to one window's content view. ```rust use gpui_kit::component::button::Button; use gpui_kit::*; struct SharedCounter { count: usize, } struct Workspace { shared: Entity, _shared_observer: Subscription, local_clicks: usize, name: &'static str, } impl Workspace { fn new(shared: Entity, name: &'static str, cx: &mut Context) -> Self { let _shared_observer = cx.observe(&shared, |_, _, cx| cx.notify()); Self { shared, _shared_observer, local_clicks: 0, name } } } impl Render for Workspace { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let count = self.shared.read(cx).count; div() .flex() .flex_col() .gap_2() .p_4() .child(format!("{}: shared count {count}", self.name)) .child(format!("Clicks in this window: {}", self.local_clicks)) .child( Button::new("increment") .label("Increment") .on_click(cx.listener(|this, _, _, cx| { this.local_clicks += 1; this.shared.update(cx, |shared, cx| { shared.count += 1; cx.notify(); }); cx.notify(); })), ) } } fn main() { application().with_assets(assets::Assets).run(|cx| { init(cx); let shared = cx.new(|_| SharedCounter { count: 0 }); for name in ["First window", "Second window"] { let shared = shared.clone(); open_window(WindowOptions::default(), cx, move |_, cx| { cx.new(|cx| Workspace::new(shared, name, cx)) }) .expect("open workspace window"); } }); } ``` Run `cargo run` in the new project. Two windows should open. Click **Increment** in either window: the shared count changes in both, while each window's click count changes only when its own button is clicked. Close one window and use the other; its count and button remain available. The `SharedCounter` was created once, outside the loop, while `Workspace::new` runs once per window. Use this sequence to check which state changed: | Action | First window | Second window | | --- | --- | --- | | Start | Shared 0; local 0 | Shared 0; local 0 | | Click **Increment** in the first window | Shared 1; local 1 | Shared 1; local 0 | | Click **Increment** in the second window | Shared 2; local 1 | Shared 2; local 1 | | Close the first window, then click in the second | Closed | Shared 3; local 2 | Trace the first click through the code: the button listener increments the first `Workspace.local_clicks`; `shared.update` mutates the one `SharedCounter` and calls its `cx.notify()`; both stored observers receive that notification and call `cx.notify()` for their own views. Their next renders read the same count. The first view also calls `cx.notify()` after changing its local count. Reading `self.shared.read(cx)` in `render` supplies a value for that render; the observer is the connection that schedules later renders when the model changes. To open a third independent workspace over the same counter, add `"Third window"` to the `for name in [...]` array and rerun. All three shared labels should advance after one click, while only the clicked window's local label advances. If you instead create `SharedCounter` inside the loop, each window gets its own model and this observable behavior changes. Keep document or session data in a feature-owned Entity shared by the windows that need it. Keep window selection, focus handles, overlay state, and window-scoped tasks in that window's view. Use an application [Global](./global) for genuinely app-wide settings or services, not as a catch-all owner for every window's UI state. A shared Entity notification reaches only views that observe it; reading shared state in `render` alone does not subscribe that view to changes. Store the returned `Subscription` on the observing view: dropping it would stop updates, and retaining it in an application owner would outlive the window unnecessarily. ## Address the intended window `gpui_kit::open_window` returns an `AnyWindowHandle` because its actual root view is GPUI Kit's `Root`, not the content view. Keep the returned content `Entity` when you need its state. Keep the window handle when later work needs that window's focus, Action dispatch, activation, or geometry. `window.window_handle()` obtains the current window's handle inside a callback. The startup example discards both return values because its buttons act on their own views; a document switcher or window registry would retain them. An `AnyWindowHandle` exposes `window_id()` and `update(...)`. `cx.windows()` enumerates open handles; `cx.active_window()` returns the platform-focused one when available. If you already know the target window, retain its handle instead of choosing one by iteration order. A handle can also be downcast to `WindowHandle` when a typed root handle is required; downcasting it to `WindowHandle` fails because `Workspace` is content inside `Root`. ```rust // `target` is the AnyWindowHandle returned by open_window. target.update(cx, |_, window, _cx| { window.activate_window(); window.set_window_title("Document"); })?; ``` The `update` result must be handled because the target may have closed. Focus and Action dispatch run in the selected window's context. For an Entity update that also needs its window, use `cx.update_window(target, |_, window, cx| { ... })` and update the content Entity inside that callback. For an async task bound to one window, use [`cx.spawn_in`](./task) and handle a failed `update_in` after the window or Entity disappears. ## Close and clean up `window.remove_window()` requests removal of the current window; it does not express a whole-app quit policy. Register `window.on_window_should_close(cx, |window, cx| { ... })` on each window that may need to cancel a platform close request; return `false` to prevent that close. A direct `remove_window()` call is an application decision to remove the window, so perform any confirmation before calling it. Capture or persist window-specific data before closing: `cx.on_window_closed(...)` runs after the `Window` is inaccessible and receives its `WindowId` for registry cleanup. For a desktop app that should quit when the last window closes: ```rust cx.on_window_closed(|cx, _closed_id| { if cx.windows().is_empty() { cx.quit(); } }) .detach(); // Deliberately keep this app-wide observer for the app lifetime. ``` Alternatively, retain the returned `Subscription` in an application owner and drop it with that owner. The basic example needs no close observer: closing either window leaves the other usable. If your app can reopen a window from the dock or tray, choose that policy instead of quitting. A registry keyed by `WindowId` can remove the closed handle in this callback. Window-owned `Task` and `Subscription` fields should drop with the corresponding view; do not keep a closed window's view alive through an app-wide collection accidentally. The close policy has two separate decision points. A platform close request reaches `on_window_should_close` while the window is still available; return `false` if the user must resolve unsaved work first. Once a window has actually closed, `on_window_closed` can remove its `WindowId` from a registry or decide whether the app should quit. If a button calls `remove_window()` directly, ask for confirmation before that call; the should-close callback is not a substitute for the button's own confirmation flow. ## Restore placement Before a window closes, read `window.window_bounds()` to obtain its restorable `WindowBounds` (`Windowed`, `Maximized`, or `Fullscreen`). Persist that value in your application settings, then pass it to the next `WindowOptions`: ```rust let options = WindowOptions { window_bounds: Some(saved_bounds), ..Default::default() }; open_window(options, cx, |window, cx| cx.new(|cx| Workspace::new(shared, "Restored", cx)))?; ``` Here `saved_bounds` is a `WindowBounds` captured from a prior window; saving and loading it is application code. Check restored placement against currently attached displays before using it, because display topology and scale may have changed. See [Window geometry](./window#geometry-and-scale) for the difference between global bounds and window-local viewport size. ## Test the boundaries In a [`TestAppContext` test](./test), open two windows through `gpui_kit::open_window`, update the shared Entity, and assert both views change while window-local state stays independent. Close the first with `window.remove_window()`; its handle update should return an error, while the second window should still render and accept input. Run the repository's existing lifecycle coverage with `cargo test -p gpui-kit --features test-support --test lifecycle closing_one_window_preserves_other_window_and_owned_snapshot`; the [test source](https://github.com/longbridge/gpui-kit/blob/main/crates/kit/tests/lifecycle.rs) exercises the close boundary. For this example, also verify the two visible counts and close behavior with `cargo run` in the new project. ## Troubleshoot the example | Observation | Check | | --- | --- | | Only one window opens | Check that `open_window` runs once per loop item and that each call succeeds. A panic from `.expect(...)` identifies an open failure. | | The clicked window changes, but the other shared label stays stale | Keep the `Subscription` returned by `cx.observe` in each `Workspace`, and call `cx.notify()` inside the `SharedCounter` update. Reading the Entity alone does not register an observer. | | Local click counts change in both windows | Create a distinct `Workspace` with `cx.new(...)` for each window; only `SharedCounter` should be constructed outside the loop. | | An action updates the wrong window or fails after close | Keep the `AnyWindowHandle` returned for the intended window, use it for window-scoped work, and handle its `update` error after closure. Do not rely on `cx.windows()` order. | | Closing one window exits the whole app | Inspect the app's last-window quit policy and any `on_window_closed` callback. The two-window sample registers no such callback. | --- # Native Extensions Source: /versions/v0.7.1/docs/native-extension License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/native-extension and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A native extension attaches an OS control or view to a GPUI window. The two concrete integrations in this repository show different paths: [NativeMenu](https://github.com/longbridge/gpui-kit/tree/main/crates/component/src/native_menu) uses the operating system's menu API, while [gpui-wry](https://github.com/longbridge/gpui-kit/tree/main/crates/webview) embeds a native WebView. Both cross the GPUI/OS boundary; neither is implemented by launching another application. Use `NativeMenu` when a popup must escape a small window's bounds or follow the system menu appearance. Use the GPUI `PopupMenu` when the menu needs to participate in GPUI's own overlay composition. Use `gpui-wry` only when the screen needs an actual browser engine and can reserve a rectangle for a native child view; [TextView HTML](/versions/v0.7.1/component/text-view#html) handles document display, and [`cx.open_url`](./context#open-a-url-in-the-default-browser) opens the user's browser. `gpui-wry` is experimental. | Platform | NativeMenu | Embedded WebView in this repository | | --- | --- | --- | | macOS | AppKit `NSMenu` attached to an `NSView` | Wry child view; current example works | | Windows | Win32 `TrackPopupMenuEx` with an `HWND` | Wry child view; current example works with its renderer setting | | Linux | GPUI-drawn `PopupMenu` fallback, clipped to the window | GTK hosting code is unfinished; no supported path yet | The Linux menu fallback preserves the `NativeMenu` API, but it is GPUI content rather than an OS popup. Linux native-view hosting would need its own GTK/Wayland/X11 integration and tests. See [WebView](./webview) for the current platform requirements. ## Source and build map The workspace pins `gpui-pre` to `=0.3.8` in the root `Cargo.toml`. Check the source in this checkout before copying an adapter to a different GPUI version. The relevant paths are: | Concern | Source in this repository | Build or exercise from the repository root | | --- | --- | --- | | Shared menu API and platform selection | `crates/component/src/native_menu/mod.rs` in the `gpui-component` crate | `cargo check -p gpui-component`; `cargo test -p gpui-component native_menu` checks its builder and icon tests | | AppKit and Win32 menu adapters; GPUI fallback | `crates/component/src/native_menu/{macos,windows,fallback}.rs`; fallback overlay ownership is in `crates/component/src/root.rs` | `cargo run -p gpui-component-story`, then open the **NativeMenu** story on the target OS | | Wry wrapper and custom element | `crates/webview/src/lib.rs` in the `gpui-wry` crate | `cargo check -p gpui-wry` checks the wrapper for the current target | | Child-view creation and renderer setup | `examples/webview/src/main.rs` with dependencies in `examples/webview/Cargo.toml` | `cargo run -p webview` on macOS or Windows | Cargo checks only the current target's `#[cfg]` branch; a Linux build does not validate the AppKit or Win32 adapter. The story and WebView example require a graphical desktop and native platform libraries. The Linux WebView branch may compile but is not a working GPUI host. ## 1. Choose the integration boundary For **platform menu integration**, keep the shared GPUI-facing API separate from platform adapters. `NativeMenu::show(position, window, cx)` chooses its macOS, Windows, or Linux fallback implementation in [the shared module](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/native_menu/mod.rs). Its native adapters create and operate the system menu, then send the selected action back through GPUI. For an **embedded child view**, native content occupies a GPUI layout slot across frames. Own it in an `Entity`, as [`gpui-wry::WebView`](https://github.com/longbridge/gpui-kit/blob/main/crates/webview/src/lib.rs) does. The owner must control show/hide, focus, bounds, and destruction before the parent window closes. ## 2. Obtain the right window handle `Window::window_handle(window)` gives a GPUI `AnyWindowHandle` for updating that same window later. To call an OS API, use the `raw-window-handle` trait instead. These methods have similar names but different purposes: ```rust use raw_window_handle::{HasWindowHandle, RawWindowHandle}; let gpui_handle = Window::window_handle(window); // Later GPUI update let native_handle = HasWindowHandle::window_handle(window)?; // OS adapter match native_handle.as_raw() { RawWindowHandle::AppKit(handle) => { /* macOS: handle.ns_view */ } RawWindowHandle::Win32(handle) => { /* Windows: handle.hwnd */ } RawWindowHandle::Xcb(handle) => { /* Linux X11: handle.window */ } RawWindowHandle::Wayland(handle) => { /* Linux Wayland: handle.surface */ } _ => { /* unsupported backend */ } } ``` This is an adapter excerpt inside a function returning `Result`; it is not a complete cross-platform function. Match the actual handle variant under the corresponding target `#[cfg]`. The `HasWindowHandle` result borrows the live window, and the raw pointer or ID does not own the native object. The menu adapters copy an `NSView` pointer or `HWND` into a foreground task and rely on the parent window staying alive during the synchronous tracking call. Do not cache or use such a value after window destruction. GPUI's pinned Linux platform implementation exposes XCB window IDs under X11 and a Wayland surface under Wayland. A raw surface pointer alone does not provide the GTK widget hierarchy or compositor integration needed to embed a GTK control. The repository's [AppKit](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/native_menu/macos.rs) and [Win32](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/native_menu/windows.rs) adapters show handle extraction. ## 3. Platform menu example: NativeMenu The adapter calls the platform's UI framework directly, using target-specific Rust crates: | Backend | Rust crates and APIs used here | Native object or operation | | --- | --- | --- | | macOS | `objc2`, `objc2-app-kit`, `objc2-foundation`; `MainThreadMarker`, `NSMenu`, `NSMenuItem`, `define_class` | Construct AppKit menu items, receive Objective-C selection, show the menu from an `NSView` | | Windows | `windows` crate with Win32 UI features; `CreatePopupMenu`, `TrackPopupMenuEx`, `DestroyMenu` | Build an `HMENU`, track the selection against an `HWND`, release native resources | | Linux | GPUI's X11 backend uses `x11rb`; its Wayland backend uses `wayland-client`. Wry's unfinished path uses `gtk` and `WebViewBuilderExtUnix`. | NativeMenu currently renders a GPUI `PopupMenu` fallback in `Root`'s overlay; native widget hosting is not implemented | These are the calls inside the actual adapters, with surrounding item construction and error handling omitted: ```rust // macOS: objc2-app-kit, on the AppKit main thread. let mtm = MainThreadMarker::new()?; let menu = NSMenu::new(mtm); menu.popUpMenuPositioningItem_atLocation_inView(None, point, Some(view)); // Windows: windows::Win32::UI::WindowsAndMessaging. let menu = unsafe { CreatePopupMenu() }.ok()?; let selected = unsafe { TrackPopupMenuEx(menu, flags.0, x, y, hwnd, None) }; let _ = unsafe { DestroyMenu(menu) }; ``` The macOS adapter turns the AppKit `NSView` into an `NSMenu` anchor, converts GPUI's top-left logical position to AppKit view coordinates, and runs the menu tracking loop. The Windows adapter obtains an `HWND`, converts logical pixels to physical client coordinates and then screen coordinates, and calls `TrackPopupMenuEx`. Both run the blocking OS tracking loop **outside GPUI's mutable borrow**, then use a foreground task, `cx.update(...)`, and the retained `AnyWindowHandle::update(...)` to dispatch the selected `Action` through `Window::dispatch_action`. Closing the window or cancelling the menu produces no action. A caller only supplies the semantic items and position; `Copy` and `Paste` below are application-defined GPUI Actions: ```rust NativeMenu::new() .menu("Copy", Box::new(Copy)) .menu("Paste", Box::new(Paste)) .show(position, window, cx); ``` Import `NativeMenu` from `gpui_kit::component::native_menu::NativeMenu`. `position` is a window-relative `Point` in logical pixels, such as `MouseDownEvent::position`. The builder also accepts separators, submenus, disabled and checked items, and icons; `NativeMenu::from(gpui_kit::Menu)` reuses an existing GPUI menu definition. `show` consumes the menu and returns immediately. It does nothing for an empty menu. Focus the intended action handler before opening the menu: the selected action is dispatched to the window's active focus context. On Linux, the fallback asks `Root` for its overlay; an application without that window root has no fallback menu to show. The [story](https://github.com/longbridge/gpui-kit/blob/main/crates/story/src/stories/native_menu_story.rs) shows focus placement and action handling. On Linux, [the fallback](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/native_menu/fallback.rs) builds a GPUI `PopupMenu` in `Root`'s overlay; it is still subject to the GPUI window boundary. ## 4. Embedded view example: WebView The [WebView example](https://github.com/longbridge/gpui-kit/blob/main/examples/webview/src/main.rs) creates a Wry native child view from a live GPUI window and stores the wrapper in an `Entity`: ```rust let webview = cx.new(|cx| { use raw_window_handle::HasWindowHandle; let handle = window.window_handle().expect("No window handle"); let native = wry::WebViewBuilder::new() .build_as_child(&handle) .expect("Failed to create WebView"); gpui_wry::WebView::new(native, window, cx) }); ``` The wrapper renders a custom `Element` in the normal GPUI tree. `request_layout` reserves the slot; `prepaint` receives its resolved `Bounds` and calls Wry's `set_bounds` with logical coordinates. It also inserts a GPUI hitbox. `paint` registers outside-click focus behavior. **GPUI does not paint the WebView pixels**: the OS owns that child view. A GPUI `ContentMask` or hitbox does not change its compositor order. The owner hides the native view on drop; cloned `WebViewHandle`s must be released before the parent window is destroyed. The current WebView sits above GPUI content in the same rectangle, so GPUI popovers, dialogs, and tooltips cannot reliably cover it. On Windows, the repository example sets `GPUI_DISABLE_DIRECT_COMPOSITION=true` before `gpui_kit::application()` for this child-view route. The Linux GTK path in the example is marked unfinished. Details and usage are in [WebView](./webview). On Linux, the example imports `gtk` and Wry's `WebViewBuilderExtUnix`, creates a `gtk::Fixed`, and calls `build_gtk(&fixed)`. The code itself says the host initialization is unfinished: creating a GTK widget does **not** attach it to GPUI's XCB or Wayland window. GPUI's pinned Linux backend uses `x11rb` calls such as `configure_window` on X11 and `wayland-client`'s `WlSurface::commit` on Wayland. A Linux adapter must use the matching backend's connection and event loop, own its surface, and arrange input and compositor order; the raw `XcbWindowHandle` or `WaylandWindowHandle` alone cannot supply that integration. The current `gpui-wry` example is not a working template for this final step. ## 5. Build another native control 1. Define one GPUI-facing operation and the same result semantics for each platform. State whether the Linux implementation is native, GPUI-drawn, or unsupported. 2. Put AppKit, Win32, and Linux backend code behind target-specific adapters. Keep raw handles and OS types inside those adapters. 3. For an embedded view, own its lifetime in an `Entity`; map GPUI layout bounds to native bounds in `prepaint`, and test scale changes, focus, input, and window close. For system UI such as NativeMenu, run its tracking loop without holding a GPUI mutable borrow, then return the result to GPUI. 4. Test each actual backend. A headless GPUI test can check state and actions; it cannot prove OS positioning, native focus, or compositor stacking. ## Debug and verify the native boundary Start with `cargo check -p gpui-component` or `cargo check -p gpui-wry` on the target machine. Run the NativeMenu story or WebView example above, then inspect the behavior at normal and high display scale. A successful Cargo check establishes type checking for that target; it does not link or run the application and cannot establish correct native input or stacking. | Symptom | Check in this repository | | --- | --- | | Menu does not appear | Confirm the menu is nonempty, the pointer position is in window-relative logical pixels, and the actual `RawWindowHandle` variant matches the adapter. On Linux, confirm the window uses GPUI Kit's `Root` so `WindowState::native_menu_overlay` exists. | | Menu appears but its action has no effect | Focus the intended view before `show`, register its `on_action` handler, and check whether selection was cancelled or the window closed before the saved `AnyWindowHandle` update. | | Menu is offset on a scaled display | Check AppKit's flipped Y coordinate or Win32's logical-to-physical conversion followed by `ClientToScreen`; test after moving the window between displays. | | WebView is blank on Windows | Check that the example's DirectComposition setting was applied before GPUI initialization and that the WebView was built as a child of the live window. | | WebView has stale bounds or captures unexpected input | Inspect the `prepaint` bounds update, visibility state, focus return on `hide`, and native compositor order. A GPUI hitbox or content mask does not clip or raise the native pixels. | When adding an adapter, test opening, cancelling, selecting, resizing, scaling, hiding, and closing its parent window on every supported backend. Keep the native object and any cloned handles within that window's lifetime. Record unsupported behavior explicitly rather than treating a successful Rust build as platform support. ### Composition boundary The `gpui-wry` wrapper in this checkout does not provide an API for putting GPUI overlays above its native child view. Keep overlay interactions outside the WebView bounds or use a separate window. See [WebView](./webview) for the current behavior; verify any experimental GPUI composition branch separately before relying on it. --- # Action Source: /versions/v0.7.1/docs/action License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/action and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. An **Action** represents an operation the application can perform. A shortcut, menu item, command palette, button, or another Action handler can all dispatch the same typed value. GPUI routes it to the part of the [Element](./element) tree that owns the command. An [Event](./event) serves the other direction: it reports something that happened after state changed. The [GPUI Action source](https://docs.rs/crate/gpui-pre/0.3.8/source/src/action.rs) defines the macro, trait, and registry described here. This page explains command definition and dispatch. Start with [Focus](./focus) if you have not yet created a keyboard target; see [KeyBinding](./keybinding) for key notation, context matching, and keymap setup. ## Run an Action already in this repository From the repository root, launch the Story Gallery directly on its Tree page: ```sh cargo run -p gpui-component-story -- Tree ``` Select a file-tree row and press **Enter**. The process prints `Renaming item: ...` in the terminal. Select another row and repeat to see that the handler reads the current selection. If Enter does nothing, click a row first: the binding is scoped to the Tree story's focused path. This example does not rename a file. Read the implementation in [`crates/story/src/stories/tree_story.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/story/src/stories/tree_story.rs): 1. `actions!(story, [Rename, OpenFile, Delete])` defines the typed commands. 2. `init` binds `enter` to `Rename` under the `TreeStory` context. [`stories::init`](https://github.com/longbridge/gpui-kit/blob/main/crates/story/src/stories/mod.rs) calls it during application setup. 3. `TreeStory::render` attaches `.key_context(CONTEXT)` and `.on_action(cx.listener(Self::on_action_rename))` to the story container. Its child Tree supplies the active focus path. 4. `on_action_rename` reads the selected entry from `tree_state`. Selection belongs to the Tree state; the Action expresses what the user requested. The rest of this guide builds from that route. A menu or button can later dispatch the same Action without copying the handler. ## Build a runnable Action in `hello_world` The Tree story is useful for tracing a real application. To build the route yourself, temporarily replace `examples/hello_world/src/main.rs` with the complete program below. It uses the existing `hello_world` package and needs no new crate or dependency. ```rust use gpui_kit::component::button::Button; use gpui_kit::*; actions!(counter, [Increment]); struct Counter { count: usize, focus: FocusHandle, } impl Counter { fn new(window: &mut Window, cx: &mut Context) -> Self { let focus = cx.focus_handle().tab_stop(true); focus.focus(window, cx); Self { count: 0, focus } } fn on_increment(&mut self, _: &Increment, _: &mut Window, cx: &mut Context) { self.count += 1; cx.notify(); } } impl Render for Counter { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .flex() .flex_col() .gap_2() .p_4() .track_focus(&self.focus) .key_context("Counter") .on_action(cx.listener(Self::on_increment)) .child(format!("Count: {}", self.count)) .child("Press Enter while this region has focus") .child( Button::new("increment") .label("Increment") .on_click(cx.listener(|this, _, window, cx| { this.focus.dispatch_action(&Increment, window, cx); })), ) } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); cx.bind_keys([KeyBinding::new("enter", Increment, Some("Counter"))]); gpui_kit::open_window(WindowOptions::default(), cx, |window, cx| { cx.new(|cx| Counter::new(window, cx)) }) .expect("failed to open window"); }); } ``` Run it from the repository root: ```sh cargo run -p hello_world --bin hello_world ``` The window starts at `Count: 0`. Press **Enter** while the counter region has Focus, or click **Increment**; each operation increases the displayed count by one. The counter's `FocusHandle` is retained in its Entity and attached to the rendered container. `cx.bind_keys` maps Enter to `Increment` only on a path containing `Counter`. The container's `.on_action` calls `on_increment`, which changes retained state and calls `cx.notify()` so the new count renders. The button sends the *same* Action to that container through its saved handle, even if clicking the button moves Focus. Check each link in the route with two small experiments, then restore the original source. First, remove `.key_context("Counter")`: Enter no longer selects this binding, while the button still works because direct dispatch does not consult Key Context. Next, restore the context and remove `.on_action(...)`: neither input changes the count because the route has no handler. If a handler runs but the label stays stale, check whether it mutates retained state and calls `cx.notify()`. Restore `examples/hello_world/src/main.rs` when finished. ## One command, several entry points Define a unit Action with a namespace. `actions!` generates the type and registers its stable name, here `chat::SendMessage`: ```rust use gpui_kit::*; actions!(chat, [SendMessage]); ``` An element handler receives the typed Action, the window, and the owning entity's [Context](./context). `cx.listener` adapts the method to the element callback: ```rust impl Chat { fn on_action_send_message( &mut self, _: &SendMessage, _: &mut Window, cx: &mut Context, ) { self.submit_draft(); cx.notify(); } } // In Chat::render: div() .track_focus(&self.focus_handle) .key_context("Chat") .on_action(cx.listener(Self::on_action_send_message)) .child("Chat") ``` Bind a shortcut to `SendMessage`, and use the same Action elsewhere: ```rust window.dispatch_action(Box::new(SendMessage), cx); // Button or command palette MenuItem::action("Send Message", SendMessage) // Native application menu ``` The command logic stays in one handler. A direct `window.dispatch_action(...)` does **not** need a KeyBinding or match a Key Context; those participate when a keystroke is translated into an Action. Use [KeyBinding](./keybinding) for the binding itself. ## Define data carrying Actions An Action can include data. Deriving `Action` requires `Clone` and `PartialEq`. If the Action should be constructible from a named JSON keymap entry, also derive `Deserialize` and `JsonSchema`: ```rust #[derive(Action, Clone, PartialEq, serde::Deserialize, schemars::JsonSchema)] #[action(namespace = chat)] struct InsertPrompt { text: String, } ``` The Action registry uses the namespace and type name to build a typed value from an action name and optional JSON payload. This lets a configurable keymap and a command UI refer to the same command. Names must be unique; duplicate registration panics during application creation. The JSON-capable example requires `serde` with its `derive` feature and `schemars` as application dependencies for those two derives. Use a unit Action for a command whose handler can read all required state from its owner, as `Rename` does in the Tree story. Use a data Action when the caller must identify a target or supply a value. An Action payload is the command input, not a place to store the owner's changing UI state. For a runtime command whose payload should never come from JSON, `no_json` retains typed dispatch but opts out of JSON construction: ```rust #[derive(Action, Clone, PartialEq)] #[action(namespace = workspace, no_json)] struct OpenConversation { conversation_id: String, } ``` Choose a stable verb based name for each command. Use one Action type for all entry points that mean the same operation; put state changes in its owner rather than in each input callback. ## Focus selects the route GPUI shortcut dispatch in three steps Focus builds a dispatch path, Key Context selects a binding, and its Action reaches a handler on that path. 1 · FOCUSBuild the Dispatch Path Chat → Workspacefocused Element → ancestors Start at the Element with Focus. 2 · KEY CONTEXTMatch a KeyBinding ⌘ Enter + "Chat"→ SendMessage Use key_context values on the path. 3 · ACTIONDispatch along the path Chat handlerthen parents if propagated The most specific handler runs first. A [FocusHandle](./window) identifies a keyboard target. Keep the handle on the entity that owns the interaction, then attach it to an element each time that entity renders: ```rust struct Chat { focus_handle: FocusHandle, } impl Chat { fn new(cx: &mut Context) -> Self { Self { focus_handle: cx.focus_handle() } } } impl Focusable for Chat { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } // In Chat::render: div() .track_focus(&self.focus_handle) .key_context("Chat") .on_action(cx.listener(Self::on_action_send_message)) ``` `track_focus` registers the handle on the element's dispatch node. Mouse down inside the element focuses that handle by default. If an inner control must retain its own focus, its mouse down handler can call `window.prevent_default()` to suppress the ancestor's default focus transfer. Tracking does not focus the element during render; call `self.focus_handle.focus(window, cx)` when the view opens or the user enters it. `handle.is_focused(window)` tests the exact target. `handle.contains_focused(window, cx)` also accepts a focused descendant, useful while a child control is active. A tracked handle is not automatically a Tab stop: configure `cx.focus_handle().tab_stop(true)` when creating it. For a stateless component, retain a handle across renders with `window.use_keyed_state(...)`. GPUI builds a **dispatch path** from the focused element through its ancestors. A `key_context("Chat")` on that path makes contextual bindings eligible; the matching key produces an Action. The Action then travels on the path. A handler on a sibling is not reachable from this route. ### Trace one shortcut Suppose the focused element is inside `Chat`, itself inside `Workspace`. A binding such as `KeyBinding::new("secondary-enter", SendMessage, Some("Chat"))` is eligible only while `Chat` appears on that focused path. GPUI chooses a matching binding, then dispatches its `SendMessage` value along that path. The `Chat` handler runs before a `Workspace` bubble handler. If no element on the route handles the Action, a global `cx.on_action` handler can receive it. The context chooses a **binding**; it does not select a handler by itself. [KeyBinding](./keybinding) explains competing bindings and predicate syntax. For a command triggered by a click, `window.dispatch_action(Box::new(SendMessage), cx)` uses the focus captured when called. Ensure the intended route has focus, or dispatch through a retained `FocusHandle`. A button's callback can also call the owning entity's method directly when no shared command route is needed. ## Handler order and propagation Action dispatch has two phases: 1. **Capture:** global capture listeners, then matching `.capture_action(...)` listeners from the root toward the target. 2. **Bubble:** matching `.on_action(...)` listeners from the target toward the root, then global `cx.on_action(...)` listeners if propagation continues. The closest bubble handler therefore gets the first chance to handle a command. An Action handler stops bubble propagation by default. Call `cx.propagate()` when this handler declines the Action and a parent or global handler should try it: ```rust fn on_action_close( &mut self, _: &ClosePanel, _: &mut Window, cx: &mut Context, ) { if !self.can_close() { cx.propagate(); return; } self.close(); cx.notify(); } ``` Capture listeners can call `cx.stop_propagation()` to stop dispatch before it reaches the target. Global bubble handlers also stop propagation by default, so a global fallback that declines a command should call `cx.propagate()`. These are Action dispatch controls; `window.prevent_default()` controls a default input behavior such as mouse focus transfer. See [Event](./event) for pointer and keyboard event propagation. To inspect a command without consuming it, a capture listener can observe it and leave propagation enabled. Capture starts with propagation enabled. In bubble, each listener starts with propagation stopped. Call `cx.propagate()` when the next ancestor or global fallback should also receive that Action. Returning early alone does not pass it on. `window.dispatch_action(Box::new(action), cx)` captures the current focus target and defers dispatch to the rendered frame. For an explicit owner, `focus_handle.dispatch_action(&action, window, cx)` starts at the element that rendered that handle, if it is present in the current frame. `cx.dispatch_action(&action)` targets the active window, or global handlers when no window is active. These choices matter when a popup or click changes focus before a command runs. | Call | Target | When to use it | | --- | --- | --- | | `window.dispatch_action(Box::new(action), cx)` | Current focus in this window, captured at call time | A menu or button command for the focused region. Dispatch is deferred. | | `focus_handle.dispatch_action(&action, window, cx)` | The element that rendered this handle in the current frame | A command for a specific rendered region, even if another control now has focus. No rendered handle means no dispatch. | | `cx.dispatch_action(&action)` | Active window, or global handlers without one | An application-level command when the caller has an `App` context. | None of these calls evaluates a Key Context predicate. Key Context participates when a **keystroke** selects an Action from the keymap. The dispatched Action still needs a handler reachable from its chosen target. ## Coordinate sibling regions through an owner Suppose a conversation selected in a Sidebar should open in Chat. The Sidebar describes the intent with `OpenConversation`; the common owner, `Workspace`, handles it and updates the Chat entity: ```rust impl Workspace { fn on_action_open_conversation( &mut self, action: &OpenConversation, window: &mut Window, cx: &mut Context, ) { self.chat.update(cx, |chat, cx| { chat.open(action.conversation_id.clone(), window, cx); }); } } // Workspace renders both regions under its handler. h_flex() .on_action(cx.listener(Self::on_action_open_conversation)) .child(self.sidebar.clone()) .child(self.chat.clone()) // From a Sidebar interaction, while its focus path is active: window.dispatch_action(Box::new(OpenConversation { conversation_id }), cx); ``` The route is **Sidebar → Workspace**. `Workspace` then updates Chat through the [Entity](./entity) API. Attaching the handler only to Chat would not work for an Action dispatched from Sidebar because Chat is a sibling, outside Sidebar's dispatch path. For commands that must target a specific rendered region regardless of current focus, retain that region's `FocusHandle` and use its `dispatch_action` method. See [Coding Guides](./coding-guides) for larger feature ownership patterns. GPUI Kit uses the same pattern in its Command palette and Popup Menu: a selected item supplies a boxed Action, then the window dispatches it. The Command palette also keeps its own focus handle, key context, and navigation Action handlers on the palette element. The framework component owns selection and keyboard mechanics; the application owner handles the command's meaning. ## Diagnose a missing command When a shortcut works only after clicking a region, inspect the route in order: 1. Which `FocusHandle` is focused, and is it attached with `track_focus` in the rendered tree? 2. Is the required `key_context` on that element or an ancestor? See [KeyBinding](./keybinding) for binding matching. 3. Is the typed `.on_action(...)` handler on the resulting dispatch path? 4. Did a closer handler consume the Action, or did a declining handler forget `cx.propagate()`? 5. If a direct dispatch runs after focus changes, should it use an explicit `FocusHandle` target? If a key does nothing, first distinguish **no binding match** from **no reachable handler**. Try dispatching the Action directly on the intended `FocusHandle`. If that reaches the handler, inspect the key string and context predicate. If it does not, inspect the rendered handle, dispatch path, and propagation. If the handler runs but the screen stays unchanged, verify that it updates the owning state and calls `cx.notify()` when a redraw is needed. Keep the handle, context, and handler with the region that owns the command. Use a global handler only for an operation that truly applies across the application. ## Practice with the Tree story 1. **Follow the path.** Run `cargo run -p gpui-component-story -- Tree`, select a row, and press Enter. In `tree_story.rs`, find the binding, context, and handler. Which component owns the selected row, and which entity owns the command handler? 2. **Change the input.** In your own branch, temporarily change the Tree story binding from `"enter"` to `"secondary-r"`. Re-run the Story Gallery. Verify that the new key invokes the same `Rename` handler and that Enter no longer does. Restore the source after the experiment. 3. **Predict propagation.** Place `Rename` handlers on a child and its parent in a small view. Have the child call `cx.propagate()` only when it has no selection. Predict which handler runs in both cases, then test with visible output. A handler that returns without calling `cx.propagate()` consumes the Action. 4. **Add a target.** Model an operation that must carry a row identifier as a data Action with `#[action(namespace = story, no_json)]`. Dispatch it from a row callback; keep the mutation in the owning handler. Compare this with `Rename`, which reads the currently selected row instead. --- # Animation Source: /versions/v0.7.1/docs/animation License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/animation and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. GPUI Kit offers three levels of motion. Choose by **what owns the changing value**, not by the shape of the effect: | Level | Use it for | State and policy | | --- | --- | --- | | GPUI `Animation` and `AnimationExt` | An element entering, pulsing, or running a fixed series while mounted | GPUI retains playback under the wrapper's [`ElementId`](./element_id); the caller chooses duration, easing, and visual property. | | [GPUI Base Motion](/versions/v0.7.1/base/motion) | A target that changes during motion, an exit before unmount, keyframes, or measured reveal | Base retains each channel under a stable key and requests frames through [Window](./window) while active; the caller chooses the visual result. | | GPUI Component motion | A styled control whose appearance follows the theme | `cx.theme().motion_tokens()` supplies semantic timing, easing, springs, and distances; components compose these with GPUI or Base. | The application owns the semantic state: whether a dialog is open, which tab is selected, or where a slider points. An animation samples that state for presentation. Keep the result understandable at both endpoints and when motion is disabled. For a first animation, decide whether a new event may change the destination before the motion finishes. If it can, start with a target-driven Base `transition` or `spring`. Use GPUI `with_animation` when one mounted element should play a known sequence from start to finish. The distinction matters when users click twice: a playback clock and a changing target have different interruption behavior. ## Start with a changing selection Run the existing [Motion example](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/motion/mod.rs) from the repository root: ```sh cargo run -p gpui-base-examples --bin motion ``` The example opens on **Sliding time**. Open the **Spring** tab and click **Focus** and **Flow**. The dark indicator moves to the selected half of the track. Switch choices before it settles to see it change direction. The labels remain visible throughout, and the selected label changes color immediately; the motion adds spatial continuity to that state. ### Keep the target in view state `MotionExample` stores `spring_selected: bool`. The button for each option assigns its value and notifies the view: ```rust .on_click(move |_, _, cx| { _ = entity.update(cx, |this, cx| { this.spring_selected = selected; cx.notify(); }); }) ``` The boolean selects **Focus** (`false`) or **Flow** (`true`). It is the durable state; the view does not need to store the indicator's current pixel position. Clicking the already selected option leaves the target unchanged. ### Sample the spring on each render In `spring_demo`, the boolean chooses a target of 0 or 120 pixels. Base's `spring` returns the current value for this frame: ```rust let x = spring( "selector-indicator", if self.spring_selected { 120. } else { 0. }, Spring::new(Duration::from_millis(420)).with_damping(0.68), window, cx, ); ``` `"selector-indicator"` is a stable channel ID, so later renders find the same spring and preserve its position and velocity when the target changes. On the first render, the spring adopts its target immediately; movement begins only after a click changes that target. While it is moving, Base requests more frames. Keep sampling the channel on every render while the indicator is mounted; the click handler's `cx.notify()` announces a state change, but no render loop or timer is needed. ### Apply the sample to the indicator The track is a relatively positioned 240-pixel parent. The indicator uses the sampled value as its left inset: ```rust div() .relative() .w(px(240.)) .h_10() .child( div() .absolute() .left(px(x)) .w(px(119.)) .h_full(), ) ``` The source also styles the track, indicator, and option buttons. The two targets place the 119-pixel indicator under the corresponding option. These fixed widths make the coordinate calculation easy to follow in the example; a product component should use its own sizing and theme policy. ### Follow one click from input to rest | Moment | What happens | Who owns it | | --- | --- | --- | | Click **Flow** | The handler writes `spring_selected = true` and calls `cx.notify()`. | `MotionExample` owns the selection. | | Next render | `spring_demo` passes the new target, `120.`, to the channel named `"selector-indicator"`. | Base retains the channel's sampled position and velocity. | | While moving | `spring` returns a new `x` and requests a later animation frame. The indicator uses that `x` in `.left(px(x))`. | GPUI schedules requested frames; the render code describes each result. | | At rest | `spring` returns the target without asking for another motion frame. | The view still owns `spring_selected`; a later click can retarget it. | Try changing `Duration::from_millis(420)` to `Duration::from_millis(700)` in `spring_demo`, then run the same command again and switch **Focus** and **Flow** before the indicator settles. This changes the spring's response policy, not the click handler or semantic selection. Restore `420` afterward. For a second experiment, change the spring target for **Flow** from `120.` to `60.`. The indicator now stops between the two labels while the selected label still changes correctly. Restore `120.`: the target is presentation geometry, whereas `spring_selected` is the answer to “which choice is selected?” To inspect reduced motion, add `cx.set_reduce_motion(true);` immediately after `gpui_base::init(cx);` in the example's `run` function, then run it again. The selected option still changes, and the spring reaches its target without animated frames. Remove the added line afterward. If the indicator does not move with motion enabled, check that the target changes, the channel ID stays stable, and `spring` is sampled while the indicator remains mounted. Give any additional moving property its own ID; sharing one channel mixes retained values. ### Let an exiting element finish before unmounting Open **Presence** in the same example and click **Remove**. The example changes `present` to `false` immediately, but keeps rendering the notice while its opacity reaches zero. Its render path samples presence *before* deciding whether to include the notice: ```rust let sample = Presence::new("presence-notice", self.present) .transition(Transition::new(Duration::from_millis(360)).easing(Easing::EaseInOut)) .sample(window, cx); // Keep the notice mounted through its exit phase. if sample.should_render() { div().opacity(sample.progress).child("Background task") } else { div() } ``` The excerpt shows the lifecycle decision; the [full example](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/motion/mod.rs) also lays out and styles the notice. If the view instead used `if self.present` to decide whether to render it, **Remove** would unmount the notice on the first changed render and there would be no exit to draw. Click **Insert** during exit to see `Presence` reverse from the sampled value. Under reduced motion it moves directly to the appropriate endpoint. `present` remains the logical state; a visually exiting notice should not remain an active control after its action is no longer available. ## GPUI's element animation `Animation::new(duration)` creates a one-shot, linear animation. `AnimationExt::with_animation(id, animation, animator)` wraps an `IntoElement`; the callback receives that element and an eased progress value. GPUI calls it during layout, applies the returned element's style, and requests another frame until the animation ends. The callback may change any property supported by that element, such as opacity or a transform. See the [GPUI 0.3.8 animation source](https://docs.rs/gpui-pre/0.3.8/src/gpui/elements/animation.rs.html) for the wrapper's playback rules. Here `gpui-pre` is the publication and version-alignment package name for GPUI; it is not an additional application layer. ```rust use std::time::Duration; use gpui_kit::*; let entering = div() .child("Saved") .with_animation( ("saved-notice", generation), Animation::new(Duration::from_millis(180)), |element, progress| element.opacity(progress), ); ``` ### Try one-shot replay beside a retargetable spring Replace `examples/hello_world/src/main.rs` with this temporary exercise, then run `cargo run -p hello_world`. It uses the existing example package and the same `gpui-kit` dependency: ```rust use std::time::Duration; use gpui_kit::*; use gpui_kit::base::{Spring, spring}; use gpui_kit::component::button::*; use gpui_kit::prelude::FluentBuilder as _; struct MotionProbe { generation: usize, show_notice: bool, selected: bool, } impl Render for MotionProbe { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let offset = spring( "motion-probe-indicator", if self.selected { 120. } else { 0. }, Spring::new(Duration::from_millis(420)).with_damping(0.68), window, cx, ); div() .flex() .flex_col() .gap_4() .p_6() .when(self.show_notice, |parent| { parent.child( div().child("Saved").with_animation( ("motion-probe-notice", self.generation), Animation::new(Duration::from_millis(900)), |element, progress| element.opacity(progress), ), ) }) .child( Button::new("replay").label("Replay notice").on_click(cx.listener( |this, _, _, cx| { this.generation += 1; this.show_notice = true; cx.notify(); }, )), ) .child( Button::new("hide").label("Hide notice").on_click(cx.listener( |this, _, _, cx| { this.show_notice = false; cx.notify(); }, )), ) .child( div().relative().w(px(240.)).h_10().child( div() .absolute() .left(px(offset)) .w(px(119.)) .h_full() .bg(rgb(0x3366cc)), ), ) .child( Button::new("retarget").label("Switch target").on_click(cx.listener( |this, _, _, cx| { this.selected = !this.selected; cx.notify(); }, )), ) } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| MotionProbe { generation: 0, show_notice: true, selected: false, }) }) .expect("failed to open window"); }); } ``` The **Saved** notice fades in once and then remains visible. Click **Replay notice**: incrementing `generation` gives the wrapper a new ID and starts a fresh fade. Click **Hide notice** before the fade finishes: `show_notice = false` removes the wrapper, so that playback stops. **Replay notice** mounts a fresh wrapper and fades in again. Click **Switch target** again while the blue bar is moving: it changes direction from its current sampled position because the Base spring keeps its channel ID while `selected` changes its target. The spring's 420 ms response is a motion scale, not a fixed completion deadline; settling ends at its configured tolerance. A click on one control does not restart the other animation. For the ID experiment, replace `("motion-probe-notice", self.generation)` with `"motion-probe-notice"` and keep the click handler. While the notice stays mounted, subsequent **Replay notice** clicks still update view state, but the finished one-shot does not fade again. Restore the tuple ID and the original example file after the exercise. To replay the exercise with reduced motion, add `cx.set_reduce_motion(true);` immediately after `gpui_kit::init(cx);` and run it again. **Saved** appears at full opacity without a fade, the spring adopts the selected target, and **Hide notice** still removes the notice. Remove the temporary line afterward. The bar is a visual probe of the spring value; its fixed 240-pixel track and 120-pixel target are local example geometry. In an application, the selected state must also be clear without movement. To compare a complete selector with labels and interaction states, run the existing [Motion example](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/motion/mod.rs) and open its **Spring** tab. Bring `AnimationExt` into scope through `gpui_kit::*` (or import the trait explicitly). The ID identifies the animation wrapper, not the text or the visual property. Rendering the same wrapper at the same place with the same ID continues its playback; recreating `Animation::new(...)` on each render does **not** restart it. Change an application-owned generation in the ID when a fresh appearance should replay. Removing the wrapper ends its lifetime. A one-shot animation stays at its final value while the same wrapper remains mounted. `with_easing(f)` maps normalized time to progress. GPUI supplies functions such as `ease_in_out` and `bounce`; a custom easing function must return a finite value. Overshoot is permitted, so clamp the resulting style property if that property has a narrower valid range. `repeat()` loops locally. `repeat_synced()` loops against an application-wide clock, useful when several indicators should share a phase. `with_max_fps(rate)` limits requests for this animation to at most that rate; invalid nonpositive or nonfinite values are ignored. See [FPS](./fps) for measuring actual frame performance. `with_animations(id, animations, |element, step, progress| ...)` plays a fixed chain and reports the active step index. GPUI also exposes `AnimationExt::with_spring(id, SpringAnimation, animator)` for a spring-driven element. Its stable ID preserves position and velocity across target changes. A newly mounted spring starts at its target unless `SpringAnimation::from(...)` supplies a starting value. Use this when the spring can live naturally on one element; Base's `spring` is useful when a separately keyed value feeds more than one part of a composition. The GPUI spring's `SpringPlayback` controls whether it runs, pauses, stops, completes, or cancels. Reduced motion snaps a **running** spring to its target; paused and stopped playback keeps its playback state. Choose the target and playback state from application state rather than treating the wrapper as the source of truth. ### What interruption means here `with_animation` is a playback of elapsed time, not a transition toward a changing target. Changing the callback's captured endpoint under the same ID continues the existing clock; changing the ID starts a fresh playback. Neither operation automatically samples the current rendered value as the new starting point. For a selection indicator that must reverse smoothly when clicked again, use a target-driven spring or Base `transition` instead. GPUI's `AnimationExt` honors `App::reduce_motion()`: a one-shot renders at its end, a repeating animation renders at its start, and neither schedules animation frames. Keep a loading state visible through text or another static cue; a stopped spinner by itself cannot tell the user what is happening. Use the same sequence of questions for each new effect: What application state changes? Which element or channel ID remains stable between renders? What value is sampled on the first render? What happens on a second input before completion? When does the effect stop requesting frames? What remains understandable with reduced motion? These questions catch most apparent “animation did not run” bugs before changing a timing curve. ## GPUI Base Motion: keyed values and lifecycles Import these APIs from `gpui_kit::base` when an application depends on `gpui-kit`: ```rust use gpui_kit::*; use gpui_kit::base::{Easing, Transition, transition}; use std::time::Duration; let opacity = transition( ("save-panel", "opacity"), if open { 1.0 } else { 0.0 }, Transition::new(Duration::from_millis(180)).easing(Easing::EaseOut), window, cx, ); div().opacity(opacity) ``` `Transition` here is a **timing policy** for a value, not a styled element. `transition` samples and returns the value; `transition_with_status` also reports `Idle`, `Delayed`, `Running`, or `Finished`. On a changed target, the channel starts at its currently sampled value. A direct reversal shortens the return duration to match the remaining distance. Base requests frames only while the channel is delayed or running. Sample the channel on every render while its owner is present, including when the result is visually hidden, so its retained value settles correctly. Every independently moving value needs its own stable channel ID. Namespace channels by domain object and property, for example `(project_element_id.clone(), "opacity")` and `(project_element_id.clone(), "height")`, where `project_element_id` is a stable `ElementId`. Two channels sharing an ID can overwrite retained state; changing IDs every render loses continuity. A reorderable list needs item IDs, not row indexes. GPUI's wrapper ID and Base's channel ID serve different lifecycles even when they describe the same visual element. Use Base `spring(id, target, Spring, window, cx)` when the target may move again before settling. It preserves position **and velocity** on retarget. During direct pointer manipulation, use `Spring::with_travel(false)` so the value tracks the pointer; restore travel on release. A spring's `epsilon` is measured in the target's units, so a pixel offset may need a coarser tolerance than normalized opacity. Base also provides the following choices: | Need | API | Lifecycle detail | | --- | --- | --- | | Authored value stops | `Keyframes`, `Timing`, `animate_keyframes` | Same ID continues playback; include an application generation in the ID to replay. Timing supports delays, iterations, and playback direction. | | Distinct ordered steps | `Sequence` | Each step begins at the previous step's absolute end time; the ID plays once until deliberately changed. Changing the active step's target restarts from its sampled value. A sequence does not reverse automatically. | | Keep content mounted through exit | `Presence` | Sample while logically closed; render until `should_render()` becomes false. Reopening during exit reverses from the current sample. | | Delay repeated items | `Stagger` | Computes a delay by index and origin; it does not own the list or its IDs. | | Expand content of unknown height | `MotionReveal` | Measures the child and clips its visible height by caller-supplied progress. It does not sample or animate progress itself. | See the [Base Motion guide](/versions/v0.7.1/base/motion) for the full signatures, validation rules, examples, and benchmark. Its `Transition` is distinct from the older `gpui_kit::base::animation::EffectTransition`, which wraps GPUI `with_animation` to apply predefined fade, slide, width, and height effects. For new target-driven work, use `base::motion` primitives and apply the sampled value yourself. ## GPUI Component: semantic motion policy Styled components use `cx.theme().motion_tokens()` for a shared policy. `MotionTokens` has `duration_instant`, `duration_fast`, `duration_normal`, and `duration_slow`; `easing_enter`, `easing_exit`, and `easing_move`; `spring_control` and `spring_move`; and `distance_short` and `distance_medium`. The defaults are a coherent scale, not a rule that every control must animate. Read tokens from the active theme so a product can tune them in one place. Try the existing styled control with `cargo run -p gpui-component-story -- SwitchStory`. Click an enabled switch twice quickly: its checked state changes with each click, and its thumb changes direction during travel. A disabled switch in the same story does not respond. In [`crates/component/src/switch.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/switch.rs), follow `checked` into the thumb's target offset and `cx.theme().motion_tokens().spring_move` into the Base `spring` call. In [`crates/component/src/theme/motion.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/theme/motion.rs), temporarily change the default `spring_move` duration from 280 ms to 600 ms and rerun the story: the same checked states now settle more slowly. Restore 280 ms afterward. The story owns the checked boolean, the component maps it to a visual target, and the theme supplies motion policy. For example, the [Switch source](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/switch.rs) samples a Base spring on `(self.id.clone(), "thumb")` toward the checked or unchecked thumb offset, using `cx.theme().motion_tokens().spring_move`. The [resizable handle source](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/resizable.rs) samples separate length and opacity channels with `duration_fast` and `easing_move`; the hairline remains present even when the indicator fades. [Collapsible](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/collapsible.rs) opts into measured, reversible reveal through `.motion_id(id)`. Without that ID, it mounts and unmounts immediately. Some components use GPUI's element wrapper for a fixed animation: [Spinner](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/spinner.rs) repeats a rotation, while [Popover](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/popover.rs) animates its entrance. The choice follows whether a component needs continuing target state or a fixed playback, not whether it belongs to the styled layer. ## Reduced motion and frame ownership GPUI stores the preference on `App`; `cx.reduce_motion()` reads it and `cx.set_reduce_motion(...)` can set it. `gpui_kit::init(cx)` initializes Base's system preference handling. Base reads macOS and Windows at initialization, follows the Linux desktop portal preference as it arrives and changes, and leaves the flag alone on other targets. If the application explicitly sets the flag, Base leaves that application choice in control. Call `gpui_kit::base::apply_system_reduce_motion(cx)` to reread macOS or Windows after initialization. GPUI element animation adopts a static endpoint as described above. Base's finite transitions, springs, keyframes, presence, and sequences snap to their appropriate target or final state and stop requesting motion frames. `MotionReveal` only consumes progress: when using it directly, pass an endpoint under reduced motion or drive it with a reduced-motion-aware sampler. It may still request a frame when the child's measured height changes. Infinite activity should remain legible in a static state. If a custom element owns its own clock, check `cx.reduce_motion()` and request frames only while useful motion remains; do not unconditionally call `cx.notify()`, `window.refresh()`, or `window.request_animation_frame()` from render. Use motion to explain appearance, dismissal, expansion, or spatial continuity. Prefer short opacity or transform changes over large layout animation when they communicate the same relationship. Coordinate keyboard focus, hit targets, and semantic state with the transition; painted movement alone does not announce a new state to assistive technology. See [Accessibility](./accessibility) for the semantic side of UI changes. --- # Auto Update Source: /versions/v0.7.1/docs/auto-update License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/auto-update and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. An update has two separate jobs: the application presents a useful, responsive experience, and the release system delivers a trustworthy replacement. GPUI Kit gives you the UI, state, and task tools for the first job. **Your application and its distribution system own the update policy, release metadata, verification, installation, and recovery.** GPUI Kit does not select a release or replace an installed app. Start with [Packaging Desktop Apps](/versions/v0.7.1/docs/packaging): an updater must match the package format and installation scope you ship. ## Choose who owns installation | Distribution | Recommended update owner | Reason | | --- | --- | --- | | A writable, single-binary portable installation | The app can use a binary updater such as `self_update`, with a controlled restart | There is one executable to replace and no package database to keep in sync. | | macOS `.app` delivered in a DMG | A bundle-aware updater or a new signed and notarized app/DMG | Updating only `Contents/MacOS/` changes the signed bundle. Replace and validate the complete `.app`. | | Windows Inno Setup `.exe` installer | The installer | It owns installed files, shortcuts, permissions, version identity, and uninstall records. A running process may lock its executable. | | Linux DEB/RPM or a managed software repository | The package manager | Replacing its binary behind its back leaves its package database and dependencies inconsistent. | | Portable archive with several files | An installer or an app-specific staged update for the **whole** directory | A new binary alone may not match its sidecar libraries or resources. | Do not silently switch an installation from one owner to another. In particular, a binary self-updater does not replace the Inno Setup installation flow, a macOS bundle update, or a Linux package upgrade. A signed update to a portable binary still needs a writable destination and a platform-specific restart strategy. ## Define the release contract Before building the UI, publish enough information to make one unambiguous decision: 1. **Identity and policy:** stable application/package identity, current version, update channel (`stable` or `beta`, for example), minimum supported version, and whether downgrades are allowed. Compare versions with a defined version scheme; do not compare arbitrary version strings lexically. 2. **Exact target:** OS, CPU architecture, and any relevant ABI or installation type. A macOS Arm artifact must not be offered to an Intel build merely because both are called “macOS.” Match the asset name and the binary path inside its archive to the actual release package. 3. **Release metadata:** version, human-readable notes, artifact URL, size if known, and a digest or signature. Serve metadata and assets over HTTPS and treat redirects and release-host credentials as part of your trust policy. Do not embed a private release token in the distributed executable. 4. **Verification:** check the downloaded bytes against the selected release's expected digest **before** installation, then apply the platform's signature or publisher-authenticity checks. A `SHA256SUMS` file hosted beside the artifact catches corruption and mismatches; by itself it does not prove who published either file. Use a trusted signing key, signed metadata, or the applicable platform signature when publisher authenticity matters. 5. **Atomicity and recovery policy:** stage the artifact outside the live installation, reserve space, verify the full payload, and define what happens if extraction, replacement, relaunch, or the new version's startup fails. Do not claim rollback unless your installer actually retains and restores a known-good version. The check may run at launch or on a reasonable timer, but network errors should leave the current app usable. A manual **Check for updates** action is useful even if checks are automatic. Cache a last-check time, avoid simultaneous checks, and do not install in the background without the app's stated policy. ## A simple path for a portable Rust executable [`self_update` 1.3](https://docs.rs/self_update/1.3.0/self_update/) is an optional application dependency, not a GPUI Kit feature. It can discover a release, download an archive, verify a checksum, and replace a single executable. This example uses a public GitHub release with a `SHA256SUMS` asset. Adapt the repository, binary name, archive naming, and target selection to **your** release contract. In your application's `Cargo.toml`: ```toml [dependencies] self_update = { version = "1.3", default-features = false, features = ["ureq", "rustls", "github", "archive-tar", "compression-tar-gz", "archive-zip", "compression-zip-deflate", "checksums"] } ``` After the user accepts an available update, run the **blocking** installation function on a background worker, never inside `render` or a GPUI click callback: ```rust fn install_portable_update() -> Result, Box> { let status = self_update::backends::github::Update::configure() .repo_owner("example") .repo_name("hello-world-releases") .bin_name("hello_world") .current_version(self_update::cargo_crate_version!()) .checksum_from_asset("SHA256SUMS") .no_confirm(true) // The application UI already obtained consent. .show_output(false) .show_download_progress(false) .build()? .update()?; Ok(status.is_updated().then(|| status.version().to_owned())) } ``` `Some(version)` means the executable was replaced; `None` means it was already up to date. For a **check only**, build the same updater and call `is_update_available()?`; it returns `Some(release)` or `None` without installing. The [crate's current API](https://docs.rs/self_update/1.3.0/self_update/) also provides verification hooks and progress callbacks. A separate check and install can observe different “latest” releases if a release is published between them; production code should pin the selected release/version and revalidate its exact asset before replacement. A successful update changes the installed executable, **not** the code already running in memory. Show **Restart to finish**, save state, exit cleanly, and relaunch with a strategy suitable for that platform and install location. The snippet is limited to a writable, single-binary portable app. If an archive also contains required assets, update that payload as a unit. If the app is inside a signed `.app`, an Inno Setup installation directory, DEB, or RPM, use the installation owner from the table instead. `self_update` does not automatically make an arbitrary application update atomic, provide a rollback policy, or preserve package-manager ownership. ## Keep GPUI responsive throughout Represent each visible phase as application-owned state: `Idle`, `Checking`, `Available`, `Downloading`, `Verifying`, `ReadyToRestart`, and `Failed` are a useful starting set. Keep the selected version and an error message in that state. Use a foreground [Task](/versions/v0.7.1/docs/task) to coordinate UI updates and a background worker for blocking download, hashing, archive extraction, or installer preparation. Return owned results to the foreground task, update the owning [Entity](./entity), and call `cx.notify()` so a later frame shows the new state. Keep the task handle alive while the operation should continue; cancel or ignore stale results if a newer request supersedes it. Progress is optional when the server does not provide a trustworthy total size. In that case show an indeterminate status and the current phase. Do not drive an animation or redraw loop while the app is idle; GPUI renders in response to work and invalidation, as explained in [120 FPS and Rendering Models](/versions/v0.7.1/docs/fps). If you animate progress, honor reduced-motion preferences, and expose phase, percentage when known, errors, and **Restart** through text or accessible status controls. Make a failed download retryable without implying that the existing installation is damaged. ## Install, restart, and recover 1. **Download and stage:** write to a temporary, application-owned location. Reject an unexpected version, target, archive layout, size, or missing required file. Never execute a partially downloaded payload. 2. **Verify:** validate the artifact digest and the publisher/authenticity policy before replacing anything. On macOS, validate the **complete** signed bundle after staging; on Windows, verify the publisher signature or installer according to your release policy. Recheck the exact artifact after any download redirect or cache substitution. 3. **Install through the owner:** let the installer/package manager apply managed updates. For a portable app, arrange replacement after the running process releases files if that platform requires it. Preserve user data outside the installation directory. 4. **Restart deliberately:** tell the user when the new version is ready, save work, and close or relaunch at a safe point. Report the active version only after the new process starts; a completed download is not an active update. 5. **Recover:** retain a known-good package or make the previous installer available, and test the actual failure paths. Rollback may require a platform installer or a separate helper; it cannot be inferred from a successful download or an updater crate call. For macOS direct distribution, stage and verify a signed/notarized replacement `.app` rather than altering one executable inside the installed bundle. For Windows, test an update while the app is running and under the intended per-user or machine-wide install scope; the helper or installer must handle file locks and elevation rather than assuming the app can overwrite itself. For Linux DEB/RPM, direct the user to the configured package source or invoke the package manager through an appropriate installer flow. See [Packaging Desktop Apps](/versions/v0.7.1/docs/packaging) for each platform's artifact and identity requirements. ## Test the release path Test with **downloaded release artifacts** on clean machines or VMs for every supported OS, architecture, channel, and installation format: | Scenario | Expected result | | --- | --- | | Up to date, offline, slow network, timeout, or bad metadata | The app remains usable; the status is truthful and retryable. | | Wrong target, missing asset, truncated file, bad digest, or invalid signature | Nothing is installed; the error identifies the failed phase without exposing credentials. | | New version while work is unsaved | The user can defer restart and keep working under the old running version. | | Concurrent checks, repeated clicks, or a release published during check/install | Only the selected, revalidated version is installed once. | | Read-only destination, Windows file lock, macOS bundle signature failure, or package-manager-owned install | The app follows its platform install path or fails safely without partially replacing files. | | Interrupted install, failed relaunch, and downgrade/rollback | The tested recovery path restores a launchable version and preserves user data. | Finally, install version N through each supported distribution format, update to N+1, verify the version **after restart**, and test ordinary uninstall. A working developer build or a successful update check does not establish that the published installer can upgrade a real installation. --- # Accessibility Source: /versions/v0.7.1/docs/accessibility License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/accessibility and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. GPUI sends an accessibility tree to platform assistive technology through [AccessKit](https://accesskit.dev/). A useful control has a **role** (what it is), a **name** (how a person identifies it), **state** (such as value, checked, selected, or expanded), and **actions** (what assistive technology can request). Its stable element ID preserves identity across frames; it is not the name a person hears. The same interface must work with a keyboard and show where focus is. Tree properties alone do not implement keyboard behavior. Applications normally use GPUI Kit's styled components from `gpui_kit::component`. Their interaction comes from the unstyled `gpui_kit::base` layer. Both are available through one `gpui-kit` dependency. Use a standard control before composing a new interactive `div`: it already coordinates pointer input, keyboard input, focus, state, and AccessKit semantics. For a custom keyboard target, follow [Focus](./focus) first: `track_focus`, Tab order, and an accessible role solve different parts of the interaction. Think of accessibility as one path through the application, not a separate description attached at the end: | Step | In the Profile example | What to verify | | --- | --- | --- | | Retained state | `Entity` owns the draft; `Profile::submitted` owns the saved value. | Typing and Save change the intended model, including after a rerender. | | Rendering | `Input`, `Button`, and the status `div` read that state. | The visible label and result match the model. | | Semantics | The controls expose a role, name, relevant state, and available actions through AccessKit. | A painted-frame snapshot contains the expected properties. | | Operation | Pointer, keyboard, and assistive technology reach the same command. | Save works with each input method; focus moves visibly and predictably. | Start with the first two steps, then inspect the semantic tree and operate the real window. A passing headless tree assertion cannot prove what a screen reader announces or whether a focus ring is visible. ## Follow one complete Save flow Run the repository's [Profile UI test](https://github.com/longbridge/gpui-kit/blob/main/crates/kit/tests/ui.rs) from the workspace root: ```sh cargo test -p gpui-kit --features test-support --test ui saves_a_profile_through_the_ui -- --exact ``` The test opens a headless window, enters a name, activates **Save**, and checks both application state and accessibility properties written by the rendered GPUI elements. This gives you a concrete path from the model to a visible control and its AccessKit node. For setup of your own test crate, continue with [Testing](./test). ### 1. Name the control that receives input The view keeps an `Entity` so the text survives rerenders. It renders the input with a stable ID for GPUI and an explicit human-readable name for assistive technology: ```rust Input::new(&self.name) .id("name") .aria_label("Profile name") .w(px(240.)) ``` The ID and label answer different questions. `"name"` lets GPUI and the test identify this particular element; `"Profile name"` tells a person what to enter. The example also renders **Profile name** as visible text directly above the input. Proximity does not create an accessibility relationship by itself, so the input has its own name. A placeholder would disappear as the user types and is a poor replacement for a persistent label. ### 2. Update one model, then render the status from it The **Save** button's listener copies the current input value into the view's `submitted` field and calls `cx.notify()`. The next `render` computes a status string from `submitted` and uses the same string for pixels and the accessibility name: ```rust let status = self.submitted.as_ref().map_or_else( || SharedString::from("Not saved"), |name| SharedString::from(format!("Saved: {name}")), ); div() .id("status") .role(Role::Status) .test_support() .aria_label(status.clone()) .child(status) ``` The status `div` is not a button; it describes the result of an action. Its stable ID plus `Role::Status` gives it a semantic node. `.test_support()` only makes this custom node discoverable to GPUI Kit's test helper when that feature is enabled. A role and label do not change the model; `cx.notify()` is what causes the new model value to reach the next frame. ### 3. Assert the contract after a painted frame The test calls `render_frame` before its first query, then interacts with the native elements and reads fresh snapshots: ```rust window.render_frame(cx); assert_eq!(window.find("status").role(), Some(Role::Status)); assert_eq!(window.find("status").label(), Some("Not saved")); window.click("name", cx); window.input("Ada José", cx); assert_eq!(window.find("name").label(), Some("Profile name")); assert_eq!(window.find("name").value(), Some("Ada José")); window.press("backspace", cx); assert_eq!(window.find("name").value(), Some("Ada Jos")); window.click("save", cx); assert_eq!(window.find("status").label(), Some("Saved: Ada Jos")); ``` The repository test also checks input focus, status geometry, and `profile.read(cx).submitted`. Checking the model prevents a test from passing when only the label changes. `window.click` exercises pointer activation here; the `backspace` step removes the accented `é` after the pointer has focused the input, testing Unicode editing rather than keyboard navigation to **Save**. As an experiment, remove `.aria_label("Profile name")` and rerun the test: the label assertion shows why visible proximity to other text cannot be assumed to name the input. To check the keyboard path in a real window, put this complete version of the same view in the existing `examples/hello_world/src/main.rs`, then run `cargo run -p hello_world` from the workspace root. This is a local exercise; restore the example file when finished. The headless test above does not run this keyboard sequence. ```rust use gpui_kit::{ AppContext, Context, Entity, Role, SharedString, Window, WindowOptions, component::{button::Button, input::{Input, InputState}}, div, prelude::*, px, }; struct Profile { name: Entity, submitted: Option, } impl Render for Profile { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let status = self.submitted.as_ref().map_or_else( || SharedString::from("Not saved"), |name| SharedString::from(format!("Saved: {name}")), ); div() .flex() .flex_col() .p_4() .gap_4() .child( div().flex().flex_col().gap_1().child("Profile name").child( Input::new(&self.name) .id("name") .aria_label("Profile name") .w(px(240.)), ), ) .child(Button::new("save").label("Save").on_click( cx.listener(|this, _, _, cx| { this.submitted = Some(this.name.read(cx).value()); cx.notify(); }), )) .child( div() .id("status") .role(Role::Status) .aria_label(status.clone()) .child(status), ) } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |window, cx| { cx.new(|cx| Profile { name: cx.new(|cx| InputState::new(window, cx)), submitted: None, }) }) .expect("failed to open window"); }); } ``` The window starts with **Not saved**. Starting before the form, Tab to **Profile name** and confirm a visible focus indication; type `Ada José` and use Backspace once, leaving `Ada Jos`. Tab to **Save**, confirm its focus indication, then press Enter. The visible result should become **Saved: Ada Jos**. While **Save** remains focused, press Space to activate it again. Use Shift+Tab to return to the input, then Tab back to **Save** to verify reverse and forward navigation. If either activation key does not work, inspect the button's focus and key handling instead of treating the pointer test as evidence. Finally, activate **Save** through your target platform's assistive technology and check the announced role, name, and result; the status role alone does not guarantee a live announcement. To adapt this exercise to your own form, follow the same order: give each actual input a stable ID and name, make the action update retained state, render any result from that state, then assert both the model and the freshly painted semantics. Keep the visible label beside the control for sighted users; the accessible name is an additional contract. If a field becomes invalid, expose the error in visible text and check that a person using assistive technology can discover the error and recover. A color change by itself does not communicate the reason or correction. ## Start with semantic controls The following controls expose useful semantics from their actual state: | Control | Accessibility contract | | --- | --- | | Button, Link | Button or Link role, accessible name, activation. Use Button for an application command and Link for an external destination. | | Checkbox, Switch, Toggle, Radio | Their respective role and checked or toggled state. Checkbox also reports mixed state. | | Input | Text input role chosen from its content type, name, non-sensitive value, and an accessible `SetValue` action when not disabled. Masked and password values are withheld. | | Select | ComboBox role, name, committed value, expanded state, and an accessible activation path. | | Tab, tab list | Tab and TabList roles; tabs report selection and, when supplied, position in the set. | | Slider, Progress | Numeric value and range; Slider handles accessible Increment and Decrement. Indeterminate Progress omits its value. | | Table | Table, row, header and cell roles with indices and optional counts. Name the Table root. | For a labeled button, the visible label is the accessible name by default. Name an icon-only button explicitly: ```rust use gpui_kit::component::{IconName, button::Button}; Button::new("search-documents") .icon(IconName::Search) .accessibility_label("Search documents") .tooltip("Search documents") .on_click(|_, window, cx| { // Invoke the same application command used by its keyboard route. }) ``` `accessibility_label` names the control for assistive technology; a tooltip is a separate hint and cannot replace that name. Keep the name specific to the action and update it if the action changes. Do not assume that arbitrary text nested inside a custom container becomes its accessible name. A visible form label and a neighboring input likewise do not gain an automatic label relationship merely by being next to each other: name the actual input with `Input::aria_label(...)`, or use a component that provides the relationship. Input can fall back to a placeholder as its name, but an explicit label remains clearer; a generated mask placeholder is deliberately not used as the name. ## Identity and roles GPUI includes an element in its accessibility tree when it has both an [ElementId](./element_id) and a non-empty accessibility role. The global identity also contains IDs of ancestors. Keep IDs stable across frames and derive repeated item IDs from domain keys, so reordering does not look like a series of removals and insertions to assistive technology. An `id` alone is not a role; an unroled `div` is a layout container, not an announced control. A role alone does not make a control focusable or operable. For a semantic status message, a GPUI element can supply both: ```rust use gpui_kit::*; div() .id("save-status") .role(Role::Status) .test_support() .aria_label("Saved") .child("Saved") ``` The explicit label is the announced name. `.test_support()` lets the later integration test find this custom `div` when `test-support` is enabled; it adds no layout container and is inert in normal builds. `Role::GenericContainer` is filtered from the accessibility tree; use an actual role for a meaningful node. `accessibility_id(...)` is a separate, author-provided identifier exposed to platform automation. It maps to identifiers such as UIA `AutomationId` on Windows and `AXIdentifier` on macOS, with Linux AT-SPI support depending on the deployed adapter. It is not a substitute for GPUI's `.id(...)` or for a human-readable name. ## Names, states, and relationships On an identified `div`, GPUI's `StatefulInteractiveElement` provides `.role(...)`, `.aria_label(...)`, `.aria_description(...)`, `.aria_selected(...)`, `.aria_expanded(...)`, `.aria_toggled(...)`, `.aria_value(...)`, `.aria_numeric_value(...)`, and range and collection properties. Numeric controls can also report minimum, maximum, step, and orientation; headings can report level; list and table items can report positions and counts. Update these from the same model that draws the visible UI; the owning view uses its [Context](./context) to notify GPUI after a state change. A description supplements the name; it does not replace it. `.aria_keyshortcuts(...)` announces a shortcut but does not bind the key: register the real GPUI keybinding separately. GPUI's current `div` API has no general `.aria_disabled(...)` builder. Base controls such as Button and Checkbox gate focus and activation when disabled, but that does not guarantee a native disabled property for every node. Verify both the available tree state and the actual disabled behavior. Likewise, `.track_focus(...)` gives a node a focus path and advertises the accessible Focus action; a role or `.focusable()` alone does not implement a useful keyboard command. The element tree establishes parent/child relationships. Composite controls may keep keyboard focus on a parent and mark the current child with `.aria_active_descendant()`. GPUI applies that child-side marker only when an ancestor actually has focus. The child needs its own ID and role. This is specialized composite behavior; use the built-in Select, menu, or list behavior when it fits. Do not infer a web `aria-labelledby`, `aria-describedby`, or `aria-controls` builder from the `aria_` prefix. These are not general builders on GPUI's current `div` API. For a Table with a visible caption, set `.accessibility_label(...)` on the Table root; the caption container does not automatically name it. ## Accessible actions and keyboard input An AccessKit action is distinct from a GPUI [Action](./action) dispatched by a keybinding or menu. GPUI exposes it as `AccessibleAction`. For an identified `div`, `.on_a11y_action(action, handler)` registers one requested action; the handler receives optional `ActionData`, `&mut Window`, and `&mut App`. `.on_click(...)` already advertises accessible Click activation and routes it to the click handler, so a second Click handler can perform the command twice. A custom slider, for example, must offer Increment and Decrement as well as its pointer and keyboard controls, and update its numeric accessibility value after the model changes. GPUI Kit's Slider already does this. Keep the interaction promise consistent: the visible label, accessible name, shortcut, pointer behavior, keyboard behavior, and assistive action should all perform the same command. A clickable painted shape with a hitbox is still missing semantics and keyboard operation until those are implemented. After dialogs or sheets close, restore focus to their trigger. Keep focus visible and ordered according to the task. For the Profile flow above, use the runnable window exercise to verify keyboard operation separately from the pointer test. The visible result changes only after `submitted` changes and `cx.notify()` triggers a rerender. For a composite control, also test its documented arrow keys and Escape behavior. Test these paths on the platforms you ship; a successful pointer test or an announced shortcut does not establish them. ### Build one interactive custom control The earlier `EventSurface` exercise adds a Status node to a painted shape, but it does not make the shape operable without a pointer. For a control whose appearance can be built from normal elements, start with composition. Replace `examples/hello_world/src/main.rs` temporarily with this complete example, then run `cargo run -p hello_world` from the repository root. Restore the example file when finished. ```rust use gpui_kit::{ *, component::ThemeStyled as _, prelude::*, }; struct CounterControl { focus: FocusHandle, activations: usize, } impl CounterControl { fn new(cx: &mut Context) -> Self { Self { focus: cx.focus_handle().tab_stop(true), activations: 0, } } } impl Render for CounterControl { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let focused = self.focus.is_focused(window); let status = format!("Activations: {}", self.activations); div() .flex() .flex_col() .gap_4() .p_4() .child( div() .id("activate-counter") .role(Role::Button) .aria_label("Activate counter") .track_focus(&self.focus) .p_3() .border_1() .when(focused, |control| control.focus_ring_style(window, cx)) .on_click(cx.listener(|this, _, _, cx| { this.activations += 1; cx.notify(); })) .child("Activate counter"), ) .child( div() .id("activation-status") .role(Role::Status) .aria_label(status.clone()) .child(status), ) } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(CounterControl::new) }) .expect("failed to open window"); }); } ``` The retained `FocusHandle` survives rerenders. Its `.tab_stop(true)` includes the control in Tab navigation, while `.track_focus(...)` registers the rendered node. The stable ID and `Role::Button` expose a control node, and `.aria_label(...)` names the operation. The focus ring is rendered only while that handle is focused. The single `.on_click(...)` listener changes the retained count and calls `cx.notify()`; GPUI routes primary pointer clicks, Enter/Space keyboard activation on the focused node, and AccessKit Click to that listener. Do not register a second `AccessibleAction::Click` for this node, or the command may run twice. Check each path in the running window. A pointer click should change **Activations: 0** to **Activations: 1**. From outside the control, Tab to it and confirm the ring is visible; Enter should produce **Activations: 2**, and Space should produce **Activations: 3**. Tab away and Shift+Tab back to confirm it remains in the navigation order. With a platform accessibility inspector, find a Button named **Activate counter** and a Status whose label matches the visible count. Invoke the Button's accessible Click operation with the target assistive technology and confirm the count increases exactly once. A headless snapshot can check the role, name, and updated model, but cannot prove the ring is visible, the platform adapter exposes the node, or a screen reader announces the changing Status. If Tab skips the control, check both `.tab_stop(true)` on the retained handle and `.track_focus(...)` on the current rendered node. If its role or name is missing, check that the same node has a stable ID, role, and label. If an activation produces two increments, look for a second Click or key handler calling the same command. This exercise demonstrates one button-like control; for real application commands, prefer the standard Button, which also handles its other component states and styling. ## Custom `Element` implementations When composition with `div()` is insufficient, a low-level `Element` has explicit hooks. Continue the [EventSurface exercise](./element#make-the-surface-visible-and-respond-to-a-press), which already implements `IntoElement`, layout, prepaint, and paint in `examples/hello_world/src/main.rs`. Make these three edits inside that existing example: 1. In `Render for SurfaceDemo`, pass the current counter text into `EventSurface` alongside `owner` and `color`: `status: format!("Pointer presses: {}", self.presses),`. 2. Add `status: String,` to the `EventSurface` struct. 3. Replace its `id()` method and add the two accessibility methods inside `impl Element for EventSurface`: ```rust fn id(&self) -> Option { Some("pointer-status".into()) } fn a11y_role(&self) -> Option { Some(Role::Status) } fn write_a11y_info(&self, node: &mut accesskit::Node) { node.set_label(self.status.clone()); } ``` `use gpui_kit::*;` in the complete EventSurface example already imports `ElementId`, `Role`, and `accesskit`. Run `cargo run -p hello_world` again. With assistive technology or a platform accessibility inspector active, find the rectangle's **Status** node named **Pointer presses: 0**. A left press inside the rectangle updates the visible counter and, after rerender, the node's name to **Pointer presses: 1**; the stable ID keeps its identity across those frames. An inspector can show the tree properties, while only a screen reader check can establish whether the change is announced. The accessibility tree is built when assistive technology is active; an absent tree with no client attached does not by itself prove the methods failed. This is a narrowly scoped semantics exercise. `EventSurface` still responds only to pointer input, and `Role::Status` describes its counter; it does not make the rectangle a keyboard or assistive-technology-activatable control. Use a standard Button for a real command, or separately implement tracked focus, keyboard activation, an appropriate control role, and an accessible action. GPUI only calls `write_a11y_info` for an element that contributes an identified role. GPUI Kit's `window.find(...)` helper observes registered controls and `div().test_support()`, not this raw `Element`; do not expect it to find `pointer-status`. `a11y_synthetic_children(...)` can add AccessKit child nodes after prepaint, for example text runs in a custom editor. `A11ySubtreeBuilder::synthetic_node_id(key)` derives a child ID from its parent and a stable key; `push_child(...)` attaches the node. Keys must be unique among a parent's synthetic children. This is an advanced path: the implementer owns hit testing, event dispatch, focus, keyboard handling, and accessible actions in addition to the tree data. ## Test the contract Enable GPUI Kit's `test-support` feature for UI integration tests and import `gpui_kit::test::TestWindowExt`; import `TestSupportExt` when observing a custom `div`. Query the **painted** element after `window.render_frame(cx)`. `ElementSnapshot` exposes `role()`, `label()`, `value()`, `focused()`, `checked()`, `indeterminate()`, `selected()`, and `expanded()`. Re-query after each interaction because a snapshot describes one completed frame. Use the runnable Profile test above as the assertion example; it checks role, name, value, focus, geometry, and the saved model value. `None` from a snapshot state reader means the property is unavailable, not `false`. In particular, `.disabled()` is `Some(true)` only if the node exposes that flag; test disabled behavior by attempting activation and checking that the result did not change. `ElementSnapshot::value()` reads a string accessibility value, not Slider's numeric value or painted text. Masked and password inputs intentionally expose no accessibility value. The current Input implementation registers `SetValue` when it is not disabled, including in read-only mode; its handler uses a programmatic replacement path. Do not treat `readonly(true)` as protection against this accessible write path without verifying the behavior you need. See [Testing](./test) for test setup, frame, and focus details. Headless snapshots read AccessKit properties produced by GPUI elements; they do not inspect the final platform adapter tree, a screen reader announcement, or pixels. Inspect the running app with assistive technology on each target platform for announcement order, focus movement, editing, and actions. In particular, verify that a changing `Role::Status` is actually announced rather than assuming its role alone guarantees a live announcement. Platform adapters differ, and [WebAssembly support](/versions/v0.7.1/docs/webassembly) must be checked independently from native desktop behavior. Pair this with visual checks for focus contrast, readable text, target size, reduced motion, and information that must not depend on color alone; see [Design Guides](./design-guides). ### A practical acceptance pass Use this sequence on every platform you plan to ship. Record the operating system, assistive technology, app build, and whether each result was observed; an AccessKit property in a test is evidence for the GPUI tree, not for every platform bridge. 1. **Keyboard only:** start before the form. Tab to the input and Save button, use Shift+Tab to return, and verify visible focus at each stop. Type, edit, and activate Save with Enter or Space. Confirm the visible result and saved model. For a composite widget, also try its documented arrow keys, Escape, and Tab exit. 2. **Assistive technology:** navigate to the same input and button. Confirm their announced role and name, the input value where disclosure is appropriate, and the state after Save. Activate the button through the assistive technology's control action. Check the resulting status announcement in the actual application; `Role::Status` alone is not proof that it will be spoken. 3. **State changes:** test empty or invalid input, disabled and enabled transitions, and an overlay if the flow opens one. Check that the user can find the error, that disabled commands cannot execute, and that focus reaches a sensible target after the overlay closes. 4. **Presentation:** zoom or enlarge text, inspect focus contrast and clipping, reduce motion where supported, and check that color is not the only way to distinguish success, error, or selection. ### If a check fails | Symptom | Inspect first | | --- | --- | | The custom node is absent from the painted snapshot. | Does it have a stable `.id(...)` and a meaningful `.role(...)`? Has `window.render_frame(cx)` run? For a custom `div` located by GPUI Kit's test helper, did you add `.test_support()` and enable `test-support`? | | The role is present but the name is missing or wrong. | Name the control itself (`Input::aria_label`, `Button::accessibility_label`, or `div().aria_label`); nearby text and tooltips are not a guaranteed naming relationship. | | The status text changes but its snapshot stays old. | Re-query after the interaction; check that the owner updated retained state and called `cx.notify()`. A saved `ElementSnapshot` describes one frame. | | A shortcut is announced but pressing it does nothing. | `.aria_keyshortcuts(...)` only reports a shortcut. Register the GPUI binding and attach its action handler to the intended focus path; see [Actions](./action). | | Pointer activation works but keyboard or assistive activation fails. | Inspect Tab stop, tracked focus, key context, action handler, and accessible action. A role and a hitbox alone do not provide these paths; consider using a built-in control. | | A headless assertion passes but a screen reader behaves differently. | Reproduce on the target platform and inspect the platform adapter's result. The headless helper does not validate announcements, focus visuals, or adapter behavior. | --- # FPS Monitor Source: /versions/v0.7.1/docs/fps License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/fps and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `gpui-fps` overlays a performance HUD on a [Window](./window): a headline rate, a rolling frame time trace, and this process' CPU, GPU and memory. It depends only on `gpui`, so any GPUI application can use it. ```rs use gpui_fps::fps_monitor; fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { div() .relative() .size_full() .child(self.content.clone()) .when(self.show_fps, |this| this.child(fps_monitor(window, cx))) } ``` The parent must be `relative()`, the HUD positions itself absolutely, and whether it is on screen is the caller's to decide. ## 120 Hz is a frame budget, not a refresh promise “120 FPS” is often shorthand for a **target on a 120 Hz display**: one refresh period is `1 / 120 s`, about **8.33 ms**. It says nothing by itself about how often an idle application asks for a frame, or whether a particular screen can finish and present every frame within that budget. A 60 Hz display gives about 16.67 ms per refresh. Variable-refresh displays and platform scheduling add further context; neither number is an application's measured FPS. GPUI responds to invalidation: an input or state change can make a view dirty; the window later draws and presents the updated scene. Multiple changes may be coalesced. An animation can request another frame while it is progressing, and should stop requesting animation frames when it settles. The GPUI render pipeline does not require an application to rebuild its element tree 120 times per second merely because the display is 120 Hz. Platform frame callbacks may still occur, and some backends may present an unchanged scene without rebuilding its elements. An FPS HUD with its own timer, an input device, a platform compositor, or application code can also cause activity while the app looks idle. Measure idle CPU and GPU use on the target platform rather than inferring zero work or continuous full redraws from the refresh rate.
When GPUI needs a new drawing pass
Conceptual frame requests, not measured FPS or a claim about platform presentation of cached scenes.
For a sustained **120 displayed FPS** claim, measure a representative *whole window* in a release build, at the target size and hardware. Include realistic data, scrolling or animation, and report a distribution of frame times and present intervals after warmup. One small component completing `render` in less than 8.33 ms only measures part of one frame. Layout, prepaint, paint, submission, GPU work, the compositor, and scheduling can consume the rest. Likewise, a low average can hide slow `P95` frames and visible stutter. GPUI does work on both sides of the CPU/GPU boundary: application state, element construction, layout, and paint preparation run on the CPU; the platform renderer and compositor use the GPU where supported. Text, scene complexity, cache behavior, graphics backend, and hardware determine the bottleneck for a particular workload. “CPU-bound” or “GPU-bound” is a profiling result for that workload, not a fixed property of every GPUI app. ## Immediate, retained, and hybrid describe different layers These terms answer different questions, so a single label is easy to misread: | Layer | What GPUI does | What persists | | --- | --- | --- | | View state | An [`Entity`](./entity) owns application state across updates and frames. | The Entity and its subscriptions, tasks, and child handles while owned. | | UI description | On a needed [render pass](./render), `Render` or `RenderOnce` constructs elements from current state. This resembles immediate-style declaration. | The element description is specific to that pass; `RenderOnce` does **not** mean once per display frame. | | Reuse and drawing | GPUI can reuse eligible [cached views](./view-cache) and keyed element state, and submits paint work for the window. | Cache and platform scene resources can outlive an individual element description. | “Retained” accurately describes the Entity state and selected caches; “immediate-style” describes construction of a current element description; “hybrid” is also the term in [Zed's GPUI overview](https://github.com/zed-industries/zed/blob/bcf6582ce3500df93a8a39366640173e6786cea6/crates/gpui/README.md#the-big-picture). None of those terms defines the idle present rate or proves a performance number. The [Render](./render), [Element](./element), and [View Cache](./view-cache) guides show the ownership and invalidation boundaries in code. Add `gpui-fps` from the same GPUI dependency family as the application. Render `fps_monitor(window, cx)` at most once per window; repeated calls reuse the same monitor and would draw it twice. The repository's runnable example is `cargo run --release -p fps_monitor`. Use `cargo run -p fps_monitor` only when comparing Debug builds: unoptimized framework code can change the measured frame cost substantially. See [Installation](./installation#improve-development-runtime-performance) for the repository's development profile. ## From an update to a displayed frame 1. A state change followed by `cx.notify()`, an animation request, or a window refresh invalidates the window. Several invalidations can become one draw. 2. GPUI runs `Window::draw`, building dirty views' elements and doing layout, prepaint, and paint work. The profiler records `draw_start`, `draw_end`, and the invalidation count for this window. `FRAME` uses **only** `draw_end - draw_start`. 3. GPUI submits the drawn scene to the platform in a separate present step. Its `present_end` timestamp feeds `FPS` and `INTERVAL`. This distinction matters: a 5 ms `FRAME` says that GPUI's draw completed in 5 ms. It does not say the request waited only 5 ms, that platform submission took 5 ms, or that the GPU and compositor displayed it within 5 ms. The profiler also exposes `dirty_to_draw_duration()` and `PresentTiming` for custom instrumentation, but those are separate measurements from this HUD's `FRAME`. See GPUI's [frame timing definitions](https://docs.rs/gpui-pre/0.3.8/gpui/profiler/struct.FrameTiming.html) and the [sampler implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/fps/src/sampler.rs). ## The headline The big figure answers one of two questions, and the `MAX` marker says which. **Right-click** to switch; **click** to collapse the HUD to a tag. | | Reads | Means | | --- | --- | --- | | `MAX FPS` (default) | `1 / mean FRAME`, capped by the display when its rate is known | Estimated draw throughput for the sampled workload; it omits presentation and GPU completion | | `FPS` | Rate inferred from recent present timestamps | The observed present cadence while the window has enough samples | They are different questions. For example, this HUD requests a readout update twice a second even if the application has no visible changes. That can give the window a low observed `FPS` while `MAX FPS` remains high; neither figure alone proves continuous idle rendering or sustained performance in a busier screen. ### Why MAX is derived rather than counted The obvious way to make a frame counter read "as fast as this UI can go" is to keep asking for frames, the way an in-game counter does. That is not free here. Marking any view dirty schedules a **window** draw, and GPUI re-renders every view in that window outside an [`Entity::cached`] boundary (see [View Cache](./view-cache)) — so each frame the HUD asked for would add window layout and [paint](./paint) work, and the CPU row underneath would include work the HUD itself was causing. The measured draw cost offers an estimate of CPU-side headroom for the sampled window. Its reciprocal is `MAX FPS`, but it does not measure a sustained animation or the complete present pipeline. The HUD does not request continuous frames to calculate it; its twice-second readout clock is described below. ### Why MAX is capped by asking, not by measuring A frame drawn in 3ms reads as 333, and no panel will ever show that. Counting presents had the ceiling for free — frames go to the compositor on vsync — and a figure derived from frame cost has no such bound, so the cap is applied explicitly. It cannot be inferred. The gaps between a window's presents are whole multiples of the panel's period, so they bound it **from below and never from above**: 41.7ms is six refreshes at 144Hz and one at 24Hz, and nothing in the timing distinguishes them. Every estimate tried read a real window wrong — 169 and 149 from the shortest and the densest gaps, 75 from a window drawing every other refresh, and 24 from an application whose own timer happened to fire every 41.7ms. So the platform is asked instead. GPUI hands out the platform's own display handle through `DisplayId`, and the HUD takes it from there: - **macOS** — `CGDisplayCopyDisplayMode` on the `CGDirectDisplayID`. A built-in panel reports no fixed rate, which is the truth on ProMotion, and is read as no cap. - **Windows** — `EnumDisplaySettingsW` on the monitor's device name. - **Wayland** — the outputs are enumerated on a second connection and matched to GPUI's displays by the identity it derives from their names, because object ids are per-connection and mean nothing across one. - **X11 and everything else** — no query, so no cap. The answer is re-asked when the window moves to another display and not otherwise. Where nobody will say, the reading is left uncapped rather than held to a guess: a ceiling under the truth hides the figure the reader came for. ## The rows | Row | Measures | | --- | --- | | `INTERVAL` | Mean time between presents. The same figure a platform overlay calls its frame interval, and the reciprocal of `FPS`. A wide gap between it and `MAX` is an idle window, not a slow one. | | `FRAME` | Mean `Window::draw` cost. Graded against the frame budget: this is the row to read when something feels slow. | | `P95` | The slow tail of the same frames, graded the same way. | | `DROP` | Share of frames that overran the budget. | | `INV` | Invalidations coalesced into one frame. Well above one means the window was asked to redraw more often than it could. | | `GPU` | This process' GPU use where the platform exposes a per-process counter; otherwise the row is absent. | | `CPU` | This process, on the scale `top` and Activity Monitor use: 100 is one saturated core, so a process spread across one and a half cores reads about 150. | | `MEM` | Process-attributed memory: macOS physical footprint, Windows private usage, or Linux resident anonymous memory, with an RSS fallback. It is not the same counter on every platform. | `FRAME`, `P95` and `DROP` are graded against the frame budget: one refresh of the display the window is on, where the platform reports the rate above, and one 60Hz frame where it does not. `frame_budget()` pins a budget of your own instead, and the display then no longer replaces it. `FRAME`, `P95`, `DROP`, and `INV` use the latest **120 retained draw samples** by default, not a fixed number of seconds; `capacity()` changes that length. `P95` is the nearest observed 95th-percentile frame, while `DROP` is the fraction whose draw time is *strictly greater* than the budget. The trace plots these draw costs, newest at the right. `FPS` and `INTERVAL` use present timestamps in a rolling one-second window. The displayed numbers are republished every 500 ms; CPU, GPU, and memory are background samples averaged over the trailing three seconds. Resource sampling is unavailable on the web. These windows can describe different moments. After an expensive interaction, spikes may remain in the frame trace, and repeated slow frames in `P95`, after `FPS` has already returned to idle. The headline is not graded because a low observed rate can simply mean that the application has stopped asking for frames. ## Reproduce and diagnose a slowdown 1. Run `cargo run --release -p fps_monitor` from this repository. Let the window and HUD settle, then note the display, window size, build profile, and baseline `FRAME`, `P95`, `DROP`, `INV`, and resource rows. The example's `+ load` and `− load` controls change its curve count; compare the same count and window size between runs. Its scene calls `window.request_animation_frame()`, so it provides a sustained draw load. 2. Increase the load and watch `FRAME` against the display's budget. For a 60 Hz target one refresh is about 16.7 ms; for 120 Hz it is about 8.3 ms. If `FRAME` is low but `P95` or `DROP` rises, investigate intermittent work. If `INV` rises while draw costs stay low, inspect redundant update or animation requests. If only `INTERVAL` grows, first check whether the window is idle, occluded, or waiting for presentation. 3. Repeat the same interaction several times after warmup. Capture a CPU profile for expensive view construction, layout, or paint submission; use the platform's GPU/compositor tools when `FRAME` is low but visible output still stutters. Narrow the workload, change one cause, and compare the same build profile and conditions again. The HUD locates a symptom; it does not attribute time to a particular view or GPU command. For application-specific records, GPUI's `profiler` feature exposes `FrameTimingCollector::new()` and `collect_unseen()`. They yield `FrameEvent::Draw` and `FrameEvent::Present`; filter each by `window_id`, and keep the collector alive while sampling. The trace is process-wide and is enabled by `gpui::profiler::set_trace_enabled(true)`; disabling it clears its buffers. The HUD manages this switch while visible. These raw records are useful when you need to correlate a frame with an app event, including the first invalidation timestamp (`dirty_at`) and present submission. They do not by themselves measure GPU completion or photons on screen. See GPUI's [collector API](https://docs.rs/gpui-pre/0.3.8/gpui/profiler/struct.FrameTimingCollector.html) and [GPUI Kit's monitor](https://github.com/longbridge/gpui-kit/blob/main/crates/fps/src/monitor.rs). ## The first frames are not measured A window's first frames are its most expensive — shaders, the glyph atlas, the icons, every cache still cold — and they are not what the application costs to run. One of them is a hundred milliseconds against a budget of sixteen, and a HUD that has seen eight frames would report it as a twelfth of the window's work, in amber, before the reader has done anything at all. So the sampler discards two things: everything GPUI recorded before the HUD was mounted, which is either somebody else's history or the cold start, and the first few frames after it. The default reading of a window that just opened is a healthy one. ## What the HUD itself costs One frame every 500ms. It does not drive the frame loop, but it does need a clock — nothing else would wake a HUD in a window that has stopped drawing, and the figures would freeze at whatever the application last drew. That clock also carries the CPU, GPU and memory sample. Those frames are not measured. To GPUI the clock's `notify` is an invalidation like any other, answered with a full draw of the window, and left in the readings it would be a cold frame every 500ms reported as the application's `FRAME` and `MAX`. So the clock announces each one, and the sampler leaves out the draw that answered it — unless the application asked for that frame too, in which case the work was wanted and the cost counts. Hidden, the HUD costs nothing. Two ticks without being rendered — a second — and the clock stops, the resource probe with it, and the HUD lets go of GPUI's frame trace unless something else is holding it. The next render starts it all again from an empty sampler: the trace buffer was cleared with the switch, and the frames the window drew meanwhile were nobody's to report. [`Entity::cached`]: https://docs.rs/gpui-pre/0.3.8/gpui/struct.Entity.html#method.cached --- # Getting Started Source: /versions/v0.7.1/docs/getting-started License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/getting-started and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. This guide builds a small desktop window with a GPUI Kit button. You need Rust and Cargo plus the system libraries for your platform; see [Installation](/versions/v0.7.1/docs/installation) for macOS, Windows and Linux requirements. For a browser target, start with [WebAssembly](/versions/v0.7.1/docs/webassembly) after learning the view model here. ## Create a project ```sh cargo new gpui-hello cd gpui-hello ``` Add GPUI Kit to the generated `Cargo.toml`: ```toml [dependencies] gpui-kit = "0.7.1" ``` This single dependency includes GPUI, GPUI Base, the styled GPUI Component library and its default icon assets. Application code accesses GPUI through `use gpui_kit::*;` and components through `gpui_kit::component`. You can change the feature selection later; see [Icons & Assets](/versions/v0.7.1/docs/assets). ### macOS text rendering: `font-kit` **macOS needs the `font-kit` feature on `gpui-pre-platform` to render text.** The `gpui-kit` dependency above already enables it, so this guide needs no additional dependency or feature setting. If you maintain an application that depends on GPUI directly, enable the feature on the `gpui_platform` dependency instead. For a macOS-only GPUI application, the dependency entries are: ```toml [dependencies] gpui = { package = "gpui-pre", version = "=0.3.8" } gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit"] } ``` Keep any other platform features your application uses, and keep the GPUI snapshot versions aligned. This is an alternative for direct GPUI users; keep the single `gpui-kit` dependency for the Kit example below. Do not add `features = ["font-kit"]` to `gpui` or `gpui-kit`, or add a separate `font-kit` dependency: the feature belongs to `gpui-pre-platform` and enables `gpui-pre-macos/font-kit`. `gpui-pre-platform` does not enable `font-kit` by default. Without it, the macOS backend uses `NoopTextSystem`: a window can open while no text is rendered, and `all_font_names()` returns an empty list. After updating your manifest, rebuild with `cargo run`. On macOS, `cargo tree -e features -i gpui-pre-macos` shows which dependencies enable the backend's features; check for `font-kit`. ## Add a view Replace `src/main.rs` with: ```rust use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::*; struct HelloWorld; impl Render for HelloWorld { fn render(&mut self, _: &mut Window, _: &mut Context) -> 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"); }); } ``` Run `cargo run` from the project directory. A window shows the label and button; clicking the button prints `Clicked!` in the terminal. The startup sequence has three parts: 1. `gpui_kit::application()` creates the desktop application; `.with_assets(...)` registers the default icon source. 2. `gpui_kit::init(cx)` initializes the enabled Kit layers, including component themes. Call it once before opening application windows or constructing components. 3. `gpui_kit::open_window(...)` creates an `Entity` from the closure and wraps it in a [`Root`](./window). `Root` owns the window's overlay layers, including dialogs, sheets and notifications. Return your content view from the closure, not another `Root`. `HelloWorld` implements GPUI's [`Render`](./render) trait. When GPUI renders the view, `render` returns an [element tree](./element): a `div` containing text and a `Button`. The button is a value built for that render; when a control needs lasting state, such as an input's text, the owning view keeps an [Entity](./entity) for that state instead of recreating it in `render`. ## A small mental model An [Entity](./entity) holds state across frames. It can own a model without drawing anything; when `T` implements `Render` and is mounted, the entity is a persistent **View** that builds a fresh element tree each time it renders. A [RenderOnce](./render-once) component takes its inputs as a value and describes a reusable piece of that tree. Use one where the caller supplies its state and handlers; it can still use small keyed element state. Give complex state, subscriptions, and tasks a lasting owner. ```text app shell → feature (model, commands, view) ├─ Entity retained state └─ Entity retained view; View implements Render └─ element tree rebuilt for each render └─ RenderOnce values for reusable pieces ``` As an app grows, a feature with its own workflow can keep its model and views together in a feature crate, with a private [Global](./global) only when it needs truly application-wide state. Let features cooperate through small public interfaces, events, or `Entity` handles. This keeps reusable pieces inexpensive to adopt and gives teammates or AI agents a clear boundary for parallel changes. The [Coding Guides](./coding-guides) explain when to make that split and how to keep ownership and dependencies clear. ## Where to go next Read these in order as your app grows: 1. [Entity](/versions/v0.7.1/docs/entity), [Context](/versions/v0.7.1/docs/context), and [Render](/versions/v0.7.1/docs/render): retain a value, change it from a button callback, and confirm the window shows the new value. Read [Window](/versions/v0.7.1/docs/window) to see how `Root` hosts that view and its overlays. 2. [Element](/versions/v0.7.1/docs/element) and [RenderOnce](/versions/v0.7.1/docs/render-once): distinguish the rebuilt element tree from persistent state. Follow the runnable Brush exercise in [Paint](/versions/v0.7.1/docs/paint), and confirm a pointer press changes the drawing. 3. [Focus](/versions/v0.7.1/docs/focus), [Action](/versions/v0.7.1/docs/action), and [Event](/versions/v0.7.1/docs/event): use Tab to reach an interactive target, then trigger one command and observe its state change. Follow [Task](/versions/v0.7.1/docs/task) to run the streaming example; press Replay twice and confirm old chunks do not return. 4. [Accessibility](/versions/v0.7.1/docs/accessibility) and [Testing](/versions/v0.7.1/docs/test): follow the Save flow with a keyboard, check its focus and visible result, then run the documented UI test and confirm both the rendered status and saved model value. Check assistive technology separately on each target platform. 5. [Component catalog](/versions/v0.7.1/component): choose controls for your application; then read [Icons & Assets](/versions/v0.7.1/docs/assets) and [Fonts](/versions/v0.7.1/docs/fonts) as your interface needs them. For a tested example of retained input state and subscriptions, read the [application recipes](https://github.com/longbridge/gpui-kit/tree/main/examples/ai_recipes). The [Coding Guides](/versions/v0.7.1/docs/coding-guides) explain the conventions behind those examples. ## Complete tested view This settings view is a compiled recipe showing retained input state and subscriptions. The excerpt is synchronized with its [Rust source](https://github.com/longbridge/gpui-kit/blob/main/examples/ai_recipes/src/settings.rs). The repository's default `gpui-kit-recipes` executable opens a small bootstrap view; it does not display this settings view. The [settings interaction test](https://github.com/longbridge/gpui-kit/blob/main/examples/ai_recipes/tests/settings.rs) mounts `Settings` in a GPUI test window. From the repository root, run: ```sh cargo test -p gpui-kit-recipes --test settings ``` The test passes when typing updates the retained preview to `a`, then to `ab` after an unrelated redraw, with change counts of 1 and 2. This verifies the input subscription and state lifetime in a test window; it does not perform a native visual review. ```rust 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, preview: SharedString, changes: usize, enabled: bool, remember: bool, delivery: Option, _subscriptions: Vec, } impl Settings { pub fn new(window: &mut Window, cx: &mut Context) -> 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 { 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) -> 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") }); }), ), ) } } ``` --- # GPUI Kit Source: /versions/v0.7.1/docs License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. GPUI Kit is a Rust desktop application framework built on GPUI. The `gpui-pre` dependency name refers to the published, version-pinned GPUI snapshot used by Kit, not a different rendering framework; see [Installation](./installation#why-the-dependency-is-named-gpui-pre). GPUI Kit's core architecture has five layers: - **`gpui`**: The underlying UI runtime, entity model, windows, elements, layout, and rendering. - **`gpui-kit`**: The application entry point that re-exports GPUI and brings together Base, Component, and default assets through one dependency. - **`gpui-base`**: Unstyled behavior, controlled state, focus, overlays, virtual lists, dock infrastructure, and semantic design tokens. - **`gpui-component`**: GPUI Component, the complete styled component library with 75+ documented components and primitives, themes, data tables, dock layout, and a code editor. - **`gpui-shell`**: Opens a Rust host to JavaScript extensions, one granted capability at a time. An application normally depends on `gpui-kit` for GPUI, Base, Component, and assets. Add `gpui-shell` separately when the application hosts JavaScript extensions; it remains part of the framework's core architecture. Use `gpui-component` for polished controls with one coherent visual language, or build your own design system on the reusable behavior and infrastructure in `gpui-base`. This section covers GPUI Kit setup, shared design and coding guides, and application development. For library APIs, see [GPUI Component](/versions/component), [GPUI Base](/versions/base), and [GPUI Shell](/versions/shell). Read [Focus](./focus) for `FocusHandle`, Tab order, and the keyboard target, then [Action](./action) for command dispatch. [KeyBinding](./keybinding) explains how to bind actions and display the active shortcut. Continue with [Event](./event) for typed notifications and the relationship between Actions and Events. For the core rendering model, start with [Entity](./entity) and [Context](./context), then read [Render](./render), [RenderOnce](./render-once), and [ElementId](./element_id). [Style](./style) covers GPUI's fluent styling methods; [Element](./element) and [Paint](./paint) explain lower-level drawing. [Task](./task) covers work that continues after a callback returns. ## Learn GPUI in a working order Use these stages as a learning path. Each stage has a small application task to try before moving on: 1. **Open a window:** follow [Installation](./installation) and [Getting Started](./getting-started), run the button example, and confirm its click reaches the terminal. 2. **Own and redraw state:** make an [Entity](./entity), update it through [Context](./context), and use [Render](./render) to show the new value. Then read [Window](./window) to understand which window receives the update. 3. **Draw a custom control:** follow [Element](./element), [Geometry](./geometry), and [Paint](./paint) with the existing Brush example. Use [ElementId](./element_id) and [View Cache](./view-cache) when the drawing needs stable state or reuse. 4. **Handle input and ongoing work:** establish a keyboard target with [Focus](./focus), then connect an [Action](./action) or [Event](./event) to the View; use [Task](./task) for asynchronous work and [Animation](./animation) for motion that ends cleanly. 5. **Check the application:** use [Accessibility](./accessibility) and [Testing](./test) for interaction checks. Read [FPS Monitor](./fps) before making frame-rate claims, then choose a target such as [WebAssembly](./webassembly) or [Mobile](./mobile) if the application needs one. Each core page distinguishes a code example from its runtime result and links to the next concept. The [Coding Guides](./coding-guides) collect ownership and architecture conventions once the first window works. ## Features - **75+ Components and Primitives**: Forms, navigation, overlays, data display, editing, feedback, layout, and more. - **Production Ready**: Refined through production desktop applications and continuously tested across GPUI Kit's components and examples. Capabilities outside that desktop path are labeled by [maturity](#maturity). - **WebAssembly**: Applications and component showcases run on the web through `wasm32-unknown-unknown`. - **Accessibility**: AccessKit roles, names, states, relationships, and actions are built into the interaction layer. - **UI Integration Testing**: Headless windows exercise real pointer, keyboard, focus, layout, and accessibility behavior. - **Native Feel**: Modern controls inspired by macOS and Windows. - **High refresh support**: GPUI can target a 120 Hz display when the complete workload fits its roughly 8.3 ms frame budget; actual smoothness depends on the application, device, and presentation path. See [Frames, refresh rates, and rendering modes](./fps#120-hz-is-a-frame-budget-not-a-refresh-promise). - **Data Tables**: Virtual scrolling, fixed and resizable columns, sorting, and cell selection across hundreds of thousands of rows. - **Virtual Lists**: Render only the visible range, including differently sized items. - **Code Editor**: 200K lines, Tree-sitter highlighting, diagnostics, completion, and hover. - **Dock Layout**: Resizable panels, draggable tabs, nested splits, and edge docks. - **Rich Content**: Native Markdown and HTML, syntax highlighting, and charts. - **Design Freedom**: Use the complete visual system or build your own on `gpui-base`. - **Typed Motion**: CSS-aligned easing, timing, keyframes, springs, presence, and measured reveal with allocation-free steady sampling. - **Cross Platform**: Ship one Rust codebase to macOS, Windows, and Linux. ## Maturity GPUI Kit's desktop components run in production applications, including Longbridge's. Other capabilities have a shorter track record, so their pages carry a label under the title. A page without a label is Stable. | Label | Meaning | | --- | --- | | **Stable** | Used by production desktop applications on macOS, Windows, and Linux. | | **Preview** | Usable and documented. The API and edge-case behavior may still change between releases. | | **Experimental** | Works with known gaps. Validate it for your product before depending on it. | | **Showcase only** | Currently used to demonstrate components in a browser, not to ship applications. | | **Platform-dependent** | Availability or behavior differs by operating system or target. The page lists the differences. | A label describes the capability, not the quality of its documentation. WebAssembly, for example, runs the same components as the desktop, but for now it serves the component showcases; shipping an application in a browser is not a supported path yet. ## Quick Example After preparing the platform libraries in [Installation](./installation), create a Rust project with `cargo new gpui-hello` and enter it with `cd gpui-hello`. Add `gpui-kit` to its `Cargo.toml`: ```toml [dependencies] gpui-kit = "0.7.1" ``` Replace `src/main.rs` with this complete "Hello, World!" application: ```rust use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::*; struct HelloWorld; impl Render for HelloWorld { fn render(&mut self, _: &mut Window, _: &mut Context) -> 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"); }); } ``` Run `cargo run` from the project directory. The window shows a label and button; clicking the button prints `Clicked!` in the terminal. Continue with [Getting Started](./getting-started) for the initialization sequence, `Render`, and retained state. ## Community & Support Learn how to build interruptible animation in the [GPUI Base Motion guide](/versions/base/motion). - [GitHub Repository](https://github.com/longbridge/gpui-kit) - [Issue Tracker](https://github.com/longbridge/gpui-kit/issues) - [Contributing Guide](https://github.com/longbridge/gpui-kit/blob/main/CONTRIBUTING.md) ## License Apache-2.0 --- # Geometry Source: /versions/v0.7.1/docs/geometry License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/geometry and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. # Geometry and color GPUI uses types to say what a number *means*. A coordinate is a `Point`, an extent is a `Size`, and a rectangle is a `Bounds`. The `T` says which unit the components use. Layout accepts lengths that may still need a parent size; drawing and hit testing usually use resolved `Pixels`. Colors distinguish a hue based representation (`Hsla`) from direct channels (`Rgba`). GPUI Kit reexports GPUI, so application examples can start with `use gpui_kit::*;`. ## Points, sizes, and bounds | Type | Fields | Meaning | | --- | --- | --- | | `Point` | `x`, `y` | A position in a coordinate space. | | `Size` | `width`, `height` | An extent, without a position. | | `Bounds` | `origin: Point`, `size: Size` | An axis aligned rectangle. | The free functions `point(x, y)`, `size(width, height)`, and `bounds(origin, size)` infer `T` from their arguments. They also work with ordinary numeric types, but UI geometry normally uses `Pixels`: ```rust use gpui_kit::*; let frame: Bounds = bounds( point(px(20.), px(40.)), size(px(240.), px(80.)), ); assert_eq!(frame.right(), px(260.)); assert_eq!(frame.bottom(), px(120.)); assert!(frame.contains(&point(px(20.), px(40.)))); assert!(!frame.contains(&point(px(260.), px(40.)))); let midpoint: Point = frame.center(); let local = point(px(35.), px(55.)).relative_to(&frame.origin); assert_eq!(local, point(px(15.), px(15.))); ``` `right()` and `bottom()` add the extent to the origin. `contains()` includes the top and left edges but excludes the bottom and right edges, so adjacent rectangles do not both claim a point on their shared boundary. `center()`, `intersects()`, `Bounds::from_corners(...)`, and `Bounds::centered_at(...)` help with alignment and placement. A local position and a window position can both be `Point`: the type checks the unit, while your code must still track which origin it uses. Add the bounds origin when painting locally measured geometry in window coordinates; subtract it when interpreting a pointer position within an element. These values typically appear after layout. A custom [`Element`](./element) receives `Bounds` in `prepaint` and uses the same resolved geometry for hitboxes and later [painting](./paint). A bounds value is geometry, not an interactive region by itself. ## Choose a coordinate origin `Point` records a unit, not a coordinate system. In a window, the top-left of the window content is the usual origin; an element's own top-left is a different origin. State the space in variable names when both appear in one calculation: ```rust use gpui_kit::*; let element_bounds = bounds(point(px(100.), px(60.)), size(px(80.), px(40.)); let pointer_in_window = point(px(125.), px(75.)); let pointer_in_element = pointer_in_window.relative_to(&element_bounds.origin); assert_eq!(pointer_in_element, point(px(25.), px(15.))); let marker_in_element = point(px(10.), px(8.)); let marker_in_window = element_bounds.origin + marker_in_element; assert_eq!(marker_in_window, point(px(110.), px(68.))); ``` The `bounds` passed to a custom element's `prepaint` and `paint` is already positioned in window coordinates. A pointer event's `position` is also in window coordinates. Test `element_bounds.contains(&pointer_in_window)` directly; convert to local coordinates only for work such as locating a character or handle *inside* the element. Do not add `element_bounds.origin` a second time to a rectangle already based on those bounds. Conversely, a local point cannot be compared directly with a window-space hitbox even though both have type `Point`. ## From layout to input and drawing The phases answer different questions: | Phase | Available geometry | Responsibility | | --- | --- | --- | | `request_layout` | Style lengths and layout nodes; some lengths still depend on the parent. | Return a `LayoutId` for the layout engine to solve. | | `prepaint` | Resolved `Bounds` for this frame. | Prepare geometry and, when needed, call `window.insert_hitbox(bounds, HitboxBehavior::Normal)`. | | `paint` | The resolved bounds and prepared state. | Draw with methods such as `window.paint_quad(fill(bounds, color))` and register frame-local input listeners. | The layout tree, hitboxes in the dispatch tree, and painted scene are separate. Painting a rectangle does not make it clickable; inserting a hitbox does not draw it. The hitbox returned by `insert_hitbox` can be carried as `PrepaintState` into `paint`, where a listener can test `hitbox.is_hovered_at(event.position, window)`. A hitbox records the content mask active when it was inserted, so establish clipping before inserting child hitboxes. See the [custom Element walkthrough](./element) for a complete event handler. ## Scrolling, clipping, and a worked calculation A scroll viewport and its content have different origins. GPUI Kit's [virtual list implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/virtual_list.rs) uses a **negative** scroll offset, adds that offset when positioning items in window space, and prepaints them under a `ContentMask` for the viewport. Its `visible_range` first selects items in content space; the mask then limits drawing and input to the visible region. Selecting a visible item does not itself clip its pixels. ### Run the coordinate calculation From the repository root, create `examples/hello_world/src/bin/geometry_walkthrough.rs` (create the `bin` directory if needed). This is a second binary in the existing `hello_world` package, not a new crate. Paste the complete program below, then run `cargo run -p hello_world --bin geometry_walkthrough`. The vertical viewport starts at window `(100, 60)` and has size `80 × 40`. Item A starts at content `y = 20`, has height `20`, and the scroll offset is `-30`. The pointer position is in window coordinates. The program converts it to item-local coordinates and calculates each item's intersection with the viewport: ```rust use gpui_kit::*; fn main() { let viewport = bounds(point(px(100.), px(60.)), size(px(80.), px(40.))); let scroll_y = px(-30.); let item_a = bounds( viewport.origin + point(px(0.), px(20.) + scroll_y), size(px(80.), px(20.)), ); assert_eq!(item_a.origin, point(px(100.), px(50.))); let pointer_in_window = point(px(105.), px(65.)); let pointer_in_item = pointer_in_window.relative_to(&item_a.origin); assert_eq!(pointer_in_item, point(px(5.), px(15.))); let visible_a = item_a.intersect(&viewport); assert_eq!(visible_a.origin, point(px(100.), px(60.))); assert_eq!(visible_a.size, size(px(80.), px(10.))); assert!(visible_a.contains(&pointer_in_window)); let item_b = bounds( viewport.origin + point(px(0.), px(80.) + scroll_y), size(px(80.), px(20.)), ); assert_eq!(item_b.origin.y, px(110.)); assert!(!item_b.intersects(&viewport)); println!( "Item A: local pointer = ({}, {}), visible height = {}; Item B visible = {}", pointer_in_item.x.as_f32(), pointer_in_item.y.as_f32(), visible_a.size.height.as_f32(), item_b.intersects(&viewport), ); } ``` The expected output is `Item A: local pointer = (5, 15), visible height = 10; Item B visible = false`. The assertions also fail immediately if the scroll offset sign or coordinate origin is wrong. Item A begins 10 pixels above the viewport; only its bottom 10 pixels intersect it. Item B begins at window `y = 110`, beyond the viewport's bottom edge at `y = 100`. You can remove the exercise file after running it. `intersect()` computes a rectangle; it does **not** clip drawing or input by itself. In a real custom element, apply the viewport's `ContentMask` while prepainting and painting children, so child hitboxes inherit the mask and painted pixels are clipped. Use the resulting hitbox to resolve pointer handling. Ordinary scroll containers manage this for you. For a custom one, keep content positioning, clipping, and hitboxes in the same coordinate space, and clamp the scroll offset to the content range as the virtual list does. Common mistakes are treating `relative(0.5)` as 0.5 pixels before layout, comparing local coordinates with window bounds, subtracting a negative scroll offset when positioning content, or assuming `contains()` clips a painted child. For a mismatch, write down the origin and unit of each intermediate value, then inspect the resolved bounds and current content mask. ## Edges, sides, and placement `Edges` holds four independent values in `top`, `right`, `bottom`, `left` order. Use `Edges` for resolved insets such as padding, borders, or the space reserved around a window. `Edges::all(value)` gives every side the same value; specify fields when they differ: ```rust use gpui_kit::*; let padding: Edges = Edges { top: px(8.), right: px(12.), bottom: px(8.), left: px(12.), }; let uniform = Edges::all(px(4.)); ``` The `Edges` imported by `use gpui_kit::*` is GPUI's type. GPUI Kit also has `gpui_kit::base::Edges` (reexported as `gpui_kit::component::Edges`) for values that need serialization or a JSON schema. They have the same four fields but are different Rust types; use the one required by the API you call. [`Placement`](https://docs.rs/gpui-base/latest/gpui_base/enum.Placement.html) is GPUI Kit's choice of **one side** of a trigger: `Top`, `Right`, `Bottom`, or `Left`. It is not a rectangle or a set of four insets. For example, `Positioner::side` treats `Placement::Bottom` as a *preferred* side; it may flip to `Top` if the popup does not fit below the trigger, then clamps the result inside the viewport. `ResolvedPosition::placement` reports the side actually chosen: ```rust use gpui_kit::*; use gpui_kit::base::{Align, Placement, Positioner}; let trigger_bounds = bounds(point(px(40.), px(40.)), size(px(100.), px(32.))); let popup = Positioner::side(trigger_bounds) .placement(Placement::Bottom) .align(Align::Start) .offset(px(8.)) .child(div().child("Menu")); ``` Here `trigger_bounds` is a `Bounds` in window coordinates. `Side` is the narrower GPUI Kit enum for `Left` or `Right`, and `Axis` expresses `Horizontal` or `Vertical`. GPUI's `Anchor` identifies a reference point such as `TopLeft` or `BottomCenter`; `Corners` holds four corner values, often radii. Neither is a substitute for `Placement`. See [Window](./window) for window-local coordinates and scale. ## Why `Pixels` instead of `int` or `float`? `px(12.)` produces `Pixels`, a wrapper around `f32`. Fractions matter for text metrics, animation, and positioning before rasterization. Integers would discard that precision. A bare `f32` could mean a coordinate, a scale factor, an opacity, or a fraction of a parent; it gives the compiler no way to catch a mix-up. For example, adding two pixel distances is meaningful, and multiplying a distance by a scalar stays in pixels: ```rust let inset: Pixels = px(8.); let width: Pixels = px(120.) - inset * 2.; let raw: f32 = width.as_f32(); // Convert only at an API boundary that needs f32. ``` `Pixels` are GPUI's logical UI pixels, not necessarily physical display pixels. `Pixels::scale(factor)` produces `ScaledPixels`; `DevicePixels` represents integer device pixel counts. For example, `px(12.).scale(2.)` is 24 scaled pixels, but it is still a distinct type from `DevicePixels(24)`. Keep those units distinct when crossing a display or raster boundary. The wrapper cannot prove that two `Point` values share the same origin, or that a width is nonnegative; those remain application responsibilities. ## Lengths before layout, pixels after layout A [style](./style) length can depend on context. GPUI expresses this with nested types: | Type | Values | Use | | --- | --- | --- | | `AbsoluteLength` | `Pixels` or `Rems` | A fixed UI length or one based on the root text scale. | | `DefiniteLength` | `AbsoluteLength` or a parent fraction | A length with a specified value, possibly relative. | | `Length` | `DefiniteLength` or `Auto` | A layout value that may be chosen by the layout engine. | `px(24.)` makes `Pixels`; `rems(1.5)` makes `Rems`; `relative(0.5)` makes `DefiniteLength::Fraction(0.5)`, or half the relevant parent dimension. `auto()` makes `Length::Auto`. Conversions from `Pixels`, `Rems`, and `DefiniteLength` into `Length` are available. Which values a style method accepts depends on that method's signature; let inference handle the conversion when using a builder: ```rust use gpui_kit::*; let panel = div() .w(relative(0.5)) .min_w(px(240.)) .h(rems(3.)); ``` The width remains relative until layout knows the parent. A rem needs the root rem size. `auto` asks layout to choose a value under its rules; it is not zero. GPUI passes these values to the layout engine and receives pixel bounds. `Percentage` is a separate `Percentage(f32)` wrapper made with `percentage(0.25)`. Its helper expects a fraction from `0.0` to `1.0` (asserted in debug builds), and GPUI can convert it to `Radians` as a portion of a full turn. It is **not** the type used by `relative(0.25)` for 25% layout width. For relative layout, use `relative`; for a percentage of a circle, use `percentage`. ## HSLA and RGBA GPUI's [`Hsla`](https://docs.rs/gpui-pre/0.3.8/gpui/struct.Hsla.html) stores hue, saturation, lightness, and alpha as values from 0 to 1. Its `hsla(0.6, 0.8, 0.5, 1.)` constructor uses a hue fraction, not degrees, and clamps its four inputs to that range. Use `Hsla` as the default representation for theme colors and their interaction states. [`Rgba`](https://docs.rs/gpui-pre/0.3.8/gpui/struct.Rgba.html) stores red, green, blue, and alpha channels in the same range. `rgb(0x3366CC)` reads a six digit RGB hex value and sets alpha to 1; `rgba(0x3366CC80)` reads eight digits in **RRGGBBAA** order, with alpha `128 / 255` (about 0.502). Convert to `Rgba` when an RGB channel API or hex color is the natural input. ```rust use gpui_kit::*; let tint: Hsla = hsla(0.6, 0.8, 0.5, 1.); let translucent = tint.opacity(0.5); // Multiply the existing alpha. let exact_alpha = tint.alpha(0.5); // Replace the alpha. let again: Rgba = translucent.to_rgb(); let source: Rgba = rgb(0x3366CC); let from_hex_alpha: Rgba = rgba(0x3366CC80); let red_channel: f32 = from_hex_alpha.r; ``` HSLA makes it easier to change hue, saturation, or lightness while retaining the other components. RGBA is direct for hex assets, channel values, and compositing. GPUI converts between them; conversion can incur floating point rounding, so it is not a promise of byte exact round trips. HSL lightness is also not perceptual brightness: equal `l` values across different hues need not look equally bright. GPUI Kit provides `Colorize::mix_oklab` when a perceptual color interpolation is useful. GPUI's `Hsla::blend(other)` places `other` over `self` by converting through RGBA. Its `Rgba::blend` interpolates RGB channels using the overlay alpha and keeps the receiver's alpha; it is useful for the opaque background case, but should not be described as a general alpha compositing equation for two translucent layers. ## Theme colors and interaction states GPUI Kit stores semantic colors as `Hsla` theme values and exposes resolved tokens for components. A primary button uses `button_primary`, `button_primary_hover`, and `button_primary_active` tokens for its states. Use those tokens for an existing component instead of inventing a local lightness adjustment. Themes may supply each token explicitly, including a background gradient. When a token is absent, GPUI Kit derives a fallback from the theme: primary hover blends the background with primary after multiplying primary's existing alpha by 0.9; primary active multiplies primary's lightness by 0.9 in light mode or 0.8 in dark mode. Button primary state tokens fall back to those primary state tokens. Other variants have their own fallbacks, so there is no universal hover formula. For a custom solid color, `Colorize` has `lighten`, `darken`, `hue`, `saturation`, and `lightness` on `Hsla`: ```rust use gpui_kit::*; use gpui_kit::component::Colorize; let base: Hsla = hsla(0.6, 0.8, 0.5, 1.); let darker = base.darken(0.1); // l = base.l * (1 - 0.1) let quieter = base.opacity(0.6); // a = base.a * 0.6 ``` `lighten(f)` multiplies lightness by `1 + f`; `darken(f)` multiplies it by `1 - f`. They do not add or subtract percentage points. Unlike the `hsla(...)` constructor, `Colorize::lighten` does not clamp its result and can produce an `l` above 1, so inspect resulting colors before using them as a theme. `Colorize::opacity` and GPUI's `Hsla::opacity` both multiply alpha. Use semantic theme tokens for control states, and check text contrast and both theme modes when defining new colors. --- # WebAssembly Source: /versions/v0.7.1/docs/webassembly License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/webassembly and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. **Current scope** GPUI and GPUI Kit WebAssembly support is used primarily to **showcase and try components in a browser**. The gallery and Base showcase build and run, but this repository has not validated WebAssembly as a mature distribution path for full applications. Treat browser support, accessibility, input, startup cost and deployment as work to verify for each product. GPUI Kit can render the same Rust views and components in a browser. The web target is `wasm32-unknown-unknown`: Rust produces a WebAssembly module, `wasm-bindgen` produces its JavaScript bindings, and a web page loads and starts the application. The browser supplies the canvas, input and network environment, so a desktop `main` function alone is not a web entry point. In this workspace, [`gpui_web` is the Cargo alias for `gpui-pre-web` 0.3.8](https://github.com/longbridge/gpui-kit/blob/main/Cargo.toml). [`gpui-kit` includes it as a WASM-only dependency](https://github.com/longbridge/gpui-kit/blob/main/crates/kit/Cargo.toml) and re-exports it as `gpui_kit::web`; [the gallery crate](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/Cargo.toml) depends on `gpui-kit`, not directly on `gpui-pre-web`. Its `cdylib`, exported `run(...)`, web platform initialization and JavaScript loader provide the browser entry path that the desktop `main` cannot provide. The [component gallery](https://gpui-kit.com/gallery/) is the quickest working example. Its [Rust entry point](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/src/lib.rs), [build script](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/scripts/build-wasm.sh) and [JavaScript loader](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/www/src/main.js) show the complete path from a GPUI Kit view to a browser page. ## Run the gallery locally Install Rust, Bun and `make`. Run these commands from the repository root (the directory containing the workspace `Cargo.toml`): ```sh cd crates/story-web rustup target add wasm32-unknown-unknown cargo install wasm-bindgen-cli --version 0.2.121 make dev ``` Keep `make dev` running and open **http://localhost:3000/gallery/**. You should see the gallery's component list and a rendered view; the loading indicator alone does not confirm that graphics started. The first Rust build can take longer than later builds. `make dev` builds the WASM module in debug mode, generates bindings in `www/src/wasm/`, installs the web dependencies and starts Vite. Check the result in this order: 1. The terminal reaches Vite's local URL without a Rust or `wasm-bindgen` error. 2. The browser Network panel shows the generated JavaScript and `.wasm` requests succeeding. Open the `/gallery/` URL above, not the root of the Vite server. 3. The loading indicator gives way to the gallery. Select a story and try a control, such as a button, to check that input reaches the Rust view. A removed loading indicator by itself only proves that the JavaScript loader called `run(...)`. If you edit a Rust view, keep Vite running in one terminal and run `make build-wasm-dev` from `crates/story-web` in another, then reload the page. Editing the loader or other `www/` files is handled by Vite. This distinction matters because Vite does not compile the Rust crate for you. The local [toolchain file](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/rust-toolchain.toml) selects nightly; the `wasm-bindgen-cli` version above matches this checkout's [`Cargo.lock`](https://github.com/longbridge/gpui-kit/blob/main/Cargo.lock). If the lockfile changes, match the CLI to the locked `wasm-bindgen` crate version. If another CLI version is already installed, check `wasm-bindgen --version` and reinstall the pinned version with `cargo install -f wasm-bindgen-cli --version 0.2.121`. The [build script](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/scripts/build-wasm.sh) sets an 8 MiB linker stack for the gallery's large render tree, including in debug builds. For a production gallery build, run `make build-prod` from the same directory. The site bundle lands in `www/dist/` and uses the `/gallery/` base path. Deploy it at that path, or change both the [Vite base](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/www/vite.config.js) and the [Rust asset endpoint](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/src/lib.rs) for your own host. ## How the web entry point works The gallery's `run(story, dark, theme_name, theme_json)` is exported with `#[wasm_bindgen]`. Its [loader](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/www/src/main.js) imports the generated JS module, awaits its default WASM initialization, reads the optional `?story=` URL parameter and host theme, then calls `run(...)`. On the Rust side, it calls `gpui_kit::platform::web_init()`, creates a web `Application`, registers assets, and passes a launch closure to `run_embedded`. Once graphics initialization succeeds, that closure calls `gpui_component_story::init(cx)` (which calls `gpui_kit::init(cx)`), loads fonts, applies the theme, and calls `gpui_kit::open_window`. The `ApplicationHandle` returned by `run_embedded` is retained in thread-local storage. Graphics initialization is asynchronous, so that return does not mean the launch closure ran or the first frame painted. For your application, initialize Kit before constructing components, as in [Getting Started](/versions/v0.7.1/docs/getting-started), and keep the handle alive while the page uses the view. The gallery uses `WebPlatform::new_with_backend_and_font_fallback` and attaches its fetch HTTP client. `Auto` tries WebGPU and then WebGL2 if WebGPU fails. The current web platform uses one document canvas and one top-level window; another top-level window, or reopening a closed one, is unsupported. Render dialogs inside that window through Kit's `Root`. Reuse the [gallery entry point](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/src/lib.rs) when adapting your own app; keep shared views in Rust, with the page responsible for loading the WASM module and hosting it. ### Trace one launch | Stage | File or API | What it establishes | | --- | --- | --- | | Compile | `scripts/build-wasm.sh` | `cargo rustc` builds the gallery's `cdylib` for `wasm32-unknown-unknown`, then `wasm-bindgen --target web` writes JavaScript bindings and the browser-loadable module into `www/src/wasm/`. | | Load | `www/index.html` and `www/src/main.js` | The page shows a loading state; the loader imports the generated bindings and awaits their default initializer. This is the JavaScript-to-Rust boundary. | | Start | `run(...)` in `src/lib.rs` | Rust creates the web platform, supplies assets and keeps the `ApplicationHandle` alive. `run_embedded` starts graphics initialization asynchronously. | | Open | The launch closure in `src/lib.rs` | Once graphics are ready, it initializes Kit, registers fonts, applies a theme and opens the single GPUI window. Only then can the first view be painted. | Use this sequence when moving a desktop app to the browser: retain its GPUI view and state code where the target supports it, then provide a web entry point, a page loader and browser-specific resources. A successful Rust target build does not test the page loader or graphics initialization. ## Fonts and CJK text The GPUI Web platform starts with **no installed font database**. In the gallery, `include_bytes!` embeds four subset TTF files in the WASM module: Inter for UI text, JetBrains Mono for code, Noto Sans SC for the Chinese characters used by the stories, and IBM Plex Sans for GPUI's `.SystemUIFont` alias. The last family must be loaded before the first window, because even initial text measurement can use the default window style and fail when the family is absent. After loading them with `cx.text_system().add_fonts(...)`, the gallery applies its theme and forces its UI and mono family back to bundled fonts; a selected theme can otherwise name a desktop-only family. See the [font setup and theme code](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/src/lib.rs) and [Fonts](/versions/v0.7.1/docs/fonts). The [subset script](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/scripts/subset-fonts.py) scans the story sources, plus the files they pull in with `include_str!` such as the README, and retains their characters. In this checkout, the Noto Sans SC subset is **42,092 bytes** versus **1,213,236 bytes** for its checked-in source TTF. Those are font file sizes, not the change in compressed WASM transfer size. New text entered by a visitor is not guaranteed to be in that subset. For eligible missing emoji and horizontal Han, kana and modern Hangul graphemes, `CanvasFontFallback::EmojiAndCjk` can let the browser measure and draw from its local fonts. GPUI's loaded fonts remain preferred. This fallback works on individual graphemes, so browser font coverage, spacing and typography can differ; it is not a full CJK font replacement. The default policy covers emoji only, while `Disabled` uses loaded fonts alone. The policy is chosen when constructing `WebPlatform` and cannot be changed later. GPUI also supports **loading a font after startup**. In the GPUI version pinned by this repository (`gpui-pre` 0.3.8), `TextSystem::add_fonts` accepts downloaded font bytes through `Cow::Owned`; it clears font resolution and line-layout caches. After an asynchronous fetch has produced a valid raw font file, install it on the application context and redraw: ```rust use std::borrow::Cow; // cx: &mut App; font_bytes: Vec fetched and checked by your app. cx.text_system().add_fonts(vec![Cow::Owned(font_bytes)])?; cx.refresh_windows(); ``` Use a raw supported font file such as the TTF in GPUI Web's `examples/hello_web/dynamic_fonts.rs`, not a font-service CSS response that may refer to WOFF2 subsets. That example uses `cx.on_missing_glyphs(...) -> Subscription` to request a font when needed, retains the subscription and download task, checks for a successful HTTP status and nonempty body, then calls `add_fonts` and `refresh_windows`. Font parsing is checked by `add_fonts`; an HTTP 200 alone does not establish that the response is a usable font. Missing-glyph reports are deduplicated and bounded; a dropped report does not schedule its own retry. The gallery currently **does not** download fonts this way; it embeds subsets and uses Canvas fallback. GPUI Web applies Canvas fallback before reporting missing glyphs, so CJK it draws successfully through Canvas will **not** trigger `on_missing_glyphs`. For high-quality CJK typography, decide which font to fetch from your content or language selection, show a loading or failure state, and test layout after installation. A font downloaded from another origin must also satisfy that host's browser CORS policy. ## A smaller GPUI Base example The [GPUI Base WASM showcase](https://github.com/longbridge/gpui-kit/tree/main/crates/base/examples/wasm) compiles the same [showcase views](https://github.com/longbridge/gpui-kit/tree/main/crates/base/examples/showcase) used by its native example. To run it independently from the repository root, install the matching `wasm-bindgen-cli` if you have not already done so: ```sh cd crates/base/examples/wasm rustup target add wasm32-unknown-unknown --toolchain nightly cargo install wasm-bindgen-cli --version 0.2.121 make dev ``` Keep the server running and open **http://localhost:3001/examples/base/**. It uses `gpui_platform::single_threaded_web()`, generates bindings with `wasm-bindgen`, and serves the examples through Vite. Its `run(component)` export selects a showcase view; the JavaScript loader reads `?component=...` from the URL. The CLI version again follows the workspace `Cargo.lock`. See its [build script](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/wasm/scripts/build.sh) and [loader](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/wasm/www/src/main.js). The gallery is the better starting point for styled GPUI Kit components; this example isolates the Base primitives. ## Troubleshoot a failed load or blank canvas Open the browser's **Console and Network** panels first. The gallery loader catches failures while importing the bindings, initializing WASM or calling `run`, and replaces its loading indicator with “Failed to load the application”. Graphics initialization happens later on the web platform's async task; a failure there is logged and a “Failed to initialize browser graphics” message is appended to the page. The loader can therefore disappear before the first frame appears. | Symptom | Check | | --- | --- | | Rust build cannot find the WASM target, or `wasm-bindgen` is not found | Run `rustup target add wasm32-unknown-unknown` in `crates/story-web` so its selected nightly toolchain receives the target. Install the CLI version in `Cargo.lock`, then rerun `make build-wasm-dev`. The Rust compiler build must finish before a Vite page can load the module. | | JS or `.wasm` request is 404 | Open `/gallery/`, not the site root. Keep Vite's `base: '/gallery/'`, the deployment path and generated files together. Rebuild with `make build-wasm-dev` after Rust changes; ensure the generated `www/src/wasm/` files were included in the web build. | | `wasm-bindgen` import or instantiation fails | Match `wasm-bindgen-cli` to the locked crate version and regenerate bindings. Serve `.wasm` as `application/wasm`; the generated loader can fall back from streaming compilation when MIME is wrong, but that is slower. Check the Network response instead of assuming the requested URL returned a module. | | Loading indicator vanishes but canvas stays empty | Read Console errors after `run` returns. `Auto` tries WebGPU and then WebGL2; if both fail, check browser GPU support, policy and hardware acceleration. The web runtime cannot create a second top-level window. | | Panic or missing text at startup | Register IBM Plex Sans and every initial theme family before opening the window. The gallery's `add_fonts(...).expect(...)` and `open_window(...).expect(...)` turn errors into a panic; its panic hook reports them in Console. | | Squares or wrong CJK appearance | Confirm the text is in the bundled subset, then check the Canvas fallback policy and actual browser font coverage. Download a suitable raw font when exact shaping matters. | | Blank icons | Inspect the exact icon URL, HTTP status and CORS. `Assets::load` concatenates the endpoint and `/assets/icons/...` literally: an endpoint ending in `/`, as in the gallery, produces `//assets/` in the URL. Set an endpoint without a trailing slash in your app. The gallery's fixed endpoint points to the published site even during local development. Its asset loader caches a successful fetch but does not itself request a window refresh, so if the icon remains blank, trigger another render and check the Console for asset errors. | ## Package size and delivery `include_bytes!` puts the gallery's subset fonts **inside the WASM payload**, so visitors download them with the module before they can see a first frame. The gallery also bundles the component stories and rendering stack; icon SVGs are separate on-demand requests. Vite packages the JavaScript loader and WASM for `/gallery/`, but changing only the Vite base will not change the Rust icon endpoint. After `make build-prod`, `ls -lh www/dist/assets/*.wasm` shows the uncompressed module size. Compare the **compressed transfer sizes** of that module and JS in the browser Network panel, and record the first-load time on a throttled connection. Local file size, CDN compression, browser cache state, compilation time and GPU initialization are different costs. Before publishing a copy of this gallery, serve `www/dist/` from the intended `/gallery/` path and repeat the three browser checks above against that served copy. Confirm that the generated `.wasm` and JS URLs resolve on the deployed host, and that icon, theme and font requests reach the hosts you intended. The example's asset endpoint is hard-coded to the published GPUI Kit site, so a successful local gallery does **not** prove that a separately hosted copy serves its own icons. Record a cold load and a warm load separately; browser caching changes the result. The repository's [release workflow](https://github.com/longbridge/gpui-kit/blob/main/.github/workflows/release-website.yml) builds both WASM examples and copies their `dist/` files under the corresponding website paths, but it does not replace browser testing on the final host. For example, **if** a full application such as Longbridge Pro were compiled into one WASM module, including all of its features and font coverage, the initial download and startup cost could grow substantially. That is a distribution risk to measure, not a measured package size or a claim that such an application currently ships on the web. Keeping fonts or features behind later requests can reduce initial transfer, but adds network, caching, CORS and loading-state work. The gallery's font subsets and on-demand icons illustrate those tradeoffs; they do not establish an application-size budget. ## Web capabilities to plan for | Concern | What the examples do | What to check in your app | | --- | --- | --- | | Assets | On WASM, `Assets::new(endpoint)` fetches icon SVGs on demand by concatenating `endpoint` and `/assets/icons/...`. The gallery's endpoint points to its published site, while its Vite build copies icons under `/gallery/assets/`. | Host the requested paths and use an endpoint matching your deployment path, without a trailing slash. Local gallery runs still request icons from the published endpoint unless you change it. A missing asset or failed fetch can leave an icon blank. See [Icons & Assets](/versions/v0.7.1/docs/assets). | | Fonts | The gallery embeds Inter, JetBrains Mono, a Noto Sans SC subset and IBM Plex Sans before its first frame, then restores its font choices when applying a theme. | Bundle initial families; consider runtime font downloads for larger scripts and validate fallback and layout. See [Fonts](/versions/v0.7.1/docs/fonts). | | Keyboard and IME | The web platform uses a small hidden HTML input for keyboard and composition events. The gallery loader manages focus when embedded and disables mobile text entry on touch-only devices to avoid raising a keyboard for a canvas interaction. | Test focus, Tab order, composition and on-screen keyboards in the browsers and devices you support. The gallery's touch-only policy is specific to a showcase, not a general text-input solution. | | [Accessibility](/versions/v0.7.1/docs/accessibility) | Components can express roles and labels in GPUI, while this web example is painted into a canvas. | Verify actual screen-reader and keyboard behavior in the browser. Do not assume that a native accessibility bridge or a GPUI accessibility property produces equivalent web semantics. Provide an accessible HTML alternative for content or actions that your target browser cannot expose. | | Native services | The browser supplies fetch and its own input and rendering APIs; desktop facilities have different availability and permissions. | Put file dialogs, clipboard, notifications and similar features behind target-specific capability code and test the browser path. See [Coding Guides](/versions/v0.7.1/docs/coding-guides#platform-and-capability-boundaries). | The release workflow [builds both WASM examples](https://github.com/longbridge/gpui-kit/blob/main/.github/workflows/release-website.yml), so these entry points also serve as maintained build references. A successful WASM build establishes compilation; interaction, font coverage and accessibility still need browser checks. --- # Coding Guides Source: /versions/v0.7.1/docs/coding-guides License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/coding-guides and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. This guide describes the application architecture and code patterns that have proved durable in GPUI Kit. It is written for both engineers and coding agents. Read [Design Guides](/versions/v0.7.1/docs/design-guides) first: code structure should preserve product intent, not replace it. This is a normative guide. **Must** marks lifecycle, correctness, or ecosystem constraints; **should** is the default architecture and requires a concrete reason to depart from it. Current source and API docs remain authoritative for exact signatures. ## Make the common path inexpensive **Design principle: choose framework types and ownership so ordinary component use is inexpensive by default.** Developers should be able to build and reuse UI without adding a cache at every call site. [SharedString](./shared-string) for retained UI text and [RenderOnce](./render-once) for lightweight component values are examples: the API places ownership where it belongs and avoids repeated work in the common path. Small costs still multiply across elements and renders; measure exceptional hot paths before adding caches. Neither type promises zero cost. The [text ownership rule](#own-persistent-ui-text-with-sharedstring) and later rendering sections explain the mechanics. ## Architecture at a glance GPUI application architecture layers GPUI application architecture layers Dependencies point downward. Higher layers own domain meaning and orchestration; lower layers own reusable presentation or behavior. Do not make a reusable component depend on an application screen, or make `gpui-base` depend on a theme from GPUI Component. Use these boundaries: - **app shell:** compose windows and feature crates while keeping feature logic out; - **feature crate:** keep one capability's model, services, views, commands, dialogs, and workflow behind one public boundary; - **app component:** a repeated domain-aware pattern; - **gpui-component:** themed, general-purpose UI; - **gpui-base:** reusable behavior and geometry without product presentation. ### Organize large applications by capability In a large Rust application, a complex capability often merits its own crate once its state, lifecycle, and public boundary are worth maintaining independently. Keep its model [Entity](./entity), services, [Render](./render) Views, commands, dialogs, and workflow together. A dialog that edits a workspace belongs to the workspace feature; only the reusable dialog primitive belongs to the UI library. A small capability can remain a module until a crate boundary has a concrete benefit. ```text crates/ ├── app/ │ └── src/main.rs # Compose windows and features ├── workspace/ │ └── src/ │ ├── lib.rs # The feature's public boundary │ ├── model.rs │ ├── commands.rs │ ├── workspace_view.rs │ └── rename_dialog.rs ├── search/ │ └── src/ │ ├── lib.rs │ ├── model.rs │ ├── commands.rs │ ├── search_view.rs │ └── filters.rs ├── settings/ │ └── src/ │ ├── lib.rs │ ├── model.rs │ ├── settings_view.rs │ └── account_dialog.rs └── shared/ └── src/ ├── lib.rs └── recent_items.rs # A stable capability with multiple owners ``` Do not invert this into global `models/`, `views/`, `modals/`, and `commands/` directories. Those folders classify files by implementation role while scattering every feature across the application. The application shell composes feature crates but contains little feature logic. Each feature owns its model Entities and the Views that render them. Keep a feature's [`Global`](./global) private if it is needed for a genuinely application-wide service or registry; a model used by one feature is not automatically a Global. Within a feature, pass cloned `Entity` handles to cooperating owners instead of repeatedly copying a large `Vec` or collection. Across a feature boundary, expose only a deliberately public Entity type or a small handle, domain ID, command, event, or interface. Keep model fields and implementation modules private. For example, the workspace crate can export a cloneable handle while retaining the mutable model behind its public API: ```rust // Condensed workspace/src/lib.rs; move the private model to model.rs as it grows. use gpui_kit::{App, Entity, SharedString}; struct WorkspaceModel { name: SharedString, } #[derive(Clone)] pub struct WorkspaceHandle { model: Entity, } impl WorkspaceHandle { pub fn rename(&self, name: SharedString, cx: &mut App) { self.model.update(cx, |model, cx| { model.name = name; cx.notify(); }); } } ``` An application may hold this handle and pass a workspace ID to search; search does not need the workspace's entire collection or its private View. A simple acyclic dependency shape is: ```text app ──▶ workspace ──▶ shared contracts └────▶ search ─────▶ shared contracts workspace, search ──▶ gpui-kit ``` Features may depend on stable shared capabilities and UI foundations, but not on the shell or a sibling's private modules. If one feature must call another, make that dependency explicit and one-way, or move a genuinely shared contract below both. Extract a shared crate only after it has a coherent purpose and real owners; avoid cycles and a catch-all `shared` crate. These boundaries let different engineers or AI agents work on separate capabilities in parallel with fewer file conflicts and less shared-state coupling. Shared contracts still require coordination. Cargo may also reuse cached dependencies and rebuild a smaller subgraph after a local change, but incremental build speed depends on the dependency graph, public API changes, features, and build configuration; adding crates has overhead and does not guarantee a faster edit-build cycle. Split where ownership and collaboration justify the boundary, not for every screen or helper. ## Bootstrap and root ownership Initialize GPUI Component once, before creating component-backed views, and put `Root` at the first level of each window: ```rust app.run(move |cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |window, cx| { cx.new(|cx| Workspace::new(window, cx)) }) .expect("failed to open window"); }); ``` `Root` coordinates window-level component facilities such as overlays and notifications. Do not create a separate root for each page inside one window. It also coordinates modal focus restoration, focus traps, tooltip/menu layers, and window-scoped text selection. Bypassing it can produce behavior that looks correct at rest but fails when overlays nest or focus changes quickly. ## Understand GPUI's phases and contexts GPUI is retained state with declarative rendering. An [Entity](./entity) survives across frames; the [Element](./element) tree returned by `render` is a fresh description of the current frame. Keep that distinction explicit. See [Context](./context) for the scopes of `Context`, `App`, and `Window`. - `Context` mutates the current entity, creates listeners tied to it, emits its events, and notifies its observers. - `App` gives access to application globals and entity reads/updates without implying ownership by the rendered element. - `Window` owns focus, actions, input dispatch, element-keyed state, measurement, and animation-frame requests for that window. - layout, prepaint, and paint are later phases; use their hooks only when resolved geometry is genuinely required. Never retain `&mut Window`, `&mut App`, or `&mut Context<_>` beyond the call in which it is provided. Retain typed handles—`Entity`, `WeakEntity`, `FocusHandle`, scroll handles, or domain IDs—instead. ## Choose the right unit ### Use `RenderOnce` for value-like elements **Default to [`RenderOnce`](./render-once) for lightweight, reusable component values.** The caller supplies current props and handlers, GPUI consumes the value to produce elements, and the value can then be discarded. GPUI Kit's `Button` and `Checkbox` follow this pattern: their displayed label, checked or selected value, and callbacks come from the caller's current state. A `RenderOnce` component may still use small keyed interaction state, such as a focus handle; it does not own an independent View lifecycle. Do not infer that its render method runs on every displayed frame: a displayed frame need not rebuild that component's element tree. ```rust #[derive(IntoElement)] struct EmptyState { title: SharedString, } impl RenderOnce for EmptyState { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { div() .v_flex() .gap_2() .items_center() .text_color(cx.theme().muted_foreground) .child(self.title) } } ``` ### Use `Entity` for retained behavior Use an entity-backed [`Render`](./render) view when a component owns persistent, complex state or an independent lifecycle: subscriptions, async work, history, child entities, or incremental updates. Store entities in an owning view rather than recreating them in `render`. ```rust struct SearchView { query: Entity, } impl SearchView { fn new(window: &mut Window, cx: &mut Context) -> Self { let query = cx.new(|cx| InputState::new(window, cx).placeholder("Search…")); Self { query } } } ``` Do not turn every visual fragment into an entity. Entity boundaries have lifecycle and coordination costs; use them where retained identity matters. ### Elements, views, and behavior systems are different Do not force every component into one template. The ecosystem contains: - semantic elements such as Button, Checkbox, Link, and Tabs; - compound behavior roots such as Dialog, Popover, Select, and Combobox; - entity-backed systems such as Input, Table, Tree, Dock, and notifications; - infrastructure such as positioning, virtualization, scrolling, focus traps, motion, history, and measurement. An element may be internally complex and still be value-like to its caller. A stateful system may expose render callbacks so applications own presentation without reimplementing behavior. Choose the public seam from the behavior, not from how many `div`s appear in its renderer. ## State ownership ### Own persistent UI text with `SharedString` **Must prefer [`SharedString`](./shared-string) for UI text retained across frames or callback closures**, including component labels, titles, and placeholders. Store a stable value in the owning View or Entity and clone that handle when building elements; do not repeatedly clone a `String` on every render. For a mutable editing buffer or text being assembled with formatting, use `String` and convert when the result is ready. For temporary read-only access during one call, use `&str`. For immutable API or JSON response text that the UI will retain, make the response field a `SharedString` at the deserialization boundary. Keep the received transport value intact; put derived or formatted presentation text in a separate domain or View model instead of rewriting the response or repeatedly cloning a `String` into UI. This rule does not require `SharedString` for a truly mutable buffer or a narrow, non-UI parsing step. See the [API response example](./shared-string#from-an-api-response-to-a-view). This is an ownership and performance rule, not a claim that text is always allocation-free. Short values can live inline; longer dynamic values use shared storage, so cloning them does not copy the text bytes. Constructing a value can still allocate, and cloning a heap-backed value updates a reference count. See [SharedString](./shared-string) for the storage details and tradeoffs. Put each state in the narrowest owner that can keep it correct: - domain state belongs to a model or feature view; - transient view state belongs to the view that renders it; - reusable behavioral state belongs to the component state designed for it; - tiny element-local state may use GPUI keyed element state; - shared application services may be stored as GPUI globals. Prefer controlled values for ordinary selection and toggles: pass the current value into the component, receive a requested change, update the owner, and render again. A callback reports intent; it should not create a second hidden source of truth. ```rust Checkbox::new("show-hidden") .checked(self.show_hidden) .label("Show hidden files") .on_click(cx.listener(|this, checked, _, cx| { this.show_hidden = *checked; cx.notify(); })) ``` Call `cx.notify()` after a mutation that changes rendering. Use `cx.emit(...)` for a semantic event that an owner should handle, and `cx.subscribe(...)` or `cx.observe(...)` when the lifetime should follow an entity. Keep returned subscriptions in an explicit lifetime owner. ### Keep ownership links and subscriptions bounded When a parent strongly owns a child `Entity`, a child reference back to that parent must be a `WeakEntity`; a strong back-reference would close an ownership cycle. `WeakEntity::upgrade()` returns `Option`, and its `update` returns `Result`, so treat a missing parent as a normal lifecycle outcome. See [Entity](./entity) for the ownership and weak-access patterns. Store returned `Subscription` handles on the View or Entity that needs the callbacks, commonly in `_subscriptions: Vec`. Dropping that owner then drops the handles and unsubscribes. A local handle dropped at the end of setup stops the subscription early; `Subscription::detach()` instead discards the unsubscribe handle and lets the callback continue until its subscribed Entity goes away. Do not detach a recurring View subscription by default; repeated detaches against a long-lived emitter can accumulate callbacks. GPUI's `observe` and `subscribe` use weak subscriber handles internally, but a callback that additionally captures a strong handle to its own View can still close a cycle. When a View remains alive unexpectedly, first inspect strong Entity cycles, callbacks that capture strong View handles, and detached subscriptions or [Task](./task)s that outlive their intended owner. A dropped Task handle cancels unfinished work; it does not itself keep the View alive. These are common places to start, not an exhaustive list of leak causes. Do not notify merely because a value was read or derived. Avoid unconditional notification from `render`; it schedules another render and can create a permanent redraw loop. When several fields form one invariant, update them together and notify once. A reusable state type that cannot receive a context should make that limitation explicit and require its owner to emit/notify. ### Avoid state feedback loops Text input, selection, filters, and controlled popups commonly have two paths: an external owner updates the value, and user interaction requests a new value. Do not send an owner-supplied value back through the user callback during sync. Track the origin or compare coherent snapshots so each logical change is reported once. Make callbacks re-entrancy-safe when a callback can synchronously close, replace, or update the component that invoked it. ## Stable identity An [`ElementId`](./element_id) is part of behavior. It gives an element stable identity and keys element-local or component state. A component may also use it as one input to its own focus, measurement, or animation identity; focus and scrolling are otherwise owned by their dedicated handles. - Use stable domain IDs for rows, tabs, tree nodes, and repeated controls. - Namespace child IDs with their owning object when the same control repeats. - Never derive identity from a translated label or a mutable list index when items can be inserted or reordered. - Do not generate a fresh random ID during `render`. ```rust Button::new(("delete-project", project.id)) .danger() .label("Delete") ``` A changed ID means a changed UI identity. Treat that reset as deliberate. The same rule applies to transition channels, overlay tokens, scroll handles, and persistence IDs. If two independently retained behaviors share a key, they can overwrite each other's state; if one behavior changes keys every frame, it never accumulates state. ## Rendering and composition Keep `render` declarative: read current state, derive presentation values, and compose elements. Move domain operations, parsing, and non-trivial mutation to named methods or services. ```rust impl Render for ProjectView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .v_flex() .size_full() .child(self.render_toolbar(cx)) .child(self.render_content(cx)) } } ``` Extract a render helper when it names a meaningful region and reduces the amount of state a reader must hold at once. Extract a new component when the region has its own reusable contract or retained lifecycle—not merely because a builder chain is long. Use GPUI Component's fluent traits consistently (`Sizable`, `Disableable`, `Selectable`, and component-specific builders). Prefer `.when(...)` and `.when_some(...)` for small conditional refinements; use ordinary Rust control flow when branches represent substantially different interfaces. Compose from the standard semantic component before building a custom surface. Do not reproduce a menu, select, dropdown, or command palette from generic `div`s merely to match one screenshot. Reusing the component preserves its item geometry, focus transfer, keyboard navigation, selection, disabled state, dismissal, and accessibility contract. If the standard component cannot express a recurring valid pattern, improve its explicit API instead of styling arbitrary descendants at each call site. Render callbacks supplied by application code should be side-effect-free. A list item renderer, menu builder, or dock panel renderer may run whenever its owner needs to measure or redraw. It must not perform a business operation, append data, or register an unbounded subscription. ## Behavior and presentation boundary The durable Base rule is: > Base owns reusable behavior and the geometry required to implement it. The > presentation layer owns the product's visual language. “Headless” does not mean “one empty `div`.” Popup collision, keyboard navigation, editing, virtualization, resize arithmetic, focus trapping, and dock reconciliation require internal structure and state. Moving that work to every caller would not create flexibility; it would duplicate fragile behavior. Conversely, Base must not choose brand colors, typography, density, final icons, component variants, or application composition. Expose presentation through `Styled`, typed semantic-state styles, explicit parts, child slots, and item renderers. Do not inspect arbitrary descendants to discover titles, descriptions, or close buttons—make semantic parts explicit. ## Theme and styling Read semantic values from the active theme and apply layout with GPUI's [`Styled` methods](./style): ```rust div() .bg(cx.theme().background) .text_color(cx.theme().foreground) .border_1() .border_color(cx.theme().border) .rounded(cx.theme().radius) ``` Rules: - do not hard-code product colors, corner radii, spacing, or control geometry; - application code must not introduce raw hex, `rgb`/`rgba`, or `hsla`; read a semantic color from `cx.theme()` or add the missing role to the product theme; - application layout should use GPUI's rem-based scale helpers (`p_2()`, `gap_3()`, `w_64()`, `text_sm()`) instead of direct `px(...)` values; - use semantic tokens for meaning, not palette position; - keep state-independent geometry in the ordinary builder chain; - use GPUI `hover`, `active`, `focus`, and `focus_visible` modifiers for runtime interaction states; - use a component's semantic state styles for checked, selected, pressed, or disabled appearance; - keep popup ownership in explicit state so its trigger can render the open or pressed appearance until dismissal; - guard hover/active refinements when a disabled control must not react; - keep component variants few and meaningful rather than adding a variant for every call site. - bind primary styling to the decision area's real default commit and Enter action; do not derive it from action count, frequency, or toolbar position. - keep Badge and Alert variants semantic and scarce. Ordinary metadata stays neutral; do not map every enum case or section to a different color merely because a variant exists. The effective precedence is instance style, active semantic value states, disabled state, then GPUI runtime interaction refinements. Later layers only replace fields they set. Prefer `Theme::semantic_tokens()` for new application-owned presentation. The semantic token surface contains generic color roles plus radius, spacing, typography, and shadow scales; it deliberately avoids component names. Legacy component-specific theme values still exist for compatibility but should not become the extension point for every application widget. There is one current ownership caveat: `Theme::spacing_tokens()` projects the default scale, and `Theme::apply_semantic_tokens(...)` does not store custom spacing or elevation scales. An application that customizes those scales must retain its own `SemanticThemeTokens` (or narrower design-system state) and make that state available to its components. Do not write a custom spacing snapshot into the global theme and expect a later `cx.theme().semantic_tokens()` call to return it. Edit the global GPUI Component theme through `Theme::update(cx, |theme| ...)`. The theme keeps the same colors twice (`colors` as solid colors, `tokens` as renderable backgrounds that may carry a gradient) and the Base layer holds a projection for its scrollbars and resize handles; `update` brings all three back in step after the closure and refreshes every window. An edit through `Theme::global_mut(cx)` updates only the field you touched — a sidebar can then paint its text from the new colors and its background from the old tokens — so it owns the rest: derive `tokens` from `colors`, call `Theme::sync_base(cx)`, refresh the windows. `Theme::change(...)` performs the projection as part of a complete theme change. An outward focus ring needs physical room. An ancestor with `overflow_hidden()` clips it. Prefer layouts that leave room; if a product must clip heavily, use the theme's focus-ring policy and retain the focused border instead of silently hiding all keyboard focus. ### Base font is the application zoom control The Component Root plugin calls `window.set_rem_size(cx.theme().font_size)` from its `prepare` hook before the Root surface is rendered. Therefore the theme's base font is not only body typography; it is the reference length for the application's rem-based design scale. This deliberately follows the useful part of Tailwind's model: named type, spacing, and size steps share one relative base instead of becoming unrelated pixel constants. See the [Tailwind CSS theme reference](https://tailwindcss.com/docs/theme) for that scale, and the [Style guide](./style) for GPUI's fluent method names and layout behavior. Change zoom by updating the base font and refreshing the window: ```rust Theme::update(cx, |theme| theme.font_size = px(18.)); ``` The base font itself is a pixel value because it anchors the scale. Descendant application UI should normally use relative helpers—`text_sm()`, `gap_2()`, `px_3()`, `h_8()`, `size_4()`—so type, whitespace, controls, and icons respond together. A custom component that combines rem-based text with fixed-pixel padding or icon geometry must document why that part should not zoom. Treat every direct `px(...)` and raw color constructor in application UI as a review finding. Accept it only for a documented physical/platform boundary, measured runtime geometry, raster/data color, or the theme/token definition itself. Convenience and matching a screenshot are not valid exceptions. Anything cached from resolved layout must include `window.rem_size()` in its invalidation key, directly or through a revision that changes with it. This includes wrapped row heights, text shaping/layout, virtual-list measurement, popup and dialog geometry, icon sizing derived from text, and custom canvas metrics. The Command component's variable-height rows are an ecosystem example: they remeasure when rem changes because the same fixed width wraps differently at a larger base font. Do not confuse this application zoom with Dock panel zoom. Dock zoom is a stateful layout operation that makes one tab group fill the DockArea while keeping the container chrome and the way back out. It must not modify the window rem size. ## Events, actions, and focus Use pointer callbacks for pointer-specific behavior. Use GPUI [Actions](./action) for commands that should support key bindings, menus, or dispatch from multiple inputs. Keep action handlers close to the view that owns the command. Model one logical desktop command once. A toolbar Button, `DropdownMenu` item, `ContextMenu` item, menu-bar item, and key binding should dispatch the same Action or call the same owner method instead of copying five mutations. Derive their label, icon, shortcut, and enabled state from one command policy where practical, so the entry points cannot disagree. The menu owns navigation and dismissal; the feature owner still owns whether the command is allowed and what it does. Preserve semantic roles in the element choice. Use `Button` for commands even when the desired treatment is quiet—select `outline`, `ghost`, or an icon presentation instead of replacing it with `Link`. GPUI Kit applications reserve `Link` for targets opened by a browser or mail client, such as a URL, web document, or email address. Use the relevant navigation component for an in-app destination and `Button`/`Action` for a command. This is a product convention, not a limitation of `gpui_kit::base::Link`, whose `open_with` seam can route a destination elsewhere. Only stop propagation when a nested interaction must prevent its parent from handling the same event. Blanket propagation stops break menus, selection, dragging, and window-level commands in ways that are difficult to diagnose. Bind keys before building the menu bar. `cx.set_menus` reads the keymap at the moment it is called and bakes each item's shortcut into the native menu, so a binding registered afterwards never shows next to its menu item and the item does not react to the key. Call `cx.bind_keys` first, then `cx.set_menus`; if the keymap changes later (a user keymap file, a locale switch that rebuilds the menus), call `cx.set_menus` again. Make focus ownership explicit: - retain a `FocusHandle` in the entity that owns keyboard interaction; - register key contexts and actions on the appropriate focused region; - transfer focus when opening an overlay and restore it on dismissal; - render a visible `focus_visible` state; - do not request focus unconditionally from `render`. A tracked handle is a Tab stop only when it says so: build it with `cx.focus_handle().tab_stop(true)` (or `.tab_index(n)`), because the element's own `tab_index`/`tab_stop` settings do not apply to a handle passed to `track_focus`. A stateless component may create that handle in `render` through `window.use_keyed_state`; the keyed state survives re-renders, so the Tab order is stable — this is what `Button` does. Attach a `key_context` and its `on_action` handlers to the same focused region. Bindings are contextual: a registered Action without the intended focus path is not a working keyboard interaction. Composite widgets should implement the complete navigation model—arrow movement, Home/End or page movement where appropriate, confirmation, cancellation, and Tab behavior—rather than a few isolated shortcuts. Modal surfaces must trap focus and restore the previous valid focus target on dismissal. Nested overlays dismiss from the top. Handle rapid close/open sequences without restoring focus through an intermediate, already-closing surface. ## Async work and side effects Start asynchronous work through a [Task](./task) from an event, lifecycle hook, or named method—not as an unconditional side effect of `render`. Capture weak entities when work should not keep a closed view alive. When the task completes, update state through the GPUI context, handle the case where the entity or window no longer exists, and notify once after the coherent state change. Keep a recurring task's handle on its owner so dropping the owner cancels it; do not detach recurring work by default. A one-shot task may call `Task::detach()` when it must finish independently, but that gives up its cancellation handle. Handle errors and failed weak-Entity updates explicitly. Represent async operations with explicit states such as idle, loading, loaded, and failed. Preserve usable previous data during refresh when possible. Prevent duplicate destructive submissions and surface recoverable errors in the UI; do not rely on logs as user feedback. Use background executors for expensive parsing or computation, but keep GPUI entity mutation on the appropriate application context. Results can arrive after the request, document, view, or selection has changed; attach a revision or identity and reject stale work rather than applying it to new state. ## Layout, measurement, and scrolling `h_flex` centres its children on the cross axis; `v_flex` leaves flexbox's default, `stretch`. This is what a row of controls wants, so a row of icon and label says nothing. It is not what a row of full-height columns wants: a column placed in a bare `h_flex` does not fill the row's height, so a column taller than the row is centred and its top — commonly a header — is clipped off the top of the window, with nothing near the column to say why. A row whose children are columns says `items_stretch()`: ```rust h_flex() .items_stretch() .size_full() .child(sidebar) .child(content) ``` Most UI should use GPUI layout rather than measuring itself. Measurement is a deep behavior tool for popups, virtualization, editors, resize handles, charts, and similar components whose correctness depends on resolved geometry. - Put measurement and geometry in the layer that owns the behavior. - Observe bounds in prepaint only when ordinary layout cannot express the relationship. - Never mutate unrelated application state every prepaint. - Treat measured data as frame- or revision-scoped; it can become stale after typography, rem size, width, theme, or content changes. - Centralize shared geometry such as popup flipping and viewport clamping so every overlay follows the same edge policy. For alignment invariants, prefer construction over correction: sibling regions should consume the same spacing token or shared inset instead of repeating equivalent literals. Add geometry assertions or visual regression coverage for critical repeated edges, columns, and gaps. Exercise more than the default window: rem zoom and display scaling can turn fractional coordinates into a one-physical-pixel drift even when the default screenshot looks aligned. Measure the resolved result when reviewing precision, but do not encode a measured correction as a raw `px(...)` nudge. Trace the mismatch to duplicated padding, nested insets, border ownership, font metrics, or rounding, then fix the structural owner. `h_flex()` and `v_flex()` are not mirror images: `h_flex()` centers its children on the cross axis, `v_flex()` leaves them stretching. A column placed in a row therefore takes its content's height, not the row's, and a column taller than the row is centered — its header is pushed off the top edge and clipped. Give a full-height column `h_full()`, or give the row `items_start()` or `items_stretch()`, whenever the child owns a header, a footer, or a scroll region that must resolve against the row's height. Every scrollable region must have one owner. In flex layouts, apply `min_w_0()` or `min_h_0()` to the flexible child that is allowed to shrink. A flex item only drops its content-based automatic minimum size when its own overflow is not visible, so an ordinary flexible child refuses to shrink around long content until you say so. `Scrollable` handles this for its own wrapper, but a plain `div` between it and the flex container still needs the minimum released. Avoid accidental nested scrolling; route wheel input to the intended axis and preserve platform/wasm differences when an API is not portable. Attach `Scrollable` to the element that owns the full panel, editor, or window viewport so its scrollbar resolves against the region edge. Put content inset inside that scroll owner rather than wrapping the scroll owner in a padded container. A scrollbar floating between content and the panel boundary usually reveals the wrong scroll owner or padding on the wrong layer. ## Lists, tables, and large data Use virtualization when data can grow beyond a small, bounded collection. Keep row identity separate from visible position and avoid cloning the full data set on every render. Let a stateful list or table own navigation, selection, scroll coordination, and visible-range calculation while item renderers own row presentation. Separate: - source data and domain IDs; - filtering/sorting state; - selection state; - viewport/scroll state; - row rendering. This keeps updates local and prevents the view tree from becoming the data model. Virtualization is a behavioral contract, not just a performance switch. Item measurement must be invalidated when width, typography, rem size, or row content changes. Keyboard selection and scroll-to-item must operate in model coordinates even when most elements do not exist in the current frame. ## Public API design For reusable components: - constructors should establish valid defaults; - builders take and return `Self` and use domain language; - callbacks describe requested changes and include pointer events only when modifiers or pointer details are meaningful; - evolvable behavioral seams use private fields, builders for construction, and readers for inspection; - boolean readers use `is_` or `has_` where a same-named builder exists; - non-boolean setters use `with_` when readers need the plain field name; - explicit compound parts are preferable to inspecting arbitrary descendants; - adding reusable behavior must not force a product-level visual choice. Private fields are the default for behavioral state that must evolve without breaking callers. Public fields are appropriate for deliberately record-like configuration, theme tokens, geometry, and serialized schemas. Every public struct with public fields must carry `#[non_exhaustive]`. Provide constructors, `Default`, or builders so callers can create values without exhaustive struct literals. This preserves the ability to add fields without breaking callers. Apply this rule to new types and public API changes; unrelated existing types can be migrated separately. Keep public module paths stable while reorganizing internals: use a module seam with deliberate re-exports so folders can change without forcing downstream imports to change. Prefer platform control terminology and established project naming over web-framework vocabulary. ## Platform and capability boundaries Do not assume every native or web target supports the same facility. Window decorations, accessibility bridges, system notifications, clipboard behavior, scroll gestures, fonts, and timing can differ. Put platform-specific code behind a narrow capability seam and define the fallback behavior. A platform branch must preserve the semantic contract even if presentation differs. For example, a system notification may have different retraction support, but the application still needs a coherent delivery state. Test both the shared state machine and the platform adapter where possible. ## File and naming conventions - Name views and entities after product concepts: `ProjectList`, `ProjectEditor`, `SettingsState`. - Name event handlers after intent: `confirm_delete`, `open_project`, `on_query_changed`. - Keep one main responsibility per module; split a file when state ownership or lifecycle can no longer be understood without reading unrelated behavior. - Keep component module, state, events, and focused tests together when they change together. - Document invariants and surprising lifecycle constraints; do not narrate obvious builder calls. - Use `rustfmt` and satisfy the workspace's Clippy rules. Avoid broad `allow` attributes that conceal unrelated warnings. ### Vocabulary is part of the API Use the same word for the same concept across components. Before naming a new method, search GPUI, `gpui-base`, and GPUI Component for the established term; prefer macOS/Windows control terminology where the ecosystem has no precedent. Localized documentation preserves exact API identifiers and established UI framework terms when translation would reduce precision. Format identifiers as code, explain retained terms when needed, and do not mix languages merely to make ordinary prose sound technical. | Concept | Naming pattern | Example | | --- | --- | --- | | Value-like rendered control | noun | `Button`, `Checkbox`, `Tab` | | Retained behavioral model | `State` | `InputState`, `TableState` | | Imperative shared reference | `Handle` | `DialogHandle`, scroll handle | | Semantic notification | `Event` | `TableEvent`, `SelectEvent` | | Keyboard command | verb or intent noun | `Confirm`, `Cancel`, `SelectNext` | | Pluggable data/behavior owner | `Delegate` / `Provider` | `TableDelegate`, `CompletionProvider` | | Application-supplied presentation | `render_` or `_renderer` | `render_item` | | Construction | `new`, or a semantic constructor | `new`, `horizontal`, `vertical` | | Fluent property | noun/adjective | `label`, `disabled`, `selected`, `placement` | | General non-boolean replacement builder | `with_` | `with_size`, `with_mode` | | In-place mutation | `set_` | `set_items`, `set_selected_index` | | Boolean reader | `is_` / `has_` | `is_open`, `is_closable`, `has_selection` | | Plain value reader | field noun | `placement`, `selected_value` | | Callback registration | `on_` | `on_click`, `on_open_change` | | Rendering a named region | `render_` | `render_toolbar`, `render_content` | For new APIs, fluent builders omit `set_` because they consume and return `Self`; mutation through `&mut self` uses `set_`. Preserve established public names when changing them would cause needless churn. Existing builder names such as `set_position` are compatibility exceptions, not patterns for new APIs. A boolean reader is either `has_`, when the value holds something, or `is_`, when it describes a state or a permission. Reach for the adjective whenever the action has one: `is_closable` over `can_close`, `is_zoomable` over `can_zoom`, `is_copyable` over `can_copy`. When the action is a verb phrase with no adjective form, name the thing it needs instead: `has_definition`, not `can_go_to_definition`. Do not add new `can_` readers. Boolean builders may use the field name (`disabled(bool)`) while their readers use `is_disabled()`. For a public seam struct containing non-boolean fields, use `with_item_ix(...)` for construction and `item_ix()` for reading so setter and getter names never collide. Prefer `_ix` for new local or internal zero-based indices, preserve established public terms such as `selected_index`, and do not introduce `_idx`. If callers never construct the seam value, do not publish a builder merely for symmetry. ### Let the enclosing name carry the context A name is read inside something. A field is read inside its type and a parameter inside its method, so neither repeats what encloses it: `with_item_ix(ix)`, not `with_item_ix(item_ix)`. Keep one type's fields at the same level of abbreviation. A single field spelled out in full becomes the odd one out, and a reader goes looking for the distinction that made it different. Because a builder is named `with_`, shortening a field shortens its builder with it and the pair stays matched. Shorten only where the enclosing name really does disambiguate. When a short form is also the established term for a *different* quantity elsewhere in the ecosystem, say which one you mean in the doc comment rather than lengthening the identifier — the doc is read at the call, and it can explain what a longer name could only hint at. ### Use precise domain words - **selected** is persistent membership or the active item; **focused** is the current keyboard target; **hovered** is pointer presence; **confirmed** is an activation result. Never use them interchangeably. - **open/close** describes an overlay or disclosure state; **show/hide** is for transient presentation requests; **expand/collapse** describes structure. - **disabled** prevents interaction; **readonly** permits navigation and selection but prevents editing; **loading** prevents duplicate work while an operation is pending. Spell the state `readonly` — one word, as the `readonly(bool)` builder and `is_readonly()` reader do — in identifiers, interface labels, and documentation alike; never `read-only` or `read only`. - **index** is a current positional coordinate; **id** is stable identity; `IndexPath` represents hierarchical position. Do not persist or key reorderable data by index. - **value** is controlled domain data; **presentation** is a read-only snapshot prepared for rendering; **state** is retained behavior. - **placement** is a side or anchor policy; **position** is resolved geometry. - **size** is a semantic control tier; **width/height/bounds** are geometry. - **child/children** follows GPUI composition; named slots such as `header`, `footer`, `trigger`, and `content` carry additional semantics. Avoid vague public names such as `data`, `item2`, `handle_action`, `update_ui`, `process`, `manager`, or `config` when a narrower domain term exists. `Manager` is appropriate only when a type truly coordinates a collection or lifecycle, as `ToastManager` does. ### Type and module style - Rust types and Actions use `UpperCamelCase`; modules, functions, methods, fields, and local variables use `snake_case`; constants use `SCREAMING_SNAKE_CASE`. - A module named after a component owns its public seam. Internal folders may split state, element, geometry, platform adapter, and tests without leaking those folder names into imports. - Use singular module names for one component concept and established ecosystem names for families (`input`, `table`, `dock`). - Suffix type-erased wrappers with `Any` only when they erase a real type boundary, such as `AnyInputState` or `AnyElement`. - Suffix identifiers with `Id`, zero-based indices with `ix`, and collections with meaningful plurals. Do not alternate `idx`, `index`, and `ix` in one subsystem. - Name predicates positively when possible. A positive `enabled`/`visible` contract is easier to compose than multiple negatives, but preserve established API terms such as `disabled` where they match control semantics. ### Callback and event wording Use `on_click` only for a genuine click-level contract. A controlled semantic primitive in Base should prefer `on_change(next_value, ...)`; a styled compatibility component may retain `on_click` when pointer details or existing API expectations matter. Do not invent a `ClickEvent` for a model-driven change. Name before/after lifecycle hooks precisely. `on_will_change` can veto or prepare; `on_change` observes a requested/current value contract; `on_confirm` commits a choice; `on_dismiss` closes a transient surface. Document whether a callback runs before internal state changes, after them, or instead of them, and whether it may synchronously re-enter the component. ### Documentation and copy style Public docs should begin with what a type does and who owns its state. Examples must use current, compilable APIs and show stable IDs. Document defaults, platform limitations, focus behavior, callback ordering, and any requirement to call `notify`, `emit`, or a theme synchronization method. Follow the [interface-language rules](/versions/v0.7.1/docs/design-guides#interface-language) for labels, commands, confirmation dialogs, capitalization, and ellipses. Keep one canonical term for each domain object, command, and state. Translation keys describe stable intent (`dialog.delete_project.title`), not a source-language sentence or a screen coordinate. Never assemble a sentence from translated fragments or reuse one key for meanings that happen to share the same English text. Localize intent, not syntax. Give every locale control over word order, pluralization, punctuation, and the amount of context it needs. Review strings inside the component and with realistic data. Tests or linting should catch missing keys, unintended CJK text in English resources, three-dot ellipses, unreviewed ALL CAPS, and inconsistent fixed terms; human review still decides whether repetition is justified by context. Verify every string inside its component with realistic content, text expansion, and application zoom. ## Testing strategy Test at the lowest layer that can prove the behavior: 1. pure tests for state transitions, geometry, parsing, and ordering; 2. GPUI context tests for entities, events, and subscriptions; 3. `VisualTestContext` interaction tests for focus, keyboard, pointer, layout, and rendered state; 4. example or application smoke tests for complete workflows. For an interactive component, cover the semantic contract rather than its implementation details: pointer and keyboard activation, controlled value changes, disabled behavior, focus movement, event count/order, stable identity, and important empty or failure states. Add a regression test before fixing a bug whenever the failure can be reproduced deterministically. For UI behavior that depends on the real window system, test through the accessibility tree by role, label, value, enabled state, focus, and selection. Re-read the tree after every state-changing action because element indexes are snapshots. Use screenshots for visual facts the semantic tree cannot express; use coordinate input only as a fallback. Report automated and manual evidence separately. ## Performance rules - Do not mutate state or notify unconditionally in `render`. - Avoid rebuilding entities, subscriptions, focus handles, and expensive data structures per frame. - Notify the narrowest owning entity after a coherent state change. - Virtualize long collections and render only the visible range. - Avoid cloning large strings or collections solely to satisfy a closure; capture stable handles or shared data. - Measure before adding caches. A cache must have a clear invalidation owner. - Keep animation work bounded and honor reduced motion. ## Common failure modes Avoid these patterns: - one entity containing the entire application's unrelated state; - business logic and network requests embedded in a long `render` method; - random or index-based `ElementId` values for reorderable content; - literal colors and radii that break custom themes; - custom clickable `div`s where a semantic component already supplies focus, keyboard, disabled, and accessibility behavior; - duplicated local state that drifts from a controlled model value; - `cx.notify()` loops caused by mutation during every render; - nested scroll containers without explicit ownership; - a new component variant for a one-off screen; - confirmation dialogs for reversible, low-risk actions; - tests that call internal methods but never exercise keyboard or pointer behavior. ## Rules for coding agents Before editing, an agent must read the nearest implementation, its tests, the re-export seam, and the relevant component documentation. It must search the current source for signatures instead of translating a React, CSS, or old GPUI example by analogy. For each change, the agent should be able to name: 1. the behavior owner and presentation owner; 2. the retained identity and state lifecycle; 3. the pointer, keyboard, focus, and accessibility contract; 4. the layout and overflow owner; 5. the theme tokens and intentional exceptions; 6. the test that would fail if the behavior regressed. Generated code must be reviewed and tested by a person. “Compiles” is not a UI quality bar, and a broad refactor that merely makes generated code look tidy is not a substitute for matching the repository's architecture. ## Implementation checklist Before opening a change for review, confirm that: - state and side-effect ownership are explicit; - `RenderOnce` versus `Entity` is chosen deliberately; - repeated elements have stable domain-based IDs; - theme tokens and component sizes replace isolated visual literals; - keyboard actions, focus, disabled state, and overlays work together; - loading, empty, error, and cancellation paths are represented; - long data sets use an appropriate virtualized component; - public API additions preserve dependency direction and encapsulation; - tests prove behavior at the appropriate layer; - formatting, Clippy, targeted tests, and relevant examples pass. See [Getting Started](/versions/v0.7.1/docs/getting-started) for application setup and the component pages for current API details. --- # Window Source: /versions/v0.7.1/docs/window License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/window and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. GPUI provides [`Window`](https://docs.rs/gpui-pre/0.3.8/gpui/struct.Window.html) as the context for one platform window. It connects the rendered Element tree to [platform input](./event#pointer-and-keyboard-input-are-also-events), Focus, Action dispatch, drawing, and window controls. A View receives it only while GPUI is updating or rendering that window: ```rust impl Render for Chat { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let active = window.is_window_active(); div() .track_focus(&self.focus_handle) .when(!active, |this| this.opacity(0.8)) .child("Chat") } } ``` Keep application state in an [Entity](./entity). Use `Window` when an operation belongs to the current window or needs its current interaction state. `App` gives access to application-wide services, [globals](./global), and entities. [`Context`](./context) adds operations tied to the current Entity, including `cx.notify()`, listeners, events, and tasks. `Window` carries focus, dispatch, input, [keyed element state](./element_id), measurement, and drawing for **one** window. These are temporary callback contexts; store an `Entity`, `FocusHandle`, task, subscription, or window handle for later work, never `&mut Window` or `&mut Context<_>`. ## Open and own a window Call `gpui_kit::init(cx)` before opening windows. `gpui_kit::open_window` creates a GPUI window with a Base `Root` around the view returned by the builder. It returns both a window handle and the application content Entity, so the app can retain the part it owns: ```rust use gpui_kit::*; application().run(|cx| { init(cx); let (window_handle, workspace) = open_window( WindowOptions::default(), cx, |window, cx| cx.new(|cx| Workspace::new(window, cx)), ) .expect("open workspace window"); // Retain the handles in an application owner if later work needs them. }); ``` [`WindowOptions`](https://docs.rs/gpui-pre/0.3.8/gpui/struct.WindowOptions.html) controls initial bounds, focus, visibility, window kind, minimum size, and other platform-facing choices. The builder receives the `Window` only for construction. A window handle lets later code request an update, but handle-based updates can fail after the window closes. In a multi-window app, use the handle for the particular window whose focus or geometry you mean; an Entity handle alone does not select a window. `open_window` returns an `AnyWindowHandle` because the actual GPUI root is `gpui_kit::base::Root`, not `Workspace`. From a later callback with `&mut App`, use the handle to enter that window, and check the result before assuming it is still open: ```rust if window_handle .update(cx, |_, window, _| window.activate_window()) .is_err() { // The window has already closed. } ``` The first callback argument is the Base `Root` view; keep the `workspace` Entity returned by `open_window` for application content updates. A window handle selects the window, while an Entity selects the state to update. ## Try it: update and close one window This exercise uses the existing `hello_world` package. Replace `examples/hello_world/src/main.rs` with the following code, then run `cargo run -p hello_world` from the repository root: ```rust use gpui_kit::component::button::*; use gpui_kit::component::*; use gpui_kit::*; struct WindowPractice { renamed: bool, } impl Render for WindowPractice { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let status = if self.renamed { "Title: Second title" } else { "Title: First title" }; div() .v_flex() .gap_2() .p_4() .child(status) .child( Button::new("rename") .label("Change title") .on_click(cx.listener(|this, _, window, cx| { this.renamed = !this.renamed; let title = if this.renamed { "Second title" } else { "First title" }; window.set_window_title(title); cx.notify(); })), ) .child( Button::new("close") .label("Close window") .on_click(|_, window, _| window.remove_window()), ) } } fn main() { application().run(|cx| { gpui_kit::init(cx); open_window(WindowOptions::default(), cx, |window, cx| { window.set_window_title("First title"); cx.new(|_| WindowPractice { renamed: false }) }) .expect("open practice window"); }); } ``` Click **Change title** twice. The native window title and the text inside the View should alternate together. `renamed` is persistent Entity state; `cx.notify()` makes its new text visible. `window.set_window_title(...)` changes the current native window directly and does not need that Entity notification. Click **Close window** to request removal of this window. The button callback's `&mut Window` is valid only during that callback; after removal, a saved window handle may return an error on update. If the content changes but the title does not, check whether your desktop displays native window titles and whether `set_window_title` runs in the button callback. If neither changes, confirm `init(cx)` ran before `open_window`, the button has its `on_click` listener, and the listener calls `cx.notify()` after changing `renamed`. Restore the original `main.rs` after the exercise. For more than one window and ownership across them, continue with [Multi Window](./multi-window). ## What belongs to Window Common window-local operations include: | Need | API | | --- | --- | | Inspect geometry and state | `bounds`, `viewport_size`, `scale_factor`, `is_window_active` | | Manage Focus | `focused`, `focus`, `blur`, `focus_next`, `focus_prev` | | Send a command from code | `dispatch_action` | | Redraw or schedule a frame callback | `refresh`, `request_animation_frame`, `on_next_frame` | | Control the native window | `set_window_title`, `activate_window`, `remove_window` | | Continue work later | `defer`, `spawn` | `Window` also carries layout, text, hit testing, input, and drawing state internally. Most Views do not manipulate those systems directly; Elements and GPUI use them during rendering. ## Geometry and scale `window.bounds()` returns the native window rectangle in **global** coordinates, potentially spanning displays. `window.viewport_size()` returns the drawable content area's size in window-local logical `Pixels`. For a local overlay that must fit inside the content, use the viewport size; for saved placement, use `window.window_bounds()`, which includes the window's restorable state. `window.inner_window_bounds()` excludes platform insets where supported. `window.scale_factor()` converts logical pixels to physical display pixels: a factor of `2.0` means one logical pixel covers two device pixels along each axis. It may change when the window moves between displays. Do not multiply GPUI layout sizes by it; use it at a boundary that actually needs device pixels, such as a native platform integration. `visual_viewport_bounds()` can shrink or move when a mobile keyboard appears, while `viewport_size()` remains the layout area. These values have different origins: `bounds().origin` is global display space, while pointer events and `visual_viewport_bounds()` use window-local logical coordinates. Do not compare a pointer position directly with a saved global window origin. For an overlay that must stay clear of system insets or the software keyboard, `window.fully_visible_bounds()` gives a conservative window-local rectangle; it cannot account for obscuring surfaces the platform does not report. The [Dialog implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/dialog/dialog.rs) uses `window.viewport_size()` and window border padding to keep a surface within available content. The [native menu integration](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/native_menu/windows.rs) reads `scale_factor()` at its platform coordinate boundary. Let standard components perform these calculations when they already own the overlay or native control. ## Focus and Action dispatch Focus is local to a Window. `window.focus(...)` selects a `FocusHandle`, and `window.focused(cx)` returns the current one. Attach that handle to a rendered Element with `.track_focus(&handle)` so it has a node on the Dispatch Path; a handle alone does not create a keyboard target. A tracked handle is not automatically in Tab order: opt in with `cx.focus_handle().tab_stop(true)` when creating it. Keyboard input then uses the focused Element's Dispatch Path to match a [KeyBinding](./keybinding) and dispatch its Action. Follow the [Focus tutorial](./focus) to build and verify the target, Tab order, and overlay restoration before adding shortcuts. ```rust fn focus_composer(&mut self, window: &mut Window, cx: &mut Context) { window.focus(&self.composer_focus, cx); } fn on_action_open_conversation( &mut self, action: &OpenConversation, window: &mut Window, cx: &mut Context, ) { self.open(action.id, cx); window.focus(&self.composer_focus, cx); } ``` Use `window.dispatch_action(action.boxed_clone(), cx)` when a button, command palette, native menu, or another piece of code should issue the same command as a KeyBinding. GPUI captures the current Focus and defers the actual dispatch until the current effect cycle completes. ```rust Button::new("open-conversation") .label("Open") .on_click(|_, window, cx| { window.dispatch_action(Box::new(OpenConversation { id }), cx); }) ``` The Action still follows the focused Dispatch Path. Place its `on_action_*` handler on that path, usually on the focused region or a common owner. See [Action](./action) for the complete routing model. ## Run work after the current update Use `window.defer` when work must wait until entities currently being updated have been released. This is common when closing an overlay changes Focus, or when the next operation updates another part of the same UI tree. ```rust fn dismiss(&mut self, window: &mut Window, cx: &mut Context) { let composer = self.composer.clone(); window.defer(cx, move |window, cx| { let focus_handle = composer.read(cx).focus_handle(cx); window.focus(&focus_handle, cx); }); } ``` Inside an Entity, `cx.defer_in(window, ...)` is often more convenient because GPUI supplies that Entity again: ```rust cx.defer_in(window, |this, window, cx| { this.rebuild_results(window, cx); }); ``` The callback already receives `&mut Self`. Do not call `update` on the same Entity from inside it; that attempts to update an Entity which is already being updated. Use `defer` and `defer_in` for Focus changes and UI-tree mutations that cannot safely happen in the current callback. Use `window.on_next_frame(...)` only when the operation specifically belongs to the next frame callback, such as an animation step. `defer` means “after the current effect cycle,” which is a different boundary. ## Redraw and frame lifecycle An Entity mutation followed by `cx.notify()` marks that Entity for rendering. `window.refresh()` marks the **whole window** dirty for its next draw; use it for window-local changes that lack an Entity notification, such as a platform or overlay state change. Neither belongs in an unconditional render path. `window.on_next_frame(callback)` runs the callback at the next platform frame tick, before that tick's optional draw. It creates frame demand but does not itself mark the window dirty. `window.request_animation_frame()` captures the currently rendering View and notifies it on the next tick. In the pinned `gpui-pre` 0.3.8 implementation, it calls `current_view()` immediately, so use it only while GPUI has a current View; outside that render path, use `on_next_frame` and explicitly notify an Entity or call `window.refresh()`. Call it only while the motion still needs another sample. GPUI's `AnimationExt::with_animation` and [Base Motion](./animation) already manage frame requests and reduced motion for their animations. For a frame callback that changes external window state, call `window.refresh()` in that callback so the change reaches a draw. For Entity state, update the Entity and call `cx.notify()` in its update callback. A frame tick alone does not redraw an unchanged window. Rendering builds a fresh Element tree from retained Entity state, then GPUI resolves layout, prepaints input geometry, and paints the scene. `Window` has methods for all these stages, but application views should normally derive elements in `render`; custom Elements need later-stage hooks only when resolved bounds are required. An unconditional `cx.notify()`, `window.refresh()`, or `window.request_animation_frame()` in `render` creates continuous work even when the UI is idle. See [Render](./render) and [Element](./element). ## Async work with a Window Use `cx.spawn_in(window, ...)` when a [Task](./task) belongs to the current Entity and later needs both Entity and Window access: ```rust struct Chat { load_task: Option>, } fn load_conversation(&mut self, window: &mut Window, cx: &mut Context) { self.load_task = Some(cx.spawn_in(window, async move |this, cx| { let Ok(messages) = fetch_messages().await else { return }; this.update_in(cx, |this, _window, cx| { this.messages = messages; cx.notify(); }) .ok(); })); } ``` `cx.spawn_in` provides a `WeakEntity` and an `AsyncWindowContext`. If the Entity or Window has gone away, `update_in` returns an error; propagate or handle it instead of assuming they still exist. Use `window.spawn(cx, ...)` when the task needs the Window but does not belong to one Entity. Use `cx.spawn(...)` when no Window access is needed, and `cx.background_spawn(...)` for CPU-heavy work. A `Task` is cancelled when dropped, so store it on the owning View when its lifetime should follow that View, or call `.detach()` only for work that should continue independently. ## Subscribe with Window access Use `cx.subscribe_in` when an Event callback needs `&mut Window`, for example to restore Focus after a child finishes: ```rust struct Workspace { chat: Entity, _subscriptions: Vec, } impl Workspace { fn new(chat: Entity, window: &mut Window, cx: &mut Context) -> Self { let _subscriptions = vec![ cx.subscribe_in(&chat, window, |_this, chat, event, window, cx| { if let ChatEvent::ConversationOpened = event { window.focus(&chat.read(cx).focus_handle(cx), cx); } }), ]; Self { chat, _subscriptions } } } ``` Store the returned `Subscription` on the subscribing View. Dropping a local variable immediately cancels the subscription. Storing it on a longer-lived global owner can keep the callback and captured resources alive after the View disappears, causing a memory leak. See [Event](./event) for subscription ownership and multiple subscribers. ## Window lifetime Do not store `&mut Window`; it is a temporary context supplied by GPUI. For later work, use `defer`, `spawn_in`, or obtain `window.window_handle()` and update it through GPUI. A handle does not keep a closed window alive, so handle-based updates can fail and should be treated accordingly. `window.remove_window()` requests removal from the current update. To decide whether a platform close request may proceed, register `window.on_window_should_close(cx, callback)` and return `false` to cancel it; the application owns any unsaved-work confirmation flow. To observe a completed close, `cx.on_window_closed(callback)` returns a `Subscription` whose callback takes `&mut App` and `WindowId`, in that order. Retain that subscription on an application owner. The closed `Window` is already inaccessible when this callback runs, so gather any needed window state before closing it. Register the close guard while that window is available, typically in the `open_window` builder. It applies to the platform's close request; `remove_window()` is an explicit programmatic removal. If the app should exit when its last window closes, use the closed callback to check `cx.windows().is_empty()` and call `cx.quit()`. The [FPS monitor example](https://github.com/longbridge/gpui-kit/blob/main/examples/fps_monitor/src/main.rs) shows this single-window quit pattern and a View requesting animation frames. Run it from this repository with `cargo run -p fps_monitor`; it uses GPUI Kit without the optional Component layer. If a window action appears to do nothing, first check that the handle still names an open window and that the focused Element's Dispatch Path contains the Action handler. If an Entity mutation is not visible, verify that its update calls `cx.notify()`; if a window-level change is not visible, call `window.refresh()`. If a frame callback fires without a draw, remember that `on_next_frame` creates frame demand but does not dirty the window. If an Event callback stops firing, check that its `Subscription` is retained. Keep these ownership rules together: - persistent UI state belongs to an Entity; - window-specific work receives `&mut Window` only for the duration of a callback; - `Task` and `Subscription` fields tie background work and observers to the owning View; - Focus and Action dispatch always use the state of the specific Window. Continue with [Multi Window](./multi-window) when an application owns several windows. [Native Extensions](./native-extension) covers OS APIs at a window boundary; [WebView](./webview) explains the native child-view integration and its limits. For OS-delivered notifications, see [SystemNotification](./system-notification). --- # Images Source: /versions/v0.7.1/docs/image License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/image and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. GPUI draws images with two elements. `img()` draws a full-color image: a photo, a screenshot, an avatar, or a multicolor SVG. `svg()` draws a single-color SVG as a mask filled with a text color, which is how icons follow the theme. Both are re-exported from `gpui_kit`. This page explains how each element finds, decodes, sizes, and caches its source, and how to keep remote images from being downloaded again. For ready-made UI patterns see the [Image](/versions/v0.7.1/component/image) component page; for bundling files into the binary see [Icons & Assets](/versions/v0.7.1/docs/assets). ## Sources `img(source)` takes anything that converts into `ImageSource`. The conversion decides where the bytes come from: | Argument | `ImageSource` | Where the bytes come from | | --- | --- | --- | | `"https://example.com/a.png"`, or any string that parses as a URL; a `SharedUri` | `Resource(Resource::Uri)` | The `HttpClient` installed on the `App` | | `"images/a.png"`, or any other string | `Resource(Resource::Embedded)` | The registered `AssetSource`, looked up by that exact key | | `&Path`, `PathBuf`, `Arc` | `Resource(Resource::Path)` | The file system | | `Arc` | `Image` | Encoded bytes you already hold, with their `ImageFormat` | | `Arc` | `Render` | Frames you already decoded; drawn as is | | `Fn(&mut Window, &mut App) -> Option, ImageCacheError>>` | `Custom` | Your own loader | A string is a URL whenever it parses as one, and an asset key otherwise. A relative-looking string such as `"images/a.png"` is never read from the working directory. Pass a `Path` for a file on disk, so it is never taken for a URL or an asset key. On native platforms the default `HttpClient` fails every request, so URL images load only after the application installs a client with `cx.set_http_client(...)` or `Application::with_http_client(...)`. On the web, `gpui_kit::application()` installs a client backed by the browser's Fetch API. A `Custom` loader runs during layout and paint of every frame. Return `None` while the image is loading and keep the result yourself, for example with `window.use_asset::(...)`, so that each frame does not start a new load. ## Loading, decoding, and failures Loading is asynchronous. While it is pending, `img()` lays out from its own style and draws nothing. When the load finishes, GPUI redraws the view that drew the image. ```rust img("https://example.com/cover.png") .id("cover") .w(px(320.)) .h(px(180.)) .with_loading(|| div().child("Loading image...").into_any_element()) .with_fallback(|| div().child("Image unavailable").into_any_element()) ``` - `with_loading` replaces the image only when the load is still pending 200 ms after it started, so a fast load never flashes a placeholder. GPUI tracks that time in the element's state, so the placeholder appears only on an image with an `.id(...)`. - `with_fallback` replaces the image when loading fails: a missing asset key or file, a network error, a response that is not `2xx`, or bytes that do not decode. - Neither callback retries. To retry, keep the attempt in your view state and draw the image again with a different source, or remove the cached failure (see [Caches for decoded images](#caches-for-decoded-images)). GPUI detects the format from the bytes, not from the file extension. PNG, JPEG, WebP, GIF, BMP, TIFF, ICO and the other formats listed by `Img::extensions()` decode through the `image` crate. Bytes that are not a known raster format are parsed as SVG and rasterized once at twice their intrinsic size, so `img()` of an SVG stays sharp at normal scales but blurs when it is enlarged far beyond its own size. Animated GIF and WebP images play only on an image with an `.id(...)`, because the current frame is kept in element state. They advance while the window is active and stop when the system asks to reduce motion. ## Size and fit Layout decides the image's bounds; `object_fit` decides how the image is drawn inside them. When a dimension is `auto`, GPUI fills it in after the image has decoded. If the other dimension has an absolute length, the image's ratio gives this one; otherwise the image's own size is used. The image's ratio also becomes the element's `aspect_ratio` unless you set one. Before decoding finishes there is nothing to measure, so an image without a size moves the surrounding layout when it arrives. Reserve its box with an explicit size, or with a width and `aspect_ratio(...)`: ```rust img("images/banner.webp") .w_full() .aspect_ratio(16. / 9.) .object_fit(ObjectFit::Cover) ``` | `ObjectFit` | Result | | --- | --- | | `Contain` (default) | The whole image, aspect ratio kept; empty space may remain. | | `Cover` | Fills the bounds, aspect ratio kept; edges may be cropped. | | `Fill` | Stretches to the bounds; may distort. | | `ScaleDown` | Like `Contain`, but never enlarges the image. | | `None` | The image's own size, centered. | `.rounded(...)` on an `img()` rounds the drawn image itself. `.grayscale(true)` draws it without color. Borders, shadows and backgrounds are ordinary element styles. ## svg() `svg()` draws an SVG as a single-color shape. GPUI rasterizes the SVG's alpha channel at the element's size and fills it with a color, so the SVG's own colors are discarded. Choose the source with one of three builders: | Builder | Where the bytes come from | | --- | --- | | `.path("icons/check.svg")` | The registered `AssetSource`, by key | | `.external_path("/path/to/check.svg")` | The file system, read asynchronously and cached by path | | `.data(bytes)` | Bytes you pass in, cached by a hash of the bytes | ```rust svg() .path("icons/check.svg") .size(px(16.)) .text_color(cx.theme().foreground) ``` - **Set the color on the element.** `svg()` paints only when the element itself has a text color. A color inherited from a parent does not count, so an `svg()` without `.text_color(...)` draws nothing. - **Set the size.** Layout never reads the SVG, so `svg()` has no intrinsic size and collapses to zero without one. - **Transform at paint time.** `.with_transformation(Transformation::rotate(percentage(0.25)))`, and the `scale` and `translate` variants, move only the drawing. Layout and the hit area stay where they were. In GPUI Kit, prefer the [Icon](/versions/v0.7.1/component/icon) component for icons: it picks the size from the component size scale and the color from the theme. ## img() or svg() | | `img()` | `svg()` | | --- | --- | --- | | Colors | Keeps the source's colors | One color, from `.text_color(...)` | | Formats | Raster formats and SVG | SVG only | | Size | Intrinsic size from the image | Must be set | | Sources | URL, asset key, path, bytes, decoded frames, custom loader | Asset key, path, bytes | | Loading and failure | `with_loading`, `with_fallback` | Draws nothing until ready or on failure | | Use for | Photos, avatars, logos, illustrations | Icons and glyphs that follow the theme or a state | `.text_color(...)` does not recolor an `img()`, and `svg()` cannot keep a multicolor logo's colors. ## Caches for decoded images Decoded images are kept so that drawing the same source again does not load it again. Which cache keeps them decides when they are released. - **Default.** An `img()` of a `Resource` (URL, asset key, or path) uses the App's asset cache, keyed by the source. One entry serves every window and view and stays until you remove it with `ImageSource::remove_asset(cx)`. Failed loads are cached too, so remove the entry before retrying the same source. An `Arc` is cached the same way. - **A scoped cache.** `image_cache(provider)` wraps children in an element whose `img()` descendants use that cache instead. `image_cache(retain_all("preview"))` keeps a `RetainAllImageCache` in element state: it holds everything it loaded and releases it when the element stops being drawn. `img(...).image_cache(&cache)` picks a cache for one image. - **Your own cache.** Implement `ImageCache` to decide what to keep. `ImageCacheItem::new(resource, cx)` starts a load through GPUI's image loader, and `item.use_image(window)` returns the result and redraws the current view when it finishes. When you evict an image, release its GPU texture with `cx.drop_image(image, Some(window))`. The cache below keeps the most recently drawn images and releases the rest. Its capacity must exceed the number of images on screen at once, or visible images will be evicted and reloaded every frame. ```rust use std::{collections::VecDeque, sync::Arc}; use gpui_kit::*; /// Keeps the `capacity` most recently drawn images and releases the rest. pub struct RecentImageCache { capacity: usize, items: VecDeque<(Resource, ImageCacheItem)>, } impl RecentImageCache { pub fn new(capacity: usize, cx: &mut App) -> Entity { let cache = cx.new(|_| Self { capacity, items: VecDeque::new(), }); // Release the GPU textures when the cache itself is dropped. cx.observe_release(&cache, |cache, cx| { for (_, item) in cache.items.drain(..) { if let Some(Ok(image)) = item.get() { cx.drop_image(image, None); } } }) .detach(); cache } } impl ImageCache for RecentImageCache { fn load( &mut self, resource: &Resource, window: &mut Window, cx: &mut App, ) -> Option, ImageCacheError>> { let item = match self.items.iter().position(|(source, _)| source == resource) { Some(ix) => self.items.remove(ix).expect("index is in bounds"), None => (resource.clone(), ImageCacheItem::new(resource, cx)), }; self.items.push_front(item); while self.items.len() > self.capacity { if let Some((_, evicted)) = self.items.pop_back() && let Some(Ok(image)) = evicted.get() { cx.drop_image(image, Some(window)); } } self.items[0].1.use_image(window) } } ``` Create it once, keep the `Entity` in your view, and wrap the images that should use it: ```rust image_cache(self.images.clone()) .flex() .gap_2() .children(self.urls.iter().map(|url| img(url.clone()).size(px(96.)))) ``` These caches hold decoded images in memory. Every one of them still loads a URL through the App's `HttpClient`, and none of them remembers what the server said about the response. That is the job of the next section. ## Cache remote images over HTTP The previous sections cover what `img()` keeps in memory. This section covers the network: how to reuse responses across views and launches on native platforms. On the web the browser's HTTP cache already applies. ### Why GPUI's image cache is not enough GPUI's image cache sits above the App's `HttpClient`. It keeps decoded images in memory, keyed by source, which is enough for repeated sources in a running view but not for network traffic: - It ends with the process, so every launch downloads every image again. - It ignores `Cache-Control`, `ETag`, and `Last-Modified`. It cannot keep a response the server allows to be reused, or ask the server whether an older copy is still current. - It lives only as long as the cache that holds it. An image with its own `.image_cache(...)`, or a loader that keeps a cache per view, requests the image again each time a new view is created. ### Cache at the HTTP layer To reuse responses across views and launches, install an `HttpClient` that applies HTTP caching rules once at startup. Every remote image then benefits, including images loaded by code the application does not own. Follow these rules: - **Cache only a GET without a request body.** Pass every other request through unchanged. - **Treat the cache as shared.** One client serves the whole application: its own views, extensions, and document views. Do not store a `no-store` or `private` response, or a response to a request carrying `Authorization`, unless the server explicitly allows a shared cache to keep it. A layer below the cache that adds cookies or tokens hides them from the cache, so add credentials above the cache, or leave those hosts uncached. - **Revalidate instead of downloading again.** Serve a fresh response without the network. When it is stale, send `If-None-Match` or `If-Modified-Since`; on `304 Not Modified`, return the stored body with the refreshed headers. - **Honor the caller's redirect policy.** `img()` follows redirects, but a loader that authorizes each hop itself requests `RedirectPolicy::NoFollow` and must receive the `3xx` response. Keep a separate cache for each policy, so a followed result never answers that caller. - **Bound memory and disk use.** Cap the cache's size and remove old entries. - **Authorize before the request.** The cache answers any caller that asks for the same URL. A check that decides whether a caller may reach a URL, such as the network grants of gpui-shell scripts, must run before `send`. The cache then never widens what a caller can reach; it only avoids repeating a request that was already allowed. ### Example [http-cache-reqwest](https://crates.io/crates/http-cache-reqwest) implements these rules as [reqwest-middleware](https://crates.io/crates/reqwest-middleware): freshness, `ETag` and `Last-Modified` revalidation, and the restrictions on a shared cache, with a disk store. The application only adapts it to GPUI's `HttpClient`. Add these dependencies: ```toml [dependencies] anyhow = "1" futures = "0.3" http-cache-reqwest = "0.15" reqwest = { version = "0.12", features = ["stream"] } reqwest-middleware = "0.4" tokio = { version = "1", features = ["rt-multi-thread"] } ``` ```rust use std::{path::Path, sync::LazyLock}; use futures::{AsyncReadExt as _, FutureExt as _, TryStreamExt as _, future::BoxFuture}; use gpui_kit::http_client::{ AsyncBody, HttpClient, RedirectPolicy, Request, Response, Url, http::HeaderValue, }; use http_cache_reqwest::{CACacheManager, Cache, CacheMode, HttpCache, HttpCacheOptions}; use reqwest::redirect; use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; /// reqwest needs a tokio runtime; GPUI's executors are not one. static RUNTIME: LazyLock = LazyLock::new(|| { tokio::runtime::Builder::new_multi_thread() .worker_threads(1) .enable_all() .build() .expect("failed to start the HTTP runtime") }); /// A reqwest client with an HTTP cache on disk. pub struct CachedHttpClient { follow: ClientWithMiddleware, no_follow: ClientWithMiddleware, } impl CachedHttpClient { pub fn new(cache_dir: &Path) -> anyhow::Result { let client = |policy, dir| -> anyhow::Result<_> { let client = reqwest::Client::builder().redirect(policy).build()?; Ok(ClientBuilder::new(client) .with(Cache(HttpCache { mode: CacheMode::Default, manager: CACacheManager { path: cache_dir.join(dir), }, options: HttpCacheOptions::default(), })) .build()) }; // A caller that disables redirects must receive the 3xx itself. It // gets a client that never follows them, with a cache of its own. Ok(Self { follow: client(redirect::Policy::default(), "follow")?, no_follow: client(redirect::Policy::none(), "no-follow")?, }) } } impl HttpClient for CachedHttpClient { fn user_agent(&self) -> Option<&HeaderValue> { None } fn proxy(&self) -> Option<&Url> { None } fn send( &self, req: Request, ) -> BoxFuture<'static, anyhow::Result>> { let (head, mut body) = req.into_parts(); let client = match head.extensions.get::() { Some(RedirectPolicy::NoFollow) => self.no_follow.clone(), _ => self.follow.clone(), }; async move { let mut bytes = Vec::new(); body.read_to_end(&mut bytes).await?; let request = client .request(head.method, head.uri.to_string()) .headers(head.headers) .body(bytes); let response = RUNTIME.spawn(request.send()).await??; let mut builder = Response::builder() .status(response.status()) .version(response.version()); *builder.headers_mut().unwrap() = response.headers().clone(); let body = response .bytes_stream() .map_err(std::io::Error::other) .into_async_read(); Ok(builder.body(AsyncBody::from_reader(body))?) } .boxed() } } ``` GPUI's own reqwest client applies the caller's `RedirectPolicy` to each request, but a `reqwest-middleware` client fixes the policy when it is built. The example therefore builds two clients: one that follows redirects, for `img()`, and one that never does, for callers that request `RedirectPolicy::NoFollow`. Each has its own cache directory, because a cache in front of a following client stores the final response under the original URL and would answer the other caller with it. `RedirectPolicy::FollowLimit` uses reqwest's default limit of 10. ### Install the client Install the client once at startup, before any window loads a remote image. Use your platform's cache directory, for example from the [dirs](https://crates.io/crates/dirs) crate, instead of the temporary directory used here: ```rust use std::sync::Arc; gpui_kit::application().run(|cx| { gpui_kit::init(cx); let cache_dir = std::env::temp_dir().join("my-app/http-cache"); let client = CachedHttpClient::new(&cache_dir).expect("failed to create the HTTP client"); cx.set_http_client(Arc::new(client)); // Open windows here. }); ``` ### Extend the example - **Size.** The disk store has no size limit. Remove old entries at startup, or clear the directory when it grows past a limit. - **Proxy and user agent.** Configure them on the `reqwest::Client::builder()`, and return them from `proxy()` and `user_agent()` when other code reads them. - **Request bodies.** The example reads a request body into memory before sending it. That is fine for images and small requests; wrap the stream with `reqwest::Body::wrap_stream` for large uploads. ## Troubleshooting | Symptom | Check | | --- | --- | | An embedded image is blank | The registered `AssetSource` must contain the exact key. `img("images/a.png")` is an asset lookup, not a file read. | | Every URL image shows the fallback on native | Install an `HttpClient`; the default one fails every request. | | `with_loading` never appears, or a GIF does not play | Give the `img()` an `.id(...)`. | | An `svg()` is invisible | Set `.text_color(...)` and a size on the `svg()` element itself. | | A multicolor SVG turns into one color | Draw it with `img()`; `svg()` always draws a single color. | | The layout jumps when an image arrives | Reserve its box with a size, or a width and `aspect_ratio(...)`. | | A fixed image still shows the old failure | The failure is cached; call `ImageSource::remove_asset(cx)` before drawing it again. | | Images download again in every new view or after a restart | Cache at the HTTP layer; see [Cache remote images over HTTP](#cache-remote-images-over-http). | --- # SystemNotification Source: /versions/v0.7.1/docs/system-notification License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/system-notification and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `SystemNotification` posts a message to the **operating system's notification center**. It is an application-level platform service, not an Element rendered inside a [Window](./window). GPUI Kit's [Notification component](../component/notification) defaults to an in-app toast and can also post to the system with `.system()` or to both places with `.in_app_and_system()`. | Need | Use | | --- | --- | | Immediate feedback while the user is working in the window | `window.push_notification(Notification::info(...), cx)` | | A completed background task that may matter while the app is inactive | `window.push_notification(Notification::info(...).system(), cx)` | | The same status in the window and the OS notification center | `window.push_notification(Notification::info(...).in_app_and_system(), cx)` | | GPUI payload fields, a chosen tag, or OS action buttons | `cx.show_system_notification(SystemNotification { ... })` | Use an in-app toast for a result the user is already watching; use a system notification when they may have switched away. Keep important state in the application itself: delivery may be disabled, suppressed, or delayed by the OS. For a failure requiring a decision, show an in-app error or dialog when the user returns instead of depending on a transient system message. ## Try the existing story first From this repository, run the notification story and press **System only** or **In-app and system**: ```sh cargo run -p gpui-component-story -- NotificationStory ``` The buttons are in `crates/story/src/stories/notification_story.rs`. The story startup sets an app identity in `crates/story/src/main.rs`. This exercises the existing GPUI Kit path without adding a crate. On macOS, a `cargo run` binary is not a bundled `.app`, so the system post is suppressed; use a bundled build for a real delivery check. The in-app half of **In-app and system** can still appear. Walk through the **System notification** section in this order: | Action | Expected observation | | --- | --- | | Press **System only**. | No toast appears inside the story. On a supported, permitted desktop installation, the OS receives **Build finished** with the body **Delivered straight to the notification center.** | | Press **In-app and system**. | A toast remains in the story because this button sets `autohide(false)`. The OS post uses **Build finished** with the body **Shown as a toast and in the notification center.** | | Switch to another application, then activate the OS notification. | The application and the original story window are requested to activate. The terminal running the story prints `[notification] system notification clicked`; an in-app counterpart, if present, closes. Window activation and notification presentation remain subject to OS policy. | Both buttons use the same `SystemNotificationKind` ID. In-app pushes with that ID replace the existing toast; a later OS post with the same tag replaces an earlier one only where the platform supports tag replacement. The exercise checks the **component route**. It does not exercise raw `SystemNotification` action buttons. On macOS, the `cargo run` exercise can verify only the in-app portion; use a packaged, authorized app to check OS delivery and activation. If your desktop suppresses banners, also inspect its notification center before treating the post as missing. ## Initialize and identify the application Call `gpui_kit::init(cx)` once at startup and set a stable identifier and display name before opening windows or posting. Windows needs an app identity for an unpackaged app; Linux can use its display name. The GPUI test platform also requires an identity, so set one in notification tests. ```rust gpui_kit::application().run(|cx| { gpui_kit::init(cx); cx.set_app_identity("com.example.exporter", "Exporter"); // Open application windows here. }); ``` | Platform | Delivery conditions and behavior | | --- | --- | | macOS | Run from a real `.app` bundle in a trusted location such as `/Applications`. A plain `cargo run` process cannot post. The first post can request notification permission; denial suppresses delivery. | | Windows | Set the identity early, especially for an unpackaged app. Windows toast delivery and visibility also depend on OS notification settings. | | Linux | A working session D-Bus and XDG notification daemon are needed. The adapter logs a warning if it cannot show a notification. Its current implementation neither retracts nor replaces by tag. | GPUI's `show_system_notification` returns `()`, not a delivery result. A successful call therefore means only that the request was submitted to the platform adapter. A missing toast is not evidence that the application's background work failed. Treat permission as part of the product flow: keep the task result in application state, and make it reachable when the user returns. The OS notification is a prompt to revisit that state, not the state itself. A denied permission, muted application, quiet mode, missing daemon, or a closed source window must not erase a completed export or its error. ## Post with the raw GPUI API The project pins `gpui-pre 0.3.8`. Its [`App::show_system_notification`](https://docs.rs/gpui-pre/0.3.8/gpui/struct.App.html#method.show_system_notification) accepts a `SystemNotification` with `tag`, `title`, `body`, and `actions`: ```rust use gpui_kit::SystemNotification; // In code that has an &mut App named cx: cx.show_system_notification(SystemNotification { tag: "export/report-42".into(), title: "Export complete".into(), body: "report.pdf is ready".into(), actions: Vec::new(), }); ``` Choose a tag for the underlying task or item. Reposting that tag replaces the earlier notification **where the platform supports replacement**. `cx.dismiss_system_notification("export/report-42")` requests removal of a pending or delivered notification, also on a best-effort basis. In particular, the current Linux adapter does not implement either tag replacement or retraction, so duplicate or stale entries may remain until the daemon ages them out. For OS buttons, put `SystemNotificationAction { id, label }` values in `actions`. `id` is returned as `SystemNotificationResponse::action_id` when that button is pressed; activating the body returns `None`. A platform may present the notification without its buttons. Register `cx.on_system_notification_response(|response, cx| { ... })` once to route raw responses by `response.tag` and `response.action_id`. The handler runs only for delivered user activations, not for posting failures or ordinary dismissal. Raw GPUI posting has no originating window to activate or domain callback to run automatically; the application must own that routing and ensure the target window still exists. For a raw response handler, keep the tag tied to a durable task key, look up that task when the response arrives, and handle both a body click (`None`) and each action ID. A response can arrive after the task or window has gone away. Do not treat the handler as a completion callback for the background operation. ## Use GPUI Kit's window integration When a click should return to the originating window, push a `Notification` through that window's `WindowExt`. Open the window through `gpui_kit::open_window` or wrap its view in `Root::new` so the notification overlay exists. ```rust use gpui_kit::component::{notification::Notification, WindowExt}; struct ExportNotice; window.push_notification( Notification::success("report.pdf is ready") .title("Export complete") .id::() .system() .on_click(|_, window, cx| { // Open the completed export in the owning window. }), cx, ); ``` The component maps its ID to a namespaced system tag and remembers the window that posted it. Use `.id1::(task_key)` when separate tasks need separate identities. It sends the title and message as OS text; if only a message is set, that message becomes the OS title. A content-only notification with neither title nor message does not post. Custom content and the component's `.action(...)` button belong to the in-app toast; component system posts have no OS action buttons. On a recognized click, GPUI Kit requests retraction, activates the app, and then activates the originating window, closes its in-app counterpart if present, and calls `on_click`. The callback needs a live originating window. If that window has closed, the app can still activate, but there is no window callback. A response after restart or after the component's bounded routing entries have been pruned can likewise activate the app without invoking `on_click`; keep essential task state outside the notification callback. The component handles a response once, and ignores raw tags outside its namespace. `.system()` creates no in-app toast, so `on_close` does not run. `.in_app_and_system()` creates both; automatic toast timeout does **not** retract the OS copy. Explicit `window.remove_notification::(cx)` or `window.clear_notifications(cx)` requests system retraction for notifications owned by that window, including system-only ones. If another window has since posted the same component ID, the first window's removal does not retract the newer post. OS retraction remains best-effort. ## Keep one response handler GPUI keeps one `App::on_system_notification_response` handler: registering another replaces the previous one. With the default component feature, `gpui_kit::init(cx)` installs GPUI Kit's handler. Do not overwrite it if component system notifications need click behavior. Raw calls can still post, but that handler ignores their tags, so raw action responses will not reach application code. GPUI Kit does not expose a public way to chain a raw handler onto its component handler; choose one response ownership path for the application. ## Test and troubleshoot `TestAppContext` exposes `shown_system_notifications()`, `delivered_system_notifications()`, `dismissed_system_notifications()`, and `simulate_system_notification_response(...)`. Set an identity in the test, inspect the posted title/body/tag, simulate a click, and assert the intended window callback or raw handler result. The test platform models replacement and retraction; it does **not** prove that a real OS daemon, permission prompt, or click activation works. Check those on every target OS. If nothing appears, verify the application identity, bundle and permission status on macOS, Windows notification settings, or the Linux session daemon. If a click does nothing, check whether the notification was posted through the component or raw GPUI path, whether another response handler replaced GPUI Kit's handler, and whether the originating window still exists. For toast styling and lifecycle, see the [Notification component](../component/notification); for platform boundary design, see [Native Extensions](./native-extension). | Symptom | Check next | | --- | --- | | **In-app and system** shows a toast but no OS entry. | The window integration is mounted. Check OS permission, quiet mode, and platform delivery prerequisites above. On macOS under `cargo run`, this is the expected result. | | **System only** appears to do nothing. | This mode deliberately has no in-app toast. Check the notification center and the same platform prerequisites; the API has no delivery-result callback. | | Clicking an OS entry activates the app but not the intended view. | Check that the originating window is still open and the component's in-memory routing entry still exists. Restore the view from application state on return. | | An old entry remains after a newer post or explicit removal. | Check whether the platform supports tag replacement and retraction. The current Linux adapter supports neither. | | A raw notification's action button has no application effect. | Check the single app-global response handler. The handler installed by `gpui_kit::init(cx)` routes GPUI Kit component tags and ignores raw tags. | --- # RenderOnce Source: /versions/v0.7.1/docs/render-once License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/render-once and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. The core distinction is ownership. **`RenderOnce::render(self, ...)` consumes a component value**: its parent normally constructs a fresh, lightweight description when the parent renders. **[`Render::render(&mut self, ...)`](./render) borrows a retained View** stored in an [Entity](./entity). Use `RenderOnce` for declarative inputs that describe a reusable piece of UI for this render, and `Render` when a View itself must keep state and a lifecycle across renders. `Entity` can also hold a model or other data that does not implement `Render`; an Entity becomes a renderable View when its type implements that trait. ```rust // RenderOnce fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement; // Render fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement; ``` That is why most reusable GPUI Kit components are `RenderOnce`: a caller can supply props and a handler, and the component can return existing semantic elements without creating an Entity for every button, row, or badge. A file tree, chat list, chart workspace, or other feature View that owns a collection, subscriptions, async work, or coordinated selection usually needs a retained `Entity` and `Render`. A complex feature View can still create many `RenderOnce` children. ```rust use gpui_kit::*; use gpui_kit::prelude::*; #[derive(IntoElement)] struct MessageRow { author: SharedString, body: SharedString, action: Option, } impl MessageRow { fn new(author: impl Into, body: impl Into) -> Self { Self { author: author.into(), body: body.into(), action: None, } } fn action(mut self, action: impl IntoElement) -> Self { self.action = Some(action.into_any_element()); self } } impl RenderOnce for MessageRow { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { let MessageRow { author, body, action } = self; div() .flex() .gap_2() .child(div().font_semibold().child(author)) .child(body) .when_some(action, |row, action| row.child(action)) } } ``` `#[derive(IntoElement)]` generates the conversion that lets the value participate in GPUI's fluent [Element](./element) tree: ```rust div().child(MessageRow::new("You", "Explain RenderOnce")) ``` The derive does not render the component eagerly. It generates an `IntoElement` implementation whose element is `ViewElement`; GPUI consumes and renders the value as part of the surrounding tree. Implementing `RenderOnce` alone gives the value a `View` implementation, but does not let you pass it directly to `.child(...)`: that also requires `IntoElement`, which this derive supplies. The derive does **not** implement `Styled`, `ParentElement`, focus, or accessibility semantics for your type. Those capabilities come from the elements you build or from additional traits you implement. ## Try it: rebuild a value, keep the count In an app that depends on `gpui-kit`, replace `src/main.rs` with this complete example and run `cargo run`. The `CounterLabel` is a `RenderOnce` value. `CounterView` is the retained `Entity` that owns the count. ```rust use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::*; #[derive(IntoElement)] struct CounterLabel { count: u32, } impl RenderOnce for CounterLabel { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { div().child(format!("Count: {}", self.count)) } } struct CounterView { count: u32, } impl Render for CounterView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .flex() .flex_col() .size_full() .items_center() .justify_center() .gap_2() .child(CounterLabel { count: self.count }) .child( Button::new("increment") .primary() .label("Add one") .on_click(cx.listener(|this, _, _, cx| { this.count += 1; cx.notify(); })), ) } } fn main() { application().with_assets(assets::Assets).run(|cx| { init(cx); open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| CounterView { count: 0 }) }) .expect("Failed to open window"); }); } ``` The window starts at `Count: 0`. Click **Add one** twice: it should show `Count: 1`, then `Count: 2`. Each `CounterView::render` call constructs a new `CounterLabel` from the current count; its earlier value is consumed. The count survives because the same `CounterView` Entity owns it. `cx.notify()` requests another render after the click changes that owner. The component's name does not promise exactly one render per display frame; see [Render](./render) for when GPUI renders. If the text stays at zero, check that the listener writes `this.count` and calls `cx.notify()`. If it always returns to one, check that `CounterView { count: 0 }` is created in the window closure, not inside `render`. If `.child(CounterLabel { ... })` does not compile, keep `#[derive(IntoElement)]` and `use gpui_kit::*;` in scope. The later snippets in this page illustrate separate variations; use this full example as the copyable starting point. ## Build and use the component `MessageRow` has a small builder surface: `new` supplies required text, while `action` is an optional **slot**. `AnyElement` stores whichever concrete element the caller supplies. The slot is rendered after the body; calling `.action(...)` twice replaces the earlier element. This follows the repository's [`Empty` component](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/empty.rs), which uses named optional slots and renders them in a fixed order. If the component must accept several children, store `Vec` and implement `ParentElement`; one named slot does not need that trait. The caller can put a semantic control in the slot and keep the behavior in its retained View. In this example the `Editor` below owns `opened`; its click listener changes that field and requests another render: ```rust // Inside Editor::render, where cx: &mut Context is available. MessageRow::new("You", "Explain RenderOnce").action( Button::new("open-message") .label("Open") .on_click(cx.listener(|this, _, _, cx| { this.opened = true; cx.notify(); })), ) ``` Import `Button` with `use gpui_kit::component::button::Button;` and give `Editor` an `opened: bool` field. For repeated messages, pass each button a stable ID derived from that message's domain ID (for example, `("open-message", message.id)` if that ID is a supported `ElementId` part). The label is display text, not an identity. `MessageRow` itself has no Entity, listener context, or callback ownership: it consumes the already-built action. A custom component that owns its own callback can instead store an owned `'static` handler in a private field and forward it to a semantic control during `render`; use that design when the callback is part of the component's contract. ## Owned values and the render lifecycle The [`Render`](./render) guide covers the retained View. `RenderOnce` requires `Self: 'static`, and the value component's actual signature is: ```rust fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement; ``` Because `self` is owned, rendering can move fields directly into the Element tree and its `'static` handlers. The component value is used once; its parent constructs a new value the next time that parent renders. Builder methods may mutate the value while constructing it; the resulting props describe one render rather than a persistent mutable model. Destructuring first keeps ownership clear when several fields move into different parts of the tree. The complete `MessageRow::render` above does this for its text and action slot. The slot conversion happens in the builder, before `render`; `.when_some(...)` adds it only when present. A `SharedString` owns reusable text, and `AnyElement` owns the type-erased child, so neither borrows a short-lived local variable. This does **not** mean the visible UI disappears after one frame, nor that every display refresh constructs a new value. “Once” refers to one component instance: the parent makes another whenever its `render` runs. A normal window redraw may render even unchanged child views, while an explicit [cached view](./view-cache) can skip a clean subtree. GPUI retains Entities and keyed state across these render passes, so this is not simply a traditional immediate-mode loop that rebuilds the whole application on every screen refresh. Do not retain `&mut Window` or `&mut App` beyond this call. Repeated rows should use stable IDs derived from domain data when their children need identity; see [ElementId](./element_id). ## State belongs outside the component value `RenderOnce` does **not** mean “no state.” The value holds props such as a label, disabled flag, or checked flag for this render. GPUI may retain small interaction details under a stable [ElementId](./element_id), and a `RenderOnce` component may hold an external `Entity` handle. The boundary is that changing application state must have a durable owner outside the consumed value. That owner may be a `Render` View, a model-only Entity, or another application state holder; pass current values, a callback, or a handle into the component. GPUI Kit's styled `Button` and `Checkbox` both implement `RenderOnce`. The Button takes a label and click handler. Checkbox takes a controlled `checked` bool and reports the requested next value through `on_click`; the owner writes that value and calls `cx.notify()`. Their Base layer supplies focus, keyboard, and accessibility behavior. The parent can recreate them cheaply while keeping the actual state in one place. ```rust use gpui_kit::component::checkbox::Checkbox; // Inside the owner's Render::render, with self.show_hidden and cx available: Checkbox::new("show-hidden") .checked(self.show_hidden) .label("Show hidden files") .on_click(cx.listener(|this, checked, _, cx| { this.show_hidden = *checked; cx.notify(); })) ``` ```rust use gpui_kit::component::button::{Button, ButtonVariants}; struct Editor { saved: bool, } impl Render for Editor { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .child(if self.saved { "Saved" } else { "Unsaved" }) .child( Button::new("save") .label("Save") .primary() .on_click(cx.listener(|this, _, _, cx| { this.saved = true; cx.notify(); })), ) } } ``` This example assumes the first example's `use gpui_kit::*;` import. The `Editor` is mounted in an `Entity`; `Button::new` establishes a stable element ID. The button's label, focus, keyboard activation, and accessible role come from the component and Base layers. Element handlers are `'static`, so a child that captures a callback or `Entity` needs an owned value. Clone a handle before moving it when the caller still needs it. Text editing shows the other side of the boundary. `Input::new(&state)` returns a `RenderOnce` visual component, but `state` is an `Entity` retained by the owning View. `InputState` implements `Render` and keeps text, selection, focus, editing history, and input behavior across parent renders. Recreating `Input::new(&state)` does not recreate the editor state: ```rust use gpui_kit::component::input::{Input, InputState}; struct SearchView { query: Entity, } impl Render for SearchView { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { Input::new(&self.query).id("search-query") } } ``` Create `query` once when constructing `SearchView`, for example with `cx.new(|cx| InputState::new(window, cx))`; do not create it inside `render`. A collection-heavy View follows the same ownership rule: keep records, filters, selected IDs, and subscriptions in the retained owner, then render value-like rows and controls from them. Capturing an `Entity` keeps that entity alive as long as the rendered handler is retained. This is often correct for a child acting on its owner. Use `WeakEntity` when the handler must not extend the target's lifetime, and handle the case where `weak.update(...)` can no longer reach it. `RenderOnce::render` receives `&mut App`, not `&mut Context`. A `RenderOnce` component therefore has no entity [Context](./context) of its own: it cannot use `cx.listener` for itself, retain its own subscriptions or tasks, or call `cx.notify()` to schedule itself. Pass a handler, dispatch an [Action](./action), or update the state-owning `Entity` instead. Keyed element state can retain local interaction details, but it does not replace an owner for durable application data. ## Common compile errors and a quick check | Symptom | Check | | --- | --- | | `MessageRow` does not implement `IntoElement` at `.child(...)` | Add `#[derive(IntoElement)]` as well as `impl RenderOnce`; keep `use gpui_kit::*;` in scope. | | `no method named when_some` (or a fluent style method) | Import `gpui_kit::prelude::*;` for `FluentBuilder`, and check whether the method belongs to the returned `div()` rather than your component type. | | A borrowed local value “does not live long enough” in a slot or handler | Move owned text (`SharedString`), an owned `AnyElement`, or a cloned `Entity` handle into the component. GPUI handlers must be `'static`. | | `cx.listener` or `cx.notify()` is unavailable in `RenderOnce::render` | That method gets `&mut App`, not an entity `Context`. Create the listener in the owner View's `Render::render` and pass it in, as above. | | Calling `.child(...)` on `MessageRow` fails | The derive provides `IntoElement`, not `ParentElement`. Add a named builder such as `.action(...)`, or implement `ParentElement` and store children explicitly. | To check a copied example, first put the type, builders, and `RenderOnce` implementation in the same module with both imports shown above. Use it as a child of a retained View and run `cargo check -p your-app` in that app's workspace. The `Editor` snippets are separate illustrations; add the mentioned fields and imports before compiling them together. For the repository's real API, compare [`Empty`](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/empty.rs), [`Checkbox`](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/checkbox.rs), and [`Button`](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/button/button.rs). ## Builder-style components Owned, private fields make `RenderOnce` work naturally with builder APIs. GPUI Kit uses this pattern in its components. A builder takes and returns `Self`, preserving a valid component while callers refine it: ```rust use gpui_kit::component::ActiveTheme; #[derive(IntoElement)] struct StatusBadge { label: SharedString, muted: bool, } impl StatusBadge { fn new(label: impl Into) -> Self { Self { label: label.into(), muted: false, } } fn muted(mut self, muted: bool) -> Self { self.muted = muted; self } } impl RenderOnce for StatusBadge { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { div() .rounded_full() .px_2() .text_color(cx.theme().muted_foreground) .when(!self.muted, |this| this.text_color(cx.theme().foreground)) .child(self.label) } } ``` This example also uses `use gpui_kit::*;`. The returned `div()` implements [`Styled`](./style) and `ParentElement`, enabling `.rounded_full()`, `.text_color()`, and `.child()`. If callers need those methods **on `StatusBadge` itself**, implement `Styled` and/or `ParentElement` for the type, store the styles or children, and apply them in `render`. `IntoElement` derive does not forward the returned element's fluent traits. GPUI Kit's `Button` explicitly implements both. Use `.when(...)` and `.when_some(...)` for small refinements; use ordinary Rust branches when the UI structure differs substantially. ## Choose the right layer | Use | When | | --- | --- | | [`RenderOnce`](./render-once) + `IntoElement` | A reusable component consumes caller-supplied props and handlers for a render. It may use keyed state or an external Entity; rendering receives `&mut Window` and `&mut App`. | | [`Render`](./render) | A retained `Entity` owns changing data, collections, subscriptions, tasks, or a lifecycle; rendering receives `&mut Context` to update and notify it. | | [`Element`](./element) | Built-in elements cannot express required layout, prepaint, paint, hit testing, or other low-level phases. | A useful composition is: a `Render` view owns state, it creates `RenderOnce` components to describe reusable UI, and those components return built-in Elements. This gives most GPUI Kit components a small, explicit API while keeping complex state in a few meaningful owners. Implement `Element` only when the standard Element APIs cannot express the rendering behavior. If a component starts accumulating mutable state, subscriptions, or background tasks, move that lifecycle into an `Entity` and implement `Render` for it. Keeping such state inside a value that is consumed on every render loses the ownership model that makes `RenderOnce` simple. --- # KeyBinding Source: /versions/v0.7.1/docs/keybinding License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/keybinding and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A **KeyBinding** maps one or more keystrokes to a typed [Action](./action). GPUI Kit uses GPUI's keymap: register bindings on the application, then put a matching Key Context and Action handler on the focused [Element](./element)'s dispatch path. Begin with [Focus](./focus) to create and track a keyboard target; the Action guide explains command dispatch. This page concentrates on writing and resolving bindings. ## Bind a command `KeyBinding::new(keys, action, context)` takes a keystroke string, an Action value, and an optional context predicate. Call `cx.bind_keys(...)` during initialization. A `None` context is eligible throughout the application; `Some("Editor")` requires an `Editor` context on the focused path. ```rust use gpui_kit::*; actions!(editor, [SaveDocument, MoveSelectionUp]); const EDITOR_CONTEXT: &str = "Editor"; fn init_keys(cx: &mut App) { cx.bind_keys([ KeyBinding::new("secondary-s", SaveDocument, Some(EDITOR_CONTEXT)), KeyBinding::new("up", MoveSelectionUp, Some(EDITOR_CONTEXT)), ]); } ``` Call `gpui_kit::init(cx)` once before `init_keys(cx)` and before opening windows. GPUI Kit registers its component bindings during initialization; the application can then add its own bindings in a deliberate order. Keep a [FocusHandle](./window) in the owning [Entity](./entity) and register it with the context and handler in its [Render](./render) implementation: ```rust impl Render for Editor { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .track_focus(&self.focus_handle) .key_context(EDITOR_CONTEXT) .on_action(cx.listener(Self::on_action_save_document)) .on_action(cx.listener(Self::on_action_move_selection_up)) .child("Editor") } } ``` The handlers have the usual GPUI signature, for example `on_action_save_document`. The `cx` argument is the owner's [Context](./context). A binding does not itself focus the element. Focus it when entering the region, or let pointer interaction focus its tracked handle. See [Action](./action) for focus ownership, dispatch, and propagation. ## Write key strings A keystroke is a key name with optional modifiers separated by hyphens. Separate successive keystrokes with spaces to form a **chord**: | Binding | Meaning | | --- | --- | | `secondary-s` | Primary shortcut modifier plus S: Command on macOS, Control on Linux and Windows. | | `ctrl-enter`, `alt-f4`, `shift-tab` | Explicit modifiers. | | `cmd-shift-p` | Platform modifier plus Shift and P. `cmd`, `super`, and `win` refer to the platform modifier; on Windows this is the Windows key, not Control. | | `up`, `escape`, `space`, `backspace` | Named keys. | | `cmd-k left` | Two keystrokes in sequence: the platform modifier plus K, then Left. | GPUI also parses `fn`, `ctrl`, `alt`, `shift`, `cmd`, `super`, `win`, and `secondary` as modifiers. Use `secondary` for a conventional cross-platform Command/Control shortcut. Use `ctrl` when Control itself is required on every platform. A capital ASCII letter such as `A` implies Shift+A; writing `shift-a` makes that intent clearer. `KeyBinding::new` **panics** if its key string or context predicate cannot be parsed, so keep static definitions reviewable and validate user input before constructing bindings. The operating system or window manager may reserve a shortcut before GPUI receives it. Test the intended combination on each supported platform, especially `cmd`/`super`/`win` combinations and system shortcuts such as `alt-f4`. A binding that parses successfully is not proof that a key event will reach the application. Chords can share a first keystroke. GPUI holds a prefix while a longer matching binding is possible; a following keystroke completes the chord or causes the prefix to be replayed. Avoid making a common text entry key a chord prefix in an editable region. ## Declare Key Contexts The element's `.key_context(...)` declares facts about a node. The binding's third argument is a **predicate** tested against contexts along the current focus path. They use related but different syntax: ```rust // Context attached to one element: identifier plus key/value fact. div().key_context("Editor mode=normal") // Predicates used as KeyBinding::new's third argument. Some("Editor") Some("Editor && mode == normal") Some("Editor && !Modal") Some("Workspace > Editor") ``` `Editor mode=normal` declares two facts on one node; `Editor && mode == normal` tests those facts. Predicates support identifiers, `==`, `!=`, `!`, `&&`, `||`, parentheses, and `>` for an ancestor followed by a descendant context. For example, `Workspace > Editor` requires an `Editor` context below a `Workspace` context. Use parentheses when combining operators so the intended grouping is explicit. `!Modal` excludes a path containing `Modal`; it is useful for a workspace shortcut that should not run while a modal owns focus. Contexts only participate when they lie on the **focused** dispatch path. A sibling's `Editor` context does not activate an editor binding. A binding can match while its handler remains unreachable if that handler is on another branch. Put both context and handler on the focused region or an ancestor that owns the command. ## Understand precedence When several bindings match the same keys, GPUI ranks them by the depth of the matching context on the focused path. A binding for an inner `Editor` ranks ahead of one for its `Workspace` ancestor. At the same depth, the binding added **later** ranks first. A binding with `None` context is treated as matching at the deepest context depth, so it is not automatically a weak fallback: a later `None` binding can rank ahead of an `Editor` binding on the same keys. Prefer an explicit workspace context for a shortcut that should yield to an inner control. GPUI can try more than one matching binding. It dispatches each candidate Action along the focused path until a handler consumes one; Action handlers stop propagation by default. A handler that declines an Action can call `cx.propagate()` to let dispatch continue. Keep competing bindings intentional: order, context depth, and handler availability all affect the result. Select a Focus target to see which `escape` binding ranks first in this example. The tree shows the focused path; the result assumes a reachable handler consumes the first Action.
Focus path
Window
Workspace Escape → ClearWorkspaceSelection
Editor Escape → CloseEditorSearch
Modal No Escape binding
Matching Escape bindings · highest first
1 · WorkspaceClearWorkspaceSelection
1 · EditorCloseEditorSearch
2 · WorkspaceClearWorkspaceSelection

No matching Escape binding on this Focus path.

```rust cx.bind_keys([ KeyBinding::new("escape", ClearWorkspaceSelection, Some("Workspace")), KeyBinding::new("escape", CloseEditorSearch, Some("Editor")), ]); ``` With focus in an Editor inside Workspace, `CloseEditorSearch` has the more specific match. With focus elsewhere in Workspace, only `ClearWorkspaceSelection` matches. This is how GPUI Kit components such as Tree and TimeField keep arrow-key behavior local to their own contexts. ## Look up a shortcut from its Action An Action is also the key for **reverse lookup**: ask the window which binding currently invokes that Action. GPUI Kit's [Kbd component](../component/kbd) displays the result. Use the focus handle of the command's intended target when rendering a button, menu, or command palette. This matters when another control or an overlay currently owns Focus. ```rust use gpui_kit::component::kbd::Kbd; let binding = window.highest_precedence_binding_for_action_in( &SaveDocument, &self.editor_focus, ); // A GPUI Kit hint for the same Action and target. let hint = Kbd::binding_for_action_in( &SaveDocument, &self.editor_focus, window, ); div().children(hint) ``` The first call returns `Option`; the second returns `Option`, ready to render beside a label. Both account for the target's context, shadowing by a higher priority binding, and the active keymap, including user overrides. `None` means there is no visible binding for that Action on that resolved path. These lookups use the **previously rendered frame**, so a focus handle first drawn in the current frame may have no result yet. A binding lookup does not check the command's runtime enabled state or prove that an Action handler is on the target path; the owner must still decide whether the command is allowed. `window.is_action_available_in(&SaveDocument, &self.editor_focus)` checks for an element Action handler on that path. For the current window context, GPUI also offers `window.highest_precedence_binding_for_action(&action)` and `window.bindings_for_action(&action)`; the latter returns all visible bindings. For a known single context, use `window.highest_precedence_binding_for_action_in_context`. GPUI Kit offers `Kbd::binding_for_action(&action, Some("Editor"), window)` for the same simple context lookup, or `None` for the window's current context. Its `Some(...)` argument uses **Key Context declaration syntax**, such as `Editor mode=normal`, not predicate syntax such as `Editor && mode == normal`. An invalid context string silently falls back to the window lookup, so validate dynamic input before passing it. A concrete focus handle is the safer choice when contexts are nested or a menu has moved Focus. GPUI Kit's `Kbd::global_binding_for_action(&action, window)` offers a last-resort lookup against an empty Key Context, without reconstructing a nested focus path. `Kbd::binding_for_action_in` currently displays **only the first keystroke** of a chord. To show the entire chord, format every stroke of the returned `KeyBinding`: ```rust use gpui_kit::AsKeystroke; use gpui_kit::component::kbd::Kbd; let shortcut = binding.map(|binding| { binding.keystrokes().iter() .map(|stroke| Kbd::format(stroke.as_keystroke())) .collect::>() .join(" ") }); ``` `Kbd::format` chooses platform-specific modifier symbols and key names: `secondary-s` displays as `⌘S` on macOS and `Ctrl+S` on Linux and Windows. `AsKeystroke` exposes each binding stroke as a `Keystroke`. Keep `shortcut` optional: an unbound Action should not show a made-up accelerator. ## Keep menus and displayed shortcuts in sync Use one Action for the shortcut, button, command palette, and menu item. A menu can hold the same value with `MenuItem::action("Save Document", SaveDocument)`, while a button can dispatch it with `window.dispatch_action(Box::new(SaveDocument), cx)`. The focused owner then runs the same handler. Register bindings **before** `cx.set_menus(...)`. Native menus read the current keymap when built and retain the displayed shortcut. If an application changes bindings later, rebuild the menus with `cx.set_menus(...)` so their shortcut labels and native accelerators reflect the new keymap. For in-window menus and tooltips, query the Action's current binding as above rather than hard-coding `⌘A` or `Ctrl+A`: the platform, target context, and user keymap can each change the displayed shortcut. GPUI Kit's Popup Menu resolves shortcuts against the command target or trigger Focus and displays them with `Kbd`; a tooltip can use `Tooltip::action` to resolve a simple context when it renders. ## User keymaps and named Actions GPUI's `actions!` macro registers unit Actions under stable names such as `editor::SaveDocument`. For an Action with configuration data, derive `Action` and the required deserialization and schema traits; `#[action(no_json)]` marks an Action that cannot be constructed from JSON. `cx.all_action_names()` lists registered names, and `cx.build_action(name, data)` builds a registered Action from an optional JSON value. GPUI provides this Action registry and keymap machinery, but an application owns its **user keymap file format**, validation, loading, and reload policy. A loader can resolve an Action name and payload with `cx.build_action(...)`, parse the context with `KeyBindingContextPredicate::parse(...)`, and construct a binding with the fallible `KeyBinding::load(...)`. Report parse or unknown-Action errors to the user rather than passing untrusted strings to the panic-on-error `KeyBinding::new(...)`. `cx.bind_keys(...)` appends bindings; `cx.clear_key_bindings()` clears the entire application keymap, including component defaults, so a reload must restore every required binding in the desired order. Rebuild native menus after a keymap change. ## Troubleshoot a shortcut 1. **Keys:** Check the actual modifier on this platform. `secondary-s` means Command+S on macOS and Control+S elsewhere; `cmd-s` does not mean Control+S on Windows. 2. **Focus:** Check which `FocusHandle` is focused and that a rendered element calls `track_focus` with it. A shortcut that works only after a click often has a focus path problem. 3. **Context:** Check that the predicate matches a `key_context` on that path. Use `mode=normal` when declaring a context and `mode == normal` when testing it. 4. **Competition:** Check deeper contexts, later bindings at the same depth, and chord prefixes. A `None` binding can outrank a shallower scoped binding. 5. **Handler:** Check that the matching Action has an `on_action` handler on the focused path and that a more specific handler does not consume it first. 6. **Menus and reloads:** If a menu shows an old shortcut, call `cx.set_menus(...)` after installing the new keymap. If a reload removed a component shortcut, confirm that `clear_key_bindings()` was followed by all component initialization. ## Verify with repository examples The [Tree implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/tree.rs) registers arrow-key bindings under `Tree` and places `.key_context(CONTEXT)`, `.track_focus(&focus_handle)`, and the `on_action` handlers on its rendered root. The [Combobox implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/combobox.rs) adds Enter, Escape, and `secondary-enter` for a second confirmation mode. The [Popover story](https://github.com/longbridge/gpui-kit/blob/main/crates/story/src/stories/popover_story.rs) shows explicit macOS Command versus other-platform Control bindings and a focused action owner. To check an application binding, give the target its focus handle, press the shortcut, and confirm the expected Action handler runs. Then focus a sibling outside the declared context: the contextual shortcut should no longer run. Finally, open an overlay or text input and repeat the test to expose focus changes and key conflicts. Check the displayed shortcut separately with `highest_precedence_binding_for_action_in` for the command's target handle; a correct label alone does not show that the handler is reachable. For the full route from Focus through Action handling, continue with [Action](./action). --- # Comparison Source: /versions/v0.7.1/docs/comparison License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/comparison and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. This guide compares **cross-platform desktop UI frameworks** and their documented ecosystems. Green means the capability is available, yellow means it needs more application work or validation, and red means it is not provided. These marks describe availability, not performance. Check the version, backend, platform, and license for your application. Legend: Yes · Partial · No. Ecosystem packages count where noted below. | Capability | GPUI Kit | Iced | egui | Qt 6 | Slint | | --- | --- | --- | --- | --- | --- | | UI model | Declarative passes + retained state | Declarative view + retained state | Immediate | Retained scene | Reactive tree | | UI authoring | Rust | Rust | Rust | QML / C++ / Python | `.slint` + Rust / C++ / JavaScript / Python | | Visual tooling | Code + component gallery | Code | Code | Qt Quick Designer | Live Preview / SlintPad | | Component count | [75+](/versions/v0.7.1/component) | [35](https://docs.rs/iced/0.14.0/iced/widget/#structs) | [16](https://docs.rs/egui/0.36.2/egui/widgets/#structs) | [52](https://doc.qt.io/qt-6/qml-qtquick-controls-control.html) | [24](https://docs.slint.dev/latest/docs/slint/reference/std-widgets/overview/) | | Rendering stack | GPUI | wgpu | eframe: glow / wgpu | Qt RHI / scene graph | FemtoVG / Skia / software | | Documentation | Complete core + component docs; API + gallery | Book + API | API + demos | Guides + API + designer | Guides + API + preview | | Default UI style | Theme-driven components | Styled widgets | egui visuals | Qt Quick styles | Slint widget styles | | Desktop OS | | | | | | | Multiple windows | | | | | | | Shortcuts | | | | | | | Themes | | | | | | | Bundled theme presets | 38 (36 variants + Light/Dark) | 22 built-in variants | Light / Dark | Qt Quick styles | Slint widget styles | | Code editor | | | | | | | CJK font support | System / bundled fonts | Font-dependent | Custom font required | System fallback | Font-dependent | | Text model | Rope | COSMIC Text | TextBuffer | QTextDocument | TextEdit string | | Syntax highlighting | | | | | | | Markdown | | | | | | | Markdown with inline HTML | | | | | | | HTML rendering | | | | | | | Rich text | | | | | | | Text selection | | | | | | | Advanced data table | | | | | | | Large-table virtualization | Rows + columns | No | Rows (`egui_extras`) | Rows + columns (`TableView`) | Rows | | Resizable table columns | | | | | | | Virtual list | | | | | | | Charts | | | | | | | Docking | | | | | | | Animation | | | | | | | Accessibility | | | | | | | I18N | | | | | | | UI testing | | | | | | | Mobile | | | | | | | WebAssembly | | | | | | | License | Apache-2.0 | MIT | MIT / Apache-2.0 | Commercial / LGPLv3 / GPLv3 by module | GPLv3 / commercial / royalty-free | | Minimal Binary Size | ~12 MB | ~11 MB | ~5 MB | ~20 MB | ~21 MB | | WebView | | | | | | **Component count.** GPUI Kit documents 75+ components and primitives. The other numbers count entries in the linked Iced 0.14 widget module (35), egui 0.36 widgets module (16, including `TextEdit`), Qt Quick `Control` descendants (52), and Slint standard widgets catalog (24). These catalogs include different kinds of helpers and exclude different add-ons, so the counts show catalog scale rather than a strict ranking. **Documentation.** GPUI Kit covers its current public surface with [core guides](/versions/v0.7.1/docs), [component documentation](/versions/v0.7.1/component), [API reference](https://docs.rs/gpui-kit/latest/gpui_kit/), and a [live gallery](/gallery/). Experimental features are documented with their current limits. **Size provenance.** The first four values preserve the original `main` branch's Hello World release estimates; its Qt footnote linked [this binary-size study](https://www.qt.io/blog/reducing-binary-size-of-qt-applications-part-3-more-platforms). Slint's ~21 MB comes from a Slint 1.18.1 Hello World built with `cargo build --release` on Linux x86-64 and stripped to 20,768,216 bytes (19.81 MiB). These are approximate references from different build conditions, not verified minimums or a same-method benchmark. **Frame rate.** This table does not rank frame rates: no shared workload, hardware, or presentation measurement backs a cross-framework FPS figure. A 120 Hz target gives each frame roughly 8.3 ms across the relevant pipeline; it is not a claim that an idle window redraws continuously or that every complex screen sustains 120 displayed frames per second. See [FPS Monitor](./fps#120-hz-is-a-frame-budget-not-a-refresh-promise) for GPUI's draw and present measurements. ## Decision guide The capability matrix shows coverage; these trade-offs show when that coverage matters. They are judgments about the documented APIs, not benchmark results. | If your product needs… | GPUI Kit advantage | GPUI Kit cost or limit | Compare with… | | --- | --- | --- | --- | | A code editor with dense data views | [Editor](/versions/v0.7.1/component/editor), [DataTable](/versions/v0.7.1/component/data-table), [VirtualList](/versions/v0.7.1/component/virtual-list), and [Dock](/versions/v0.7.1/component/dock) live in one Rust component stack. | The app still owns data sorting and must validate [accessibility](/versions/v0.7.1/docs/accessibility) for its target platforms. | Qt's [model/view](https://doc.qt.io/qt-6/modelview.html) and [QTextDocument](https://doc.qt.io/qt-6/qtextdocument.html) offer a broader established desktop stack. | | Selectable Markdown and article HTML | [TextView](/versions/v0.7.1/component/text-view) covers both in the same UI tree. | Its HTML support excludes general CSS layout and scripts. | Qt [QTextDocument](https://doc.qt.io/qt-6/richtext-html-subset.html) supports a broader rich-text subset; use a WebView when browser behavior is required. | | A product on desktop, mobile, and web | The same GPUI Kit component APIs can be explored in [WebAssembly showcases](/versions/v0.7.1/docs/webassembly) and experimental [iOS work](/versions/v0.7.1/docs/mobile). | Mobile and Web integrations still need validation for the target application. | Qt's [platform matrix](https://doc.qt.io/qt-6/supported-platforms.html) and Slint's [mobile](https://docs.slint.dev/latest/docs/slint/guide/platforms/mobile/general/) and [web](https://docs.slint.dev/latest/docs/slint/guide/platforms/web/) guides document broader targets. | | Visual UI authoring | Rust code and the [component gallery](/versions/v0.7.1/component) keep the interface close to application logic. | GPUI Kit does not include a visual designer. | [Qt Quick Designer](https://doc.qt.io/qtcreator/creator-using-qt-quick-designer.html) offers visual editing; Slint provides [Live Preview](https://github.com/slint-ui/slint#tooling). | > AI can edit GPUI Kit's Rust UI code directly; a drag-and-drop editor is unnecessary. ## What the rows mean **Rendering model.** GPUI Kit constructs an element description from current inputs during a [render](./render) pass. It retains [Entities](./entity), keyed element state, and optionally [cached views](./view-cache). The label in the table describes both parts; it is not a promise to rebuild the entire UI on every display refresh. The [rendering model explanation](./fps#immediate-retained-and-hybrid-describe-different-layers) separates these layers. Iced describes a [state/message/update/view architecture](https://book.iced.rs/architecture.html). egui calls itself [immediate mode](https://docs.rs/egui/latest/egui/#understanding-immediate-mode). Qt Quick [retains its scene graph between frames](https://doc.qt.io/qt-6/qtquick-visualcanvas-scenegraph.html), while Slint uses [reactive property bindings](https://docs.slint.dev/latest/docs/slint/guide/language/concepts/reactivity/). **Desktop basics.** All five can open multiple desktop windows, handle keyboard shortcuts, and customize visual themes, though the APIs differ. For example, Iced exposes [window opening](https://docs.rs/iced/latest/iced/window/fn.open.html), eframe supports [native viewports](https://docs.rs/eframe/latest/eframe/trait.App.html), and Slint documents [key bindings](https://docs.slint.dev/latest/docs/slint/reference/keyboard-input/overview/) and [widget styles](https://docs.slint.dev/latest/docs/slint/reference/std-widgets/style/). Test IME, clipboard, and drag-and-drop behavior on each target platform. **Rendering, themes, and CJK.** The “Rendering stack” row names the framework or selectable graphics backends, not a common GPU benchmark: egui's eframe supports [glow and wgpu](https://docs.rs/eframe/latest/eframe/enum.Renderer.html), Qt Quick uses a [scene graph over Qt RHI](https://doc.qt.io/qt-6/qtquick-visualcanvas-scenegraph.html), and Slint lists [FemtoVG, Skia, and software renderers](https://github.com/slint-ui/slint#runtime). GPUI Kit's 38 theme choices are the 36 variants in this repository's `themes/*.json` plus Default Light and Dark; Iced 0.14 documents [22 preset variants plus Custom](https://docs.rs/iced/0.14.0/iced/theme/enum.Theme.html). The original table's “egui CJK: Bad” was too broad: egui [requires a custom font for Asian characters](https://docs.rs/egui/latest/egui/#installing-additional-fonts), while Qt documents [script-aware fallback](https://doc.qt.io/qt-6/qfontdatabase.html). Font coverage still depends on the shipped or installed fonts. **Table column resizing.** GPUI Kit, [egui_extras](https://docs.rs/egui_extras/latest/egui_extras/struct.TableBuilder.html#method.resizable), [Qt](https://doc.qt.io/qt-6/qheaderview.html), and [Slint](https://github.com/slint-ui/slint/blob/master/internal/compiler/widgets/fluent/tableview.slint) support it; [Iced](https://docs.rs/iced/latest/iced/widget/table/struct.Column.html) does not provide built-in drag resizing. **Large-table virtualization.** This retains the original table's distinction between visible rows and visible columns: GPUI Kit's `DataTable` tracks both in its [visible range](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/table/state.rs); [egui_extras `TableBody::rows`](https://docs.rs/egui_extras/latest/egui_extras/struct.TableBody.html#method.rows) renders visible rows; and Qt Quick [`TableView`](https://doc.qt.io/qt-6/qml-qtquick-tableview.html) reuses delegates as rows and columns leave the viewport. Iced's documented [table API](https://docs.rs/iced/0.14.0/iced/widget/table/fn.table.html) does not promise built-in virtualization. Slint's [`StandardTableView`](https://github.com/slint-ui/slint/blob/master/internal/compiler/widgets/fluent/tableview.slint) places its row repeater inside a `ListView`, which [instantiates only visible rows](https://docs.slint.dev/latest/docs/slint/reference/std-widgets/views/listview/). Its column and cell repeaters do not use the same viewport virtualization. **Editor and highlighting.** GPUI Kit's [Editor](/versions/v0.7.1/component/editor) stores text in a [Rope](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/input/base/state.rs#L343). It provides code-oriented behavior such as folding, diagnostics, completion, and hover. Syntax highlighting uses [Tree-sitter](https://tree-sitter.github.io/tree-sitter/) when the matching [grammar feature](/versions/v0.7.1/component/editor#basic-usage) is enabled; large buffers are parsed in the background, and edits can reuse the previous parse tree. The editor's text model and highlighting engine are separate decisions: a Rope alone does not imply highlighted text. Iced's [TextEditor](https://docs.rs/iced/latest/iced/widget/text_editor/struct.TextEditor.html) has feature-gated highlighting, egui's [TextEdit](https://docs.rs/egui/latest/egui/widgets/text_edit/struct.TextEdit.html) accepts a custom layouter and can use [egui_extras syntax highlighting](https://docs.rs/egui_extras/latest/egui_extras/syntax_highlighting/), while Qt provides [QSyntaxHighlighter](https://doc.qt.io/qt-6/qsyntaxhighlighter.html). This row describes an available API, not comparable performance at the same document size. **Text models.** The table names each editor's storage or editing abstraction: GPUI Kit keeps text in a Rope; Iced uses [COSMIC Text](https://docs.iced.rs/src/iced_graphics/text/editor.rs.html), egui accepts a [TextBuffer](https://docs.rs/egui/latest/egui/widgets/text_edit/trait.TextBuffer.html), and Qt exposes [QTextDocument](https://doc.qt.io/qt-6/qtextdocument.html). This describes an API shape, not a comparative speed result. **Formatted text.** GPUI Kit [TextView](/versions/v0.7.1/component/text-view) renders selectable Markdown and HTML; its Markdown parser also passes inline HTML nodes to its HTML parser. Iced's [Markdown widget](https://docs.rs/iced/latest/iced/widget/markdown/) does not expose an HTML item; egui relies on add-ons for Markdown, and Qt's [QTextEdit](https://doc.qt.io/qt-6/qtextedit.html) supports Markdown with only part of embedded HTML. “No” in this row means the documented built-in Markdown path does not handle inline HTML; an application can still add another renderer. **HTML rendering.** [TextView::html](/versions/v0.7.1/component/text-view#html) handles article content such as headings, paragraphs, links, images, lists, and tables. It is partial because it does not implement general CSS layout or run scripts; it is not a WebView. Qt's [QTextDocument](https://doc.qt.io/qt-6/richtext-html-subset.html) supports a broader HTML 4 and CSS subset, also without browser behavior. This row compares documented native HTML document rendering, so a separate WebView integration does not count. Iced, egui, and Slint do not provide an equivalent native document renderer in the compared APIs. **Tables and lists.** “Advanced data table” means a component for large datasets with virtualized rendering, sorting, selection, and column management. GPUI Kit's [DataTable](/versions/v0.7.1/component/data-table) includes these behaviors; sorting the underlying data is supplied by its delegate. Qt's [QTableView](https://doc.qt.io/qt-6/qtableview.html) and model/view system also cover this use case. [egui_extras TableBuilder](https://docs.rs/egui_extras/latest/egui_extras/struct.TableBuilder.html) virtualizes rows and resizes columns, but leaves more data behavior to the app. Slint's [StandardTableView](https://docs.slint.dev/latest/docs/slint/reference/std-widgets/views/standardtableview/) exposes sorting callbacks and selection; Iced's [Table](https://docs.rs/iced/latest/iced/widget/table/fn.table.html) provides a simpler starting point. Separately, GPUI Kit [VirtualList](/versions/v0.7.1/component/virtual-list), egui [ScrollArea::show_rows](https://docs.rs/egui/latest/egui/containers/scroll_area/struct.ScrollArea.html#method.show_rows), Qt [ListView](https://doc.qt.io/qt-6/qml-qtquick-listview.html), and Slint [ListView](https://docs.slint.dev/latest/docs/slint/reference/std-widgets/views/listview/) create visible list items on demand. Iced offers [scrolling and visibility primitives](https://docs.rs/iced/latest/iced/widget/struct.Sensor.html), with virtualization left to the application. **Dense desktop UI.** GPUI Kit includes [VirtualList](/versions/v0.7.1/component/virtual-list), [DataTable](/versions/v0.7.1/component/data-table), [charts](/versions/v0.7.1/component/chart), [TextView](/versions/v0.7.1/component/text-view), and [Dock](/versions/v0.7.1/component/dock). Iced has [rich text](https://docs.rs/iced/latest/iced/widget/fn.rich_text.html), a [table](https://docs.rs/iced/latest/iced/widget/table/fn.table.html), a feature-gated [Markdown widget](https://docs.rs/iced/latest/iced/widget/markdown/), [Canvas](https://docs.rs/iced/latest/iced/widget/struct.Canvas.html), and [pane grids](https://docs.rs/iced/latest/iced/widget/pane_grid/); assembling a virtualized table or chart remains application work. The egui ecosystem supplies third-party [egui_commonmark](https://docs.rs/egui_commonmark/latest/egui_commonmark/) for Markdown, [egui_dock](https://docs.rs/egui_dock/latest/egui_dock/) for docking, [egui_extras tables](https://docs.rs/egui_extras/latest/egui_extras/struct.TableBuilder.html) and [egui_plot](https://docs.rs/egui_plot/latest/egui_plot/). Qt provides [model/view tables](https://doc.qt.io/qt-6/modelview.html), [dock widgets](https://doc.qt.io/qt-6/qdockwidget.html), [QTextDocument](https://doc.qt.io/qt-6/qtextdocument.html), and the separately licensed [Qt Graphs](https://doc.qt.io/qt-6/qtgraphs-index.html) module. Slint provides [StandardTableView](https://docs.slint.dev/latest/docs/slint/reference/std-widgets/views/standardtableview/) and a [ListView that instantiates visible items](https://docs.slint.dev/latest/docs/slint/reference/std-widgets/views/listview/); its [Path](https://docs.slint.dev/latest/docs/slint/reference/elements/path/) can draw custom graphics, but is not a ready-made chart. **Product readiness.** GPUI Kit's [Accessibility](/versions/v0.7.1/docs/accessibility), [Testing](/versions/v0.7.1/docs/test), [I18n](/versions/v0.7.1/docs/i18n), and [Animation](/versions/v0.7.1/docs/animation) guides describe the local implementation and its limits. Its components contain translations, while application strings and locale policy remain the application's responsibility. Iced's [accessibility integration is still tracked as open work](https://github.com/iced-rs/iced/issues/552); its [test harness](https://docs.rs/iced_test/latest/iced_test/) is available. egui documents [AccessKit and egui_kittest](https://github.com/emilk/egui/blob/main/docs/accessibility.md). Qt has mature [accessibility](https://doc.qt.io/qt-6/accessible.html), [internationalization](https://doc.qt.io/qt-6/internationalization.html), and [Qt Test](https://doc.qt.io/qt-6/qtest-overview.html) facilities. Slint documents [accessibility properties](https://docs.slint.dev/latest/docs/slint/reference/common/#accessibility-properties) and [translations](https://docs.slint.dev/latest/docs/slint/guide/development/translations/); its [testing backend](https://docs.slint.dev/latest/docs/rust/i_slint_backend_testing/) is preliminary and belongs to an internal crate. A “Yes” in the accessibility row still requires testing with assistive technology on each target platform. **Platform reach.** GPUI Kit has [experimental iOS integration](/versions/v0.7.1/docs/mobile) and working [WebAssembly showcases](/versions/v0.7.1/docs/webassembly); the latter have not been validated here as a full application distribution path. Iced has a [web example](https://github.com/iced-rs/iced/blob/master/examples/README.md#tour), while [native mobile support remains under discussion](https://github.com/iced-rs/iced/issues/302). [eframe](https://github.com/emilk/egui/blob/main/README.md#official-integrations) runs egui on web and native platforms, with Android/iOS integration to validate for each app. Qt documents its [supported platforms](https://doc.qt.io/qt-6/supported-platforms.html) and [WebAssembly limits](https://doc.qt.io/qt-6/wasm.html). Slint documents [mobile](https://docs.slint.dev/latest/docs/slint/guide/platforms/mobile/general/) and [web](https://docs.slint.dev/latest/docs/slint/guide/platforms/web/) targets; its web output is a canvas, without browser screen-reader support. None of these canvas-based Wasm paths should be treated as an HTML application by default. **WebView.** GPUI Kit has an experimental [Wry integration](/versions/v0.7.1/docs/webview). Its native view covers GPUI elements in the same bounds, so use a separate window or popup when layering matters. The integration currently documents macOS and Windows; its Linux example is unfinished. Qt provides official [WebEngine](https://doc.qt.io/qt-6/qwebengineview.html) and [WebView](https://doc.qt.io/qt-6/qtwebview-index.html) modules, though Qt WebView also limits overlapping QML items. This row counts integrations maintained for the compared framework version: Iced has a third-party [iced_webview](https://docs.rs/iced_webview/latest/iced_webview/) crate targeting Iced 0.13, but no verified integration here for current Iced 0.14; egui has no comparable maintained native WebView, and Slint's [WebView request](https://github.com/slint-ui/slint/issues/3930) remains open. A red dot does not rule out an application-specific bridge. **Licensing and scope.** GPUI Kit is Apache-2.0; Iced is [MIT](https://github.com/iced-rs/iced/blob/master/LICENSE); egui is [MIT or Apache-2.0](https://github.com/emilk/egui/blob/main/LICENSE-MIT); Qt uses [commercial, LGPLv3, or GPLv3 terms by module](https://doc.qt.io/qt-6/licensing.html), with Qt Graphs GPLv3 or commercial; Slint has [GPLv3 and commercial or royalty-free terms](https://slint.dev/pricing). Native single-platform stacks such as SwiftUI and WinUI have a different platform scope. Electron and Tauri use a WebView/JavaScript UI architecture and deserve a separate evaluation rather than a score in this native UI matrix. Bundle size and frame rate depend on build features, renderers, fonts, packaging, and workload; measure a release build of the product you intend to distribute. --- # TextSystem Source: /versions/v0.7.1/docs/text-system License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/text-system and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. This page follows the `gpui-pre` 0.3.8 API pinned by this repository. `gpui-pre` is the published snapshot and version-alignment mechanism for GPUI, not a separate text engine. Start with a text element; use `TextSystem` directly when your element needs the glyph geometry that GPUI normally manages. GPUI's `TextSystem` resolves [fonts](./fonts) and supplies font metrics. Each [Window](./window) has a `WindowTextSystem` that adds a line-layout cache to the shared text system. Ordinary text elements and GPUI Kit controls use these services for you. Reach for `window.text_system()` when writing custom text geometry, a chart label, an editor, or another element that must use shaped glyph positions directly. Text rendering is a pipeline: 1. Resolve a `Font` to a `FontId`, including fallback when the requested family is unavailable. 2. Shape UTF-8 text and styled `TextRun`s into positioned glyph runs and line metrics. 3. Wrap and measure lines against available width where needed. 4. Use the layout for hit testing and paint the shaped glyphs in the window. Shaping is necessary because the width of a string is not generally the sum of independent character widths. Script shaping, ligatures, kerning, font fallback, and emoji can change glyph count, positions, and advances. Measure the **actual shaped text** for a custom label rather than multiplying a character count by an average width. ## Follow a line from text to pixels Use the existing `hello_world` example for a short experiment. Replace `examples/hello_world/src/main.rs` with the code below, then run `cargo run -p hello_world` from the repository root. It uses the normal text element for the visible label and asks the same window text system for that line's shaped advance. The measurement is displayed so you can change the sample and see the result; in an application, keep expensive intrinsic measurements in layout instead of recomputing them merely to print a number in `render`. ```rust use gpui_kit::*; struct TextLab; impl Render for TextLab { fn render(&mut self, window: &mut Window, _: &mut Context) -> impl IntoElement { let label: SharedString = "Office fi café 👋".into(); let font_size = px(18.); let run = TextRun { len: label.len(), // UTF-8 bytes, not character count. font: window.text_style().font(), color: window.text_style().color, ..Default::default() }; let line = window .text_system() .shape_line(label.clone(), font_size, &[run], None); div() .flex() .flex_col() .gap_2() .p_4() .text_size(font_size) .child(label) .child(format!("Shaped advance: {:.1} logical px", line.width().as_f32())) } } fn main() { application().with_assets(assets::Assets).run(|cx| { init(cx); open_window(WindowOptions::default(), cx, |_, cx| cx.new(|_| TextLab)) .expect("Failed to open window"); }); } ``` The window should show the sample on one line and a second line beginning `Shaped advance:`. The number is a **logical pixel width of the shaped first line**, not a frame time or the width of the enclosing `div`; its exact value depends on the resolved font and platform. Change the sample to `"iiii"`, then `"WWWW"`, and rerun: the advances should differ. Change it to `"café 👋"` and observe that `label.len()` counts more bytes than displayed characters. Do not add `\n` to this single-line call; the multi-line API is explained below. ### Paint the shaped line yourself The first exercise measures text in `render` so the result can be printed. To see the low-level drawing path, replace the same `examples/hello_world/src/main.rs` file with this complete program and run `cargo run -p hello_world` again. `canvas` supplies a prepaint callback and a paint callback without requiring a new `Element` implementation: ```rust use gpui_kit::*; struct CanvasTextLab; impl Render for CanvasTextLab { fn render(&mut self, window: &mut Window, _: &mut Context) -> impl IntoElement { let sample: SharedString = "Office fi café 👋".into(); let canvas_text = sample.clone(); let font = window.text_style().font(); let color = window.text_style().color; let font_size = px(18.); let line_height = px(24.); div() .flex() .flex_col() .gap_2() .p_4() .text_size(font_size) .child("Normal text element:") .child(sample) .child("Shaped and painted in a canvas:") .child( canvas( move |_, window, _| { let run = TextRun { len: canvas_text.len(), font, color, ..Default::default() }; window .text_system() .shape_line(canvas_text, font_size, &[run], None) }, move |bounds, line, window, cx| { let advance = line.width(); line.paint(bounds.origin, line_height, TextAlign::Left, None, window, cx) .expect("Failed to paint shaped text"); let marker = Bounds { origin: point(bounds.origin.x + advance, bounds.origin.y), size: size(px(1.), line_height), }; window.paint_quad(fill(marker, color)); }, ) .w(px(320.)) .h(line_height), ) } } fn main() { application().with_assets(assets::Assets).run(|cx| { init(cx); open_window(WindowOptions::default(), cx, |_, cx| cx.new(|_| CanvasTextLab)) .expect("Failed to open window"); }); } ``` The canvas draws the same sample beneath the ordinary text element. Its thin vertical marker sits at the line's **advance**, which is the pen position after shaping, not necessarily the last glyph's visible edge. `canvas` requests the fixed `320 × 24` area during layout. Once bounds are known, its prepaint callback shapes the line and returns a `ShapedLine`; its paint callback uses that same value for both `width()` and `paint(...)`. Change the sample in the code, rerun, and watch the marker move. A very long sample can run past the fixed canvas width; this exercise does not wrap or clip it. If the ordinary line appears but the canvas line does not, check the canvas height, the `TextRun::len` byte count, and the result of `line.paint`. The canvas line has no text-node accessibility identity, selection, hitbox, or keyboard behavior. Use the ordinary text element for interface copy. When text width must determine the custom element's **requested size**, shape in a measured-layout callback instead of this fixed-size canvas; [Element](./element) explains that layout boundary. This is the progression to use in a real view: ordinary label → only if needed, shaped width for intrinsic layout → only if needed, reuse shaped glyphs in a custom element's paint phase. The [Element](./element) tutorial supplies the full lifecycle for an element with its own layout or children. You do not need direct `TextSystem` calls for a label, button, or ordinary paragraph. ## Start with a text element For interface copy, use a normal text child and let GPUI own layout and painting: ```rust use gpui_kit::*; div() .font_family(".SystemUIFont") .text_size(px(14.)) .child(text!("Recent activity")) ``` `text!(...)` creates a `Text` element with an ID based on this macro call's source location. That ID lets GPUI expose a label to the accessibility tree and report content changes under a stable identity. `text!(id = "activity-label", value)` sets an explicit ID when the same call site creates multiple labels; each simultaneously rendered element needs a distinct ID. A plain `.child("Recent activity")` also lays out and paints text, but has no text-node ID. Neither form returns a measured width from `render`: GPUI shapes it during the element's measured-layout callback, places its bounds in prepaint, then paints it. GPUI Kit's [TextView](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/text/compat.rs) renders Markdown or HTML with styled runs, selection, and optional scrolling; it delegates parsing, layout, and selection behavior to Base. Use `TextView::markdown("article", source)` for a rich document. [Input](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/input/base/element.rs) shapes visible lines in [`prepaint`](./element#prepaint), where the resolved width and input geometry are known; its caret and pointer mapping use the same shaped layout. A plain label needs neither implementation. ## Font resolution and metrics `Font` names a family and carries weight, style, features, and optional fallbacks. `font("Family")` constructs one; `Font::default()` requests `.SystemUIFont`. `cx.text_system().resolve_font(&font)` returns a `FontId`, trying GPUI's fallback stack if the requested family cannot load. If no fallback resolves, it panics. `all_font_names()` lists available families, including fonts added by `add_fonts(...)`. Add bundled fonts before the first frame so the first layout uses the intended metrics; adding fonts later invalidates cached font resolution and line layouts, while a layout already underway may finish against the earlier set. GPUI exposes `ascent`, `descent`, `cap_height`, `x_height`, `bounding_box`, `advance`, and `typographic_bounds` for a resolved font and `Pixels` size. These answer different questions. `advance(font_id, size, ch)` returns pen movement for one character's glyph in that font; `typographic_bounds(font_id, size, ch)` describes that glyph's typographic rectangle; ascent and descent establish vertical metrics. Neither API shapes a string or applies its font fallback. `WindowTextSystem::layout_width(font_id, size, ch)` shapes one character; use it for a specific cell or space measurement, not for a sentence. The displayed result depends on installed families and platform font fallback. Desktop GPUI resolves against the operating system's font collection; a requested family may resolve to a different available family. A web build cannot assume desktop system families are exposed and must register the fonts it requires. Test representative Latin, CJK, emoji, and mixed-script strings on target platforms when line breaks or alignment are important. ## Shape one line `TextRun::len` is a **UTF-8 byte length**, and all runs together should cover the text they style, including newline bytes for `shape_text`. Split runs only at valid UTF-8 boundaries. A run selects the font, color, background, underline, and strikethrough for its byte range. The text argument is a [SharedString](./shared-string). This example follows [Plot label](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/plot/label.rs): ```rust use gpui_kit::*; fn shape_label(text: SharedString, color: Hsla, window: &mut Window) -> ShapedLine { let run = TextRun { len: text.len(), font: window.text_style().font(), color, background_color: None, underline: None, strikethrough: None, }; window.text_system().shape_line(text, px(14.), &[run], None) } // Inside an existing render, measured-layout, or paint method with `window`: let width = shape_label("Recent activity".into(), color, window).width(); ``` The helper is a copyable pattern inside an existing GPUI view or element, where `window` and `color` are already available; it is not a standalone `main`. The [canvas exercise above](#paint-the-shaped-line-yourself) gives a complete application that shapes and paints a line. For a production example, [`crates/component/src/plot/label.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/plot/label.rs) uses this shape path in `measure_text_width` and calls `ShapedLine::paint(origin, line_height, align, align_width, window, cx)` in `PlotLabel::paint`. `shape_line(text, font_size, runs, force_width)` returns a `ShapedLine`: its `width()` is the shaped advance, and it carries the original text, positioned glyphs, font IDs, ascent, descent, and decoration runs. Pass `None` for `force_width` unless a custom layout intentionally supplies a width. `shape_line` is for **one** line; do not pass text with `\n`. `layout_line(&str, size, runs, force_width)` returns an `Arc` when geometry is enough, but `shape_line` is the direct choice if it will be painted. An advance is the distance to move the text pen after the line; it is not necessarily the rectangle of colored pixels. A glyph can overhang its advance, and `font_size` alone does not determine a row's height. Use the line's ascent/descent and your chosen line height for vertical placement. If you need a box for clipping or hit testing, derive it from the actual placed line and the interaction area, not by treating `width()` as an ink bounding box. `LineLayout::x_for_index(byte_index)`, `index_for_x(x)`, and `closest_index_for_x(x)` support cursor and hit-test calculations. Indices refer to UTF-8 bytes in the original text, not Unicode scalar values or visual columns. `x_for_index` yields a line-local x position; `index_for_x` returns `None` at or beyond the line width, while `closest_index_for_x` chooses an insertion boundary. To draw a caret, add the painted line origin to its local x coordinate. To handle a pointer, subtract that same origin before querying the layout. Their positions use GPUI's [logical pixel geometry](./geometry). Keep selection boundaries on valid text boundaries; a glyph or ligature need not correspond to exactly one character. Use the same shape, font size, line height, and alignment for measurement, caret placement, and painting so they agree. ## Wrap multiple lines Use `shape_text(text, font_size, runs, wrap_width, line_clamp)` for newlines and optional soft wrapping. It returns `Result>`. `wrap_width: Some(width)` sets the available width. Each `WrappedLine` represents one source line between explicit `\n` characters; it can contain several visual rows. `WrappedLine::size(line_height)` includes those rows, and `position_for_index(index, line_height)` or `closest_index_for_position(local_point, line_height)` maps between local geometry and a byte offset **within that source line**. Add the source line's byte start (and the skipped newline byte) to get an offset in the full text. `line_clamp` limits soft-wrap boundaries, but `shape_text` still returns a `WrappedLine` for every explicit newline-separated source line; it does not guarantee at most N lines in total. A width or font change can alter wrap boundaries, so recompute layout when either changes. For ordinary paragraphs, let a GPUI text element or GPUI Kit `TextView` perform this work. A custom element should only shape text directly when it needs glyph-aware placement, drawing, or hit testing that existing elements cannot supply. ## Match GPUI's rendering phases GPUI's [rendering pipeline](./render) separates layout, prepaint, and paint. Place custom text work in the matching phase: | Phase | Text work | Why | | --- | --- | --- | | `request_layout` | Declare style and layout nodes; use a measured-layout callback if intrinsic text size or wrapping determines the requested size. | The callback receives width constraints; final `Bounds` are not available yet. | | `prepaint` | Use resolved bounds to place prepared lines, calculate cursor geometry, and establish hitboxes; shape here if this custom element only now knows its content width. | Input geometry must match this frame. | | `paint` | Paint the prepared `ShapedLine`s or `WrappedLine`s at their origins. | Reusing the shape prepared during measurement or prepaint keeps pixels and hit tests aligned. | The ordinary GPUI text element shapes in its measured-layout callback, records bounds in prepaint, and paints the prepared `WrappedLine`s. The [Input element](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/input/base/element.rs) instead shapes visible editor lines in prepaint because it uses its resolved viewport and scroll geometry. `ShapedLine::paint` submits one line; `paint_background(...)` is a separate pass for run backgrounds. Handle both `Result`s. `WrappedLine::paint` takes optional `Bounds` as its alignment width, whereas `ShapedLine::paint` takes `Option`. Text painting does not itself establish a hitbox, keyboard focus, or accessibility name; a custom interactive text element must supply those contracts too. See [Paint](./paint) for custom drawing. For a chart label, ask whether its width changes the chart's requested size. If it does, shape it in the measured-layout callback and carry that result forward; prepaint positions it from the resolved bounds, then paint draws that same result. If the chart already has a fixed rectangle and only the label's location depends on it, shape and position in prepaint. In either case, the pointer mapping and paint origin must use the same prepared line. The `render` measurement in the small exercise above is only an observable probe, not a replacement for these element phases. ## Diagnose a text mismatch | Symptom | First check | Next action | | --- | --- | --- | | First layout panics while resolving a font | Inspect `cx.text_system().all_font_names()` after initialization. On Web, check that required font bytes were registered before opening the window. | Use an available family or register the intended font; see [Fonts](./fonts#diagnose-a-font-problem). | | Text appears in another face, or widths differ by machine | Compare the requested family with the available names and the family embedded in a bundled file. A family name does not prove glyph coverage. | Test on each target platform; bundle the needed faces and check fallback for Latin, CJK, and emoji. | | Measured width does not match the painted label | Check the resolved font, features, size, text, and `force_width` on both paths. A parent `.text_size(...)` does not silently change a `shape_line` call's explicit size. | Shape with the same inputs and reuse the result for placement and painting. | | Caret or click lands at the wrong character | Check whether the index is a UTF-8 byte offset and whether the line origin was added or subtracted exactly once. | Use `x_for_index` and `closest_index_for_x` on the same line layout used to paint; keep indices at valid boundaries. | | Last glyph is cut off or the row is too short | Check whether code used `width()` as ink bounds or `font_size` as full line height. | Allow for glyph overhang and use line metrics plus the chosen line height when allocating and clipping. | | A paragraph wraps differently after fonts load | Check whether registration happened after the first layout and whether the window was refreshed. | Register bundled fonts before the first frame, or call `cx.refresh_windows()` after a later `add_fonts` and measure again. | ## Cache and performance boundaries `WindowTextSystem` caches line layouts in current- and previous-frame caches and shares font resolution and metrics through `TextSystem`. The shared system also caches glyph raster bounds; painting uses glyph rendering parameters that include font, size, scale factor, and subpixel position. A cache key includes text, resolved font runs, size, and width constraints. Repeated identical inputs can reuse layout; changing text, font, features, size, forced width, or wrap width may require a new layout. Paint colors and backgrounds live in decoration runs, separate from glyph positions; changing only those need not reshape glyphs. These frame caches are not a promise that a layout remains cached indefinitely. For a known set of fonts, `TextSystem::prewarm_fonts(&fonts)` can prepare platform font caches on a background executor; ordinary shaping fills missing entries on demand. Reuse the window's text system and do not retain frame-local `&mut Window` references. For large virtualized editors or documents, shape only visible content and avoid measuring every row on every redraw. GPUI Kit's [TextView](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/text/text_view.rs) can virtualize scrollable blocks, while [Input](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/input/base/element.rs) shapes its visible lines. If a profiler shows repeated materialization of long line text, `try_layout_line_by_hash` probes the cache without building a contiguous string, and `shape_line_by_hash` builds one only on a miss. With the latter API, `ShapedLine.text` is an empty placeholder even on a miss; keep the original text separately if needed. The caller must guarantee that the same hash implies identical text, and pass its UTF-8 byte length as `text_len`. Prefer the ordinary API until that cost is measured. --- # Context Source: /versions/v0.7.1/docs/context License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/context and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. GPUI callbacks often receive `window: &mut Window, cx: &mut Context`. GPUI supplies these parameters for the duration of the call, giving code access to the current window, current Entity, and whole application while keeping mutable access within that call. Start by separating the scopes: | Type | Scope | Common capabilities | | --- | --- | --- | | `Window` | Current system window | Focus, input, window bounds, drawing, Action dispatch | | `Context` | The `Entity` currently being updated | The Entity for `self`, `notify`, subscriptions, Entity tasks | | `App` | The whole application | Globals, creating Entities, opening windows, application Actions and tasks | | `AsyncApp` | A handle for foreground work across `await` | Re-enter `App` or an Entity in a short update | | `AsyncWindowContext` | An async handle for one window | Re-enter an Entity together with its Window | Read the table as a progression: `App` is available first in `application().run`; opening a window supplies `Window`; creating an Entity supplies its `Context` whenever GPUI builds or updates that Entity. A foreground task receives an async handle instead of borrowing either synchronous context across an `await`. | Where code runs | Parameters GPUI supplies | Use it for | | --- | --- | --- | | `application().run`, application callbacks | `&mut App` | Initialize the kit, set Globals, create Entities, open windows | | Window builder, element or component callback | `&mut Window`, `&mut App` | Work with that window and application state; use `cx.listener` when the callback needs its owning View | | `Render` or an Entity update | `&mut Self`, `&mut Context`; `Render` and `update_in` also receive `&mut Window` | Read or change the current View; call `notify` when its rendered state changes | | `App::spawn` / `Context::spawn` task | `&mut AsyncApp`, plus a `WeakEntity` for Entity tasks | Resume short application or Entity operations after `await` | | `Context::spawn_in` task | `&mut AsyncWindowContext` and `WeakEntity` | Resume an operation that also needs the original Window | `Context` dereferences to `App`, so code with `cx: &mut Context` can already call App APIs and does not need a separate `&mut App`. It also knows which Entity is current; plain `App` does not. Window remains separate because the same Entity may appear in different windows, while a data-only update may not belong to any window. Window also owns per-window state keyed by [ElementId](./element_id). Async contexts are handles, not long-lived `&mut App` or `&mut Window` borrows. The [GPUI `Context` source](https://docs.rs/crate/gpui-pre/0.3.8/source/src/app/context.rs) defines the entity-specific methods used below. GPUI Kit applications depend on `gpui-kit` and import GPUI through `use gpui_kit::*;`. Call `gpui_kit::init(cx)` before creating component-backed Views. An application-wide [Global](./global) lives on `App`; a component or feature View keeps retained state in an [Entity]. ## Open a URL in the default browser Use `cx.open_url(...)` to hand a URL to the platform's default browser. It is an `App` API, so it also works when `cx` is a `Context` or the `&mut App` supplied to a button callback: ```rust use gpui_kit::component::button::Button; Button::new("open-docs") .label("Open docs") .on_click(|_, _, cx| cx.open_url("https://gpui-kit.com/docs")) ``` This opens an external browser; use [WebView](./webview) when browser content must live inside a GPUI window. `open_url` returns no completion result. GPUI Kit's `Link` with an `href` also calls `cx.open_url(...)` when clicked. ## `window, cx` or only `cx` View state belongs to its Entity, while window interaction belongs to Window. A method receives both when it changes View state and operates on the window displaying that View. GPUI style places runtime parameters last, in `window, cx` order: ```rust fn focus_input(&mut self, window: &mut Window, cx: &mut Context) { self.composer_open = true; self.input_focus.focus(window, cx); cx.notify(); } ``` An Action, Event, or pointer callback may put `action`, `event`, or similar arguments first, while keeping runtime parameters last: ```rust fn on_action_send_message( &mut self, action: &SendMessage, window: &mut Window, cx: &mut Context, ) { // ... } ``` If a method only changes data and does not read Focus, input, window bounds, or other window state, keep only the final `cx` argument: ```rust fn clear_messages(&mut self, cx: &mut Context) { self.messages.clear(); cx.notify(); } ``` When there is no current Entity and the work is application-wide, a callback receives `&mut App` directly. Application initialization, registering global state, and opening the first window are common examples. Do not add an unused Window for signature consistency; parameters should expose the scope the logic actually needs. Async code uses the corresponding `AsyncApp` or `AsyncWindowContext` to re-enter GPUI after an `await`. See [Window](./window) for Window-specific capabilities. ## A callback that needs its View A GPUI Kit `Button` click handler receives `(&ClickEvent, &mut Window, &mut App)`. It does not receive the View as `&mut Self`. Build the callback with `cx.listener` while [rendering](./render) a View; GPUI will update that View and pass its `Context` to the inner closure. The component also supplies keyboard and [accessibility](./accessibility) behavior: To try the complete example, replace `examples/hello_world/src/main.rs` in this repository's existing `hello_world` package with the following code. From the repository root, run `cargo run -p hello_world --bin hello_world`. ```rust use gpui_kit::*; use gpui_kit::assets::Assets; use gpui_kit::component::button::Button; struct Counter { count: usize, } impl Render for Counter { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { div().child( Button::new("increment") .label(format!("Count: {}", self.count)) .on_click(cx.listener(|this, _event, _window, cx| { this.count += 1; cx.notify(); })), ) } } fn main() { application() .with_assets(Assets) .run(|cx| { init(cx); open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| Counter { count: 0 }) }) .expect("failed to open window"); }); } ``` The window first shows `Count: 0`. Click the button once and its label should become `Count: 1`; each further click increments it by one. This checks that the listener mutates the existing `Counter` Entity and that `cx.notify()` makes the new value visible. Restore your original `main.rs` after the exercise if you want to keep the package's previous example. The outer callback type belongs to the button. The inner closure receives `&mut Counter` and `&mut Context`. `cx.listener` uses a weak handle to the View, so a stored handler does not keep a closed View alive. The count is Entity state, and `cx.notify()` tells GPUI to render its new value. Do not create the View again inside `render`; the owning `Entity` is created when the window opens. ## Create, read, and update `cx.new` creates an [Entity]. Its closure receives the new Entity's own `Context`. A strong `Entity` handle keeps it alive. Use the handle with `read` for a synchronous borrowed view of its data, or `update` to receive `&mut T` and that Entity's own context: ```rs struct Draft { text: String, } fn edit_draft(cx: &mut App) -> Entity { let draft = cx.new(|_| Draft { text: String::new() }); let was_empty = draft.read(cx).text.is_empty(); if was_empty { draft.update(cx, |draft, cx| { draft.text.push_str("Hello"); cx.notify(); }); } draft } ``` The `read` reference cannot outlive the `App` borrow. Complete the read before requesting mutable access with `update`; copy or clone the small amount of data you need later. A strong handle's `update` returns the closure's value directly. A `WeakEntity` can disappear, so its `update` and `update_in` return `Result` instead. Always use the inner `cx` passed to the update closure when notifying or using Entity-specific methods. `cx.new` can be called through `App` or `Context` because both implement GPUI's `AppContext` API. From a `Context`, the construction closure receives a **new** `Context`. It is not the parent's context. Keep the returned strong `Entity` in its owner when the child should survive across renders. `cx.notify()` reports that the current Entity changed. It schedules dependent views and `observe` callbacks; changing a field alone does not. `notify` is unnecessary for a read. Avoid calling it unconditionally from `render`, which can schedule repeated renders. When one action changes several related fields, finish the change and notify once. Do not retain a reference from `read` across an `await`; clone the small piece of data the task needs first. Do not store `&mut App`, `&mut Window`, or `&mut Context` in a View or task. Those are short-lived access granted for the current GPUI call. Do not read or update an Entity again while it is already inside its `render` or `update`. GPUI prevents re-entrant access and will panic. Use the `self` and inner `cx` already provided. The same applies inside `cx.listener`: its `this` argument is already the View. If code must update that Entity *after* the current callback, defer the work instead of trying to access it through its handle immediately. Use `cx.entity()` when another object needs a strong handle to the current Entity. Prefer `cx.weak_entity()` or `downgrade()` in long-lived callbacks that should not keep a View alive. ## Defer until the current update ends `App::defer` runs an app-level closure after the current effect cycle. `window.defer(cx, ...)` supplies that Window later. From an Entity, `cx.defer_in(window, ...)` supplies the same View, its Window, and `Context` after the current update has released its borrow: ```rust fn finish_edit(&mut self, window: &mut Window, cx: &mut Context) { cx.defer_in(window, |this, window, cx| { this.finish_edit_after_update(window, cx); }); } ``` The deferred closure still receives `this: &mut Self`; do not call `update` on that same Entity from inside it. Deferral is for work that needs the current update to finish, such as focus restoration after changing the UI tree. [`window.on_next_frame(...)`](https://docs.rs/crate/gpui-pre/0.3.8/source/src/window.rs) instead queues a callback for the next platform frame request and wakes the frame source. The callback runs before any drawing for that request; registering it does not mark the window dirty or cause a render. If its work changes visible Entity state, call `cx.notify()` from the callback. Use `window.request_animation_frame()` when the intent is to request a redraw on the next frame. A closed window or released View may prevent deferred work from running, so do not use it as a durable job queue. ## Async work `cx.spawn` starts a foreground task. From `Context`, it supplies the current Entity as a `WeakEntity` and an `AsyncApp`: ```rs self._load_task = cx.spawn(async move |this, cx| { let messages = fetch_messages().await?; this.update(cx, |chat, cx| { chat.messages = messages; cx.notify(); })?; anyhow::Ok(()) }); ``` The weak handle does not keep the View alive. Its `update` returns an error if the View was released, so handle or propagate that result. When `spawn` starts from `App`, there is no current Entity handle. The closure receives only `AsyncApp`; use `cx.update(|cx| ...)` to run a short application-level mutation after an `await`. `AsyncApp` also offers closure-based access to globals, such as `read_global` and `update_global`. It does not give an async task a `&mut App` to hold across awaits. Use `spawn_in` when completion also needs the same Window. Its `AsyncWindowContext` lets `update_in` restore both Window and Entity access: ```rs cx.spawn_in(window, async move |this, cx| { let message = send_to_server().await?; this.update_in(cx, |chat, window, cx| { chat.messages.push(message); chat.input_focus.focus(window, cx); cx.notify(); })?; anyhow::Ok(()) }) .detach(); ``` Use this `spawn_in` → `update_in` pattern for async work that updates a View and its Window. Use `background_spawn` for CPU-heavy work; it cannot update GPUI state directly, so bring its result back to a foreground task first. The `WeakEntity` may have been released, and the Window may have closed while the task waited. `update` and `update_in` therefore return a `Result`; handle it. If a request can finish after a newer request, compare an ID or revision before applying its result. ## Task lifetime A GPUI [Task](./task) is cancelled when its handle is dropped: ```rs struct Chat { _load_task: Task>, } ``` - Store View-owned work on the View, so releasing the View cancels it. - Call `.detach()` only when work should continue independently. - Replacing a stored refresh or debounce task cancels the previous one. ## Observe and subscribe `observe` reacts when another Entity calls `cx.notify()`. `subscribe` reacts to a typed [Event]: ```rs struct Chat { input: Entity, _subscriptions: Vec, } // In Chat::new: let _subscriptions = vec![ cx.observe(&input, |_, _, cx| cx.notify()), cx.subscribe(&input, |chat, input, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { chat.draft = input.read(cx).value().to_string(); cx.notify(); } }), ]; ``` Both return a `Subscription`. Store it on the subscribing View so it remains active for exactly that View's lifetime. Dropping it cancels the callback immediately. Keeping it in a longer-lived owner may retain its callback and captured resources after the View disappears, causing a memory leak. Use `observe_in` or `subscribe_in` when the callback also needs `&mut Window`. To publish an application event, implement `EventEmitter` on the emitting Entity type, then call `cx.emit(event)` from its `Context`: ```rust struct SaveRequested; struct Draft; impl EventEmitter for Draft {} impl Draft { fn request_save(&mut self, cx: &mut Context) { cx.emit(SaveRequested); } } ``` A parent can use `cx.subscribe(&draft, ...)` to handle `SaveRequested`; the event type is part of that subscription, so an Entity can emit more than one type. An observer is tied to `cx.notify()`, while a subscription is tied to `cx.emit(...)`. Emitting an event does **not** notify observers or redraw the emitter: call `cx.notify()` as well if its rendered state changed. Conversely, `notify` does not emit an event. For an application-wide value, use `observe_global` and keep its `Subscription`; see [Global]. ## Common mistakes - A task stops early because its `Task` was dropped. Store it or intentionally detach it. - An observer never runs because its `Subscription` was only a local variable. - A View stays alive because a task or callback captured a strong Entity handle. Capture a weak handle. - GPUI reports an Entity is already borrowed because code re-entered the same Entity during `render` or `update`. - A callback receives plain `App` but needs its View; create it with `cx.listener` instead of trying to find and re-enter the View manually. - A later operation needs the current Entity after a UI-tree change; use `cx.defer_in` rather than updating the borrowed Entity immediately. - Async code cannot access Window because it used `spawn`; use `spawn_in` and `update_in`. - A borrow is held across `await`; extract owned data first, then reacquire access with `update` or `update_in`. - The UI stays stale because state changed without `cx.notify()`. GPUI convention names every context parameter `cx`, regardless of its concrete type, and names the Window parameter `window`. [Entity]: ./entity.md [Event]: ./event.md [Global]: ./global.md --- # Focus Source: /versions/v0.7.1/docs/focus License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/focus and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. **Focus** identifies the target for keyboard input in one [Window](./window). GPUI uses the focused element's path through the rendered tree to route [Actions and key bindings](./action). A pointer press may move Focus, but drawing a control or giving it an `ElementId` does not. This guide uses the GPUI API published as `gpui-pre` 0.3.8 through `gpui-kit`; `gpui-pre` is the snapshot publishing and version alignment name, not a different rendering engine. ## Try the existing example From the repository root, run: ```sh cargo run -p focus_trap ``` The source is [`examples/focus_trap/src/main.rs`](https://github.com/longbridge/gpui-kit/blob/main/examples/focus_trap/src/main.rs). Click an outside button and press Tab: Focus follows the window's ordinary Tab order. Click a button in either numbered area and press Tab or Shift+Tab: Focus cycles among that area's buttons. The example calls `.focus_trap(id, &handle)` on each container; the buttons are real GPUI Kit `Button`s with their own focus handles. It demonstrates containment after entering an area, not a complete modal lifecycle. For a new application, initialize Kit before opening a window with `gpui_kit::init(cx)`, as the example does. `gpui_kit::open_window` supplies the Base `Root` that handles normal Tab and Shift+Tab navigation and participates in Kit's trap handling. The [Getting Started](./getting-started) guide covers the application setup. ## Four separate operations | Operation | What it does | What it does not do | | --- | --- | --- | | `cx.focus_handle()` | Creates a stable target that an owner can retain. | It does not register a rendered element or move Focus. Its default `tab_stop` is `false`. | | `.track_focus(&handle)` | Registers that handle on a rendered interactive element. The element can then participate in Focus dispatch and receive its focused style. | It does not move Focus or automatically opt the handle into Tab order. | | `handle.focus(window, cx)` or `window.focus(&handle, cx)` | Makes the handle the window's current Focus target. | It does not create a rendered node or a keyboard handler. | | `cx.focus_handle().tab_stop(true)` | Includes a tracked handle in Tab navigation. | It does not focus the handle immediately. | The handle belongs to the long lived owner, usually an [`Entity`](./entity). The `Element` is rebuilt for each frame; attach the *same* handle again from [`Render::render`](./render). If you create a fresh handle on every render, Focus identity and Tab behavior can change underneath the user. For a stateless Kit component, retain a handle through `window.use_keyed_state(...)`; Kit's `Button` uses that pattern. An `ElementId` can key retained state, but it is not itself a Focus target. ### A minimal focusable view The following view can be placed in the window builder shown in [Getting Started](./getting-started). It creates one Tab stop and displays whether it has exact Focus: ```rust use gpui_kit::*; struct FocusPanel { focus_handle: FocusHandle, } impl FocusPanel { fn new(cx: &mut Context) -> Self { Self { focus_handle: cx.focus_handle().tab_stop(true), } } } impl Focusable for FocusPanel { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } impl Render for FocusPanel { fn render(&mut self, window: &mut Window, _: &mut Context) -> impl IntoElement { let label = if self.focus_handle.is_focused(window) { "Focused" } else { "Press Tab or click here" }; div() .track_focus(&self.focus_handle) .p_4() .child(label) } } ``` Create it with `cx.new(FocusPanel::new)` in a window builder. `Focusable` exposes the handle to code that owns the Entity; it does not automatically call `.track_focus(...)`. A view can implement `Focusable` and still fail keyboard dispatch if its rendered tree omits the tracked element. To move Focus from a callback, call `self.focus_handle.focus(window, cx)`. Do that when opening or entering a region, not unconditionally in `render`: repeated requests from rendering can steal Focus from a child or another control. ## Pointer, Tab, and keyboard commands For an element with `.track_focus(&handle)`, GPUI's default mouse down behavior focuses that handle when the pointer hits it. A nested tracked child gets the first opportunity to focus and suppresses an ancestor's default transfer; a custom mouse handler can use `window.prevent_default()` when it deliberately handles Focus itself. A pointer callback that only changes application state is not a keyboard interaction. Tab and Shift+Tab visit *tab stops* registered in the most recently rendered frame. The default `FocusHandle` is not one. Set `.tab_stop(true)` when constructing a handle that users should reach by Tab. GPUI also has `FocusHandle::tab_index(n)` for explicit ordering, but prefer the rendered order for ordinary forms. When passing a handle to `.track_focus(...)`, set Tab configuration on the **handle**; an element's separate `.tab_index(...)` or `.tab_stop(...)` does not change that handle's settings. Once Focus reaches a tracked element, its dispatch path determines which `key_context`, `on_action`, and key bindings apply. For example, an editor's Save binding works when the editor or a descendant owns Focus and the matching handler lies on that path. A sibling's handler is not on the path. See [Action](./action) and [KeyBinding](./keybinding) for a complete command example. A custom control must also provide a visible Focus treatment and the expected keyboard activation or navigation behavior; `track_focus` only supplies the routing target. See [Accessibility](./accessibility) for semantics and testing limits. ## Exact Focus versus Focus within a region Use `handle.is_focused(window)` when only that handle should count as active. Use `handle.contains_focused(window, cx)` when the handle or a tracked descendant should count—for example, keeping a panel visually active while a text field inside it has Focus. Containment comes from the **most recently rendered dispatch tree**; both handles must be attached to elements in the expected parent and child relationship. A stored Entity relationship alone does not establish it. ```rust let panel_itself = self.focus_handle.is_focused(window); let panel_or_child = self.focus_handle.contains_focused(window, cx); ``` `window.focused(cx)` returns an `Option` for the current window. It is useful for diagnostics and for saving the previous target before opening an overlay. The returned handle may later outlive its rendered element, so validate the UI lifecycle before restoring it. ## Observe transitions without putting subscriptions in `render` `Context` provides `on_focus`, `on_blur`, `on_focus_in`, and `on_focus_out`. Register them while constructing the owner, and retain the returned [`Subscription`](./event) on that owner. Dropping a subscription unregisters it. The exact pair fires when the named handle gains or loses Focus; the `in`/`out` pair considers tracked descendants as well. ```rust struct SearchPanel { focus_handle: FocusHandle, _subscriptions: Vec, active: bool, } impl SearchPanel { fn new(window: &mut Window, cx: &mut Context) -> Self { let focus_handle = cx.focus_handle(); let entered = cx.on_focus_in(&focus_handle, window, |this, _, cx| { this.active = true; cx.notify(); }); let left = cx.on_focus_out(&focus_handle, window, |this, _, _, cx| { this.active = false; cx.notify(); }); Self { focus_handle, _subscriptions: vec![entered, left], active: false, } } } ``` Attach `focus_handle` to the panel's rendered root with `.track_focus(&self.focus_handle)`. This pattern is appropriate when another state change must follow Focus; a simple Focus style can often read `is_focused` during rendering instead. Registering listeners during every render would accumulate duplicate callbacks. ## Trap and restore Focus in an overlay GPUI Kit adds `.focus_trap(id, &container_handle)` to an interactive container. The Base `Root` checks whether the current Focus is inside that container when it handles Tab or Shift+Tab. If normal navigation would leave, it searches for another Tab stop inside the container. The trap's `id` must be stable, its handle must survive rendering, and the container must contain usable child Tab stops. ```rust div() .child(first_button) .child(second_button) .focus_trap("settings-dialog", &self.dialog_focus_handle) ``` **A trap alone does not open a modal, enter its first control, block pointer focus outside it, or restore Focus after dismissal.** For a modal flow: 1. Save `window.focused(cx)` when the user opens the overlay. Keep the saved handle with the overlay owner. 2. Render the overlay and move Focus to an enabled control inside it when that control exists. Choose a predictable first target; do not rely on the trap to perform this step. 3. On dismissal, remove the overlay and restore the saved target only if it is still an appropriate rendered target. If it has disappeared, choose a current fallback such as the control that opens the overlay. Coordinate the update and restoration so a closing overlay does not immediately take Focus back. 4. Test both Tab directions, Escape or explicit close, pointer clicks, disabled controls, nested overlays, and the case where the previous target disappears. GPUI Kit's `Dialog` and `Sheet` components provide their own modal focus behavior; use them for ordinary modal UI. The manual trap is useful for a custom surface whose entry, dismissal, and restoration lifecycle you explicitly own. An `on_focus_out` listener on a container can help observe Focus leaving; it is not a substitute for the modal lifecycle. ## Verify and debug Start with the `focus_trap` example to see pointer entry and both Tab directions in a running window. In an application test, render the real view, click its intended control, send Tab or the command key, and assert the resulting owner state. The [Testing](./test) guide covers Kit's UI integration test helpers; focus scopes need an explicit tracked handle for reliable inspection. | Symptom | Check | | --- | --- | | Tab skips the custom view | The retained handle has `.tab_stop(true)` and its element calls `.track_focus(&handle)` in the current frame. | | Click focuses a parent instead of a child | The child has its own tracked handle; inspect mouse handlers that call `prevent_default`. | | A shortcut works only after clicking | The intended handle is focused, the `key_context` matches, and the handler lies on the focused dispatch path. | | Panel appears inactive when a child is focused | Use `contains_focused`, and verify the child's tracked element is nested under the panel's tracked element. | | Tab escapes a custom trap | Focus entered the trap first; the container is rendered through Kit's Base `Root`; children are actual Tab stops. | | Focus disappears after closing an overlay | Save the previous target and restore an existing rendered target after dismissal. | Related guides: [Window](./window), [Action](./action), [KeyBinding](./keybinding), [Accessibility](./accessibility), and [Testing](./test). --- # Style Source: /versions/v0.7.1/docs/style License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/style and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. GPUI styles an [Element](./element) where it is built. The `Styled` trait supplies chainable methods for layout, spacing, color, borders, and text. Many names deliberately correspond to Tailwind CSS utilities: `flex items-center gap-2 px-3` becomes `.flex().items_center().gap_2().px_3()` in Rust. This is a useful way to read and write GPUI layouts, but the values are typed Rust values rather than CSS classes. ```rust use gpui_kit::*; use gpui_kit::component::ActiveTheme as _; div() .flex() .items_center() .gap_2() .px_3() .py_2() .bg(cx.theme().background) .text_color(cx.theme().foreground) .child("Search results") ``` The builder consumes and returns an element on each call. Rendering can build a fresh tree from current state; persistent application state belongs in an [`Entity`](./entity) or keyed element state. A style chain describes this frame's presentation, not a stylesheet or a retained component instance. See [RenderOnce](./render-once) for frame-local component construction. ## Build a first layout Start with the region that owns the available space, then decide which child has a fixed width and which child can grow. Replace `examples/hello_world/src/main.rs` with this complete program, then run `cargo run -p hello_world` from the repository root: ```rust use gpui_kit::*; use gpui_kit::component::{h_flex, v_flex, ActiveTheme as _}; use gpui_kit::prelude::FluentBuilder as _; struct StyleExample; impl Render for StyleExample { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let compact = window.viewport_size().width < px(600.); let layout = if compact { v_flex() } else { h_flex() }; let documents = (1..=60).map(|number| { div().p_2().child(format!("Document {number:02}")) }); layout .items_stretch() .size_full() .bg(cx.theme().background) .text_color(cx.theme().foreground) .child( v_flex() .when(!compact, |nav| nav.w_64().flex_shrink_0()) .p_3() .gap_2() .child("Navigation") .child("Overview · Documents"), ) .child( v_flex() .flex_1() .min_w_0() .min_h_0() .child(h_flex().p_3().child("Documents")) .child( v_flex() .id("document-list") .flex_1() .min_h_0() .overflow_y_scroll() .child(v_flex().p_3().gap_2().children(documents)), ), ) } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| StyleExample) }) .expect("failed to open window"); }); } ``` In a wide window, navigation occupies a fixed `w_64()` rail and the document pane fills the rest. Narrow the content area below 600 logical pixels: the same navigation moves above the document pane, while the document list remains independently scrollable. Scroll to `Document 60`, then resize the window in both directions. The header stays in place while the list scrolls. This threshold is an explicit Rust condition evaluated when the view renders, not a Tailwind responsive prefix. The navigation in this small exercise is illustrative text; a real application should use reachable navigation controls. `h_flex()` makes a row and centers its children on the cross axis. `.items_stretch()` overrides that default so both panes occupy the row's height. `v_flex()` makes a column whose children stretch across its width. The fixed navigation pane does not shrink in the wide layout; the document pane takes the remaining width. The scroll area takes the remaining height below the header. The window-sized root gives `.size_full()` a resolved height; the `min_h_0()` calls let its flexible descendants shrink into a scroll viewport. ### Decide where each size belongs | Need | Put it on | Why | | --- | --- | --- | | Space between siblings | The parent with `.gap_3()` | Gap separates children without adding padding at the outer edge. | | Space inside a surface | The surface with `.p_3()` | Padding moves its content inward and participates in its layout size. | | A fixed rail beside flexible content | Rail `.w_64().flex_shrink_0()`; content `.flex_1().min_w_0()` | The rail keeps its width while the content may shrink below its natural text width. | | A header above scrolling content | Column with a height; scroll child `.flex_1().min_h_0()` | The child can shrink into the available height, creating a real scroll viewport. | | Half the parent width | `.w(relative(0.5))` on the child | The fraction resolves against the relevant parent dimension during layout. | `w_full()` and `h_full()` mean the full *available* dimension. A percentage height still needs a definite height upstream. Min and max sizes constrain the result; they do not give an otherwise unbounded scroll area a viewport. For a long single-line label in a row, combine `.flex_1().min_w_0().truncate()` on the label container. `.truncate()` only changes text overflow; it cannot force an inflexible sibling to give up width. ### Choose clipping, scrolling, or positioning `.overflow_hidden()` clips content; it does not make that content scrollable. On a stateful element, `.overflow_y_scroll()` creates vertical scrolling once the element has a bounded height. GPUI Kit's `.overflow_y_scrollbar()` adds a visible scrollbar and wraps the original element as its scroll area; it is an extension from `ScrollableElement`, not a `Styled` method. Keep one owner for each scroll region, and put content padding inside that region if its scrollbar should sit at the pane edge. See [Coding Guides](./coding-guides) for scroll ownership and measurement. Normal flex children consume layout space. Use `.relative()` on a container and `.absolute().top_0().right_0()` on a badge when the badge should overlay content without consuming a row or column slot. Offset setters position an absolute child; they do not make an ordinary flex child absolute. Later siblings normally paint over earlier siblings; general `Styled` has no `z_index(...)` method. ### Theme and scale Use semantic colors and radius from `cx.theme()` for application surfaces. GPUI Kit components already apply their normal theme appearance; style their instances for local layout or an intentional refinement. The named spacing and size helpers are rem based: `_1` is `0.25rem`, `_2` is `0.5rem`, `_3` is `0.75rem`, and `_4` is `1rem`. In a GPUI Kit `Root`, the active theme's base font size sets the window rem size, so a font size or zoom change also changes rem based geometry. Use typed setters such as `.gap(rems(0.625))` when the scale has no suitable step; reserve `px(...)` for a dimension that truly needs pixels. See [Geometry](./geometry) for length types and [Fonts](./fonts) for the theme's rem setup. ### Troubleshoot the result | Symptom | Check | | --- | --- | | The rail does not move above the documents when the window narrows | Resize the drawable content area below 600 logical pixels. The condition reads `window.viewport_size().width` during `render`; changing only display scale does not cross this logical-pixel threshold. | | `Document 60` cannot be reached | Place the pointer over the document list and scroll there. Keep `.id("document-list").overflow_y_scroll()` on the bounded list region, with `.flex_1().min_h_0()` on it and its containing column. | | The header moves when scrolling | Ensure the header is a sibling of the list viewport, not a child inside the scrolling element. | | A pane header disappears at the top | `h_flex()` centers children by default; stretch the row's children or give that pane full height. | | A title overflows instead of truncating | Release the flexible child's minimum width with `.min_w_0()` and bound the text width. | | A list grows past the window instead of scrolling | Give its ancestors a resolved height, let the flexible child shrink with `.min_h_0()`, and put scrolling on the intended viewport. | | A scrollbar sits inside the pane edge | Check which element owns scrolling and whether padding wraps the scroll owner. | | Layout changes after theme zoom | Recheck rem based dimensions and any cached measurements that assumed the old rem size. | ## Common `Styled` methods GPUI uses underscores where Tailwind uses hyphens. Where a matching concept exists, the first column links to its official Tailwind CSS reference in a new tab. The names are GPUI methods; call them on a `Styled` value, such as `div().gap_2()`. These tables cover the distinct `Styled` operations and the generic setters generated by its macros. Numeric variants follow the families described below, rather than occupying thousands of near-identical rows. Linked pages explain the corresponding styling concept; they do not imply identical behavior in GPUI and a browser. ### Display and visibility | Method | Description | | --- | --- | | block | Use block layout. | | flex | Use Flexbox layout. | | grid | Use Grid layout. | | hidden | Remove the element from layout and painting. | | invisible | Keep its layout space but do not paint it. | | visible | Restore painting while retaining the element's layout. | ### Flexbox and Grid | Method | Description | | --- | --- | | flex_row | Place flex children along a row. | | flex_col | Place flex children along a column. | | flex_wrap | Allow flex children to wrap. | | flex_nowrap | Keep flex children on one line. | | items_start | Align children to the start of the cross axis. | | items_center | Center children on the cross axis. | | items_stretch | Stretch children along the cross axis. | | self_center | Center this child on its parent's cross axis. | | self_stretch | Stretch this child on its parent's cross axis. | | justify_center | Center children on the main axis. | | justify_between | Put free space between children on the main axis. | | content_between | Distribute wrapped lines along the cross axis. | | flex_basis | Set a typed initial main axis size. | | flex_1 | Grow and shrink with a zero flex basis. | | flex_auto | Grow and shrink from the item's automatic basis. | | flex_grow_1 | Allow a flex child to grow. | | flex_shrink_0 | Prevent a flex child from shrinking. | | grid_cols | Set a numbered column template. | | grid_rows | Set a numbered row template. | | col_span | Span a specified number of grid columns. | | row_span | Span a specified number of grid rows. | | flex_row_reverse | Reverse row order. | | flex_col_reverse | Reverse column order. | | flex_wrap_reverse | Wrap flex lines in reverse order. | | items_end | Align children to the cross-axis end. | | items_baseline | Align children's text baselines. | | self_start | Align this item to the cross-axis start. | | self_end | Align this item to the cross-axis end. | | self_flex_start | Align this item to flex start. | | self_flex_end | Align this item to flex end. | | self_baseline | Align this item's text baseline. | | justify_start | Pack children at the main-axis start. | | justify_end | Pack children at the main-axis end. | | justify_around | Distribute space around children. | | justify_evenly | Distribute equal spaces along the main axis. | | content_normal | Use the default cross-axis line packing. | | content_start | Pack wrapped lines at cross-axis start. | | content_center | Center wrapped lines on the cross axis. | | content_end | Pack wrapped lines at cross-axis end. | | content_around | Distribute space around wrapped lines. | | content_evenly | Distribute equal space between wrapped lines. | | content_stretch | Stretch wrapped lines along the cross axis. | | flex_initial | Use an automatic basis and shrink without growing. | | flex_none | Prevent both flex growth and shrinkage. | | flex_grow | Set a numeric flex growth factor. | | flex_grow_0 | Prevent flex growth. | | flex_shrink | Set a numeric flex shrink factor. | | flex_shrink_1 | Allow flex shrinkage. | | grid_cols_min_content | Create columns with min-content minimums. | | grid_cols_max_content | Create columns with max-content limits. | | grid_rows_min_content | Create rows with min-content minimums. | | grid_rows_max_content | Create rows with max-content limits. | | col_start | Set the starting grid column line. | | col_start_auto | Use automatic column start placement. | | col_end | Set the ending grid column line. | | col_end_auto | Use automatic column end placement. | | col_span_full | Span the full grid column range. | | row_start | Set the starting grid row line. | | row_start_auto | Use automatic row start placement. | | row_end | Set the ending grid row line. | | row_end_auto | Use automatic row end placement. | | row_span_full | Span the full grid row range. | ### Space and size | Method | Description | | --- | --- | | gap_2 | Set row and column gaps to `0.5rem`. | | gap | Set a typed gap on both axes. | | gap_x_2 | Set the column gap to `0.5rem`. | | gap_y_2 | Set the row gap to `0.5rem`. | | p_4 | Set padding on all sides to `1rem`. | | p | Set typed padding on all sides. | | px_3 | Set horizontal padding to `0.75rem`. | | py_2 | Set vertical padding to `0.5rem`. | | mt_4 | Set top margin to `1rem`. | | m | Set typed margins on all sides. | | m_auto | Set automatic margins on all sides. | | w | Set a typed width. | | w_full | Fill the available width. | | h | Set a typed height. | | h_full | Fill the available height. | | min_w | Set a typed minimum width. | | min_w_0 | Permit width to shrink to zero. | | max_w | Set a typed maximum width. | | size | Set typed width and height together. | | size_4 | Set width and height to `1rem`. | | aspect_square | Keep a 1:1 width to height ratio. | | mt | Set a typed top margin. | | mb | Set a typed bottom margin. | | mx | Set typed horizontal margins. | | my | Set typed vertical margins. | | ml | Set a typed left margin. | | mr | Set a typed right margin. | | px | Set typed horizontal padding. | | py | Set typed vertical padding. | | pt | Set a typed top padding. | | pb | Set a typed bottom padding. | | pl | Set a typed left padding. | | pr | Set a typed right padding. | | gap_x | Set a typed column gap. | | gap_y | Set a typed row gap. | | min_h | Set a typed minimum height. | | max_h | Set a typed maximum height. | | aspect_ratio | Set a numeric width to height ratio. | ### Position and overflow | Method | Description | | --- | --- | | relative | Keep normal layout placement and establish a positioned ancestor. | | absolute | Position relative to an ancestor using inset offsets. | | inset | Set a typed offset on all four sides. | | inset_0 | Set top, right, bottom, and left offsets to zero. | | top | Set a typed top offset. | | top_0 | Set the top offset to zero. | | overflow_hidden | Clip overflowing content on both axes. | | overflow_x_hidden | Clip horizontal overflow only. | | overflow_y_hidden | Clip vertical overflow only. | | bottom | Set a typed bottom offset. | | left | Set a typed left offset. | | right | Set a typed right offset. | | scrollbar_width | Reserve a typed scrollbar width for scrolling layout. | ### Color and borders | Method | Description | | --- | --- | | bg | Set a typed background fill. | | text_color | Set a typed foreground color. | | border_1 | Set a one pixel border on all sides. | | border_t_1 | Set a one pixel top border. | | border_color | Set a typed border color. | | border_dashed | Draw borders with a dashed style. | | rounded | Set a typed corner radius. | | rounded_lg | Use the named large corner radius. | | rounded_full | Round corners as far as the size permits. | | border | Set a typed border width on all sides. | | border_t | Set a typed top border width. | | border_b | Set a typed bottom border width. | | border_l | Set a typed left border width. | | border_r | Set a typed right border width. | | border_x | Set typed left and right border widths. | | border_y | Set typed top and bottom border widths. | | rounded_t | Set typed radii on the top corners. | | rounded_b | Set typed radii on the bottom corners. | | rounded_l | Set typed radii on the left corners. | | rounded_r | Set typed radii on the right corners. | | rounded_tl | Set a typed top-left radius. | | rounded_tr | Set a typed top-right radius. | | rounded_bl | Set a typed bottom-left radius. | | rounded_br | Set a typed bottom-right radius. | ### Typography | Method | Description | | --- | --- | | font_family | Set a font family by name. | | font_weight | Set a typed font weight. | | italic | Use italic text. | | text_size | Set a typed font size. | | text_xs | Use the extra small text size. | | text_sm | Use the small text size. | | text_lg | Use the large text size. | | text_left | Align text to the left. | | text_center | Center text within its line. | | text_right | Align text to the right. | | line_height | Set a typed line height. | | whitespace_normal | Allow normal text wrapping. | | whitespace_nowrap | Prevent text from wrapping. | | text_ellipsis | Truncate overflowing text at the end with an ellipsis. | | truncate | Clip single line text and add an ellipsis. | | line_clamp | Limit text to a chosen number of lines. | | text_base | Use the base text size. | | text_xl | Use the extra large text size. | | text_2xl | Use the 2× large text size. | | text_3xl | Use the 3× large text size. | | text_align | Set a typed text alignment. | | not_italic | Use upright text. | | underline | Underline the text. | | line_through | Strike through the text. | | text_decoration_none | Remove text decoration. | | text_decoration_color | Set text decoration color. | | text_decoration_solid | Use a solid text decoration line. | | text_decoration_wavy | Use a wavy text decoration line. | | text_decoration_0 | Set decoration thickness to zero. | | text_decoration_1 | Set decoration thickness to one pixel. | | text_decoration_2 | Set decoration thickness to two pixels. | | text_decoration_4 | Set decoration thickness to four pixels. | | text_decoration_8 | Set decoration thickness to eight pixels. | | text_overflow | Set typed text overflow behavior. | | font_features | Set OpenType font features. | ### Effects and cursor | Method | Description | | --- | --- | | shadow_none | Remove box shadows. | | shadow_sm | Apply the named small box shadow. | | shadow_md | Apply the named medium box shadow. | | opacity | Set opacity with a floating point value. | | cursor_pointer | Use the pointing hand cursor on hover. | | cursor_text | Use a text insertion cursor on hover. | | shadow | Set a typed list of box shadows. | | shadow_2xs | Apply the named 2× extra small shadow. | | shadow_xs | Apply the named extra small shadow. | | shadow_lg | Apply the named large shadow. | | shadow_xl | Apply the named extra large shadow. | | shadow_2xl | Apply the named 2× extra large shadow. | | cursor | Set a typed mouse cursor. | | cursor_default | Use the default cursor. | | cursor_move | Use a move cursor. | | cursor_not_allowed | Use the not-allowed cursor. | | cursor_context_menu | Use a context-menu cursor. | | cursor_crosshair | Use a crosshair cursor. | | cursor_vertical_text | Use a vertical-text cursor. | | cursor_alias | Use an alias cursor. | | cursor_copy | Use a copy cursor. | | cursor_no_drop | Use a no-drop cursor. | | cursor_grab | Use a grab cursor. | | cursor_grabbing | Use a grabbing cursor. | | cursor_ew_resize | Use a horizontal resize cursor. | | cursor_ns_resize | Use a vertical resize cursor. | | cursor_nesw_resize | Use a northeast to southwest resize cursor. | | cursor_nwse_resize | Use a northwest to southeast resize cursor. | | cursor_col_resize | Use a column resize cursor. | | cursor_row_resize | Use a row resize cursor. | | cursor_n_resize | Use an upward resize cursor. | | cursor_e_resize | Use a rightward resize cursor. | | cursor_s_resize | Use a downward resize cursor. | | cursor_w_resize | Use a leftward resize cursor. | ### GPUI-specific methods These APIs have no direct Tailwind utility. Their names remain unlinked so the table does not suggest a false correspondence. | Method | Description | | --- | --- | | `font` | Replace the text font with a typed GPUI `Font`. | | `min_size` | Set typed minimum width and height together. | | `max_size` | Set typed maximum width and height together. | | `text_bg` | Set the background color of text runs, rather than the element box. | | `text_ellipsis_start` | Truncate at the start to preserve the end of text. | | `text_ellipsis_middle` | Truncate in the middle to preserve both ends. | | `debug` | Draw a debug outline in debug builds. | | `debug_below` | Draw debug outlines for this element and conforming descendants in debug builds. | | `style` | Return the mutable `StyleRefinement` used by the element; this is the trait's low-level accessor. | | `text_style` | Return the mutable text refinement within the element style. | | `grid_location_mut` | Access the mutable grid placement within a `StyleRefinement`. | The macros also generate methods for the size (`w`, `h`, `size`, `min_size`, `min_w`, `min_h`, `max_size`, `max_w`, `max_h`), gap (`gap`, `gap_x`, `gap_y`), margin (`m`, `mt`, `mb`, `mx`, `my`, `ml`, `mr`), padding (`p`, `pt`, `pb`, `px`, `py`, `pl`, `pr`), and inset (`inset`, `top`, `bottom`, `left`, `right`) families. For example, `w_64`, `px_3`, and `top_0` are real methods. Their shared numeric suffixes are `0`, `0p5`, `1`, `1p5`, `2`, `2p5`, `3`, `3p5`, every integer from `4` to `12`, then `16`, `20`, `24`, `32`, `40`, `48`, `56`, `64`, `72`, `80`, `96`, `112`, and `128`. They also include `_px`, `_full`, and fractional suffixes such as `_1_2`; only families that accept `auto` generate `_auto`. Non-auto values also have `_neg_` forms, such as `mt_neg_2`. Border sides (`border`, `border_t`, `border_b`, `border_l`, `border_r`, `border_x`, `border_y`) have pixel-width suffixes `0` through `12`, plus `16`, `20`, `24`, and `32`. Rounded sides and corners use `none`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl`, `3xl`, and `full`. Use these mechanically generated names only when the resulting property makes sense for the layout. Spacing helpers use a rem based scale: `_1` is `0.25rem`, `_2` is `0.5rem`, `_3` is `0.75rem`, and `_4` is `1rem`. For a value outside the named helpers, use a typed setter such as `.gap(rems(0.625))`, `.w(px(240.))`, or `.w(relative(0.5))`. `relative(0.5)` expresses half the available relative size; `px(...)` expresses pixels. The named scale and method set are GPUI's implementation, so check the actual API rather than assuming every Tailwind class exists. ## What a style call changes Every `Styled` element exposes `fn style(&mut self) -> &mut StyleRefinement`. A call such as `.px_3()` writes the relevant optional padding fields; `.bg(...)` writes the background field. Fields left unset do not replace existing values when refinements are merged. The resolved `Style` holds both layout data and presentation data. ```text Styled calls → StyleRefinement → resolved Style ├─ layout fields → Taffy → bounds └─ color, text, shadow, cursor → GPUI paint and interaction ``` In an element's `request_layout` phase, GPUI passes layout fields such as display, size, padding, gap, flex alignment, position, and grid placement, together with child layout IDs, to Taffy. Taffy computes the geometry. GPUI then uses the bounds in `prepaint` and the [Paint](./paint) phase for drawing and hit testing. Taffy does not implement GPUI's text shaping, hover listeners, [Actions](./action), or painting. `StyleRefinement` itself implements `Styled`, so a state style closure can use the same utility methods. Interaction variants such as `.hover(|style| style.bg(...))` belong to `InteractiveElement`, and need an interactive element. Conditional builder calls are different: `.when(...)` chooses a chain step while this frame is built. ## Fluent composition and trait boundaries The fluent surface combines several traits. `Styled` supplies style methods. `ParentElement` supplies `.child(...)`. `InteractiveElement` supplies `.id(...)`, returning a `Stateful
` that supports identity dependent methods such as `.overflow_y_scroll()`. `InteractiveElement` also supplies state style refinements such as `.hover(...)`. Every `IntoElement` implements `FluentBuilder`; `IntoElement` alone does not grant styling, children, or interaction. If a method is missing, check the receiver's trait and whether a preceding call changed its type. | `FluentBuilder` method | Effect | | --- | --- | | `map` | Transform the value and optionally change its return type. | | `when` | Apply a builder step when a boolean is true. | | `when_else` | Choose between two steps that both return the same builder type. | | `when_some` | Apply a step and pass the value inside an `Option`. | | `when_none` | Apply a step when a referenced `Option` is empty. | ```rust use gpui_kit::*; use gpui_kit::component::ActiveTheme as _; div() .id("result-row") .px_3() .py_2() .when(selected, |row| row.bg(cx.theme().selection)) .when_some(subtitle, |row, text| row.child(text)) .child(title) ``` Conditional closures consume and return the builder. The condition is evaluated during the current render; it is not a subscription. `.map(...)` can return a different type. Use an `Entity` to own state that changes across frames. For example, `.when(selected, ...)` evaluates `selected` while building this frame; `.hover(|style| ...)` installs a style refinement for pointer hover. A scroll call needs a stateful element, so give the intended scroll owner an `.id(...)` first. An arbitrary component implementing `IntoElement` cannot be assumed to accept `.child(...)` or `.bg(...)`; inspect its own builder API or wrap it in a `div()` that owns those styles. GPUI Kit adds `StyledExt` for neutral helpers such as `h_flex`, `v_flex`, and `refine_style`, and `ThemeStyled` for theme driven appearance such as `popover_style(cx)`. These are extensions on top of GPUI's `Styled`, not Tailwind utilities. When implementing a custom element, return its `StyleRefinement` from `style()` and apply the resolved style during layout and paint. Implement only the child and interaction traits that the element can actually support. The correspondence with Tailwind is deliberately scoped: there are no CSS selectors, cascade, responsive prefixes, or promise that every Tailwind utility has a GPUI method. Use GPUI's typed layout helpers for geometry, GPUI Kit theme tokens for product appearance, and explicit element or entity APIs for behavior. --- # Packaging Source: /versions/v0.7.1/docs/packaging License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/packaging and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. # Packaging Desktop Apps `cargo build --release` produces an executable, not an installer. GPUI Kit supplies the application and UI layers; your application owns its package identity, icons, external files, installer, signing, and release tests. Start with [Installation](/versions/v0.7.1/docs/installation) and [Getting Started](/versions/v0.7.1/docs/getting-started), then build and package **on each target platform**. This guide uses the repository's `hello_world` binary to make paths concrete. Replace `hello_world`, `HelloWorld`, the sample version, and `com.example.helloworld` with your application's values before shipping. ## Choose the artifact | Platform | Portable artifact | Installed artifact | What changes | | --- | --- | --- | --- | | macOS | A `.app` inside a ZIP or tarball | A `.app` distributed in a `.dmg` | Finder recognizes the app bundle; direct distribution needs Developer ID signing and notarization. A DMG is a delivery container, not an installer that writes files automatically. | | Windows | ZIP containing an `.exe` and its files | An Inno Setup `.exe` installer | The installer registers the app, shortcuts, and uninstall behavior. Renaming a ZIP to `.exe` does not provide that behavior. | | Linux | `.tar.gz` with the binary and resources | Distribution package such as `.deb` | A DEB declares package identity and dependencies, installs a desktop entry and icon, and supports package manager upgrade/removal. A tarball needs an explicit install process. | Choose only the formats your users need. A `.deb` does not cover RPM distributions, and a Windows ZIP is useful for portable use even when you also provide an installer. [Apple's distribution guide](https://developer.apple.com/documentation/xcode/packaging-mac-software-for-distribution), [Microsoft's packaging overview](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/packaging/), and the [Debian binary package guide](https://www.debian.org/doc/debian-policy/ap-pkg-binarypkg.html) describe the platform rules. ## Build and inventory the application From this repository's root, build the existing example on the platform being packaged: ```sh cargo build --release --locked -p hello_world ``` The output is `target/release/hello_world` on macOS/Linux or `target\release\hello_world.exe` on Windows. In your own application, run `cargo build --release --locked` from its project directory and use its binary name. Keep the `Cargo.lock` used to build the release, and record the target OS, CPU architecture, app version, and build revision with the artifact. Build separately for each architecture you publish; a successful x86-64 build does not establish an Arm build. Before packaging, list everything the program opens **at runtime**: - GPUI Kit's default native icon assets are embedded in the binary. A custom `AssetSource` may instead read files at runtime. Assets addressed through a filesystem `Path` need to be copied into the package and resolved relative to a stable installed location; see [Icons & Assets](/versions/v0.7.1/docs/assets). - Fonts inserted with `include_bytes!` are compiled into the executable. Fonts opened from disk must travel with it, with their licenses; see [Fonts](/versions/v0.7.1/docs/fonts). - Include other required files such as configuration templates, translations, helper executables, and license notices. Keep mutable user data outside the installed application directory. - Review native dynamic libraries and system services used by *your* feature set. A compile-time development package is not necessarily a runtime dependency. For example, a Linux GPUI window needs a working graphical session and Vulkan driver; a machine with only the Vulkan loader cannot render it. See [Installation](/versions/v0.7.1/docs/installation). Give the app a stable identity before publishing it. The visible name, executable name, platform package identifier, update channel, and data directory policy should agree across releases. If the app uses GPUI's application identity for notifications or related platform integration, set it at startup with `cx.set_app_identity("com.example.helloworld", "HelloWorld")`; see [System Notifications](/versions/v0.7.1/docs/system-notification). Package identity and GPUI runtime identity serve different APIs, so check both. A new identifier can create a second installation or separate settings/notification identity rather than upgrading the old one. ## macOS A macOS application is a directory with a defined bundle layout: `Contents/MacOS` holds the executable, `Contents/Resources` holds resources, and `Contents/Info.plist` declares the executable and bundle identifier. [Apple documents the bundle structure](https://developer.apple.com/documentation/bundleresources/placing-content-in-a-bundle). On a Mac, assemble a minimal local test bundle from this repository's example: ```sh mkdir -p dist/HelloWorld.app/Contents/MacOS dist/HelloWorld.app/Contents/Resources cp target/release/hello_world dist/HelloWorld.app/Contents/MacOS/hello_world cat > dist/HelloWorld.app/Contents/Info.plist <<'PLIST' CFBundleNameHelloWorld CFBundleDisplayNameHelloWorld CFBundleIdentifiercom.example.helloworld CFBundleExecutablehello_world CFBundlePackageTypeAPPL CFBundleShortVersionString0.7.1 CFBundleVersion1 PLIST plutil -lint dist/HelloWorld.app/Contents/Info.plist open dist/HelloWorld.app ``` That bundle has no custom icon. For your app, add an `.icns` in `Contents/Resources`, declare `CFBundleIconFile`, and copy external files under `Resources`; resolve them from the bundle location rather than the caller's working directory. Update both version fields for each release. The example above is a **local bundle test**, not a signed public release. For distribution outside the Mac App Store, sign the final app with a Developer ID Application certificate and hardened runtime, then create and sign a DMG. Sign any nested executable code from the inside out before signing the app; do not treat `codesign --deep` as a substitute for that signing order. Verify the signed app before creating the DMG. Apple's [signing guide](https://developer.apple.com/documentation/xcode/creating-distribution-signed-code-for-the-mac/) explains these requirements. A representative final sequence is: ```sh codesign --force --timestamp --options runtime --sign "Developer ID Application: Your Organization" dist/HelloWorld.app codesign --verify --deep --strict --verbose=2 dist/HelloWorld.app mkdir -p dist/dmg-root cp -R dist/HelloWorld.app dist/dmg-root/HelloWorld.app hdiutil create -volname HelloWorld -srcfolder dist/dmg-root -ov -format UDZO dist/HelloWorld.dmg codesign --force --timestamp --sign "Developer ID Application: Your Organization" dist/HelloWorld.dmg xcrun notarytool submit dist/HelloWorld.dmg --keychain-profile "notary-profile" --wait xcrun stapler staple dist/HelloWorld.dmg xcrun stapler validate dist/HelloWorld.dmg ``` Replace the signing identity and `notary-profile` with credentials you provision in your own release process. Check that `notarytool` reports **Accepted**; `--wait` alone is not a success criterion. Investigate its submission log if rejected. Staple and validate the accepted DMG, then download and launch that exact artifact on a clean Mac. An ad hoc signature (`--sign -`) only seals code for local use; it does not replace Developer ID signing and notarization for direct distribution. See Apple's [notarization workflow](https://developer.apple.com/documentation/security/customizing-the-notarization-workflow). ## Windows Build with the MSVC Rust toolchain and Windows SDK described in [Installation](/versions/v0.7.1/docs/installation). Stage the executable and any runtime files first. A ZIP can be enough for a portable app: ```powershell New-Item -ItemType Directory -Force dist\windows | Out-Null Copy-Item target\release\hello_world.exe dist\windows\hello_world.exe Compress-Archive -Path dist\windows\* -DestinationPath dist\hello-world-windows-x64.zip -Force ``` Test by extracting the ZIP into a fresh directory and launching the extracted `.exe`; do not test only `target\release`. A ZIP does not add an uninstall entry or Start menu shortcut. If your app needs those, build an `.exe` installer with [Inno Setup](https://jrsoftware.org/isinfo.php). Save this minimal **unsigned local example** as `packaging.iss` in the project root, after staging `dist\windows\hello_world.exe`: ```ini [Setup] AppId=Example.HelloWorld AppName=HelloWorld AppVersion=0.7.1 DefaultDirName={autopf}\HelloWorld DefaultGroupName=HelloWorld OutputDir=dist OutputBaseFilename=HelloWorld-Setup ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible [Files] Source: "dist\windows\hello_world.exe"; DestDir: "{app}"; Flags: ignoreversion [Icons] Name: "{group}\HelloWorld"; Filename: "{app}\hello_world.exe" [Run] Filename: "{app}\hello_world.exe"; Description: "Launch HelloWorld"; Flags: nowait postinstall skipifsilent ``` ```powershell ISCC.exe packaging.iss ``` Keep `AppId` stable for upgrades. Add your own icon, publisher metadata, license, files, and install scope as needed. For a signed release, sign the application executable **before** placing it in either the ZIP or installer, then sign the finished setup `.exe`. Inno Setup's uninstaller is embedded in that `.exe`, so signing the setup afterward does **not** sign the uninstaller; configure Inno's [signed uninstaller workflow](https://jrsoftware.org/ishelp/topic_setup_signeduninstaller.htm) or an equivalent two-pass build. Check all signatures and install/uninstall behavior on a clean Windows VM. The exact signing mechanism depends on your certificate provider; it is not supplied by GPUI Kit. ## Linux For a portable tarball, stage the executable, any external files, license notices, and a launcher that resolves files from its own directory. Add your own 256 × 256 PNG at `packaging/hello-world-gpui.png`, then make an archive for the minimal example: ```sh mkdir -p dist/portable/HelloWorld/bin \ dist/portable/HelloWorld/share/icons/hicolor/256x256/apps cp target/release/hello_world dist/portable/HelloWorld/bin/hello_world install -m 0644 packaging/hello-world-gpui.png \ dist/portable/HelloWorld/share/icons/hicolor/256x256/apps/hello-world-gpui.png tar -czf dist/hello-world-linux-x64.tar.gz -C dist/portable HelloWorld tar -tzf dist/hello-world-linux-x64.tar.gz ``` Extract the archive somewhere else and launch the executable there. For an app with external files, copy them under `HelloWorld` before archiving and locate them from the executable or launcher, not the shell's current directory. A bare Rust binary may run on your build machine while missing shared libraries or a usable GPU driver on another. Inspect direct ELF requirements with `objdump -p target/release/hello_world` and test on the oldest distribution you support. If you use `ldd`, run it only on binaries you built or trust. For users who want a desktop menu entry without a system package, publish an `install.sh` alongside the tarball. The following small installer accepts a **local** archive and its expected SHA-256 digest as two arguments. Give users the digest through a trusted release channel; a checksum downloaded from the same untrusted location as a replaced archive does not authenticate that archive. The script verifies the entire archive before changing installed files, rejects unsafe paths and links, requires the binary and icon, then installs into the current user's default XDG locations. It needs Python 3 and no root access: ```sh #!/bin/sh set -eu [ "$#" -eq 2 ] || { echo 'Usage: sh install.sh ARCHIVE EXPECTED_SHA256' >&2; exit 2; } python3 - "$1" "$2" <<'PY' from pathlib import Path, PurePosixPath import hashlib import io import os import re import shutil import sys import tarfile import tempfile archive = Path(sys.argv[1]) expected = sys.argv[2].lower() if not re.fullmatch(r"[0-9a-f]{64}", expected): raise SystemExit("Expected a 64-character SHA-256 digest") digest = hashlib.sha256() verified = tempfile.TemporaryFile() with archive.open("rb") as archive_stream: for chunk in iter(lambda: archive_stream.read(1024 * 1024), b""): digest.update(chunk) verified.write(chunk) if digest.hexdigest() != expected: raise SystemExit("Checksum mismatch; nothing installed") verified.seek(0) binary_name = "HelloWorld/bin/hello_world" icon_name = "HelloWorld/share/icons/hicolor/256x256/apps/hello-world-gpui.png" with tarfile.open(fileobj=verified, mode="r:gz") as bundle: members = {} for member in bundle: path = PurePosixPath(member.name) if (not path.parts or path.is_absolute() or ".." in path.parts or path.parts[0] != "HelloWorld" or not (member.isfile() or member.isdir()) or str(path) in members): raise SystemExit("Unsafe or duplicate archive entry") members[str(path)] = member if not all(name in members and members[name].isfile() for name in (binary_name, icon_name)): raise SystemExit("Archive is missing its executable or icon") bin_dir = Path.home() / ".local/bin" data_dir = Path.home() / ".local/share" app_dir = data_dir / "applications" icon_dir = data_dir / "icons/hicolor/256x256/apps" for directory in (bin_dir, app_dir, icon_dir): directory.mkdir(parents=True, exist_ok=True) binary = bin_dir / "hello_world" icon = icon_dir / "hello-world-gpui.png" desktop = app_dir / "hello-world-gpui.desktop" # Desktop Entry quoting has two escape layers; this handles a home path with spaces. exec_path = str(binary).replace("\\", "\\\\\\\\") for character in ('"', '`', '$'): exec_path = exec_path.replace(character, "\\\\" + character) exec_path = exec_path.replace("%", "%%") desktop_content = ( "[Desktop Entry]\nType=Application\nName=HelloWorld\n" + f'Exec="{exec_path}"\n' + "Icon=hello-world-gpui\nTerminal=false\nCategories=Utility;\n" ) staged_files = [] def stage_file(destination, input_stream, mode): with tempfile.NamedTemporaryFile( dir=destination.parent, prefix=".hello-world-install-", delete=False ) as staged: staged_path = Path(staged.name) staged_files.append(staged_path) shutil.copyfileobj(input_stream, staged) staged_path.chmod(mode) return staged_path try: with bundle.extractfile(members[binary_name]) as entry_stream: staged_binary = stage_file(binary, entry_stream, 0o755) with bundle.extractfile(members[icon_name]) as entry_stream: staged_icon = stage_file(icon, entry_stream, 0o644) staged_desktop = stage_file( desktop, io.BytesIO(desktop_content.encode("utf-8")), 0o644 ) for staged, destination in ( (staged_binary, binary), (staged_icon, icon), (staged_desktop, desktop), ): os.replace(staged, destination) finally: for staged in staged_files: staged.unlink(missing_ok=True) verified.close() print("Installed HelloWorld for the current user") PY ``` Save the code as `install.sh`. Run `sh install.sh dist/hello-world-linux-x64.tar.gz SHA256_FROM_TRUSTED_RELEASE`, replacing the final argument with the archive's real 64-character digest. The example uses `~/.local/bin` for the executable and the default XDG data directory `~/.local/share` for the desktop file and icon; [the XDG Base Directory specification](https://specifications.freedesktop.org/basedir/latest/) defines these defaults. If your desktop uses a custom XDG data home, adapt `data_dir` to that configured **absolute** directory before using the script. The desktop entry uses an absolute executable path, so the menu does not depend on `~/.local/bin` being on the desktop session's `PATH`. See the [Desktop Entry specification](https://specifications.freedesktop.org/desktop-entry/latest-single/) for `Exec` quoting and icon lookup. Close the app and rerun the installer with a verified newer archive to upgrade. Replacing the binary within one directory is atomic, while updating the binary, icon, and desktop entry together is **not** one transaction. For this minimal example, uninstall with `rm ~/.local/bin/hello_world ~/.local/share/applications/hello-world-gpui.desktop ~/.local/share/icons/hicolor/256x256/apps/hello-world-gpui.png`; leave user settings and documents in their own directories. An application with additional runtime files needs an installer that stages and updates those files too. For Debian/Ubuntu installation, stage files under a package root. The following tree shows the key locations; copy your actual binary into `usr/lib/hello-world-gpui/`, your icon into the hicolor icon tree, and your desktop entry into `usr/share/applications/`: ```text dist/deb-root/ ├── DEBIAN/control └── usr/ ├── lib/hello-world-gpui/hello_world └── share/ ├── applications/hello-world-gpui.desktop └── icons/hicolor/256x256/apps/hello-world-gpui.png ``` Create the staging directories and copy the example binary on a Linux build machine; supply your own application icon: ```sh mkdir -p dist/deb-root/DEBIAN \ dist/deb-root/usr/lib/hello-world-gpui \ dist/deb-root/usr/share/applications \ dist/deb-root/usr/share/icons/hicolor/256x256/apps install -m 0755 target/release/hello_world \ dist/deb-root/usr/lib/hello-world-gpui/hello_world ``` Use a stable package name and an architecture/version that match the binary. A minimal `DEBIAN/control` for the sample is: ```text Package: hello-world-gpui Version: 0.7.1 Section: utils Priority: optional Architecture: amd64 Maintainer: Example Maintainer Description: A GPUI Kit desktop example ``` Add the runtime `Depends` derived from your **built application** and distribution baseline; `dpkg-shlibdeps` can help derive shared-library dependencies. Do not copy another application's dependency list. A desktop entry for the staged paths is: ```ini [Desktop Entry] Type=Application Name=HelloWorld Exec=/usr/lib/hello-world-gpui/hello_world Icon=hello-world-gpui Terminal=false Categories=Utility; ``` When all staged files are present, build and inspect the package. The [documented `--root-owner-group` option](https://manpages.debian.org/unstable/dpkg/dpkg-deb.1.en.html) records root ownership for staged package files even when you build as a regular user; use it when all packaged files should be root-owned. ```sh dpkg-deb --root-owner-group --build dist/deb-root dist/hello-world-gpui_0.7.1_amd64.deb dpkg-deb --info dist/hello-world-gpui_0.7.1_amd64.deb dpkg-deb --contents dist/hello-world-gpui_0.7.1_amd64.deb ``` Install a copy on a clean test machine with `sudo apt install ./dist/hello-world-gpui_0.7.1_amd64.deb`, launch it from the desktop menu, then remove it with `sudo apt remove hello-world-gpui`. [Debian policy](https://www.debian.org/doc/debian-policy/ch-opersys.html) prohibits packages from placing their files in `/usr/local`; the [Desktop Entry specification](https://specifications.freedesktop.org/desktop-entry/latest-single/) defines the launcher keys. Use a separate RPM packaging recipe for RPM distributions. ## Verify the release artifact Do the final checks against the **downloaded package**, not the original build directory: 1. Confirm filename, embedded app version, architecture, package identity, and checksum match the intended release. Keep one version source of truth where possible. 2. Inspect the archive/package contents for the executable, icon, external assets, required notices, and accidental development files. Extract it to a new location; launch from there without relying on the source tree or working directory. 3. On a clean machine or VM for each supported OS/architecture, check first launch, window creation, text and icons, keyboard and pointer input, notifications or WebView if used, and behavior without a network connection if offline use is promised. Linux tests need a real graphical session and Vulkan driver. 4. Install version N, install N+1 over it, and check both application data and shortcuts. Then uninstall and check which files remain by design. Test portable archives separately because they have no package-managed upgrade or removal. 5. Verify signatures and notarization where applicable, publish SHA-256 checksums, and download the artifact again to compare its checksum before announcing it. Package format does not guarantee platform support for every optional feature. In particular, check the [WebView](/versions/v0.7.1/docs/webview) target limitations before promising a Linux WebView build. For in-app version checks and upgrades after packaging, continue with [Auto Update](/versions/v0.7.1/docs/auto-update). --- # Paint Source: /versions/v0.7.1/docs/paint License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/paint and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. GPUI paints after layout and `prepaint`. The resolved `Bounds` tell a custom [`Element`](./element) where it can draw. `paint` records drawing commands for the current frame; it does not establish layout or input geometry. Use `window.paint_quad` for rectangles and borders, existing image and text elements for those media, and `window.paint_path` for freeform shapes. A `canvas` is a convenient way to run prepaint and paint callbacks without implementing the whole `Element` trait. ## Start with one painted triangle This exercise needs no new crate or dependency. In a local checkout, replace the contents of the existing `examples/hello_world/src/main.rs` with the following code, then run `cargo run -p hello_world` from the repository root. You should see a blue triangle near the upper-left corner of the window. Restore the example file after experimenting if you do not want to keep the change. ```rust use gpui_kit::*; struct PaintedTriangle; impl Render for PaintedTriangle { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div().size_full().child( canvas( |bounds, _, _| bounds, |_, bounds, window, _| { let mut path = PathBuilder::fill(); let x = bounds.origin.x; let y = bounds.origin.y; path.move_to(point(x + px(24.), y + px(24.))); path.line_to(point(x + px(144.), y + px(24.))); path.line_to(point(x + px(84.), y + px(128.))); path.close(); if let Ok(path) = path.build() { window.paint_path(path, rgb(0x3b82f6)); } }, ) .size_full(), ) } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| PaintedTriangle) }) .expect("failed to open window"); }); } ``` The parent `div` and child `canvas` both have `.size_full()`, so layout gives the callback a usable rectangle. The first callback runs during `prepaint` and returns the resolved bounds as its state `T`; the second receives that same value during `paint`. Here it builds one filled triangle in window coordinates and passes the resulting `Path` to `paint_path`. `close()` joins the last point back to the first. The `if let Ok` handles possible tessellation failure instead of panicking. The two `canvas` callbacks are `FnOnce` callbacks for one element pass. Return owned prepared data from the first callback when painting needs it; do not save references to `Window` or `App` for a later frame. The `canvas` itself is a frame-local element, while retained drawing data belongs in an Entity or keyed window state if it must survive another render. Try changing `px(84.)` to `px(120.)` in the third point, then run again: only the triangle's geometry changes. Replace `PathBuilder::fill()` with `PathBuilder::stroke(px(4.))` to draw its outline instead. The element tree is recreated when GPUI renders a new frame; the path here is also rebuilt during paint. This is suitable for a tiny illustration, but a large or frequently redrawn path may merit a cache after measurement. The coordinate handoff is the key idea: ```text layout: canvas bounds = origin (x, y) + size (width, height) prepaint: pass resolved bounds to paint paint: local point (24, 24) + origin (x, y) -> window point -> PathBuilder ``` The bounds establish a coordinate system; they do not automatically clip paths to the canvas. If a shape extends past the canvas, apply a content mask or an appropriate clipping style to its container. ## Make drawing respond to a click Replace `examples/hello_world/src/main.rs` with this complete example and run `cargo run -p hello_world` again. Each left click adds a small blue triangle at the pointer. The triangles remain visible after later clicks because their positions live in the `ClickPainter` Entity, not in a one-frame `canvas` value. ```rust use gpui_kit::base::ElementExt; use gpui_kit::*; struct ClickPainter { marks: Vec>, canvas_bounds: Option>, } impl ClickPainter { fn add_mark( &mut self, event: &MouseDownEvent, _: &mut Window, cx: &mut Context, ) { if let Some(bounds) = self.canvas_bounds { self.marks.push(point( event.position.x - bounds.origin.x, event.position.y - bounds.origin.y, )); cx.notify(); } } } impl Render for ClickPainter { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let marks = self.marks.clone(); let painter = cx.entity().clone(); div() .size_full() .relative() .on_mouse_down(MouseButton::Left, cx.listener(Self::add_mark)) .on_prepaint(move |bounds, _, cx| { painter.update(cx, |this, _| { this.canvas_bounds = Some(bounds); }); }) .child( canvas( move |bounds, _, _| (bounds.origin, marks), |_, (origin, marks), window, _| { for mark in marks { let center = point(origin.x + mark.x, origin.y + mark.y); let mut path = PathBuilder::fill(); path.move_to(point(center.x, center.y - px(16.))); path.line_to(point(center.x + px(16.), center.y + px(12.))); path.line_to(point(center.x - px(16.), center.y + px(12.))); path.close(); if let Ok(path) = path.build() { window.paint_path(path, rgb(0x3b82f6)); } } }, ) .absolute() .size_full(), ) } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| ClickPainter { marks: Vec::new(), canvas_bounds: None, }) }) .expect("failed to open window"); }); } ``` The parent `div` receives the mouse event. Its `on_prepaint` hook records its resolved bounds for the next input event without requesting another render. `add_mark` subtracts that origin to retain a canvas-local position, then `cx.notify()` requests a new frame. During the next render, the canvas receives a snapshot of the marks; its paint callback adds the **current** origin and builds a path for each one. Try clicking twice, then resize the window: both marks should remain in place relative to the canvas. If a click does nothing, check that the parent has nonzero size, that `canvas_bounds` was recorded, and that the handler calls `cx.notify()` after changing `marks`. ## Follow a real drawing app Run the existing [Brush example](https://github.com/longbridge/gpui-kit/blob/main/examples/brush/src/main.rs) from the repository root: ```sh cargo run -p example-brush ``` Drag inside **Drawing Canvas** to make a stroke. Try **Size**, **Opacity**, a color swatch, **Show Grid**, and **Clear Canvas**. The following steps trace one stroke through the example's input, layout, and paint code. The frame has a useful order to keep in mind: a mouse handler changes retained `BrushStory` state and calls `cx.notify()`; `render` builds a new element tree; layout resolves sizes and positions; `prepaint` receives bounds; `paint` submits paths for that frame. The stored stroke points survive between frames. The `Path` objects in this example are built again during painting. ### 1. Give the canvas space and collect input `render_canvas` puts the painting canvas inside a full-size `div`. The parent gets space from the flexible **Drawing Canvas** section. The `div` handles mouse events; the child canvas fills that same area: ```rust let base_div = div() .id("canvas") .size_full() .bg(theme.background) .cursor_crosshair() .relative() .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down)) .on_mouse_move(cx.listener(Self::handle_mouse_move)) .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up)) .on_prepaint(move |bounds, _window, cx| { state_entity.update(cx, |state, _| { state.canvas_bounds = Some(bounds); }) }); ``` The example adds `canvas(...).absolute().size_full()` as this `div`'s child. A canvas needs a size from its own styles or its parent; otherwise there may be no useful drawing area. The parent's `on_prepaint` saves its resolved `Bounds` for the mouse handlers. Bounds and mouse positions use **window coordinates**, so the canvas origin is generally not `(0, 0)`. Saving bounds in `on_prepaint` does not call `cx.notify()`: it records geometry needed by later input, without requesting another render during every frame. ### 2. Store points relative to the canvas On mouse down, `BrushStory::handle_mouse_down` starts a `Stroke`. It subtracts the saved origin before storing the pointer position; mouse move uses the same conversion for subsequent points: ```rust let local_pos = if let Some(bounds) = self.canvas_bounds { Point::new( event.position.x - bounds.origin.x, event.position.y - bounds.origin.y, ) } else { event.position }; ``` `BrushStory` retains completed strokes in `strokes` and the active one in `current_stroke`. The handlers call `cx.notify()` when a stroke changes, so GPUI renders a new frame. A single mouse-down point does not yet form a visible line: `build_stroke_path` requires at least two points. Releasing the mouse adds a stroke with two or more points to the completed list. ### 3. Pass resolved bounds into painting In `render_canvas`, the canvas's first callback receives its final bounds and returns them with the strokes, active stroke, grid setting, and theme. The second callback receives that value and paints. This excerpt shows the handoff; the [source](https://github.com/longbridge/gpui-kit/blob/main/examples/brush/src/main.rs) also draws the optional grid and active stroke: ```rust canvas( move |bounds, _window, _cx| { ( strokes_for_prepaint, current_stroke_for_prepaint, show_grid_for_prepaint, theme_for_prepaint, bounds, ) }, move |_bounds, (strokes, current_stroke, show_grid, theme, prepaint_bounds), window, _cx| { for stroke in strokes.iter() { if let Some(path) = BrushStory::build_stroke_path(stroke, &prepaint_bounds) { window.paint_path(path, stroke.color); } } // The example also paints the grid and current_stroke here. }, ) .absolute() .size_full() ``` `prepaint` happens after layout, when bounds are known. `paint_path` submits drawing for this frame; it does not change state or schedule another frame. ### 4. Build the path in window coordinates `build_stroke_path` converts stored canvas-local points back to window coordinates before tessellating a stroked path: ```rust let mut builder = PathBuilder::stroke(px(stroke.size)); let first_point = Point::new( bounds.origin.x + stroke.points[0].x, bounds.origin.y + stroke.points[0].y, ); builder.move_to(first_point); for point in stroke.points.iter().skip(1) { let abs_point = Point::new(bounds.origin.x + point.x, bounds.origin.y + point.y); builder.line_to(abs_point); } builder.build().ok() ``` Try increasing **Size** and drawing another line: `stroke.size` sets the width of each new stroke, while earlier strokes keep their stored widths. **Opacity** and color are likewise captured when a stroke begins. Toggle **Show Grid** to see another path drawn in the same paint callback, or click **Clear Canvas** to clear the retained strokes and request a frame. If layout moves or resizes the canvas, each paint uses its current bounds origin to place the stored points. If a stroke does not appear, check the failure point in order: | Symptom | Check | | --- | --- | | Nothing appears, including the grid | Confirm the parent and canvas have nonzero layout size; a path does not allocate its own space. | | A click leaves no mark | A single point is not a line in this example; drag far enough for a second sampled point. | | The stroke is offset after moving the canvas | Subtract the canvas origin on input and add the **current** origin when painting. | | Stored points change but the image does not | Make sure the state owner calls `cx.notify()` after a meaningful change. | | Some geometry silently disappears | Inspect the `PathBuilder::build()` result; this example converts errors to `None`. | The example treats a failed build as “draw nothing”; report or retain errors when geometry comes from user data. Input handlers live on the containing `div`; a path alone has no hitbox or accessibility behavior. ## Build a path `PathBuilder` describes vector geometry and tessellates it into a `Path` when `build()` succeeds. Choose `fill()` for a closed area or `stroke(width)` for a line. Its points use window pixel coordinates, so add the element's resolved origin when your data is local to its bounds. ```rust use gpui_kit::*; fn paint_triangle(bounds: Bounds, color: Hsla, window: &mut Window) { let mut builder = PathBuilder::fill(); builder.move_to(bounds.origin); builder.line_to(point(bounds.right(), bounds.top())); builder.line_to(point(bounds.left() + bounds.size.width / 2., bounds.bottom())); builder.close(); if let Ok(path) = builder.build() { window.paint_path(path, color); } } ``` `move_to`, `line_to`, `curve_to` (quadratic), `cubic_bezier_to`, `arc_to`, `add_polygon`, and `close` describe segments. `translate`, `scale`, and `rotate` transform the path before tessellation. `PathBuilder::stroke(px(1.)).dash_array(&[px(4.), px(2.)])` produces a dashed outline. `build()` returns a `Result`; handle failure rather than assuming arbitrary geometry can always be tessellated. A built `Path` can be cloned and painted in more than one color or frame when its geometry has not changed. ### From SVG paths to GPUI `PathBuilder` was introduced to GPUI for candlestick chart drawing needs. It uses Lyon's SVG path builder internally, so its segment vocabulary is familiar from SVG. If you know [SVG path commands](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/d), the segment concepts transfer directly: | SVG path | GPUI builder | Meaning | | --- | --- | --- | | `M x y` | `move_to(point)` | Start a subpath. | | `L x y` | `line_to(point)` | Draw a straight segment. | | `Q cx cy x y` | `curve_to(end, control)` | Quadratic Bézier. | | `C c1x c1y c2x c2y x y` | `cubic_bezier_to(end, control_a, control_b)` | Cubic Bézier. | | `A rx ry rotation large sweep x y` | `arc_to(radii, rotation, large_arc, sweep, end)` | Elliptical arc. | | `Z` | `close()` | Close the current subpath. | The geometry model is familiar, but the Rust argument order is not a literal transcription of SVG syntax: `curve_to` and `cubic_bezier_to` take the **end point first**, followed by control points. `x_rotation` is passed as a `Pixels` value whose numeric part represents degrees in the current API. Coordinates are typed `Point`, and `build()` tessellates the path before `paint_path` submits it. This makes porting SVG drawing logic straightforward while keeping GPUI's typed coordinate and error handling rules explicit. ### The GPUI Kit mark in two path notations The GPUI Kit mark makes the relationship between SVG paths and `PathBuilder` concrete. Its two closed paths paint separately: the outer shape uses the theme foreground and the inner stroke uses the theme blue accent. Switch between the GPUI and SVG source, then compare them with the rendered mark below. The example uses a local 32 × 32 coordinate space; the GPUI code adds the bounds origin to each point. `foreground` and `accent_color` are colors supplied by the caller.
let p = |x: f32, y: f32| point(bounds.left() + px(x), bounds.top() + px(y));

let mut outer = PathBuilder::fill();
outer.move_to(p(4., 4.));
outer.line_to(p(28., 4.));
outer.line_to(p(28., 9.));
outer.line_to(p(10., 9.));
outer.line_to(p(10., 23.));
outer.line_to(p(28., 23.));
outer.line_to(p(28., 28.));
outer.line_to(p(4., 28.));
outer.close();
if let Ok(path) = outer.build() {
    window.paint_path(path, foreground);
}

let mut accent = PathBuilder::fill();
accent.move_to(p(16., 13.));
accent.line_to(p(28., 13.));
accent.line_to(p(28., 23.));
accent.line_to(p(23., 23.));
accent.line_to(p(23., 18.));
accent.line_to(p(16., 18.));
accent.close();
if let Ok(path) = accent.build() {
    window.paint_path(path, accent_color);
}
<svg viewBox="0 0 32 32">
  <path fill="currentColor" d="M4 4 L28 4 L28 9 L10 9 L10 23 L28 23 L28 28 L4 28 Z" />
  <path fill="#3B82F6" d="M16 13 L28 13 L28 23 L23 23 L23 18 L16 18 Z" />
</svg>
GPUI Kit mark drawn with two colored paths
Two filled paths, with foreground and the theme blue accent painted separately.
GPUI’s `PathBuilder` takes full point coordinates; it has no SVG `H` or `V` shorthand and does not parse an SVG `d` string directly. The SVG tab spells out every `L` coordinate to make the correspondence visible. If the source is already an SVG file and no `Path` is needed, render the asset with `svg().path("icons/logo.svg")`. Converting arbitrary SVG path data into a GPUI `Path` requires a separate parser that feeds segments into `PathBuilder`. ## Choose the right phase | Work | Phase | Reason | | --- | --- | --- | | Declare size and child layout nodes | `request_layout` | Taffy needs the style before bounds exist. | | Prepare geometry shared by hit testing and drawing; insert a `Hitbox` | `prepaint` | Bounds and current frame input geometry are available. | | Build paint-only geometry if necessary; call `paint_path`, `paint_quad`, or paint prepared children | `paint` | Drawing order is now known; Brush builds its paths here. | For a small decorative shape, `canvas(prepaint, paint)` is enough. [GPUI Kit's Plot line](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/plot/shape/line.rs) builds a stroke from data points and paints it into the chart. The [input element](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/input/base/element.rs) uses paths for selections and text range decorations; it paints blinking carets as quads. Both use `PathBuilder`, but the input must also coordinate text metrics and hit testing. The scene can clip drawing with `window.with_content_mask`. Clipping, input hitboxes, and [accessibility](./accessibility) are separate contracts: a painted path is not automatically clickable or announced to assistive technology. A chart with point interaction must also establish hitboxes or an equivalent pointer mapping, and a semantic chart needs an accessible representation. ## Avoid unnecessary tessellation Path tessellation has real cost. Keep a path when its source points and dimensions are unchanged; rebuild it when either changes. Do not cache a path that contains absolute window coordinates across relocation unless the cache also accounts for the new origin. For simple boxes, prefer `paint_quad` or a styled `div()` so GPUI can use its standard painting and interaction machinery. ## Three GPUI Kit examples, three ownership choices The drawing primitives are the same, but each GPUI Kit feature keeps its work at a different lifetime. The choice follows where the data lives and how often it changes. ### A model-owned gauge: an Entity keeps geometry A gauge driven by an [Entity](./entity) can keep separate `Option>` values for its background, value arc, and needle. On a value change, clear only the value arc and needle. On an origin change, clear all paths if they contain absolute window coordinates. A `canvas` callback can build missing paths from its bounds during prepaint, then paint them with current theme colors. This keeps geometry invalidation separate from color selection. This pattern fits a view that already owns model subscriptions; avoid calling `cx.notify()` unconditionally from prepaint, because that can schedule an extra render every frame. ### Plot: a value-like element uses keyed window state This cache depends on a stable [ElementId](./element_id) across frames. [`Line`](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/plot/shape/line.rs) is recreated as a value during render. Storing a cache on that value would lose it on the next frame. [`PathCaches::for_paint`](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/plot/path_cache.rs) instead uses `window.use_keyed_state` under the plot's current element ID. `Line::paint_cached` hashes the projected points, stroke width, and curve style; `PathCache::get` tessellates only when the key changes. It builds the path relative to zero, then clones and translates cached vertices to this frame's origin, so scrolling does not trigger tessellation, although translation still costs work. Dots remain cheap quads painted at the new origin. This pattern depends on stable element identity and benefits from using the same slot for the same series across frames; reordering series by index causes avoidable cache misses when their shape keys differ. ### Input: a text editor owns the whole Element pipeline GPUI Kit's [input element](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/input/base/element.rs) owns much more than its selection paths. It measures text, tracks wrapping and viewport geometry, inserts a hitbox in prepaint, then paints selection paths, caret quads, and text from prepared layout. Its input handlers and focus behavior depend on the same geometry. This is why a complex editor needs a custom `Element`: text layout, hit testing, input routing, and drawing must agree on one snapshot. Paths for selections and text range decorations are only part of that pipeline. The progression is useful when choosing an API: use `canvas` for a focused decoration owned by an existing Entity; use keyed window state when a value-like drawing element needs a cache across frames; implement `Element` when layout, text, hit testing, and input must be coordinated directly. See the [Element lifecycle](./element) for the trait methods and the [Event guide](./event) for input propagation. --- # Testing Source: /versions/v0.7.1/docs/test License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/test and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. This guide covers testing GPUI Kit applications and GPUI behavior. Choose the test level from the behavior you need to verify: - Use ordinary Rust `#[test]` for pure data transformations, validation and state transitions. - Use `#[gpui_kit::test]` and `TestAppContext` for [entities](./entity), [actions](./action), [subscriptions](./event) and async [tasks](./task), creating a window when needed. - For UI integration tests, render the production application view, dispatch [events](./event) through `gpui_kit::test`, and check control state, layout and the application result. - Use the separate offscreen renderer for pixel checks, and retain native-window and platform integration tests for those behaviors. GPUI Kit exposes its types and `#[gpui_kit::test]` through the Kit root; applications do not need an additional GPUI dependency. In test modules, import the types you use explicitly: `use gpui_kit::*;` also imports the GPUI `test` macro and can shadow Rust’s ordinary `#[test]`. The complete example below uses explicit imports. ## What is a UI integration test? A **UI integration test** renders real components or an application view in a headless window, simulates clicks, keyboard input and scrolling, then verifies state, focus, layout and application callbacks. For example, a Checkbox test can verify that clicking changes the owner's value and that a disabled Checkbox rejects the same interaction. `#[gpui_kit::test]` runs the test and provides its GPUI context. `gpui_kit::test` supplies the tools to operate and inspect the UI: ```rust use gpui_kit::{TestAppContext, Window}; use gpui_kit::test::TestWindowExt; ``` Use these tests when a behavior depends on components working together, such as entering a value, saving a dialog and checking the result in the parent view. Find controls by [`ElementId`](./element_id), dispatch real GPUI events and assert the outcome with ordinary Rust assertions. This guide covers in-process behavior and layout automation. Element snapshots do not inspect pixels or launch your packaged application. For pixel checks, use GPUI’s separate offscreen renderer as described below. Keep native-window, platform integration and visual checks alongside these tests when those are part of the behavior you need to verify. ## Set up a test project UI testing is part of `gpui-kit`, behind its `test-support` feature. The example below uses a Kit source checkout containing these helpers. There is no additional testing crate, GPUI fork or Cargo patch to install. Prepare the platform dependencies described in [Installation](/versions/v0.7.1/docs/installation). Headless tests still compile GPUI's native dependencies. For a standalone test project next to the checkout, use this layout: ```text workspace/ gpui-kit/ ui-tests/ Cargo.toml tests/ui.rs tests/common/mod.rs ``` Put the following in `ui-tests/Cargo.toml`: ```toml [package] name = "ui-tests" version = "0.1.0" edition = "2024" publish = false [dev-dependencies] gpui-kit = { path = "../gpui-kit/crates/kit", features = ["test-support"] } ``` For an existing application, add this development dependency to its package. Its normal `gpui-kit` dependency must resolve to the same source and version; features then unify for tests. Keep `test-support` in development dependencies so ordinary application builds do not enable observation. An application that uses the component crate directly can enable `gpui-component/test-support`. ## A complete test The source below is the repository's compiled `tests/ui.rs`. It initializes the component library and retains the input state on the view, as a real application should. Its `mod common;` line loads the companion `tests/common/mod.rs` fixture. That fixture calls the public `gpui_kit::open_window` with explicit 640 × 480 bounds, wraps the view in `gpui_kit::base::Root`, and returns both the window handle and the view entity. It is test setup, not an additional library dependency. The test enters a Unicode name, edits it with Backspace, clicks Save, checks the status node's AccessKit role and label plus layout, and verifies the saved application value. The same source is compiled and run in GPUI Kit's integration suite. These assertions do not verify what a screen reader actually announces; check that in the running app on each target platform. ```rust mod common; use gpui_kit::test::{TestSupportExt, TestWindowExt}; use gpui_kit::{ AppContext, Context, Entity, Role, SharedString, TestAppContext, Window, component::{ button::Button, input::{Input, InputState}, }, div, prelude::*, px, size, }; struct Profile { name: Entity, submitted: Option, } impl Render for Profile { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let status = self.submitted.as_ref().map_or_else( || SharedString::from("Not saved"), |name| SharedString::from(format!("Saved: {name}")), ); div() .size_full() .flex() .flex_col() .p_4() .gap_4() .child( div().flex().flex_col().gap_1().child("Profile name").child( Input::new(&self.name) .id("name") .aria_label("Profile name") .w(px(240.)), ), ) .child( Button::new("save") .label("Save") .on_click(cx.listener(|this, _, _, cx| { this.submitted = Some(this.name.read(cx).value()); cx.notify(); })), ) .child( div() .id("status") .role(gpui_kit::Role::Status) .test_support() .aria_label(status.clone()) .child(status), ) } } #[gpui_kit::test] fn saves_a_profile_through_the_ui(cx: &mut TestAppContext) { cx.update(gpui_kit::init); let (handle, profile) = common::open_window(cx, Some(size(px(640.), px(480.))), |window, cx| { let view = cx.new(|cx| Profile { name: cx.new(|cx| InputState::new(window, cx)), submitted: None, }); view }); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); assert_eq!(window.find("status").role(), Some(Role::Status)); assert_eq!(window.find("status").label(), Some("Not saved")); window.click("name", cx); window.input("Ada José", cx); let name = window.find("name"); assert_eq!(name.label(), Some("Profile name")); assert_eq!(name.focused(), Some(true)); assert_eq!(name.value(), Some("Ada José")); assert!(name.bounds().size.width > px(0.)); // Named keys share the same Window API and refresh the resulting frame. window.press("backspace", cx); assert_eq!(window.find("name").value(), Some("Ada Jos")); window.click("save", cx); let status = window.find("status"); assert!(status.visible()); assert_eq!(status.label(), Some("Saved: Ada Jos")); assert!(status.bounds().top() >= window.find("save").bounds().bottom()); }) .unwrap(); // Verify the application result as well as the native properties. cx.update(|cx| { assert_eq!(profile.read(cx).submitted.as_deref(), Some("Ada Jos")); }); } ``` For the standalone layout above, copy **both** files from the Kit checkout; `ui.rs` alone will fail at `mod common;`. From `ui-tests/`, run: ```sh mkdir -p tests/common cp ../gpui-kit/crates/kit/tests/ui.rs tests/ui.rs cp ../gpui-kit/crates/kit/tests/common/mod.rs tests/common/mod.rs cargo generate-lockfile cargo test --test ui --locked ``` Commit `Cargo.lock` with the test project. In your application, import its production view and constructor instead of copying the example's `Profile`; keep the same window setup and interaction pattern. A separate test view can drift from the application. Inside the GPUI Kit checkout, run this exact test with: ```sh cargo test -p gpui-kit --features test-support --test ui --locked ``` ## Choose stable test targets With `test-support` enabled, these controls register their existing native element; observation adds no layout container: | Control | Native properties beyond geometry and visibility | | --- | --- | | Button | Accessibility label, focus scope | | Input | Non-sensitive accessibility value, label, focus scope | | Checkbox | Checked, indeterminate, label, focus scope | | Switch / Toggle | Checked, label, focus scope | | Radio | Checked, selected, label, focus scope | | Tab | Selected, label | | Command | Native option selected state, root focus scope and row bounds | | Combobox | Native expanded state and focus scope; selection verified through events and retained state | | Select | Accessibility value (including title prefix), expanded, focus scope | | ListItem / SidebarMenuItem | Geometry; additional state only when provided by native accessibility properties | | Accordion | Expanded trigger; header and panel bounds | | Tree | Native tree/item roles, label, selected and expanded; root focus scope | | Table / DataTable | Native table parts; DataTable row selection and root focus scope | | DatePicker / Calendar | DatePicker displayed date value, expanded and focus scope; calendar item labels and bounds | | Slider | Track and thumb bounds; numeric accessibility values are not exposed by `ElementSnapshot::value()` | | Stepper | Step and trigger bounds; verify the resulting application content | | Dialog / Sheet | Host focus scope and surface bounds; child controls retain their own properties | | Menu | Item label and selection, menu focus scope and submenu bounds | | Notification | Alert role and bounds; close button uses normal Button observation | | Dock | Area/group/content bounds and focus scopes; tabs retain native selection | Use constructor IDs where available. Input and Select accept `.id("name")`; their defaults include the state entity ID. Tabs inside a TabBar use their index as ID. Select's existing `"input"` child identifies its trigger: `window.within("language").click("input", cx)`. Native divs opt in without supplying a second description of their state: ```rust use gpui_kit::TestSupportExt as _; let target = div().id("details").test_support().child(content); ``` `TestSupportExt` is available without `test-support`; in normal builds `.test_support()` returns the original native element with its exact type. With the feature enabled, it preserves identity, layout, events and accessibility, without adding a layout container. Repeated observation keeps one registration. Call `.test_support()` before `.track_focus(&handle)` so the wrapper sees the actual binding. Kit controls do this internally. `focused()` checks whether that focus scope contains keyboard focus, including the nested editor inside an Input frame. If GPUI advertises focus support but the binding was not observed, `focused()` panics with a diagnostic instead of silently returning `None`. This catches `.track_focus(&handle).test_support()`; implicit `.focusable()` handles are also unavailable, so use an explicit handle. This diagnostic is best effort: it relies on the native accessibility `Action::Focus`. A custom element that omits this action can still return `None` for a missed binding. `None` means neither a binding nor an advertised focus action was observed; it does not prove that the element cannot receive focus. Debug output marks a detected missed binding as `focused: ` without panicking. Snapshots read native `role`, `aria_toggled`, `aria_selected`, `aria_expanded`, `aria_label` and `aria_value`. There are no `TestProps` or hand-supplied fallback values. Input uses its existing accessibility-value path in tests, with the same masking and sensitive-content restrictions. Select's `value()` is its accessible value, including any title prefix; it is not a selected item ID. `label()` means accessibility label, not visible text. `value()` means accessibility value, not pixels. These properties can still contain component bugs. Do not add `aria_label` or `aria_value` solely to make a visual assertion pass. The example's Status role and label serve the production accessibility announcement. Arbitrary child text is not discovered automatically, and there is no `text()` shortcut that substitutes model strings for rendered text. `disabled()` returns `Some(true)` only when the native node exposes its disabled flag; otherwise it returns `None`. GPUI's div API currently cannot expose a known enabled state this way. Test disabled behavior by attempting the interaction and checking that the application result did not change; do not interpret `None` as enabled. There is no reliable positive enabled-property assertion through this API. To verify that a button accepts activation, exercise it and assert its intended result, for example: ```rust window.click("save", cx); assert_eq!(window.find("status").label(), Some("Saved: Ada")); ``` Use the actual expected application result; `assert_ne!(button.disabled(), Some(true))` or `button.disabled().is_none()` does not establish that activation works. IDs only need to be unique within their GPUI identity scope. Window-wide queries panic on ambiguity. Use existing scopes without adding test containers: ```rust window.within("toolbar").click("save", cx); window.within("dialog").click("save", cx); let save = window.within("dialog").within("footer").find("save"); assert!(save.visible()); ``` A parent scope need not itself be observed: its ID is part of its observed children's GPUI paths. `within` requires a unique painted path. Composite row IDs such as `("row", record_id)` preserve record identity after reordering. ## Interact and assert Import `gpui_kit::test::TestWindowExt` for the following methods: | API | Behavior | | --- | --- | | `window.find(id)` | Requires an `ElementSnapshot` from the last completed frame; missing targets panic with registered paths and troubleshooting hints. | | `window.try_find(id)` | Returns `None` when absent; ambiguity still panics. | | `window.click(id, cx)` | Native mouse move/down/up at the target center. | | `window.click_at(id, offset, cx)` | Click at a pixel offset from the target's top-left corner, useful for partial clipping. | | `window.right_click(id, cx)` / `double_click(id, cx)` | Native right-button or two-click sequences. | | `window.hover(id, cx)` | Move the pointer without pressing a button. | | `window.scroll(id, delta, cx)` | Native wheel event; `ScrollDelta` retains GPUI units and sign. | | `window.drag_to(from_id, to_id, cx)` | Resolve both targets and drag between their centers using native hit testing. | | `window.drag(from, to, cx)` | Left-button drag between window-local points, through GPUI drag creation and drop hit testing. | | `window.press("backspace", cx)` | Native key-down/key-up for a named key or shortcut using GPUI's keystroke parser. | | `window.input(text, cx)` | Per-character text input to the current focus; does not focus or replace the whole value. | Scoped queries support `find`, `try_find`, nested `within`, `click`, `click_at`, `right_click`, `double_click`, `hover`, `scroll`, `drag_to`, `press` and `input`. `drag_to` resolves both IDs within the scope. For cross-scope drags or custom offsets, query the targets and pass window-local points to `window.drag`. ```rust let mut dialog = window.within("dialog"); dialog.click("name", cx); dialog.input("Ada", cx); dialog.press("backspace", cx); dialog.hover("help", cx); ``` Scoped keyboard operations do not move focus. They require an observed focus binding inside the scope; otherwise they panic before dispatch. `input` checks before every character, so a handler moving focus outside the scope cannot redirect the remaining text. Use `window.press` for deliberate window-wide shortcuts. For custom input controls, register the actual focus-bearing element with `.id("editor").test_support().track_focus(&focus_handle)`, using its real focus handle. An unobserved input, or an observed outer container without a tracked handle, cannot satisfy this check even if keyboard focus is physically inside the scope. Window-level `input` and `press` dispatch to the current focus without this scope guarantee. Scoped input shares the window input loop: one initial refresh, then one refresh per character, with scope checks against each completed frame. `ElementSnapshot` is an owned, immutable record of a completed paint. Its readers are `role()`, `path()`, `bounds()`, `visible()`, `focused()`, `disabled()`, `label()`, `value()`, `checked()`, `indeterminate()`, `selected()` and `expanded()`. Focused, disabled, checked, indeterminate, selected and expanded readers return `Option`: `None` means unavailable, not false. Label/value are also optional. Re-query after interactions: ```rust let before = window.find("agree"); window.click("agree", cx); assert_eq!(before.checked(), Some(false)); // The original frame. assert_eq!(window.find("agree").checked(), Some(true)); // The new frame. ``` Assert native properties and application results together. Checking saved model state or an emitted result is a useful part of an integration test; it should not replace verifying the relevant visible control state. Command presses, including Enter, must not inject newline text through an IME callback. Text input does not model complete OS IME composition. Masked inputs report no value; verify sensitive results through application state. ## Complete the frame before querying Call `window.render_frame(cx)` before the first query and after direct external state/focus changes or resizing. Interaction helpers refresh around synchronous dispatch, including `press`. They cannot finish deferred callbacks while the surrounding window update is still borrowed. ```rust cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window.click("name", cx); window.input("Ada", cx); window.press("backspace", cx); assert_eq!(window.find("name").value(), Some("Ad")); }).unwrap(); ``` Use `TestAppContext::update_window`; typed `WindowHandle::update` already borrows the root entity and cannot safely redraw it in the same callback. For asynchronous work or deferred selection commits, use an async `#[gpui_kit::test]` and wait **outside** the window update: ```rust use gpui_kit::test::TestAppContextExt; use std::time::Duration; cx.wait_for(handle.into(), Duration::from_millis(200), |window, _| { window.try_find("result").is_some_and(|snapshot| snapshot.visible()) }).await; ``` `wait_for` refreshes frames and polls every 10 ms using GPUI's test executor clock, with registered paths in timeout errors. This is a bounded condition wait, not an OS event loop or a network-service simulator. Provide controlled responses for external dependencies. A parked executor alone does not imply that timers or deferred work have completed. GPUI `dispatch_action` queues work. Complete the dispatch (for example by leaving `update_window` and running `cx.run_until_parked()`) before editing values that the action will read. Use `wait_for` for the resulting state or timer completion. Legacy non-synced GPUI `Animation` uses wall-clock `Instant`; advancing the test clock does not finish it. The Sheet/Notification geometry tests wait their actual entrance durations before asserting final bounds. Base motion can instead honor the public `cx.set_reduce_motion(true)` preference when testing final disclosure geometry. Snapshots never update in place. Cached views keep their painted facts until invalidated. Unmounted targets disappear after the frame releasing their element state; virtualized rows become queryable when painted after scrolling. ## Coverage and failure cases The repository covers the following component workflows through real input, native properties and resolved bounds. These are concrete regression contracts, not a claim that every option or combination of every component has been exhaustively tested. | Suite | Behavior exercised | | --- | --- | | `test_macro.rs` | Published `#[gpui_kit::test]` sync/async compatibility alongside ordinary Rust tests; the independent Kit-only recipes package runs the same contract | | `input.rs` and `input/` | Input, Textarea and Editor editing, clipboard, selection, history, read-only transitions, Unicode, multiline viewport behavior, search/replace, completion acceptance and retained state across renders | | `input_focus.rs` | Repeated Tab/Shift-Tab traversal with passive addons, addon button focus and activation, and Textarea/Editor body-click focus followed by editing | | `search.rs` | Command disabled-item skipping, wraparound, Unicode keywords, empty results, Action dispatch and original-index callbacks, two-stage Escape; Combobox search, single/multi selection, clearing, empty-result recovery, disabled behavior and exactly one Confirm on close | | `disclosure.rs` | Accordion exclusive expansion/collapse and actual panel geometry; Stepper content navigation; disabled disclosure/steps; Slider track click, thumb drag and disabled behavior | | `collections.rs` | Tree pointer expansion, keyboard collapse/expansion and selection; DataTable row selection, keyboard virtualization and wheel scrolling | | `date_picker.rs` | Opening, exact preset/day selection, month navigation, clearing, Escape and disabled behavior | | `overlays.rs` | Dialog validation → scoped Input → save → Notification; hover-revealed close; auto-dismiss timer; Dialog/Sheet Escape and focus restoration; surface bounds | | `menu.rs` | Disabled items, keyboard confirmation, Escape, focus restoration, submenu hover and nested item activation | | `dock.rs` | Tab selection/reordering, cross-group drag/drop, zoom and restored split geometry | The existing form, Select, HoverCard, virtual-list, pointer, lifecycle and isolation suites remain in place. Pure presentation components need geometry or pixel assertions, not invented interaction state. Custom parts register their existing native elements; unsupported properties remain unavailable, with no manual test-only override. The [Input regression example](https://github.com/longbridge/gpui-kit/tree/main/crates/kit/tests/input) shows how to turn a manual editing sequence into a repeatable UI test. From the repository root, run both editing and focus targets, or select one workflow: ```sh script/test-input # Complete Input gate: Base, Component and Kit workflows (Bash). cargo test -p gpui-kit --features test-support --test input --test input_focus --locked cargo test -p gpui-kit --features test-support --test input --locked -- history::paste_is_atomic_and_separate_from_surrounding_typing --exact cargo test -p gpui-kit --features test-support --test input_focus --locked -- reverse_tab_cycles_three_inputs_with_passive_addons --exact ``` Append `-- --list` to the combined command to list cases without executing them. These commands are reproduction instructions, not recorded passing results. Report the revision, platform, command and observed result for each run. Example workflows include typing → paste → typing → Undo/Redo, Textarea Enter submission versus Shift-Enter insertion, and Editor completion → acceptance → Undo. Each checks fresh snapshots plus public state or owner events where needed. Completion responses come from a deterministic provider, not a live language server. The suite also exercises the public IME handler protocol (preedit, UTF-16 ranges, commit/cancel and history), multi-cursor editing, folding, provider cancellation and failure. Its operation matrix is the review checklist for ordinary input changes; add a regression for the changed interaction and require platform CI. The separate `input_focus` target exercises focus callbacks after window updates. These cases do not establish full OS IME, accessibility action, system clipboard or pixel correctness; use the corresponding platform checks for those boundaries. Views that open dialogs, sheets or notifications through `WindowExt` need a `Root` as the window's root view. `Root` always renders all three overlay layers above application content, including cached views. No manual layer mounting is needed. Use `within` for repeated controls. A Sheet's `"sheet"` host scope contains its `"sheet-content"` surface; Dialog's `"dialog"` scope contains the layer-indexed surface. Nested menus also contain a `"popup-menu"`, so retain the resolved parent scope when opening a submenu, or query under `"submenu"`. Do not assume a previously unique ID remains unique after another layer opens. Missing or invisible click targets panic. Disabled controls receive real events and decide whether to respond. Visibility combines geometry, viewport/content clipping and the target's computed style; it does not detect pixel occlusion. Overlays can intercept clicks. `click_at(id, point(px(10.), px(10.)), cx)` can choose a visible portion of a clipped target without bypassing hit testing. Test instrumentation is feature-gated, so the test build is not byte-identical to a production build. The transparent wrapper adds no layout box, but visibility inspection computes style an additional time; style/drag predicates must not rely on call counts. GPUI does not expose inherited paint opacity from an unobserved ancestor. No GPUI fork or Cargo patch is used to bypass these limitations. On failure, check the reported paths, observation, completed frame, keyboard focus, clipping/overlays and asynchronous completion, in that order as relevant. | Symptom | First check | | --- | --- | | `mod common` cannot be found | Copy `tests/common/mod.rs` beside the included `tests/ui.rs`, or replace the fixture call with your application's window setup. | | `find` lists no matching path | Confirm `test-support`, the control's ID or `.test_support()`, and an initial `render_frame`. | | A query is ambiguous | Resolve an existing parent with `within`, then query its child ID. | | `focused()` reports a missed binding, or scoped `input` panics | Observe the element before `.track_focus(&handle)` and click the intended input before typing. | | An assertion still sees the old state | Query a fresh snapshot after a completed frame; for queued work, leave `update_window` and use `wait_for`. | | A visible target does not receive the click | Inspect clipping and overlay order; pointer helpers use native hit testing. | ## Verify rendering independently A correct value or checked flag does not prove the control was drawn correctly. GPUI exposes `HeadlessAppContext::with_platform`, `Window::render_to_image` and `HeadlessAppContext::capture_screenshot` for real offscreen images. The currently pinned platform crate supplies its headless renderer on macOS (Metal) only. Run this target on a Mac with Metal available: ```sh cargo test -p gpui-kit --features test-support --test rendering --locked ``` The target uses `test = false`, so the default Cargo command does not select it. The macOS CI job explicitly runs `--test rendering` as a required step, alongside the portable interaction suite. Linux and Windows run only the portable suite. Cargo supports this [explicit target selection](https://doc.rust-lang.org/cargo/commands/cargo-test.html#target-selection). It also uses `harness = false` because AppKit initialization requires the main thread; `--test-threads=1` would still run an ordinary Rust test on a worker thread. On other platforms it explicitly reports that pixel verification is skipped. Missing renderer support on macOS fails rather than substituting a fake image. The tests inject two defects into real Kit controls: a missing check-mark asset while `checked()` remains true, and transparent input text while `value()` remains correct. Images must differ from the working control, and repeated working checkbox renders must match. A separate native-event test disconnects a checkbox's change handler and checks that clicking cannot fabricate a checked result. These are sensitivity checks, not a complete golden-image suite. For application visual regression, compare images against reviewed expectations under controlled fonts, dimensions, theme, focus and animation state. State assertions and image assertions detect different defects; neither establishes packaged-app or full IME correctness. The executable rendering examples are in [`crates/kit/tests/rendering.rs`](https://github.com/longbridge/gpui-kit/blob/testing/crates/kit/tests/rendering.rs). ## Run in CI The Kit repository runs the interaction/layout suite on macOS, Linux and Windows. The macOS job additionally runs the two Metal pixel checks; a failure fails the job. A minimal macOS workflow for a Kit checkout is: ```yaml name: UI tests on: [push, pull_request] jobs: test: runs-on: macos-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - run: ./script/bootstrap - run: cargo test -p gpui-kit --features test-support --locked - run: cargo test -p gpui-kit --features test-support --test rendering --locked ``` For an application repository, install its platform dependencies and run `cargo test --test ui --locked` in its test package instead. Make the pinned Kit source available at the paths declared in its manifest. Add Linux and Windows jobs using the same system setup as your normal native builds. The repository suite also covers read-only/disabled inputs, focus changes, cached views, mount/unmount, window isolation, native hit testing, and cleanup when a 1,000-element list shrinks. That large-list case checks correctness; it is not a rendering performance benchmark. --- # WebView Source: /versions/v0.7.1/docs/webview License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/webview and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. [`gpui-wry`](https://github.com/longbridge/gpui-kit/tree/main/crates/webview) is GPUI Kit's **experimental** integration with [Wry](https://github.com/tauri-apps/wry). Use it when a screen needs browser behavior; [TextView HTML](/versions/v0.7.1/component/text-view#html) renders document content but is not a browser. To open a URL in the user's default external browser, use [`cx.open_url`](./context#open-a-url-in-the-default-browser). The integration currently supports macOS and Windows. The Linux path in the repository's example is unfinished. ## Run the example From the repository root: ```sh cargo run -p webview ``` The [complete example](https://github.com/longbridge/gpui-kit/blob/main/examples/webview/src/main.rs) is the runnable starting point. It creates the child view inside the `open_window` callback, wraps it in an `Entity`, and renders that Entity below an address input. Enter in the input calls `load_url`; the example also contains a back handler. Run it from the repository root with the command above. For another application, match the dependency versions in [the example manifest](https://github.com/longbridge/gpui-kit/blob/main/examples/webview/Cargo.toml): `gpui-kit`, `gpui-wry`, `wry` (package `lb-wry`), and `raw-window-handle` are direct dependencies of this integration. ```rust use gpui_kit::*; use gpui_wry::WebView; let webview = cx.new(|cx| { use raw_window_handle::HasWindowHandle; let handle = window.window_handle().expect("No window handle"); let native = wry::WebViewBuilder::new() .build_as_child(&handle) .expect("Failed to create WebView"); WebView::new(native, window, cx) }); webview.update(cx, |view, _| view.load_url("https://gpui-kit.com")); // In the owning View's render method: div().flex_1().child(webview.clone()) ``` This excerpt shows the macOS and Windows child-view path. Create the native view only after GPUI supplies a live `Window`, and keep the `Entity` in its owning view so it survives renders. The example calls `gpui_kit::init(cx)` before creating component state and uses `gpui_kit::open_window(...)` to create the window. For the complete application setup, use the linked source rather than treating this excerpt as a standalone `main` function. ## Own the view and its layout `WebView::new` initially sets the native bounds to an empty rectangle. Rendering the entity installs a GPUI layout element; during `prepaint`, that element sends its resolved bounds to Wry in **logical coordinates** and inserts a GPUI hitbox. Give the containing region a real size. The example uses `div().flex_1().h(px(400.)).child(self.webview.clone())`. The browser pixels themselves are painted by the operating system, so GPUI clipping, hitboxes, and content masks do not put GPUI UI above the child view. Keep one `Entity` for each native view. Do not build a new Wry view on each `render`. The wrapper's `visible()` and `bounds()` report its stored visibility and last layout bounds; `show()` and `hide()` change native visibility. If a page or tab no longer renders the entity, explicitly call `hide()` when it should disappear, and `show()` when it returns. `prepaint` skips bounds updates while hidden. Call wrapper methods through `webview.update(cx, |view, _| ...)` on the GPUI UI context. `load_url(&str)` requests navigation but discards Wry's `Result`; it does not report load completion. `back()` runs JavaScript `history.back()` and returns a `Result` for script submission, not a history or navigation result. Use `view.raw()` for Wry methods such as `reload()`, `url()`, or `evaluate_script()` when their return values matter. `view.handle().raw()` gives an owned, UI-thread-local Wry handle when a short-lived callback needs one. Avoid keeping a handle merely to avoid updating the entity. ## Loading, navigation, and page messages Configure page policy and callbacks on `wry::WebViewBuilder` **before** `build_as_child`; these are Wry facilities, not `gpui-wry` events. The pinned `lb-wry` version provides: | Need | Wry API | Meaning | | --- | --- | --- | | Initial content | `with_url(...)` or `with_html(...)` | Select content before building; the repository example instead calls wrapper `load_url` after creation. | | Loading status | `with_on_page_load_handler(|event, url| ...)` | Observe `Started` and `Finished`; `Finished` is not a success or HTTP-status report. Treat this separately from whether `load_url` accepted a request. | | Navigation policy | `with_navigation_handler(|url| -> bool { ... })` | Return `true` to allow or `false` to cancel an incoming navigation. Decide policy for redirects and links as well as the initial URL. | | New windows | `with_new_window_req_handler(...)` | Decide what happens to `window.open` requests; its callback has a platform-specific thread contract. | | Rust to page | `evaluate_script(...)` on the raw Wry view | Submits JavaScript and returns a Wry `Result`; use `evaluate_script_with_callback` when a serialized result is needed. | | Page to Rust | `with_ipc_handler(|request| ...)` | Receives strings posted by `window.ipc.postMessage(...)`. Parse and validate the request before acting on it. | For example, a fixed-URL navigation policy can be attached while constructing the builder: ```rust let builder = wry::WebViewBuilder::new() .with_navigation_handler(|url| url == "https://gpui-kit.com/") .with_on_page_load_handler(|event, url| { // Forward a small status message to the owning GPUI view if needed. // Do not capture &mut Window, &mut App, or &mut Context here. let phase = match event { wry::PageLoadEvent::Started => "started", wry::PageLoadEvent::Finished => "finished", }; eprintln!("{phase}: {url}"); }); ``` This exact-match check is only an illustration of the callback shape; use parsed URL origin and an explicit scheme/host policy for a real allowlist. Keep callback work short. Pass status or IPC messages into application-owned state through a safe scheduling/channel path, then update the `Entity` on GPUI's context and call `cx.notify()` if the UI changes. Wry callbacks are not GPUI entity events, and the Windows new-window callback runs on a separate thread. Plan for callbacks arriving after a page has navigated or its owning window has closed. ## Security and failure boundaries Treat page content as untrusted, including content served from a URL you control. IPC is an application capability: allow only expected message types and payload sizes, check the sender origin where the platform supplies it, and require application authorization before file, network, or account actions. Wry's IPC request URL is the main-frame URL for iframes on Linux/Android, so do not use it alone as iframe identity. Initialization scripts can run on each new page; the pinned Wry documentation says Windows also injects them into subframes, even when main-frame-only is requested. Avoid placing secrets in scripts or page globals. Decide how downloads, external links, and `window.open` requests are handled before displaying remote content. Wry's default download-start handler allows downloads, so add a download policy if that is inappropriate for the application. Keep user-visible loading and error state in the owning GPUI view: `gpui-wry::WebView::load_url` ignores immediate errors, and a successful request can still lead to a failed page load. Use raw Wry results and page-load callbacks as separate signals; neither `PageLoadEvent::Finished` nor `load_url` alone proves successful content loading. Show a retry path for failures your application detects. ## Focus and lifetime The WebView is a **native child view**, not a GPUI-painted Element. It occupies the bounds of its GPUI layout node and receives native browser input. `WebView` implements `Focusable`; its wrapper tracks a `FocusHandle`. Calling `hide()` returns focus to the parent before hiding the native view. Clicking outside the view's bounds also requests focus on the parent. Test keyboard and focus behavior for your own screen, especially when it combines GPUI inputs with browser inputs. Keep the WebView and its handles within the parent window's lifetime. Dropping the owning Entity hides the child view, but cloned `WebViewHandle`s or frame-held clones can delay native destruction. Drop those handles before destroying the parent window. See [Entity](./entity) for state ownership and [Window](./window) for window-local handles. ## Debug and verify The example requests Wry devtools in debug builds (or with its `inspector` feature); it does not open them automatically. On macOS release builds, Wry also requires its own `devtools` feature for this request to take effect. Wry's `open_devtools()` is available behind debug or that dependency feature. Inspect the page to distinguish JavaScript/network failures from GPUI layout issues. For a blank child view, first verify the containing GPUI element has nonzero bounds, that `visible()` is true, and that the native view was created from the current live window. On Windows, check the example's DirectComposition setting below. Confirm navigation and IPC in a real native window; GPUI's headless UI tests cannot prove native browser pixels, system focus, or compositor order. For an application smoke test on each supported OS, exercise initial load, links and redirects, back and reload, new-window/download policy, failed/offline load, IPC input validation, resize, show/hide, keyboard focus transfer, and closing the parent window. Keep pure policy parsing tests separate from these native checks. The repository example demonstrates loading and layout but does not implement a complete navigation, bridge, or error-state test suite. ## Current limitations | Area | Current behavior | | --- | --- | | Platforms | Experimental macOS and Windows support. The Linux example contains an unfinished GTK hosting path; do not treat it as supported. | | Overlay order | The native WebView sits above the GPUI surface and covers GPUI content in the same rectangle, including popovers, dialogs, menus, and tooltips. A GPUI overlay cannot reliably appear on top of it. | | Windows renderer | The repository example sets `GPUI_DISABLE_DIRECT_COMPOSITION=true` before starting GPUI so this child-view approach renders. This is an example-specific requirement, not a general GPUI setting recommendation. | When an overlay must be visible, place the WebView in a separate window or arrange the screen so the overlay does not cross its bounds. Do not present a normal GPUI overlay over the WebView as a supported interaction in the current implementation. ## Unmerged composition experiments The following PRs explore overlay composition. **None is part of the current `gpui-wry` behavior described above.** Check their status and implementation before using a branch in an application: - [GPUI Kit #2626](https://github.com/longbridge/gpui-kit/pull/2626) explores drawing GPUI overlays above the native WebView. Its current branch depends on [Zed/GPUI #61945](https://github.com/zed-industries/zed/pull/61945), an opt-in layered scene for deferred GPUI overlays. The GPUI Kit PR validates the macOS path; Windows composition and Linux hosting remain follow-up work in that PR. - [Zed/GPUI #62379](https://github.com/zed-industries/zed/pull/62379) proposes a separate, broader opt-in `CompositionTree` for ordering GPUI and native surfaces, with macOS and Windows examples. It is an alternative to #61945, **not** the dependency of GPUI Kit #2626. Its Linux composition path is outside that PR's scope. These experiments may change before merge. They explain the intended direction; they do not remove today's platform, overlay, or focus limitations. --- # Event Source: /versions/v0.7.1/docs/event License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/event and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. GPUI provides **Event** as a typed notification mechanism between [Entities](./entity). An Event reports something that already happened; unlike an [**Action**](./action), it does not use Focus, Key Contexts, KeyBindings, or the Dispatch Path. GPUI also calls raw mouse and keyboard input values “events”; those follow different rules, covered below. ## Action in, Event out An Action can cause the state change, but Event delivery starts after that change: ```text Chat changes state → emit(MessageSent) → subscribers receive Event → Workspace updates ``` Chat emits one MessageSent Event to independent Workspace, Activity Log, and Telemetry subscribers Chat emits one MessageSent Event to independent Workspace, Activity Log, and Telemetry subscribers - [**Action**](./action) carries intent inward: “send this message.” - **Event** reports the result outward: “this message was sent.” The command owner handles the Action and changes its state. It then emits an Event so owners or services can react without being coupled to the command's UI entry point. See [Action](./action) for Focus and command dispatch, and [KeyBinding](./keybinding) for shortcut matching. ## A complete first Event This small application has two Entities. `Chat` owns the count and emits a typed fact. `Workspace` owns the `Chat` handle, subscribes to that exact Entity, and renders the latest count. Clicking the button updates `Chat`; the subscription then updates `Workspace`. Use the project from [Getting Started](./getting-started), which already depends on `gpui-kit`. Replace its `src/main.rs` with this complete program and run `cargo run` from that project directory. The window should start at **Messages sent: 0**; each click on **Send** should increase the count by one. ```rust use gpui_kit::*; use gpui_kit::assets::Assets; use gpui_kit::component::button::Button; #[derive(Clone, Debug)] enum ChatEvent { MessageSent { total: usize }, } struct Chat { sent: usize, } impl EventEmitter for Chat {} impl Chat { fn send(&mut self, cx: &mut Context) { self.sent += 1; cx.emit(ChatEvent::MessageSent { total: self.sent }); } } struct Workspace { chat: Entity, shown_total: usize, _subscriptions: Vec, } impl Workspace { fn new(cx: &mut Context) -> Self { let chat = cx.new(|_| Chat { sent: 0 }); let subscription = cx.subscribe(&chat, |workspace, _chat, event, cx| { match event { ChatEvent::MessageSent { total } => workspace.shown_total = *total, } cx.notify(); // Workspace's visible count changed. }); Self { chat, shown_total: 0, _subscriptions: vec![subscription], } } } impl Render for Workspace { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .p_4() .child(format!("Messages sent: {}", self.shown_total)) .child(Button::new("send").label("Send").on_click(cx.listener( |workspace, _, _, cx| { workspace.chat.update(cx, |chat, cx| chat.send(cx)); }, ))) } } fn main() { gpui_kit::application().with_assets(Assets).run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(Workspace::new) }) .expect("failed to open window"); }); } ``` The bootstrap calls `gpui_kit::init` before opening the window; `open_window` supplies the application window. The button's callback updates `Chat`, where the state mutation and `cx.emit` happen together. The subscriber reads the Event payload and calls `cx.notify()` because the value rendered by `Workspace` changed. `cx.emit(...)` queues an effect. GPUI delivers it after the current entity update can finish; the subscription callback is not a direct call inside `Chat::send`. Emit only after the operation succeeds. If the emitting Entity also renders changed state, call `cx.notify()` in that Entity as well: `emit` does not request a render. A model Entity such as `Chat` above has no view to redraw. Name Event variants as facts, such as `MessageSent`, `Saved`, and `Dismissed`; `SendMessage` names a command. ### Connect the Action to this Event The complete example above begins at a button callback. To exercise the full [Action](./action) → state → Event → subscription route, edit the **same** `src/main.rs` in four places. Keep `Chat`, `ChatEvent`, `Chat::send`, and the existing subscription callback. 1. After the imports, add `actions!(chat, [SendMessage]);`. Add `focus: FocusHandle,` to `Workspace` beside its `chat` field. 2. Change `Workspace::new` to accept `window: &mut Window` before `cx`. Before the existing `let chat = ...` line, create and focus the handle, then add `focus,` to the returned `Self`: ```rust let focus = cx.focus_handle().tab_stop(true); focus.focus(window, cx); ``` 3. Add this method to `impl Workspace`: ```rust fn on_send_message(&mut self, _: &SendMessage, _: &mut Window, cx: &mut Context) { self.chat.update(cx, |chat, cx| chat.send(cx)); } ``` In `render`, add the following three calls to the **outer** `div()` before its children, then replace only the button's `.on_click(...)` with the callback below: ```rust .track_focus(&self.focus) .key_context("Chat") .on_action(cx.listener(Self::on_send_message)) .on_click(cx.listener(|workspace, _, window, cx| { workspace.focus.dispatch_action(&SendMessage, window, cx); })) ``` 4. In `main`, after `gpui_kit::init(cx)`, bind Enter. Change the `open_window` closure to pass its window to the constructor: ```rust cx.bind_keys([KeyBinding::new("enter", SendMessage, Some("Chat"))]); gpui_kit::open_window(WindowOptions::default(), cx, |window, cx| { cx.new(|cx| Workspace::new(window, cx)) }) .expect("failed to open window"); ``` Run `cargo run` again. Press **Enter** while the Workspace region has focus, then click **Send**. Each input dispatches the same `SendMessage` Action to `Workspace::on_send_message`. That handler updates `Chat`; `Chat::send` increments `sent` and emits `MessageSent`; the retained subscription updates `Workspace::shown_total`. The visible count should rise once per input. As a check, temporarily remove `cx.emit(...)` from `Chat::send`: the internal `sent` value still rises, but the displayed count stops changing because `Workspace` listens for the Event. Restore the emit call before the next exercise. If Enter does nothing, check that the focus handle is attached to the rendered container and its `Chat` Key Context is present; if the button does nothing, inspect the Action handler and dispatch path. ## Subscription lifetime and ownership `cx.subscribe(&chat, callback)` returns a `Subscription`. Keep it in the subscribing owner, as `Workspace` does above. Dropping the handle disconnects the callback, so a local handle that disappears at the end of `new` silently stops delivery. Dropping `Workspace` also drops its handles. An `Entity` clone only identifies the source; retaining it does not retain a subscription. Keep view-specific subscriptions with the view instead of placing them in long-lived global state. The callback arguments are `&mut Workspace`, the emitting `Entity`, `&ChatEvent`, and `&mut Context`. The payload is borrowed for the callback; copy or clone data that must outlive it. The subscription is tied to one source Entity and one Event type, not every `Chat` or every event in the application. Multiple owners may subscribe independently to the same source. To stop one subscription early, remove or drop its handle from the owner's collection. Use `cx.subscribe_in(&chat, window, callback)` when the handler also needs `&mut Window`. Its callback receives five arguments: owner, `&Entity`, `&ChatEvent`, window, and context; keep its returned `Subscription` just as above. Use `cx.observe(&chat, ...)` for a generic entity-change notification when no typed Event payload is needed. An Event is deliberate semantic information; `cx.notify()` reports that an Entity needs an update and does not produce a `ChatEvent`. If a subscription seems silent, check that the source is the same Entity, the `EventEmitter` implementation matches the emitted type, the handle is still stored, and `cx.emit` is reached after a successful state change. If the callback runs but the screen stays stale, check which Entity renders the changed value and call `cx.notify()` on its context. ### Try `observe` without an Event payload Restore the original complete `Chat`/`Workspace` example above if you made the Action continuation, then run it: each click raises the visible count by one. Now make these three changes to that application: 1. Remove the `ChatEvent` enum and `impl EventEmitter for Chat {}`. Replace `Chat::send` with the following method. This version changes state and calls `notify`; it emits no Event. ```rust fn send(&mut self, cx: &mut Context) { self.sent += 1; cx.notify(); } ``` 2. In `Workspace::new`, replace the `cx.subscribe(...)` block with this observer. Keep the returned handle in the existing `_subscriptions` field. ```rust let subscription = cx.observe(&chat, |workspace, chat, cx| { workspace.shown_total = chat.read(cx).sent; cx.notify(); }); ``` 3. Run the application again and click **Send**. The count still rises. The observer receives the changed `Entity` and reads its state; it receives no `ChatEvent` or payload. Remove `cx.notify()` from `Chat::send` once and rerun: the observer no longer fires, so the count stays at zero even though `Chat.sent` changes. Restore `cx.notify()` afterward. `observe` is useful when any notification from a particular Entity is enough and the observer can read current state. `subscribe` is useful when the producer deliberately reports a typed fact. Both return a `Subscription` that the owner must retain; `cx.emit` alone does not notify `observe` callbacks, and `cx.notify()` alone does not emit a typed Event. **INFO — Event delivery does not follow Focus** An Event goes to subscribers of its source Entity. Moving Focus or changing a Key Context does not change who receives it. Do not use Events as a global command bus to bypass Action routing. ## Action or Event? | Question | Use | Examples | | --- | --- | --- | | Is this an instruction a user or caller wants performed? | **Action** | Save, Delete, Open Search | | Should it be bindable to a key or shown in a menu? | **Action** | Copy, Toggle Sidebar, Rename | | Is this a fact reported after state or lifecycle changed? | **Event** | ValueChanged, Saved, Dismissed | | Should an owner observe a child independently of its UI tree? | **Event** | Input changed, row selected, dialog submitted | | Is it only a pointer gesture with no other command entry point? | callback | hover, drag delta, pointer position | Use both when a command produces a fact other parts of the application need to observe: handle the Action first, commit the state change, then emit the Event. ## Pointer and keyboard input are also events `MouseDownEvent`, `MouseUpEvent`, `MouseMoveEvent`, `ScrollWheelEvent`, `KeyDownEvent`, and `KeyUpEvent` describe raw input. They are distinct from the typed `EventEmitter` notifications above. A normal `div()` can register `.on_mouse_down(MouseButton::Left, ...)` or `.on_key_down(...)`; its `InteractiveElement` implementation handles the underlying hitbox and dispatch registration. Use [Action](./action) for an operation that needs a shortcut or menu entry, and raw events when positions, buttons, modifiers, or gesture deltas matter. Raw input callbacks do not create a typed entity Event unless the owning entity calls `cx.emit(...)`. For example, [GPUI Kit's TimeField](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/time_field.rs) binds arrow-key **Actions** inside its own Key Context, handles a typed `KeyDownEvent` for digit input, and emits `TimeFieldEvent::Change` only after the time value changes. Its owner can subscribe to that event without knowing whether the change came from a key or another control. A matching KeyBinding can consume a key before a raw `on_key_down` handler receives it, so commands belong in Actions rather than duplicate raw key handlers. A digit handler follows this shape: ```rust fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { let stroke = &event.keystroke; if stroke.modifiers.modified() || stroke.key.chars().count() != 1 { return; } let Some(digit) = stroke.key.chars().next().and_then(|c| c.to_digit(10)) else { return; // Leave unrelated keys to the rest of the UI. }; window.prevent_default(); cx.stop_propagation(); if self.editor.input_digit(digit) { cx.emit(TimeFieldEvent::Change(self.editor.time)); } cx.notify(); } ``` ### Capture and bubble Raw input has two dispatch phases. **Keyboard** listeners follow the focused element's path: capture walks from root to focused node; bubble returns from focused node to root. **Mouse** listeners are registered in paint order rather than on that ancestry path: capture runs back to front, and bubble runs front to back. The dispatcher calls matching mouse listeners in that order; a low-level listener must check its own hitbox before acting. Normal `.on_mouse_down(...)` and `.on_key_down(...)` callbacks run in bubble; `.capture_any_mouse_down(...)` is an element-level capture hook. ### Try a nested pointer handler Return to the original Event version of the complete example above. Add `parent_hits: usize` and `child_hits: usize` to `Workspace`, initialize both to `0` in `Workspace::new`, and replace its `Render` implementation with this one: ```rust impl Render for Workspace { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .p_4() .child(format!("Messages sent: {}", self.shown_total)) .child(Button::new("send").label("Send").on_click(cx.listener( |workspace, _, _, cx| { workspace.chat.update(cx, |chat, cx| chat.send(cx)); }, ))) .child(format!( "Parent: {} | Child: {}", self.parent_hits, self.child_hits )) .child( div() .p_4() .bg(rgb(0xd0d7de)) .on_mouse_down(MouseButton::Left, cx.listener(|workspace, _, _, cx| { workspace.parent_hits += 1; cx.notify(); })) .child("Parent surface") .child( div() .p_4() .bg(rgb(0x8ecae6)) .on_mouse_down(MouseButton::Left, cx.listener( |workspace, _, _, cx| { workspace.child_hits += 1; cx.notify(); // Uncomment to stop the parent handler: // cx.stop_propagation(); }, )) .child("Child surface"), ), ) } } ``` Click the blue **Child surface** once: both counters increase. The child listener is registered later in paint order and runs first in mouse bubble; the parent hitbox also contains that point, so its listener runs next. Click the gray area outside the child: only the parent counter increases. Uncomment `cx.stop_propagation()` in the child callback and rerun. Clicking the child now increases only its counter; clicking the gray area still increases only the parent counter. A click on **Send** changes the message count without changing either hit counter. The two surfaces are nested for this exercise, but GPUI mouse dispatch uses the window frame's ordered listener list and each listener's hitbox. It does **not** route mouse input along a DOM-style ancestor chain. An overlapping surface painted later can be the first bubble listener even when it is not a child in the element tree. Stop propagation only when the child interaction must keep later listeners from acting on the same input. Custom [`Element`](./element#the-three-phases) code can register a listener during `paint` with `window.on_mouse_event` and inspect `DispatchPhase`: ```rust window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { if phase.capture() && hitbox.is_hovered(window) { // Decide whether this surface owns the gesture. if event.button == MouseButton::Left { cx.stop_propagation(); } } }); ``` This listener is registered during `paint` and is replaced when the next frame is rendered. A `Hitbox` should have been inserted during `prepaint`. GPUI Kit's [Carousel scroll mask](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/carousel/scroll_mask.rs) uses capture for pointer and wheel gestures: it consumes movement on the carousel's axis while letting movement on the other axis reach an outer scroller. `Hitbox::is_hovered` tests pointer location; `should_handle_scroll` also accounts for scroll occlusion. Prefer fluent element handlers for ordinary controls; use `window.on_mouse_event` when building a custom Element that needs its own hitbox or phase handling. ### `stop_propagation` versus `prevent_default` | Call | Meaning | Typical use | | --- | --- | --- | | `cx.stop_propagation()` | Stop delivery to later listeners in the **current dispatch**. In mouse bubble this blocks surfaces behind the current one; in keyboard bubble it blocks ancestors. In capture it also prevents the remaining capture listeners and bubble phase. | A nested control consumed a drag or key. | | `window.prevent_default()` | Mark the current input event's default behavior as prevented. GPUI uses this for built-in behavior such as parent focus acquisition on mouse down. | A child handles mouse down but should keep the parent's focus from moving. | They are independent. Stopping propagation does not itself cancel the focus default; preventing the default does not itself stop another handler. GPUI resets both flags for each input dispatch. `prevent_default` controls GPUI behavior that checks this flag; do not treat it as a general browser-style or operating-system event cancellation. Call either only after deciding that this input belongs to the control. An Action has the reverse initial propagation policy from raw input: its handler stops bubbling by default, and `cx.propagate()` explicitly lets an ancestor try it. Input callbacks receive `&mut Window`; call `window.prevent_default()` there. When debugging an input handler, check the event phase, focused path, content mask, hitbox behavior, and z order. A handler may be registered correctly but never see a point because a higher surface occludes it or because the event follows a different focus path. --- # Icons & Assets Source: /versions/v0.7.1/docs/assets License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/assets and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. GPUI Kit exposes [IconName] and [Icon] through `gpui_kit::assets` and `gpui_kit::component`. The default `gpui-kit` features make both available, but the application must register an `AssetSource` to load icons by path. The underlying [gpui-kit-assets] crate keeps the SVG payloads separate from the component code: register the default `Assets`, select extra icons, or provide your own source. **NOTE — Depending on the crate does not embed every icon** **The complete catalog does not make existing applications embed every icon.** `Assets` keeps the original 101 component icons. Applications provide additional icons through their own `AssetSource`, as before; they do not need to redeclare the component icons. Only explicitly registering `AllAssets` embeds all 1,830 SVGs on native platforms. Depending on the crate or using the shared `IconName` alone does not reference every SVG payload. | Native asset configuration | Embedded SVG data | Binary increase vs. default `Assets` | | --- | ---: | ---: | | Default component icons (101) | 44.28 KiB | 0 B (baseline) | | Default + 2 application icons (103) | 45.04 KiB | +15.19 KiB | | Default + 10 application icons (111) | 48.09 KiB | +19.19 KiB | | Explicit `AllAssets` (1,830) | 731.45 KiB | +1.02 MiB | **In this example, adding 10 application icons costs about 19 KiB, not the full catalog.** Their SVGs total 3,903 bytes; the measured binary increase is 19,648 bytes, including the extra source's lookup/list-composition code, metadata and alignment. These are not fixed per-icon costs or whole-application sizes. Measured with Lucide 1.43.0 on Linux x86_64, Rust 1.98.0, `--release`, and stripped symbols. Each program uses the same `IconName` lookup and runtime asset path. The extra source falls back to `Assets`, and merges, sorts and deduplicates both sources' lists. The 10 extras are `Accessibility`, `AlarmClock`, `Archive`, `Award`, `Backpack`, `Bike`, `Bird`, `Camera`, `Coffee` and `Compass`; the two-icon case uses the first two. SVG complexity, toolchain and source implementation change the result. Binary size is not RAM usage. Selected sources borrow static bytes without a copy/cache; actual rendering still allocates for parsing, rasterization and render caches. Runtime shared-name lookup can retain a name/path table, and Cargo's downloaded package/build artifacts still contain the complete catalog. On [WebAssembly](./webassembly), `Assets::new(endpoint)` and `AllAssets::new(endpoint)` use the existing on-demand CDN loader instead of embedding the complete bundle. ## Shared names and compatibility `gpui_kit::assets::IconName` provides the complete shared catalog without a Component dependency. `gpui_kit::component::IconName` remains the original compatibility enum: existing imports, exhaustive matches and `.view(cx)` calls continue to work without a new trait import. `Icon::new(...)` accepts either type. A legacy name converts into the shared name with `.into()`. For the new shared enum, use `Icon::new(name).view(cx)` when a component [Entity](./entity) is needed, or import `gpui_kit::component::IconNameExt` to call `name.view(cx)`. `IconName::ALL` enumerates all 1,830 names; `IconName::Accessibility.path()` returns `icons/accessibility.svg`. The default source contains only the original 101 component icons. Supply extra icons using the custom source below, or explicitly register `AllAssets` to use the complete bundle. ## Start with the default source `gpui_kit::assets::Assets` provides the default resource source with the original 101 component icons listed in [`default-icons.txt`](https://github.com/longbridge/gpui-kit/blob/main/crates/assets/default-icons.txt). Add the umbrella crate to `Cargo.toml`; its default features include `component` and `assets`: ```toml [dependencies] gpui-kit = "0.7.1" ``` For a native desktop app, register the source before opening a window. This complete `src/main.rs` renders a default icon: ```rust use gpui_kit::*; use gpui_kit::assets::Assets; use gpui_kit::component::{Icon, IconName}; struct Example; impl Render for Example { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div().child(Icon::new(IconName::Inbox)) } } fn main() { gpui_kit::application().with_assets(Assets).run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| Example) }) .expect("Failed to open window"); }); } ``` `with_assets` installs one application-wide source. `gpui_kit::init(cx)` initializes the component layer before the window is constructed. `IconName::Inbox` resolves to `icons/inbox.svg`; GPUI passes that exact path to the source. Merely adding the crate dependency does not register a source. See [Getting Started](/versions/v0.7.1/docs/getting-started) for the full application setup. ## Pick additional catalog icons with `icon_assets!` If an app already depends on `gpui-kit`, use `gpui_kit::assets::icon_assets!`; its default `assets` feature exposes the macro and the shared catalog. **Do not add a second `gpui-kit-assets` dependency just to use it.** A crate that intentionally uses the asset layer without the umbrella can depend on `gpui-kit-assets` directly and call `gpui_kit_assets::icon_assets!` instead. Choose the source according to the icons the app renders: | Need | Register with `with_assets` | Native icon data | | --- | --- | --- | | Only the default component icons | `Assets` | 101 default SVGs | | Default icons plus a few catalog icons | A composite of `Assets` and `icon_assets!` | Defaults plus named selections | | Every catalog icon | `AllAssets` | All 1,830 SVGs | | Only a few catalog icons, without Component | The generated source alone | Named selections only | The macro arguments are **`IconName` variant identifiers**, not strings, paths, or your own SVG filenames. For example, `Accessibility` selects `icons/accessibility.svg`. The generated `ExtraIcons` is a unit struct implementing `AssetSource`: `load` returns borrowed embedded bytes for selected paths and `Ok(None)` otherwise; `list(prefix)` lists selected paths. Selection happens at compile time. A misspelled or nonexistent variant fails compilation. The macro accepts an optional visibility modifier, such as `icon_assets!(pub ExtraIcons, [Accessibility]);`, when the source must be used from another module. The following complete `src/main.rs` selects two extra Lucide icons while retaining the default component icons. It targets native desktop applications. Use the `gpui-kit` dependency shown above; no SVG copying or new crate is needed. ```rust use gpui_kit::*; use gpui_kit::assets::{Assets as ComponentAssets, icon_assets}; use gpui_kit::component::Icon; use std::borrow::Cow; icon_assets!(ExtraIcons, [Accessibility, AlarmClock]); struct AppAssets; impl AssetSource for AppAssets { fn load(&self, path: &str) -> Result>> { if let Some(bytes) = ExtraIcons.load(path)? { return Ok(Some(bytes)); } ComponentAssets.load(path) } fn list(&self, path: &str) -> Result> { let mut paths = ComponentAssets.list(path)?; paths.extend(ExtraIcons.list(path)?); paths.sort(); paths.dedup(); Ok(paths) } } struct Example; impl Render for Example { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div() .child(Icon::new(gpui_kit::assets::IconName::Accessibility)) .child(Icon::new(gpui_kit::assets::IconName::AlarmClock)) } } fn main() { gpui_kit::application().with_assets(AppAssets).run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| Example) }) .expect("Failed to open window"); }); } ``` `with_assets` takes one source, so `AppAssets` owns both lookups. It checks `ExtraIcons` first because native `ComponentAssets.load` returns an error for an unknown path; that error would prevent a later fallback. `list` merges, sorts, and deduplicates both sources so callers see the same keys that `load` can serve. The generated source alone is suitable only when the app does not need the default component icons. To migrate a hand-written asset lookup for bundled Lucide icons, replace its copied SVG bytes or filename match arms with one `icon_assets!(ExtraIcons, [...]);` declaration. Keep any unrelated app files in their own source, query that source and `ExtraIcons` before the default `Assets` fallback, and merge all three `list` results. Remove the old SVG copies only after verifying that no other code loads those files by path. Your own files such as `icons/brand-mark.svg` are outside the catalog and still need the custom source described below. `IconName::ALL` lists names; it does not make every name loadable from the currently registered source. For a quick check, call `AppAssets.load("icons/accessibility.svg")` and `AppAssets.load("icons/inbox.svg")`; both should return `Some` on native. `AppAssets.list("icons/")` should contain both paths. If the extra icon is blank, check that the rendered `IconName` appears in the macro list and that `AppAssets`, rather than `ComponentAssets`, was registered. On WebAssembly, the macro source can still embed the selection, but the built-in `Assets` is constructed with an endpoint and must be stored as a field in the composite source. ## Add your own asset files `icon_assets!` selects from GPUI Kit's named catalog. For an application's own logo, illustration, photo, or other image, use an application `AssetSource`. The [assets] folder in this repository supplies catalog SVGs; placing a new file in your app does **not** add an `IconName` variant. A key such as `icons/brand-mark.svg` is relative to the embedded folder, not a URL or a path relative to the running process's working directory. The example below embeds one custom SVG and an image folder. Use this layout next to your application's `Cargo.toml`: ```text Cargo.toml src/main.rs assets/icons/brand-mark.svg assets/images/cover.png ``` Add [rust-embed] alongside `gpui-kit`: ```toml [dependencies] gpui-kit = "0.7.1" rust-embed = { version = "8.7", features = ["include-exclude"] } ``` This complete native `src/main.rs` serves app files first and falls back to GPUI Kit's default component icons. The `images/**/*` pattern embeds **every file** under that folder, so keep only intended assets there; you can replace it with narrower `#[include]` patterns. The example registers one source before opening the window. ```rs use gpui_kit::*; use gpui_kit::assets::Assets as ComponentAssets; use gpui_kit::component::Icon; use rust_embed::RustEmbed; use std::borrow::Cow; #[derive(RustEmbed)] #[folder = "./assets"] #[include = "icons/**/*.svg"] #[include = "images/**/*"] struct AppFiles; struct AppAssets; impl AssetSource for AppAssets { fn load(&self, path: &str) -> Result>> { if path.is_empty() { return Ok(None); } if let Some(file) = AppFiles::get(path) { return Ok(Some(file.data)); } ComponentAssets.load(path) } fn list(&self, path: &str) -> Result> { let mut paths = ComponentAssets.list(path)?; paths.extend(AppFiles::iter().filter_map(|p| p.starts_with(path).then(|| p.into()))); paths.sort(); paths.dedup(); Ok(paths) } } struct Example; impl Render for Example { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div() .flex() .flex_col() .gap_3() .child(Icon::default().path("icons/brand-mark.svg")) .child(img("images/cover.png").w(px(240.)).h(px(160.)).object_fit(ObjectFit::Cover)) } } fn main() { gpui_kit::application().with_assets(AppAssets).run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| Example) }) .expect("Failed to open window"); }); } ``` `AssetSource::load(path)` supplies bytes for an exact key; `list(prefix)` reports matching keys. In this example app files win on a key collision, and unknown keys reach `ComponentAssets`. On native, that built-in source errors on an unknown nonempty path. If the app also uses `icon_assets!`, check its generated source before this fallback and include its paths in `list` as shown above. On WASM, construct `Assets::new(endpoint)` and store that value in the composite source instead of using the native unit struct. ## Use an asset path Use the path exposed by the registered source. `Icon::path` is for SVG icons; `IconName` maps known names to those paths. ```rust use gpui_kit::component::{Icon, IconName}; let built_in = Icon::new(IconName::Inbox); let custom = Icon::default().path("icons/brand-mark.svg"); let extra = Icon::new(gpui_kit::assets::IconName::Accessibility); ``` `extra` needs the selected-icons source above or `AllAssets`. `custom` needs a source that contains `icons/brand-mark.svg`. Use `Icon::path` or `svg().path(...)` for a **monochrome SVG** that should follow the text color; GPUI paints it as a tinted alpha mask. Set an explicit size and `text_color` when needed. Use `img()` for photographs and other raster graphics, or for a **multicolor SVG** whose original colors should be preserved: ```rust use gpui_kit::{ObjectFit, StyledImage, img, px, svg}; let tinted_mark = svg().path("icons/brand-mark.svg").size(px(24.)); let cover = img("images/cover.png") .w(px(240.)) .h(px(160.)) .object_fit(ObjectFit::Cover); let color_artwork = img("images/illustration.svg").size(px(160.)); ``` The `img()` source supports common PNG, JPEG, WebP, GIF, and SVG files (and other formats listed by GPUI's `Img::extensions()`). It detects raster formats from the bytes; SVG takes a separate decoding path. Use a real image file with a supported format, not only a matching extension. `ObjectFit::Contain` is the default; `Cover` fills the bounds and may crop, while `Fill` can distort. `object_fit` controls `img()`, not the monochrome `svg()` element. `img()` loads and decodes asynchronously through GPUI's image cache; embedded source bytes are copied into its loader, so embedding alone does not eliminate decode or runtime image memory. Animated GIF and WebP can contain multiple frames. For a string like `"images/cover.png"`, GPUI calls the registered `AssetSource` with that key. `img(std::path::Path::new("/absolute/file.png"))` reads a filesystem path, and a URL string uses the HTTP loader. They have different deployment and error behavior. See [Images](/versions/v0.7.1/docs/image) for how each source loads, sizes, and caches. ## Embed individual SVG icons For custom icons, `Icon::data` accepts SVG bytes directly without an asset-path registry: ```rust use gpui_kit::component::{Icon, button::Button}; Button::new("search") .icon(Icon::default().data(include_bytes!("search.svg"))) .label("Search") ``` This only removes the asset lookup for that icon. Built-in `IconName` values and other path-based component icons still need an asset source. See [SVG Bytes](/versions/v0.7.1/component/icon#svg-bytes) for ownership, source replacement, loading icons, and custom icon types. ## Diagnose a missing asset | Symptom | Check | | --- | --- | | A default component icon is blank | Confirm `.with_assets(Assets)` (or a composed source) runs before opening windows. Check that the path starts with `icons/` and ends in `.svg`. | | A shared catalog name is blank | Confirm that name belongs to the registered source. `Assets` has 101 defaults; register selected icons or `AllAssets` for other names. | | A custom icon is blank | Compare the exact `Icon::path` key with the path below the source's `#[folder]`; check `#[include]` and case. `list("icons/")` can reveal what the source exposes. | | An image is blank | Check whether the argument is an asset key, filesystem `Path`, or URL; verify that the source includes the raster file and that the bytes are a supported image format. | On native, a missing path in the built-in `Assets` and `AllAssets` returns an error; an empty path returns `Ok(None)`. In a composed source, test custom matches before the built-in fallback. `Icon::data` avoids path lookup for that one SVG, but malformed SVG bytes can still fail to render. ## Packaging and WebAssembly Native `Assets` embeds only the default SVGs; `AllAssets` embeds the full catalog. `icon_assets!` embeds selected SVG bytes, and `rust-embed` embeds files matched by your include patterns. Source paths are resolved at build time, so packaged native apps do not need the source `assets/` directory at runtime. A filesystem `Path` passed to `img()` is different: that file must exist where the app runs. Use the size measurements above as examples, then measure your own release binary. On WebAssembly, `Assets::new(endpoint)` and `AllAssets::new(endpoint)` use the same on-demand HTTP source. It requests `endpoint + "/assets/" + path` for `icons/*.svg`, caches successful responses, and reports a temporary loading error while a request is in flight. Host the exact files and use an endpoint without a trailing slash. Check the browser Network and Console panels for status, CORS, or URL problems; the loader does not itself request a repaint after a download. `list()` returns an empty list on this source. The native `rust-embed` example above is a separate choice for explicitly embedding app files, including on WASM. See [WebAssembly](/versions/v0.7.1/docs/webassembly) for the gallery's deployment setup. ## Resources - [Lucide Icons](https://lucide.dev/) - GPUI Kit's icon catalog is based on the open-source Lucide collection. [rust-embed]: https://docs.rs/rust-embed/latest/rust_embed/ [IconName]: https://docs.rs/gpui-kit-assets/0.7.1/gpui_kit_assets/enum.IconName.html [Icon]: https://docs.rs/gpui-component/latest/gpui_component/struct.Icon.html [assets]: https://github.com/longbridge/gpui-kit/tree/main/crates/assets/assets/icons [gpui-kit-assets]: https://docs.rs/crate/gpui-kit-assets/0.7.1 --- # Render Source: /versions/v0.7.1/docs/render License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/render and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `Render` is the boundary between a persistent [`Entity`](./entity) and the UI it currently describes. GPUI calls `T::render` when it needs that View's [element tree](./element). The Entity keeps its data and identity; the returned elements describe layout, appearance, and handlers for this rendering pass. Use `Render` for a panel, page, editor, or other View that owns changing state, subscriptions, or child entities. ## Run a stateful View After [Getting Started](./getting-started), replace that project's `src/main.rs` with this complete example. It uses the same `gpui-kit` dependency and does not need another example crate. ```rust use gpui_kit::*; use gpui_kit::component::button::Button; #[derive(Default)] struct Chat { messages: Vec, } impl Chat { fn add(&mut self, cx: &mut Context) { self.messages .push(format!("Message {}", self.messages.len() + 1).into()); cx.notify(); } fn clear(&mut self, cx: &mut Context) { if !self.messages.is_empty() { self.messages.clear(); cx.notify(); } } } impl Render for Chat { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .flex() .flex_col() .size_full() .gap_2() .p_4() .child(format!("Messages: {}", self.messages.len())) .child( Button::new("add-message") .label("Add message") .on_click(cx.listener(|this, _, _, cx| this.add(cx))), ) .child( Button::new("clear-messages") .label("Clear") .on_click(cx.listener(|this, _, _, cx| this.clear(cx))), ) .children( self.messages .iter() .cloned() .map(|message| div().child(message)), ) } } fn main() { application() .with_assets(assets::Assets) .run(|cx| { init(cx); open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| Chat::default()) }) .expect("Failed to open window"); }); } ``` Run `cargo run`. The window initially shows **Messages: 0** and two buttons. **Add message** changes the count to 1 and adds **Message 1**; further clicks append another row. **Clear** removes all rows and returns the count to 0. Clearing an already empty list leaves it unchanged. `open_window` retains the `Entity` returned by `cx.new`; `Chat::default()` runs once when that Entity is created. `Render::render` receives mutable access to the View, a `Window`, and that View's [Context](./context), then returns `impl IntoElement`; the trait requires `Self: 'static + Sized`. The return type keeps the concrete, often deeply nested element type out of the signature. Here `div()` is a GPUI element and `Button` is a GPUI Kit component. The click closures are *registered* while rendering. They run later on input, with mutable access to `Chat` through `cx.listener`. Neither button mutates `Chat` while the tree is being built. Follow one click through the ownership boundary: | Step | What happens | | --- | --- | | 1. Input | The button invokes its registered callback. `cx.listener` enters the existing `Chat` Entity and calls `add`. | | 2. State | `add` appends to `Chat::messages`, then calls that Entity's `cx.notify()`. | | 3. Window work | GPUI invalidates windows currently showing `Chat`. When it next processes the affected view, `render` describes the new count and rows. | | 4. Element work | GPUI resolves layout and runs prepaint and paint as needed before the changed result can be presented. | This is an update path, not a fixed schedule of `render` calls or display frames. If the text stays at **Messages: 0**, check that the callback updates the mounted `Chat` Entity and calls its `Context::notify()`. If clicks trigger network or logging work more than once, check whether that work accidentally lives in `render` instead of the click handler. ## The View owns state; the tree describes this pass Create the Entity with `cx.new`. A parent can retain and render the same handle on every pass: ```rust struct ConversationPage { chat: Entity, } impl ConversationPage { fn new(cx: &mut Context) -> Self { Self { chat: cx.new(|_| Chat::default()), } } } impl Render for ConversationPage { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div().size_full().child(self.chat.clone()) } } ``` `ConversationPage` owns the same `Chat` Entity across rendering passes; it does not recreate the message list whenever the page redraws. Cloning `Entity` copies its handle, not its messages. A file list page can follow the same pattern: keep its file list View as a child Entity, with the list owning selection, loading state, and subscriptions. An `Entity` can be a child because GPUI converts it into a View element. Its Entity ID gives that View a reactive boundary and a distinct element ID space. See [Entity](./entity) for creation, sharing, and lifetime rules. ## Render or RenderOnce? `Entity` is a state container first: `T` does not need to implement `Render`. An `Entity` can hold data, receive updates, and notify observers without ever appearing in an element tree. When `T: Render`, the same container can be placed in the tree as a persistent View, keyed by its Entity ID. GPUI's `View` machinery also accepts `RenderOnce` values, but those have no Entity ID of their own. `Render` is the Entity-backed UI route, not a requirement for every Entity. | Trait | Receiver and owner | Appropriate work | | --- | --- | --- | | [`Render`](./render) | `render(&mut self, ..., &mut Context)` on a persistent `Entity` View | Own changing or complex state, subscriptions, focus, tasks, and child entities across render passes. | | [`RenderOnce`](./render-once) | `render(self, ..., &mut App)` consumes a value constructed by its parent | Describe a lightweight component from current inputs and callbacks; the parent supplies a new value when it renders again. | The `Chat` View above implements `Render` because it owns a changing message collection. GPUI Kit's `SelectState` is another state owner that implements `Render`. Its styled `Button` implements `RenderOnce`: the parent supplies its current props and handles the result of a click. A `RenderOnce` value can still be interactive, and GPUI may retain small keyed element state beneath it; it simply has no independent Entity lifecycle. This is a choice about **who owns persistent state**, not a promise that one trait runs once per display frame or that the other runs every frame. Returning a tree does not paint it immediately. GPUI converts the values to elements, resolves layout, runs `prepaint`, then [paints](./paint). A View's `render` can be called as part of layout or prepaint, and GPUI can reuse a valid cached subtree. Do not assume exactly one `render` call per frame, or that every parent render calls every child View's `render`. The output is a description for GPUI's element pipeline, not a retained list of pixels. [Element](./element) covers those phases. For the distinction between a render pass, a display refresh, and a presented frame, including the meaning of “120 FPS” and GPUI's hybrid model, see [FPS Monitor](./fps#120-hz-is-a-frame-budget-not-a-refresh-promise). ## Notify after a meaningful state change An Entity update grants mutable access, but mutation alone does not report a visible change. Call the *updated Entity's* `Context::notify()` when its rendered output or observers should change: ```rust chat.update(cx, |chat, cx| { chat.messages.push("Hello".into()); cx.notify(); // This is Context, not the caller's context. }); ``` `notify` invalidates live windows displaying that Entity and queues notification to observers. GPUI arranges a later rendering pass; `render` is not called synchronously at the `notify` line. A View shown in more than one live window may invalidate in each. GPUI marks the changed View and its rendered ancestors dirty, then may reuse eligible cached subtrees. Do not rely on a particular number of `render` calls. Notifications are also meaningful for observed model entities that do not themselves render. An [Event](./event) and a notification have different jobs: `cx.emit(event)` delivers a typed fact to subscribers; `cx.notify()` reports that the Entity changed. Emitting an event alone does not mean every View that reads the Entity will redraw. Conversely, a notification does not convey an event payload. Use both when the state and the event each need to be observed. When one View reads another Entity's state, keep ownership and invalidation explicit. If the other Entity renders as a child, its own `notify` can invalidate that child View. If the parent copies the other's values into its own output, arrange for the parent to observe the source and call the parent's `cx.notify()` when those copied values change. Avoid assuming a parent rerender is the only way a retained child can change. ### Trace one update through the example 1. `ConversationPage::new` creates one `Chat` Entity and retains its handle. Each page render places that same handle in the tree. 2. A caller can append a message with `chat.update(cx, |chat, cx| { chat.messages.push("Hello".into()); cx.notify(); });`. The inner `cx` belongs to `Chat`; the update does not require a separate `ConversationPage` notification. 3. On a later window pass, GPUI builds the affected element tree and runs layout, prepaint, and paint as needed. The new message appears. A click on **Clear** then runs the registered listener, clears `Chat`'s messages, and notifies that same Entity. 4. Clicking **Clear** again leaves the collection unchanged, so `clear` does not notify. Neither step promises a fixed count of `render` calls: window refreshes and cache eligibility also affect that count. To check the ownership boundary in a running view, append a message through the retained `Chat` handle and confirm it appears. Change unrelated parent state and confirm the child keeps the message; if it resets, look for a new `Chat` Entity being constructed during the parent's `render`. Then click **Clear** and confirm the message disappears. ## Render builds values; handlers perform work Reading state, choosing children, applying [styles](./style), and attaching handlers are ordinary render work. Rebuilding a tree may happen for reasons unrelated to a user action. Starting a request, registering a subscription, emitting an event, or mutating application state unconditionally in `render` would repeat that work whenever GPUI rebuilds the View. An unconditional `cx.notify()` from `render`, `prepaint`, `paint`, or a canvas callback can keep invalidating the window. Place lifecycle work in Entity initialization, subscriptions, or an explicit input handler. Hold a `Subscription` on the owning Entity. When asynchronous work completes, update the owning Entity and notify from that update if its state changed. For a click, `cx.listener` turns the later GPUI Kit `Button` callback into an update of this `Render` owner, as in the Chat example. Captured `Entity` handles can similarly update a different owner from a handler; never try to reenter an Entity already being rendered or updated. GPUI Kit uses `Render` for state owners such as input and selection state, while controls such as `Button` use `RenderOnce` to turn their current props into elements. A custom `Element` is appropriate when layout, hitboxes, or painting need direct control. This composition lets a persistent View own behavior while short lived values describe its current interface. ## Common mistakes | Symptom | Check | | --- | --- | | State changed but the screen stayed the same | Was the correct Entity updated and its context notified? | | Work runs repeatedly while idle | Is `render` or a paint callback starting work or calling `notify` on every pass? | | Child state resets when the parent changes | Is the child a newly created Entity inside `render` instead of a handle retained by an owner? | | A click handler cannot borrow `self` | Register an owned callback with `cx.listener`, or capture an `Entity` handle for later update. | | Repeated rows lose local UI state | Give repeated elements with keyed state stable IDs based on item identity, not array positions. See [ElementId](./element_id). | For the context APIs used here, see [Context](./context). For input callback and event details, see [Event](./event). --- # SharedString Source: /versions/v0.7.1/docs/shared-string License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/shared-string and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Small costs add up. In UI code, a cost built into an ordinary component call is repeated wherever that component appears and redraws. GPUI and GPUI Kit treat that as an API design constraint: retained text should be easy to own and pass around without making every caller copy its full contents. `SharedString` is one concrete choice that gives component authors this default. `SharedString` is GPUI's owned, immutable text type. GPUI Kit uses it for labels, placeholders, titles, and other text that an element or component keeps after the builder call returns. Use it by default for **UI text** held across [render](./render) calls, component boundaries, or task closures. Borrow temporarily with [`&str`](https://doc.rust-lang.org/std/primitive.str.html). Import `SharedString` with `use gpui_kit::*;` or `use gpui_kit::SharedString;`. ## Small costs add up Small copies and allocations are a long-term budget to control when choosing framework types, not a burden to leave at every call site. If a label API required each caller to supply a newly owned buffer, ordinary composition would repeat full text copies across many components. By accepting `SharedString` for retained text, a component can keep an owned value while callers reuse the one they already hold. Authors do not need a hand-written cache around each label for that common path. This default avoids repeated **full text** copies, not all work: cloning inline text copies its small bytes, cloning shared heap text updates a reference count, and `format!(...).into()` still constructs new text each time it runs. Other costs have their own tools, such as [virtualization](../base/virtual-list) for offscreen rows and [view caching](./view-cache) for an unchanged subtree. ## Why use it instead of `String` for UI text? Consider a title passed from a workspace View through a header and tab component to a final label. Each layer that keeps the title after its caller returns needs a valid ownership choice: move the value and give up the caller's copy, borrow it with a lifetime tied to the source, or clone it. Rust's [`String`](https://doc.rust-lang.org/std/string/struct.String.html) owns a mutable buffer, so cloning a nonempty `String` at each boundary creates independent buffers and copies the title's bytes. Rebuilding the title with `format!` at each layer repeats formatting and allocation instead. `SharedString` represents that immutable value in a form that is cheap to clone. The workspace can retain one value while each layer takes an owned clone; for long heap-backed text, the clones share its bytes instead of copying them at every handoff. This is why GPUI and GPUI Kit expose it in many text properties and accept `impl Into` at component boundaries. The tradeoff is immutability: changing the text means constructing a new value. Sharing helps while the value stays unchanged; it is not a promise of zero cost.
Three String owners compared with three SharedString owners For long heap-backed text, three String owners each point to a separate full text buffer. Three SharedString owners hold separate small handles that point to one shared text buffer with a reference count. Animated dashed arrows show the ownership links, not elapsed time. String::clone() Each owned clone copies the full text into its own allocation. View A · handle View B · handle View C · handle Text buffer A · full text Text buffer B · full text Text buffer C · full text 3 text buffers SharedString::clone() Each owner keeps a handle; the long text stays in one allocation. View A · handle View B · handle View C · handle One shared text bufferreference count: 3
Conceptual long-text example with three owners, not a byte-accurate benchmark. SharedString still stores a handle per owner and updates a reference count; short inline values copy their small bytes instead.
## How it stores text GPUI currently backs `SharedString` with `SmolStr`. The representation depends on how the value is created and on its length in **UTF-8 bytes**: | Input | Current storage | What cloning does | | --- | --- | --- | | `SharedString::new_static("Ready")` | References the static literal | Copies the small handle; no heap allocation | | `SharedString::from("Ready")` | Copies this short text inline | Copies the inline bytes; no heap allocation | | Longer dynamic text | Shared heap allocation | Clones a reference-counted handle; no copy of the text bytes | The current inline capacity is 23 bytes. Certain runs of newlines followed by spaces also use a special allocation-free static representation. These are implementation details, not a length limit or a promise that every `SharedString` avoids allocation. In particular, `SharedString::from("a long literal ...")` uses the normal conversion path; use `new_static` when you explicitly want static storage for a literal. Neither construction nor cloning interns strings or deduplicates equal values. For these current representations, `SharedString::clone()` does work bounded independently of the text length: a static clone copies a handle, a short inline clone copies a few bytes, and a heap-backed clone copies a handle and updates a reference count. It does not duplicate the bytes of a long heap-backed value. `String::clone()` instead allocates an independent buffer and copies its bytes. Constructing a fresh long `SharedString` can still allocate; formatting, conversion, reference-count updates, and eventually dropping shared copies still cost time. ## Ownership and access `SharedString` owns a usable value even when its input was a temporary `String` or a borrowed `&str`. Converting a nonstatic borrow copies its content as needed; the result does not borrow from the caller. You can therefore store it in an [Entity](./entity) or move it into a callback without managing the input's lifetime. ```rust use gpui_kit::SharedString; let title: SharedString = "Quarterly report for the product team".into(); let header_title = title.clone(); // Retain it for one UI owner. let tab_title = title.clone(); // Retain it for another owner. let borrowed: &str = title.as_str(); // Read without taking ownership. assert_eq!(borrowed, "Quarterly report for the product team"); assert_eq!(header_title, tab_title); ``` Here `.into()` converts the literal to an owned `SharedString`; for a literal that should use static storage, use `SharedString::new_static(...)` instead. Because this conversion uses the normal path and the title is longer than the current inline capacity, `header_title` and `tab_title` share its heap-backed text while unchanged. Their clones still update reference counts. `as_str()`, `AsRef`, and dereferencing provide a `&str` without creating another owner; that borrow lasts only as long as the `SharedString` it came from. `SharedString` cannot be edited in place. If a task truly needs a mutable construction or editing buffer, use a local `String` and convert it once when the result is ready. Converting an owned `String` to a long `SharedString` can still copy into shared storage; do not assume the `String` buffer is reused. ## Convert at the ownership boundary Choose the operation according to who needs the text next: | Source and next use | Pattern | Ownership result | | --- | --- | --- | | Fixed literal retained by UI | `SharedString::new_static("Ready")` | Owns a value backed by static text | | Temporary `&str` retained by UI | `SharedString::from(text)` | Owns a value independent of the borrowed source | | Finished `String` retained by UI | `let label: SharedString = text.into();` | Moves the `String` into conversion; it may still copy or allocate | | Existing `SharedString`, both owners need it | `let label = title.clone();` | Both keep owned values; long heap text stays shared | | Existing `SharedString`, only the receiver needs it | `Label::new(title)` | Moves the value; no extra clone | | Existing `SharedString`, callee reads only | `read_text(title.as_str())` | Borrows `&str` for the call | For a builder taking `impl Into`, passing `&title` also works because GPUI implements conversion from `&SharedString` by cloning it. Passing `title.as_str()` instead converts borrowed text into a **new** `SharedString`; use `title.clone()` (or `&title`) when you mean to share the existing value. If an API specifically requires `String`, `title.to_string()` creates a separate mutable string; only do this at that API boundary. ```rust use gpui_kit::SharedString; use gpui_kit::component::label::Label; let title = SharedString::new_static("Downloads"); let heading = Label::new(title.clone()); // Keep title for another owner. let tab = Label::new(title); // Last use: move it. ``` The `heading` and `tab` values each own their text. The move makes `title` unavailable afterward; clone first only where another owner still needs it. ## From an API response to a View Design immutable text fields in API response snapshots as `SharedString` by default. Deserialize a JSON `title` directly into that type with Serde, then carry the response into application state. If the UI needs a formatted title, derive a separate presentation value when the response arrives instead of changing the transport response: ```rust use gpui_kit::SharedString; use serde::Deserialize; #[derive(Deserialize)] struct UserResponse { title: SharedString, } struct Workspace { response: UserResponse, display_title: SharedString, } impl From for Workspace { fn from(response: UserResponse) -> Self { let display_title = format!("Profile: {}", response.title).into(); Self { response, display_title } } } ``` Moving `response` into `Workspace` keeps its original `title` without cloning it. The separate `display_title` is computed once here; nested Views and components can clone either value when they need owned text. For long heap-backed text, those later clones share the value's text allocation rather than copying its bytes; short text is stored inline. Initial JSON parsing still costs work: the current `Deserialize` implementation reads a `String` and constructs a `SharedString` from it, which may copy or allocate. `Serialize` writes the text as an ordinary string; sharing is an in-memory property, not part of the JSON value. `format!` also constructs a new value for the derived title, so derive it when data changes rather than on every render. Use a mutable text buffer only when the response text truly needs editing; an ordinary read-only response field need not start as `String`. ## Use it in GPUI Kit GPUI Kit component builders often accept `impl Into`, so callers can pass a literal or an existing `SharedString`. For example, [`Button::label`](../component/button) and [`Label::new`](../component/label) accept that form: ```rust use gpui_kit::*; use gpui_kit::component::{button::Button, label::Label}; let title = SharedString::new_static("Downloads"); let heading = Label::new(title.clone()); let button = Button::new("open-downloads").label(title); ``` For text kept in a view and used on many renders, store one `SharedString` in the view and clone it into each element. The view's [Context](./context) is available alongside `Window` in `Render::render`: ```rust use gpui_kit::*; use gpui_kit::component::label::Label; struct Header { title: SharedString, } impl Render for Header { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { Label::new(self.title.clone()) } } ``` The clone gives the new element its own handle while the view retains its copy. For a long title, it does not copy all the characters on every render. Constructing the long title anew with `format!(...).into()` inside every render would still format and allocate on every render. Keep stable text in the state that owns it, and replace the value when it changes. ## Choose text for its lifetime For text that a UI element, View, callback, or several owners will retain, prefer `SharedString`. For a fixed literal that needs ownership, `SharedString::new_static` explicitly uses static storage. Component builders that accept `impl Into` let callers pass these values without building a fresh `String` first. When an API only reads text during the current call, pass `&str` or borrow from an existing `SharedString` with `.as_str()`. This creates no additional text owner. Avoid introducing `String` for ordinary UI text. Use it when text actually needs **mutable construction or editing**, such as assembling a draft, and convert the finished value at the ownership boundary: ```rust let mut draft = String::from("Quarterly report"); draft.push_str(" for the product team"); let title: SharedString = draft.into(); ``` This `.into()` consumes the `String` and builds a `SharedString`; it may copy or allocate and does not promise to reuse the original buffer. GPUI Kit's [`InputState`](../component/input) shows why an editing buffer and an exposed value need not have the same type: it stores editable text in a `Rope`, while `value()` materializes a `SharedString` snapshot on each call. Avoid repeatedly calling `value()` just to read an unchanged field. Ordinary UI code often needs no `String` at all, but it remains useful for real construction and editing work. **Source snapshot (2026-09-24)** In GPUI Kit's production library source under `crates/{kit,base,component,assets}/src`, **34 named struct storage fields** have a type containing `String`, versus **375** with a type containing `SharedString`. The `String` fields include valid exceptions such as editable text, search state, and theme schema data. This supports the default for retained UI text; it does not mean the repository contains only 34 uses of `String`. To reproduce the count, scan `*.rs` named struct bodies under those four `src` directories, skip test-only files and content after `#[cfg(test)]`, then count field types containing the distinct Rust tokens `String` and `SharedString`. This is a lightweight source scan rather than a Rust semantic parse: it excludes local variables, function signatures, and enum fields, includes both native and WebAssembly `cfg` variants present in source, and says nothing about runtime allocations. ## Common compiler errors and surprises | Symptom | Cause and fix | | --- | --- | | “use of moved value” after `Label::new(title)` | The builder consumes its argument. Pass `title.clone()` when the view or another element still needs the value; move it only on the last use. | | “borrowed data escapes” or a callback must be `'static` | A callback cannot retain `title.as_str()` borrowed from a view. Clone the `SharedString` into the `move` closure, then call `.as_str()` inside the closure if needed. | | A method expects `&str`, but it receives `SharedString` | Pass `title.as_str()` (or `&title` where deref coercion applies). This borrows without copying text. | | `push_str` or another mutation method is unavailable | `SharedString` is immutable. Build or edit in a `String`, then convert the completed text into `SharedString`. | | `.into()` has an ambiguous destination type | Specify it: `let title: SharedString = source.into();` or call `SharedString::from(source)`. | The callback case follows the same ownership rule as an element: the closure must own anything it keeps after `render` returns. For example, `let title_for_click = self.title.clone();` before an `on_click(move |_, _, _| { /* use title_for_click here */ })` gives the handler an independent value. Clone once when building the callback, rather than converting `self.title.as_str()` into a fresh value on each render. ## Related: `Cow` Rust's [`Cow<'a, str>`](https://doc.rust-lang.org/std/borrow/enum.Cow.html) can avoid a copy by borrowing existing text, but its `Borrowed` variant is tied to the source's lifetime. Its `Owned` variant contains a `String`; cloning a nonempty owned value copies those bytes. Calling `to_mut()` on a borrowed value copies it into an owned `String` before editing: ```rust use std::borrow::Cow; let mut text: Cow<'_, str> = Cow::Borrowed("Ready"); text.to_mut().push('!'); // Now owned and editable. ``` `SharedString` is an independently owned, immutable value with static, inline, or shared heap storage. Cloning a long heap-backed value shares its bytes; changing the text requires a new value. It is not an alias for `Cow` and does not offer `Cow`'s write-on-mutation behavior. --- # View Cache Source: /versions/v0.7.1/docs/view-cache License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/view-cache and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. GPUI keeps application state in [entities](./entity), but normally builds a new [element tree](./element) for each window draw. Keeping an `Entity` alive does not keep its last element tree alive. When a window redraws, an ordinary child view may run `Render::render` again even if its own state did not change. A cached view instead reuses selected records from the last frame, including its input handlers. There is no public type named `ViewCache` to construct. The view-cache API in current GPUI is **`Entity::cached(style)`** (or **`AnyView::cached(style)`**). It creates a cached view boundary for one entity-backed subtree. GPUI Kit also uses other, narrower caches; they solve different costs. | Mechanism | Reuses or avoids | Lifetime and owner | | --- | --- | --- | | `Entity::cached(style)` | A clean view's render, child layout/prepaint, and paint work | GPUI's window cache, keyed by the entity view and its element path | | [`Window::use_keyed_state`](./window) | Small state or computed data used by a rebuilt element | Window element state under a stable [`ElementId`](./element_id) | | A model-owned cache | A derived value, measurement, or drawing resource | An owning `Entity`, with application-defined invalidation | | `VirtualList` | Building offscreen rows | Its visible range; a separate scroll handle keeps scroll position | These mechanisms can be combined. For example, a cached panel may contain a virtual list, and a visible chart row may reuse tessellated paths. A virtual list does **not** cache all of its row views, and `use_keyed_state` does **not** skip a view's `render`. Think of three separate questions: **What requests a redraw?** `cx.notify()` reports changed Entity output, and `window.refresh()` requests a full window refresh. **What survives a rebuilt element?** An Entity owned by the application or state stored under an element key can survive. **What work can be skipped in the new frame?** Only a clean cached View can replay its recorded subtree; a keyed state value or path cache merely supplies reusable data to work that still runs. A cached scene is GPUI's previous-frame paint record, not an application-owned image or a separately addressable texture. ## Cache an entity-backed subtree This example uses the existing `hello_world` package. Replace `examples/hello_world/src/main.rs` with the following code, then run `cargo run -p hello_world`. The two buttons make the cache boundary observable without a profiler: the terminal prints whenever either View's `render` runs. ```rust use gpui_kit::base::StyledExt; use gpui_kit::component::button::Button; use gpui_kit::*; struct Workspace { panel: Entity, parent_clicks: usize, } struct ResultsPanel { panel_clicks: usize, } impl Render for ResultsPanel { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { println!("panel render: {}", self.panel_clicks); div() .v_flex() .gap_2() .size_full() .child(format!("Panel clicks: {}", self.panel_clicks)) .child(Button::new("panel-click").label("Update panel").on_click( cx.listener(|this, _, _, cx| { this.panel_clicks += 1; cx.notify(); }), )) } } impl Render for Workspace { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { println!("workspace render: {}", self.parent_clicks); div() .v_flex() .gap_2() .size_full() .child(format!("Workspace clicks: {}", self.parent_clicks)) .child(Button::new("workspace-click").label("Update workspace").on_click( cx.listener(|this, _, _, cx| { this.parent_clicks += 1; cx.notify(); }), )) .child( div().w_full().h(px(180.)).child( self.panel .clone() .cached(StyleRefinement::default().size_full()), ), ) } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { let panel = cx.new(|_| ResultsPanel { panel_clicks: 0 }); cx.new(|_| Workspace { panel, parent_clicks: 0, }) }) .expect("failed to open window"); }); } ``` After the first frame, click **Update workspace** several times. `Workspace` renders again, while `ResultsPanel` can be replayed without another `panel render` line. **Update panel** still works inside the cached area: it changes the child Entity, calls `cx.notify()`, and produces another `panel render` line with the new count. The exact number of surrounding window frames is platform and input dependent; compare the two render logs rather than expecting one draw per click. `panel` is created once with `cx.new(...)` and retained by `Workspace` (see [Context](./context)). Creating a fresh entity inside `render` gives it a new identity and loses both its state and its warm cache. The `style` argument is the cached view's **outer layout contract**. GPUI lays out that box before deciding whether to reuse its contents; it cannot ask an unrendered subtree for an intrinsic size. The example's fixed-height parent supplies a definite box, while the cached child fills it. For content-sized views, embed the entity normally with `.child(self.panel.clone())`. `AnyView::cached(style)` has the same behavior when a parent stores a type-erased panel, as GPUI Kit's dock does. [`RenderOnce`](./render-once) values and arbitrary `ViewElement`s cannot opt into this API: they have no entity notification contract to invalidate a frozen subtree. The cache belongs to the **window frame**, not to the `Entity` itself. The same entity rendered in two windows has separate frame records. GPUI locates the record by the View's entity-derived element ID within its keyed ancestor path, so moving a View to another path does not carry its old recorded subtree with it. Retaining an Entity preserves its state; retaining its position and a clean cache key makes frame reuse possible. [GPUI Kit's dock panel](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/dock/tab_panel.rs) embeds an `AnyView` with `cached(StyleRefinement::default().absolute().size_full())` in a sized tab area. ## When does GPUI reuse it? On a cache hit, GPUI does not call that child view's `render`. It replays the earlier subtree's prepaint and paint records into the current frame. These include hitboxes, dispatch nodes, focus state, mouse listeners, input handlers, and the drawing scene, so the cached area remains interactive. The original element values are not kept as a permanent tree. Event handlers in a reused subtree run again on input; only the work to rebuild and paint them is skipped. For a hit, the entity must remain clean, and the cached view's **bounds, content mask, and inherited text style** must match the recorded frame. GPUI also bypasses reuse during a forced refresh; inspector picking can disable caching. If any of these conditions fail, it renders and lays out the child again, then records a new cache entry. | Change | What happens at this boundary | | --- | --- | | A sibling or parent redraws, while this child and its box stay unchanged | The parent still builds its tree; the cached child can be replayed. | | The child changes and calls `cx.notify()` | GPUI marks the view dirty, rebuilds it, and updates the cache. | | A descendant view changes and notifies | GPUI marks its ancestor view path dirty so the cached boundary is rebuilt. | | The cached box resizes, clips differently, or inherits a different text style | GPUI misses this cache entry and rebuilds it. | | The child is removed or its identity/path changes | Its old cached subtree cannot be used at the new position. | Keep state mutations in event handlers or [tasks](./task) and call `cx.notify()` on the affected entity when its visible output changes. If the child's output depends on a [Global](./global), observe that global and notify the child; changing a global by itself does not dirty cached readers. If it depends on another entity, use an observation or another explicit invalidation path. Do not assume that a parent re-render alone will refresh a clean cached child. Use `window.refresh()` for an intentional full refresh, not as a normal state-update mechanism. An observation must target the View whose cached output depends on the source. For example, if a panel reads a model Entity during `render`, a model update alone does not tell GPUI that the panel's *View* is dirty. Arrange for the panel to observe the model and call the panel's `cx.notify()` when the displayed value changes. The same rule applies to theme, locale, or other Global values read inside the boundary. A cache hit intentionally skips both the reads and the closures that would have been built by that render, so a fresh value captured by the parent is not sufficient to refresh it. Caching has a scope: it can skip work *inside* the child boundary, but the window still draws a frame and the parent still runs as needed. The first draw and every cache miss pay the ordinary render/layout/paint cost. Use it where a measured subtree is expensive and often stays clean while surrounding content changes. ## Element state is a different cache GPUI recreates value-like elements on subsequent renders. If an element needs a little state across consecutive frames, `Window::use_keyed_state` stores an `Entity` under the current element path plus a supplied key. It also observes that state entity and notifies the current View when the state changes. The state survives while that path is accessed on successive frames, including frames where a cached subtree replays its element-state accesses; it is released when the path disappears and no other strong handle keeps it alive. `Window::use_state` uses a call-site key, which is suitable only where that location uniquely identifies the state. An `ElementId` derived from stable domain data matters for repeated or reorderable items: changing an ID resets the state; reusing one for unrelated siblings risks collision. GPUI Kit's [`Plot` path cache](../component/plot) is a concrete example. A plot and each `Line` are rebuilt as values, so a path held on a `Line` would disappear with that value. `PathCaches::for_paint("lines", window, cx)` stores caches in keyed window state under the plot's element ID. A `ShapeKey` covers projected points and geometry-affecting stroke settings; `PathCache::get` tessellates only when that key changes. The path is built relative to zero and translated to the current origin for painting, so moving a chart can reuse the geometry. A color change can be applied while painting without rebuilding unchanged path geometry. That cache saves **path construction**, not the plot view's `render` or the current frame's paint submission. Its slots are positional: when series can reorder, map stable series identities to slots or ensure the shape key safely invalidates the changed slot. See [Paint](./paint#plot-a-value-like-element-uses-keyed-window-state) for the source-level walkthrough. The key must include every input that changes the cached **geometry**: projected points, size, stroke width, curve style, and any other tessellation setting used by the builder. It need not include a paint-only color when that color is selected at paint time. A key that omits a geometry input can draw stale paths; a key that includes the absolute origin forfeits reuse while scrolling. GPUI Kit's [`PathCache::get` and `ShapeKey`](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/plot/path_cache.rs) make this dependency explicit. Their `PathCaches` owner is the window's keyed element state, so removal of the plot path for a frame ends that window-owned cache even if a later plot uses the same ID. ## Virtualization avoids work instead of replaying it For a long collection, caching a view containing every row still leaves an expensive first render and invalidations. GPUI Kit's [`VirtualList`](../base/virtual-list) accepts item sizes and calls its render closure for the visible range (with a small overdraw); it may also render one representative item for cross-axis measurement. It never constructs most offscreen row elements in that frame. Its `VirtualListScrollHandle` is retained separately in the owner so scrolling survives element rebuilds. ```rust use std::rc::Rc; use gpui_kit::*; use gpui_kit::base::{v_virtual_list, VirtualListScrollHandle}; struct Row { id: u64, name: SharedString, } struct ResultsList { rows: Vec, sizes: Rc>>, scroll: VirtualListScrollHandle, } impl Render for ResultsList { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_virtual_list(cx.entity(), "results", self.sizes.clone(), |this, range, _, _| { range .map(|ix| { div() .id(("result", this.rows[ix].id)) .child(this.rows[ix].name.clone()) }) .collect::>() }) .track_scroll(&self.scroll) .size_full() } } ``` The closure should read prepared data; avoid sorting, loading, or creating one long-lived entity per row in it. Give repeated interactive rows stable IDs from the row data. Virtualization and cached views address different dimensions: how **many** elements are made, and whether an **unchanged subtree** is replayed. ## Choose the smallest useful boundary Start with ordinary entities and declarative rendering. If profiling shows a stable panel being rebuilt because nearby UI changes, place `cached(style)` around that panel and give it a reliable layout box. If a drawing operation remains costly on every visible frame, cache its derived geometry with explicit keys and invalidation. If the cost grows with collection length, virtualize. Do not add a broad cache to hide render work that should have been moved out of `render` or data that should have been retained by an entity. The [Render](./render) guide explains when a View creates a new tree. ## Verify behavior before keeping a cache Count calls to the child View's `render` in a focused test or profiler, and compare these cases after the first draw: 1. Redraw an unrelated sibling while the cached box stays fixed. The child should not render again; its controls should still receive input. 2. Change the child's displayed state, call its `cx.notify()`, and verify that the child renders again and the new content appears. 3. Change a dependency outside the child, such as a model or Global, through its real observation path. Verify that the child rebuilds. If the parent changes but the child does not, the missing dependency notification is a correctness bug. 4. Resize or move the boundary, change its clipping or inherited text style, and check that the expected miss occurs. Moving the box changes its bounds; scrolling a plot may leave its **path geometry** cache warm while the View scene still repaints. GPUI Kit's [cached text-selection test](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/text/window_selection.rs) uses a render counter and checks selection during replayed frames. Use a release/profile build for performance measurements: an extra cache boundary has bookkeeping cost, and a subtree that changes every frame may not benefit. For stale output, inspect Entity notifications and external dependencies first; for unexpected misses, inspect entity identity, keyed ancestor path, bounds, content mask, and inherited text style. --- # Fonts Source: /versions/v0.7.1/docs/fonts License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/fonts and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. This page covers which fonts an application supplies. See [TextSystem](./text-system) for how GPUI resolves, shapes, measures, and paints their glyphs. ## Start here For a desktop app, start with the theme defaults. If you need a specific look, choose a family installed on every machine you support, or bundle its font file. Set the UI and monospace families through `Theme::update`; use `.font_family(...)` only for a particular element. Check the result with real Latin, CJK, emoji, and mixed-script content on each target platform. A family name alone does not guarantee that all those glyphs exist. For a WebAssembly app, register font files before the first window or text measurement. The browser's installed font list is not GPUI Web's font collection. The [WebAssembly setup](#webassembly-choose-a-font-supply-strategy) below shows the extra choices for browser builds. ## Default fonts Every app starts with a UI font and a monospace font from the theme: | Role | Family | Size | | --- | --- | --- | | UI text | `.SystemUIFont` | 16px | | Code / monospace | macOS: `Menlo`, Windows: `Consolas`, Linux: `DejaVu Sans Mono` | 13px | The editor paints its code in `mono_font_family` at `mono_font_size`. See [Editor](/versions/v0.7.1/component/editor) for details. Both defaults are checked against the installed fonts when the theme is applied. A missing monospace default is swapped for an installed alternative, and when `.SystemUIFont` resolves to one of GPUI's fallback families rather than the system font itself (Linux desktops without the family GPUI maps it to), the theme names that family directly so text lookups stay cached. A family you set yourself is used as-is. ## System fonts Desktop apps can use **any font installed on the OS** by name — no bundling, no config. GPUI resolves the family live against the system collection (CoreText on macOS, DirectWrite on Windows, fontconfig on Linux). ```rust div().font_family("Segoe UI") Editor::new(&editor).font_family("JetBrains Mono") ``` Common examples per platform: - macOS: `SF Pro`, `Helvetica`, `Arial`, `Times New Roman`, `Menlo`, `Monaco` - Windows: `Segoe UI`, `Arial`, `Consolas`, `Courier New` - Linux: `Noto Sans`, `DejaVu Sans`, `Liberation Sans`, `DejaVu Sans Mono` If a requested family cannot load, `TextSystem::resolve_font` tries GPUI's default font stack. If none loads, layout panics. A family that loads but lacks a particular glyph is a different problem: glyph fallback may draw that character in another face. Verify both the family name and the glyphs you need on each target platform. `Font::fallbacks` controls missing-glyph fallbacks after the requested family has loaded; it does not make an absent primary family available. To inspect names GPUI currently sees, run this after initialization and after any bundled fonts have been registered: ```rust let families = cx.text_system().all_font_names(); println!("Available font families: {families:?}"); ``` This lists family names, including fonts installed with `add_fonts`, but does not prove that a face contains every character or requested weight. ## Changing fonts via Theme Set the app-wide fonts through `Theme::update`, which syncs the base layer and refreshes every window: ```rust Theme::update(cx, |theme| { theme.font_family = "Inter".into(); theme.mono_font_family = "JetBrains Mono".into(); theme.font_size = px(18.); }); ``` `font_size` doubles as the application zoom control — `Root` calls `window.set_rem_size(cx.theme().font_size)`, so [`rem`-based spacing](./geometry) scales with it. See [Coding Guides](/versions/v0.7.1/docs/coding-guides) for details. ## Per-element override Elements implementing `Styled` accept a font override without touching the theme: ```rust div() .font_family("JetBrains Mono") .text_size(px(15.)) .font_weight(FontWeight::BOLD) ``` These are ordinary [`Styled`](https://docs.rs/gpui-pre/0.3.8/gpui/trait.Styled.html) methods, so they compose with the rest of the style chain. ## Bundling custom fonts Fonts that are not installed on the user's system must be bundled and registered with the text system **before the first frame**. Put the font file in your app and call `add_fonts` during application startup, before opening a window or constructing anything that measures text: ```rust use std::borrow::Cow; cx.text_system() .add_fonts(vec![Cow::Borrowed( include_bytes!("../fonts/MyFont-Regular.ttf").as_slice(), )]) .expect("Failed to load fonts"); ``` Then reference them by family name as usual: ```rust Theme::update(cx, |theme| theme.font_family = "MyFont".into()); ``` Use the family name stored *inside* the font file, which may differ from its filename. Register every face you need for predictable regular, bold, and italic text; a single regular file is not a promise of all styles. Bundling also makes a desktop app independent of whether that family is installed on the user's machine. Keep the font's redistribution license with the app. The [GPUI Kit web gallery](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/src/lib.rs) bundles `Inter Variable`, `JetBrains Mono`, a subset of `Noto Sans SC`, and `IBM Plex Sans` this way. Rust's [`include_bytes!`](https://doc.rust-lang.org/std/macro.include_bytes.html) puts those font bytes into the WebAssembly download. The gallery's CJK subset is about 42 KB, compared with about 1.2 MB for its source font: subset known interface copy to limit initial payload, then plan separately for arbitrary text entered by users. ### Try a bundled font in `hello_world` The repository already contains `crates/story-web/fonts/Inter-Regular.ttf`; its internal family name is `Inter Variable`. Replace `examples/hello_world/src/main.rs` with this complete example, then run `cargo run -p hello_world` from the repository root. The `include_bytes!` path below is relative to that `main.rs` file. Your own application should place a licensed font in its own assets and adjust the path. ```rust use std::borrow::Cow; use gpui_kit::component::theme::Theme; use gpui_kit::*; struct FontLab; impl Render for FontLab { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div() .flex() .flex_col() .gap_2() .p_4() .child("Theme font: Inter Variable") .child( div() .font_family(".SystemUIFont") .child("System UI font for comparison"), ) } } fn main() { application().run(|cx| { init(cx); cx.text_system() .add_fonts(vec![Cow::Borrowed( include_bytes!("../../../crates/story-web/fonts/Inter-Regular.ttf").as_slice(), )]) .expect("Failed to load bundled font"); let families = cx.text_system().all_font_names(); assert!(families.iter().any(|family| family == "Inter Variable")); println!("Registered font: Inter Variable"); Theme::update(cx, |theme| theme.font_family = "Inter Variable".into()); open_window(WindowOptions::default(), cx, |_, cx| cx.new(|_| FontLab)) .expect("Failed to open window"); }); } ``` The terminal should print `Registered font: Inter Variable`, and the window should show two labels. The first inherits the theme's registered font; the second explicitly requests the system UI family. Their appearance may differ by platform. This checks registration and theme selection, not glyph coverage: try another string and the target platforms before relying on a font for user-entered text. Registration happens before `Theme::update` and `open_window`, so the first layout uses the new face. ## Font changes affect layout Different faces have different advances, ascent, descent, and glyph coverage. A substituted family or newly loaded CJK face can change line wrapping, control height, caret placement, and alignment even at the same `px` size. Theme changes refresh windows, and `add_fonts` invalidates font resolution and cached line layouts; if fonts are installed while a window is already visible, call `cx.refresh_windows()` after registration. Recheck text after the new frame, especially in narrow controls and mixed-script paragraphs. For custom measurements, use the shaped line from [TextSystem](./text-system) instead of estimating width from character count. ## Theme JSON config Font families and sizes can also come from a theme file: ```json { "font.family": "Inter", "font.size": 16, "mono_font.family": "JetBrains Mono", "mono_font.size": 13 } ``` In a desktop application's startup callback, after `init(cx)`, choose a theme name and watch a directory containing theme files. This is a contextual snippet: `cx` comes from the callback, and `"My Theme"` must match the name inside a theme file in `./themes`. ```rust use std::path::PathBuf; use gpui_kit::component::theme::{Theme, ThemeRegistry}; use gpui_kit::SharedString; let theme_name: SharedString = "My Theme".into(); ThemeRegistry::watch_dir(PathBuf::from("./themes"), cx, move |cx| { if let Some(theme) = ThemeRegistry::global(cx).themes().get(&theme_name).cloned() { Theme::update(cx, |current| current.apply_config(&theme)); } }) .expect("Failed to watch theme directory"); ``` See [Theme](/versions/v0.7.1/component/theme) for the full config reference. ## WebAssembly: choose a font supply strategy See the [WebAssembly guide](./webassembly) for the browser build and its font setup. GPUI's Web text system does **not enumerate or load the browser's installed fonts** as its main font collection. Register every family that must be shaped reliably, including the family used by the initial text style, before creating a window or measuring its first text. On this Web platform, `.SystemUIFont` maps to `IBM Plex Sans`; if that alias can be used before the theme applies, register that family too. Apply or change the theme **after** registration and keep its `font_family` and `mono_font_family` pointed at loaded families. A theme file naming an unavailable desktop font can otherwise fail font resolution. The [gallery initialization](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/src/lib.rs) follows this order: initialize GPUI Kit, register font bytes, apply the theme, then open the window; later theme changes reassert its loaded families. There are three useful supply choices: | Choice | Initial download | Coverage and tradeoff | | --- | --- | --- | | Bundle full fonts with `include_bytes!` | Larger WebAssembly payload | Offline and predictable, including user-entered text within the font's coverage. | | Bundle subsets for known UI strings | Smaller payload | Other CJK characters and newly entered text need another source. | | Fetch a font file when needed | Smaller initial payload; later network request | Install it at runtime, then refresh windows so text is shaped again. Handle loading and failure states. | For a fetched font, pass owned bytes to the **same** `TextSystem::add_fonts` API. The HTTP download can come from the application's `cx.http_client()` or another client; the registration step is: ```rust use std::borrow::Cow; use gpui_kit::*; fn install_downloaded_font(cx: &mut App, bytes: Vec) -> Result<()> { cx.text_system().add_fonts(vec![Cow::Owned(bytes)])?; cx.refresh_windows(); Ok(()) } ``` `add_fonts` invalidates font resolution and line-layout caches, but an already visible window needs `refresh_windows()` to show the newly shaped text. Validate the HTTP response and nonempty bytes before installing. Supply an actual supported font file such as raw TTF; a font-service CSS URL may return a stylesheet or WOFF2 subset rather than bytes this text system accepts. Browser cross-origin rules apply to external requests. Download only fonts the current content needs, and deduplicate concurrent requests. `App::on_missing_glyphs(callback) -> Subscription` can report unresolved grapheme clusters after shaping. Keep the subscription alive, inspect `MissingGlyph::grapheme()` and `font_class()`, and use it to request a script-specific font once. A new registration replaces the previous callback; reports are deduplicated and bounded, so this is a loading hint rather than a guaranteed complete inventory. If Canvas fallback can already draw a CJK grapheme, that grapheme will **not** produce a missing-glyph report. For accurate CJK typography, trigger loading from the selected language or known content coverage instead of relying only on missing-glyph reports. ## Diagnose a font problem | Symptom | Check | | --- | --- | | App panics while laying out first text | List `all_font_names()` before opening the window. Confirm the primary family and a usable default family were registered, especially `.SystemUIFont`'s Web mapping. | | Text appears in an unexpected face | Compare the requested family with `all_font_names()` and the family embedded in the file. Check whether a theme switch replaced your choice. | | Squares or mixed faces in CJK text | Confirm the font contains the exact characters, not just a family name. A subset may cover labels but omit user input; load broader coverage or an appropriate script font. | | Text wraps differently after a font loads | Recheck the layout with the installed font's metrics; refresh an already visible window after `add_fonts`. | | Missing-glyph callback never fires on Web | Check whether Canvas fallback already drew the grapheme. Use content or language selection to trigger a font needed for consistent typography. | ## Browser Canvas fallback Text the loaded fonts cannot draw can sometimes come from the browser. The Web platform can render eligible emoji through Canvas 2D with the visitor's local fonts, avoiding a bundled emoji font. Choose the policy when constructing the platform; it cannot change afterwards: | `CanvasFontFallback` | Browser draws | | --- | --- | | `Emoji` (default) | Emoji, including skin tones, flags, keycaps and ZWJ sequences | | `EmojiAndCjk` | Emoji plus eligible horizontal Han, kana, modern Hangul, and related punctuation | | `Disabled` | Nothing; only bundled fonts are used | `gpui_kit::application()` and `gpui_kit::platform::single_threaded_web()` keep the default. To widen it, build the platform yourself: ```rust use gpui_kit::web::{CanvasFontFallback, WebBackendPreference, WebPlatform}; let platform = Rc::new(WebPlatform::new_with_backend_and_font_fallback( false, WebBackendPreference::Auto, CanvasFontFallback::EmojiAndCjk, )); let http_client = Arc::new(platform.fetch_http_client()); let app = Application::with_platform(platform).with_http_client(http_client); ``` Loaded fonts stay preferred wherever they have the glyph and requested presentation. Canvas fallback is limited to eligible **single complete graphemes**; it is not a general system-font API or a guarantee that the browser has a matching glyph. CJK fallback draws eligible graphemes independently and horizontally, so it favors readable coverage over exact spacing, shaping, and font features. Its appearance depends on the visitor's fonts. Use a real CJK font for text whose metrics, line breaks, or visual consistency matter. The gallery opts into `EmojiAndCjk` because its bundled CJK subset covers known gallery copy while visitors can type other characters into inputs. --- # Global Source: /versions/v0.7.1/docs/global License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/docs/global and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `Global` marks a Rust type that GPUI can store once per [`App`](./context). It is useful for a setting or service shared by several features and windows. The value is keyed by its concrete Rust type, so `AppSettings` and [`Theme`](../component/theme) occupy different slots. `Global` is an empty marker trait with a `'static` bound; it does not make the value a View or create an event stream by itself. ```rust use gpui_kit::*; struct AppSettings { notifications_enabled: bool, } impl Global for AppSettings {} ``` The slot belongs to this `App`, not to a particular [`Window`](./window) or [`Entity`](./entity). Initialize it once at application startup, before Views read it. A second `set_global` for the same type replaces the previous value; it does not merge fields. ## Choose the owner | Owner | Use it for | How a change reaches a View | | --- | --- | --- | | `Global` in `App` | One setting or service shared by the whole application, including multiple windows. | Observe that global type and notify the dependent View, or use a domain API that refreshes windows. | | `Entity` | A document, feature model, or retained View state with its own lifetime and behavior. | Update the Entity and call `cx.notify()` when its rendered state changes; other owners can observe it or subscribe to its events. | | `Window` | Focus, input dispatch, bounds, and other state or operations belonging to one window. | Use the Window's own invalidation or refresh APIs as appropriate. | The value's *scope* does not determine what redraws. Reading a Global in `render` creates no automatic dependency. Conversely, a View can observe a Global even if it is not the code that changes it. Each window's View must retain its own observer if it renders that setting. For a concrete decision, imagine two editor windows. A notification preference applies to both windows, so one `AppSettings` Global is appropriate. Each window's selected tab and click count belong to its own View Entity. A document opened in both windows can instead have a shared model `Entity` that both Views observe. These are three different lifetimes: app, window View, and document. Moving a value to a Global merely to make it reachable does not establish the right lifetime or make rendering reactive. For a shared model exercise, see [Multi Window](./multi-window). ## Read and change a global | API | Result | Use | | --- | --- | --- | | `cx.has_global::()` | `bool` | Check whether the slot exists. | | `cx.global::()` | `&T` | Read a required value; panics if missing. | | `cx.try_global::()` | `Option<&T>` | Read an optional value. | | `cx.set_global(value)` | `()` | Install or replace a value and notify global observers. | | `cx.global_mut::()` | `&mut T` | Edit an installed value and notify global observers. | | `cx.update_global::(\|value, cx\| …)` | closure result | Edit an installed value while also using its GPUI context; notifies observers when the edit finishes. | | `cx.default_global::()` | `&mut T` | Read or install `T::default()` mutably; requires `T: Default`. | | `cx.update_default_global::(\|value, cx\| …)` | closure result | Update a value, installing its default first if needed. | | `cx.remove_global::()` | `T` | Remove an installed value and notify global observers. | `global_mut`, `update_global`, and `remove_global` require an existing value. A read does not notify observers. The mutable and defaulting APIs schedule a notification even if the code leaves the value unchanged. If redundant work matters, check with `global::()` **before** calling a mutable API; a comparison inside `update_global` is too late to avoid its notification. `remove_global` also notifies: an observer that reads the slot after removal must use `try_global` or handle absence another way. References returned by `global`, `global_mut`, and `default_global` are tied to the current GPUI call; copy or clone data needed later. `update_global` temporarily lends out the global so its closure can hold `&mut T` and `&mut cx` at the same time. Use the `value` passed to the closure. Do not try to read or update that same global again inside the closure. ```rust cx.update_global::(|settings, _cx| { settings.notifications_enabled = false; }); ``` [`Context`](./context) can call these application APIs because it dereferences to `App`. Code outside an Entity, such as application initialization, receives `&mut App` directly. ## App scope and Window scope Choose a global when all windows should see the same value: for example, an application preference, a theme, or a shared service handle. Keep focus, input dispatch, bounds, and other window-specific behavior in [Window](./window). A global is one slot for the entire app; putting a separate selection for each window into one global makes window ownership and cleanup harder. Keep a feature's business state in an Entity owned by that feature's crate or view. Reserve `Global` for services, settings, and coordination genuinely shared across the application; difficulty passing data between modules is not a reason to move a large business collection into an app-wide slot. When features need to collaborate, pass a lightweight Entity handle where the ownership boundary permits it, or use an explicit interface, command, or event. The [Coding Guides](./coding-guides) explain how to keep each feature's model and workflow behind its module boundary. GPUI Kit's `Theme` illustrates application-wide ownership. After `gpui_kit::init(cx)`, components read the active theme through `cx.theme()`. GPUI Kit also keeps derived theme data in sync for its lower layers and refreshes windows when the theme changes. Use `Theme::change(...)` for a mode change or `Theme::update(cx, |theme| { … })` for an edit; a raw `Theme::global_mut(cx)` edit does not perform that synchronization or refresh every window. This is a theme-specific rule on top of GPUI's ordinary `Global` behavior. ## Build a two-window example Changing a global notifies **global observers**. GPUI queues these notifications as effects and coalesces repeated touches of the same type while a notification is pending. The callback sees the current value, not an intermediate snapshot or a field-level change. A global update does not automatically call `cx.notify()` on every Entity that happened to read it. A View whose rendered output depends on a global can register `cx.observe_global::(...)`, then notify itself in the callback. Keep the returned `Subscription` in that View so the observer lives as long as the View. Use the existing `examples/hello_world` workspace package. Replace `examples/hello_world/src/main.rs` with the complete file below and run `cargo run -p hello_world --bin hello_world` from the repository root. No new package or dependency is needed. The example creates one Global before opening either window, then creates one `SettingsView` Entity per window. The Global stores the shared preference; `local_clicks` belongs only to that window's Entity. Both buttons report changes outside `render`. The complete shared-state path is: install the value during startup → create each View and store its observer → read the value in `render` → change it in an input callback → let each observer call its own `cx.notify()` → each View renders again from the current value. The setting is not copied into a second View field. ```rust use gpui_kit::*; use gpui_kit::assets::Assets; use gpui_kit::component::button::Button; struct AppSettings { notifications_enabled: bool, } impl Global for AppSettings {} struct SettingsView { _settings_observer: Subscription, local_clicks: usize, name: &'static str, } impl SettingsView { fn new(name: &'static str, cx: &mut Context) -> Self { let _settings_observer = cx.observe_global::(|_this, cx| { cx.notify(); }); Self { _settings_observer, local_clicks: 0, name, } } } impl Render for SettingsView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let enabled = cx.global::().notifications_enabled; div() .flex() .flex_col() .gap_2() .p_4() .child(format!("{}: notifications {}", self.name, if enabled { "on" } else { "off" })) .child(format!("Clicks in this window: {}", self.local_clicks)) .child( Button::new("toggle-notifications") .label("Toggle shared setting") .on_click(|_, _window, cx| { cx.update_global::(|settings, _cx| { settings.notifications_enabled = !settings.notifications_enabled; }); }), ) .child( Button::new("local-click") .label("Increment local clicks") .on_click(cx.listener(|this, _, _, cx| { this.local_clicks += 1; cx.notify(); })), ) } } fn main() { application() .with_assets(Assets) .run(|cx| { init(cx); cx.set_global(AppSettings { notifications_enabled: true, }); for name in ["First window", "Second window"] { open_window(WindowOptions::default(), cx, move |_, cx| { cx.new(|cx| SettingsView::new(name, cx)) }) .expect("failed to open window"); } }); } ``` Click **Toggle shared setting** in either window. Both windows should change between `notifications on` and `notifications off`. Click **Increment local clicks** in one window; only that window's number should change. Close one window and click either button in the remaining window; its own Entity and the app Global are still alive. The first button edits the Global through `update_global`. Both saved observers notify their owning Entities, so both renders read the new value. The second button mutates only its own Entity and calls that Entity's `cx.notify()`; it does not touch the Global. If you remove the observer field, the subscription drops and the shared label stops following later Global updates. If you remove the local button's `cx.notify()`, its count changes in memory but can remain stale on screen until another render. The observer notification is especially necessary if a View is [cached](./view-cache): a parent redraw alone can replay its old subtree. When one `SettingsView` is dropped, its stored `Subscription` drops and that window's callback disconnects; the other window's observer remains. `observe_global` reports that the type was touched, without a typed change payload. Use an [Event](./event) from an Entity when consumers need a specific operation or payload. For a debugging exercise, temporarily comment out the observer's `cx.notify()`, run the example, and toggle the setting. The Global changes but the two labels need not update immediately. Restore the call before continuing. This isolates the missing invalidation from a missing mutation. If a label remains stale with the call restored, check that each View stores its `Subscription` and that the mutation goes through a GPUI Global API. If a callback also needs its window, use `cx.observe_global_in::(window, ...)` and store its `Subscription`. For a window-level observer without an owning Entity, `window.observe_global::(cx, ...)` provides `&mut Window` and `&mut App` to the callback. Keep that subscription with a suitable owner as well. `window.refresh()` or `cx.refresh_windows()` explicitly requests rendering; neither is required for the View above because its observer calls `cx.notify()`. ## Common mistakes - Calling `cx.global::()` before installing `T`: it panics. Initialize first or use `try_global` for an optional slot. - Assuming `set_global` or `update_global` redraws every reader: register a global observer and notify dependent Views, or use an API such as GPUI Kit's theme update that explicitly refreshes windows. - Dropping the `Subscription` returned by `observe_global` at the end of a constructor: the observer stops immediately. - Putting per-window focus, selection, or document state in a single app global: give it a window or Entity owner, or store explicitly keyed state when application-wide coordination is actually required. - Writing GPUI Kit's theme through `global_mut` and expecting all theme projections and windows to update: use its theme APIs. - Mutating a global during `render`: render can run again for many reasons. Handle input or other effects outside rendering, then let observation invalidate the View. - Changing data *inside* a Global through an `Arc`, lock, atomic, or other interior-mutable handle and expecting Global observers to run: GPUI sees only calls to its Global mutation APIs. Explicitly notify the relevant owner or use an observable Entity for frequently changing data. --- # NumberInput Source: /versions/v0.7.1/component/number-input License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/number-input and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A specialized input component for numeric values with built-in increment/decrement buttons and support for min/max values, step values, and number formatting with thousands separators. ## Import ```rust use gpui_kit::component::input::{InputState, NumberInput, NumberInputEvent, StepAction}; ``` ## Usage ### Basic Number Input ```rust let number_input = cx.new(|cx| InputState::new(window, cx) .placeholder("Enter number") .default_value("1") ); NumberInput::new(&number_input) ``` ### Input Restriction and Normalization By default, the NumberInput only accepts a valid number: an optional leading `+`/`-` sign, digits and a single decimal point (e.g. `-1.5`), other characters are rejected on typing and pasting. Full-width number characters are normalized into their ASCII equivalents automatically, for CJK IME users: - Full-width digits: `123` → `123` - Full-width signs: `+` → `+`, `-` → `-` - Full-width dot and ideographic full stop: `.`, `。` → `.` A bare leading decimal point is kept as-is (e.g. `.5`, parsed as `0.5`), matching the web behavior, so deleting the integer part of `1.2` keeps `.2` and stays editable. To opt out of the default restriction, set an explicit mask: `state.set_mask_pattern(MaskPattern::None, window, cx)`. To further restrict the input (e.g. positive integers only), use `pattern`: ```rust // Integer input with validation let integer_input = cx.new(|cx| InputState::new(window, cx) .placeholder("Integer value") .pattern(Regex::new(r"^\d+$").unwrap()) // Only positive integers ); NumberInput::new(&integer_input) ``` ### With Min/Max/Step By default, the NumberInput updates the value internally with `step(1.)`: the `↑`/`↓` keys and the `+`/`-` buttons step the value by 1 and emit `InputEvent::Change`. Set `min`/`max` to clamp the range, or set a custom step. To fall back to emitting `NumberInputEvent::Step` only (the subscriber is responsible for updating the value), call `state.set_step(None, window, cx)`. A typed out-of-range value is kept while typing, and clamped on blur. Stepping follows the web behavior: a step that cannot move the value in the pressed direction (e.g. `↓` on a value at or below the `min`) does nothing. ```rust let stepper_input = cx.new(|cx| InputState::new(window, cx) .default_value("50") .step(5.) .min(0.) .max(100.) ); NumberInput::new(&stepper_input) ``` ### Dynamic Step Use `step_by` to calculate the step value from the current value and the step direction, e.g. a step size that varies by range. Because the step can differ by direction at a boundary, the closure receives the `StepAction`; here `1.0` steps by `0.1` going down and `0.5` going up. The closure also receives a `Context` for reading or updating other entities: ```rust let price_input = cx.new(|cx| InputState::new(window, cx) .step_by(|value, action, _cx| match action { StepAction::Increment => if value < 1.0 { 0.1 } else { 0.5 }, StepAction::Decrement => if value <= 1.0 { 0.1 } else { 0.5 }, }) .min(0.) ); NumberInput::new(&price_input) ``` The step strategy can also be updated at runtime via `set_step`: ```rust use gpui_kit::component::input::NumberStep; state.set_step(NumberStep::Fixed(0.01), window, cx); state.set_step(NumberStep::by_value(|v, _, _cx| if v < 1. { 0.01 } else { 0.1 }), window, cx); state.set_step(None, window, cx); // Fall back to NumberInputEvent::Step ``` ### With Number Formatting ```rust use gpui_kit::component::input::MaskPattern; // Currency input with thousands separator let currency_input = cx.new(|cx| InputState::new(window, cx) .placeholder("Amount") .mask_pattern(MaskPattern::Number { separator: Some(','), fraction: Some(2), // 2 decimal places }) ); NumberInput::new(¤cy_input) ``` ### Different Sizes ```rust // Large size NumberInput::new(&input).large() // Medium size (default) NumberInput::new(&input) // Small size NumberInput::new(&input).small() ``` ### With Prefix and Suffix ```rust use gpui_kit::component::{button::{Button, ButtonVariants}, IconName}; // With currency prefix NumberInput::new(&input) .prefix(div().child("$")) // With info button suffix NumberInput::new(&input) .suffix( Button::new("info") .ghost() .icon(IconName::Info) .xsmall() ) ``` ### Disabled State ```rust NumberInput::new(&input).disabled(true) ``` ### Without Default Styling ```rust // For custom container styling div() .w_full() .bg(cx.theme().secondary) .rounded(cx.theme().radius) .child(NumberInput::new(&input).appearance(false)) ``` ### Handle Number Input Events By default, the NumberInput updates the value internally. To fall back to `NumberInputEvent::Step` (the subscriber is responsible for updating the value), call `state.set_step(None, window, cx)`: ```rust let number_input = cx.new(|cx| InputState::new(window, cx)); let mut value: i64 = 0; // Subscribe to input changes cx.subscribe_in(&number_input, window, |view, state, event, window, cx| { match event { InputEvent::Change => { let text = state.read(cx).value(); if let Ok(new_value) = text.parse::() { view.value = new_value; } } _ => {} } }); // Subscribe to increment/decrement actions cx.subscribe_in(&number_input, window, |view, state, event, window, cx| { match event { NumberInputEvent::Step(step_action) => { match step_action { StepAction::Increment => { view.value += 1; state.update(cx, |input, cx| { input.set_value(view.value.to_string(), window, cx); }); } StepAction::Decrement => { view.value -= 1; state.update(cx, |input, cx| { input.set_value(view.value.to_string(), window, cx); }); } } } } }); ``` ### Programmatic Control ```rust // Increment programmatically NumberInput::increment(&number_input, window, cx); // Decrement programmatically NumberInput::decrement(&number_input, window, cx); ``` ## API Reference ### NumberInput | Method | Description | | ------------------------------ | ------------------------------------------ | | `new(state)` | Create number input with InputState entity | | `placeholder(str)` | Set placeholder text | | `size(size)` | Set input size (small, medium, large) | | `prefix(el)` | Add prefix element | | `suffix(el)` | Add suffix element | | `appearance(bool)` | Enable/disable default styling | | `disabled(bool)` | Set disabled state | | `increment(state, window, cx)` | Increment value programmatically | | `decrement(state, window, cx)` | Decrement value programmatically | ### NumberInputEvent | Event | Description | | ------------------ | ---------------------------------- | | `Step(StepAction)` | Increment/decrement pressed. Only emitted when `step` is `None` (opt out via `set_step(None, ...)`). | ### StepAction | Action | Description | | ----------- | ------------------------- | | `Increment` | Value should be increased | | `Decrement` | Value should be decreased | ### InputState (Number-specific methods) | Method | Description | | ----------------------------------- | ------------------------------------------------------- | | `step(impl Into)` | Set step value for built-in increment/decrement (default: 1) | | `step_by(fn(f64, StepAction, &mut Context) -> f64)` | Calculate step value based on the current value and direction | | `min(f64)` | Set minimum value, clamped on stepping and blur | | `max(f64)` | Set maximum value, clamped on stepping and blur | | `set_step(Option, ...)` | Update step strategy after construction | | `set_min(Option, ...)` | Update minimum value after construction | | `set_max(Option, ...)` | Update maximum value after construction | | `pattern(regex)` | Set regex pattern for validation (e.g., digits only) | | `mask_pattern(MaskPattern::Number)` | Set number formatting with separator and decimal places | | `value()` | Get current display value (formatted) | | `unmask_value()` | Get actual numeric value (unformatted) | ### MaskPattern::Number | Field | Type | Description | | ----------- | --------------- | -------------------------------------- | | `separator` | `Option` | Thousands separator (e.g., ',' or ' ') | | `fraction` | `Option` | Number of decimal places | ## Keyboard Navigation | Key | Action | | ----------- | -------------------------- | | `↑` | Increment value | | `↓` | Decrement value | | `Tab` | Navigate to next field | | `Shift+Tab` | Navigate to previous field | | `Enter` | Submit/confirm value | | `Escape` | Clear input (if enabled) | ## Examples ### Integer Counter ```rust struct CounterView { counter_input: Entity, counter_value: i32, } impl CounterView { fn new(window: &mut Window, cx: &mut Context) -> Self { let counter_input = cx.new(|cx| InputState::new(window, cx) .placeholder("Count") .default_value("0") .pattern(Regex::new(r"^-?\d+$").unwrap()) // Allow negative integers ); let _subscription = cx.subscribe_in(&counter_input, window, Self::on_number_event); Self { counter_input, counter_value: 0, } } fn on_number_event( &mut self, state: &Entity, event: &NumberInputEvent, window: &mut Window, cx: &mut Context, ) { match event { NumberInputEvent::Step(StepAction::Increment) => { self.counter_value += 1; state.update(cx, |input, cx| { input.set_value(self.counter_value.to_string(), window, cx); }); } NumberInputEvent::Step(StepAction::Decrement) => { self.counter_value -= 1; state.update(cx, |input, cx| { input.set_value(self.counter_value.to_string(), window, cx); }); } } } } // Usage NumberInput::new(&self.counter_input) ``` ### Currency Input ```rust struct PriceInput { price_input: Entity, price_value: f64, } impl PriceInput { fn new(window: &mut Window, cx: &mut Context) -> Self { let price_input = cx.new(|cx| InputState::new(window, cx) .placeholder("0.00") .mask_pattern(MaskPattern::Number { separator: Some(','), fraction: Some(2), }) ); Self { price_input, price_value: 0.0, } } } // Usage with currency prefix h_flex() .gap_2() .child(div().child("$")) .child(NumberInput::new(&self.price_input)) ``` ### Quantity Selector with Limits ```rust struct QuantitySelector { quantity_input: Entity, } impl QuantitySelector { fn new(window: &mut Window, cx: &mut Context) -> Self { // Step by 1 and clamp to 1..=99, no event handling needed. let quantity_input = cx.new(|cx| InputState::new(window, cx) .default_value("1") .min(1.) .max(99.) ); Self { quantity_input } } } // Usage NumberInput::new(&self.quantity_input).small() ``` ### Floating Point Input ```rust // Step by 0.1, the fraction digits of the value are kept on stepping, // e.g. 0.2 -> 0.3 (not 0.30000000000000004). let float_input = cx.new(|cx| InputState::new(window, cx) .placeholder("0.0") .step(0.1) ); NumberInput::new(&float_input) ``` ## Best Practices 1. **Validation**: Always validate numeric input on both client and server side 2. **Range Limits**: Use `min`/`max` to clamp values for user safety 3. **Step Size**: Choose appropriate `step` values for your use case 4. **Error Handling**: Provide clear feedback for invalid input 5. **Formatting**: Use consistent number formatting across your application 6. **Performance**: Debounce rapid increment/decrement actions if needed 7. **Accessibility**: Always provide proper labels and descriptions --- # Input Source: /versions/v0.7.1/component/input License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/input and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. For multiple addons, shared frames, and textarea toolbars, see [Input Group](/versions/v0.7.1/component/input-group). A single-line text input with validation, masking, prefix/suffix elements, and different visual states. Use [Textarea](/versions/v0.7.1/component/textarea) for ordinary multi-line text and [Editor](/versions/v0.7.1/component/editor) for source code. ## Import ```rust use gpui_kit::component::input::{Input, InputState}; ``` ## Usage ### Basic Input ```rust let input = cx.new(|cx| InputState::new(window, cx)); Input::new(&input) ``` ### With Placeholder ```rust let input = cx.new(|cx| InputState::new(window, cx) .placeholder("Enter your name...") ); Input::new(&input) ``` ### With Default Value ```rust let input = cx.new(|cx| InputState::new(window, cx) .default_value("John Doe") ); Input::new(&input) ``` ### Cleanable Input ```rust Input::new(&input) .cleanable(true) // Show clear button when input has value ``` ### With Prefix and Suffix ```rust use gpui_kit::component::{Icon, IconName}; // With prefix icon Input::new(&input) .prefix(Icon::new(IconName::Search).small()) // With suffix button Input::new(&input) .suffix( Button::new("info") .ghost() .icon(IconName::Info) .xsmall() ) // With both Input::new(&input) .prefix(Icon::new(IconName::Search).small()) .suffix(Button::new("btn").ghost().icon(IconName::Info).xsmall()) ``` ### Password Input (Masked) ```rust let input = cx.new(|cx| InputState::new(window, cx) .masked(true) .default_value("password123") ); Input::new(&input) .content_type(InputContentType::Password) .mask_toggle() // Shows toggle button to reveal password ``` While the value is masked, the input keeps it out of the clipboard and out of the selection: Copy and Cut do nothing (and are disabled in the context menu), a word-wise delete takes everything before the caret, and a double click selects the whole value instead of one word. Paste and Select All keep working, and revealing the value with `mask_toggle` restores all of them. ### Input Sizes ```rust Input::new(&input).large() Input::new(&input) // medium (default) Input::new(&input).small() ``` ### Disabled Input ```rust Input::new(&input).disabled(true) ``` ### Read-only Input Unlike `disabled`, a read-only input keeps the normal appearance and still can be focused, selected and copied, it only rejects the changes made by the user. ```rust Input::new(&input).readonly(true) ``` ### Clean on ESC ```rust let input = cx.new(|cx| InputState::new(window, cx) .clean_on_escape() // Clear input when ESC is pressed ); Input::new(&input) ``` ### Input Validation ```rust // Validate float numbers let input = cx.new(|cx| InputState::new(window, cx) .validate(|s, _| s.parse::().is_ok()) ); // Regex pattern validation let input = cx.new(|cx| InputState::new(window, cx) .pattern(regex::Regex::new(r"^[a-zA-Z0-9]*$").unwrap()) ); ``` ### Input Masking ```rust // Phone number mask let input = cx.new(|cx| InputState::new(window, cx) .mask_pattern("(999)-999-9999") ); // Custom pattern: AAA-###-AAA (A=letter, #=digit, 9=digit optional) let input = cx.new(|cx| InputState::new(window, cx) .mask_pattern("AAA-###-AAA") ); // Number with thousands separator use gpui_kit::component::input::MaskPattern; let input = cx.new(|cx| InputState::new(window, cx) .mask_pattern(MaskPattern::Number { separator: Some(','), fraction: Some(3), }) ); ``` ### Handle Input Events ```rust let input = cx.new(|cx| InputState::new(window, cx)); cx.subscribe_in(&input, window, |view, state, event, window, cx| { match event { InputEvent::Change => { let text = state.read(cx).value(); println!("Input changed: {}", text); } InputEvent::PressEnter { secondary } => { println!("Enter pressed, secondary: {}", secondary); } InputEvent::Focus => println!("Input focused"), InputEvent::Blur => println!("Input blurred"), } }); ``` ### Custom Appearance ```rust // Without default styling Input::new(&input).appearance(false) // Use in custom container div() .border_b_2() .px_6() .py_3() .border_color(cx.theme().border) .bg(cx.theme().secondary) .child(Input::new(&input).appearance(false)) ``` ### Context Menu ```rust // Turn off the right-click menu entirely, including a custom one. let input = cx.new(|cx| InputState::new(window, cx).context_menu(false)); // Or replace the built-in menu with your own. The state's context menu must // stay enabled, which is the default. Input::new(&input).context_menu(|menu, window, cx| { // You can define your own actions and even utilize // built-in actions (cut, copy, paste, etc.) // to avoid having to re-implement that functionality. menu.menu("Custom Action", Box::new(CustomAction)) .separator() .menu("Cut", Box::new(input::Cut)) .menu("Copy", Box::new(input::Copy)) .menu("Paste", Box::new(input::Paste)) }) ``` ### Touch Selection On a touch screen, a long press selects the word under the finger and keeps following the finger while it stays down. Lifting it opens an edit menu over the selection with the commands that apply — `Cut`, `Copy`, `Paste`, and `Select All` — and puts a grab handle at each end of the selection. Dragging a handle moves that end; the other end stays put, and a multi-line input scrolls when the finger reaches its edge. A long press on whitespace or in an empty field places the caret and offers `Paste` and `Select All`. The handles and the menu belong to the selection the gesture made. They disappear as soon as anything else moves the selection — a tap, typing, an arrow key, `Escape` — and the menu steps aside while the content scrolls under a finger. Tapping the selected text brings the menu back. Cut, Copy, and Paste go through the input's own actions, so a custom key binding or an open completion menu sees them the same way. A read-only input offers only `Copy` and `Select All`; a masked input keeps its value out of the clipboard. ### Paste Hook `on_paste` intercepts the clipboard before the default text insertion, so pasted images and copied files can live in app-owned state instead of being silently dropped. It is available on `Input`, `Textarea` and `Editor`. ```rust use gpui_kit::ClipboardEntry; let view = cx.entity().downgrade(); Textarea::new(&self.composer).on_paste(move |item, _, cx| { let images: Vec<_> = item.entries().iter().filter_map(|entry| match entry { ClipboardEntry::Image(image) => Some(image.clone()), _ => None, }).collect(); if images.is_empty() { return false; // fall through to the default text insertion } view.update(cx, |this, cx| { // Store the images beside the input, e.g. as `Attachment`s. this.attachments.extend(images); cx.notify(); }).ok(); true // consumed, the input inserts nothing }) ``` Return `true` when the handler took the paste: the `input::Paste` action stops there and the input inserts nothing. Return `false` to let the action reach the engine, which inserts `clipboard.text()` as before. Copied files arrive as `ClipboardEntry::ExternalPaths` through the same hook. Known limit: on web `read_from_clipboard()` is `None` (text arrives through the platform input handler); image paste there needs async clipboard access and permission, and is out of scope. ## Examples ### Search Input ```rust let search = cx.new(|cx| InputState::new(window, cx) .placeholder("Search...") ); Input::new(&search) .prefix(Icon::new(IconName::Search).small()) ``` ### Currency Input ```rust let amount = cx.new(|cx| InputState::new(window, cx) .mask_pattern(MaskPattern::Number { separator: Some(','), fraction: Some(2), }) ); div() .child(Input::new(&amount)) .child(format!("Value: {}", amount.read(cx).value())) ``` ### Form with Multiple Inputs ```rust struct FormView { name_input: Entity, email_input: Entity, } v_flex() .gap_3() .child(Input::new(&self.name_input)) .child(Input::new(&self.email_input)) ``` ## Atomic inline tokens Use inline tokens for mentions, file references or commands that should be selected and deleted as a whole. For example, an input can display “Alice” as a token while `value()` and Copy return its text, `@alice`. ### Insert a reference Create the input state once, then insert a token when the user picks a reference: ```rust use gpui_kit::component::input::{InlineToken, Input, InputState}; let input = cx.new(|cx| InputState::new(window, cx)); input.update(cx, |state, cx| { state.replace_with_token( InlineToken::new("person-1", "@alice").with_label("Alice"), window, cx, ).expect("valid reference"); }); Input::new(&input) ``` `replace_with_token` replaces the selection, or inserts at the caret. It does not add a space. To replace a completion query such as `@ali`, use `replace_range_with_token(range, token, window, cx)`. Rust ranges are half-open UTF-8 byte ranges; use byte offsets such as those returned by `str::find`. The ID names the referenced resource, so two mentions of the same person carry the same ID. Use `text` for the value to copy or submit and `with_label` for its displayed name. Omit `with_label` to display the text itself. Users can move the caret to either side of a token, click it to select it, or delete it with Backspace/Delete. A selection that crosses part of a token includes the whole token. Undo/Redo restores both its text and reference. Pasting inserts plain text. ### Customize appearance and opening a reference Tokens render as an `InputToken` by default. The `token` slot supplies the element for each token; return one with an icon from it, and use `on_token_click` to open the reference: ```rust use gpui_kit::component::{ IconName, input::{InputToken, InlineTokenClickEvent}, }; Input::new(&input) .token(|token, _, _| { InputToken::new(token).icon(IconName::File) }) .on_token_click(|event: &InlineTokenClickEvent, _, _| { // Look up event.token().id() and open its resource. }); ``` You can also return your own single-row element. Keep it within the input's line height; content wider than the available row is clipped. Read selection and readonly/disabled state from the renderer's context. Do not edit the input from the renderer; event callbacks may update it. Keep hover and selection styles the same size. Tokens are measured whenever they render, so an element that grows once its data arrives reflows on the next frame. A click selects the token and then opens it; dragging or Shift-selecting a token does not open it. Readonly inputs allow opening references; disabled inputs do not. To offer a keyboard shortcut for opening an exactly selected token, bind `ActivateToken` to a key of your choice; assistive technology reaches the same listener through the token's click action. Add a menu item for it through `context_menu` when your application has a name for the reference, such as "Open file". ### Show a tooltip or preview on hover Use `on_token_hover` to react to pointer presence without changing the document. Hover never selects or edits; it reports entry and exit so the application can show a tooltip, preview, or status detail: ```rust use gpui_kit::component::input::InlineTokenHoverEvent; Input::new(&input) .on_token_hover(|event: &InlineTokenHoverEvent, _, _| { if event.is_hovered() { // Look up event.token().id() and show its preview. } // `false` means the pointer left the token: dismiss the preview. }); ``` The event carries the token, its byte `range()`, the measured `bounds()`, and `is_hovered()`. Disabled tokens never report hover entry, matching click; readonly tokens do. Removing, replacing, or disabling a hovered token still sends its exit event so tooltips dismiss. Keep hover and selection styles the same size so the row does not shift under the pointer. If your token includes a button, consume its mouse-down and click events so that it does not also open the reference. Apply `token.is_disabled()` to every child action, including accessibility actions, and `token.is_readonly()` to actions that change the content. ### Save, restore and submit Use `content()` to keep the text and references together when saving a draft: ```rust let draft = input.read(cx).content(); // Restore the saved draft later: `set_value` takes plain text or content. input.update(cx, |state, cx| { state.set_value(draft, window, cx); }); ``` To restore data from your own storage, build an `InputContent` from the text and attach each token to its byte range. `with_token` validates the range against the text as you go, so a content value is always consistent by the time it is set: ```rust use gpui_kit::component::input::InputContent; let draft = InputContent::new("Ask @alice") .with_token(4..10, InlineToken::new("person-1", "@alice").with_label("Alice"))?; ``` At submission time, read a fresh `content()`: `text()` is the message, and `tokens()` contains the references still present in it. Use each token's ID to look up its resource and handle missing resources before sending. `set_value` clears undo history and does not emit `InputEvent::Change`. Passing plain text removes every token, even if the text is unchanged; passing content restores its tokens, except in modes that cannot show them. Use `replace_all` for an undoable plain-text replacement. Token edits, including adding a reference to existing text, emit `InputEvent::Change`. Programmatic setters can update readonly or disabled inputs, so check these states in application commands that should be unavailable to users. ### Validation and supported inputs Tokens work with Input and Textarea. They are not available in Editor, NumberInput, formatted masks or password fields. A token's ID must not be blank; its text and label must be nonempty, single-line strings without control characters. Ranges cannot overlap or split a Unicode grapheme (such as an emoji or a character with a combining accent), and each token's text must match its range when restoring a draft. Token operations return `Result<_, InlineTokenError>`. A rejected operation leaves the input unchanged. If insertion returns `CompositionActive`, wait until the user finishes composing with their input method before inserting the token. ### JavaScript Create and keep an `InputState` in `init()`, then render an Input with that state. JavaScript ranges use **UTF-16 string offsets**, matching `slice()` and `indexOf()`: ```javascript import { Input, InputState } from "gpui-component"; // In init(): this.input = InputState(); this.input.set_value({ text: "🙂 @alice", tokens: [{ range: { start: 3, end: 9 }, token: { id: "person-1", text: "@alice", label: "Alice" }, }], }); // In render(): new Input(this.input) .on_token_click((event, cx) => { // Look up event.token.id and open its resource. }) .on_token_hover((event, cx) => { // event.hovered is true on entry and false on exit; show or dismiss a preview. }); ``` Use `replace_with_token` or `replace_range_with_token` to insert references, `content()` and `set_value(content)` to save and restore drafts, and `tokens()` to read the current references. To remove a reference, pass its current range to `set_selected_range`, then call `replace("")`. Returned snapshots are independent objects; changing one does not update the input. Make edits from initialization, event or task callbacks, not from renderers. Token validation errors expose an `error.code`, such as `InvalidBoundary` or `CompositionActive`; invalid argument shapes also throw. Textarea offers the same methods on `TextareaState()`. If you use `gpui-base`, construct these states with `InputState.new()` or `TextareaState.new()` instead. --- # Speech Source: /versions/v0.7.1/component/speech License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/speech and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. The speech module turns what the user says into text. `SpeechState` owns a dictation session: it captures audio from an `AudioInput`, feeds it to a `SpeechRecognizer`, keeps the transcript, and emits `SpeechEvent`s. `SpeechButton` starts and stops the session and `SpeechWaveform` shows the input level while it captures. The application owns where the text goes. The state never edits an input on its own; subscribe to its events and insert the final transcript where it belongs. Recognition and capture are both replaceable. Without further setup, the state uses the recognizer built into macOS or Windows and the default microphone. Implement `SpeechRecognizer` to use a cloud service or a local model, and `AudioInput` to feed audio from elsewhere. ## Enable the feature The microphone and the system recognizer are behind the `speech` feature: ```toml [dependencies] gpui-kit = { version = "0.7.1", features = ["speech"] } ``` The feature adds [cpal](https://crates.io/crates/cpal) for audio capture. On Linux, building it needs the ALSA development package (`libasound2-dev` on Debian and Ubuntu). Without the feature, and on the web, the types are still available but there is no default input and no system recognizer. A state then works only with both an application recognizer and an application input. ## Import ```rust use gpui_kit::component::speech::{ SpeechButton, SpeechEvent, SpeechState, SpeechStatus, SpeechWaveform, }; ``` ## Usage ### Dictate into an input Create the state beside the input it fills, subscribe to it, and put the button and waveform in the input's suffix: ```rust use gpui_kit::component::{ WindowExt as _, h_flex, input::{Input, InputState}, notification::Notification, speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform}, }; let input = cx.new(|cx| InputState::new(window, cx)); let speech = cx.new(SpeechState::new); let subscription = cx.subscribe_in(&speech, window, { let input = input.clone(); move |_, _, event, window, cx| match event { SpeechEvent::Final(text) if !text.is_empty() => { input.update(cx, |input, cx| input.insert(text.clone(), window, cx)); } SpeechEvent::Error(error) => { window.push_notification(Notification::error(error.to_string()), cx); } _ => {} } }); ``` ```rust let capturing = self.speech.read(cx).status().is_capturing(); Input::new(&self.input).suffix( h_flex() .gap_2() .when(capturing, |this| { this.child(SpeechWaveform::new(&self.speech).w(px(48.)).xsmall()) }) .child(SpeechButton::new(&self.speech).xsmall()), ) ``` Keep the subscription on the view that owns the input, for example in its `_subscriptions` list. ### Events | Event | When | | --- | --- | | `Started` | A session started and audio is being captured. | | `Partial(text)` | The transcript changed while the user speaks. | | `Final(text)` | The session ended normally. The text may be empty. | | `Cancelled` | The session was cancelled and its transcript discarded. | | `Error(error)` | The session failed and ended. | `Partial` and `Final` carry the **whole** transcript of the session so far: every committed phrase followed by the current hypothesis. A later event replaces an earlier one, so show the latest `Partial` text as a preview and commit only the `Final` text. `SpeechState::transcript()` returns the same text during render: ```rust let speech = self.speech.read(cx); v_flex() .gap_2() .child(Input::new(&self.input)) .when(speech.status().is_active(), |this| { this.child( div() .text_sm() .text_color(cx.theme().muted_foreground) .child(speech.transcript()), ) }) ``` ### Session control `SpeechButton` toggles the session. To drive it from an action, a key binding, or another control, call the state directly: ```rust speech.update(cx, |speech, cx| speech.start(cx)); // Does nothing while running. speech.update(cx, |speech, cx| speech.stop(cx)); // Emits `Final` once the result is in. speech.update(cx, |speech, cx| speech.cancel(cx)); // Emits `Cancelled` at once. speech.update(cx, |speech, cx| speech.toggle(cx)); // `start` when idle, otherwise `stop`. ``` `status()` reports where the session is: | `SpeechStatus` | Meaning | | --- | --- | | `Idle` | No session is running. | | `Connecting` | Audio is captured while the recognizer connects. | | `Recording` | Audio is captured and recognized. | | `Stopping` | Capture stopped; waiting for the final result. | `is_active()` is true for every status but `Idle`, and `is_capturing()` for `Connecting` and `Recording`. While `Stopping`, the button shows a spinner and ignores clicks. If the recognizer does not deliver its final result within the stop timeout, the session ends with the transcript so far. The timeout is 3 seconds by default: ```rust let speech = cx.new(|cx| SpeechState::new(cx).stop_timeout(Duration::from_secs(5))); ``` ### Button states `SpeechButton` is a ghost icon `Button` with a localized tooltip and accessible name: a microphone at rest, and a pressed stop glyph while capturing. - When the state has no recognizer or no input on this platform (`has_recognizer()` is `false`), the button renders **nothing**, so an application can place it unconditionally. Use `.show_when_unsupported(true)` to render it disabled instead. - When the recognizer reports itself unavailable (`is_available(cx)` is `false`), for example because the language is not installed, the button is disabled with an “unavailable” tooltip. - `.disabled(true)` disables it for application reasons, such as a readonly field. ```rust SpeechButton::new(&speech) .small() .show_when_unsupported(true) .disabled(readonly) ``` ## Choose a recognizer The state picks its recognizer in this order: 1. the one passed to `.recognizer(...)`; 2. otherwise the platform's `SystemRecognizer`, unless `.system_fallback(false)` turned it off; 3. otherwise none, and `has_recognizer()` is `false`. ```rust use gpui_kit::component::speech::SystemRecognizer; // Dictate Chinese regardless of the system language. let speech = cx.new(|cx| { SpeechState::new(cx).recognizer(SystemRecognizer::new().locale("zh-CN")) }); // Use the application's own recognizer instead of the system's. let speech = cx.new(|cx| SpeechState::new(cx).recognizer(CloudRecognizer::new(client))); // Never fall back to the system recognizer. Without a recognizer of its own, // `has_recognizer()` is false and the button renders nothing. let speech = cx.new(|cx| SpeechState::new(cx).system_fallback(false)); ``` `CloudRecognizer` stands for an application type that implements `SpeechRecognizer`; see [Implement a recognizer](#implement-a-recognizer). `SystemRecognizer::new()` recognizes the system's current language; `.locale(...)` takes a BCP 47 tag such as `en-US` or `zh-CN`. A language the platform cannot recognize makes the recognizer unavailable. ## Implement a recognizer Implement `SpeechRecognizer` to use any speech service. `start` opens a `RecognitionSession` and reports results through the `SpeechSink` it receives: ```rust use gpui_kit::App; use gpui_kit::component::speech::{ RecognitionSession, SpeechError, SpeechRecognizer, SpeechSink, }; /// Reports how much audio it has heard, as a stand-in for a real service. struct DurationRecognizer; impl SpeechRecognizer for DurationRecognizer { fn start( &self, sink: SpeechSink, cx: &mut App, ) -> Result, SpeechError> { // A service would connect here and call `ready` once connected. sink.ready(cx); Ok(Box::new(DurationSession { sink, samples: 0 })) } } struct DurationSession { sink: SpeechSink, samples: usize, } impl RecognitionSession for DurationSession { fn push_audio(&mut self, samples: &[i16], cx: &mut App) { // 16 kHz mono, the default `audio_format`. self.samples += samples.len(); let seconds = self.samples / 16_000; self.sink.hypothesis(format!("{seconds} s of audio"), cx); } fn finish(&mut self, cx: &mut App) { let seconds = self.samples / 16_000; self.sink.phrase(format!("{seconds} s of audio"), cx); self.sink.finish(cx); } } ``` A session runs as follows: 1. **`start`** opens the session. Connecting may take a while, so return at once, buffer the audio pushed in the meantime, and call `sink.ready(cx)` when the service accepts audio. The status moves from `Connecting` to `Recording`. 2. **`push_audio`** delivers interleaved 16-bit PCM in the recognizer's `audio_format()`, 16 kHz mono by default. Report the phrase being spoken with `sink.hypothesis(...)`, which replaces the previous hypothesis, and a recognized phrase with `sink.phrase(...)`, which commits it and clears the hypothesis. 3. **`finish`** means the user stopped talking: send the remaining audio, wait for the last result, then call `sink.finish(cx)`. The state then emits `Final`. 4. **Dropping** the session cancels it. Close the connection there and report nothing more. Report a failure with `sink.error(SpeechError::recognizer(error), cx)`; the session ends with `SpeechEvent::Error`. A few rules keep a recognizer simple: - The sink is cheap to clone, so a task that reads the service's responses can own a copy. Once its session is stopped, cancelled, or replaced, its calls are ignored, so a late response cannot leak into the next session. - Sink calls are applied after the current update. They may be made from anywhere on the main thread, including from inside `start` or `push_audio`. - Phrases are joined verbatim. Include any separator the language needs, such as a leading space between English sentences and none between Chinese ones. - Override `is_available` to return `false` while the recognizer cannot work, for example before the user signs in. It is called on every render, so keep it cheap. ## Provide audio `AudioInput` is the audio seam. The built-in `Microphone` captures the default input device and converts it to the recognizer's format. Replace it to feed audio from elsewhere, such as a file in tests: ```rust use std::time::Duration; use gpui_kit::{App, Subscription}; use gpui_kit::component::speech::{AudioFormat, AudioInput, AudioSink, SpeechError}; /// Pushes 100 ms of silence at a time. struct Silence; impl AudioInput for Silence { fn start( &self, format: AudioFormat, sink: AudioSink, cx: &mut App, ) -> Result { let len = format.sample_rate() as usize / 10 * format.channels() as usize; let task = cx.spawn(async move |cx| { loop { cx.background_executor().timer(Duration::from_millis(100)).await; cx.update(|cx| sink.push(vec![0; len], cx)); } }); // Capture runs until the state drops this subscription. Ok(Subscription::new(move || drop(task))) } } let speech = cx.new(|cx| SpeechState::new(cx).recognizer(recognizer).input(Silence)); ``` `levels()` exposes the recent input levels that `SpeechWaveform` draws, in `0.0..=1.0` with the oldest first, one per 80 ms of audio, for an application that renders its own meter. Peaks rise at once and fall back smoothly, and background noise reads as `0.0`. ## Platform support | Platform | `SystemRecognizer` | `Microphone` | | --- | --- | --- | | macOS | `SFSpeechRecognizer`, on the device only | Core Audio | | Windows | `Windows.Media.SpeechRecognition`, through Microsoft's online service | WASAPI | | Linux | None; provide a recognizer | ALSA | | Web | None | None; provide an input | ### macOS The application's `Info.plist` must describe why it uses the microphone and speech recognition: ```xml NSMicrophoneUsageDescription Dictate text into messages. NSSpeechRecognitionUsageDescription Turn your speech into text. ``` Without `NSMicrophoneUsageDescription`, the system refuses microphone access without asking. Without `NSSpeechRecognitionUsageDescription`, the system recognizer reports itself unavailable instead of asking, because asking without it would terminate the process. A binary run outside an app bundle, such as from `cargo run`, has neither key. Recognition runs on the device only, so audio never leaves the machine. A language the Mac cannot recognize offline is unavailable. ### Windows Dictation needs the language's speech pack and the **Online speech recognition** setting in **Settings > Privacy & security > Speech**. Audio is sent to Microsoft's online speech service. An application that must keep audio on the device should use `.system_fallback(false)` with its own recognizer. The Windows recognizer listens to the default microphone itself. The state still captures through its input, but only to drive the waveform, so a custom `AudioInput` does not change what the system recognizer hears. ### Linux Linux has no system recognizer. Speech input works there only with an application recognizer; without one, `SpeechButton` renders nothing. The `Microphone` captures through ALSA, and building it needs `libasound2-dev`. ## API Reference - [SpeechState] — the session: `recognizer`, `input`, `system_fallback`, `stop_timeout`, `start`, `stop`, `cancel`, `toggle`, `status`, `has_recognizer`, `is_available`, `transcript`, `levels` - [SpeechEvent] and [SpeechStatus] - [SpeechButton] — `show_when_unsupported`, plus `Sizable` and `Disableable` - [SpeechWaveform] — a live waveform that grows from its trailing edge as audio arrives and scrolls at a steady pace (one bar per 80 ms) until it fills its width (96 px unless styled); silence shows as dots; `Sizable` sets its height, `Styled` its width - [SpeechRecognizer], [RecognitionSession] and [SpeechSink] — the recognition seam - [AudioInput], [AudioSink] and [AudioFormat] — the audio seam - [SpeechError] - [SystemRecognizer] and [Microphone] — the `speech` feature's defaults [SpeechState]: https://docs.rs/gpui-component/latest/gpui_component/speech/struct.SpeechState.html [SpeechEvent]: https://docs.rs/gpui-component/latest/gpui_component/speech/enum.SpeechEvent.html [SpeechStatus]: https://docs.rs/gpui-component/latest/gpui_component/speech/enum.SpeechStatus.html [SpeechButton]: https://docs.rs/gpui-component/latest/gpui_component/speech/struct.SpeechButton.html [SpeechWaveform]: https://docs.rs/gpui-component/latest/gpui_component/speech/struct.SpeechWaveform.html [SpeechRecognizer]: https://docs.rs/gpui-component/latest/gpui_component/speech/trait.SpeechRecognizer.html [RecognitionSession]: https://docs.rs/gpui-component/latest/gpui_component/speech/trait.RecognitionSession.html [SpeechSink]: https://docs.rs/gpui-component/latest/gpui_component/speech/struct.SpeechSink.html [AudioInput]: https://docs.rs/gpui-component/latest/gpui_component/speech/trait.AudioInput.html [AudioSink]: https://docs.rs/gpui-component/latest/gpui_component/speech/struct.AudioSink.html [AudioFormat]: https://docs.rs/gpui-component/latest/gpui_component/speech/struct.AudioFormat.html [SpeechError]: https://docs.rs/gpui-component/latest/gpui_component/speech/enum.SpeechError.html [SystemRecognizer]: https://docs.rs/gpui-component/latest/gpui_component/speech/struct.SystemRecognizer.html [Microphone]: https://docs.rs/gpui-component/latest/gpui_component/speech/struct.Microphone.html --- # Slider Source: /versions/v0.7.1/component/slider License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/slider and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A slider component for selecting numeric values within a specified range. Supports both single value and range selection modes, horizontal and vertical orientations, custom styling, and step intervals. ## Import ```rust use gpui_kit::component::slider::{Slider, SliderState, SliderEvent, SliderValue}; ``` ## Usage ### Basic Slider ```rust let slider_state = cx.new(|_| { SliderState::new() .min(0.0) .max(100.0) .default_value(50.0) .step(1.0) }); Slider::new(&slider_state) ``` ### Slider with Event Handling ```rust struct MyView { slider_state: Entity, current_value: f32, } impl MyView { fn new(cx: &mut Context) -> Self { let slider_state = cx.new(|_| { SliderState::new() .min(0.0) .max(100.0) .default_value(25.0) .step(5.0) }); let subscription = cx.subscribe(&slider_state, |this, _, event: &SliderEvent, cx| { match event { SliderEvent::Change(value) => { this.current_value = value.start(); cx.notify(); } } }); Self { slider_state, current_value: 25.0, } } } impl Render for MyView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .gap_2() .child(Slider::new(&self.slider_state)) .child(format!("Value: {}", self.current_value)) } } ``` ### Range Slider ```rust let range_slider = cx.new(|_| { SliderState::new() .min(0.0) .max(100.0) .default_value(20.0..80.0) // Range from 20 to 80 .step(1.0) }); Slider::new(&range_slider) ``` ### Vertical Slider ```rust Slider::new(&slider_state) .vertical() .h(px(200.)) ``` ### Custom Step Intervals ```rust // Integer steps let integer_slider = cx.new(|_| { SliderState::new() .min(0.0) .max(10.0) .step(1.0) .default_value(5.0) }); // Decimal steps let decimal_slider = cx.new(|_| { SliderState::new() .min(0.0) .max(1.0) .step(0.01) .default_value(0.5) }); ``` ### Min/Max Configuration ```rust // Temperature slider let temp_slider = cx.new(|_| { SliderState::new() .min(-10.0) .max(40.0) .default_value(20.0) .step(0.5) }); // Percentage slider let percent_slider = cx.new(|_| { SliderState::new() .min(0.0) .max(100.0) .default_value(75.0) .step(5.0) }); ``` ### Disabled State ```rust Slider::new(&slider_state) .disabled(true) ``` ### Custom Styling ```rust Slider::new(&slider_state) .bg(cx.theme().success) .text_color(cx.theme().success_foreground) .rounded(px(4.)) ``` ### Scale There have 2 types of scale for the slider: - `Linear` (default) - `Logarithmic` The logarithmic scale is useful when the range of values is large and you want to give more precision to smaller values. ```rust let log_slider = cx.new(|_| { SliderState::new() .min(1.0) // min must be greater than 0 for log scale .max(1000.0) .default_value(10.0) .step(1.0) .scale(SliderScale::Logarithmic) }); ``` In this case: $$ v = min \times (max/min)^p $$ The value `v` is calculated using the formula above, where `p` is the slider percentage (0 to 1). - If slider at 25%, value will be approximately `5.62`. - If slider at 50%, value will be approximately `31.62`. - If slider at 75%, value will be approximately `177.83`. - If slider at 100%, value will be `1000.0`. #### Conversions ```rust // From f32 let single_value: SliderValue = 42.0.into(); // From tuple let range_value: SliderValue = (10.0, 90.0).into(); // From Range let range_value: SliderValue = (10.0..90.0).into(); ``` ### SliderEvent | Event | Description | | ---------------------- | --------------------------------------------------------------- | | `Change(SliderValue)` | Emitted continuously while the slider value is being changed | | `Release(SliderValue)` | Emitted once when the user releases the slider after interaction | ### Styling The slider component implements `Styled` trait and supports: - Background color for track and thumb - Text color for thumb - Border radius - Size customization ## Examples ### Color Picker ```rust struct ColorPicker { hue_slider: Entity, saturation_slider: Entity, lightness_slider: Entity, alpha_slider: Entity, current_color: Hsla, } impl ColorPicker { fn new(cx: &mut Context) -> Self { let hue_slider = cx.new(|_| { SliderState::new() .min(0.0) .max(1.0) .step(0.01) .default_value(0.5) }); let saturation_slider = cx.new(|_| { SliderState::new() .min(0.0) .max(1.0) .step(0.01) .default_value(1.0) }); // Subscribe to all sliders to update color let subscriptions = [&hue_slider, &saturation_slider /* ... */] .iter() .map(|slider| { cx.subscribe(slider, |this, _, event: &SliderEvent, cx| { match event { SliderEvent::Change(_) => { this.update_color(cx); } } }) }) .collect::>(); Self { hue_slider, saturation_slider, // ... other fields } } fn update_color(&mut self, cx: &mut Context) { let h = self.hue_slider.read(cx).value().start(); let s = self.saturation_slider.read(cx).value().start(); // ... calculate color self.current_color = hsla(h, s, l, a); cx.notify(); } } impl Render for ColorPicker { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { h_flex() .gap_4() .child( v_flex() .gap_2() .child("Hue") .child(Slider::new(&self.hue_slider).vertical().h(px(120.))) ) .child( v_flex() .gap_2() .child("Saturation") .child(Slider::new(&self.saturation_slider).vertical().h(px(120.))) ) // ... other sliders } } ``` ### Volume Control ```rust struct VolumeControl { volume_slider: Entity, volume: f32, } impl VolumeControl { fn new(cx: &mut Context) -> Self { let volume_slider = cx.new(|_| { SliderState::new() .min(0.0) .max(100.0) .step(1.0) .default_value(50.0) }); let subscription = cx.subscribe(&volume_slider, |this, _, event: &SliderEvent, cx| { match event { SliderEvent::Change(value) => { this.volume = value.start(); this.apply_volume_change(); cx.notify(); } } }); Self { volume_slider, volume: 50.0, } } fn apply_volume_change(&self) { // Apply volume change to audio system println!("Volume changed to: {}%", self.volume); } } impl Render for VolumeControl { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { h_flex() .items_center() .gap_3() .child("🔊") .child(Slider::new(&self.volume_slider).flex_1()) .child(format!("{}%", self.volume as i32)) } } ``` ### Price Range Filter ```rust struct PriceFilter { price_range: Entity, min_price: f32, max_price: f32, } impl PriceFilter { fn new(cx: &mut Context) -> Self { let price_range = cx.new(|_| { SliderState::new() .min(0.0) .max(1000.0) .step(10.0) .default_value(100.0..500.0) // Range slider }); let subscription = cx.subscribe(&price_range, |this, _, event: &SliderEvent, cx| { match event { SliderEvent::Change(value) => { this.min_price = value.start(); this.max_price = value.end(); this.filter_products(); cx.notify(); } } }); Self { price_range, min_price: 100.0, max_price: 500.0, } } fn filter_products(&self) { println!("Filtering products: ${} - ${}", self.min_price, self.max_price); } } impl Render for PriceFilter { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { v_flex() .gap_2() .child("Price Range") .child(Slider::new(&self.price_range)) .child(format!("${} - ${}", self.min_price as i32, self.max_price as i32)) } } ``` ### Temperature Slider with Custom Styling ```rust struct TemperatureControl { temp_slider: Entity, temperature: f32, } impl Render for TemperatureControl { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let temp_color = if self.temperature < 10.0 { cx.theme().info // Cold - blue } else if self.temperature > 25.0 { cx.theme().destructive // Hot - red } else { cx.theme().success // Comfortable - green }; v_flex() .gap_3() .child("Temperature Control") .child( Slider::new(&self.temp_slider) .bg(temp_color) .text_color(cx.theme().background) .rounded(px(8.)) ) .child(format!("{}°C", self.temperature as i32)) } } ``` ## Keyboard Shortcuts | Key | Action | | ------------- | ------------------------------ | | `←` / `↓` | Decrease value by step | | `→` / `↑` | Increase value by step | | `Page Down` | Decrease by larger amount | | `Page Up` | Increase by larger amount | | `Home` | Set to minimum value | | `End` | Set to maximum value | | `Tab` | Move focus to next element | | `Shift + Tab` | Move focus to previous element | --- # Alert Source: /versions/v0.7.1/component/alert License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/alert and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A versatile alert component for displaying important messages to users. Supports multiple variants (info, success, warning, error), custom icons, optional titles, closable functionality, and banner mode. Perfect for notifications, status messages, and user feedback. ## Import ```rust use gpui_kit::component::alert::Alert; ``` ## Usage ### Basic Alert ```rust Alert::new("alert-id", "This is a basic alert message.") ``` ### Alert with Title ```rust Alert::new("alert-with-title", "Your changes have been saved successfully.") .title("Success!") ``` ### Alert Variants ```rust // Info alert (blue) Alert::info("info-alert", "This is an informational message.") .title("Information") // Success alert (green) Alert::success("success-alert", "Your operation completed successfully.") .title("Success!") // Warning alert (yellow/orange) Alert::warning("warning-alert", "Please review your settings before proceeding.") .title("Warning") // Error alert (red) Alert::error("error-alert", "An error occurred while processing your request.") .title("Error") ``` ### Alert Sizes ```rust use gpui_kit::component::{alert::Alert, Sizable as _}; Alert::info("alert", "Message content") .xsmall() .title("XSmall Alert") Alert::info("alert", "Message content") .small() .title("Small Alert") Alert::info("alert", "Message content") .title("Medium Alert") Alert::info("alert", "Message content") .large() .title("Large Alert") ``` ### Closable Alerts When you add an `on_close` handler, a close button appears on the alert: ```rust Alert::info("closable-alert", "This alert can be dismissed.") .title("Dismissible") .on_close(|_event, _window, _cx| { println!("Alert was closed"); // Handle alert dismissal }) ``` ### Banner Mode Banner alerts take full width and don't display titles: ```rust Alert::info("banner-alert", "This is a banner alert that spans the full width.") .banner() Alert::success("banner-success", "Operation completed successfully!") .banner() Alert::warning("banner-warning", "System maintenance scheduled for tonight.") .banner() Alert::error("banner-error", "Service temporarily unavailable.") .banner() ``` ### Custom Icons ```rust use gpui_kit::component::IconName; Alert::new("custom-icon", "Meeting scheduled for tomorrow at 3 PM.") .title("Calendar Reminder") .icon(IconName::Calendar) ``` ### With Markdown Content We can use `TextView` to render formatted (Markdown or HTML) text within the alert, for displaying lists, bold text, links, etc. ```rust use gpui_kit::component::text::markdown; Alert::error( "error-with-markdown", markdown( "Please verify your billing information and try again.\n\ - Check your card details\n\ - Ensure sufficient funds\n\ - Verify billing address" ), ) .title("Payment Failed") ``` ### Conditional Visibility ```rust Alert::info("conditional-alert", "This alert may be hidden.") .title("Conditional") .visible(should_show_alert) // boolean condition ``` ## API Reference - [Alert] ## Examples ### Form Validation Errors ```rust Alert::error( "validation-error", "Please correct the following errors before submitting:\n\ - Email address is required\n\ - Password must be at least 8 characters\n\ - Terms of service must be accepted" ) .title("Validation Failed") ``` ### Success Notification ```rust Alert::success("save-success", "Your profile has been updated successfully.") .title("Changes Saved") .on_close(|_, _, _| { // Auto-dismiss after showing }) ``` ### System Status Banner ```rust Alert::warning( "maintenance-banner", "Scheduled maintenance will occur tonight from 2:00 AM to 4:00 AM EST. \ Some services may be temporarily unavailable." ) .banner() .large() ``` ### Interactive Alert with Custom Action ```rust Alert::info("update-available", "A new version of the application is available.") .title("Update Available") .icon(IconName::Download) .on_close(cx.listener(|this, _, _, cx| { // Handle update or dismiss this.handle_update_notification(cx); })) ``` ### Multi-line Content with Formatting ```rust use gpui_kit::component::text::markdown; Alert::warning( "security-alert", markdown( "**Security Notice**: Unusual activity detected on your account.\n\n\ Recent activity:\n\ - Login from new device (Chrome on Windows)\n\ - Location: San Francisco, CA\n\ - Time: Today at 2:30 PM\n\n\ If this wasn't you, please [change your password](/) immediately." ) ) .title("Security Alert") .icon(IconName::Shield) ``` [Alert]: https://docs.rs/gpui-component/latest/gpui_component/alert/struct.Alert.html --- # Badge Source: /versions/v0.7.1/component/badge License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/badge and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A versatile badge component that can display counts, dots, or icons on elements. Perfect for indicating notifications, status, or other contextual information on avatars, icons, or other UI elements. ## Import ```rust use gpui_kit::component::badge::Badge; ``` ## Usage ### Badge with Count Use `count` to display a numeric badge, if the count is greater than zero (`> 0`) the badge will be shown, otherwise it will be hidden. There is a default maximum count of `99`, any count above this will be displayed as `99+`. You can customize this maximum using the [max](https://docs.rs/gpui-component/latest/gpui_component/badge/struct.Badge.html#method.max) method. ```rust Badge::new() .count(3) .child(Icon::new(IconName::Bell)) ``` ### Variants - Default: Displays a numeric count. - Dot: A small dot indicator, typically used for status. - Icon: Displays an icon instead of a number. ```rust // Number badge (default) Badge::new() .count(5) .child(Avatar::new().src("https://example.com/avatar.jpg")) // Dot badge Badge::new() .dot() .child(Icon::new(IconName::Inbox)) // Icon badge Badge::new() .icon(IconName::Check) .child(Avatar::new().src("https://example.com/avatar.jpg")) ``` ### Badge Sizes The Badge is also implemented with the [Sizable] trait, allowing you to set small, medium (default), or large sizes. ```rust // Small badge Badge::new() .small() .count(1) .child(Avatar::new().small()) // Medium badge (default) Badge::new() .count(5) .child(Avatar::new()) // Large badge Badge::new() .large() .count(10) .child(Avatar::new().large()) ``` ### Badge Colors ```rust use gpui_kit::component::ActiveTheme; // Custom colors Badge::new() .count(3) .color(cx.theme().blue) .child(Avatar::new()) Badge::new() .icon(IconName::Star) .color(cx.theme().yellow) .child(Avatar::new()) Badge::new() .dot() .color(cx.theme().green) .child(Icon::new(IconName::Bell)) ``` ### Badge on Icons ```rust use gpui_kit::component::{Icon, IconName}; // Badge with count on icon Badge::new() .count(3) .child(Icon::new(IconName::Bell).large()) // Badge with high count (shows max) Badge::new() .count(103) .child(Icon::new(IconName::Inbox).large()) // Custom max count Badge::new() .count(150) .max(999) .child(Icon::new(IconName::Mail)) ``` ### Badge on Avatars ```rust use gpui_kit::component::avatar::Avatar; // Basic count badge Badge::new() .count(5) .child(Avatar::new().src("https://example.com/avatar.jpg")) // Status badge with icon Badge::new() .icon(IconName::Check) .color(cx.theme().green) .child(Avatar::new().src("https://example.com/avatar.jpg")) // Online indicator with dot Badge::new() .dot() .color(cx.theme().green) .child(Avatar::new().src("https://example.com/avatar.jpg")) ``` ### Complex Nested Badges ```rust // Badge on badge for complex status Badge::new() .count(212) .large() .child( Badge::new() .icon(IconName::Check) .large() .color(cx.theme().cyan) .child(Avatar::new().large().src("https://example.com/avatar.jpg")) ) // Multiple status indicators Badge::new() .count(2) .color(cx.theme().green) .large() .child( Badge::new() .icon(IconName::Star) .large() .color(cx.theme().yellow) .child(Avatar::new().large().src("https://example.com/avatar.jpg")) ) ``` ## API Reference - [Badge] ## Examples ### Notification Indicators ```rust // Unread messages Badge::new() .count(12) .child(Icon::new(IconName::Mail).large()) // New notifications Badge::new() .count(3) .color(cx.theme().red) .child(Icon::new(IconName::Bell).large()) // High priority with custom max Badge::new() .count(1234) .max(999) .color(cx.theme().orange) .child(Icon::new(IconName::AlertTriangle)) ``` ### Status Indicators ```rust // Online status Badge::new() .dot() .color(cx.theme().green) .child(Avatar::new().src("https://example.com/user.jpg")) // Verified status Badge::new() .icon(IconName::CheckCircle) .color(cx.theme().blue) .child(Avatar::new().src("https://example.com/verified-user.jpg")) // Warning status Badge::new() .icon(IconName::AlertTriangle) .color(cx.theme().yellow) .child(Avatar::new().src("https://example.com/user.jpg")) ``` ### Different Badge Positions ```rust // The badge automatically positions itself based on variant: // - Dot: top-right corner (small dot) // - Number: top-right with dynamic sizing // - Icon: bottom-right corner with border ``` ### Count Formatting ```rust // Numbers 1-99 show as-is Badge::new().count(5) // Shows "5" Badge::new().count(99) // Shows "99" // Numbers above max show with "+" Badge::new().count(100) // Shows "99+" (default max) Badge::new().count(1000).max(999) // Shows "999+" // Zero count hides the badge Badge::new().count(0) // Badge not visible ``` [Badge]: https://docs.rs/gpui_component/latest/gpui_component/badge/struct.Badge.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html --- # AlertDialog Source: /versions/v0.7.1/component/alert-dialog License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/alert-dialog and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. AlertDialog is a modal dialog component that interrupts the user with important content and expects a response. It is built on top of the [Dialog] component with opinionated defaults and a simplified API. ## Differences from Dialog AlertDialog provides these defaults on top of Dialog: - Not overlay closable by default (can be changed with `overlay_closable(true)`) - No close button by default (can be changed with `close_button(true)`) - Footer buttons are center-aligned (Dialog uses right-alignment) - Simplified API focused on alert and confirmation scenarios ## Import ```rust use gpui_kit::component::dialog::{AlertDialog, DialogAction, DialogClose}; use gpui_kit::component::WindowExt; ``` ## Usage ### Setup Application Root View Like Dialog, you need to set up your application's root view to render the dialog layer. See [Dialog documentation](/versions/v0.7.1/component/dialog#setup-application-root-view) for details. ### Basic AlertDialog (Declarative API) Create a fully declarative AlertDialog using `trigger` and `content`: ```rust use gpui_kit::component::dialog::{AlertDialog, DialogHeader, DialogTitle, DialogDescription, DialogFooter}; AlertDialog::new(cx) .trigger( Button::new("show-alert") .outline() .label("Show Alert") ) .content(|content, _, cx| { content .child( DialogHeader::new() .child(DialogTitle::new().child("Are you absolutely sure?")) .child(DialogDescription::new().child( "This action cannot be undone. \ This will permanently delete your account from our servers." )) ) .child( DialogFooter::new() .child( Button::new("cancel") .outline() .label("Cancel") .on_click(|_, window, cx| { window.close_dialog(cx); }) ) .child( Button::new("ok") .primary() .label("Continue") .on_click(|_, window, cx| { window.push_notification("Confirmed", cx); window.close_dialog(cx); }) ) ) }) ``` ### Using DialogAction and DialogClose `DialogAction` and `DialogClose` are wrapper components that simplify button click handling by automatically triggering the appropriate actions: - **DialogClose**: Wraps a button to trigger the `Cancel` action, invoking `on_cancel` callback - **DialogAction**: Wraps a button to trigger the `Confirm` action, invoking `on_ok` callback These components eliminate the need to manually call `window.close_dialog(cx)`: ```rust AlertDialog::new(cx) .trigger(Button::new("show-alert").outline().label("Show Alert")) .on_ok(|_, window, cx| { window.push_notification("You confirmed!", cx); true // Return true to close dialog }) .on_cancel(|_, window, cx| { window.push_notification("You cancelled!", cx); true }) .content(|content, _, cx| { content .child( DialogHeader::new() .child(DialogTitle::new().child("Confirm Action")) .child(DialogDescription::new().child("Do you want to proceed?")) ) .child( DialogFooter::new() .child( DialogClose::new().child( Button::new("cancel").outline().label("Cancel") ) ) .child( DialogAction::new().child( Button::new("ok").primary().label("Confirm") ) ) ) }) ``` **Benefits:** - No need to manually close the dialog - Automatically connects to `on_ok` and `on_cancel` callbacks - Cleaner, more declarative code - Supports returning `false` from callbacks to prevent closing ### Basic AlertDialog (Imperative API) Open a dialog imperatively using `WindowExt::open_alert_dialog`: ```rust window.open_alert_dialog(cx, |alert, _, _| { alert .title("Delete File") .description("Are you sure you want to delete this file? This action cannot be undone.") .show_cancel(true) .on_ok(|_, window, cx| { window.push_notification("File deleted", cx); true // Return true to close dialog }) }) ``` ### Custom Buttons Set the button text and variant directly on the dialog: ```rust use gpui_kit::component::button::ButtonVariant; window.open_alert_dialog(cx, |alert, _, _| { alert .title("Delete Account") .description("This will permanently delete your account and all associated data.") .confirm() .ok_text("Delete") .ok_variant(ButtonVariant::Danger) .cancel_text("Keep") .on_ok(|_, window, cx| { window.push_notification("Account deleted", cx); true }) }) ``` `button_props` takes the same properties as one value, for a configuration you want to build up or pass around. It overrides only the fields the value sets, so everything the dialog already carries — the Cancel button `confirm` asked for, a callback an earlier `on_ok` installed — survives, whatever the call order: ```rust use gpui_kit::component::dialog::DialogButtonProps; window.open_alert_dialog(cx, move |alert, _, _| { alert .title("Delete Account") .confirm() .button_props( DialogButtonProps::default() .ok_text("Delete") .ok_variant(ButtonVariant::Danger) ) }) ``` ### AlertDialog with Icon Using icon in declarative API: ```rust use gpui_kit::component::{Icon, IconName, ActiveTheme}; AlertDialog::new(cx) .w(px(320.)) .trigger(Button::new("permission").outline().label("Request Permission")) .on_ok(|_, window, cx| { window.push_notification("Permission granted", cx); true }) .content(|content, _, cx| { content .child( DialogHeader::new() .items_center() .child( Icon::new(IconName::TriangleAlert) .size_10() .text_color(cx.theme().warning) ) .child(DialogTitle::new().child("Network Permission Required")) .child(DialogDescription::new().child( "We need your permission to access the network to provide better services." )) ) .child( DialogFooter::new() .v_flex() .child( DialogAction::new().child( Button::new("allow").w_full().primary().label("Allow") ) ) .child( DialogClose::new().child( Button::new("deny").w_full().outline().label("Don't Allow") ) ) ) }) ``` Using icon in imperative API: ```rust window.open_alert_dialog(cx, |alert, _, cx| { alert .title("Warning") .description("This action requires confirmation.") .icon( Icon::new(IconName::AlertTriangle) .size_8() .text_color(cx.theme().warning) ) }) ``` ### Destructive Action Confirmation ```rust AlertDialog::new(cx) .trigger( Button::new("delete-account") .outline() .danger() .label("Delete Account") ) .on_ok(|_, window, cx| { window.push_notification("Account deletion initiated", cx); true }) .content(|content, _, _| { content .child( DialogHeader::new() .child(DialogTitle::new().child("Delete Account")) .child(DialogDescription::new().child( "This will permanently delete your account \ and all associated data. This action cannot be undone." )) ) .child( DialogFooter::new() .child( DialogClose::new().child( Button::new("cancel").flex_1().outline().label("Cancel") ) ) .child( DialogAction::new().child( Button::new("delete") .flex_1() .outline() .danger() .label("Delete Forever") ) ) ) }) ``` ### Custom Width ```rust AlertDialog::new(cx) .width(px(500.)) .trigger(Button::new("custom-width").label("Custom Width")) .content(|content, _, _| { // ... dialog content }) ``` ### Controlling Dialog Close Behavior #### Allow Overlay Click to Close ```rust window.open_alert_dialog(cx, |alert, _, _| { alert .title("Notice") .description("Click outside this dialog or press ESC to close it.") .overlay_closable(true) }) ``` #### Disable Keyboard ESC to Close ```rust window.open_alert_dialog(cx, |alert, _, _| { alert .title("Important Notice") .description("Please read this carefully before proceeding.") .keyboard(false) }) ``` #### Show Close Button ```rust window.open_alert_dialog(cx, |alert, _, _| { alert .title("Information") .description("Some information...") .close_button(true) }) ``` ### Prevent Dialog from Closing Return `false` from `on_ok` or `on_cancel` callbacks to prevent the dialog from closing: ```rust window.open_alert_dialog(cx, |alert, _, _| { alert .title("Processing") .description("A process is running. Click Continue to stop it or Cancel to keep waiting.") .confirm() .ok_text("Continue") .on_ok(|_, window, cx| { // Return false to prevent closing window.push_notification("Cannot close: Process still running", cx); false }) .on_cancel(|_, window, cx| { window.push_notification("Waiting...", cx); false }) }) ``` ### Dialog Close Callback Use `on_close` to execute actions after the dialog closes (called after `on_ok` or `on_cancel`): ```rust window.open_alert_dialog(cx, |alert, _, _| { alert .title("Confirm") .description("Are you sure?") .on_close(|_, window, cx| { window.push_notification("Dialog closed", cx); }) }) ``` ## API Reference ### AlertDialog | Method | Description | | ------------------------ | ------------------------------------------------------------- | | `new(cx)` | Create a new AlertDialog | | `trigger(element)` | Set trigger element that opens the dialog when clicked | | `content(builder)` | Set dialog content using a builder function (declarative API) | | `title(title)` | Set dialog title (imperative API) | | `description(desc)` | Set dialog description (imperative API) | | `icon(icon)` | Set dialog icon (imperative API) | | `confirm()` | Show OK and Cancel buttons | | `ok_text(text)` | Set OK button text, default "OK" | | `ok_variant(variant)` | Set OK button variant, default `Primary` | | `cancel_text(text)` | Set cancel button text, default "Cancel" | | `cancel_variant(variant)`| Set cancel button variant | | `button_props(props)` | Override the button properties the value sets, keep the rest | | `show_cancel(bool)` | Show/hide cancel button, default `false` | | `width(px)` | Set dialog width, default `420px` | | `overlay_closable(bool)` | Allow clicking overlay to close, default `false` | | `close_button(bool)` | Show/hide close button, default `false` | | `keyboard(bool)` | Support ESC key to close, default `true` | | `on_ok(callback)` | Set OK button callback, return `true` to close dialog | | `on_cancel(callback)` | Set cancel button callback, return `true` to close dialog | | `on_close(callback)` | Set callback after dialog closes | ### DialogButtonProps Every property is unset until a builder sets it, and an unset property keeps whatever the dialog already carries. | Method | Description | | ------------------------- | ---------------------------------------- | | `ok_text(text)` | Set OK button text, default "OK" | | `cancel_text(text)` | Set cancel button text, default "Cancel" | | `ok_variant(variant)` | Set OK button variant | | `cancel_variant(variant)` | Set cancel button variant | | `show_cancel(bool)` | Show/hide cancel button | | `on_ok(callback)` | Set OK callback | | `on_cancel(callback)` | Set cancel callback | ### DialogAction A wrapper component that automatically triggers the `Confirm` action when its child element is clicked. This invokes the `on_ok` callback set on the AlertDialog. **Usage:** ```rust DialogAction::new().child( Button::new("ok").primary().label("Confirm") ) ``` **Behavior:** - Dispatches `Confirm` action on click - Invokes the `on_ok` callback - Dialog closes if callback returns `true` - Dialog stays open if callback returns `false` ### DialogClose A wrapper component that automatically triggers the `Cancel` action when its child element is clicked. This invokes the `on_cancel` callback set on the AlertDialog. **Usage:** ```rust DialogClose::new().child( Button::new("cancel").outline().label("Cancel") ) ``` **Behavior:** - Dispatches `Cancel` action on click - Invokes the `on_cancel` callback - Dialog closes if callback returns `true` (or if no callback is set) - Dialog stays open if callback returns `false` ## Examples ### Delete Confirmation Using imperative API: ```rust Button::new("delete") .danger() .label("Delete") .on_click(|_, window, cx| { window.open_alert_dialog(cx, |alert, _, _| { alert .title("Delete File?") .description("This action cannot be undone.") .confirm() .ok_text("Delete") .ok_variant(ButtonVariant::Danger) .on_ok(|_, window, cx| { // Perform delete operation window.push_notification("File deleted", cx); true }) }); }) ``` Or using declarative API with DialogAction/DialogClose: ```rust AlertDialog::new(cx) .trigger(Button::new("delete").danger().label("Delete")) .on_ok(|_, window, cx| { window.push_notification("File deleted", cx); true }) .content(|content, _, cx| { content .child( DialogHeader::new() .child(DialogTitle::new().child("Delete File?")) .child(DialogDescription::new().child("This action cannot be undone.")) ) .child( DialogFooter::new() .child( DialogClose::new().child( Button::new("cancel").outline().label("Cancel") ) ) .child( DialogAction::new().child( Button::new("delete-confirm").danger().label("Delete") ) ) ) }) ``` ### Session Timeout ```rust window.open_alert_dialog(cx, |alert, _, _| { alert .content(|content, _, _| { content .child( DialogHeader::new() .items_center() .child(DialogTitle::new().child("Session Expired")) .child(DialogDescription::new().child( "Your session has expired due to inactivity. \ Please log in again to continue." )) ) .child( DialogFooter::new() .child( Button::new("sign-in") .label("Sign in") .primary() .flex_1() .on_click(|_, window, cx| { window.push_notification("Redirecting to login...", cx); window.close_dialog(cx); }) ) ) }) }) ``` ### Update Available ```rust AlertDialog::new(cx) .trigger(Button::new("update").outline().label("Update Available")) .on_cancel(|_, window, cx| { window.push_notification("Update postponed", cx); true }) .on_ok(|_, window, cx| { window.push_notification("Starting update...", cx); true }) .content(|content, _, _| { content .child( DialogHeader::new() .child(DialogTitle::new().child("Update Available")) .child(DialogDescription::new().child( "A new version (v2.0.0) is available. \ This update includes new features and bug fixes." )) ) .child( DialogFooter::new() .child( DialogClose::new().child( Button::new("later").flex_1().outline().label("Later") ) ) .child( DialogAction::new().child( Button::new("update-now").flex_1().primary().label("Update Now") ) ) ) }) ``` ## Best Practices 1. **Choose the Right API**: Use imperative API (`open_alert_dialog`) for simple confirmations; use declarative API (`trigger` + `content`) for complex layouts or integration with other components 2. **Use DialogAction and DialogClose**: Prefer wrapping buttons with `DialogAction` and `DialogClose` over manual `window.close_dialog()` calls for cleaner, more declarative code 3. **Clarify Intent**: Use appropriate button variants (e.g., `ButtonVariant::Danger` for delete operations) to communicate the importance of actions 4. **Provide Clear Descriptions**: Ensure users understand the consequences of their actions, especially for destructive operations 5. **Use Icons Wisely**: Icons can enhance attention for warnings and errors, but use them appropriately 6. **Prevent Closing Carefully**: Only prevent dialog closing when user confirmation is truly necessary (e.g., a process is running) 7. **Maintain Consistency**: Keep dialog button order and styles consistent throughout your application ## Related Components - [Dialog] - More flexible dialog component - [DialogHeader] - Dialog header component - [DialogTitle] - Dialog title component - [DialogDescription] - Dialog description component - [DialogFooter] - Dialog footer component - [DialogAction] - Wrapper component for confirm/OK buttons - [DialogClose] - Wrapper component for cancel/close buttons [AlertDialog]: https://docs.rs/gpui-component/latest/gpui_component/dialog/struct.AlertDialog.html [Dialog]: https://docs.rs/gpui-component/latest/gpui_component/dialog/struct.Dialog.html [DialogHeader]: https://docs.rs/gpui-component/latest/gpui_component/dialog/struct.DialogHeader.html [DialogTitle]: https://docs.rs/gpui-component/latest/gpui_component/dialog/struct.DialogTitle.html [DialogDescription]: https://docs.rs/gpui-component/latest/gpui_component/dialog/struct.DialogDescription.html [DialogFooter]: https://docs.rs/gpui-component/latest/gpui_component/dialog/struct.DialogFooter.html [DialogAction]: https://docs.rs/gpui-component/latest/gpui_component/dialog/struct.DialogAction.html [DialogClose]: https://docs.rs/gpui-component/latest/gpui_component/dialog/struct.DialogClose.html --- # Dock Source: /versions/v0.7.1/component/dock License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/dock and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Dock builds application workspaces from draggable tab groups, nested splits, and collapsible left, right, and bottom docks. It is the layout foundation used by Longbridge in production, not an isolated UI demo. `gpui-base` owns the data model, layout calculation, and drag-and-drop behavior. `gpui-component` supplies the polished controls and visual language. Use `gpui_kit::component::dock` when you want a Dock ready to fit into a real application. For the renderer-independent architecture and custom-renderer API, see [Dock — gpui-base](/versions/v0.7.1/base/dock). ## Create a dock area Create the area through `DockSkin`. Keep the returned skin if you want to change its appearance later. ```rust use gpui_kit::component::dock::{DockArea, DockSkin}; struct Workspace { dock_area: Entity, dock_skin: Rc, } impl Workspace { fn new(window: &mut Window, cx: &mut Context) -> Self { let (dock_area, dock_skin) = DockSkin::dock_area("main-dock", Some(1), window, cx); Self { dock_area, dock_skin } } } ``` The optional version belongs to your saved layout schema. Increase it when your application can no longer restore an older layout. ## Define a panel A styled Dock panel implements `BasePanel` for identity and persistence, and `Panel` for its title, tab, and toolbar presentation. ```rust use gpui_kit::component::dock::{BasePanel, Panel, PanelEvent}; struct FilesPanel { focus_handle: FocusHandle, } impl EventEmitter for FilesPanel {} impl Focusable for FilesPanel { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } impl BasePanel for FilesPanel { fn panel_name(&self) -> &'static str { "FilesPanel" } } impl Panel for FilesPanel { fn title(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { "Files" } } impl Render for FilesPanel { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div().size_full().p_3().child("Project files") } } ``` Wrap styled panels with `panel_handle`. This preserves the `gpui-component` panel chrome when the base Dock stores the panel behind its renderer-independent handle. ## Describe the initial layout `DockLayout` is a value: compose it before a window exists, serialize it, compare it, or generate it from application state. Tabs and splits can be nested freely. ```rust use gpui_kit::component::dock::{DockLayout, panel_handle}; let files = cx.new(|cx| FilesPanel { focus_handle: cx.focus_handle(), }); let editor = cx.new(|cx| EditorPanel::new(cx)); let layout = DockLayout::h_split() .child( DockLayout::tabs().panel_view(panel_handle(files), cx), Some(px(240.)), ) .child( DockLayout::tabs().panel_view(panel_handle(editor), cx), None, ); self.dock_area.update(cx, |area, cx| { area.set_center(layout, window, cx); }); ``` Use `h_split()` and `v_split()` for rows and columns and `tabs()` for a tab group. A `None` split size fills the remaining space. The Dock area also supports left, right, and bottom regions through `DockPlacement`. Panels can be added, removed, activated, zoomed, and moved at runtime; user operations emit `DockEvent`, including `LayoutChanged` for persistence. ## Restore and persist layouts Dump the entire workspace as `DockAreaState`, store it with Serde, then load it on the next launch. ```rust use gpui_kit::component::dock::DockAreaState; // Save. let state = self.dock_area.read(cx).dump(cx); let json = serde_json::to_string_pretty(&state)?; // Restore. let state: DockAreaState = serde_json::from_str(&json)?; self.dock_area.update(cx, |area, cx| { area.load(state, window, cx) })?; ``` Register every restorable panel name during application initialization. The registered factory recreates the styled panel behind a `panel_handle`. ```rust register_panel(cx, "FilesPanel", |state, window, cx| { let panel = cx.new(|cx| FilesPanel::from_state(state, window, cx)); panel_handle(panel) }); ``` Dock state retains compatibility with layouts saved by earlier releases. Keep a sensible fallback layout for removed application panels or deliberate schema changes. ## Style the workspace `DockSkin` keeps rendering decisions outside the layout engine. You can configure the common panel presentation without changing Dock behavior: ```rust self.dock_skin.set_panel_style(PanelStyle::default(), cx); self.dock_skin.set_toggle_button_visible(true, cx); ``` For complete control, implement the renderer traits in `gpui-base`. The same layout data and operations can then drive an entirely different Dock style. ## Runnable example The repository includes a complete workspace with edge docks, runtime panel operations, layout persistence, and keyboard actions: ```sh cargo run -p example-dock ``` See [`examples/dock/src/main.rs`](https://github.com/longbridge/gpui-kit/blob/main/examples/dock/src/main.rs) for the full implementation. --- # Scrollable Source: /versions/v0.7.1/component/scrollable License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/scrollable and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A comprehensive scrollable container component that provides custom scrollbars, scroll tracking, and virtualization capabilities. Supports both vertical and horizontal scrolling with customizable appearance and behavior. ## Import ```rust use gpui_kit::component::{ scroll::{ScrollableElement, ScrollbarAxis, ScrollbarMode}, StyledExt as _, }; ``` ## Usage ### Basic Scrollable Container The simplest way to make any element scrollable is using the `overflow_scrollbar()` method from `ScrollableElement` trait. This method is almost like the `overflow_scroll()` method, but it adds scrollbars. - `overflow_scrollbar()` - Adds scrollbars for both axes as needed. - `overflow_x_scrollbar()` - Adds horizontal scrollbar as needed. - `overflow_y_scrollbar()` - Adds vertical scrollbar as needed. ```rust use gpui_kit::{div, Axis}; use gpui_kit::component::ScrollableElement; div() .id("scrollable-container") .size_full() .child("Your content here") .overflow_scrollbar() ``` ### Vertical Scrolling ```rust v_flex() .id("scrollable-container") .overflow_y_scrollbar() .gap_2() .p_4() .child("Scrollable Content") .children((0..100).map(|i| { div() .h(px(40.)) .w_full() .bg(cx.theme().secondary) .child(format!("Item {}", i)) })) ``` ### Horizontal Scrolling ```rust h_flex() .id("scrollable-container") .overflow_x_scrollbar() .gap_2() .p_4() .children((0..50).map(|i| { div() .min_w(px(120.)) .h(px(80.)) .bg(cx.theme().accent) .child(format!("Card {}", i)) })) ``` ### Both Directions ```rust div() .id("scrollable-container") .size_full() .overflow_scrollbar() .child( div() .w(px(2000.)) // Wide content .h(px(2000.)) // Tall content .bg(cx.theme().background) .child("Large content area") ) ``` ## Custom Scrollbars ### Manual Scrollbar Creation For more control, you can create scrollbars manually: ```rust use gpui_kit::component::scroll::{ScrollableElement}; pub struct ScrollableView { scroll_handle: ScrollHandle, } impl Render for ScrollableView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .relative() .size_full() .child( div() .id("content") .track_scroll(&self.scroll_handle) .overflow_scroll() .size_full() .child("Your scrollable content") ) .vertical_scrollbar(&self.scroll_handle) } } ``` ## Virtualization ### VirtualList for Large Datasets For rendering large lists efficiently, use `VirtualList`: ```rust use gpui_kit::component::{VirtualList, VirtualListScrollHandle}; pub struct LargeListView { items: Vec, scroll_handle: VirtualListScrollHandle, } impl Render for LargeListView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let item_count = self.items.len(); VirtualList::new( self.scroll_handle.clone(), item_count, |ix, window, cx| { // Item sizes - can be different for each item size(px(300.), px(40.)) }, |ix, bounds, selected, window, cx| { // Render each item div() .size(bounds.size) .bg(if selected { cx.theme().accent } else { cx.theme().background }) .child(format!("Item {}: {}", ix, self.items[ix])) .into_any_element() }, ) } } ``` ### Scrolling to Specific Items ```rust impl LargeListView { fn scroll_to_item(&mut self, index: usize) { self.scroll_handle.scroll_to_item(index, ScrollStrategy::Top); } fn scroll_to_item_centered(&mut self, index: usize) { self.scroll_handle.scroll_to_item(index, ScrollStrategy::Center); } } ``` ### Variable Item Sizes ```rust VirtualList::new( scroll_handle, items.len(), |ix, window, cx| { // Different heights based on content let height = if items[ix].len() > 50 { px(80.) // Tall items for long content } else { px(40.) // Normal height }; size(px(300.), height) }, |ix, bounds, selected, window, cx| { // Render logic }, ) ``` ## Theme Customization ### Scrollbar Appearance Customize scrollbar appearance through theme configuration: ```rust // In your theme JSON { "scrollbar.background": "#ffffff20", "scrollbar.thumb.background": "#00000060", "scrollbar.thumb.hover.background": "#000000" } ``` ### Scrollbar Show Modes Control when scrollbars are visible: ```rust use gpui_kit::component::{Theme, scroll::ScrollbarMode}; // In theme initialization Theme::set_scrollbar_mode(ScrollbarMode::Scrolling, cx); // Only while scrolling Theme::set_scrollbar_mode(ScrollbarMode::Hover, cx); // On hover Theme::set_scrollbar_mode(ScrollbarMode::Always, cx); // Always visible ``` ### System Integration Sync scrollbar behavior with system preferences: ```rust // Automatically sync with system settings Theme::sync_scrollbar_appearance(cx); ``` ## Examples ### File Browser with Scrolling ```rust pub struct FileBrowser { files: Vec, } impl Render for FileBrowser { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .border_1() .border_color(cx.theme().border) .size_full() .child( v_flex() .gap_1() .p_2() .overflow_y_scrollbar() .children(self.files.iter().map(|file| { div() .h(px(32.)) .w_full() .px_2() .flex() .items_center() .hover(|style| style.bg(cx.theme().secondary_hover)) .child(file.clone()) })) ) } } ``` ### Chat Messages with Auto-scroll ```rust pub struct ChatView { messages: Vec, scroll_handle: ScrollHandle, should_auto_scroll: bool, } impl ChatView { fn add_message(&mut self, message: String) { self.messages.push(message); if self.should_auto_scroll { // Scroll to bottom for new messages let max_offset = self.scroll_handle.max_offset(); self.scroll_handle.set_offset(point(px(0.), max_offset.y)); } } } ``` ### Data Table with Virtual Scrolling ```rust pub struct DataTable { data: Vec>, scroll_handle: VirtualListScrollHandle, } impl Render for DataTable { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { VirtualList::new( self.scroll_handle.clone(), self.data.len(), |_ix, _window, _cx| size(px(800.), px(32.)), // Fixed row height |ix, bounds, _selected, _window, cx| { h_flex() .size(bounds.size) .border_b_1() .border_color(cx.theme().border) .children(self.data[ix].iter().map(|cell| { div() .flex_1() .px_2() .flex() .items_center() .child(cell.clone()) })) .into_any_element() }, ) } } ``` --- # Dialog Source: /versions/v0.7.1/component/dialog License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/dialog and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Dialog component for creating dialogs, confirmations, and alerts. Supports overlay, keyboard shortcuts, and various customizations. ## Import ```rust use gpui_kit::component::dialog::DialogButtonProps; use gpui_kit::component::WindowExt; ``` ## Usage ### Where dialogs render The window's [Root](/versions/v0.7.1/component/root) automatically mounts and renders dialogs. Open the window with `gpui_kit::open_window`, or wrap the application view in `Root::new`. Application views do not render overlay layers themselves. ### Basic Dialog ```rust window.open_dialog(cx, |dialog, _, _| { dialog .title("Welcome") .child("This is a dialog dialog.") }) ``` ### Form Dialog ```rust let input = cx.new(|cx| InputState::new(window, cx)); window.open_dialog(cx, |dialog, _, _| { dialog .title("User Information") .child( v_flex() .gap_3() .child("Please enter your details:") .child(Input::new(&input)) ) .footer(|_, _, _, _| { vec![ Button::new("ok") .primary() .label("Submit") .on_click(|_, window, cx| { window.close_dialog(cx); }), Button::new("cancel") .label("Cancel") .on_click(|_, window, cx| { window.close_dialog(cx); }), ] }) }) ``` ### Dialog with Icon ```rust window.open_dialog(cx, |dialog, _, cx| { dialog .child( h_flex() .gap_3() .child(Icon::new(IconName::TriangleAlert) .size_6() .text_color(cx.theme().warning)) .child("This action cannot be undone.") ) }) ``` ### Scrollable Dialog ```rust use gpui_kit::component::text::markdown; window.open_dialog(cx, |dialog, window, cx| { dialog .h(px(450.)) .title("Long Content") .child(markdown(long_markdown_text)) }) ``` A dialog never extends past the window. Its width is capped at the viewport minus a 16px margin on each side, and its height at the space between its top offset and a 16px bottom margin, so the title and footer stay visible while the body scrolls. `w`, `max_w`, `h`, and `margin_top` apply within those limits; a dialog that already fits keeps its requested size and default position. ### Dialog Options ```rust window.open_dialog(cx, |dialog, _, _| { dialog .title("Custom Dialog") .overlay(true) // Show overlay (default: true) .overlay_closable(true) // Click overlay to close (default: true) .keyboard(true) // ESC to close (default: true) .close_button(false) // Show close button (default: true) .child("Dialog content") }) ``` ### Action Buttons A `Dialog` puts its own buttons in the [`footer`](#dialogfooter) and has them dispatch `Confirm` or `Cancel`; `on_ok` and `on_cancel` decide what Enter and Esc do. For a confirmation with default buttons, use [AlertDialog](/versions/v0.7.1/component/alert-dialog). ### Nested Dialogs ```rust window.open_dialog(cx, |dialog, _, _| { dialog .title("First Dialog") .child("This is the first dialog") .footer(|_, _, _, _| { vec![ Button::new("open-another") .label("Open Another Dialog") .on_click(|_, window, cx| { window.open_dialog(cx, |dialog, _, _| { dialog .title("Second Dialog") .child("This is nested") }); }), ] }) }) ``` ### Custom Styling ```rust window.open_dialog(cx, |dialog, _, cx| { dialog .rounded(cx.theme().radius_lg) .bg(cx.theme().cyan) .text_color(cx.theme().info_foreground) .title("Custom Style") .child("Styled dialog content") }) ``` ### Custom Padding ```rust window.open_dialog(cx, |dialog, _, _| { dialog .p_3() // Custom padding .title("Custom Padding") .child("Dialog with custom spacing") }) ``` ### Close Dialog Programmatically The `close_dialog` method can be used to close the active dialog from anywhere within the window context. ```rust // Close top level active dialog. window.close_dialog(cx); // Close and perform action Button::new("submit") .primary() .label("Submit") .on_click(|_, window, cx| { // Do something window.close_dialog(cx); }) ``` ## Declarative API The Dialog component now supports a declarative API that provides a more React-like component composition pattern using dedicated header, title, description, and footer components. ### Import ```rust use gpui_kit::component::dialog::{ Dialog, DialogHeader, DialogTitle, DialogDescription, DialogFooter, }; ``` ### Trigger-based Dialog The trigger-based approach allows you to create a dialog that opens when a trigger element is clicked. The dialog is defined inline with the trigger. ```rust Dialog::new(cx) .trigger( Button::new("open-dialog") .outline() .label("Open Dialog") ) .content(|content, _, cx| { content .child( DialogHeader::new() .child(DialogTitle::new().child("Account Created")) .child(DialogDescription::new().child( "Your account has been created successfully!", )) ) .child( DialogFooter::new() .border_t_1() .border_color(cx.theme().border) .bg(cx.theme().muted) .child( Button::new("cancel") .outline() .label("Cancel") .on_click(|_, window, cx| { window.close_dialog(cx); }) ) .child( Button::new("ok") .primary() .label("Save Changes") ) ) }) ``` ### Content Builder Pattern Use the content builder pattern with `window.open_dialog` for more control over dialog creation: ```rust window.open_dialog(cx, |dialog, _, _| { dialog .w(px(400.)) .content(|content, _, _| { content .child( DialogHeader::new() .child(DialogTitle::new().child("Custom Width")) .child(DialogDescription::new().child( "This dialog has a custom width of 400px.", )) ) .child(div().child( "Content area with custom width configuration." )) .child( DialogFooter::new() .justify_center() .child( Button::new("cancel") .flex_1() .outline() .label("Cancel") .on_click(|_, window, cx| { window.close_dialog(cx); }) ) .child( Button::new("done") .flex_1() .primary() .label("Done") .on_click(|_, window, cx| { window.close_dialog(cx); }) ) ) }) }) ``` ### Declarative Components #### DialogHeader Container for the dialog's title and description section. ```rust DialogHeader::new() .child(DialogTitle::new().child("Title")) .child(DialogDescription::new().child("Description")) ``` #### DialogTitle Displays the main title of the dialog with semantic styling. ```rust DialogTitle::new() .child("Account Settings") ``` #### DialogDescription Displays descriptive text below the title with muted styling. ```rust DialogDescription::new() .child("Update your account settings and preferences here.") ``` #### DialogFooter Container for action buttons and footer content. Automatically applies proper spacing and alignment. ```rust DialogFooter::new() .bg(cx.theme().muted) .border_t_1() .border_color(cx.theme().border) .child(Button::new("cancel").outline().label("Cancel")) .child(Button::new("save").primary().label("Save")) ``` ### Form Dialog with Declarative API ```rust let name_input = cx.new(|cx| InputState::new(window, cx)); let email_input = cx.new(|cx| InputState::new(window, cx)); Dialog::new(cx) .trigger(Button::new("edit-profile").label("Edit Profile")) .content(|content, _, cx| { content .child( DialogHeader::new() .child(DialogTitle::new().child("Edit Profile")) .child(DialogDescription::new().child( "Make changes to your profile here. Click save when done." )) ) .child( v_flex() .gap_4() .py_4() .child( v_flex() .gap_2() .child("Name") .child(Input::new(&name_input).placeholder("Enter your name")) ) .child( v_flex() .gap_2() .child("Email") .child(Input::new(&email_input).placeholder("Enter your email")) ) ) .child( DialogFooter::new() .child(Button::new("cancel").outline().label("Cancel")) .child(Button::new("save").primary().label("Save Changes")) ) }) ``` ### Styled Footer Customize the footer appearance with background colors, borders, and alignment: ```rust DialogFooter::new() .justify_center() // Center align buttons .bg(cx.theme().muted) // Background color .border_t_1() // Top border .border_color(cx.theme().border) .child(Button::new("btn1").flex_1().label("Cancel")) .child(Button::new("btn2").flex_1().primary().label("Confirm")) ``` ### DialogContent Container The `DialogContent` component provides a flexible container for dialog body content: ```rust use gpui_kit::component::dialog::DialogContent; window.open_dialog(cx, |dialog, _, _| { dialog.content(|content, _, cx| { content .child(DialogHeader::new() .child(DialogTitle::new().child("Settings")) .child(DialogDescription::new().child("Configure your preferences")) ) .child( div() .py_4() .child("Main content area") ) .child(DialogFooter::new() .child(Button::new("close").label("Close")) ) }) }) ``` ## API Reference - Declarative Components ### Dialog | Method | Description | | ------------------------ | ----------------------------------------------------- | | `new(cx)` | Create a new Dialog (no longer requires window param) | | `trigger(element)` | Set trigger element that opens the dialog | | `content(builder)` | Set content using a builder function | | `w(px)` / `width(px)` | Set dialog width | | `max_w(px)` | Set maximum width | | `margin_top(px)` | Set top margin | | `overlay(bool)` | Show/hide overlay (default: true) | | `overlay_closable(bool)` | Allow closing by clicking overlay (default: true) | | `keyboard(bool)` | Allow closing with ESC key (default: true) | | `close_button(bool)` | Show/hide close button (default: true) | ### DialogContent Container for dialog body content. Automatically applies padding and flex layout. ```rust DialogContent::new() .child(DialogHeader::new()...) .child(/* your content */) .child(DialogFooter::new()...) ``` ### DialogHeader Container for title and description. Automatically applies vertical flex layout with proper gap. ```rust DialogHeader::new() .child(DialogTitle::new().child("Title")) .child(DialogDescription::new().child("Description")) ``` ### DialogTitle Displays the dialog title with semantic styling (font-semibold, proper line-height). ```rust DialogTitle::new() .child("Dialog Title") ``` ### DialogDescription Displays descriptive text with muted foreground color and proper text sizing. ```rust DialogDescription::new() .child("This is a description text that provides more context.") ``` ### DialogFooter Container for footer buttons with automatic spacing and alignment. ```rust DialogFooter::new() .justify_end() // Right align (default) .child(Button::new("btn1").label("Cancel")) .child(Button::new("btn2").primary().label("OK")) ``` ## Breaking Changes ### Dialog::new() Signature Change The `Dialog::new()` constructor no longer requires a `window` parameter: ```rust // Old API (deprecated) Dialog::new(window, cx) // New API Dialog::new(cx) ``` ### Content Builder Function The `.content()` method now accepts a builder function instead of a pre-built `DialogContent`: ```rust // Old approach (still works) dialog.child(DialogHeader::new()...) // New declarative API dialog.content(|content, window, cx| { content .child(DialogHeader::new()...) .child(DialogFooter::new()...) }) ``` ## Best Practices 1. **Use Declarative Components**: Prefer `DialogHeader`, `DialogTitle`, `DialogDescription`, and `DialogFooter` for consistent styling 2. **Trigger-based for Simple Cases**: Use the trigger pattern for straightforward dialogs that open from a button 3. **Builder Pattern for Complex Dialogs**: Use `window.open_dialog` with content builder for dialogs requiring complex logic or state 4. **Semantic Structure**: Always include `DialogHeader` with title and description for accessibility 5. **Consistent Footer**: Use `DialogFooter` for all action buttons to maintain visual consistency 6. **Proper Sizing**: Explicitly set dialog width when content requires specific dimensions --- # Questionnaire Source: /versions/v0.7.1/component/questionnaire License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/questionnaire and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `Questionnaire` guides a user through an ordered set of questions. It owns the active item, answer state, validation, progress, and navigation. A containing page, `GroupBox`, `Dialog`, or `Sheet` remains responsible for closing, cancelling, persistence, transport, and application-specific branching. ## Import ```rust use gpui_kit::component::questionnaire::{ Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireState, QuestionnaireSubmit, QuestionnaireTitle, }; ``` ## Usage Create the item collection once and use one `QuestionnaireState` entity as the source of truth for all parts. ```rust use gpui_kit::component::input::InputState; use gpui_kit::component::questionnaire::{ QuestionnaireChoiceDefinition, QuestionnaireInputDefinition, QuestionnaireItemDefinition, QuestionnaireState, }; let direction_input = cx.new(|cx| { InputState::new(window, cx).placeholder("Type another answer…") }); let items = vec![ QuestionnaireItemDefinition::new("direction", "What should we prototype next?") .with_required(true) .with_description("Choose a direction or write your own.") .with_choices([ QuestionnaireChoiceDefinition::new("delegation", "Delegation") .with_description("Show how work moves to a specialist."), QuestionnaireChoiceDefinition::new("questions", "Question prompts"), QuestionnaireChoiceDefinition::new("both", "Both together"), ]) .with_input(QuestionnaireInputDefinition::new( direction_input, "Another answer", )), QuestionnaireItemDefinition::new("detail", "How much detail should it include?") .with_description("You can skip this question if you are not sure yet.") .with_choices([ QuestionnaireChoiceDefinition::new("focused", "Focused"), QuestionnaireChoiceDefinition::new("complete", "Complete flow"), ]), ]; let state = cx.new(|cx| { QuestionnaireState::new(items, cx) .expect("valid questionnaire schema") }); ``` Map every definition in the collection into the compound parts. The active `QuestionnaireItem` is the only item rendered, so omitting an item from this composition leaves the UI empty when navigation reaches that item. ```rust Questionnaire::new(&state) .child(QuestionnaireProgress::new(&state)) .child( QuestionnaireItem::new(&state, "direction") .child(QuestionnaireTitle::new(&state, "direction")) .child(QuestionnaireDescription::new(&state, "direction")) .child( QuestionnaireChoices::new(&state, "direction") .child(QuestionnaireChoice::new(&state, "direction", "delegation")) .child(QuestionnaireChoice::new(&state, "direction", "questions")) .child(QuestionnaireChoice::new(&state, "direction", "both")) .child(QuestionnaireInput::new(&state, "direction")), ) .child(QuestionnaireError::new(&state, "direction")), ) .child( QuestionnaireItem::new(&state, "detail") .child(QuestionnaireTitle::new(&state, "detail")) .child(QuestionnaireDescription::new(&state, "detail")) .child( QuestionnaireChoices::new(&state, "detail") .child(QuestionnaireChoice::new(&state, "detail", "focused")) .child(QuestionnaireChoice::new(&state, "detail", "complete")), ) .child(QuestionnaireError::new(&state, "detail")), ) .child( QuestionnaireActions::new(&state) .child(QuestionnairePrevious::new(&state)) .child(QuestionnaireSkip::new(&state)) .child(QuestionnaireNext::new(&state)) .child(QuestionnaireSubmit::new(&state)), ) ``` ## Composition ```text Questionnaire ├── QuestionnaireProgress ├── QuestionnaireItem │ ├── QuestionnaireTitle │ ├── QuestionnaireDescription │ ├── QuestionnaireChoices │ │ ├── QuestionnaireChoice │ │ │ └── QuestionnaireChoiceDescription (custom child) │ │ └── QuestionnaireInput │ └── QuestionnaireError └── QuestionnaireActions ├── QuestionnairePrevious ├── QuestionnaireSkip ├── QuestionnaireNext └── QuestionnaireSubmit ``` Every part accepts ordinary GPUI styling and can be composed with existing `Button`, `Input`, `Radio`, `Checkbox`, `Progress`, `Stepper`, `GroupBox`, and `Dialog` elements. Pass the same state entity to each part. A custom part should read its corresponding state and call state methods for user actions; it should not create a second answer store. `QuestionnaireChoice` supplies the default indicator, content, and shortcut. Adding children replaces the fallback label and description while preserving choice activation, focus, state, and accessibility behavior. Use `QuestionnaireChoiceDescription::new()` for secondary text in a custom choice body. The following seams customize only the corresponding region: ```rust use gpui_kit::{IntoElement as _, ParentElement as _, StyleRefinement, Styled as _, div}; use gpui_kit::component::{ActiveTheme as _, StyledExt as _}; use gpui_kit::component::questionnaire::{ QuestionnaireChoice, QuestionnaireChoiceDescription, }; let _styled_choice = QuestionnaireChoice::new(&state, "direction", "questions") .indicator_style(StyleRefinement::default().opacity(0.9)) .content_style(StyleRefinement::default().opacity(0.95)) .shortcut_style(StyleRefinement::default().opacity(0.8)); let _rendered_choice = QuestionnaireChoice::new(&state, "direction", "delegation") .render_indicator(|choice, _, cx| { div() .size_4() .rounded_full() .bg(if choice.is_selected() { cx.theme().primary } else { cx.theme().muted }) .into_any_element() }) .child( div() .child("Delegation") .child(QuestionnaireChoiceDescription::new().child( "Show how work moves to a specialist.", )), ); ``` `render_shortcut` has the same renderer signature and receives the `QuestionnaireChoiceState`; use it when an application wants to replace the default `Kbd` hint. A renderer replaces that region completely, so its matching style seam is not applied; style the custom renderer directly. The state snapshot exposes `is_selected`, `is_disabled`, `is_invalid`, and `shortcut` for custom rendering. ## Choices An item is single-selection by default. Activating a choice — clicking it, pressing Space or Enter on it, or pressing its shortcut — answers the item and confirms it: after a brief pause that keeps the selection visible, the questionnaire moves to the next enabled item, or submits on the last one. An answer that fails validation stays on its item with the error shown. Choosing the answer that is already selected, for example after going back, confirms at once. With reduced motion the pause is skipped. Arrow keys move the selection within the item without confirming it, and typing in a freeform input never confirms. `with_multiple` keeps every selected choice instead; activating a choice there only toggles it, and `Next` continues. The answer reader preserves schema order, and a choice disabled later leaves the effective answer. Definition builders carry the initial snapshot: a choice can start selected, an item, a choice, or an input can start disabled, and a single-choice item may carry at most one default. ```rust let tools_input = cx.new(|cx| InputState::new(window, cx)); let items = vec![ QuestionnaireItemDefinition::new("plan", "Which plan fits your team?") .with_required(true) .with_choices([ QuestionnaireChoiceDefinition::new("plus", "Plus").with_default_selected(true), QuestionnaireChoiceDefinition::new("pro", "Pro"), ]), QuestionnaireItemDefinition::new("tools", "Which tools do you use?") .with_multiple(true) .with_choices([ QuestionnaireChoiceDefinition::new("editor", "Editor"), QuestionnaireChoiceDefinition::new("terminal", "Terminal"), QuestionnaireChoiceDefinition::new("browser", "Browser").with_disabled(true), ]) .with_input(QuestionnaireInputDefinition::new(tools_input, "Something else")), QuestionnaireItemDefinition::new("advanced", "Advanced preferences").with_disabled(true), ]; ``` `QuestionnaireState::new` rejects duplicate item names, duplicate choice values within an item, and multiple defaults on a single-choice item. Setters for unknown items or choices return `QuestionnaireSchemaError`. ## Freeform answer Add `QuestionnaireInputDefinition` to allow a user to enter an answer that is not in the fixed choices. Give the input an accessible label; a placeholder is not a label. Whitespace-only input is unanswered. The input draft is kept when a fixed choice is selected, but it is submitted only when the freeform answer is active. In a multiple item, a non-empty freeform answer can accompany fixed choices. ## Validation Required status validation is built in. Add a synchronous validator to an item for domain-specific checks. The validator receives the current item, its answer, and the complete enabled answer snapshot through `QuestionnaireValidationContext`. `Next` validates the current item; `Submit` validates all enabled items and focuses the first invalid item. ```rust let item = QuestionnaireItemDefinition::new("handle", "Choose a handle") .with_required(true) .with_validator(|context| { if context .answer() .freeform() .is_some_and(|value| value.as_ref().len() >= 3) { Ok(()) } else { Err("Use at least three characters.".into()) } }); ``` An optional unanswered item is invalid until the user explicitly skips it; `Skipped` is intentionally valid. Disabled items and disabled controls do not participate in validation. The first invalid item is selected on submit, and focus goes to its filled input or selected choice before falling back to the first enabled control. Use external errors for schema or server responses. External errors belong to the host and remain until the host clears them. ```rust state.update(cx, |state, cx| { state .set_external_error("handle", "This handle is already taken.", cx) .expect("known questionnaire item"); }); // After the owner accepts a corrected answer or a new server response: state.update(cx, |state, cx| { state .clear_external_error("handle", cx) .expect("known questionnaire item"); }); ``` `reset` clears internal validation attempts and errors, but preserves owner-managed external errors. ## Navigation and submission `QuestionnaireState` exposes the current item, ordered item states, and navigation state for custom action layouts. ```rust let state = state.read(cx); let progress = state.progress(); let status = state.item_state("direction").map(|item| item.status()); let navigation = state.navigation_state(); let show_skip = navigation.is_skip_visible(); ``` `QuestionnaireNavigationState` answers the same question for `Previous`, `Next`, `Submit`, and `is_confirmable`; `current_item` and `current_ix` locate the active item. The default action layout shows `Previous` at the beginning, `Next` between items, `Skip` only for the active optional item, and `Submit` at the end. Hidden actions are not rendered and do not enter keyboard navigation. Disabled items are removed from the navigation and progress totals. The three item statuses are `Unanswered`, `Answered`, and `Skipped`. ### Skipping Optional items can expose `QuestionnaireSkip`. A skip is an intentional valid state, clears the item answer, and allows `Next` to continue. Required items do not allow skipping. Re-entering an item and choosing an answer clears its skipped state. Skipping the final enabled item requests submission after the skip has been recorded. ### Events and submission Subscribe to `QuestionnaireEvent` for active-item changes, answer changes, completion, and successful submit. `Completed` is emitted on the transition into a complete state; `Submit` is emitted for each successful explicit submit. On the first successful submit, the order is `Completed` followed by `Submit`. Changing answers or enabled conditions clears completion, so the next successful submit can emit `Completed` again. ```rust use gpui_kit::component::questionnaire::QuestionnaireEvent; cx.subscribe(&state, |_, _, event, _| match event { QuestionnaireEvent::CurrentItemChanged { current, .. } => { println!("Current item: {:?}", current); } QuestionnaireEvent::AnswerChanged(change) => { println!("Changed: {:?} ({:?})", change.item(), change.status()); } QuestionnaireEvent::Completed(submission) | QuestionnaireEvent::Submit(submission) => { println!("Answers: {:?}", submission.items()); } _ => {} }) .detach(); ``` Detaching keeps the callback alive until the subscribed entities are dropped. Store the returned `Subscription` in the host instead when it needs to cancel the listener earlier. The submission is ordered by the item schema and contains only enabled items. Each item includes its name, `Unanswered`/`Answered`/`Skipped` status, and effective answer. It represents a validated local submission request; saving it remotely remains the host application's responsibility. ## Controlling the state When a page owns the active item or needs to apply a saved answer after state creation, use the silent setters. They update the UI and focus as needed but do not emit user-interaction events. ```rust use gpui_kit::component::questionnaire::QuestionnaireAnswer; state.update(cx, |state, cx| { state .set_current_item("detail", window, cx) .expect("known enabled questionnaire item"); state .set_answer( "direction", QuestionnaireAnswer::new().with_choices(["delegation"]), window, cx, ) .expect("known questionnaire item"); state .set_input_value("direction", "A controlled draft", window, cx) .expect("item has an input"); }); ``` Use `choose`, `confirm_current`, `go_previous`, `go_next`, `skip_current`, and `submit` for user intent. `choose` is what a click or shortcut runs, so on a single-choice item it also confirms; `activate_choice` changes the answer without confirming. Those paths emit the relevant `QuestionnaireEvent` values. A host can also use `set_item_disabled` and `set_choice_disabled`; disabling the current item moves focus to the next enabled item, or to the previous one when there is no next item. ### Reset Reset restores the initial choices and input drafts, clears intentional skips, validation attempts, and completion, and returns to the initial current item. It also focuses the restored current item. ```rust state.update(cx, |state, cx| { state.reset(window, cx); }); ``` External errors remain owner-managed across reset. If a reset should also remove a server error, clear it explicitly with `clear_external_error`. `reset` returns to the snapshot the schema was built with, so a saved draft belongs in the definitions: `InputState::default_value`, `with_default_selected`, and `with_current_item` establish that baseline. Values applied later with `set_answer`, `set_input_value`, or `set_current_item` change the current state without moving the reset baseline. ### Conditional items Questionnaire does not contain a branching engine. The host can derive an item's disabled state from an earlier answer and synchronize it with `set_item_disabled`. This keeps conditional policy in the page while the Questionnaire continues to own ordering, focus, progress, validation, and submission. ```rust fn sync_advanced_item( state: &Entity, window: &mut Window, cx: &mut App, ) { let enabled = state.read(cx).answer("direction").is_some_and(|answer| { answer .choices() .iter() .any(|choice| choice.as_ref() == "delegation") }); state.update(cx, |state, cx| { let _ = state.set_item_disabled("advanced", !enabled, window, cx); }); } ``` Call this helper from the host's answer-change handling or from the UI action that changes the earlier answer. A disabled conditional item is excluded from progress, navigation, validation, focus, shortcuts, and submission. ## Keyboard shortcuts Enable letter or number shortcuts on the state. Shortcuts apply only to the active item's enabled choices. Repeated key events, text input, IME composition, and modified key presses are left untouched. ```rust use gpui_kit::component::questionnaire::QuestionnaireShortcutMode; let state = cx.new(|cx| { QuestionnaireState::new(items, cx) .expect("valid questionnaire schema") .with_shortcuts(QuestionnaireShortcutMode::Letters) }); ``` Questionnaire handles radio movement according to the native single-choice interaction. Up and Down otherwise move through enabled choices and the freeform input in schema order; the input remains in that order when present. When a non-empty text input has focus, its normal text-editing behavior is preserved. Left and Right move between items only outside text inputs and single-choice radio controls; Right requires a confirmable current item. Enter confirms a filled answer, and Space or Enter on an unselected choice chooses it and confirms its single-choice item. Command/Ctrl+Enter confirms the current item. An empty answer does not implicitly submit. Shortcut labels are assigned in enabled-choice order (`A`–`Z` or `1`–`9`), and disabled choices receive no label. ## Progress `QuestionnaireProgress` follows the default presentation: “Question 2 of 4”. The same snapshot can drive an existing indicator instead. ```rust QuestionnaireProgress::new(&state); let progress = state.read(cx).progress(); let percent = if progress.total() == 0 { 0. } else { progress.current() as f32 / progress.total() as f32 * 100. }; Progress::new("questionnaire-progress").value(percent); ``` `current` and `total` count only the enabled items, and both move when the host disables or re-enables a question. An indicator with one fixed label per step — a `Stepper`, for example — has to derive its steps from the same enabled set, or its labels and its selected step drift apart from the questionnaire. ## Sizes and theming `Questionnaire` takes the scale for the whole questionnaire, and every part of that questionnaire follows it — the root publishes the size under its state, so the compound parts do not have to be told individually. A part that names its own size keeps it. ```rust use gpui_kit::component::{Sizable as _, Size}; Questionnaire::new(&state) .with_size(Size::Small) .child(QuestionnaireProgress::new(&state)) .child( QuestionnaireItem::new(&state, "direction") .child(QuestionnaireTitle::new(&state, "direction")) .child( QuestionnaireChoices::new(&state, "direction") // Follows the root; pass `with_size` here only to differ. .child(QuestionnaireChoice::new(&state, "direction", "delegation")), ), ); ``` The supported sizes are `XSmall`, `Small`, `Medium` (the default) and `Large`, plus `Size::Size(value)` for a custom scale. Answer text matches the Checkbox and Radio family's label at the same size. Spacing, typography, radius, border, input, primary, muted, destructive, and focus-ring values all come from the active theme's semantic tokens, so an application changes the questionnaire's shape by changing the theme. Use `Styled` methods or `StyleRefinement` for local adjustments; local style refinement is applied after the component defaults. `QuestionnaireChoiceDescription` is the one part with no state of its own — it is a plain text slot for a custom choice body — so it defaults to `Medium` and takes `with_size` when a custom composition needs another scale. ## Card and Dialog composition The questionnaire owns the question flow; the container owns its surface and its close or cancel behavior. Put the whole composition — progress, every item, and the actions — inside the container, so moving to the next question stays visible. ```rust use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants as _}; GroupBox::new() .outline() .title("Set up your workspace") .child(questionnaire); ``` In a dialog, the footer carries the container's own `Cancel` next to the questionnaire's actions, and the host closes the dialog when the questionnaire reports a successful submit. ```rust use gpui_kit::component::dialog::{ Dialog, DialogClose, DialogFooter, DialogHeader, DialogTitle, }; use gpui_kit::component::{WindowExt as _, questionnaire::QuestionnaireEvent}; let dialog_state = state.clone(); cx.subscribe_in( &dialog_state, window, |_, _, event: &QuestionnaireEvent, window, cx| { if matches!(event, QuestionnaireEvent::Submit(_)) { window.close_dialog(cx); } }, ) .detach(); Dialog::new(cx) .trigger(Button::new("open-questionnaire").outline().label("Open questionnaire")) .content(move |content, _, _| { content .child(DialogHeader::new().child(DialogTitle::new().child("Workspace setup"))) .child( Questionnaire::new(&dialog_state) // …progress and every item, as in Usage above .child( DialogFooter::new() .child(DialogClose::new().child( Button::new("cancel-questionnaire").outline().label("Cancel"), )) .child( QuestionnaireActions::new(&dialog_state) .child(QuestionnairePrevious::new(&dialog_state)) .child(QuestionnaireNext::new(&dialog_state)) .child(QuestionnaireSubmit::new(&dialog_state)), ), ), ) }); ``` `Cancel` always closes. `Submit` closes only after the questionnaire has validated every enabled item, and the same event hands the validated `QuestionnaireSubmission` to application transport. ## Accessibility `Questionnaire` uses the GPUI `Form` role for the root. `QuestionnaireItem` is an accessible group with its item label and description. The definition's `accessibility_label` and `description` remain the semantic source for the item and choice, even when a custom child replaces the visible fallback content. `QuestionnaireError` is announced as an alert only while the item is invalid. Choice parts preserve radio and checkbox semantics, progress exposes current and total values, and navigation uses real buttons. Inactive items and hidden actions are removed from keyboard navigation. On a successful transition focus moves to the new item; on validation failure focus moves to the selected or filled answer control, then to the first available control. Always provide an accessible label for a freeform input with its definition's `accessibility_label`; a visible label or equivalent custom composition can supplement it. The GPUI accessibility layer does not expose a direct `aria-invalid` builder. Questionnaire still exposes invalid state through its error alert, semantic group state, focus behavior, and destructive styling. ## Current scope The questionnaire asks one question at a time: parts belonging to any question other than the current one render nothing, so a single page of several questions is not what this component builds. The schema is fixed at construction — questions and choices cannot be inserted or reordered at runtime, though any of them can be disabled — and validators run synchronously. Persistence, transport, and submission side effects belong to the containing page, which subscribes to `QuestionnaireEvent`. ## API reference ### Compound parts - [Questionnaire] - [QuestionnaireProgress] - [QuestionnaireItem] - [QuestionnaireTitle] - [QuestionnaireDescription] - [QuestionnaireChoices] - [QuestionnaireChoice] - [QuestionnaireChoiceDescription] - [QuestionnaireInput] - [QuestionnaireError] - [QuestionnaireActions] - [QuestionnairePrevious] - [QuestionnaireSkip] - [QuestionnaireNext] - [QuestionnaireSubmit] ### State, answers, and events - [QuestionnaireState] - [QuestionnaireItemDefinition] - [QuestionnaireChoiceDefinition] - [QuestionnaireInputDefinition] - [QuestionnaireAnswer] - [QuestionnaireAnswers] - [QuestionnaireItemStatus] - [QuestionnaireShortcutMode] - [QuestionnaireProgressState] - [QuestionnaireItemState] - [QuestionnaireChoiceState] - [QuestionnaireNavigationState] - [QuestionnaireValidationContext] - [QuestionnaireValidator] - [QuestionnaireAnswerChange] - [QuestionnaireSubmission] - [QuestionnaireSubmissionItem] - [QuestionnaireEvent] - [QuestionnaireSchemaError] - [Sizable] [Questionnaire]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.Questionnaire.html [QuestionnaireProgress]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireProgress.html [QuestionnaireItem]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireItem.html [QuestionnaireTitle]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireTitle.html [QuestionnaireDescription]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireDescription.html [QuestionnaireChoices]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoices.html [QuestionnaireChoice]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoice.html [QuestionnaireChoiceDescription]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoiceDescription.html [QuestionnaireInput]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireInput.html [QuestionnaireError]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireError.html [QuestionnaireActions]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireActions.html [QuestionnairePrevious]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnairePrevious.html [QuestionnaireSkip]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSkip.html [QuestionnaireNext]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireNext.html [QuestionnaireSubmit]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSubmit.html [QuestionnaireState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireState.html [QuestionnaireItemDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireItemDefinition.html [QuestionnaireChoiceDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoiceDefinition.html [QuestionnaireInputDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireInputDefinition.html [QuestionnaireAnswer]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireAnswer.html [QuestionnaireAnswers]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireAnswers.html [QuestionnaireItemStatus]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireItemStatus.html [QuestionnaireShortcutMode]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireShortcutMode.html [QuestionnaireProgressState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireProgressState.html [QuestionnaireItemState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireItemState.html [QuestionnaireChoiceState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoiceState.html [QuestionnaireNavigationState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireNavigationState.html [QuestionnaireValidationContext]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireValidationContext.html [QuestionnaireValidator]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/type.QuestionnaireValidator.html [QuestionnaireAnswerChange]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireAnswerChange.html [QuestionnaireSubmission]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSubmission.html [QuestionnaireSubmissionItem]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSubmissionItem.html [QuestionnaireEvent]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireEvent.html [QuestionnaireSchemaError]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireSchemaError.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html --- # Stepper Source: /versions/v0.7.1/component/stepper License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/stepper and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A step-by-step progress component that guides users through a series of steps or stages. Supports horizontal and vertical layouts, custom icons, and different sizes. ## Import ```rust use gpui_kit::component::stepper::{Stepper, StepperItem}; ``` ## Usage ### Basic Stepper Use `selected_index` method to set current active step by index (0-based), default is `0`. ```rust Stepper::new("my-stepper") .selected_index(0) .items([ StepperItem::new().child("Step 1"), StepperItem::new().child("Step 2"), StepperItem::new().child("Step 3"), ]) .on_click(|step, _, _| { println!("Clicked step: {}", step); }) ``` ### With Icons ```rust use gpui_kit::component::IconName; Stepper::new("icon-stepper") .selected_index(0) .items([ StepperItem::new() .icon(IconName::Calendar) .child("Order Details"), StepperItem::new() .icon(IconName::Inbox) .child("Shipping"), StepperItem::new() .icon(IconName::Frame) .child("Preview"), StepperItem::new() .icon(IconName::Info) .child("Finish"), ]) ``` ### Vertical Layout ```rust Stepper::new("vertical-stepper") .vertical() .selected_index(2) .items_center() .items([ StepperItem::new() .pb_8() .icon(IconName::Building2) .child(v_flex().child("Step 1").child("Description for step 1.")), StepperItem::new() .pb_8() .icon(IconName::Asterisk) .child(v_flex().child("Step 2").child("Description for step 2.")), StepperItem::new() .pb_8() .icon(IconName::Folder) .child(v_flex().child("Step 3").child("Description for step 3.")), StepperItem::new() .icon(IconName::CircleCheck) .child(v_flex().child("Step 4").child("Description for step 4.")), ]) ``` ### Text Center The `text_center` method centers the text within each step item. ```rust Stepper::new("center-stepper") .selected_index(0) .text_center(true) .items([ StepperItem::new().child( v_flex() .items_center() .child("Step 1") .child("Desc for step 1."), ), StepperItem::new().child( v_flex() .items_center() .child("Step 2") .child("Desc for step 2."), ), StepperItem::new().child( v_flex() .items_center() .child("Step 3") .child("Desc for step 3."), ), ]) ``` ### Different Sizes ```rust use gpui_kit::component::{Sizable as _, Size}; Stepper::new("stepper") .xsmall() .items([...]) Stepper::new("stepper") .small() .items([...]) Stepper::new("stepper") .large() .items([...]) ``` ### Disabled State ```rust Stepper::new("disabled-stepper") .disabled(true) .items([ StepperItem::new().child("Step 1"), StepperItem::new().child("Step 2"), ]) ``` ### Handle Click Events ```rust Stepper::new("my-stepper") .selected_index(current_step) .items([ StepperItem::new().child("Step 1"), StepperItem::new().child("Step 2"), StepperItem::new().child("Step 3"), ]) .on_click(cx.listener(|this, step, _, cx| { this.current_step = *step; cx.notify(); })) ``` ## API Reference - [Stepper] - [StepperItem] ### Sizing Implements [Sizable] trait: - `xsmall()` - Extra small size - `small()` - Small size - `medium()` - Medium size (default) - `large()` - Large size ## Examples ### Multi-step Form ```rust Stepper::new("form-stepper") .w_full() .selected_index(form_step) .items([ StepperItem::new() .icon(IconName::User) .child("Personal Info"), StepperItem::new() .icon(IconName::CreditCard) .child("Payment"), StepperItem::new() .icon(IconName::CircleCheck) .child("Confirmation"), ]) .on_click(cx.listener(|this, step, _, cx| { this.form_step = *step; cx.notify(); })) ``` ### Disabled Individual Steps ```rust Stepper::new("stepper") .selected_index(0) .items([ StepperItem::new().child("Available"), StepperItem::new().disabled(true).child("Locked"), StepperItem::new().child("Available"), ]) ``` [Stepper]: https://docs.rs/gpui-component/latest/gpui_component/stepper/struct.Stepper.html [StepperItem]: https://docs.rs/gpui-component/latest/gpui_component/stepper/struct.StepperItem.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html --- # Kbd Source: /versions/v0.7.1/component/kbd License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/kbd and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A component for displaying keyboard shortcuts and key combinations with proper platform-specific formatting. Automatically adapts the display to match the conventions of macOS (using symbols) or Windows/Linux (using text labels). ## Import ```rust use gpui_kit::component::kbd::Kbd; use gpui_kit::Keystroke; ``` ## Usage ### Basic Keyboard Shortcut ```rust // Create from a keystroke let kbd = Kbd::new(Keystroke::parse("cmd-shift-p").unwrap()); // Or convert directly from keystroke let kbd: Kbd = Keystroke::parse("escape").unwrap().into(); ``` ### Common Shortcuts ```rust // Command palette Kbd::new(Keystroke::parse("cmd-shift-p").unwrap()) // New tab Kbd::new(Keystroke::parse("cmd-t").unwrap()) // Zoom controls Kbd::new(Keystroke::parse("cmd--").unwrap()) // Zoom out Kbd::new(Keystroke::parse("cmd-+").unwrap()) // Zoom in // Navigation Kbd::new(Keystroke::parse("escape").unwrap()) Kbd::new(Keystroke::parse("enter").unwrap()) Kbd::new(Keystroke::parse("backspace").unwrap()) ``` ### Multiple Modifiers ```rust // Complex combinations Kbd::new(Keystroke::parse("cmd-ctrl-shift-a").unwrap()) Kbd::new(Keystroke::parse("cmd-alt-backspace").unwrap()) Kbd::new(Keystroke::parse("ctrl-alt-shift-a").unwrap()) ``` ### Arrow Keys and Function Keys ```rust // Arrow keys Kbd::new(Keystroke::parse("left").unwrap()) Kbd::new(Keystroke::parse("right").unwrap()) Kbd::new(Keystroke::parse("up").unwrap()) Kbd::new(Keystroke::parse("down").unwrap()) // Function keys Kbd::new(Keystroke::parse("f12").unwrap()) Kbd::new(Keystroke::parse("secondary-f12").unwrap()) // Page navigation Kbd::new(Keystroke::parse("pageup").unwrap()) Kbd::new(Keystroke::parse("pagedown").unwrap()) ``` ### Without Visual Styling ```rust // Display only the key text without the styled background Kbd::new(Keystroke::parse("cmd-s").unwrap()) .appearance(false) ``` ### From Action Bindings ```rust use gpui_kit::{Action, Window, FocusHandle}; // Get first keybinding for an action if let Some(kbd) = Kbd::binding_for_action(&MyAction {}, None, window) { // Display the bound shortcut } // Get keybinding for action within a specific context if let Some(kbd) = Kbd::binding_for_action(&MyAction {}, Some("Editor"), window) { // Display context-specific shortcut } // Get keybinding for action within a focus handle if let Some(kbd) = Kbd::binding_for_action_in(&MyAction {}, &focus_handle, window) { // Display shortcut for focused element } ``` ## Platform Differences The Kbd component automatically formats shortcuts according to platform conventions: ### macOS - Uses symbols: ⌃ ⌥ ⇧ ⌘ - No separators between modifiers - Order: Control, Option, Shift, Command - Special keys: ⌫ (backspace), ⎋ (escape), ⏎ (enter), ← → ↑ ↓ (arrows) ### Windows/Linux - Uses text labels: Ctrl, Alt, Shift, Win - Plus sign (+) separators - Order: Ctrl, Alt, Shift, Win - Special keys: Backspace, Esc, Enter, Left, Right, Up, Down ### Examples by Platform | Input | macOS | Windows/Linux | | ------------------- | ----- | ----------------- | | `cmd-a` | ⌘A | Win+A | | `ctrl-shift-a` | ⌃⇧A | Ctrl+Shift+A | | `cmd-alt-backspace` | ⌥⌘⌫ | Win+Alt+Backspace | | `escape` | ⎋ | Esc | | `enter` | ⏎ | Enter | | `left` | ← | Left | ## Examples ### Keyboard Shortcut Help ```rust use gpui_kit::{div, h_flex, v_flex}; // Display common shortcuts v_flex() .gap_2() .child( h_flex() .gap_2() .items_center() .child("Open command palette:") .child(Kbd::new(Keystroke::parse("cmd-shift-p").unwrap())) ) .child( h_flex() .gap_2() .items_center() .child("Save file:") .child(Kbd::new(Keystroke::parse("cmd-s").unwrap())) ) .child( h_flex() .gap_2() .items_center() .child("Find in files:") .child(Kbd::new(Keystroke::parse("cmd-shift-f").unwrap())) ) ``` ### Menu Item with Shortcut ```rust h_flex() .justify_between() .items_center() .child("New File") .child(Kbd::new(Keystroke::parse("cmd-n").unwrap())) ``` ### Inline Documentation ```rust div() .child("Press ") .child(Kbd::new(Keystroke::parse("escape").unwrap())) .child(" to cancel or ") .child(Kbd::new(Keystroke::parse("enter").unwrap())) .child(" to confirm.") ``` ### Custom Styling ```rust Kbd::new(Keystroke::parse("cmd-k").unwrap()) .text_color(cx.theme().accent) .border_color(cx.theme().accent) .bg(cx.theme().accent.opacity(0.1)) ``` ### Text-Only Format ```rust // Get formatted text without styling let shortcut_text = Kbd::format(&Keystroke::parse("cmd-shift-p").unwrap()); div().child(format!("Shortcut: {}", shortcut_text)) ``` ## Styling The Kbd component uses the following default styles: - Border with theme border color - Muted foreground text color - Background with theme background color - Small rounded corners - Centered text alignment - Extra small font size - Minimal padding (0.5px vertical, 1px horizontal) - Minimum width of 5 units - Flex shrink disabled to maintain size All styles can be customized using the `Styled` trait methods. --- # TextView Source: /versions/v0.7.1/component/text-view License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/text-view and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `TextView` renders formatted text in GPUI. It supports Markdown and simple HTML, text selection, code block actions, and custom Markdown plugins for project-specific syntax. The canonical implementation now lives in `gpui-base`; this module remains a compatibility re-export and provides component-theme adaptation. Base-only setup, complete default styling, and opt-in syntax highlighting are documented on [GPUI Base TextView](/versions/v0.7.1/base/text-view). TextView is selectable by default and uses the shared window selection engine from `gpui-base`. Use `.selectable(false)` only when selection must be disabled. See [GPUI Base Text Selection](/versions/v0.7.1/base/text-selection) when integrating plain text or a custom renderer with the same selection. ## Import ```rust use gpui_kit::component::text::{markdown, TextView}; ``` ## Usage ### Markdown Use the `markdown` helper when you only need to render Markdown text: ```rust use gpui_kit::component::text::markdown; markdown("# Hello\n\nThis is **Markdown**.") .scrollable(true) ``` You can also construct a `TextView` directly when you need a stable id: ```rust use gpui_kit::component::text::TextView; TextView::markdown("preview", markdown_source) ``` ### HTML ```rust TextView::html("html-preview", "Hello") ``` ### Inside a colored surface A `TextView` without an explicit `.style()` follows the text color of its container. On a surface that is inverted from the page, such as a `Filled` [Bubble](/versions/v0.7.1/component/bubble), links, muted text, code and table backgrounds, borders and selection are derived from that color as well, and syntax highlighting is left out, so the content needs no extra styling: ```rust Bubble::new().child(TextView::markdown("reply", text)) ``` ### Clamp to a number of lines Use `max_lines` to render a bounded preview of rich content — for example a collapsed "show more" section. The view's height is capped at `n` × the base line height, and a line of glyphs is never cut in half: a line that would straddle the bottom of the box is left out whole, across paragraphs, lists, headings, code blocks and tables: ```rust TextView::markdown("preview", markdown_source).max_lines(5) ``` Nothing is shown with less than a line of itself to show, so the border and padding a table row leads with never strands at the bottom. Whatever has more than that is cut on the box edge and keeps the part that fits, so an image crossing the edge shows instead of disappearing and leaving blank space behind. `TextViewState::is_clamped()` reports whether the previous painted frame actually clipped content, so the caller can decide whether to render an "expand" affordance. `n` counts lines of body text, so paragraph spacing and taller lines mean fewer of them fit inside the capped height, and a line taller than the whole budget keeps the part that fits rather than emptying the box. `max_lines` only applies to the fit-content mode and is ignored when `scrollable` is set. ### Fade in streamed text A chat reply arrives in chunks. `stream_fade(true)` fades each chunk in where it lands instead of popping it onto the screen, the way Claude reveals a response: ```rust TextView::new(&self.reply).stream_fade(true) ``` The fade follows the rendered text. Whatever a `push_str`, or a `set_text` whose text extends the current one, adds starts transparent and reaches full color over 350 ms on an ease-out curve, the timing measured from Claude: longer than the 50–300 ms a model's chunks arrive at, so consecutive chunks overlap into one gradient tail rather than the newest chunk blinking in. Code in fenced blocks and text in table cells fade the same way. Markdown that completes as it streams (`**bo` becoming bold `bold`) fades the changed glyphs rather than the whole paragraph. Text that replaces the current content shows at once, and so does everything when the system asks for reduced motion. Nothing animates unless the view opts in. Pass a `TextViewMotion` through `.motion(...)` to choose the duration or easing yourself, or to reveal each chunk word by word; see [GPUI Base TextView](/versions/v0.7.1/base/text-view#retained-state-and-streaming-updates). ### Highlight ranges An application that searches a document, or points at a citation inside it, paints its ranges with `set_range_highlights`. The application owns the search: it finds its ranges in `rendered_text()`, the text the view shows, and hands them back with the colors to paint them in, a stronger one for the current result: ```rust use gpui_kit::component::{ ActiveTheme as _, text::{RangeHighlight, RangeHighlightError, TextViewState}, }; fn highlight_matches( state: &mut TextViewState, query: &str, current_match: usize, cx: &mut Context, ) -> Result<(), RangeHighlightError> { let (color, current_color) = (cx.theme().warning.opacity(0.3), cx.theme().warning); let text = state.rendered_text(); let matches = if query.is_empty() { Vec::new() } else { text.as_str().match_indices(query).collect() }; let highlights = matches.into_iter().enumerate().map(|(ix, (start, found))| { RangeHighlight::new( start..start + found.len(), if ix == current_match { current_color } else { color }, ) }); state.set_range_highlights(highlights, cx) } ``` `rendered_text()` is the text plain copy produces: `hello **world**` reads `hello world`, escapes are resolved, and heading and list markers are left out. Offsets are UTF-8 byte offsets, so the ranges `str` search returns can be passed as they are, and a repeated phrase is addressed by where it occurs. The text is built the first time it is read. A highlight is painted behind the text and under the selection, so wrapping, alignment, syntax colors, links, selection and copy stay as they were. Where highlights overlap, the later one paints over the earlier. A range that crosses from one block into the next paints in both. Text that belongs to no block is left unpainted: the line breaks between blocks, the spaces between table cells, custom blocks, HTML blocks and inline plugin objects. Only a range that is reversed, out of bounds or not on a character boundary is rejected, and the whole set with it. When the content changes, a highlight follows its block and stays as far as the block's text is unchanged. Text appended while streaming, through `push_str` or `set_text`, keeps the highlights before it, and an edit keeps those before and after it. After an edit inside a table, the cells in and after the edited row lose theirs, since a cell is only known by its place in the table. The view notifies when its text changes: observe the state and search the new `rendered_text()` again. Compute ranges and call `set_range_highlights` in the same state update so the ranges address the current text. Backgrounds that are part of the text, such as `` and syntax highlighting, paint over a range highlight (inline code's background is painted under it), and highlights do not fade in with streamed text. HTML views do not support range highlights. An application that keeps ranges of the Markdown source instead, such as the ranges `selected_source_range()` returns for the passages people comment on, converts each with `range_for_source` on the same `rendered_text()`: ```rust use std::ops::Range; use gpui_kit::component::{ ActiveTheme as _, text::{RangeHighlight, RangeHighlightError, TextViewState}, }; fn highlight_comments( state: &mut TextViewState, markdown: &str, comments: &[Range], cx: &mut Context, ) -> Result<(), RangeHighlightError> { let text = state.rendered_text(); // The ranges index `markdown`. Until its parse lands, the view renders the // source it had before; convert again when the view notifies. if text.source() != markdown { return Ok(()); } let color = cx.theme().info.opacity(0.3); let highlights = comments .iter() .filter_map(|comment| text.range_for_source(comment.clone())) .map(|range| RangeHighlight::new(range, color)); state.set_range_highlights(highlights, cx) } ``` `range_for_source` returns the smallest range holding every character rendered from the source range. Source that renders nothing, such as emphasis delimiters, heading and list markers, code fences, table pipes and link destinations, adds nothing, so `**bold**` and `bold` convert to the same range. A character is included when any of its source is in the range: `&` for part of `&`, or the whole text of an inline plugin object for part of its source. Converting the source range a selection reports gives the selected range back, except that a line break between blocks at either end of the selection is rendered from no source and is left out: Select All gives back everything but the line break after the last block. `rendered_text().source()` is the source of the last parse that landed. While text is appended with `push_str`, it is a prefix of the text the view was given; after `set_text` replaces the content, it can be the earlier, unrelated text until the new parse lands, so compare it with the source your ranges index, as the example does, before converting. The result is `None` when the range is empty, out of bounds or not on a character boundary, when nothing is rendered from it, and always in HTML views. ### Scroll to a range `reveal_range` scrolls to a range of the same text, such as the current result when the user steps to the next one: ```rust state.reveal_range(current_range, cx)?; ``` It scrolls the line the range starts on into view, down to a line in the middle of a long paragraph, and leaves the view where it is when that line, or a whole block revealed, is already visible. An empty range reveals the line of its position. A `scrollable` view scrolls itself. A fit-content view scrolls the nearest enclosing `gpui::list`, as a chat transcript is, as long as the row that holds the view is laid out: scroll to that row first when it may be off screen. Any other scroll container, such as a `div` with `overflow_y_scroll`, scrolls through `on_reveal`, which receives the line's bounds in window coordinates: ```rust let scroll = scroll_handle.clone(); TextView::new(&state).on_reveal(move |line, _, _| { let viewport = scroll.bounds(); let mut offset = scroll.offset(); if line.bottom() > viewport.bottom() { offset.y -= line.bottom() - viewport.bottom(); } else if line.top() < viewport.top() { offset.y += viewport.top() - line.top(); } scroll.set_offset(offset); }) ``` A range that covers no block's text, such as a custom block's, scrolls its whole block into a scrollable view. Only the latest reveal is carried out. It follows the content the way highlights do, and it is dropped when its text changes, when the view clamps its lines with `max_lines`, or when it cannot be shown within a second, so it never scrolls long after it was asked for. Text scrolled sideways inside a table stays where it is, a block revealed whole and taller than the view shows its end when it comes from below, a scrollable view inside an application list scrolls only itself, and views sharing one state share one reveal. Revealing is best effort. `Ok(())` means the range is valid for the current text and the request was taken, not that the view has scrolled, and a dropped request is not reported. ## Touch Selection On a touch screen, a long press selects the word under the finger and keeps following the finger while it stays down. Lifting it opens an edit menu with `Copy` and `Select All` over the selection and puts a grab handle at each end. Dragging a handle moves that end while the other stays put; `Select All` selects the view that was pressed, and its handles keep working on the result. The handles and the menu are drawn by [`Root`](/versions/v0.7.1/component/root) for the whole window selection, so they cover a selection that spans several views. A tap elsewhere clears them, and the menu steps aside while the content scrolls under a finger. ## Link Click Handling Use `on_link_click` when links should be routed by the application instead of being opened directly by `App::open_url`. The callback receives the resolved URL and the original GPUI `ClickEvent`, so it can distinguish mouse buttons, keyboard activation, touch, and modifier keys: ```rust use gpui_kit::ClickEvent; use gpui_kit::component::text::markdown; markdown("[Open the project](https://github.com/longbridge/gpui-kit)") .on_link_click(|url, event, _window, cx| { if event.is_right_click() { println!("Show a context menu for {url}"); return; } match event { ClickEvent::Mouse(click) if click.up.modifiers.control => { println!("Open {url} in an internal view"); } _ => cx.open_url(url), } }) ``` Installing a handler consumes the link event and disables the default URL opening behavior. If no handler is installed, links continue to use `App::open_url` as usual. The callback is used for both text links and linked images. ## Images A Markdown `![alt](src)` or HTML `` renders through GPUI's `img` element, and `src` decides where the bytes come from: - `http://` and `https://` URLs are fetched with the application's HTTP client. - `data:` URLs are decoded in place, so a document can embed its own images (`data:image/png;base64,…`, or a percent-encoded `data:image/svg+xml,…`). Any image format GPUI can decode is accepted; a `data:` URL with another media type is left to the loader and reports an error like any other unreachable image. - Every other value — a relative path, `file://`, a custom scheme — is passed through as a URI. `TextView` never reads the filesystem or the asset bundle on a document's behalf. To resolve those other sources, or to change how any image is loaded, wrap the `TextView` in an element that installs a GPUI `ImageCache`. Every `img` inside it, including the ones the document produces, asks that cache for its `Resource` before falling back to the default loader: ```rust use gpui_kit::{ImageCache, ImageCacheProvider}; div() .image_cache(app_image_cache.clone()) .child(markdown("![diagram](app://diagrams/pipeline.svg)")) ``` `ImageCache::load` receives the `Resource::Uri` and decides how to turn it into a `RenderImage`, so the application owns the loading policy while the document stays plain Markdown. ## Markdown Plugins Use `.plugin(...)` to support custom Markdown formats. A plugin owns both parsing and rendering, so callers only need to attach it to the `TextView`: ```rust markdown(source) .plugin(TickerPlugin::new()) ``` A Markdown plugin implements `MarkdownPlugin`: ```rust use gpui_kit::{App, IntoElement, ParentElement as _, Window}; use gpui_kit::component::text::{ markdown_ast, MarkdownNode, MarkdownParseContext, MarkdownPlugin, }; struct TickerNode { symbol: String, } struct TickerPlugin; impl TickerPlugin { fn new() -> Self { Self } } impl MarkdownPlugin for TickerPlugin { fn is_block(&self) -> bool { true } fn name(&self) -> &str { "ticker" } fn parse( &self, node: &markdown_ast::Node, cx: &MarkdownParseContext<'_>, ) -> Option { let markdown_ast::Node::Paragraph(paragraph) = node else { return None; }; let [markdown_ast::Node::Text(text)] = paragraph.children.as_slice() else { return None; }; let symbol = text.value.strip_prefix('$')?; Some( MarkdownNode::new( "ticker", TickerNode { symbol: symbol.to_string(), }, ) .text(format!("${symbol}")) .markdown(cx.node_source(node).unwrap_or(text.value.as_str())), ) } fn render( &self, node: &MarkdownNode, _window: &mut Window, _cx: &mut App, ) -> impl IntoElement { let ticker = node.data::().expect("ticker node data"); gpui_kit::div().child(format!("${}", ticker.symbol)) } } ``` Then attach it to a Markdown `TextView`: ```rust markdown("$AAPL.US") .plugin(TickerPlugin::new()) ``` ## MarkdownNode `MarkdownNode` is the neutral data passed between `parse` and `render`. ```rust MarkdownNode::new("ticker", TickerNode { symbol }) .text("$AAPL.US") .markdown("$AAPL.US") ``` - `name` is the stable node name used to match the renderer. - `data` is typed parser output read with `node.data::()`. - `text` is the plain text representation used by selection and fallback rendering. - `markdown` is the Markdown representation used when the document is serialized back to Markdown. ## Block Plugins Return `true` from `is_block()` to use the block parser and renderer: ```rust fn is_block(&self) -> bool { true } ``` Inline plugins use the default `is_block() == false` and return `Option` from `render_inline`. Wrap any GPUI element with `InlineElement::new(...)`, use native styles and events, and set an optional baseline. TextView measures and selects the whole element as one atom, with plain/Markdown copying, text fallback, and explicit asynchronous layout invalidation. See [Inline plugin](/versions/v0.7.1/base/text-view#inline-plugin) for the contract and `.plugin(...)` registration example. The component facade exports the same `InlineElement` and `InlineRenderContext` types. ## YAML Frontmatter YAML frontmatter is opt-in because it is not part of CommonMark or GFM. Enable the parser construct and attach `FrontmatterPlugin` to render top-level mappings as a `DescriptionList`: ```rust use gpui_component::text::{markdown, FrontmatterPlugin, MarkdownExtensions}; let extensions = MarkdownExtensions::default().frontmatter(); markdown("---\nname: example\ndescription: Example metadata.\n---") .markdown_extensions(extensions) .plugin(FrontmatterPlugin::new()) ``` Values are rendered as plain text. Simple unquoted values and block scalars using `|-` or `>-` are supported; literal scalars preserve content indentation. Quoted values, inline comments, collections, aliases, other block headers, and more-indented folded lines fall back to a YAML code block, preserving the source instead of displaying an incorrectly interpreted value. ## Code Block Actions You can render controls for Markdown code blocks: ```rust markdown(source) .code_block_actions(|code_block, _window, _cx| { gpui_kit::div().child(format!("Run {}", code_block.lang().unwrap_or_default())) }) ``` --- # Plot Source: /versions/v0.7.1/component/plot License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/plot and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. The `plot` module provides low-level building blocks for creating custom charts. It includes scales, shapes, and utilities that power the high-level `Chart` components. The primitives live in gpui-base as [`gpui_kit::base::plot`](/versions/v0.7.1/base/plot) and are re-exported here unchanged, so either path works. GPUI Component adds the styled pieces on top: the `Tooltip`, `CrossLine` and `Dot` overlay in `plot::tooltip`, the `#[derive(IntoPlot)]` shorthand, and the hover motion it projects from its theme. ## Import ```rust use gpui_kit::component::plot::{ scale::{Scale, ScaleLinear, ScaleBand, ScalePoint, ScaleOrdinal}, shape::{Bar, Stack, Line, Area, Pie, Arc}, PlotAxis, AxisText }; ``` ## Scales Scales map a dimension of abstract data to a visual representation. ### ScaleLinear Maps a continuous quantitative domain to a continuous range. ```rust let scale = ScaleLinear::new( vec![0., 100.], // Domain (data values) [0., 500.] // Range (pixel coordinates) ); scale.tick(&50.); // Returns pixel position ``` ### ScaleBand Maps a discrete domain to a continuous range, useful for bar charts. ```rust let scale = ScaleBand::new( vec!["A", "B", "C"], // Domain [0., 300.] // Range ) .padding_inner(0.1) .padding_outer(0.1); scale.band_width(); // Returns width of each band scale.tick(&"A"); // Returns start position of band "A" ``` ### ScalePoint Maps a discrete domain to a set of points in a continuous range, useful for scatter plots or line charts with categorical axes. ```rust let scale = ScalePoint::new( vec!["A", "B", "C"], // Domain [0., 300.] // Range ); scale.tick(&"A"); // Returns position of point "A" ``` ### ScaleOrdinal Maps a discrete domain to a discrete range. ```rust let scale = ScaleOrdinal::new( vec!["A", "B", "C"], // Domain vec![color1, color2, color3] // Range ); scale.map(&"A"); // Returns color1 ``` ## Shapes ### Bar Renders a bar shape, commonly used in bar charts. ```rust Bar::new() .data(data) .band_width(30.) .x(|d| x_scale.tick(&d.category)) .y0(|d| y_scale.tick(&0.).unwrap()) .y1(|d| y_scale.tick(&d.value)) .fill(|d| color_scale.map(&d.category)) .paint(&bounds, window, cx); ``` ### Line Renders a line shape, commonly used in line charts. ```rust Line::new() .data(data) .x(|d| x_scale.tick(&d.date)) .y(|d| y_scale.tick(&d.value)) .stroke(cx.theme().chart_1) .stroke_width(px(2.)) .paint(&bounds, window); ``` #### Line with Dots Supports rendering dots at data points. ```rust Line::new() .data(data) .x(|d| x_scale.tick(&d.date)) .y(|d| y_scale.tick(&d.value)) .dot() .dot_size(px(4.)) .paint(&bounds, window); ``` ### Area Renders an area shape, commonly used in area charts. ```rust Area::new() .data(data) .x(|d| x_scale.tick(&d.date)) .y0(height) // Baseline .y1(|d| y_scale.tick(&d.value)) .fill(cx.theme().chart_1.opacity(0.5)) .stroke(cx.theme().chart_1) .paint(&bounds, window); ``` ### Pie & Arc Renders pie charts and donut charts using `Pie` layout and `Arc` shape. ```rust // 1. Compute pie layout let pie = Pie::new() .value(|d| Some(d.value)) .pad_angle(0.05); let arcs = pie.arcs(&data); // 2. Render arcs let arc_shape = Arc::new() .inner_radius(0.) .outer_radius(100.); for arc_data in arcs { arc_shape.paint( &arc_data, color_scale.map(&arc_data.data.category), // Color None, // Override inner radius None, // Override outer radius &bounds, window ); } ``` ### Stack Computes stacked layout for data series. ```rust let stack = Stack::new() .data(data) .keys(vec!["series1", "series2"]) .value(|d, key| match key { "series1" => Some(d.val1), "series2" => Some(d.val2), _ => None }); let series = stack.series(); // Returns Vec> ``` ## Components ### PlotAxis Renders chart axes with labels and ticks. ```rust PlotAxis::new() .x(height) // Y position for X axis .x_label(labels) // Iterator of AxisText .stroke(cx.theme().border) .paint(&bounds, window, cx); ``` ## Examples ### Custom Bar Chart Implementation Here's how to implement a custom stacked bar chart using low-level plot primitives: ```rust struct StackedBarChart { data: Vec, series: Vec>, } impl StackedBarChart { pub fn new(data: Vec) -> Self { let series = Stack::new() .data(data.clone()) .keys(vec!["desktop", "mobile"]) .value(|d, key| match key { "desktop" => Some(d.desktop), "mobile" => Some(d.mobile), _ => None, }) .series(); Self { data, series } } } impl Plot for StackedBarChart { fn paint(&mut self, bounds: Bounds, window: &mut Window, cx: &mut App) { // 1. Setup Scales let x = ScaleBand::new( self.data.iter().map(|v| v.date.clone()), [0., width], ); let y = ScaleLinear::new(vec![0., max_value], [height, 0.]); // 2. Draw Axis // ... (axis rendering logic) // 3. Draw Stacked Bars let bar = Bar::new() .stack_data(&self.series) .band_width(x.band_width()) .x(move |d| x.tick(&d.data.date)) .fill(move |_| cx.theme().chart_1); bar.paint(&bounds, window, cx); } } ``` --- # GroupBox Source: /versions/v0.7.1/component/group-box License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/group-box and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. The GroupBox component is a versatile container that groups related content together with optional borders, backgrounds, and titles. It provides visual organization and semantic grouping for form controls, settings panels, and other related UI elements. ## Import ```rust use gpui_kit::component::group_box::{GroupBox, GroupBoxVariant, GroupBoxVariants as _}; ``` ## Usage ### Basic GroupBox ```rust GroupBox::new() .child("Subscriptions") .child(Checkbox::new("all").label("All")) .child(Checkbox::new("newsletter").label("Newsletter")) .child(Button::new("save").primary().label("Save")) ``` ### GroupBox Variants ```rust // Normal variant (default) - no background or border GroupBox::new() .child("Content without visual container") // Fill variant - with background color GroupBox::new() .fill() .title("Settings") .child("Content with background") // Outline variant - with border, no background GroupBox::new() .outline() .title("Preferences") .child("Content with border") ``` ### With Title ```rust GroupBox::new() .fill() .title("Account Settings") .child( h_flex() .justify_between() .child("Make profile private") .child(Switch::new("privacy").checked(false)) ) .child(Button::new("save").primary().label("Save Changes")) ``` ### Footer outside the surface Use `footer` for supporting content below the filled background or outline, not inside the content area. It shares the title's leading edge, sits 8 px under the surface, and renders as small muted text like a description, so plain text is enough. `content_style` only changes the body. ```rust GroupBox::new() .fill() .child("Update preferences") .footer("Changes apply to this device only.") ``` ### Custom ID ```rust GroupBox::new() .id("user-preferences") .outline() .title("User Preferences") .child("Preference controls...") ``` ### Custom Title Styling ```rust use gpui_kit::{StyleRefinement, relative}; GroupBox::new() .outline() .title("Custom Title") .title_style( StyleRefinement::default() .font_semibold() .line_height(relative(1.0)) .px_3() .text_color(cx.theme().accent) ) .child("Content with custom title styling") ``` ### Custom Content Styling ```rust GroupBox::new() .fill() .title("Custom Content Area") .content_style( StyleRefinement::default() .rounded_xl() .py_3() .px_4() .border_2() .border_color(cx.theme().accent) ) .child("Content with custom styling") ``` ### Complex Example ```rust GroupBox::new() .id("notification-settings") .outline() .bg(cx.theme().group_box) .rounded_xl() .p_5() .title("Notification Preferences") .title_style( StyleRefinement::default() .font_semibold() .line_height(relative(1.0)) .px_3() ) .content_style( StyleRefinement::default() .rounded_xl() .py_3() .px_4() .border_2() ) .child( v_flex() .gap_3() .child( h_flex() .justify_between() .child("Email notifications") .child(Switch::new("email").checked(true)) ) .child( h_flex() .justify_between() .child("Push notifications") .child(Switch::new("push").checked(false)) ) .child( h_flex() .justify_between() .child("SMS notifications") .child(Switch::new("sms").checked(false)) ) ) .child( h_flex() .justify_end() .gap_2() .child(Button::new("cancel").label("Cancel")) .child(Button::new("save").primary().label("Save Settings")) ) ``` ## Examples ### Form Section ```rust GroupBox::new() .fill() .title("Personal Information") .child( v_flex() .gap_4() .child( h_flex() .gap_2() .child(Input::new("first-name").placeholder("First Name")) .child(Input::new("last-name").placeholder("Last Name")) ) .child(Input::new("email").placeholder("Email Address")) .child( h_flex() .justify_end() .child(Button::new("update").primary().label("Update Profile")) ) ) ``` ### Settings Panel ```rust GroupBox::new() .outline() .title("Display Settings") .child( v_flex() .gap_3() .child( h_flex() .justify_between() .child(Label::new("Theme")) .child( RadioGroup::horizontal("theme") .child(Radio::new("light").label("Light")) .child(Radio::new("dark").label("Dark")) .child(Radio::new("auto").label("Auto")) ) ) .child( h_flex() .justify_between() .child(Label::new("Font Size")) .child( Select::new("font-size") .option("small", "Small") .option("medium", "Medium") .option("large", "Large") ) ) ) ``` ### Subscription Management ```rust GroupBox::new() .title("Email Subscriptions") .child( v_flex() .gap_2() .child(Checkbox::new("newsletter").label("Weekly Newsletter")) .child(Checkbox::new("updates").label("Product Updates")) .child(Checkbox::new("security").label("Security Alerts")) .child(Checkbox::new("marketing").label("Marketing Communications")) ) .child( h_flex() .justify_between() .mt_4() .child(Button::new("unsubscribe-all").link().label("Unsubscribe All")) .child(Button::new("save").primary().label("Update Preferences")) ) ``` ### Without Title ```rust GroupBox::new() .outline() .child( h_flex() .justify_between() .items_center() .child("Enable two-factor authentication") .child(Switch::new("2fa").checked(false)) ) ``` ## Styling The GroupBox component supports extensive customization through both built-in variants and custom styling: ### Theme Integration ```rust // Using theme colors GroupBox::new() .fill() .bg(cx.theme().group_box) .title("Themed Group Box") ``` ### Custom Appearance ```rust GroupBox::new() .outline() .border_2() .border_color(cx.theme().accent) .rounded(cx.theme().radius_lg) .title("Custom Styled Group Box") .title_style( StyleRefinement::default() .text_color(cx.theme().accent) .font_bold() ) ``` ## Best Practices 1. **Use titles for clarity** - Always include a descriptive title when grouping form controls 2. **Choose appropriate variants** - Use `fill()` for primary content groups, `outline()` for secondary groupings 3. **Maintain visual hierarchy** - Use GroupBox to create clear sections without overwhelming the interface 4. **Group related content** - Only group logically related controls and information 5. **Consider spacing** - The component automatically handles internal spacing, but consider external margins 6. **Responsive design** - GroupBox adapts well to different screen sizes and container widths ## Related Components - **Form**: Use GroupBox within forms to organize sections - **Dialog**: GroupBox works well within dialogs for organizing content - **Accordion**: For collapsible grouped content, consider using Accordion instead - **Card**: For elevated content containers with more visual weight --- # Tabs Source: /versions/v0.7.1/component/tabs License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/tabs and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A tabbed interface component for organizing content into separate sections. Supports multiple variants, sizes, navigation controls, and interactive features like reordering and prefix/suffix elements. ## Import ```rust use gpui_kit::component::tab::{Tab, TabBar}; ``` ## Usage ### Basic Tabs ```rust TabBar::new("tabs") .selected_index(0) .on_click(|selected_index, _, _| { println!("Tab {} selected", selected_index); }) .child(Tab::new().label("Account")) .child(Tab::new().label("Profile")) .child(Tab::new().label("Settings")) ``` ### Tab Variants #### Default Tabs ```rust TabBar::new("default-tabs") .selected_index(0) .child(Tab::new().label("Account")) .child(Tab::new().label("Profile")) .child(Tab::new().label("Documents")) ``` #### Underline Tabs ```rust TabBar::new("underline-tabs") .underline() .selected_index(0) .child(Tab::new().label("Account")) .child(Tab::new().label("Profile")) .child(Tab::new().label("Documents")) ``` #### Pill Tabs ```rust TabBar::new("pill-tabs") .pill() .selected_index(0) .child(Tab::new().label("Account")) .child(Tab::new().label("Profile")) .child(Tab::new().label("Documents")) ``` #### Outline Tabs ```rust TabBar::new("outline-tabs") .outline() .selected_index(0) .child(Tab::new().label("Account")) .child(Tab::new().label("Profile")) .child(Tab::new().label("Documents")) ``` #### Segmented Tabs ```rust use gpui_kit::component::IconName; TabBar::new("segmented-tabs") .segmented() .selected_index(0) .child(IconName::Bot) .child(IconName::Calendar) .child(IconName::Map) .children(vec!["Settings", "About"]) ``` ### Tab Sizes ```rust // Extra Small TabBar::new("tabs").xsmall() .child(Tab::new().label("Small")) // Small TabBar::new("tabs").small() .child(Tab::new().label("Small")) // Medium (default) TabBar::new("tabs") .child(Tab::new().label("Medium")) // Large TabBar::new("tabs").large() .child(Tab::new().label("Large")) ``` ### Tabs with Icons ```rust use gpui_kit::component::{Icon, IconName}; TabBar::new("icon-tabs") .child(Tab::default().icon(IconName::User).with_variant(TabVariant::Tab)) .child(Tab::default().icon(IconName::Settings).with_variant(TabVariant::Tab)) .child(Tab::default().icon(IconName::Mail).with_variant(TabVariant::Tab)) ``` ### Tabs with Prefix and Suffix ```rust use gpui_kit::component::button::Button; use gpui_kit::component::{h_flex, IconName}; TabBar::new("tabs-with-controls") .prefix( h_flex() .gap_1() .child(Button::new("back").ghost().xsmall().icon(IconName::ArrowLeft)) .child(Button::new("forward").ghost().xsmall().icon(IconName::ArrowRight)) ) .suffix( h_flex() .gap_1() .child(Button::new("inbox").ghost().xsmall().icon(IconName::Inbox)) .child(Button::new("more").ghost().xsmall().icon(IconName::Ellipsis)) ) .child(Tab::new().label("Account")) .child(Tab::new().label("Profile")) .child(Tab::new().label("Settings")) ``` ### Disabled Tabs ```rust TabBar::new("tabs-with-disabled") .child(Tab::new().label("Account")) .child(Tab::new().label("Profile").disabled(true)) .child(Tab::new().label("Settings")) ``` ### Dynamic Tabs ```rust struct TabsView { active_tab: usize, tabs: Vec, } impl Render for TabsView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { TabBar::new("dynamic-tabs") .selected_index(self.active_tab) .on_click(cx.listener(|view, index, _, cx| { view.active_tab = *index; cx.notify(); })) .children( self.tabs .iter() .map(|tab_name| Tab::new().label(tab_name.clone())) ) } } ``` ### Tabs with Menu Use `menu` option to enable a dropdown menu for tab selection when there are many tabs, this is default `false`. If enable, the will have a dropdown button at the end of the tab bar to show all tabs in a menu. ```rust TabBar::new("tabs-with-menu") .menu(true) .selected_index(0) .child(Tab::new().label("Account")) .child(Tab::new().label("Profile")) .child(Tab::new().label("Documents")) .child(Tab::new().label("Mail")) .child(Tab::new().label("Settings")) ``` ### Maximum Tab Width Use `max_width` to cap the width of each tab. For tabs created with `.label()`, text longer than the limit is truncated with an ellipsis automatically. Icon-only tabs (`.icon()`) are not affected. Prefix and suffix elements (e.g. a close button) are never truncated — the label yields space first. The *more* menu (when enabled) still shows the full label text. Tabs built from custom children (via `.child()`) are only given the width limit; add `truncate()` yourself to the part that should shrink. ```rust use gpui_kit::{div, px}; TabBar::new("tabs-with-max-width") .max_width(px(100.)) .menu(true) .selected_index(0) .child(Tab::new().label("Account Settings & Preferences")) .child(Tab::new().label("Documents & Files")) .child(Tab::new().label("Appearance & Themes")) .child( Tab::new().child( h_flex() .gap_1() .child(Icon::new(IconName::Bot)) .child(div().truncate().child("Custom Child Tab")), ), ) ``` ### Scrollable Tabs ```rust use gpui_kit::ScrollHandle; struct ScrollableTabsView { scroll_handle: ScrollHandle, } impl Render for ScrollableTabsView { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { TabBar::new("scrollable-tabs") .track_scroll(&self.scroll_handle) .child(Tab::new().label("Very Long Tab Name 1")) .child(Tab::new().label("Very Long Tab Name 2")) .child(Tab::new().label("Very Long Tab Name 3")) .child(Tab::new().label("Very Long Tab Name 4")) .child(Tab::new().label("Very Long Tab Name 5")) } } ``` ### Individual Tab Configuration ```rust TabBar::new("custom-tabs") .child( Tab::new().label("Custom Tab") .id("custom-id") .prefix(IconName::Star) .suffix(IconName::X) .on_click(|_, _, _| { println!("Custom tab clicked"); }) ) ``` ## API Reference ### TabBar | Method | Description | | --------------------------- | -------------------------------------------------- | | `new(id)` | Create a new tab bar with the given ID | | `child(tab)` | Add a tab to the bar | | `children(tabs)` | Add multiple tabs to the bar | | `selected_index(index)` | Set the active tab index | | `on_click(fn)` | Callback when a tab is clicked, receives tab index | | `prefix(element)` | Add element before the tabs | | `suffix(element)` | Add element after the tabs | | `last_empty_space(element)` | Custom element for empty space at the end | | `track_scroll(handle)` | Enable scrolling with a scroll handle | | `with_menu(bool)` | Enable dropdown menu for tab selection | | `max_width(width)` | Set maximum width of each tab; truncates | ### TabBar Variants | Method | Description | | ----------------------- | ------------------------------------ | | `with_variant(variant)` | Set the tab variant for all children | | `underline()` | Use underline variant | | `pill()` | Use pill variant | | `outline()` | Use outline variant | | `segmented()` | Use segmented variant | ### Tab | Method | Description | | ----------------------- | ---------------------------------------------- | | `new(label)` | Create a new tab with a label | | `empty()` | Create an empty tab | | `icon(icon)` | Create a tab with only an icon | | `id(id)` | Set custom ID for the tab | | `with_variant(variant)` | Set the tab variant | | `pill()` | Use pill variant | | `outline()` | Use outline variant | | `segmented()` | Use segmented variant | | `underline()` | Use underline variant | | `prefix(element)` | Add element before tab content | | `suffix(element)` | Add element after tab content | | `disabled(bool)` | Set disabled state | | `selected(bool)` | Set selected state (usually handled by TabBar) | | `on_click(fn)` | Custom click handler for individual tab | ### TabVariant ```rust pub enum TabVariant { Tab, // Default bordered tabs Outline, // Rounded outline tabs Pill, // Rounded pill-shaped tabs Segmented, // Segmented control style Underline, // Underline indicator tabs } ``` ### Styling Both `TabBar` and `Tab` implement `Sizable` trait: - `xsmall()` - Extra small size - `small()` - Small size - `medium()` - Medium size (default) - `large()` - Large size ## Advanced Examples ### Custom Tab Content ```rust Tab::empty() .child( h_flex() .items_center() .gap_2() .child(IconName::Folder) .child("Documents") .child( div() .px_1() .py_0p5() .text_xs() .bg(cx.theme().accent) .text_color(cx.theme().accent_foreground) .rounded(cx.theme().radius.half()) .child("12") ) ) ``` ### Tabs with State Management ```rust struct TabsWithContent { active_tab: usize, tab_contents: Vec, } impl TabsWithContent { fn render_tab_content(&self, cx: &mut Context) -> impl IntoElement { match self.active_tab { 0 => div().child("Account content"), 1 => div().child("Profile content"), 2 => div().child("Settings content"), _ => div().child("Unknown content"), } } } impl Render for TabsWithContent { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .child( TabBar::new("content-tabs") .selected_index(self.active_tab) .on_click(cx.listener(|view, index, _, cx| { view.active_tab = *index; cx.notify(); })) .child(Tab::new().label("Account")) .child(Tab::new().label("Profile")) .child(Tab::new().label("Settings")) ) .child( div() .flex_1() .p_4() .child(self.render_tab_content(cx)) ) } } ``` ### Tabs with Close Buttons While the basic Tab component doesn't include closeable functionality, you can create closeable tabs using suffix elements: ```rust struct CloseableTabsView { tabs: Vec, active_tab: usize, } impl CloseableTabsView { fn close_tab(&mut self, index: usize, cx: &mut Context) { if self.tabs.len() > 1 { self.tabs.remove(index); if self.active_tab >= index && self.active_tab > 0 { self.active_tab -= 1; } cx.notify(); } } } impl Render for CloseableTabsView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { TabBar::new("closeable-tabs") .selected_index(self.active_tab) .on_click(cx.listener(|view, index, _, cx| { view.active_tab = *index; cx.notify(); })) .children( self.tabs .iter() .enumerate() .map(|(index, tab_name)| { Tab::new().label(tab_name.clone()) .suffix( Button::new(format!("close-{}", index)) .icon(IconName::X) .ghost() .xsmall() .on_click(cx.listener(move |view, _, _, cx| { view.close_tab(index, cx); })) ) }) ) } } ``` ## Notes - The `TabBar` manages the selection state of all child tabs - Individual tab `on_click` handlers are ignored when `TabBar.on_click` is set - Tabs automatically inherit the variant and size from their parent `TabBar` - The `with_menu` option adds a dropdown for tab selection when there are many tabs - Scrolling is automatically enabled when tabs overflow the container width - The dock system provides advanced closeable tab functionality for complex layouts --- # Pagination Source: /versions/v0.7.1/component/pagination License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/pagination and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. The [Pagination] component provides page navigation with next and previous links. It displays page numbers and allows users to navigate through multiple pages of content. ## Import ```rust use gpui_kit::component::pagination::Pagination; ``` ## Usage ### Basic Pagination ```rust Pagination::new("my-pagination") .current_page(5) .total_pages(10) .on_click(|page, _, cx| { println!("Navigated to page: {}", page); }) ``` ### With Visible Pages By default, the pagination shows up to 5 visible page buttons. You can customize this with `visible_pages()`: ```rust Pagination::new("my-pagination") .current_page(1) .total_pages(50) .visible_pages(10) .on_click(|page, _, cx| { // Handle page change }) ``` ### Compact Style The compact style only shows the previous and next buttons with icons, without displaying page numbers. Use `compact` method to enable compact style: ```rust Pagination::new("my-pagination") .compact() .current_page(3) .total_pages(10) .on_click(|page, _, cx| { // Handle page change }) ``` ### Different Sizes The Pagination supports the [Sizable] trait for different sizes: ```rust use gpui_kit::component::{Sizable as _, Size}; Pagination::new("my-pagination") .xsmall() .current_page(1) .total_pages(10) Pagination::new("my-pagination") .small() .current_page(1) .total_pages(10) Pagination::new("my-pagination") .current_page(1) .total_pages(10) // Medium (default) Pagination::new("my-pagination") .large() .current_page(1) .total_pages(10) ``` ### Disabled State ```rust Pagination::new("my-pagination") .current_page(4) .total_pages(10) .disabled(true) .on_click(|_, _, _| {}) ``` ### Handle Page Change Events The `on_click` callback receives the new page number when users click on page numbers, previous, or next buttons: ```rust Pagination::new("my-pagination") .current_page(current_page) .total_pages(total_pages) .on_click(|page, _, cx| { // Update your state with the new page // The page number is 1-based }) ``` ## API Reference - [Pagination] ### Sizing Implements [Sizable] trait: - `xsmall()` - Extra small size - `small()` - Small size - `medium()` - Medium size (default) - `large()` - Large size - `with_size(size)` - Set custom size ### Methods - `current_page(page: usize)` - Set the current page number (1-based). The value will be clamped between 1 and total_pages. - `total_pages(pages: usize)` - Set the total number of pages. - `visible_pages(max: usize)` - Set the maximum number of visible page buttons (default: 5). - `compact()` - Enable compact style (only shows prev/next buttons with icons). - `disabled(bool)` - Set the disabled state. - `on_click(handler)` - Set the handler for page change events. ## Examples ### With State Management ```rust let mut current_page = 1; let total_pages = 20; Pagination::new("pagination") .current_page(current_page) .total_pages(total_pages) .on_click({ let entity = entity.clone(); move |page, _, cx| { entity.update(cx, |this, cx| { this.current_page = *page; cx.notify(); }); } }) ``` ### Large Dataset Pagination For large datasets, use `visible_pages()` to show more page options: ```rust Pagination::new("large-pagination") .current_page(25) .total_pages(100) .visible_pages(10) .on_click(|page, _, cx| { // Load data for the new page }) ``` [Pagination]: https://docs.rs/gpui-component/latest/gpui_component/pagination/struct.Pagination.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html --- # Avatar Source: /versions/v0.7.1/component/avatar License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/avatar and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. The Avatar component displays user profile images with intelligent fallbacks. When no image is provided, it shows user initials or a placeholder icon. The component supports various sizes and can be grouped together for team displays. ## Import ```rust use gpui_kit::component::avatar::{Avatar, AvatarGroup}; ``` ## Usage ### Basic Avatar You can create an [Avatar] by providing an image source URL and a user name: ```rust Avatar::new() .name("John Doe") .src("https://example.com/avatar.jpg") ``` ### Avatar with Fallback Text When no image source is provided, the Avatar displays user initials with an automatically generated color background: ```rust // Shows "JD" initials with colored background Avatar::new() .name("John Doe") // Shows "JS" initials Avatar::new() .name("Jane Smith") ``` The color is derived from the initials, so the same person always gets the same one. It comes from a ring of 12 evenly spaced OkLCH hues held at a fixed lightness and chroma, which keeps every avatar at the same visual weight and its text above WCAG AA contrast in both the light and dark themes. The outline follows the same hue; an Avatar showing an image keeps the neutral border. ### Avatar Placeholder For anonymous users or when no name is provided: ```rust use gpui_kit::component::IconName; // Default user icon placeholder Avatar::new() // Custom placeholder icon Avatar::new() .placeholder(IconName::Building2) ``` ### Avatar Sizes ```rust Avatar::new() .name("John Doe") .xsmall() Avatar::new() .name("John Doe") .small() Avatar::new() .name("John Doe") // 48px (default medium) Avatar::new() .name("John Doe") .large() // Custom size Avatar::new() .name("John Doe") .with_size(px(100.)) ``` ### Custom Styling ```rust Avatar::new() .src("https://example.com/avatar.jpg") .with_size(px(100.)) .border_3() .border_color(cx.theme().foreground) .shadow_sm() .rounded(px(20.)) // Custom border radius ``` ## AvatarGroup The [AvatarGroup] component allows you to display multiple avatars in a compact, overlapping layout: ### Basic Group ```rust AvatarGroup::new() .child(Avatar::new().src("https://example.com/user1.jpg")) .child(Avatar::new().src("https://example.com/user2.jpg")) .child(Avatar::new().src("https://example.com/user3.jpg")) .child(Avatar::new().name("John Doe")) ``` ### Group with Limit ```rust AvatarGroup::new() .limit(3) // Show maximum 3 avatars .child(Avatar::new().src("https://example.com/user1.jpg")) .child(Avatar::new().src("https://example.com/user2.jpg")) .child(Avatar::new().src("https://example.com/user3.jpg")) .child(Avatar::new().src("https://example.com/user4.jpg")) // Hidden .child(Avatar::new().src("https://example.com/user5.jpg")) // Hidden ``` ### Group with Ellipsis Show an ellipsis indicator when avatars are hidden due to the limit. In this example, only 3 avatars are shown, and "..." indicates there are more: ```rust AvatarGroup::new() .limit(3) .ellipsis() // Shows "..." when limit is exceeded .child(Avatar::new().src("https://example.com/user1.jpg")) .child(Avatar::new().src("https://example.com/user2.jpg")) .child(Avatar::new().src("https://example.com/user3.jpg")) .child(Avatar::new().src("https://example.com/user4.jpg")) .child(Avatar::new().src("https://example.com/user5.jpg")) ``` ### Group Sizes The [Sizeable] trait can also be applied to AvatarGroup, and it will set the size for all contained avatars. ```rust // Extra small group AvatarGroup::new() .xsmall() .child(Avatar::new().name("A")) .child(Avatar::new().name("B")) .child(Avatar::new().name("C")) // Small group AvatarGroup::new() .small() .child(Avatar::new().name("A")) .child(Avatar::new().name("B")) // Medium group (default) AvatarGroup::new() .child(Avatar::new().name("A")) .child(Avatar::new().name("B")) // Large group AvatarGroup::new() .large() .child(Avatar::new().name("A")) .child(Avatar::new().name("B")) ``` ### Adding Multiple Avatars ```rust let avatars = vec![ Avatar::new().src("https://example.com/user1.jpg"), Avatar::new().src("https://example.com/user2.jpg"), Avatar::new().name("John Doe"), ]; AvatarGroup::new() .children(avatars) .limit(5) .ellipsis() ``` ## API Reference - [Avatar] - [AvatarGroup] ## Examples ### Team Display ```rust use gpui_kit::component::{h_flex, v_flex}; v_flex() .gap_4() .child("Development Team") .child( AvatarGroup::new() .limit(4) .ellipsis() .child(Avatar::new().name("Alice Johnson").src("https://example.com/alice.jpg")) .child(Avatar::new().name("Bob Smith").src("https://example.com/bob.jpg")) .child(Avatar::new().name("Charlie Brown")) .child(Avatar::new().name("Diana Prince")) .child(Avatar::new().name("Eve Wilson")) ) ``` ### User Profile Header ```rust h_flex() .items_center() .gap_4() .child( Avatar::new() .src("https://example.com/profile.jpg") .name("John Doe") .large() .border_2() .border_color(cx.theme().primary) ) .child( v_flex() .child("John Doe") .child("Software Engineer") ) ``` ### Anonymous User ```rust use gpui_kit::component::IconName; Avatar::new() .placeholder(IconName::UserCircle) .medium() ``` ### Avatar with Custom Colors ```rust // The avatar automatically generates colors based on the name // Different names will get different colors from the color palette Avatar::new().name("Alice") // Gets one color Avatar::new().name("Bob") // Gets a different color Avatar::new().name("Charlie") // Gets another color ``` [Avatar]: https://docs.rs/gpui-component/latest/gpui_component/avatar/struct.Avatar.html [AvatarGroup]: https://docs.rs/gpui-component/latest/gpui_component/avatar/struct.AvatarGroup.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html --- # StatusBar Source: /versions/v0.7.1/component/status-bar License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/status-bar and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. StatusBar is a horizontal bar split into three regions — `left`, `center`, and `right`. It is usually placed at the bottom of a window or pane to show contextual information and quick actions. The design mirrors the status bars found in native UI frameworks: Windows `StatusStrip`, WPF `StatusBar`, and macOS `NSStatusBar`. ## Import ```rust use gpui_kit::component::status_bar::StatusBar; ``` ## Regions Pass any `impl IntoElement` — a string, an `Icon`, a `Button`, a custom layout, etc. — to a region. `left` and `right` pin items to each end; `child` / `children` add to the center, whose alignment follows the pinned ends — centered with both `left` and `right`, end-aligned with only `left`, start-aligned otherwise (only `right`, or neither, like a plain container). Call a method multiple times to add more. - For a **non-interactive label**, pass a plain string — it inherits the bar's text style and has no hover. - For a **clickable button**, pass a ghost, xsmall `Button` — `Button::new(id).ghost().xsmall()` — so buttons stay a consistent size. Chain `label`, `icon`, `tooltip`, `on_click`, etc. - For a **separator**, pass `Separator::vertical()`. - For anything else, pass the element directly. ## Usage ### Labels ```rust StatusBar::new() .left("Ready") .child("README.md") .right("UTF-8") ``` ### Buttons ```rust StatusBar::new() .left( Button::new("branch").ghost().xsmall() .icon(IconName::Github) .label("main") .on_click(|_, window, cx| { /* ... */ }), ) .right( Button::new("go-to-line").ghost().xsmall() .label("Ln 1, Col 1") .tooltip("Go to Line/Column") .on_click(cx.listener(|this, _, window, cx| { /* ... */ })), ) ``` ### Separators and custom elements ```rust StatusBar::new() .left(Button::new("branch").ghost().xsmall().icon(IconName::Github).label("main")) .left(Separator::vertical()) .left( // Any custom element works. h_flex() .items_center() .gap_1() .child(Icon::new(IconName::CircleCheck).xsmall()) .child("0 problems"), ) .child(Progress::new("indexing").value(60.).w_24()) ``` ### Custom styling `StatusBar` implements `Styled`, so any style method overrides the defaults. ```rust StatusBar::new() .bg(cx.theme().secondary) .border_color(cx.theme().border) .left("Ready") ``` ## API Reference ### StatusBar | Method | Description | | ----------------- | ---------------------------------------------------- | | `new()` | Create a new, empty status bar | | `left(child)` | Append an element to the left region (call to add more) | | `right(child)` | Append an element to the right region | | `child(c)` / `children(cs)` | Add element(s) to the center region | Each region method takes `impl IntoElement`. `StatusBar` also implements `Styled`, so style methods (`bg`, `border_color`, `py`, etc.) can override the defaults. ## Notes - The center (via `child` / `children`) is centered with both `left` and `right`, end-aligned with only `left`, and start-aligned otherwise (only `right`, or neither — like a plain container). - Use a plain string (or any non-interactive element) for read-only items to avoid the button hover effect; use a ghost xsmall `Button` only for clickable items. - Colors come from the `status_bar` (background) and `status_bar_border` theme tokens, which fall back to `background` / `border`. --- # Tag Source: /versions/v0.7.1/component/tag License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/tag and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A versatile tag component for categorizing and labeling content. Tags are compact visual indicators that help organize information and display metadata like categories, status, or properties. ## Import ```rust use gpui_kit::component::tag::Tag; ``` ## Usage ### Basic Tags ```rust // Primary tag (default filled style) Tag::primary().child("Primary") // Secondary tag Tag::secondary().child("Secondary") // Status tags Tag::danger().child("Danger") Tag::success().child("Success") Tag::warning().child("Warning") Tag::info().child("Info") ``` ### Tag Variants ```rust // Semantic variants Tag::primary().child("Featured") Tag::secondary().child("Category") Tag::danger().child("Critical") Tag::success().child("Completed") Tag::warning().child("Pending") Tag::info().child("Information") ``` ### Outline Tags ```rust // Outline style variants Tag::primary().outline().child("Primary Outline") Tag::secondary().outline().child("Secondary Outline") Tag::danger().outline().child("Error Outline") Tag::success().outline().child("Success Outline") Tag::warning().outline().child("Warning Outline") Tag::info().outline().child("Info Outline") ``` ### Tag Sizes ```rust // Small size Tag::primary().small().child("Small Tag") // Medium size (default) Tag::primary().child("Medium Tag") ``` ### Custom Colors ```rust use gpui_kit::component::ColorName; // Using predefined color names Tag::color(ColorName::Blue).child("Blue Tag") Tag::color(ColorName::Green).child("Green Tag") Tag::color(ColorName::Purple).child("Purple Tag") Tag::color(ColorName::Pink).child("Pink Tag") Tag::color(ColorName::Indigo).child("Indigo Tag") Tag::color(ColorName::Yellow).child("Yellow Tag") Tag::color(ColorName::Red).child("Red Tag") ``` ### Custom HSLA Colors ```rust use gpui_kit::{hsla, Hsla}; // Custom colors with HSLA values let color = hsla(220.0 / 360.0, 0.8, 0.5, 1.0); let foreground = hsla(0.0, 0.0, 1.0, 1.0); let border = hsla(220.0 / 360.0, 0.8, 0.4, 1.0); Tag::custom(color, foreground, border).child("Custom Color") ``` ### Rounded Corners ```rust use gpui_kit::px; // Fully rounded tags Tag::primary().rounded_full().child("Rounded Full") // Custom border radius Tag::primary().rounded(px(4.0)).child("Custom Radius") // Square corners Tag::primary().rounded(px(0.0)).child("Square Tag") ``` ### Combined Styles ```rust // Small tags with full rounding Tag::primary().small().rounded_full().child("Small Pill") Tag::success().small().rounded_full().child("Success Pill") // Outline tags with custom rounding Tag::warning().outline().rounded(px(2.0)).child("Custom Outline") // Color tags with outline style Tag::color(ColorName::Purple).outline().child("Purple Outline") ``` ## Tag Categories and Use Cases ### Status Tags ```rust // Task or item status Tag::success().child("Completed") Tag::warning().child("In Progress") Tag::danger().child("Failed") Tag::info().child("Pending Review") ``` ### Category Labels ```rust // Content categorization Tag::secondary().child("Technology") Tag::color(ColorName::Blue).child("Design") Tag::color(ColorName::Green).child("Development") Tag::color(ColorName::Purple).child("Marketing") ``` ### Priority Indicators ```rust // Priority levels Tag::danger().child("High Priority") Tag::warning().child("Medium Priority") Tag::secondary().child("Low Priority") ``` ### Feature Tags ```rust // Feature flags or attributes Tag::primary().small().child("New") Tag::success().small().child("Popular") Tag::info().small().child("Beta") Tag::warning().small().child("Limited") ``` ## API Reference ### Tag Creation Methods | Method | Description | | --------------------------- | ------------------------------------------ | | `primary()` | Create a primary tag (blue theme) | | `secondary()` | Create a secondary tag (gray theme) | | `danger()` | Create a danger tag (red theme) | | `success()` | Create a success tag (green theme) | | `warning()` | Create a warning tag (yellow/orange theme) | | `info()` | Create an info tag (blue theme) | | `color(ColorName)` | Create a tag with predefined color | | `custom(color, fg, border)` | Create a tag with custom HSLA colors | ### Style Methods | Method | Description | | ----------------- | -------------------------------------------- | | `outline()` | Apply outline style (transparent background) | | `rounded(radius)` | Set custom border radius | | `rounded_full()` | Apply full rounding (pill shape) | ### Size Methods (from Sizable trait) | Method | Description | | ----------------- | -------------------------------- | | `small()` | Small tag size (reduced padding) | | `with_size(size)` | Set custom size | ### Content Methods (from ParentElement trait) | Method | Description | | ---------------- | ---------------------------- | | `child(element)` | Add child content to the tag | ## Examples ### Tag Collections ```rust use gpui_kit::component::{h_flex, v_flex}; // Horizontal tag group h_flex() .gap_2() .child(Tag::primary().child("React")) .child(Tag::success().child("TypeScript")) .child(Tag::info().child("Next.js")) .child(Tag::warning().child("Beta")) // Vertical tag stack v_flex() .gap_1() .child(Tag::danger().small().child("Critical")) .child(Tag::warning().small().child("Important")) .child(Tag::secondary().small().child("Normal")) ``` ### Status Dashboard Tags ```rust // System status indicators h_flex() .gap_3() .child( v_flex() .child("API Status:") .child(Tag::success().child("Operational")) ) .child( v_flex() .child("Database:") .child(Tag::warning().child("Maintenance")) ) .child( v_flex() .child("Cache:") .child(Tag::danger().child("Down")) ) ``` ### Interactive Tag Lists ```rust // Note: Event handling would require additional state management // Tags themselves are display components // Filter tags (would need click handlers) h_flex() .gap_2() .child(Tag::primary().small().child("All")) .child(Tag::secondary().outline().small().child("Active")) .child(Tag::secondary().outline().small().child("Completed")) .child(Tag::secondary().outline().small().child("Archived")) ``` ### Color-Coded Categories ```rust use gpui_kit::component::ColorName; // Content type tags h_flex() .gap_2() .flex_wrap() .child(Tag::color(ColorName::Red).child("Bug")) .child(Tag::color(ColorName::Blue).child("Feature")) .child(Tag::color(ColorName::Green).child("Enhancement")) .child(Tag::color(ColorName::Purple).child("Documentation")) .child(Tag::color(ColorName::Yellow).child("Question")) .child(Tag::color(ColorName::Pink).child("Discussion")) ``` ### Pill-Style Tags ```rust // Skill tags with pill styling h_flex() .gap_2() .flex_wrap() .child(Tag::color(ColorName::Blue).rounded_full().small().child("Rust")) .child(Tag::color(ColorName::Green).rounded_full().small().child("JavaScript")) .child(Tag::color(ColorName::Purple).rounded_full().small().child("Python")) .child(Tag::color(ColorName::Red).rounded_full().small().child("Go")) ``` ## Behavior Notes - Tags automatically adjust their appearance based on the current theme - Outline tags maintain border visibility across different backgrounds - Small tags use reduced padding and border radius for compact layouts - Custom colors support both light and dark theme adaptations - Tags are display components and don't include built-in interaction handlers - Multiple tags can be combined in flex layouts for tag clouds or lists - Border radius automatically scales based on tag size unless explicitly overridden ## Design Guidelines ### When to Use Tags - **Categorization**: Group content by type, topic, or theme - **Status Indication**: Show state, progress, or health status - **Metadata Display**: Present attributes, properties, or classifications - **Filtering**: Visual indicators for active filters or selections - **Feature Flags**: Highlight new, beta, or special features ### Color Usage - **Semantic Colors**: Use danger (red) for errors, success (green) for completion, warning (yellow) for caution, info (blue) for information - **Category Colors**: Use the ColorName variants for content categorization where color coding helps with recognition - **Custom Colors**: Reserve for brand colors or specific design system requirements ### Size Guidelines - **Small Tags**: Use for compact layouts, metadata, or when space is limited - **Medium Tags**: Default size for most use cases, provides good readability and click targets - **Rounding**: Use `rounded_full()` for pill-style tags, custom `rounded()` for specific design requirements --- # Accordion Source: /versions/v0.7.1/component/accordion License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/accordion and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. An accordion component that allows users to show and hide sections of content. It uses collapse functionality internally to create collapsible panels. ## Import ```rust use gpui_kit::component::accordion::Accordion; ``` ## Usage ### Basic Accordion ```rust Accordion::new("my-accordion") .item(|item| { item.title("Section 1") .child("Content for section 1") }) .item(|item| { item.title("Section 2") .child("Content for section 2") }) .item(|item| { item.title("Section 3") .child("Content for section 3") }) ``` ### Multiple Open Items By default, only one accordion item can be open at a time. Use `multiple()` to allow multiple items to be open: ```rust Accordion::new("my-accordion") .multiple(true) .item(|item| item.title("Section 1").child("Content 1")) .item(|item| item.title("Section 2").child("Content 2")) ``` ### With Borders ```rust Accordion::new("my-accordion") .bordered(true) .item(|item| item.title("Section 1").child("Content 1")) ``` ### Different Sizes ```rust use gpui_kit::component::{Sizable as _, Size}; Accordion::new("my-accordion") .small() .item(|item| item.title("Small Section").child("Content")) Accordion::new("my-accordion") .large() .item(|item| item.title("Large Section").child("Content")) ``` ### Handle Toggle Events ```rust Accordion::new("my-accordion") .on_toggle_click(|open_indices, window, cx| { println!("Open items: {:?}", open_indices); }) .item(|item| item.title("Section 1").child("Content 1")) ``` ### Disabled State ```rust Accordion::new("my-accordion") .disabled(true) .item(|item| item.title("Disabled Section").child("Content")) ``` ## API Reference - [Accordion] - [AccordionItem] ### Sizing Implements [Sizable] trait: - `small()` - Small size - `medium()` - Medium size (default) - `large()` - Large size - `xsmall()` - Extra small size ## Examples ### With Custom Icons ```rust Accordion::new("my-accordion") .item(|item| { item.title( h_flex() .gap_2() .child(Icon::new(IconName::Settings)) .child("Settings") ) .child("Settings content here") }) ``` ### Nested Accordions ```rust Accordion::new("outer") .item(|item| { item.title("Parent Section") .child( Accordion::new("inner") .item(|item| item.title("Child 1").child("Content")) .item(|item| item.title("Child 2").child("Content")) ) }) ``` [Accordion]: https://docs.rs/gpui-component/latest/gpui_component/accordion/struct.Accordion.html [AccordionItem]: https://docs.rs/gpui-component/latest/gpui_component/accordion/struct.AccordionItem.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html --- # Combobox Source: /versions/v0.7.1/component/combobox License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/combobox and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A searchable dropdown for selecting one or multiple values from a list. ## Select vs Combobox | Feature | Select | Combobox | | --- | --- | --- | | Searchable | ✓ (optional) | ✓ (optional) | | Multi-select | — | ✓ (`.multiple(true)`) | | Custom trigger rendering | — | ✓ | | Custom item rendering | — | ✓ | | Footer action slot | — | ✓ | Use `Select` for simple single-value picking. Use `Combobox` when you need multi-select, a fully custom trigger, or custom item rendering. ## Import ```rust use gpui_kit::component::combobox::{ Combobox, ComboboxState, ComboboxEvent, ComboboxTriggerCtx, }; use gpui_kit::component::searchable_list::{ SearchableListItem, SearchableVec, SearchableGroup, }; ``` ## Usage ### Basic Single-Select ```rust let state = cx.new(|cx| { ComboboxState::new( SearchableVec::new(vec!["Next.js", "SvelteKit", "Nuxt.js"]), vec![], // no initial selection window, cx, ) .searchable(true) }); Combobox::new(&state) .placeholder("Select framework...") .search_placeholder("Search...") .w_full() ``` ### Multi-Select Pass `.multiple(true)` to enable multi-select mode. Clicking an item toggles it; the dropdown stays open until the user presses Escape or clicks outside. ```rust let state = cx.new(|cx| { ComboboxState::new( SearchableVec::new(vec!["React", "Vue", "Angular"]), vec![IndexPath::new(0)], // pre-selected window, cx, ) .multiple(true) .searchable(true) }); Combobox::new(&state).placeholder("Select frameworks") ``` ### Pre-selected Item Pass index paths of items to pre-select: ```rust let state = cx.new(|cx| { ComboboxState::new(items, vec![IndexPath::new(0)], window, cx) }); ``` ### Grouped Items Use `SearchableGroup` to group items under a heading: ```rust let grouped = SearchableVec::new(vec![ SearchableGroup::new("Fruits").items(vec![ FoodItem::new("Apples"), FoodItem::new("Bananas"), ]), SearchableGroup::new("Vegetables").items(vec![ FoodItem::new("Carrots"), FoodItem::new("Spinach"), ]), ]); let state = cx.new(|cx| { ComboboxState::new(grouped, vec![], window, cx).searchable(true) }); Combobox::new(&state) ``` ### Implementing `SearchableListItem` Built-in implementations exist for `String`, `SharedString`, and `&'static str`. For custom types implement the trait: ```rust #[derive(Clone)] struct Country { name: SharedString, code: SharedString, } impl SearchableListItem for Country { type Value = SharedString; fn title(&self) -> SharedString { self.name.clone() } fn value(&self) -> &SharedString { &self.code } fn matches(&self, query: &str) -> bool { self.name.to_lowercase().contains(query) || self.code.to_lowercase().contains(query) } } ``` ### Disabled Items Return `true` from `disabled()` on items that should not be selectable: ```rust impl SearchableListItem for MyItem { // ... fn disabled(&self) -> bool { self.is_unavailable } } ``` ### Custom Check Icon ```rust Combobox::new(&state) .check_icon(Icon::new(IconName::CircleCheck)) ``` ### Footer Action Render a persistent action at the bottom of the dropdown (e.g. an "Add new" button): ```rust Combobox::new(&state) .footer(|_, cx| { Button::new("add-new") .ghost() .label("New item") .icon(Icon::new(IconName::Plus)) .w_full() .justify_start() .into_any_element() }) ``` ### Custom Trigger Override the entire trigger element. `ComboboxTriggerCtx` exposes the current selection, open/disabled flags, and size: ```rust Combobox::new(&state) .render_trigger(|ctx, _, cx| { h_flex() .w_full() .items_center() .gap_2() .when(ctx.selection.is_empty(), |this| { this.text_color(cx.theme().muted_foreground) .child("Select...") }) .children(ctx.selection.iter().map(|(_, item)| { div() .bg(cx.theme().accent) .rounded_sm() .px_1p5() .py_0p5() .text_sm() .child(item.title()) })) .into_any_element() }) ``` ### Sizes ```rust Combobox::new(&state).large() Combobox::new(&state) // medium (default) Combobox::new(&state).small() ``` ### Cleanable ```rust Combobox::new(&state).cleanable(true) // show clear button when a value is selected ``` ### Disabled ```rust Combobox::new(&state).disabled(true) ``` ### Events Both `Change` (fired on every toggle) and `Confirm` (fired when the dropdown closes) carry the full selection as `Vec`. ```rust cx.subscribe_in(&state, window, |view, _, event, window, cx| { match event { ComboboxEvent::Change(values) => { // fired on every toggle } ComboboxEvent::Confirm(values) => { // fired when the dropdown closes } } }); ``` ### Mutating Programmatically Values are resolved through the current delegate. Values that cannot be found are ignored. `set_selected_values` clears the search query first, so an active search never decides which values can be selected. Index paths address the list as it is currently displayed, so `set_selected_indices`, `add_selected_index` and `remove_selected_index` act on the visible rows and leave the query alone. ```rust // Replace the entire selection by value state.update(cx, |s, cx| { s.set_selected_values(&["React", "Angular"], window, cx); }); // Replace the entire selection by index path state.update(cx, |s, cx| { s.set_selected_indices(vec![IndexPath::new(0), IndexPath::new(2)], window, cx); }); // Add / remove individual items state.update(cx, |s, cx| { s.add_selected_index(IndexPath::new(1), cx); s.remove_selected_index(IndexPath::new(0), cx); }); // Clear all selections state.update(cx, |s, cx| { s.clear_selection(cx); }); // Read all selected values (multi-select) let values = state.read(cx).selected_values(); // Vec // Read the first selected value (single-select convenience) let value = state.read(cx).selected_value(); // Option ``` ## Keyboard Shortcuts | Key | Action | | --------- | ---------------------------------------- | | `Tab` | Focus trigger | | `Enter` | Open menu or confirm highlighted item | | `Up/Down` | Navigate options (opens menu if closed) | | `Escape` | Close menu | ## Theming - `background` — Dropdown input background - `input` — Trigger border color - `foreground` — Text color - `muted_foreground` — Placeholder and disabled text - `border` — Menu border - `radius` — Border radius --- # Icon Source: /versions/v0.7.1/component/icon License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/icon and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A flexible icon component that renders SVG icons from asset paths or in-memory bytes, with customizable size, color, and transformations. The built-in Lucide icons use the assets bundle; custom SVG bytes can be supplied directly with `Icon::data`. Before you start, please make sure you have read: [Icons & Assets](/versions/v0.7.1/docs/assets) to understand how use SVG in GPUI & GPUI Component application. `gpui_kit::assets::IconName` provides the complete shared catalog without a Component dependency. `gpui_kit::component::IconName` remains the original compatibility enum: existing imports, exhaustive matches and `.view(cx)` calls continue to work without a new trait import. `Icon::new(...)` accepts either type. A legacy name converts into the shared name with `.into()`. For the new shared enum, use `Icon::new(name).view(cx)` when a component entity is needed, or import `gpui_kit::component::IconNameExt` to call `name.view(cx)`. **NOTE — Depending on the crate does not embed every icon** **The complete catalog does not make existing applications embed every icon.** `Assets` keeps the original 101 component icons. Applications provide additional icons through their own `AssetSource`, as before; they do not need to redeclare the component icons. Only explicitly registering `AllAssets` embeds all 1,830 SVGs on native platforms. Depending on the crate or using the shared `IconName` alone does not reference every SVG payload. | Native asset configuration | Embedded SVG data | Binary increase vs. default `Assets` | | --- | ---: | ---: | | Default component icons (101) | 44.28 KiB | 0 B (baseline) | | Default + 2 application icons (103) | 45.04 KiB | +15.19 KiB | | Default + 10 application icons (111) | 48.09 KiB | +19.19 KiB | | Explicit `AllAssets` (1,830) | 731.45 KiB | +1.02 MiB | **In this example, adding 10 application icons costs about 19 KiB, not the full catalog.** Their SVGs total 3,903 bytes; the measured binary increase is 19,648 bytes, including the extra source's lookup/list-composition code, metadata and alignment. These are not fixed per-icon costs or whole-application sizes. Measured with Lucide 1.43.0 on Linux x86_64, Rust 1.98.0, `--release`, and stripped symbols. Each program uses the same `IconName` lookup and runtime asset path. The extra source falls back to `Assets`, and merges, sorts and deduplicates both sources' lists. The 10 extras are `Accessibility`, `AlarmClock`, `Archive`, `Award`, `Backpack`, `Bike`, `Bird`, `Camera`, `Coffee` and `Compass`; the two-icon case uses the first two. SVG complexity, toolchain and source implementation change the result. Binary size is not RAM usage. Selected sources borrow static bytes without a copy/cache; actual rendering still allocates for parsing, rasterization and render caches. Runtime shared-name lookup can retain a name/path table, and Cargo's downloaded package/build artifacts still contain the complete catalog. On WASM, `Assets::new(endpoint)` and `AllAssets::new(endpoint)` use the existing on-demand CDN loader instead of embedding the complete bundle. ## Additional application icons Keep the default `Assets` registration. For extra catalog icons, follow the [selected-icons recipe](/versions/v0.7.1/docs/assets#pick-additional-catalog-icons-with-icon_assets): `icon_assets!` creates a source for exactly the named SVGs, and the application registers it together with the default source. For your own SVG files, see [custom assets](/versions/v0.7.1/docs/assets#add-your-own-asset-files). ## Import ```rust use gpui_kit::component::{Icon, IconName}; ``` ## Usage ### Basic Icon ```rust // Using IconName enum directly IconName::Heart // Or creating an Icon explicitly Icon::new(IconName::Heart) ``` ### Icon with Custom Size ```rust // Predefined sizes Icon::new(IconName::Search).xsmall() // size_3() Icon::new(IconName::Search).small() // size_3p5() Icon::new(IconName::Search).medium() // size_4() (default) Icon::new(IconName::Search).large() // size_6() // Custom pixel size Icon::new(IconName::Search).with_size(px(20.)) ``` ### Icon with Custom Color ```rust // Using theme colors Icon::new(IconName::Heart) .text_color(cx.theme().red) // Using custom colors Icon::new(IconName::Star) .text_color(gpui_kit::red()) ``` ### Rotated Icons ```rust use gpui_kit::{Transformation, radians}; // Rotate by radians Icon::new(IconName::ArrowUp) .rotate(radians(std::f32::consts::FRAC_PI_2)) // Transform with custom transformation Icon::new(IconName::ChevronRight) .transform(Transformation::rotate(radians(std::f32::consts::PI))) ``` ### Custom SVG Path ```rust // Using a custom SVG file from assets Icon::new(Icon::empty()) .path("icons/my-custom-icon.svg") ``` ### SVG Bytes Use `data(&[u8])` to supply SVG bytes without registering an `AssetSource` path: ```rust use gpui_kit::component::{Icon, button::Button, menu::PopupMenuItem}; let icon = Icon::default().data(include_bytes!("search.svg")); Button::new("search").icon(icon.clone()).label("Search"); PopupMenuItem::new("Search").icon(icon); ``` `data` copies its input into shared storage, so the input need not be `'static`. Cloning an `Icon` shares those bytes and preserves its style and transformation. Both direct rendering and `Icon::view(cx)` retain the data source. GPUI's renderer may copy the bytes again; this API does not promise zero-copy rendering. The last source builder wins, including when the new source is empty: ```rust let bytes = include_bytes!("search.svg"); Icon::default().path("icons/old.svg").data(bytes); // Uses SVG bytes Icon::default().data(bytes).path("icons/search.svg"); // Uses the asset path ``` Bytes go through the same SVG renderer as path-based icons. They retain component sizing, foreground colors, and button loading behavior. Use `loading_icon` to choose a custom loading symbol: ```rust Button::new("search") .icon(Icon::default().data(include_bytes!("search.svg"))) .loading_icon(Icon::default().data(include_bytes!("loader.svg"))) .loading(true) .label("Searching") ``` `NativeMenu::menu_with_icon` also accepts data-backed icons. Native menus keep their existing platform sizing and tinting rules. Other path-based icons used by your application or components still need an asset source. ### Custom Icon Types with SVG Bytes An icon crate can export individual types that implement `From for Icon`: ```rust use gpui_kit::component::{Icon, button::Button}; pub struct Search; impl From for Icon { fn from(_: Search) -> Self { Icon::default().data(include_bytes!("search.svg")) } } Button::new("search").icon(Search); ``` Existing `IconNamed` implementations continue to provide asset paths. A data-backed type uses the conversion above without also implementing `IconNamed`. Binary-size savings depend on which resources are referenced and on build settings. ## Available Icons The `IconName` enum provides access to a curated set of icons. Here are some commonly used ones: ### Navigation - `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight` - `ChevronUp`, `ChevronDown`, `ChevronLeft`, `ChevronRight` - `ChevronsUpDown` ### Actions - `Check`, `Close`, `Plus`, `Minus` - `Copy`, `Delete`, `Search`, `Replace` - `Maximize`, `Minimize`, `WindowRestore` ### Files & Folders - `File`, `Folder`, `FolderOpen`, `FolderClosed` - `BookOpen`, `Inbox` ### UI Elements - `Menu`, `Settings`, `Settings2`, `Ellipsis`, `EllipsisVertical` - `Eye`, `EyeOff`, `Bell`, `Info` ### Social & External - `GitHub`, `Globe`, `ExternalLink` - `Heart`, `HeartOff`, `Star`, `StarOff` - `ThumbsUp`, `ThumbsDown` ### Status & Alerts - `CircleCheck`, `CircleX`, `TriangleAlert` - `Loader`, `LoaderCircle` ### Panels & Layout - `PanelLeft`, `PanelRight`, `PanelBottom` - `PanelLeftOpen`, `PanelRightOpen`, `PanelBottomOpen` - `LayoutDashboard`, `Frame` ### Users & Profile - `User`, `CircleUser`, `Bot` ### Other - `Calendar`, `Map`, `Palette`, `Inspector` - `Sun`, `Moon`, `Building2` ## Icon Sizes The Icon component supports several predefined sizes: | Size | Method | CSS Class | Pixels | | ----------- | --------------------- | ------------ | ------ | | Extra Small | `.xsmall()` | `size_3()` | 12px | | Small | `.small()` | `size_3p5()` | 14px | | Medium | `.medium()` (default) | `size_4()` | 16px | | Large | `.large()` | `size_6()` | 24px | | Custom | `.with_size(px(n))` | - | n px | ## Build you own `IconName`. You can define your own `IconName` to have more specific icons for your application. We have `IconNamed` trait for you to implement for your. ```rust use gpui_kit::component::IconNamed; pub enum IconName { Encounters, Monsters, Spells, } impl IconNamed for IconName { fn path(self) -> gpui_kit::SharedString { match self { IconName::Encounters => "icons/encounters.svg", IconName::Monsters => "icons/monsters.svg", IconName::Spells => "icons/spells.svg", } .into() } } // This allows for the following interactions (works with anything that has the `.icon(icon)` method. Button::new("my-button").icon(IconName::Spells); Icon::new(IconName::Monsters); ``` If you want to directly `render` a custom `IconName` you must implement the `RenderOnce` trait and derive `IntoElement` on the `IconName`. ```rust impl RenderOnce for IconName { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { Icon::empty().path(self.path()) } } // Now you can use it directly in your element tree: div() .child(IconName::Monsters) ``` ## Examples ### Icon in Button ```rust use gpui_kit::component::button::Button; Button::new("like-btn") .icon( Icon::new(IconName::Heart) .text_color(cx.theme().red) .large() ) .label("Like") ``` ### Animated Loading Icon ```rust Icon::new(IconName::LoaderCircle) .text_color(cx.theme().muted_foreground) .medium() // Add rotation animation in your render logic ``` ### Status Icons ```rust // Success Icon::new(IconName::CircleCheck) .text_color(cx.theme().green) // Error Icon::new(IconName::CircleX) .text_color(cx.theme().red) // Warning Icon::new(IconName::TriangleAlert) .text_color(cx.theme().yellow) ``` ### Navigation Icons ```rust // Back button Icon::new(IconName::ArrowLeft) .medium() .text_color(cx.theme().foreground) // Dropdown indicator Icon::new(IconName::ChevronDown) .small() .text_color(cx.theme().muted_foreground) ``` ### Custom Icon from Assets ```rust // Using a custom SVG file Icon::empty() .path("icons/my-brand-logo.svg") .large() .text_color(cx.theme().primary) ``` ## Notes - Icons are rendered as SVG elements and support full CSS styling - The default size matches the current text size if no explicit size is set - Icons are flex-shrink-0 by default to prevent unwanted shrinking in flex layouts - All icon paths are relative to the assets bundle root - Icons from Lucide.dev are designed to work well at 16px and scale nicely to other sizes --- # Button Source: /versions/v0.7.1/component/button License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/button and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. The [Button] element with multiple variants, sizes, and states. Supports icons, loading states, and can be grouped together. ## Import ```rust use gpui_kit::component::{ Sizable as _, button::{Button, ButtonGroup, ButtonVariants as _}, }; ``` ## Usage The marked recipe below is a complete, **Tested consumer recipe**. The remaining examples are contextual fragments; keep the imports above when using variant or size builders. ```rust use gpui_kit::IntoElement; use gpui_kit::component::{ Sizable as _, button::{Button, ButtonVariants as _}, }; pub fn primary_command() -> impl IntoElement { Button::new("save").primary().small().label("Save changes") } ``` ### Basic Button ```rust Button::new("my-button") .label("Click me") .on_click(|_, _, _| { println!("Button clicked!"); }) ``` ### Variants ```rust use gpui_kit::component::button::ButtonVariants as _; // Primary button Button::new("btn-primary").primary().label("Primary") // Secondary button (default) Button::new("btn-secondary").label("Secondary") // Danger button Button::new("btn-danger").danger().label("Delete") // Warning button Button::new("btn-warning").warning().label("Warning") // Success button Button::new("btn-success").success().label("Success") // Info button Button::new("btn-info").info().label("Info") // Ghost button Button::new("btn-ghost").ghost().label("Ghost") // Link button Button::new("btn-link").link().label("Link") // Text button Button::new("btn-text").text().label("Text") ``` ### Outline Buttons Outline style is not a variant itself, but can be combined with other variants. ```rust use gpui_kit::component::button::ButtonVariants as _; Button::new("btn").primary().outline().label("Primary Outline") Button::new("btn").danger().outline().label("Danger Outline") ``` ### Compact Button The `compact` method reduces the padding of the button for a more condensed appearance. ```rust // Compact (reduced padding) Button::new("btn") .label("Compact") .compact() ``` ### Sizeable The Button supports the [Sizable] trait for different sizes. ```rust use gpui_kit::component::Sizable as _; Button::new("btn").xsmall().label("Extra Small") Button::new("btn").small().label("Small") Button::new("btn").label("Medium") // default Button::new("btn").large().label("Large") ``` ### With Icons The `icon` method supports multiple types, allowing you to use different visual indicators: - **[Icon] / [IconName]** - Static icons for actions and visual cues - **[Spinner]** - Animated loading indicator for async operations - **[ProgressCircle]** - Circular progress indicator showing completion percentage All icon types automatically adapt to the button's size and can be customized with colors and other properties. #### Icon Types ```rust use gpui_kit::component::{Icon, IconName}; // Using IconName (simplest) Button::new("btn") .icon(IconName::Check) .label("Confirm") // Using Icon with custom size Button::new("btn") .icon(Icon::new(IconName::Heart)) .label("Like") // Icon only (no label) Button::new("btn") .icon(IconName::Search) ``` #### Spinner Icon Use a [Spinner] to indicate loading or processing state: ```rust use gpui_kit::component::{ActiveTheme as _, spinner::Spinner}; // Basic spinner Button::new("btn") .icon(Spinner::new()) .label("Loading...") // Spinner with custom color Button::new("btn") .icon(Spinner::new().color(cx.theme().blue)) .label("Processing") // Spinner with icon Button::new("btn") .icon(Spinner::new().icon(IconName::LoaderCircle)) .label("Syncing") ``` #### ProgressCircle Icon Use a [ProgressCircle] to show progress percentage: ```rust use gpui_kit::component::{ ActiveTheme as _, Sizable as _, button::ButtonVariants as _, progress::ProgressCircle, }; // Basic progress circle Button::new("btn") .icon(ProgressCircle::new("install-progress").value(45.0)) .label("Installing...") // Progress circle with custom color Button::new("btn") .primary() .icon( ProgressCircle::new("download-progress") .value(75.0) .color(cx.theme().primary_foreground) ) .label("Downloading") // Different sizes Button::new("btn") .small() .icon(ProgressCircle::new("progress-1").value(60.0)) .label("Installing...") Button::new("btn") .large() .icon(ProgressCircle::new("progress-2").value(80.0)) .label("Installing...") ``` #### Dynamic Icon Updates Icons can be updated dynamically based on component state: ```rust struct InstallButton { progress: f32, is_installing: bool, } impl InstallButton { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let button = Button::new("install-btn") .label(if self.is_installing { "Installing..." } else { "Install" }); if self.is_installing { button.icon( ProgressCircle::new("install-progress") .value(self.progress) ) } else { button.icon(IconName::Download) } } } ``` #### Loading State with Icons When a button is in loading state, it automatically handles icon transitions: ```rust // If icon is already a Spinner or ProgressCircle, it will be shown during loading Button::new("btn") .icon(Spinner::new()) .label("Processing") .loading(true) // Spinner will continue to show // If icon is a regular Icon, it will be replaced with a Spinner during loading Button::new("btn") .icon(IconName::Save) .label("Saving") .loading(true) // Icon will be replaced with Spinner ``` ### With a dropdown caret icon The `.dropdown_caret` method can allows adding a dropdown caret icon to end of the button. ```rust Button::new("btn") .label("Options") .dropdown_caret(true) ``` ### Button States There have `disabled`, `loading`, `selected` state for buttons to indicate different statuses. ```rust use gpui_kit::component::{Disableable as _, Selectable as _}; // Disabled Button::new("btn") .label("Disabled") .disabled(true) // Loading Button::new("btn") .label("Loading") .loading(true) // Selected Button::new("btn") .label("Selected") .selected(true) ``` ## Button Group ```rust ButtonGroup::new("btn-group") .child(Button::new("btn1").label("One")) .child(Button::new("btn2").label("Two")) .child(Button::new("btn3").label("Three")) ``` ### Toggle Button Group ```rust use gpui_kit::component::Selectable as _; ButtonGroup::new("toggle-group") .multiple(true) // Allow multiple selections .child(Button::new("btn1").label("Option 1").selected(true)) .child(Button::new("btn2").label("Option 2")) .child(Button::new("btn3").label("Option 3")) .on_click(|selected_indices, _, _| { println!("Selected: {:?}", selected_indices); }) ``` ## Custom Variant ```rust use gpui_kit::component::{ ActiveTheme as _, Colorize as _, button::{ButtonCustomVariant, ButtonVariants as _}, }; let custom = ButtonCustomVariant::new(cx) .color(cx.theme().magenta) .foreground(cx.theme().primary_foreground) .hover(cx.theme().magenta.opacity(0.1)) .active(cx.theme().magenta); Button::new("custom-btn") .custom(custom) .label("Custom Button") ``` ## API Reference - [Button] - [ButtonGroup] - [ButtonCustomVariant] ## Examples ### With Tooltip ```rust Button::new("btn") .label("Hover me") .tooltip("This is a helpful tooltip") .tooltip_placement(Placement::Bottom) ``` Use `.tooltip_placement(...)` to prefer a side for either `.tooltip(...)` or `.tooltip_with_action(...)`. The tooltip still flips when that side does not fit. Omit placement to keep automatic positioning. ### Custom Children ```rust Button::new("btn") .child( h_flex() .items_center() .gap_2() .child("Custom Content") .child(IconName::ChevronDown) .child(IconName::Eye) ) ``` [Button]: https://docs.rs/gpui-component/latest/gpui_component/button/struct.Button.html [ButtonGroup]: https://docs.rs/gpui-component/latest/gpui_component/button/struct.ButtonGroup.html [ButtonCustomVariant]: https://docs.rs/gpui-component/latest/gpui_component/button/struct.ButtonCustomVariant.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html [Spinner]: https://docs.rs/gpui-component/latest/gpui_component/spinner/struct.Spinner.html [ProgressCircle]: https://docs.rs/gpui-component/latest/gpui_component/progress/struct.ProgressCircle.html [Icon]: https://docs.rs/gpui-component/latest/gpui_component/icon/struct.Icon.html [IconName]: https://docs.rs/gpui-component/latest/gpui_component/icon/enum.IconName.html --- # DataTable Source: /versions/v0.7.1/component/data-table License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/data-table and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. # Data Table A comprehensive data table component designed for handling large datasets with high performance. Features virtual scrolling, column configuration, sorting, filtering, row/column/cell selection, and custom cell rendering. Perfect for displaying tabular data with thousands of rows while maintaining smooth performance. ## Key Features - **Multiple Selection Modes**: Row, column, and individual cell selection - **Virtual Scrolling**: Handle thousands of rows with smooth performance - **Column Management**: Resizable, movable, and fixed columns - **Sorting**: Built-in column sorting support - **Keyboard Navigation**: Full keyboard support for all selection modes - **Custom Cell Rendering**: Render any content in table cells - **Context Menus**: Right-click support for rows and cells - **Infinite Loading**: Load more data as user scrolls - **Events**: Comprehensive event system for user interactions ## Import ```rust use gpui_kit::component::table::{ DataTable, TableState, TableDelegate, Column, ColumnSort, ColumnFixed, TableEvent }; ``` ## Usage ### Basic Table To create a table, you need to implement the `TableDelegate` trait and provide column definitions, and use `TableState` to manage the table state. ```rust use std::ops::Range; use gpui_kit::{App, Context, Window, IntoElement}; use gpui_kit::component::table::{DataTable, TableDelegate, Column, ColumnSort}; struct MyData { id: usize, name: String, age: u32, email: String, } struct MyTableDelegate { data: Vec, columns: Vec, } impl MyTableDelegate { fn new() -> Self { Self { data: vec![ MyData { id: 1, name: "John".to_string(), age: 30, email: "john@example.com".to_string() }, MyData { id: 2, name: "Jane".to_string(), age: 25, email: "jane@example.com".to_string() }, ], columns: vec![ Column::new("id", "ID").width(60.), Column::new("name", "Name").width(150.).sortable(), Column::new("age", "Age").width(80.).sortable(), Column::new("email", "Email").width(200.), ], } } } impl TableDelegate for MyTableDelegate { fn columns_count(&self, _: &App) -> usize { self.columns.len() } fn rows_count(&self, _: &App) -> usize { self.data.len() } fn column(&self, col_ix: usize, _: &App) -> Column { self.columns[col_ix].clone() } fn render_td(&mut self, row_ix: usize, col_ix: usize, _: &mut Window, _: &mut Context>) -> impl IntoElement { let row = &self.data[row_ix]; let col = &self.columns[col_ix]; match col.key.as_ref() { "id" => row.id.to_string(), "name" => row.name.clone(), "age" => row.age.to_string(), "email" => row.email.clone(), _ => "".to_string(), } } } // Create the table let delegate = MyTableDelegate::new(); let state = cx.new(|cx| TableState::new(delegate, window, cx)); ``` ### Column Configuration Columns provide extensive configuration options: ```rust // Basic column Column::new("id", "ID") // Sortable column Column::new("name", "Name") .sortable() .width(150.) // Right-aligned column Column::new("price", "Price") .text_right() .sortable() // Fixed column (pinned to left) Column::new("actions", "Actions") .fixed(ColumnFixed::Left) .resizable(false) .movable(false) // Column with custom padding Column::new("description", "Description") .width(200.) .paddings(px(8.)) // Non-resizable column Column::new("status", "Status") .width(100.) .resizable(false) // Custom sort orders Column::new("created", "Created") .ascending() // Default ascending // or Column::new("modified", "Modified") .descending() // Default descending ``` ### Virtual Scrolling for Large Datasets The table automatically handles virtual scrolling for optimal performance: ```rust struct LargeDataDelegate { data: Vec, // Could be 10,000+ items columns: Vec, } impl TableDelegate for LargeDataDelegate { fn rows_count(&self, _: &App) -> usize { self.data.len() // No performance impact regardless of size } // Only visible rows are rendered fn render_td(&mut self, row_ix: usize, col_ix: usize, _: &mut Window, _: &mut Context>) -> impl IntoElement { // This is only called for visible rows // Efficiently render cell content let row = &self.data[row_ix]; format_cell_data(row, col_ix) } // Track visible range for optimizations fn visible_rows_changed(&mut self, visible_range: Range, _: &mut Window, _: &mut Context>) { // Only update data for visible rows if needed // This is called when user scrolls } } ``` ### Sorting Implementation Implement sorting in your delegate: ```rust impl TableDelegate for MyTableDelegate { fn perform_sort(&mut self, col_ix: usize, sort: ColumnSort, _: &mut Window, _: &mut Context>) { let col = &self.columns[col_ix]; match col.key.as_ref() { "name" => { match sort { ColumnSort::Ascending => self.data.sort_by(|a, b| a.name.cmp(&b.name)), ColumnSort::Descending => self.data.sort_by(|a, b| b.name.cmp(&a.name)), ColumnSort::Default => { // Reset to original order or default sort self.data.sort_by(|a, b| a.id.cmp(&b.id)); } } } "age" => { match sort { ColumnSort::Ascending => self.data.sort_by(|a, b| a.age.cmp(&b.age)), ColumnSort::Descending => self.data.sort_by(|a, b| b.age.cmp(&a.age)), ColumnSort::Default => self.data.sort_by(|a, b| a.id.cmp(&b.id)), } } _ => {} } } } ``` ### ContextMenu ```rust impl TableDelegate for MyTableDelegate { // Context menu for right-click fn context_menu(&mut self, row_ix: usize, menu: PopupMenu, _: &mut Window, _: &mut Context>) -> PopupMenu { let row = &self.data[row_ix]; menu.menu(format!("Edit {}", row.name), Box::new(EditRowAction(row_ix))) .menu("Delete", Box::new(DeleteRowAction(row_ix))) .separator() .menu("Duplicate", Box::new(DuplicateRowAction(row_ix))) } } ``` ### Cell Rendering Create rich cell content with custom rendering: ```rust impl TableDelegate for MyTableDelegate { fn render_td(&mut self, row_ix: usize, col_ix: usize, _: &mut Window, cx: &mut Context>) -> impl IntoElement { let row = &self.data[row_ix]; let col = &self.columns[col_ix]; match col.key.as_ref() { "status" => { // Custom status badge let (color, text) = match row.status { Status::Active => (cx.theme().green, "Active"), Status::Inactive => (cx.theme().red, "Inactive"), Status::Pending => (cx.theme().yellow, "Pending"), }; div() .px_2() .py_1() .rounded(px(4.)) .bg(color.opacity(0.1)) .text_color(color) .child(text) } "progress" => { // Progress bar div() .w_full() .h(px(8.)) .bg(cx.theme().muted) .rounded(px(4.)) .child( div() .h_full() .w(percentage(row.progress)) .bg(cx.theme().primary) .rounded(px(4.)) ) } "actions" => { // Action buttons h_flex() .gap_1() .child(Button::new(format!("edit-{}", row_ix)).text().icon(IconName::Edit)) .child(Button::new(format!("delete-{}", row_ix)).text().icon(IconName::Trash)) } "avatar" => { // User avatar with image h_flex() .items_center() .gap_2() .child( div() .w(px(32.)) .h(px(32.)) .rounded_full() .bg(cx.theme().accent) .flex() .items_center() .justify_center() .child(row.name.chars().next().unwrap_or('?').to_string()) ) .child(row.name.clone()) } _ => row.get_field_value(col.key.as_ref()).into_any_element(), } } } ``` ### Selection Modes The table supports three distinct selection modes: ```rust // Row selection mode (default) let state = cx.new(|cx| { TableState::new(delegate, window, cx) .row_selectable(true) // Enable row selection .col_selectable(false) .cell_selectable(false) }); // Column selection mode let state = cx.new(|cx| { TableState::new(delegate, window, cx) .row_selectable(false) .col_selectable(true) // Enable column selection .cell_selectable(false) }); // Cell selection mode let state = cx.new(|cx| { TableState::new(delegate, window, cx) .row_selectable(true) // Keep row selection for row selector column .col_selectable(false) .cell_selectable(true) // Enable cell selection }); ``` ### Reading and Writing the Selection A table holds one selection at a time: nothing, a row, a column, or a cell. `selection()` returns it as a single `TableSelection` value, and `set_selection()` writes one back, so persisting and restoring the selection is one read and one write: ```rust use gpui_component::table::TableSelection; match state.read(cx).selection() { TableSelection::None => {} TableSelection::Row(row_ix) => println!("Row {row_ix}"), TableSelection::Column(col_ix) => println!("Column {col_ix}"), TableSelection::Cell(row_ix, col_ix) => println!("Cell ({row_ix}, {col_ix})"), } state.update(cx, |state, cx| { // Scrolls and emits exactly as `set_selected_cell(5, 3, cx)` would. state.set_selection(TableSelection::Cell(5, 3), cx); }); ``` The positional getters `selected_row()`, `selected_col()` and `selected_cell()` each answer only for their own kind of selection. `selected_row()` is `Some` only when a row itself is selected; a selected cell does not surface through it, and selecting a row clears what `selected_cell()` reports. When you need the row a selected cell sits in, map it from the cell: ```rust let row_ix = state.read(cx).selected_cell().map(|(row_ix, _)| row_ix); ``` Keyboard navigation remembers the last row and column position across mode changes. Pressing `Down` after selecting a column continues from the row that was selected before, even though `selected_row()` returned `None` in column mode. ### Column Resizing and Moving Enable dynamic column management: ```rust // Configure table features let state = cx.new(|cx| { TableState::new(delegate, window, cx) .col_resizable(true) // Allow column resizing .col_movable(true) // Allow column reordering .sortable(true) // Enable sorting .col_selectable(true) // Allow column selection .row_selectable(true) // Allow row selection }); // Listen for column changes cx.subscribe_in(&state, window, |view, table, event, _, cx| { match event { TableEvent::ColumnWidthsChanged(widths) => { // Save column widths to user preferences save_column_widths(widths); } TableEvent::MoveColumn(from_ix, to_ix) => { // Save column order save_column_order(from_ix, to_ix); } _ => {} } }).detach(); ``` ### Infinite Loading / Pagination Implement loading more data as user scrolls: ```rust impl TableDelegate for MyTableDelegate { fn has_more(&self, _: &App) -> bool { self.has_more_data } fn load_more_threshold(&self) -> usize { 50 // Load more when 50 rows from bottom } fn load_more(&mut self, _: &mut Window, cx: &mut Context>) { if self.loading { return; // Prevent multiple loads } self.loading = true; // Spawn async task to load data cx.spawn(async move |view, cx| { let new_data = fetch_more_data().await; cx.update(|cx| { view.update(cx, |view, _| { let delegate = view.table.delegate_mut(); delegate.data.extend(new_data); delegate.loading = false; delegate.has_more_data = !new_data.is_empty(); }); }) }).detach(); } fn loading(&self, _: &App) -> bool { self.loading } } ``` ### Table Styling Customize table appearance. `DataTable` implements `Sizable`: use preset sizes such as `.small()` and `.large()` for standard density, or pass a custom pixel size to set a uniform header and body row height. ```rust use gpui_kit::px; use gpui_kit::component::Sizable as _; let state = cx.new(|cx| { TableState::new(delegate, window, cx) }); // In render DataTable::new(&state) .with_size(px(48.)) // Custom uniform row height .stripe(true) // Alternating row colors .bordered(true) // Border around table .scrollbar_visible(true, true) // Vertical, horizontal scrollbars ``` ## Examples ### Financial Data Table ```rust struct StockData { symbol: String, price: f64, change: f64, change_percent: f64, volume: u64, } impl TableDelegate for StockTableDelegate { fn render_td(&mut self, row_ix: usize, col_ix: usize, _: &mut Window, cx: &mut Context>) -> impl IntoElement { let stock = &self.stocks[row_ix]; let col = &self.columns[col_ix]; match col.key.as_ref() { "symbol" => div().font_weight(FontWeight::BOLD).child(stock.symbol.clone()), "price" => div().text_right().child(format!("${:.2}", stock.price)), "change" => { let color = if stock.change >= 0.0 { cx.theme().green } else { cx.theme().red }; div() .text_right() .text_color(color) .child(format!("{:+.2}", stock.change)) } "change_percent" => { let color = if stock.change_percent >= 0.0 { cx.theme().green } else { cx.theme().red }; div() .text_right() .text_color(color) .child(format!("{:+.1}%", stock.change_percent * 100.0)) } "volume" => div().text_right().child(format!("{:,}", stock.volume)), _ => div(), } } } ``` ### User Management Table ```rust struct UserTableDelegate { users: Vec, columns: Vec, } impl UserTableDelegate { fn new() -> Self { Self { users: Vec::new(), columns: vec![ Column::new("avatar", "").width(50.).resizable(false).movable(false), Column::new("name", "Name").width(150.).sortable().fixed_left(), Column::new("email", "Email").width(200.).sortable(), Column::new("role", "Role").width(100.).sortable(), Column::new("status", "Status").width(100.), Column::new("last_login", "Last Login").width(120.).sortable(), Column::new("actions", "Actions").width(100.).resizable(false), ], } } } ``` ### Cell Selection Enable individual cell selection for more granular control: ```rust let state = cx.new(|cx| { TableState::new(delegate, window, cx) .cell_selectable(true) // Enable cell selection .row_selectable(true) // Also allow row selection }); // Listen for cell events cx.subscribe_in(&state, window, |view, table, event, _, cx| { match event { TableEvent::SelectCell(row_ix, col_ix) => { println!("Selected cell: ({}, {})", row_ix, col_ix); } TableEvent::DoubleClickedCell(row_ix, col_ix) => { // Open editor or detail view open_cell_editor(row_ix, col_ix); } TableEvent::RightClickedCell(row_ix, col_ix) => { // Show cell-specific context menu show_cell_context_menu(row_ix, col_ix); } TableEvent::ClearSelection => { println!("Selection cleared"); } _ => {} } }).detach(); ``` #### Cell Selection Features When cell selection is enabled: - **Click to select**: Click on any cell to select it - **Row selector column**: A dedicated column appears on the left for selecting entire rows - **Keyboard navigation**: Arrow keys navigate between cells (not rows/columns) - **Double-click support**: Trigger actions like editing by double-clicking cells - **Right-click support**: Show context menus specific to cell content - **Visual feedback**: Selected cells show highlight with border #### Programmatic Cell Selection ```rust // Get the currently selected cell. `None` while a row or column is // selected instead; see "Reading and Writing the Selection" above. if let Some((row_ix, col_ix)) = state.read(cx).selected_cell() { println!("Current cell: ({}, {})", row_ix, col_ix); } // Select a specific cell programmatically state.update(cx, |state, cx| { state.set_selected_cell(5, 3, cx); // Select row 5, column 3 }); // Clear all selections state.update(cx, |state, cx| { state.clear_selection(cx); }); ``` #### Non-selectable Columns Prevent specific columns from being selected (useful for action columns): ```rust Column::new("actions", "Actions") .width(100.) .selectable(false) // This column's cells cannot be selected .resizable(false) ``` #### Cell Selection with Custom Rendering ```rust impl TableDelegate for MyTableDelegate { fn render_td(&mut self, row_ix: usize, col_ix: usize, _: &mut Window, cx: &mut Context>) -> impl IntoElement { let row = &self.data[row_ix]; let col = &self.columns[col_ix]; // Render different content based on whether cell is selected let is_selected = cx.entity().read(cx).selected_cell() == Some((row_ix, col_ix)); match col.key.as_ref() { "editable_field" => { if is_selected { // Show input when selected Input::new(format!("cell-{}-{}", row_ix, col_ix)) .value(row.field_value.clone()) .into_any_element() } else { // Show plain text when not selected div().child(row.field_value.clone()).into_any_element() } } _ => div().child(row.get_value(col.key.as_ref())).into_any_element() } } } ``` ## Keyboard Shortcuts ### Row Selection Mode (default) - `↑/↓` - Navigate rows - `←/→` - Navigate columns - `Home` - Jump to first row/column - `End` - Jump to last row/column - `PageUp/PageDown` - Navigate by page - `Escape` - Clear selection ### Cell Selection Mode - `↑/↓` - Navigate up/down within current column - `←/→` - Navigate left/right within current row - `Tab` - Move to next cell (right, then next row) - `Shift+Tab` - Move to previous cell - `Home` - Jump to first cell in current row - `End` - Jump to last cell in current row - `PageUp/PageDown` - Navigate by page within current column - `Escape` - Clear selection ## API Reference ### Core Types - [DataTable] - The data table component - [TableState] - Table state management - [TableDelegate] - Trait for implementing table data source - [Column] - Column configuration - [TableEvent] - Table events (selection, clicks, etc.) - [TableSelection] - The current selection as one value: `None`, `Row`, `Column`, or `Cell` ### Column Types - [ColumnSort] - Column sort direction enum - [ColumnFixed] - Column fixed position enum ### Methods #### TableState - `new(delegate, window, cx)` - Create a new table state - `cell_selectable(bool)` - Enable/disable cell selection - `row_selectable(bool)` - Enable/disable row selection - `col_selectable(bool)` - Enable/disable column selection - `selection()` - Get the current selection as a `TableSelection` - `set_selection(selection, cx)` - Set the selection from a `TableSelection`; `TableSelection::None` clears it - `selected_cell()` - Get the selected cell; `None` unless a cell is selected - `set_selected_cell(row_ix, col_ix, cx)` - Select a specific cell - `selected_row()` - Get the selected row; `None` unless a row itself is selected - `selected_col()` - Get the selected column; `None` unless a column itself is selected - `clear_selection(cx)` - Clear all selections - `scroll_to_row(row_ix, cx)` - Scroll to specific row - `scroll_to_col(col_ix, cx)` - Scroll to specific column #### Column - `new(key, name)` - Create a new column - `width(pixels)` - Set column width - `sortable()` - Make column sortable - `ascending()` - Set default sort to ascending - `descending()` - Set default sort to descending - `text_right()` - Right-align column text - `text_center()` - Center-align column text - `fixed(ColumnFixed)` - Pin column to left - `resizable(bool)` - Enable/disable column resizing - `movable(bool)` - Enable/disable column moving - `selectable(bool)` - Enable/disable column/cell selection - `paddings(edges)` - Set custom padding - `min_width(pixels)` - Set minimum width - `max_width(pixels)` - Set maximum width ### Events - `SelectRow(usize)` - Row selected - `DoubleClickedRow(usize)` - Row double-clicked - `SelectColumn(usize)` - Column selected - `SelectCell(usize, usize)` - Cell selected (row_ix, col_ix) - `DoubleClickedCell(usize, usize)` - Cell double-clicked (row_ix, col_ix) - `RightClickedCell(usize, usize)` - Cell right-clicked (row_ix, col_ix) - `RightClickedRow(Option)` - Row right-clicked - `ColumnWidthsChanged(Vec)` - Column widths changed - `MoveColumn(usize, usize)` - Column moved (from_ix, to_ix) [DataTable]: https://docs.rs/gpui-component/latest/gpui_component/table/struct.DataTable.html [TableState]: https://docs.rs/gpui-component/latest/gpui_component/table/struct.TableState.html [TableDelegate]: https://docs.rs/gpui-component/latest/gpui_component/table/trait.TableDelegate.html [Column]: https://docs.rs/gpui-component/latest/gpui_component/table/struct.Column.html [TableEvent]: https://docs.rs/gpui-component/latest/gpui_component/table/enum.TableEvent.html [TableSelection]: https://docs.rs/gpui-component/latest/gpui_component/table/enum.TableSelection.html [ColumnSort]: https://docs.rs/gpui-component/latest/gpui_component/table/enum.ColumnSort.html [ColumnFixed]: https://docs.rs/gpui-component/latest/gpui_component/table/enum.ColumnFixed.html --- # Tooltip Source: /versions/v0.7.1/component/tooltip License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/tooltip and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A versatile tooltip component that displays helpful information when hovering over or focusing on elements. Supports text content, custom elements, keyboard shortcuts, different trigger methods, and positioning options. ## Mobile behavior On iOS and Android, tooltips managed by the GPUI Base overlay are disabled. Shared components may keep their tooltip configuration, but mobile actions still need visible or accessible labels. Direct GPUI `.tooltip()` calls, including the basic `div()` example below, bypass this overlay and are not disabled by this policy. See [Mobile](/versions/v0.7.1/docs/mobile) for integration guidance. ## Import ```rust use gpui_kit::component::tooltip::Tooltip; ``` ## Usage ### Basic Tooltip with Text ```rust // Simple text tooltip div() .child("Hover me") .id("basic-tooltip") .tooltip(|window, cx| { Tooltip::new("This is a helpful tooltip").build(window, cx) }) ``` ### Button with Tooltip ```rust Button::new("save-btn") .label("Save") .tooltip("Save the current document") ``` ### Tooltip with Action/Keybinding ```rust actions!(my_actions, [SaveDocument]); Button::new("save-btn") .label("Save") .tooltip_with_action( "Save the current document", &SaveDocument, Some("MyContext") ) ``` ### Custom Element Tooltip ```rust div() .child("Hover for rich content") .id("rich-tooltip") .tooltip(|window, cx| { Tooltip::element(|_, cx| { h_flex() .gap_x_1() .child(IconName::Info) .child( div() .child("Muted Text") .text_color(cx.theme().muted_foreground) ) .child( div() .child("Danger Text") .text_color(cx.theme().danger) ) .child(IconName::ArrowUp) }) .build(window, cx) }) ``` ### Tooltip with Manual Keybinding ```rust div() .child("Custom keybinding") .id("custom-kb") .tooltip(|window, cx| { Tooltip::new("Delete item") .key_binding(Some(Kbd::new("Delete"))) .build(window, cx) }) ``` ## Advanced Usage ### Components with Built-in Tooltip Support Many components have built-in tooltip methods: ```rust // Button Button::new("btn") .label("Click me") .tooltip("This button performs an action") // Switch Switch::new("toggle") .label("Enable notifications") .tooltip("Toggle push notifications on/off") // Checkbox Checkbox::new("check") .label("Remember me") .tooltip("Keep me logged in for 30 days") // Radio Radio::new("option") .label("Option 1") .tooltip("Select this option to enable feature X") ``` ### Complex Tooltip Content ```rust div() .child("Hover for details") .id("complex-tooltip") .tooltip(|window, cx| { Tooltip::element(|_, cx| { v_flex() .gap_2() .child( h_flex() .gap_1() .child(IconName::User) .child("User Information") .text_sm() .font_semibold() ) .child( div() .child("Last login: 2 hours ago") .text_xs() .text_color(cx.theme().muted_foreground) ) .child( div() .child("Status: Active") .text_xs() .text_color(cx.theme().success) ) }) .build(window, cx) }) ``` ### Tooltip in Form Elements ```rust v_flex() .gap_4() .child( Input::new("email") .placeholder("Enter your email") .tooltip("We'll never share your email address") ) .child( Input::new("password") .input_type(InputType::Password) .placeholder("Password") .tooltip("Must be at least 8 characters with special characters") ) ``` ## API Reference ### Tooltip | Method | Description | | ------------------------- | -------------------------------------------- | | `new(text)` | Create a tooltip with text content | | `element(builder)` | Create a tooltip with custom element content | | `action(action, context)` | Set action to display keybinding information | | `key_binding(kbd)` | Set manual keybinding information | | `build(window, cx)` | Build and return the tooltip as AnyView | ### Built-in Tooltip Methods Components with tooltip support typically provide these methods: | Method | Description | | -------------------------------------------- | --------------------------------------- | | `tooltip(text)` | Add simple text tooltip | | `tooltip_with_action(text, action, context)` | Add tooltip with action keybinding | | `tooltip(closure)` | Add custom tooltip with builder closure | ### Tooltip Styling The tooltip automatically applies theme-appropriate styling: - Background: `theme.popover` - Text color: `theme.popover_foreground` - Border: `theme.border` - Shadow: Medium drop shadow - Border radius: 6px - Font: System UI font You can apply additional styling using the `Styled` trait: ```rust Tooltip::new("Custom styled tooltip") .bg(cx.theme().accent) .text_color(cx.theme().accent_foreground) .build(window, cx) ``` ## Examples ### Toolbar with Tooltips ```rust h_flex() .gap_1() .child( Button::new("new") .icon(IconName::Plus) .tooltip_with_action("Create new file", &NewFile, Some("Editor")) ) .child( Button::new("open") .icon(IconName::FolderOpen) .tooltip_with_action("Open file", &OpenFile, Some("Editor")) ) .child( Button::new("save") .icon(IconName::Save) .tooltip_with_action("Save file", &SaveFile, Some("Editor")) ) ``` ### Status Indicators with Tooltips ```rust h_flex() .gap_2() .child( div() .size_3() .rounded_full() .bg(cx.theme().success) .tooltip(|window, cx| { Tooltip::new("Connected to server").build(window, cx) }) ) .child( div() .size_3() .rounded_full() .bg(cx.theme().warning) .tooltip(|window, cx| { Tooltip::new("Limited connectivity").build(window, cx) }) ) ``` ### Interactive Elements with Rich Tooltips ```rust v_flex() .gap_3() .child( div() .p_2() .border_1() .border_color(cx.theme().border) .rounded(cx.theme().radius) .child("File: document.txt") .id("file-item") .tooltip(|window, cx| { Tooltip::element(|_, cx| { v_flex() .gap_1() .child( h_flex() .gap_2() .child(IconName::File) .child("document.txt") .text_sm() .font_medium() ) .child( div() .child("Size: 2.4 KB") .text_xs() .text_color(cx.theme().muted_foreground) ) .child( div() .child("Modified: 2 hours ago") .text_xs() .text_color(cx.theme().muted_foreground) ) .child( h_flex() .gap_1() .child(Kbd::new("Enter")) .child("to open") .text_xs() .text_color(cx.theme().muted_foreground) ) }) .build(window, cx) }) ) ``` ### Form Validation with Tooltips ```rust struct FormView { email_error: Option, password_error: Option, } v_flex() .gap_4() .child( Input::new("email") .placeholder("Email address") .when_some(self.email_error.clone(), |this, error| { this.tooltip(move |window, cx| { Tooltip::element(|_, cx| { h_flex() .gap_1() .child(IconName::AlertCircle) .child(error.clone()) .text_color(cx.theme().destructive) }) .build(window, cx) }) }) ) ``` ## Best Practices ### Content Guidelines - **Be concise**: Keep tooltip text short and to the point - **Be helpful**: Provide additional context, not redundant information - **Use proper tone**: Match your application's voice and tone - **Avoid critical info**: Don't put essential information only in tooltips ### Usage Guidelines - **Progressive disclosure**: Use tooltips for additional context, not primary information - **Consistency**: Use consistent tooltip patterns throughout your application - **Performance**: Avoid complex content in frequently triggered tooltips - **Testing**: Test tooltips with both mouse and keyboard interaction ### Examples of Good Tooltip Content ```rust // Good: Provides helpful context Button::new("delete") .icon(IconName::Trash) .tooltip("Delete this item permanently") // Good: Explains abbreviation div() .child("CPU: 45%") .tooltip("Central Processing Unit usage") // Good: Describes action with keybinding Button::new("undo") .icon(IconName::Undo) .tooltip_with_action("Undo last action", &Undo, Some("Editor")) ``` ### Examples to Avoid ```rust // Avoid: Redundant information Button::new("save") .label("Save") .tooltip("Save") // Doesn't add value // Avoid: Critical information Button::new("delete") .tooltip("This will permanently delete all your files") // Too important for tooltip only ``` --- # DropdownButton Source: /versions/v0.7.1/component/dropdown_button License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/dropdown_button and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A [DropdownButton] is a combination of a button and a trigger button. It allows us to display a dropdown menu when the trigger is clicked, but the left Button can still respond to independent events. Shared variant and size can be set on the DropdownButton. Action-specific options such as its label, icon, tooltip, loading state and click handler belong to the inner [Button]. ## Import ```rust use gpui_kit::component::button::{Button, DropdownButton}; ``` ## Usage ```rust use gpui_kit::Anchor; DropdownButton::new("dropdown") .button(Button::new("btn").label("Click Me")) .dropdown_menu(|menu, _, _| { menu.menu("Option 1", Box::new(MyAction)) .menu("Option 2", Box::new(MyAction)) .separator() .menu("Option 3", Box::new(MyAction)) }) ``` ### Variants Same as [Button], DropdownButton supports different variants. ```rust DropdownButton::new("dropdown") .primary() .button(Button::new("btn").label("Primary")) .dropdown_menu(|menu, _, _| { menu.menu("Option 1", Box::new(MyAction)) }) ``` Leaving the variant or size unset on the DropdownButton uses the inner button's value for both halves. ### Inner button options ```rust DropdownButton::new("dropdown") .button( Button::new("btn") .label("Save") .compact() .loading(is_saving) .tooltip("Save the current view") .on_click(|_, _, _| println!("Saved")), ) .dropdown_menu(|menu, _, _| { menu.menu("Save as…", Box::new(MyAction)) }) ``` ### With custom anchor ```rust DropdownButton::new("dropdown") .button(Button::new("btn").label("Click Me")) .dropdown_menu_with_anchor(Anchor::BottomRight, |menu, _, _| { menu.menu("Option 1", Box::new(MyAction)) }) ``` [Button]: https://docs.rs/gpui-component/latest/gpui_component/button/struct.Button.html [DropdownButton]: https://docs.rs/gpui-component/latest/gpui_component/button/struct.DropdownButton.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html --- # HoverCard Source: /versions/v0.7.1/component/hover-card License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/hover-card and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. HoverCard component for displaying rich content that appears when the mouse hovers over a trigger element. Ideal for previewing user profiles, link previews, and other contextual information without requiring a click. Features configurable delays for both opening and closing to prevent flickering during quick mouse movements. This is most like the [Popover] component, but triggered by hover instead of click, and with timing controls for a smoother user experience. On iOS and Android, tap the trigger to open or close the card. Tapping outside closes it; tapping inside keeps it open. Hover delays do not apply. Tooltip hints remain disabled; see [Mobile](/versions/v0.7.1/docs/mobile). ## Import ```rust use gpui_kit::component::hover_card::HoverCard; ``` ## Usage ### Basic HoverCard ```rust use gpui_kit::{ParentElement as _, Styled as _}; use gpui_kit::component::{hover_card::HoverCard, v_flex}; HoverCard::new("basic") .trigger( div() .child("Hover over me") .text_color(cx.theme().primary) .cursor_pointer() .text_sm() ) .child( v_flex() .gap_2() .child( div() .child("This is a hover card") .font_semibold() .text_sm() ) .child( div() .child("You can display rich content when hovering over a trigger element.") .text_color(cx.theme().muted_foreground) .text_sm() ) ) ``` ### User Profile Preview A common use case is showing user profiles when hovering over a username, similar to GitHub or Twitter: ```rust use gpui_kit::{px, relative, Styled as _}; use gpui_kit::component::{ avatar::Avatar, hover_card::HoverCard, h_flex, v_flex, }; h_flex() .child("Hover over ") .text_sm() .child( HoverCard::new("user-profile") .trigger( div() .child("@huacnlee") .cursor_pointer() .text_color(cx.theme().link) ) .child( h_flex() .w(px(320.)) .gap_4() .items_start() .child( Avatar::new() .src("https://avatars.githubusercontent.com/u/5518?s=64") ) .child( v_flex() .gap_1() .line_height(relative(1.)) .child(div().child("Jason Lee").font_semibold()) .child( div() .child("@huacnlee") .text_color(cx.theme().muted_foreground) .text_sm() ) .child("The author of GPUI Kit.") ) ) ) .child(" to see their profile") ``` ### Custom Timing Adjust the opening and closing delays to suit your needs: ```rust use std::time::Duration; use gpui_kit::Styled as _; use gpui_kit::component::{ button::{Button, ButtonVariants as _}, h_flex, }; h_flex() .gap_4() .child( HoverCard::new("fast-open") .open_delay(Duration::from_millis(200)) .close_delay(Duration::from_millis(100)) .trigger(Button::new("fast").label("Fast Open (200ms)").outline()) .child(div().child("This hover card opens after 200ms").text_sm()) ) .child( HoverCard::new("slow-open") .open_delay(Duration::from_secs(1)) .close_delay(Duration::from_secs_f32(0.5)) .trigger(Button::new("slow").label("Slow Open (1000ms)").outline()) .child(div().child("This hover card opens after 1000ms").text_sm()) ) ``` ### Positioning HoverCard supports 6 positioning options using the [Anchor] type: - TopLeft - TopCenter - TopRight - BottomLeft - BottomCenter - BottomRight Imagine the card has a pointer tip (like a speech bubble's tail). The anchor is where that tip sits relative to the trigger — `TopCenter` places it at the trigger's top center, `BottomRight` at the bottom-right, and so on. The card then hangs off that point. For example, `Anchor::TopLeft` places the card just below the trigger, left-aligned to it: ```text [ Trigger ] ┌──────────────┐ │ Hover Card │ └──────────────┘ ``` ### Custom Content Builder For performance optimization, you can provide a content builder function for more complex case, which only calls when the HoverCard is opened: ```rust HoverCard::new("complex") .trigger(Button::new("btn").label("Hover me")) .content(|state, window, cx| { v_flex() .child("Dynamic content") .child(format!("Open: {}", state.is_open())) }) ``` ### Styling HoverCard inherits all `Styled` trait methods: ```rust HoverCard::new("styled") .trigger(Button::new("btn").label("Styled")) .w(px(400.)) .max_h(px(500.)) .text_sm() .gap_2() .child("Styled content") ``` Disable default appearance and apply custom styles: ```rust HoverCard::new("custom-styled") .appearance(false) // Disable default popover styling .trigger(Button::new("btn").label("Custom")) .bg(cx.theme().background) .border_2() .border_color(cx.theme().primary) .rounded(px(12.)) .p_4() .child("Custom styled content") ``` ## API Reference ### HoverCard Methods - `new(id: impl Into)` - Create a new HoverCard with a unique ID - `trigger(trigger: T)` - Set the element that triggers the hover - `content(content: F)` - Set a content builder function that receives `(&mut HoverCardState, &mut Window, &mut Context)` - `open_delay(duration: Duration)` - Set delay before showing (default: 600ms) - `close_delay(duration: Duration)` - Set delay before hiding (default: 300ms) - `anchor(anchor: impl Into)` - Set positioning (default: TopCenter) - `on_open_change(callback: F)` - Callback when open state changes, receives `(&bool, &mut Window, &mut App)` - `appearance(appearance: bool)` - Enable/disable default styling (default: true) ### HoverCardState Methods - `is_open() -> bool` - Check if the hover card is currently open ## Behavior Details ### Hover Timing The HoverCard uses a sophisticated timing system to provide a smooth user experience: 1. **Open Delay (600ms default)**: Prevents the card from flickering when the mouse quickly passes over the trigger 2. **Close Delay (300ms default)**: Gives users time to move their mouse from the trigger to the content area without the card closing 3. **Interactive Content**: Users can move their mouse into the content area, and the card will remain open as long as the mouse is either on the trigger or in the content ### Edge Cases Handled - **Quick Mouse Sweep**: If the mouse quickly moves across the trigger, the card won't open (cancelled by the open delay) - **Trigger to Content Movement**: The card stays open when moving the mouse from the trigger to the content area - **Rapid Hovering**: Multiple rapid hover events are debounced using an epoch-based timer system - **Multiple HoverCards**: Each HoverCard has independent state, so multiple cards can coexist without interfering ## Best Practices 1. **Use appropriate delays**: - Standard content: 600ms open, 300ms close - Quick previews: 500ms open, 200ms close - Tooltips: 300ms open, 100ms close 2. **Keep content concise**: HoverCards should provide preview information, not full content 3. **Make triggers visually distinct**: Use colors, underlines, or cursor changes to indicate hoverable elements 4. **Consider accessibility**: HoverCards are visual-only and don't support keyboard navigation. For keyboard-accessible content, consider using a Popover instead 5. **Avoid nested HoverCards**: They can create confusing user experiences ## Differences from [Popover] | Feature | HoverCard | Popover | | ------------------------ | ---------------- | ------------------ | | Trigger | Mouse hover | Click/right-click | | Keyboard navigation | No | Yes (with focus) | | Dismiss on outside click | No | Yes (configurable) | | Timing delays | Yes (open/close) | No | | Primary use case | Previews | Actions/forms | [Popover]: ./popover.md [Anchor]: https://docs.rs/gpui-component/latest/gpui_component/enum.Anchor.html [Avatar]: ./avatar.md --- # Components Source: /versions/v0.7.1/component License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. ### Basic Components - [Accordion](accordion) - Collapsible content panels - [Alert](alert) - Alert messages with different variants - [Attachment](attachment) - File and media attachment surfaces - [Avatar](avatar) - User avatars with fallback text - [Badge](badge) - Count badges and indicators - [Bubble](bubble) - Chat message surface with alignment and reactions - [Button](button) - Interactive buttons with multiple variants - [Checkbox](checkbox) - Binary selection control - [Collapsible](collapsible) - Expandable/collapsible content - [DropdownButton](dropdown_button) - Button with dropdown menu - [Icon](icon) - Icon display component - [Image](image) - Image display with fallbacks - [Kbd](kbd) - Keyboard shortcut display - [Label](label) - Text labels for form elements - [Marker](marker) - Conversation status and separator marker - [Message](message) - Composable chat message structure - [MessageScroller](message-scroller) - Tail-following virtualized message list - [Pagination](pagination) - Page navigation controls - [Progress](progress) - Progress bars - [Questionnaire](questionnaire) - Composable multi-step questions and answers - [Radio](radio) - Single selection from multiple options - [Rating](rating) - Interactive star rating component - [Skeleton](skeleton) - Loading placeholders - [Slider](slider) - Value selection from a range - [Spinner](spinner) - Loading and status spinners - [Stepper](stepper) - Step-by-step progress indicator - [Switch](switch) - Toggle on/off control - [Tag](tag) - Labels and categories - [TextView](text-view) - Markdown and HTML text rendering - [Toggle](toggle) - Toggle button states - [Tooltip](tooltip) - Helpful hints on hover ### Form Components - [Input](input) - An input field or a component that looks like an input field. - [Textarea](textarea) - Multi-line text with fixed rows or auto-grow. - [Editor](editor) - Source-code editing with highlighting, gutter, and folding. - [Select](select) - A list of options for the user to pick. - [Combobox](combobox) - Searchable single-select or multi-select dropdown. - [NumberInput](number-input) - Numeric input with increment/decrement - [DatePicker](date-picker) - Date selection with calendar - [TimeField](time-field) - Segmented time-of-day input - [OtpInput](otp-input) - One-time password input - [Speech](speech) - Dictation through the system recognizer or your own - [ColorPicker](color-picker) - Color selection interface - [Form](form) - Form container and layout ### Layout Components - [DescriptionList](description-list) - Key-value pair display - [GroupBox](group-box) - Grouped content with borders - [Root](root) - Window-level provider for themes, dialogs, and notifications - [Theme](theme) - Customize colors, typography, and light/dark appearance - [Dialog](dialog) - Dialog and modal windows - [Notification](notification) - Toast notifications - [Popover](popover) - Floating content display - [Resizable](resizable) - Resizable panels and containers - [Scrollable](scrollable) - Scrollable containers - [Sheet](sheet) - Slide-in panel from edges - [Sidebar](sidebar) - Navigation sidebar - [StatusBar](status-bar) - Bottom status bar with left/center/right regions - [Toolbar](toolbar) - Top toolbar with left/right regions and sizes ### Advanced Components - [Calendar](calendar) - Calendar display and navigation - [Carousel](carousel) - Browse through a set of related items - [Command](command) - Command palette for search and quick actions - [Chart](chart) - Data visualization charts (Line, Bar, Area, Pie, Candlestick) - [List](list) - List display with items - [Menu](menu) - Menu and context menu and dropdown menu. - [Settings](settings) - Settings UI - [DataTable](data-table) - High-performance data tables - [Dock](dock) - Production-ready dock layouts with tabs, splits, and persistent state - [Tabs](tabs) - Tabbed interface - [Tree](tree) - Hierarchical tree data display - [VirtualList](virtual-list) - Virtualized list for large datasets --- # Input Group Source: /versions/v0.7.1/component/input-group License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/input-group and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Use `InputGroup` to place text, icons, buttons, or toolbars around an input or textarea inside one frame. For a simple prefix or suffix, use [Input](/versions/v0.7.1/component/input). The examples below define views for an initialized GPUI Kit application. See [Getting Started](/versions/v0.7.1/docs/getting-started) for application setup. ## Input with a clear button Create an `InputState` once in your view and pass it to `InputGroupInput`. Subscribe to `InputEvent::Change` to refresh anything that depends on the text. Keep the returned `Subscription` in the view so the callback stays active. This view shows a character count and lets the user clear the input: ```rust use gpui_kit::{ AppContext as _, ClickEvent, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _, Subscription, Window, rems, }; use gpui_kit::assets::IconName; use gpui_kit::component::{ Disableable as _, Icon, input::{ InputEvent, InputGroup, InputGroupAddon, InputGroupAddonAlignment, InputGroupButton, InputGroupInput, InputGroupText, InputState, }, }; struct SearchField { query: Entity, _change: Subscription, } impl SearchField { fn new(window: &mut Window, cx: &mut Context) -> Self { let query = cx.new(|cx| InputState::new(window, cx).placeholder("Search…")); let change = cx.subscribe(&query, |_, _, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { cx.notify(); } }); Self { query, _change: change } } fn clear(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { self.query.update(cx, |state, cx| { state.set_value("", window, cx); state.focus(window, cx); }); cx.notify(); } } impl Render for SearchField { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let count = self.query.read(cx).value().chars().count(); InputGroup::new("search") .max_w(rems(24.)) .input(InputGroupInput::new(&self.query).aria_label("Search")) .addon(InputGroupAddon::new("search-icon") .child(Icon::new(IconName::Search).size_4())) .addon(InputGroupAddon::new("search-actions") .align(InputGroupAddonAlignment::InlineEnd) .child(InputGroupText::new().child(format!("{count} characters"))) .child(InputGroupButton::new("clear").label("Clear") .disabled(count == 0) .on_click(cx.listener(Self::clear)))) } } ``` Read or set the value through the same state: ```rust let value = self.query.read(cx).value(); self.query.update(cx, |state, cx| { state.set_value("gpui", window, cx); }); cx.notify(); ``` `InputEvent::Change` reports user edits. Setting a value with `set_value` does not emit this event; call `cx.notify()` when other content in your view must refresh after a programmatic update. ## Parts and alignment | Part | Use | | --- | --- | | `InputGroup` | Combine one input with any number of addons | | `InputGroupInput` | An [Input](/versions/v0.7.1/component/input) placed in the group, using `InputState` | | `InputGroupTextarea` | A [Textarea](/versions/v0.7.1/component/textarea) placed in the group, using `TextareaState` | | `InputGroupAddon` | Position text, icons, buttons, or custom content | | `InputGroupButton` | A [Button](/versions/v0.7.1/component/button) with compact input-group presentation | | `InputGroupText` | Display helper text, a prefix, suffix, or counter | `InputGroupInput` and `InputGroupTextarea` are the ordinary `Input` and `Textarea` types under the names the group uses for them, so every builder those controls have — `aria_label`, `content_type`, `on_paste`, `cleanable`, `mask_toggle`, `Styled` methods — works inside a group. The group removes the control's own border, background, and focus ring and draws them around the whole frame instead. Pass the input to `.input(...)` and each addon to `.addon(...)`. Use `.child(...)` or `.children(...)` inside an addon. A later `.input(...)` replaces the earlier input; repeated `.addon(...)` calls keep all addons. Set an addon's position with `.align(InputGroupAddonAlignment::...)`: | Alignment | Position | | --- | --- | | `InlineStart` (default) | Before the input | | `InlineEnd` | After the input | | `BlockStart` | Above the input row | | `BlockEnd` | Below the input row | You can combine all four positions. Addons on the same side and children within an addon appear in the order you add them. Give each part a stable, distinct ID. Clicking text, icons, or empty space in an addon focuses the input. For example, add a protocol prefix and domain suffix to a single-line input: ```rust InputGroup::new("website") .input(InputGroupInput::new(&self.query).aria_label("Website")) .addon(InputGroupAddon::new("protocol") .child(InputGroupText::new().child("https://"))) .addon(InputGroupAddon::new("domain") .align(InputGroupAddonAlignment::InlineEnd) .child(InputGroupText::new().child(".com"))) ``` ## Buttons, icons, and menus Use `.label(...)` for a text button or `.icon(...)` for an icon button. Give icon-only buttons an `.accessibility_label(...)`; `.tooltip(...)` adds a visible hint. ```rust InputGroupButton::new("clear-icon") .icon(IconName::X) .accessibility_label("Clear search") .tooltip("Clear search") .on_click(cx.listener(Self::clear)) ``` Buttons size through `Sizable` like every other control. `.xsmall()` is the default compact size and `.small()` the larger one; a button with only an icon is square at either size. `.medium()` and `.large()` keep the standard button sizes for a prominent action in a block addon. Buttons default to ghost styling. Import `button::ButtonVariants` to use `.primary()`, `.secondary()`, or `.danger()`. Use `.outline()` for an outline, `.disabled(true)` to disable an action, and `.loading(true)` to show progress and prevent repeated clicks. Clicking a button runs its action without moving focus back to the input afterwards. For an action menu, use `.dropdown_menu(...)` with the [menu API](/versions/v0.7.1/component/menu); `.dropdown_caret(true)` draws the caret after the label. For contextual help, pass an `InputGroupButton` to [Popover](/versions/v0.7.1/component/popover)'s `.trigger(...)`, then add the Popover to an addon. ## Textarea with a counter and submit action Use `TextareaState` with `InputGroupTextarea`. `.auto_grow(min, max)` grows the input between the given row counts; longer content scrolls. Use `.rows(n)` for a fixed row count or `InputGroupTextarea::h(...)` for a fixed height. This complete view counts characters, disables submission for empty or oversized drafts, and displays the submitted text below the composer. Submitting clears and focuses the textarea. ```rust use gpui_kit::{ AppContext as _, ClickEvent, Context, Entity, IntoElement, ParentElement as _, Render, SharedString, Styled as _, Subscription, Window, rems, }; use gpui_kit::component::{ Disableable as _, button::ButtonVariants as _, v_flex, input::{ InputEvent, InputGroup, InputGroupAddon, InputGroupAddonAlignment, InputGroupButton, InputGroupText, InputGroupTextarea, TextareaState, }, }; struct MessageComposer { message: Entity, submitted: Option, _change: Subscription, } impl MessageComposer { fn new(window: &mut Window, cx: &mut Context) -> Self { let message = cx.new(|cx| { TextareaState::new(window, cx) .placeholder("Write a message…") .auto_grow(2, 6) }); let change = cx.subscribe(&message, |_, _, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { cx.notify(); } }); Self { message, submitted: None, _change: change } } fn submit(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { let value = self.message.read(cx).value(); if value.trim().is_empty() || value.chars().count() > 280 { return; } self.submitted = Some(value); self.message.update(cx, |state, cx| { state.set_value("", window, cx); state.focus(window, cx); }); cx.notify(); } } impl Render for MessageComposer { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let value = self.message.read(cx).value(); let count = value.chars().count(); v_flex().max_w(rems(28.)).gap_2() .child(InputGroup::new("message") .invalid(count > 280) .input(InputGroupTextarea::new(&self.message).aria_label("Message")) .addon(InputGroupAddon::new("message-footer") .align(InputGroupAddonAlignment::BlockEnd) .child(InputGroupText::new().child(format!("{count}/280"))) .child(InputGroupButton::new("submit").ml_auto().primary().label("Submit") .disabled(value.trim().is_empty() || count > 280) .on_click(cx.listener(Self::submit))))) .children(self.submitted.as_ref().map(|text| format!("Submitted: {text}"))) } } ``` Use `BlockStart` for a heading or toolbar above the textarea. Addons stay in place while the text scrolls. See [Textarea](/versions/v0.7.1/component/textarea) for more text options. ## Disabled, read-only, and validation | Method | Effect | | --- | --- | | `.disabled(true)` | Disables the input and direct `InputGroupButton` children | | `.readonly(true)` | Prevents editing while allowing focus, selection, copying, and addon actions | | `.invalid(true)` | Shows an error state while allowing further edits | An input part with `.disabled(true)` also disables its group. Pass the disabled flag to custom interactive addon content and wrapped controls separately. Set `.invalid(...)` from your validation result and show an explanation next to the group. To reject particular edits, use [`InputState::validate`](/versions/v0.7.1/component/input). Give each input an `.aria_label(...)`, even if you also name the group. Use `.content_type(...)` on `InputGroupInput` for hints such as a URL or email address. Password masking is configured with `InputState::masked`. Both input parts support `.context_menu(...)` for a custom right-click menu. On touch devices, long-press the text to select a word, drag the selection handles, and use the edit menu to cut, copy, paste, or select all. In Rust, both input parts also support `.on_paste(...)` to handle clipboard images and files before text is inserted. Return `true` to consume the paste, or `false` to allow the default text insertion. The handler is not called while the input is disabled or read-only. See [Paste Hook](/versions/v0.7.1/component/input#paste-hook) for an attachment example and web limitations. ## Sizes and custom styles The default group size is Medium. Import `Sizable` to use `.xsmall()`, `.small()`, `.large()`, or `.with_size(Size::Medium)`; the size sets the frame height, the text size, and the insets the addons share with the control. Colors, corners, the focus ring, and the invalid ring follow your [Theme](/versions/v0.7.1/component/theme). Use `Styled` methods to set the group's width, spacing, and other appearance. The control keeps its own `Styled` methods for the text it edits, and each addon, button, and text part styles itself the same way: ```rust use gpui_kit::component::{ActiveTheme as _, Sizable as _, StyledExt as _}; InputGroup::new("styled-search") .small() .max_w(rems(24.)) .input(InputGroupInput::new(&self.query) .aria_label("Search") .px_3() .text_base()) .addon(InputGroupAddon::new("styled-actions") .align(InputGroupAddonAlignment::InlineEnd) .child(InputGroupButton::new("styled-clear").label("Clear").icon(IconName::X) .font_semibold() .on_click(cx.listener(Self::clear)))) ``` Placeholder, caret, and selection colors follow the Theme. Import `FocusableExt` and use `.focus_ring(false)` to hide the default ring. ## JavaScript Import the same parts from `gpui-component`. Create text states in `View.init`. Use `.value(...)` and `.on_change(...)` for a controlled input: ```javascript import { View } from "gpui-kit"; import { InputState, InputGroup, InputGroupInput, InputGroupAddon, InputGroupButton, } from "gpui-component"; export default class Search extends View { init() { this.input = InputState("Search…"); this.query = ""; } render() { return new InputGroup("search") .input(new InputGroupInput(this.input) .aria_label("Search").value(this.query) .on_change((value, cx) => { this.query = value; cx.notify(); })) .addon(new InputGroupAddon("actions").align("inline-end") .child(new InputGroupButton("clear").label("Clear") .disabled(this.query.length === 0) .on_click((_event, cx) => { this.query = ""; cx.notify(); }))); } } ``` Programmatic `.value(...)` updates do not call `on_change`. Setting the same value keeps the selection and undo history. Omit `.value(...)` to let the input keep its own value, and use `on_change(value, cx)` when you need to react to edits. `InputGroupTextarea` accepts `TextareaState` and supports `.rows(n)` and `.auto_grow(min, max)`. Both input parts provide `.placeholder(...)`. `InputGroupInput` also provides `.masked(bool)` and `.content_type(...)`, with values such as `email_address`, `url`, and `new_password`. Set group and button size with `.size("small")`; available values are `xsmall`, `small`, `medium`, and `large`. Button icons take an asset path, such as `.icon("icons/search.svg")`. Style methods apply to each part directly, as in Rust: ```javascript new InputGroupInput(this.input).px(12).text_base(); new InputGroupButton("clear").label("Clear").icon("icons/x.svg").font_semibold(); ``` Run `gpui-component-shell types ` to generate editor completion. ## Inline references To combine inline references with attachments or send buttons, pass an Input or Textarea containing tokens to `InputGroup`. You can customize its labels as usual: ```rust use gpui_kit::component::{ IconName, input::{InputToken, InputGroup, Textarea}, }; InputGroup::new("composer") .input(Textarea::new(&state) .token(|token, _, _| InputToken::new(token).icon(IconName::File))) ``` The JavaScript group controls also expose `token` and `on_token_click`. Retain the same input state across redraws; call `set_value` with saved content to restore a draft. See [atomic inline tokens](/versions/v0.7.1/component/input#atomic-inline-tokens). --- # Carousel Source: /versions/v0.7.1/component/carousel License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/carousel and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Carousel displays one or more related items in a snapping viewport. It supports horizontal and vertical layouts, keyboard navigation, pointer and trackpad gestures, looping, and controlled selection. ## Import ```rust use gpui_kit::Axis; use gpui_kit::component::carousel::{ Carousel, CarouselContent, CarouselEvent, CarouselItem, CarouselNext, CarouselPagination, CarouselPaginationItem, CarouselPrevious, CarouselState, }; ``` ## Usage Create one `CarouselState` for the content and pass it to every carousel part. ```rust let state = cx.new(|_| CarouselState::new(3)); Carousel::new("projects-carousel", &state) .child( CarouselContent::new(&state) .child(CarouselItem::new("project-1", 0, &state).child("Project one")) .child(CarouselItem::new("project-2", 1, &state).child("Project two")) .child(CarouselItem::new("project-3", 2, &state).child("Project three")), ) .child(CarouselPrevious::new(&state)) .child(CarouselNext::new(&state)) ``` `CarouselContent` owns the viewport and snap layout. `CarouselItem` identifies one logical slide. The previous and next controls automatically become disabled at the corresponding boundary. Keep the state's item count equal to the number of direct `CarouselItem` children. A state and its scroll handle belong to one viewport. ## Composition Build a Carousel from one content viewport, its items, and optional controls: ```text Carousel ├── CarouselContent │ ├── CarouselItem │ └── CarouselItem ├── CarouselPrevious └── CarouselNext ``` Constrain the Carousel with `.w_full().max_w_96()` on its root, or style `CarouselContent` when the viewport itself needs a custom width or height. Use `track_style` only for inner-track adjustments such as spacing. The root lays out its flow children as a column with a 16px gap, so a `CarouselPagination` placed after the content keeps its distance; restyle the root for another arrangement. ## Multiple items `CarouselItem` implements `Styled`. Set its flex basis to show more than one item in the viewport, and pair a negative leading margin on the content track with matching leading padding on every item to tune the gap between them. This is the same paired spacing model shadcn/ui uses. ```rust use gpui_kit::{ParentElement as _, StyleRefinement, Styled as _, relative}; let state = cx.new(|_| CarouselState::new(6)); CarouselContent::new(&state) .track_style(StyleRefinement::default().ml_neg_1()) .children((0..6).map(|index| { CarouselItem::new(("project", index), index, &state) .flex_basis(relative(1. / 3.)) .pl_1() .child(format!("Project {}", index + 1)) })) ``` The flex basis controls item geometry; it is separate from the semantic `Size` used by buttons and other controls. Horizontal carousels default to `.ml_neg_4()` on the content track and `.pl_4()` on items. Vertical carousels use the corresponding `.mt_neg_4()` and `.pt_4()` pair. Override both sides with the same spacing scale so the first item stays aligned with the viewport while the visual gap changes. ## Orientation Use `with_axis` when creating the state: ```rust let state = cx.new(|_| { CarouselState::new(3).with_axis(Axis::Vertical) }); ``` Horizontal carousels use Left and Right. Vertical carousels use Up and Down. Give vertical `CarouselContent` an explicit height so each full-height item has a viewport to snap within. The Carousel root is a tab stop, so keyboard navigation also works when optional controls are omitted. Home and End select the first and last items. Clicking inside the carousel or on one of its controls focuses it for keyboard navigation without drawing the focus ring; the ring appears only when focus arrives from the keyboard. ## Looping Enable looping to wrap navigation from the last item to the first: ```rust let state = cx.new(|_| CarouselState::new(5).with_looping(true)); ``` ## Controlled selection `CarouselState` can be controlled by application state. Use `with_selected_index` for the initial selection and `set_selected_index` for programmatic changes. ```rust let state = cx.new(|_| CarouselState::new(4).with_selected_index(1)); state.update(cx, |state, cx| { state.set_selected_index(3, cx); }); ``` Subscribe to `CarouselEvent::Change` when the application needs to mirror the selected item: ```rust cx.subscribe(&state, |this, _, event: &CarouselEvent, cx| { let CarouselEvent::Change(index) = event; this.selected_index = *index; cx.notify(); }); ``` ## Events | Event | Description | | --- | --- | | `CarouselEvent::Change(index)` | Emitted when user navigation selects a new item. | Keyboard navigation and previous/next controls use the same state transition and emit the same event. Pointer and trackpad gestures select the nearest snap point when the gesture ends. A mouse-wheel notch moves one item, and a gesture that begins at an edge scrolls the surrounding container instead. ## Pagination indicators Pagination is optional and does not impose one visual treatment. Compose indicators with `CarouselPaginationItem`, then style or fill each item as needed: ```rust CarouselPagination::new().children((0..3).map(|index| { CarouselPaginationItem::new(("project-page", index), index, &state) .child((index + 1).to_string()) })) ``` `CarouselPaginationItem` uses the same selection transition as pointer, keyboard, and previous/next navigation. ## Control size `CarouselPrevious`, `CarouselNext`, and `CarouselPaginationItem` implement `Sizable`. Apply the same semantic size to the controls when they should scale together: ```rust use gpui_kit::component::{Sizable as _, Size}; CarouselPrevious::new(&state).with_size(Size::Large); CarouselNext::new(&state).with_size(Size::Large); ``` Previous and next controls default to `Size::Medium`. Pagination items default to `Size::XSmall`. ## Custom controls `CarouselPrevious` and `CarouselNext` implement `ParentElement` and `Styled`. Without children they display the direction-appropriate chevron. Add a child to replace that visible content while preserving automatic navigation and disabled boundary states. `accessibility_label` also replaces the control's tooltip. ```rust use gpui_kit::ParentElement as _; CarouselPrevious::new(&state) .accessibility_label("Previous project") .child("Back"); CarouselNext::new(&state) .accessibility_label("Next project") .child("Forward"); ``` For a completely custom control, omit the corresponding Carousel part and compose any control with the public state API: ```rust use gpui_kit::ParentElement as _; use gpui_kit::component::{Disableable as _, button::Button}; let previous_state = state.clone(); let previous_disabled = !state.read(cx).has_previous(); Button::new("projects-previous") .label("Back") .disabled(previous_disabled) .on_click(move |_, _, cx| { previous_state.update(cx, |state, cx| { state.select_previous(cx); }); }) ``` ## Accessibility The carousel exposes a labelled region and each item reports its position within the set. Use `accessibility_label` when the default "Carousel" label does not describe the content. Carousel animation follows the application's reduced-motion preference. --- # Chart Source: /versions/v0.7.1/component/chart License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/chart and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A comprehensive charting library providing Line, Bar, Area, Pie, Radar, Candlestick, and Sankey charts for data visualization. The charts feature smooth animations, customizable styling, tooltips, legends, and automatic theming that adapts to your application's theme. ## Import ```rust use gpui_kit::component::chart::{ LineChart, BarChart, AreaChart, PieChart, RadarChart, CandlestickChart, SankeyChart, }; ``` ## Chart Types ### LineChart A line chart displays data points connected by straight line segments, perfect for showing trends over time. #### Basic Line Chart ```rust #[derive(Clone)] struct DataPoint { x: String, y: f64, } let data = vec![ DataPoint { x: "Jan".to_string(), y: 100.0 }, DataPoint { x: "Feb".to_string(), y: 150.0 }, DataPoint { x: "Mar".to_string(), y: 120.0 }, ]; LineChart::new(data) .x(|d| d.x.clone()) .y(|d| d.y) ``` #### Line Chart Variants ```rust // Basic curved line (default) LineChart::new(data) .x(|d| d.month.clone()) .y(|d| d.value) // Linear interpolation LineChart::new(data) .x(|d| d.month.clone()) .y(|d| d.value) .linear() // Step after interpolation LineChart::new(data) .x(|d| d.month.clone()) .y(|d| d.value) .step_after() // With dots at data points LineChart::new(data) .x(|d| d.month.clone()) .y(|d| d.value) .dot() // Custom stroke color LineChart::new(data) .x(|d| d.month.clone()) .y(|d| d.value) .stroke(cx.theme().success) ``` #### Tick Control ```rust // Show every tick LineChart::new(data) .x(|d| d.month.clone()) .y(|d| d.value) .tick_margin(1) // Show every 2nd tick LineChart::new(data) .x(|d| d.month.clone()) .y(|d| d.value) .tick_margin(2) ``` `LineChart` also takes `y_domain` and `point_count`; see Pinned Axis and Unfinished Series under AreaChart. #### Axes and Guides `LineChart` and `AreaChart` share these. `y_axis` shows tick labels at the y ticks, in a gutter left of the plot by default, which widens to fit the widest label, or over the plot with `y_axis_label_placement(AxisLabelPlacement::Inside)`. `y_tick_count` sets how many ticks there are, evenly spaced from the baseline to the top edge with both ends included; they place the horizontal grid lines too, and each label reads the value the scale puts at its height. The default of 5 is the grid the charts have always drawn. `y_tick_format` writes the label text from that value. `x_tick_count` labels only that many x values, spread evenly from the first to the last, instead of every `tick_margin`-th; with `point_count` set they spread over every point the axis is laid out for, so they stay put as the data grows. `grid_columns` adds vertical grid lines, `grid_dashed(false)` draws the grid solid, `reference_line` marks a value with a dashed line across the plot, drawn darker than the grid, and `y_padding` sets the space kept above the highest value and below the lowest, 10px and 0 by default. ```rust use gpui_kit::component::plot::AxisLabelPlacement; // An intraday chart: labels over the plot, a solid grid, the previous close marked AreaChart::new(minutes) .x(|d| d.time.clone()) .y(|d| d.price) .y_domain(low, high) .y_axis(true) .y_axis_label_placement(AxisLabelPlacement::Inside) .y_tick_count(3) .y_tick_format(|v| format!("{v:.2}")) .x_tick_count(3) .grid_columns(4) .grid_dashed(false) .reference_line(prev_close) .y_padding(6., 6.) ``` ### BarChart A bar chart uses rectangular bars to show comparisons among categories. Bars can be oriented vertically or horizontally via the `alignment` option. #### Basic Bar Chart ```rust BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) ``` #### Bar Chart Customization ```rust // Custom fill colors // // The `fill` closure receives the datum, the bar's bounds (in pixel space, // relative to the chart), the chart's bounds, and the bar's `BarAlignment`. // Any value convertible to `Background` may be returned (solid color, gradient, // pattern, etc.). BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .fill(|d, _bar_bounds, _chart_bounds, _alignment| d.color) // With value labels on bars BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .label(|d| format!("{}", d.value)) // Custom tick spacing BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .tick_margin(2) // Hide the band-axis line and labels BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .label_axis(false) ``` #### Bar Chart Gradient Fills For gradient fills aligned to the bar's orientation, use `fill_gradient`. The closure receives the datum, the chart's full data range, and a `chart_to_bar` helper that maps a chart-value coordinate to a bar-local gradient position (`0.0` is the bar's base, `1.0` is its tip). The gradient angle is derived from the bar's `BarAlignment` so stop-0 sits at the base and stop-1 at the tip. ```rust use gpui_kit::linear_color_stop; // Per-bar gradient: every bar fades from a translucent base to its full color // at the tip, regardless of its value. BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .fill_gradient(|d, _chart_range, _chart_to_bar| { let c = d.color; [ linear_color_stop(c.opacity(0.3), 0.0), linear_color_stop(c, 1.0), ] }) // Chart-wide gradient: each bar shows the slice of a single gradient // spanning the chart's full data range. Stops outside `[0, 1]` are clipped // to the bar with colors interpolated at the clip points. BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .fill_gradient(|d, chart_range, chart_to_bar| { let c = d.color; [ linear_color_stop(c.opacity(0.3), chart_to_bar(*chart_range.start())), linear_color_stop(c, chart_to_bar(*chart_range.end())), ] }) ``` `fill` and `fill_gradient` are mutually exclusive — setting one clears the other. #### Bar Chart Alignment `BarAlignment` controls the bar orientation and the side where the baseline sits. Import it from `gpui_kit::component::plot::shape`. ```rust use gpui_kit::component::plot::shape::BarAlignment; // Default: vertical bars growing upward from the bottom BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .alignment(BarAlignment::Bottom) // Vertical bars growing downward from the top BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .alignment(BarAlignment::Top) // Horizontal bars growing rightward from the left BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .alignment(BarAlignment::Left) // Horizontal bars growing leftward from the right BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .alignment(BarAlignment::Right) ``` #### Bar Chart Corner Radii Round the bar rectangles. Pass any value convertible into `Corners` — use a single `px(..)` for uniform rounding, or construct `Corners` manually to round only specific corners (e.g. just the tip end of each bar). ```rust use gpui_kit::{px, Corners}; // Uniform 4px rounded corners on every bar BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .corner_radii(px(4.)) // Round only the top corners (tip end for bottom-aligned bars) BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .corner_radii(Corners { top_left: px(4.), top_right: px(4.), bottom_left: px(0.), bottom_right: px(0.), }) ``` #### Bar Chart Negative Values Bars grow from zero rather than from the edge of the plot, so negative values extend to the opposite side of the zero line. The band-axis line follows zero, and each category label moves to whichever side its own bar leaves empty. No configuration is needed — a data set containing negative values renders this way. ```rust // `growth` may be negative; bars below the zero line are drawn downward BarChart::new(data) .band(|d| d.quarter.clone()) .value(|d| d.growth) .label(|d| format!("{:+.0}%", d.growth)) ``` #### Bar Chart Value Axis Show tick labels for the value scale with `value_axis`, and set how many ticks it carries with `value_tick_count`. The ticks are evenly spaced from the baseline to the far edge with both ends included, and drive both the grid lines and the tick labels, so the two always agree. `tick_margin`, by contrast, is a stride over the band-axis categories: `tick_margin(2)` keeps every second category label. ```rust // Value labels left of vertical bars, below horizontal ones BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .value_axis(true) // 7 ticks instead of the default 5 BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .value_axis(true) .value_tick_count(7) ``` `value_axis_label_placement(AxisLabelPlacement::Inside)` draws the labels over the plot beside their grid lines, so the bars keep the room a gutter would take, and `value_tick_format` writes their text. `band_count` lays the band axis out for more bands than there is data, so a short series keeps each bar's width and fills only the leading bands. `band_tick_count` labels only that many bands, spread evenly from the first to the last (over every band when `band_count` is set), and `grid_dashed(false)` draws the grid solid. ```rust use gpui_kit::component::plot::AxisLabelPlacement; // A value per day over the last 20 days, however many have data yet BarChart::new(days) .band(|d| d.date.clone()) .value(|d| d.value) .value_axis(true) .value_axis_label_placement(AxisLabelPlacement::Inside) .value_tick_count(2) .value_tick_format(|v| format!("{v:.2}")) .band_count(20) .band_tick_count(2) .grid_dashed(false) ``` #### Bar Chart Labels and Spacing `label_color` colors each bar's `label` text, so a count can take its bar's color instead of the foreground. `padding_inner` and `padding_outer` set the gap between bars and before the first and after the last, as shares of a band; they default to 0.4 and 0.2. `min_length` draws every bar at least that many pixels long, so an empty bucket still shows a stub on the baseline. ```rust // A distribution: narrow bars, counts in their bar's color, a stub for zero BarChart::new(buckets) .band(|d| d.range.clone()) .value(|d| d.count) .fill(|d, _, _, _| d.color) .label(|d| d.count.to_string()) .label_color(|d| d.color) .padding_inner(0.6) .min_length(2.) ``` A stub grows the way its bar's value would: away from the zero line, to the negative side for a negative value and to the positive side for zero. Vertical bars with a `label` keep a line of text clear above the tallest bar, so its label stays inside the chart. ### AreaChart An area chart displays quantitative data visually, similar to a line chart but with the area below the line filled. #### Basic Area Chart ```rust AreaChart::new(data) .x(|d| d.time.clone()) .y(|d| d.value) ``` #### Stacked Area Charts ```rust // Multi-series area chart AreaChart::new(data) .x(|d| d.date.clone()) .y(|d| d.desktop) // First series .stroke(cx.theme().chart_1) .fill(cx.theme().chart_1.opacity(0.4)) .y(|d| d.mobile) // Second series .stroke(cx.theme().chart_2) .fill(cx.theme().chart_2.opacity(0.4)) ``` #### Area Chart Styling ```rust use gpui_kit::{linear_gradient, linear_color_stop}; // With gradient fill AreaChart::new(data) .x(|d| d.month.clone()) .y(|d| d.value) .fill(linear_gradient( 0., linear_color_stop(cx.theme().chart_1.opacity(0.4), 1.), linear_color_stop(cx.theme().background.opacity(0.3), 0.), )) // Different interpolation styles AreaChart::new(data) .x(|d| d.month.clone()) .y(|d| d.value) .linear() // or .step_after() ``` #### Pinned Axis and Unfinished Series By default the y axis fits the data from zero. `y_domain` pins it to a range instead, so a price or a balance that never nears zero is not pressed flat against the top. `point_count` lays the x axis out for more points than the data has, so a series still in progress, such as today's intraday prices, fills only the leading part. `LineChart` takes both as well. ```rust // An intraday price thumbnail: 390 one-minute points in a US session. AreaChart::new(minutes) .x(|d| d.time.clone()) .y(|d| d.price) .linear() .y_domain(low, high) .point_count(390) .x_axis(false) .grid(false) .interactive(false) ``` A pinned range keeps the 10px of headroom the default leaves above the highest value, and the series are clipped to the plot, so a value outside the range stops at its edge. Nothing is drawn when `min` equals `max`, so widen a flat series before passing it in. A natural curve can swing past its highest and lowest points; prefer `linear` when the range is fitted tightly to the data. The i-th item of data sits on the i-th point, so the data has to be contiguous from the first point: a missing item shifts every later one a point to the left. ### PieChart A pie chart displays data as slices of a circular chart, ideal for showing proportions. #### Basic Pie Chart ```rust PieChart::new(data) .value(|d| d.amount as f32) .outer_radius(100.) ``` #### Donut Chart ```rust PieChart::new(data) .value(|d| d.amount as f32) .outer_radius(100.) .inner_radius(60.) // Creates donut effect ``` #### Pie Chart Customization ```rust // Custom colors PieChart::new(data) .value(|d| d.amount as f32) .outer_radius(100.) .color(|d| d.color) // With padding between slices PieChart::new(data) .value(|d| d.amount as f32) .outer_radius(100.) .inner_radius(60.) .pad_angle(4. / 100.) // 4% padding ``` ### RadarChart A radar chart displays multivariate data as closed polygons around a center, ideal for comparing multiple series across several dimensions. #### Basic Radar Chart ```rust RadarChart::new(data) .label(|d| d.month.clone()) .value(|d| d.desktop) ``` #### Multiple Series ```rust // Each `.value()` call adds a series, paired with the matching // `.stroke()` / `.fill()` calls. Colors default to the theme // chart colors, cycled per series. RadarChart::new(data) .label(|d| d.month.clone()) .value(|d| d.desktop) .stroke(cx.theme().chart_1) .value(|d| d.mobile) .stroke(cx.theme().chart_2) ``` #### Element Labels `label` accepts either a string or a custom element. Return `element.into_any_element()` to render anything you like around the outer ring — an icon, several lines, per-dimension colors. ```rust RadarChart::new(data) .label({ let foreground = cx.theme().foreground; let muted_foreground = cx.theme().muted_foreground; move |d: &Device| { v_flex() .items_center() .child(div().text_xs().text_color(foreground).child(d.month.clone())) .child( div() .text_xs() .text_color(muted_foreground) .child(format!("{:.0}", d.desktop)), ) .into_any_element() } }) .value(|d| d.desktop) ``` Each label is measured at its natural size and pushed radially outward from its dimension, so even a tall one clears the outer ring. Element labels style themselves, so `.label_color()` does not apply to them, and they supply no tooltip title (a string label does). The ring is not shrunk to make room: the default outer radius is 40% of the chart's height, so a label much taller than a line of text needs a smaller `.outer_radius()` to keep it inside the chart's bounds. #### Radar Chart Customization ```rust // Vertex dots and custom fill RadarChart::new(data) .label(|d| d.month.clone()) .value(|d| d.desktop) .stroke(cx.theme().chart_2) .fill(cx.theme().chart_2.opacity(0.2)) .dot() // Fixed outer ring value and grid rings RadarChart::new(data) .label(|d| d.month.clone()) .value(|d| d.desktop) .max_value(400.) .grid_levels(5) .outer_radius(120.) ``` ### CandlestickChart A candlestick chart displays financial data using OHLC (Open, High, Low, Close) values, perfect for visualizing stock prices and market trends. #### Basic Candlestick Chart ```rust #[derive(Clone)] struct StockPrice { pub date: String, pub open: f64, pub high: f64, pub low: f64, pub close: f64, } let data = vec![ StockPrice { date: "Jan".to_string(), open: 100.0, high: 110.0, low: 95.0, close: 105.0 }, StockPrice { date: "Feb".to_string(), open: 105.0, high: 115.0, low: 100.0, close: 112.0 }, StockPrice { date: "Mar".to_string(), open: 112.0, high: 120.0, low: 108.0, close: 115.0 }, ]; CandlestickChart::new(data) .x(|d| d.date.clone()) .open(|d| d.open) .high(|d| d.high) .low(|d| d.low) .close(|d| d.close) ``` #### Candlestick Chart Customization ```rust // Adjust body width ratio (default: 0.6) CandlestickChart::new(data) .x(|d| d.date.clone()) .open(|d| d.open) .high(|d| d.high) .low(|d| d.low) .close(|d| d.close) .body_width_ratio(0.4) // Narrower bodies // Custom tick spacing CandlestickChart::new(data) .x(|d| d.date.clone()) .open(|d| d.open) .high(|d| d.high) .low(|d| d.low) .close(|d| d.close) .tick_margin(2) // Show every 2nd tick ``` #### Candlestick Chart Colors A candle that closed above its open is drawn in the theme's `chart.bullish` color and one that closed at or below it in `chart.bearish`. Markets that read a rise as red swap them: ```rust CandlestickChart::new(data) .x(|d| d.date.clone()) .open(|d| d.open) .high(|d| d.high) .low(|d| d.low) .close(|d| d.close) .bullish(cx.theme().danger) .bearish(cx.theme().success) ``` ### SankeyChart A sankey diagram visualizes flows between nodes, ideal for financial statements, energy flows, and traffic analysis. The layout algorithm mirrors [d3-sankey](https://github.com/d3/d3-sankey). #### Basic Sankey Chart ```rust use gpui_kit::component::plot::shape::SankeyLink; #[derive(Clone)] struct FlowNode { pub name: SharedString, } let nodes = vec![ FlowNode { name: "Revenue".into() }, FlowNode { name: "Gross Profit".into() }, FlowNode { name: "Cost".into() }, ]; // Links reference nodes by their index in `nodes`. let links = vec![ SankeyLink::new(0, 1, 45.0), SankeyLink::new(0, 2, 55.0), ]; SankeyChart::new(nodes, links) .node_label(|d| d.name.clone()) .value_label(|_, value| format!("{:.1}", value).into()) ``` The value label is drawn above the name label. Its closure receives the node's computed throughput (the larger of incoming and outgoing flow). #### Node Alignment ```rust use gpui_kit::component::plot::shape::SankeyAlign; // Justify (default): nodes without outgoing links move to the last column SankeyChart::new(nodes, links).node_align(SankeyAlign::Justify) // Left: nodes stay at their topological depth SankeyChart::new(nodes, links).node_align(SankeyAlign::Left) // Also available: SankeyAlign::Right, SankeyAlign::Center ``` #### Sankey Chart Styling ```rust SankeyChart::new(nodes, links) .node_width(8.) // Node bar width (default: 10) .node_padding(20.) // Vertical gap between nodes in a column (default: 16) .node_corner_radius(px(2.)) // Corner radius of node bars (default: 0) .node_color(|d| d.color) // Per-node color; defaults to the theme chart palette .link_opacity(0.4) // Ribbon opacity (default: 0.3) .min_link_width(2.) // Minimum ribbon thickness (default: 1) .iterations(10) // Layout relaxation passes (default: 6) ``` Link ribbons are filled with a horizontal gradient from the source node color to the target node color. #### Custom Labels For full control over the label lines, use `labels` — one `SankeyLabel` per line, top to bottom, each with its own color and font size. It takes precedence over `node_label`/`value_label` when set. For example, a financial-statement label with a year-over-year change line: ```rust use gpui_kit::component::chart::SankeyLabel; SankeyChart::new(nodes, links).labels(move |d: &FlowNode, value| { let arrow = if d.growth >= 0. { "▲" } else { "▼" }; let growth_color = if d.growth >= 0. { green } else { red }; vec![ SankeyLabel::new(format!("{:.1}", value)), SankeyLabel::new(format!("{} {:+.2}%", arrow, d.growth)).color(growth_color), SankeyLabel::new(d.name.clone()).color(muted), ] }) ``` Line color defaults to the theme foreground and font size to 10; the chart keeps handling placement, alignment and margin reservation. A first/last-column label wider than its reserved margin is truncated with a trailing ellipsis rather than drawn outside the plot, so break or shorten long labels yourself if you want the full text on multiple lines. #### Compressing Large Value Ranges Node heights are linear in flow value by default, so a large value range (e.g. 200:1) leaves the small flows nearly invisible and the dominant flow oversized. Set `value_scale(SankeyValueScale::Sqrt)` to compress the range — the component sizes nodes by the square root of the value, so small flows stay visible without pre-transforming the data, and labels still receive the raw values: ```rust use gpui_kit::component::plot::shape::SankeyValueScale; SankeyChart::new(nodes, links).value_scale(SankeyValueScale::Sqrt) ``` Every node stays exactly filled by its ribbons under either scale, so children always match their parent's height. ## Hover and Tooltips Every chart hit-tests the cursor, shows a tooltip for the datum under it, and emphasizes that datum the way the chart's kind calls for. Nothing opts in: ```rust LineChart::new(data) .x(|d| d.date.clone()) .y(|d| d.value) .name("Desktop") // The series name in the tooltip row ``` | Chart | On hover | | --- | --- | | `LineChart`, `AreaChart` | A crosshair and a dot per series glide along the line to the hovered point; the dot grows a halo. | | `BarChart` | A highlight band the width of a bar slides to the hovered bar, and the other bars fade behind it. | | `PieChart` | The hovered slice lifts out of the ring and the others fade; the tooltip shows the value and its share, via `tooltip_value` if one is set. | | `RadarChart` | A dot per series glides along the polygon to the hovered spoke. | | `CandlestickChart` | A highlight band slides to the hovered candle; the tooltip lists open, high, low and close. | | `SankeyChart` | The links of the hovered node keep their color while the rest fade; the tooltip shows the node's name and throughput, via `tooltip_name` / `tooltip_value` if set. | The tooltip box follows the cursor, flipping toward the center of the plot near each edge. `AreaChart` and `RadarChart` take one `.name()` per series, called after the matching `.y()` / `.value()`. A tooltip row reads as a swatch, a name and a value. `PieChart::tooltip_name` and `SankeyChart::tooltip_name` set that name from the datum under the cursor — the slice's name, the node's name — which for a chart showing one number per datum is what the row wants. Unset, a pie falls back to `name`, the single name the whole series carries, and a sankey's row carries no name at all: a swatch and a number with a gap between them. `name` cannot stand in for it. It says what the numbers measure, the same for every slice, so it can never say which slice the tooltip is about. Titling the tooltip from `label` cannot either, since `label` also draws the leader lines around the ring, and a sankey's `node_label` likewise writes beside the node. `PieChart::tooltip_value` replaces the text of its row, which the default writes as the raw value and its share. Set it wherever the raw number is not what a reader should see — a value that is already a ratio reads as `0.35 (35.0%)` otherwise, and a chart drawn from adjusted values, such as a floor that keeps a hairline slice visible, would report the adjustment as though it were the datum: ```rust PieChart::new(holdings) .value(|d| d.ratio.max(MIN_VISIBLE)) // Drawn with a floor .tooltip_name(|d| d.name.clone()) // Named without leader lines .tooltip_value(|d, _, _| pct(d.ratio)) // Read as it truly is ``` ### Tooltip Content `LineChart`, `AreaChart`, `BarChart`, `RadarChart` and `CandlestickChart` title their tooltip with the hovered x, band or dimension value and write each row's value as the raw number. `tooltip_title` and `tooltip_value` replace that text from the datum under the cursor, and `tooltip_value_color` colors each row's value, such as green or red by its sign. Both closures receive the datum and the value the row reads; on `AreaChart`, `RadarChart` and `CandlestickChart`, which show several rows, they also receive the row's index between the two — the series in the order they were added, or open, high, low and close for a candlestick: ```rust BarChart::new(flows) .band(|d| d.month.clone()) .value(|d| d.net) .tooltip_title(|d| format!("{} 2025", d.month).into()) .tooltip_value(|_, value| format!("${value:.2}").into()) .tooltip_value_color(move |_, value| if value >= 0. { gain } else { loss }) ``` For a layout the title and rows cannot express, such as a table, `tooltip_content` draws the box's content from the datum. The chart's hover marks — crosshair, dots, highlight band — and where the box sits stay the chart's, and the three text options no longer apply: ```rust AreaChart::new(data) .x(|d| d.month.clone()) .y(|d| d.last_year) .y(|d| d.revenue) .tooltip_content(|d, _, _| { v_flex() .child(d.month.clone()) .child(format!("2025: {}", d.revenue)) .child(format!("2024: {}", d.last_year)) }) ``` ### Identity All of it is keyed on an `ElementId`, which a chart takes from the source location it was constructed at — unique for a chart written out once, which is nearly every chart. Where one construction site renders several charts as siblings, name them apart, or they share one hover state and one cache: ```rust shares.iter().enumerate().map(|(i, share)| PieChart::new(share.clone()).id(("share", i))) ``` A `GlobalElementId` is the whole id stack, so siblings that already carry an id of their own — rows drawn by `List` or `uniform_list`, say — separate the charts beneath them without help. ### Turning it off `interactive(false)` takes the whole layer away, hitbox included, the way Highcharts' `enableMouseTracking` or ECharts' `silent` does. Reach for it in two places: a chart that only decorates, and a chart something else is drawn over: ```rust AreaChart::new(placeholder).interactive(false) // A skeleton, a thumbnail AreaChart::new(range).interactive(false) // A backdrop under drag handles ``` The second matters because a plain hitbox does not block the one behind it: an element painted over an interactive chart is hovered *and so is the chart*, so the crosshair keeps tracking under it. The chart has to stand down. A chart that is off keeps its id, so it still draws in and keeps its cache. ### Motion The emphasis is animated with the styled layer's motion tokens (`cx.theme().motion_tokens()`), which the theme projects onto gpui-base as its [`PlotMotion`](/versions/v0.7.1/base/plot#motion): pointers — crosshair, band, dots — follow the hovered datum on a fast spring, a pie slice lifts on the control spring, and the whole overlay fades in when the cursor lands on a datum and out after it leaves. The motion honors the operating system's reduced-motion preference, under which every value adopts its target at once. ### Appear The data draws in the first time a chart is painted, over 1000 ms on `easeOutQuart`, Chart.js' default curve: lines, areas, candlesticks and a sankey diagram are revealed from the left the way ECharts, Highcharts and Recharts draw them, bars grow out of the zero line together, a pie sweeps clockwise from its first slice, and a radar grows out of its center. Axes, grid lines and tick labels are there from the first frame, and the tooltip waits until the data is whole. The appear runs once per id. New data paints in place, so a chart fed live quotes does not draw in again on every tick. To replay it when the chart starts showing something else, such as another symbol or period, hand it a key: ```rust LineChart::new(candles).appear_key((&symbol, period)) ``` A chart that stops being painted forgets its appear, so one in a virtual list draws in again every time it scrolls back into view. Wrap the list in a [`PlotAppearScope`](/versions/v0.7.1/base/plot#appear-scope) to have each chart draw in once and show whole when it returns; name the scope after what the list shows, such as a conversation or a watchlist, so moving to another one, or closing the view, draws its charts in afresh: ```rust PlotAppearScope::new(("rows", list_id), list(state, render_row).flex_1()) ``` Where a chart should never draw in, turn the appear off: ```rust LineChart::new(intraday).interactive(false).appear(false) ``` Reduced motion skips the appear. ### Caching A chart also keeps its heavy geometry across frames, since it repaints on every frame it is on screen: line and area strokes and pie slices stay tessellated while their projected points are unchanged, and a sankey diagram keeps its placement while its data, settings and size are unchanged. This cache hangs off the same id, so charts sharing one share the cache and thrash it — another reason to name siblings apart. A pie or radar being drawn in is a new shape on every frame, so it tessellates afresh until the appear ends. ### Custom Plots A custom [`Plot`] opts in by hand — `Plot::id` defaults to `None` there. The trait, `PlotElement` and hover tracking come from [gpui-base](/versions/v0.7.1/base/plot), so a plot written against `gpui_kit::base::plot` works here unchanged. Return an id from `Plot::id`, resolve the datum under the cursor in `Plot::tooltip_state`, and build the overlay in `Plot::tooltip`. To draw in, keep the `PlotAppear` that `Plot::appear` hands over on every frame and paint with its progress. The `Tooltip` returned there animates the hover on its own, the same way the built-in charts do: the whole overlay fades with the hover, the crosshair and dots glide to each hovered datum on the pointer spring, adopting it on the frame the cursor lands, and a dot's `halo` grows as the hover fades in. A crosshair glides along the axis it marks only, so a line that also follows the cursor keeps up with it. Pass the data point itself; the tooltip does the rest: ```rust fn tooltip(&self, state: &TooltipState, cursor: Point, bounds: Bounds, _: &mut Window, cx: &mut App) -> Option { Some( Tooltip::new(cursor, bounds.size) .cross_line(CrossLine::new(state.cross_line).band(px(24.))) .title("Title") .row(cx.theme().chart_1, "Series", "42") .into_any_element(), ) } ``` A row's value takes a color with `value_color`, which colors the row added last, such as a change by its sign; call it right after that row. `plain_row` adds a row without a swatch, for a figure no series on the plot draws, such as a total or a ratio; beside series rows its label lines up with theirs: ```rust Tooltip::new(cursor, bounds.size) .title("Apr 5") .row(desktop, "Desktop", "373") .row(mobile, "Mobile", "187") .plain_row("Total", "560") .plain_row("Change", "+12%") .value_color(gain) ``` To emphasize the plot's own graphics as well — fade the bars around the hovered one, lift a slice — implement `Plot::hover`, which runs each frame before `tooltip` and `paint` with the hovered [`PlotHover`]. It carries the `TooltipState` and lingers after the cursor leaves while `hover.progress()` eases back to zero, so sample the motion there and keep the result on `self`. `hover.glide` follows a position on the same spring the tooltip uses; hand the result to the crosshair and turn the tooltip's own glide off with `Tooltip::glide(false)`, so it springs once: ```rust fn hover(&mut self, hover: Option<&PlotHover>, window: &mut Window, cx: &mut App) { self.band_center = hover.map(|hover| hover.glide(("my-plot", "band"), hover.state().cross_line.x, window, cx)); } ``` ## Data Structures ### Example Data Types ```rust // Time series data #[derive(Clone)] struct DailyDevice { pub date: String, pub desktop: f64, pub mobile: f64, } // Category data with styling #[derive(Clone)] struct MonthlyDevice { pub month: String, pub desktop: f64, pub color_alpha: f32, } impl MonthlyDevice { pub fn color(&self, base_color: Hsla) -> Hsla { base_color.alpha(self.color_alpha) } } // Financial data #[derive(Clone)] struct StockPrice { pub date: String, pub open: f64, pub high: f64, pub low: f64, pub close: f64, pub volume: u64, } // Sankey flow: nodes are referenced by index (from gpui_kit::component::plot::shape) pub struct SankeyLink { pub source: usize, pub target: usize, pub value: f64, } ``` ## Chart Configuration ### Container Setup ```rust fn chart_container( title: &str, chart: impl IntoElement, center: bool, cx: &mut Context, ) -> impl IntoElement { v_flex() .flex_1() .h_full() .border_1() .border_color(cx.theme().border) .rounded(cx.theme().radius_lg) .p_4() .child( div() .when(center, |this| this.text_center()) .font_semibold() .child(title.to_string()), ) .child( div() .when(center, |this| this.text_center()) .text_color(cx.theme().muted_foreground) .text_sm() .child("Data period label"), ) .child(div().flex_1().py_4().child(chart)) .child( div() .when(center, |this| this.text_center()) .font_semibold() .text_sm() .child("Summary statistic"), ) .child( div() .when(center, |this| this.text_center()) .text_color(cx.theme().muted_foreground) .text_sm() .child("Additional context"), ) } ``` ### Theme Integration ```rust // Charts automatically use theme colors let chart = LineChart::new(data) .x(|d| d.date.clone()) .y(|d| d.value) .stroke(cx.theme().chart_1); // Uses theme chart colors // Available theme chart colors (`chart.1` … `chart.5` in the theme file): // cx.theme().chart_1 … cx.theme().chart_5 ``` ## API Reference - [LineChart] - [BarChart] - [AreaChart] - [PieChart] - [RadarChart] - [CandlestickChart] - [SankeyChart] ## Examples ### Sales Dashboard ```rust #[derive(Clone)] struct SalesData { month: String, revenue: f64, profit: f64, region: String, } fn sales_dashboard(data: Vec, cx: &mut Context) -> impl IntoElement { v_flex() .gap_4() .child( h_flex() .gap_4() .child( chart_container( "Monthly Revenue", LineChart::new(data.clone()) .x(|d| d.month.clone()) .y(|d| d.revenue) .stroke(cx.theme().chart_1) .dot(), false, cx, ) ) .child( chart_container( "Profit Breakdown", PieChart::new(data.clone()) .value(|d| d.profit as f32) .outer_radius(80.) .color(|d| match d.region.as_str() { "North" => cx.theme().chart_1, "South" => cx.theme().chart_2, "East" => cx.theme().chart_3, "West" => cx.theme().chart_4, _ => cx.theme().chart_5, }), true, cx, ) ) ) .child( chart_container( "Regional Performance", BarChart::new(data) .band(|d| d.region.clone()) .value(|d| d.revenue) .fill(|d, _, _, _| match d.region.as_str() { "North" => cx.theme().chart_1, "South" => cx.theme().chart_2, "East" => cx.theme().chart_3, "West" => cx.theme().chart_4, _ => cx.theme().chart_5, }) .label(|d| format!("${:.0}k", d.revenue / 1000.)), false, cx, ) ) } ``` ### Multi-Series Time Chart ```rust #[derive(Clone)] struct DeviceUsage { date: String, desktop: f64, mobile: f64, tablet: f64, } fn device_usage_chart(data: Vec, cx: &mut Context) -> impl IntoElement { chart_container( "Device Usage Over Time", AreaChart::new(data) .x(|d| d.date.clone()) .y(|d| d.desktop) .stroke(cx.theme().chart_1) .fill(linear_gradient( 0., linear_color_stop(cx.theme().chart_1.opacity(0.4), 1.), linear_color_stop(cx.theme().background.opacity(0.3), 0.), )) .y(|d| d.mobile) .stroke(cx.theme().chart_2) .fill(linear_gradient( 0., linear_color_stop(cx.theme().chart_2.opacity(0.4), 1.), linear_color_stop(cx.theme().background.opacity(0.3), 0.), )) .y(|d| d.tablet) .stroke(cx.theme().chart_3) .fill(linear_gradient( 0., linear_color_stop(cx.theme().chart_3.opacity(0.4), 1.), linear_color_stop(cx.theme().background.opacity(0.3), 0.), )) .tick_margin(3), false, cx, ) } ``` ### Financial Chart ```rust #[derive(Clone)] struct StockData { date: String, price: f64, volume: u64, } #[derive(Clone)] struct StockOHLC { date: String, open: f64, high: f64, low: f64, close: f64, } fn stock_chart(ohlc_data: Vec, price_data: Vec, cx: &mut Context) -> impl IntoElement { v_flex() .gap_4() .child( chart_container( "Stock Price - Candlestick", CandlestickChart::new(ohlc_data.clone()) .x(|d| d.date.clone()) .open(|d| d.open) .high(|d| d.high) .low(|d| d.low) .close(|d| d.close) .tick_margin(3), false, cx, ) ) .child( chart_container( "Stock Price - Line", LineChart::new(price_data.clone()) .x(|d| d.date.clone()) .y(|d| d.price) .stroke(cx.theme().chart_1) .linear() .tick_margin(5), false, cx, ) ) .child( chart_container( "Trading Volume", BarChart::new(price_data) .band(|d| d.date.clone()) .value(|d| d.volume as f64) .fill(|d, _, _, _| { if d.volume > 1000000 { cx.theme().chart_1 } else { cx.theme().muted_foreground.opacity(0.6) } }) .tick_margin(5), false, cx, ) ) } ``` ## Customization Options ### Color Schemes ```rust // Theme-based colors (recommended) LineChart::new(data) .x(|d| d.x.clone()) .y(|d| d.y) .stroke(cx.theme().chart_1) // Custom color palette let colors = [ cx.theme().success, cx.theme().warning, cx.theme().destructive, cx.theme().info, cx.theme().chart_1, ]; BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .fill(|d, _, _, _| colors[d.category_index % colors.len()]) ``` ### Responsive Design ```rust // Container with responsive sizing div() .flex_1() .min_h(px(300.)) .max_h(px(600.)) .w_full() .child( LineChart::new(data) .x(|d| d.x.clone()) .y(|d| d.y) ) ``` ### Grid and Axis Styling Charts automatically include: - Grid lines with dashed appearance, in the theme's `chart.grid` color (a translucent `border` when a theme leaves it unset) - X-axis labels with smart positioning - Y-axis scaling starting from zero - Responsive tick spacing based on `tick_margin` ## Performance Considerations ### Large Datasets ```rust // For large datasets, consider data sampling let sampled_data: Vec<_> = data .iter() .step_by(5) // Show every 5th point .cloned() .collect(); LineChart::new(sampled_data) .x(|d| d.date.clone()) .y(|d| d.value) .tick_margin(3) // Reduce tick density ``` ### Memory Optimization ```rust // Use efficient data accessors LineChart::new(data) .x(|d| d.date.clone()) // Clone only when necessary .y(|d| d.value) // Direct field access ``` ## Integration Examples ### With State Management ```rust struct ChartComponent { data: Vec, chart_type: ChartType, time_range: TimeRange, } impl ChartComponent { fn render_chart(&self, cx: &mut Context) -> impl IntoElement { match self.chart_type { ChartType::Line => LineChart::new(self.filtered_data()) .x(|d| d.date.clone()) .y(|d| d.value) .into_any_element(), ChartType::Bar => BarChart::new(self.filtered_data()) .band(|d| d.date.clone()) .value(|d| d.value) .into_any_element(), ChartType::Area => AreaChart::new(self.filtered_data()) .x(|d| d.date.clone()) .y(|d| d.value) .into_any_element(), } } fn filtered_data(&self) -> Vec { self.data .iter() .filter(|d| self.time_range.contains(&d.date)) .cloned() .collect() } } ``` ### Real-time Updates ```rust struct LiveChart { data: Vec, max_points: usize, } impl LiveChart { fn add_data_point(&mut self, point: DataPoint) { self.data.push(point); if self.data.len() > self.max_points { self.data.remove(0); // Remove oldest point } } fn render(&self, cx: &mut Context) -> impl IntoElement { LineChart::new(self.data.clone()) .x(|d| d.timestamp.clone()) .y(|d| d.value) .linear() .dot() } } ``` [LineChart]: https://docs.rs/gpui-component/latest/gpui_component/chart/struct.LineChart.html [BarChart]: https://docs.rs/gpui-component/latest/gpui_component/chart/struct.BarChart.html [AreaChart]: https://docs.rs/gpui-component/latest/gpui_component/chart/struct.AreaChart.html [PieChart]: https://docs.rs/gpui-component/latest/gpui_component/chart/struct.PieChart.html [RadarChart]: https://docs.rs/gpui-component/latest/gpui_component/chart/struct.RadarChart.html [CandlestickChart]: https://docs.rs/gpui-component/latest/gpui_component/chart/struct.CandlestickChart.html --- # Editor Source: /versions/v0.7.1/component/editor License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/editor and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `Editor` is the styled source-code control. Use [`Input`](/versions/v0.7.1/component/input) for single-line values and [`Textarea`](/versions/v0.7.1/component/textarea) for ordinary multi-line text. ## Import ```rust use gpui_kit::component::input::{Editor, EditorState, TabSize}; ``` ## Language editing rules `LanguageConfig` describes a language; `.auto_close(bool)` and `.smart_indent(bool)` are independent editor preferences. Changing languages or replacing rules does not reset either preference. Automatic closing, skip-over, and paired Backspace use `auto_closing_pairs`. Enter uses `brackets` and `indentation_rules`, so it can still split an existing pair when automatic closing is disabled. ```rust use gpui_kit::component::input::{ AutoClosingPair, BracketPair, language_config::LanguageConfig, SyntaxContext, set_language_config, }; let rules = LanguageConfig::default() .brackets([BracketPair::new("{", "}"), BracketPair::new("(", ")")]) .auto_closing_pairs([ AutoClosingPair::new("{", "}") .not_in([SyntaxContext::String, SyntaxContext::Comment]), AutoClosingPair::new("(", ")") .not_in([SyntaxContext::String, SyntaxContext::Comment]), ]) .auto_close_before(";:.,=}])>"); set_language_config("rust", rules, cx); let editor = cx.new(|cx| { EditorState::new(window, cx) .language("rust") .auto_close(true) .smart_indent(true) }); ``` `set_language_config` replaces the configuration for a language in the current application. Existing editors use the replacement on their next edit, including within the same event handler. Aliases share configurations: `python`, `py`, and `pyi` refer to the same language even without its grammar feature. Custom configurations survive component initialization. Exact custom grammar registrations take precedence over built-in aliases and retain their original case. Unknown languages use `LanguageConfig::default()`. Component installs a `LanguageProvider` for language names, editing defaults, and editor-owned syntax providers. Syntax selection follows the language on the first edit and after language changes, independently of rendering. Base clients can install their own service with `set_language_provider`; ordinary Component clients only need `set_language_config`. Grammar resources are available as `highlighter::GrammarConfig`; its existing `highlighter::LanguageConfig` name remains compatible. Pairs use strings, including multi-character delimiters. `auto_closing_pairs` is optional: `None` uses the structural `brackets`, while `Some(vec![])` disables all automatic pairs. Its builder sets `Some`. Whitespace and end-of-document always allow automatic insertion; `auto_close_before` lists other allowed following characters. `not_in` requires a syntax-context provider; without one, the Base editor reports `Code`. The styled editor supplies a provider when the language's Tree-sitter grammar is enabled. `IndentationRules::new(increase, decrease)` accepts two compiled `regex::Regex` patterns. On Enter, the increase pattern tests text before the cursor and the decrease pattern tests text after it. Without an increase pattern, structural opening brackets provide the default indentation. These rules do not reformat existing lines or pasted text. Python's language defaults additionally recognize a trailing colon; unknown languages use structural brackets only. This is the supported subset of Monaco-style language configuration, not a loader for Monaco JSON or Tree-sitter `.scm` files. Selection-surrounding and custom `onEnterRules` are not part of this interface yet. ## Basic usage ```rust let editor = cx.new(|cx| { EditorState::new(window, cx) .language("rust") .line_number(true) .folding(true) .tab_size(TabSize { tab_size: 4, hard_tabs: false, }) .default_value("fn main() {\n println!(\"Hello\");\n}") }); Editor::new(&editor).h(px(320.)) ``` The language set via `.language()` selects syntax highlighting. Enable the matching Cargo feature, such as `tree-sitter-rust` or `tree-sitter-markdown`; use `tree-sitter-languages` to bundle all built-in grammars. ## Editor options ```rust let editor = cx.new(|cx| { EditorState::new(window, cx) .language("json") .line_number(true) .folding(true) .show_whitespaces(true) .default_value(source) }); ``` ## Keyboard shortcuts and column selection These defaults apply while the editor is focused. On macOS, Option is the Alt modifier. Linux uses no Super/Win bindings for these operations. | Operation | macOS | Linux | Windows | | --- | --- | --- | --- | | Add a cursor above / below | Cmd+Option+Up / Down | Alt+Shift+Up / Down | Ctrl+Alt+Up / Down | | Extend every selection by one character | Shift+Left / Right | Shift+Left / Right | Shift+Left / Right | | Extend every selection by one word | Option+Shift+Left / Right | Ctrl+Shift+Left / Right | Ctrl+Shift+Left / Right | | Add a cursor with the mouse | Option+left click | Alt+left click | Alt+left click | | Select a rectangular block | Option+Shift+left drag | Alt+Shift+left drag | Alt+Shift+left drag | | Keep only the active cursor | Escape | Escape | Escape | Linux also accepts Ctrl+Alt+left drag for rectangular selection, matching Ghostty, and Alt+Shift+Left / Right for word selection. Windows additionally accepts Alt+Shift+Left / Right for character selection. Alt/Option+left drag works as a column-selection shortcut on all three platforms: a click adds a cursor, while dragging builds a new block from the mouse-down position. Holding Alt/Option over the editor shows a `+` crosshair. Selection gestures that include Alt take priority over Ctrl/Cmd-click go-to-definition. A block creates one selection per display row, clipped to the available text on short rows. Typing or deleting edits all selections. Releasing the mouse ends the drag; Escape keeps the active cursor (an open context menu handles Escape first). Adding cursors with Up / Down is additive: reversing direction does not shrink the block's height. This is multi-cursor editing with mouse column selection, not a persistent Vim Visual Block mode. During keyboard input, carets remain visible; blinking resumes after 300 ms without input. Linux desktop shortcuts can intercept key combinations before the editor sees them. In particular, Ctrl+Alt+Up / Down is not bound by default on Linux because some desktops use it to switch workspaces. The shortcuts above refer to logical modifiers after any keyboard remapping. ## Search The editor has a built-in search panel. Press `Ctrl-F` (Windows/Linux) or `Cmd-F` (macOS) while the editor is focused to open it. `Enter` jumps to the next match, `Shift+Enter` to the previous one, `Escape` closes the panel. ```rust // Open the find panel programmatically editor.update(cx, |state, cx| { state.open_search(false, cx); }); // Close it editor.update(cx, |state, cx| { state.close_search(cx); }); ``` Search is enabled by default for `Editor`. To disable it: ```rust editor.update(cx, |state, cx| { state.set_searchable(false, cx); }); ``` A read-only editor can still be searched — the replace UI is hidden automatically. ### Custom search UI The search engine is usable without the panel, so an application can draw its own search bar on top of the editor's matching, highlighting, scrolling and replacing. `set_search_query` starts a search; the editor highlights the matches until `close_search`. An editor that is not `searchable` never opens the built-in panel and leaves `Ctrl-F` / `Cmd-F` to its ancestors, so the application can bind the shortcut to its own search field. ```rust let editor = cx.new(|cx| EditorState::new(window, cx).searchable(false)); // Search from the application's own field editor.update(cx, |state, cx| { state.set_search_query("needle", true, cx); }); // Navigate; each call scrolls the match into view editor.update(cx, |state, cx| { state.next_search_match(cx); state.previous_search_match(cx); }); // Describe the matches: "2/5" let matcher = &editor.read(cx).search_session().matcher; let label = matcher.label(); let count = matcher.len(); let current = matcher.current(); // None without matches // Replace, when the editor is editable editor.update(cx, |state, cx| { state.replace_current_search_match("replacement", window, cx); state.replace_all_search_matches("replacement", window, cx); }); // End the search and its highlights editor.update(cx, |state, cx| { state.close_search(cx); }); ``` Take the shortcut on the view that owns the search field: ```rust use gpui_kit::component::input::Search; div() .on_action(cx.listener(|this: &mut Self, _: &Search, window, cx| { this.search.update(cx, |search, cx| search.focus(window, cx)); })) .child(Editor::new(&this.editor)) ``` ## Decorations ```rust let decorations = editor.update(cx, |state, cx| { state.create_decorations_collection(initial_decorations, cx) }); ``` Keep the returned `TextDecorationCollection` to update or clear that owner's text styles. Ranges follow edits; dropping the handle does not remove decorations. ### Geometric range decorations Use a separate `RangeDecorationCollection` for continuous fills or one-logical-pixel frames. These are paint-only annotations: they do not reserve inline space, add widgets, intercept pointer events, or change keyboard focus. ```rust use gpui_kit::component::input::{RangeDecoration, RangeDecorationStyle}; let review_ranges = editor.update(cx, |state, cx| { state.create_range_decorations_collection( vec![ RangeDecoration::new(0..8).with_style(RangeDecorationStyle::Fill), RangeDecoration::new(12..24), // Frame is the default. ], cx, ) }); review_ranges.set(vec![RangeDecoration::new(4..16)], cx); review_ranges.append(vec![RangeDecoration::new(20..28)], cx); let tracked_ranges = review_ranges.get_ranges(cx); review_ranges.clear(cx); // Keep the collection available for reuse. review_ranges.dispose(cx); // Invalidate this handle and all its clones. ``` Each collection owns only its own entries, so separate extensions cannot overwrite one another. Dropping a handle leaves its collection in the editor; `dispose` releases it permanently. Calls on disposed collections or a dropped editor are no-ops. Individual decorations do not require an ID. Ranges are half-open UTF-8 **byte offsets**, not character indices or line numbers. Start/end offsets are clipped outward to valid character boundaries; empty, reversed, and entirely out-of-document ranges are discarded. Both text and geometric decorations share these tracking rules: - Inserting at either edge does not grow the range; inserting inside it does. - Replacements clip overlapping anchors to the replaced span; deleting an entire range removes the decoration. - Undo/redo, `set_value`, and `replace_all` (including formatting) apply the same edit transforms. Annotations are **not** snapshots in undo history: undoing a deletion does not resurrect a removed decoration, and undoing a replacement does not recover its former interior anchors. Reset the collection from your semantic source when that distinction matters. - Folding changes only visual projection. Hidden-only ranges are not painted; visible portions remain clipped to the viewport and follow soft-wrapped glyphs. Fills paint behind frames, and both sit below the selection and glyphs. Within one style, later collections/items paint over earlier ones. Without `with_color`, frames use the editor foreground and fills use that color at 12% opacity, so the fallback follows theme changes. An explicit color is owned by the application. Visible-range queries use an interval index and skip folded buffer spans; they do not scan every decoration per frame. Setting/appending entries rebuilds that collection's index; edits update affected collections linearly without re-sorting. The Editor showcase's **Decorations** tab demonstrates both collection types. ## Value and events ```rust let source = editor.read(cx).value(); editor.update(cx, |state, cx| { state.set_value(new_source, window, cx); }); cx.subscribe(&editor, |this, state, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { this.source = state.read(cx).value(); cx.notify(); } }); ``` ## Font The editor paints its code in the theme's monospace font — `mono_font_family` at `mono_font_size` — with rows 1.5 times the font size. That is only the default: a text style set on the editor refines over it, and the gutter and row height follow the size. The theme's platform default (`Menlo`, `Consolas`, `DejaVu Sans Mono`) is checked against the installed fonts when the theme loads and swapped for an installed monospace font, or `.SystemUIFont`, when it is missing; a family you set yourself is used as-is. ```rust Editor::new(&editor).text_sm() Editor::new(&editor) .font_family("JetBrains Mono") .text_size(px(15.)) ``` These are the ordinary [`Styled`](https://docs.rs/gpui/latest/gpui/trait.Styled.html) methods every element has, so `font_weight` and `line_height` work the same way. ## Appearance ```rust Editor::new(&editor) .h(px(480.)) .bordered(true) .disabled(false) .readonly(false) .aria_label("Rust source") ``` Use `readonly` to preview a file without allowing changes. Unlike `disabled`, a read-only editor keeps the normal appearance and still can be focused, selected, copied and searched, it only rejects the changes made by the user. The programmatic APIs such as `set_value` keep working. ```rust Editor::new(&editor).readonly(true) ``` Editor focus does not add the single-line Input focus-border treatment. The gutter, current-line background, and scrollbars are painted as one aligned editor surface. Input-only adornments such as `prefix`, `suffix`, mask toggle, and clear button are intentionally absent. Compose toolbars and actions around `Editor`. --- # Image Source: /versions/v0.7.1/component/image License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/image and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. GPUI's `img()` draws an image, and `svg()` draws a single-color icon. GPUI Kit re-exports both from `gpui_kit`. This page shows the patterns an application uses most; [Images](/versions/v0.7.1/docs/image) explains sources, loading, sizing, `svg()`, caching, and HTTP caching in detail. ## Start with a working image This complete native `src/main.rs` uses an icon already bundled by GPUI Kit, so it needs no extra asset file. Add `gpui-kit = "0.7.1"` to `Cargo.toml`. The same asset is shown as a color-preserving image and as a monochrome SVG. This complete native `src/main.rs` uses an icon already bundled by GPUI Kit, so it needs no extra asset file. Add `gpui-kit = "0.7.1"` to `Cargo.toml`. The same asset is shown as a color-preserving image and as a theme-colored monochrome SVG; the difference is explained below. ```rust use gpui_kit::*; use gpui_kit::assets::Assets; struct ImageExample; impl Render for ImageExample { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div() .flex() .gap_4() .child( img("icons/inbox.svg") .id("inbox-image") .size(px(64.)) .object_fit(ObjectFit::Contain) .with_loading(|| div().child("Loading image...").into_any_element()) .with_fallback(|| div().child("Image unavailable").into_any_element()), ) .child(svg().path("icons/inbox.svg").size(px(64.))) } } fn main() { gpui_kit::application().with_assets(Assets).run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| ImageExample) }) .expect("Failed to open window"); }); } ``` `with_assets(Assets)` registers the default component icons; register your own `AssetSource` to embed your images (see [Icons & Assets](/versions/v0.7.1/docs/assets)). A string such as `"images/cover.png"` is an asset key, a `Path` reads a file, and a URL is fetched through the App's `HttpClient`; see [Sources](/versions/v0.7.1/docs/image#sources). ## Common patterns A thumbnail that fills a fixed box and crops its edges: ```rust img("images/cover.png") .w(px(320.)) .h(px(180.)) .object_fit(ObjectFit::Cover) ``` A banner that follows the parent width and reserves its height before the image arrives: ```rust img("images/banner.webp") .w_full() .aspect_ratio(16. / 9.) .object_fit(ObjectFit::Cover) ``` A remote image with loading and failure states. The `.id(...)` is required for the loading state to appear: ```rust img("https://example.com/avatar.png") .id("avatar") .size(px(48.)) .rounded_full() .with_loading(|| div().child("Loading image...").into_any_element()) .with_fallback(|| div().child("Image unavailable").into_any_element()) ``` Use `ObjectFit::Contain` (the default) for logos and diagrams that must stay fully visible. To keep a multicolor SVG's colors, draw it with `img()`; for an icon that follows the theme, use [Icon](/versions/v0.7.1/component/icon). See [Size and fit](/versions/v0.7.1/docs/image#size-and-fit) and [img() or svg()](/versions/v0.7.1/docs/image#img-or-svg). ## Gallery with selection A thumbnail that changes the main image is a control. Give it a real `Button` so keyboard activation and an accessible name work, and keep the selected index in the view's retained state. This complete `src/main.rs` uses three icons already shipped with GPUI Kit; replace the source array with your own registered image keys for a photo gallery. Run it with the same `gpui-kit = "0.7.1"` dependency as the first example. ```rust use gpui_kit::*; use gpui_kit::assets::Assets; use gpui_kit::base::Selectable; use gpui_kit::component::button::Button; const PICTURES: [(&str, &str); 3] = [ ("icons/inbox.svg", "Inbox"), ("icons/book-open.svg", "Book"), ("icons/gallery-vertical-end.svg", "Gallery"), ]; struct Gallery { selected: usize, } impl Render for Gallery { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let (source, name) = PICTURES[self.selected]; div() .flex() .flex_col() .gap_3() .p_4() .child(format!("Selected: {name}")) .child( img(source) .id("gallery-main") .w(px(360.)) .h(px(240.)) .object_fit(ObjectFit::Contain) .with_fallback(|| div().child("Image unavailable").into_any_element()), ) .child( div().flex().gap_2().children( PICTURES.iter().enumerate().map(|(index, (source, name))| { Button::new(format!("gallery-thumbnail-{index}")) .accessibility_label(format!("Show {name}")) .selected(index == self.selected) .child( img(*source) .size(px(40.)) .object_fit(ObjectFit::Contain), ) .on_click(cx.listener(move |this, _, _, cx| { this.selected = index; cx.notify(); })) }), ), ) } } fn main() { gpui_kit::application().with_assets(Assets).run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| Gallery { selected: 0 }) }) .expect("Failed to open window"); }); } ``` The selected button has an explicit selected state, and the visible `Selected: ...` text reports the current choice. In a product gallery, use meaningful labels such as `Show front view` rather than a file name. If the collection can be empty, render an empty-state message before indexing it. If image sources can be removed or reordered, store a stable domain ID instead of an index and resolve it during render. ## Accessibility - Pair an informative image with visible text or an accessible description in the surrounding UI. A file name is not a description. Decorative images need no narration. - Make an image that triggers a command a real control, such as a `Button`, so it has a name, focus, and keyboard activation. - Keep the loading and failure views in the same box as the image, so nearby content does not move. ## Learn more - [Images](/versions/v0.7.1/docs/image): every `img()` source, loading and decoding, `svg()`, and troubleshooting. - [Caches for decoded images](/versions/v0.7.1/docs/image#caches-for-decoded-images): scoped and custom `ImageCache`s. - [Cache remote images over HTTP](/versions/v0.7.1/docs/image#cache-remote-images-over-http): reuse downloads across views and launches. --- # Empty Source: /versions/v0.7.1/component/empty License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/empty and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `Empty` presents missing content, empty results, and first-use states. Its named slots provide the layout and visual hierarchy; the application decides when to show it and owns the state and actions of its children. The component is stateless and lives entirely in GPUI Component, using its theme and native controls. ## Import ```rust use gpui_kit::{ParentElement as _, Styled as _, rems}; use gpui_kit::assets::IconName; use gpui_kit::component::{ ActiveTheme as _, Icon, Sizable as _, avatar::{Avatar, AvatarGroup}, button::{Button, ButtonVariants as _}, empty::{ Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyMediaVariant, EmptyTitle, }, input::{Input, InputState}, link::Link, }; ``` Import the component through `gpui_kit::component::empty`; GPUI's own `gpui_kit::Empty` is a separate element that renders nothing. ## Basic usage ```rust Empty::new() .header( EmptyHeader::new() .media( EmptyMedia::new() .with_variant(EmptyMediaVariant::Icon) .child(Icon::new(IconName::Folder)), ) .title(EmptyTitle::new().child("No projects yet")) .description( EmptyDescription::new() .child("Create your first project to get started."), ), ) .content( EmptyContent::new() .flex_row() .flex_wrap() .justify_center() .gap_2() .child(Button::new("create-project").label("Create project…")) .child( Button::new("import-project") .outline() .label("Import project…"), ), ) .child( Link::new("empty-help") .href("https://gpui-kit.com/docs/getting-started") .text_sm() .child("Learn more"), ) ``` Attach normal Button callbacks for application actions. The extra root child appears after `EmptyContent`, so a help link can remain separate from the primary content group. ## Anatomy | Part | Composition | Purpose | | --- | --- | --- | | `Empty` | `.header(EmptyHeader)`, `.content(EmptyContent)`, `.child(...)` | Overall alignment and spacing | | `EmptyHeader` | `.media(EmptyMedia)`, `.title(EmptyTitle)`, `.description(EmptyDescription)` | Media and explanatory content | | `EmptyMedia` | `.with_variant(...)`, `.child(...)` | Icon, image, avatar, or arbitrary media | | `EmptyTitle` | `.child(...)` | Title text or custom content | | `EmptyDescription` | `.child(...)` | Wrapping text or rich supporting content | | `EmptyContent` | `.child(...)` | Actions, inputs, or other controls | All parts have `new()` and `Default` constructors and implement `Styled`. All parts except `EmptyHeader` implement `ParentElement`. Named slots are optional and have replacement semantics: calling `.header(...)` twice keeps the second header. Rendering always places the header before the content, and media before title before description, regardless of the order in which those setters are called. Direct root children are appended after both named slots, in their own insertion order; they are not inserted into the content slot. Replacing a slot leaves the other slots and extra root children intact. ## Outline The default Empty has a transparent background and no visible border. Add a border through `Styled`; its default border style is dashed. ```rust Empty::new() .border_1() .header( EmptyHeader::new() .title(EmptyTitle::new().child("Cloud storage is empty")) .description( EmptyDescription::new() .child("Upload files to access them anywhere."), ), ) ``` Use `.border_color(...)` to refine its semantic color. ## Background Apply a semantic surface directly, without adding a component variant: ```rust Empty::new() .bg(cx.theme().muted.opacity(0.3)) .header( EmptyHeader::new() .title(EmptyTitle::new().child("No notifications")) .description( EmptyDescription::new() .child("New notifications will appear here."), ), ) ``` ## Avatar The default media variant adds no frame, background, or fixed size. An existing Avatar retains its own image, fallback, size, and appearance. ```rust EmptyHeader::new() .media( EmptyMedia::new().child( Avatar::new() .name("Alex Morgan") .src("https://avatars.githubusercontent.com/u/5518?v=4"), ), ) .title(EmptyTitle::new().child("Alex is offline")) .description( EmptyDescription::new() .child("Leave a message for Alex to read when they're back."), ) ``` ## Avatar group Multiple avatars use the same media slot. The group owns avatar overlap and size; Empty does not inspect or modify its children. ```rust EmptyHeader::new() .media( EmptyMedia::new().child( AvatarGroup::new() .child(Avatar::new().name("Alex Morgan")) .child(Avatar::new().name("Taylor Lee")) .child(Avatar::new().name("Sam Chen")), ), ) .title(EmptyTitle::new().child("No team members")) .description( EmptyDescription::new() .child("Invite your team to collaborate on this project."), ) ``` ## Inputs and custom content Retain an `Entity` in the owning view, then compose the existing Input in `EmptyContent`: ```rust EmptyContent::new() .child( Input::new(&self.search) .prefix(Icon::new(IconName::Search).size_4()) .cleanable(true), ) .child( EmptyDescription::new() .child("Search by name or try a different keyword."), ) ``` The application handles input events and switches between results and Empty. Each rendered input retains its own state entity and focus. Empty has no input state, validation, submission, or loading policy. ## Constrained layouts Refine the root and the individual slots together to build a compact, leading-aligned empty state: ```rust Empty::new() .max_w(rems(20.)) .p_4() .items_start() .text_left() .header( EmptyHeader::new() .items_start() .title(EmptyTitle::new().child("No shared files")) .description( EmptyDescription::new() .child("Add files so your team can review and edit them together."), ), ) .content( EmptyContent::new() .items_start() .child(Button::new("add-files").outline().label("Add files…")), ) ``` The root fills the available width and can grow within a flex layout. Header and content use the available width up to 24 rem. Text wraps naturally, and Empty does not clip its children or own a scroll region. The parent supplies the viewport and any required scrolling. Custom media should fit its container; action rows can use `.flex_wrap()` when space is constrained. ## Styling defaults | Part | Default | | --- | --- | | Root | Centered column, `p_6()`, `gap_4()`, theme `radius_tokens().xl` | | Header | `gap_2()`, centered items, maximum width 24 rem | | Media | Centered column sized to its content, `mb_2()`, does not shrink | | Icon media | `size_8()`, muted background, foreground text, theme `radius_tokens().lg` | | Title | `text_sm()`, medium weight | | Description | `text_sm()`, line height 1.625, muted foreground | | Content | Centered column, `gap_2p5()`, `text_sm()`, maximum width 24 rem | Instance styles override defaults and media-variant styles. Icon media supplies a one-rem font size that an unsized GPUI Component `Icon` inherits; an explicit icon size is preserved. Arbitrary SVG/image children keep their own sizing. Typography uses the application's font and rem scale. GPUI's native wrapping and letter spacing apply; CSS `text-balance` and `tracking-tight` are not reimplemented by this component. Empty does not create focus targets or automatically announce itself as an alert or live status. Its Button and Input children retain their normal focus and keyboard behavior. Choose application commands as Buttons and external resources as Links. --- # List Source: /versions/v0.7.1/component/list License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/list and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A powerful List component that provides a virtualized, searchable list interface with support for sections, headers, footers, selection states, and infinite scrolling. The component is built on a delegate pattern that allows for flexible data management and custom item rendering. ## Import ```rust use gpui_kit::component::list::{List, ListState, ListDelegate, ListItem, ListEvent, ListSeparatorItem}; use gpui_kit::component::IndexPath; ``` ## Usage ### Basic List To create a list, you need to implement the `ListDelegate` trait for your data: ```rust struct MyListDelegate { items: Vec, selected_index: Option, } impl ListDelegate for MyListDelegate { type Item = ListItem; fn items_count(&self, _section: usize, _cx: &App) -> usize { self.items.len() } fn render_item( &mut self, ix: IndexPath, _window: &mut Window, _cx: &mut Context>, ) -> Option { self.items.get(ix.row).map(|item| { ListItem::new(ix) .child(Label::new(item.clone())) .selected(Some(ix) == self.selected_index) }) } fn set_selected_index( &mut self, ix: Option, _window: &mut Window, cx: &mut Context>, ) { self.selected_index = ix; cx.notify(); } } // Create the list let delegate = MyListDelegate { items: vec!["Item 1".into(), "Item 2".into(), "Item 3".into()], selected_index: None, }; /// Create a list state. let state = cx.new(|cx| ListState::new(delegate, window, cx)); ``` Now use [List] to render list: ```rs div().child(List::new(&state)) ``` ### List with Sections **Note:** Sections with `items_count` of 0 will be automatically hidden (no header or footer will be rendered for empty sections). ```rust impl ListDelegate for MyListDelegate { type Item = ListItem; fn sections_count(&self, _cx: &App) -> usize { 3 // Number of sections } fn items_count(&self, section: usize, _cx: &App) -> usize { match section { 0 => 5, 1 => 3, 2 => 7, _ => 0, } } fn render_section_header( &mut self, section: usize, _window: &mut Window, cx: &mut Context>, ) -> Option { let title = match section { 0 => "Section 1", 1 => "Section 2", 2 => "Section 3", _ => return None, }; Some( h_flex() .px_2() .py_1() .gap_2() .text_sm() .text_color(cx.theme().muted_foreground) .child(Icon::new(IconName::Folder)) .child(title) ) } fn render_section_footer( &mut self, section: usize, _window: &mut Window, cx: &mut Context>, ) -> Option { Some( div() .px_2() .py_1() .text_xs() .text_color(cx.theme().muted_foreground) .child(format!("End of section {}", section + 1)) ) } } ``` ### List Items with Icons and Actions ```rust fn render_item( &mut self, ix: IndexPath, _window: &mut Window, cx: &mut Context>, ) -> Option { self.items.get(ix.row).map(|item| { ListItem::new(ix) .child( h_flex() .items_center() .gap_2() .child(Icon::new(IconName::File)) .child(Label::new(item.title.clone())) ) .suffix(|_, _| { Button::new("action") .ghost() .small() .icon(IconName::MoreHorizontal) }) .selected(Some(ix) == self.selected_index) .on_click(cx.listener(move |this, _, window, cx| { this.delegate_mut().select_item(ix, window, cx); })) }) } ``` ### List with Search The list automatically includes a search input by default. Implement `perform_search` to handle queries: And you should use `searchable(true)` when creating the `ListState` to show search input. ```rust impl ListDelegate for MyListDelegate { fn perform_search( &mut self, query: &str, _window: &mut Window, _cx: &mut Context>, ) -> Task<()> { // Filter items based on query self.filtered_items = self.all_items .iter() .filter(|item| item.to_lowercase().contains(&query.to_lowercase())) .cloned() .collect(); Task::ready(()) } } let state = cx.new(|cx| ListState::new(delegate, window, cx).searchable(true)); List::new(&state) ``` ### List with Loading State ```rust impl ListDelegate for MyListDelegate { fn loading(&self, _cx: &App) -> bool { self.is_loading } fn render_loading( &mut self, _window: &mut Window, _cx: &mut Context>, ) -> impl IntoElement { // Custom loading view v_flex() .justify_center() .items_center() .py_4() .child(Skeleton::new().h_4().w_full()) .child(Skeleton::new().h_4().w_3_4()) } } ``` ### Infinite Scrolling ```rust impl ListDelegate for MyListDelegate { fn has_more(&self, _cx: &App) -> bool { self.has_more_data } fn load_more_threshold(&self) -> usize { 20 // Trigger when 20 items from bottom } fn load_more(&mut self, window: &mut Window, cx: &mut Context>) { if self.is_loading { return; } self.is_loading = true; cx.spawn_in(window, async move |view, window| { // Simulate API call Timer::after(Duration::from_secs(1)).await; view.update_in(window, |view, _, cx| { // Add more items view.delegate_mut().load_more_items(); view.delegate_mut().is_loading = false; cx.notify(); }); }).detach(); } } ``` ### List Events ```rust // Subscribe to list events let _subscription = cx.subscribe(&state, |_, _, event: &ListEvent, _| { match event { ListEvent::Select(ix) => { println!("Item selected at: {:?}", ix); } ListEvent::Confirm(ix) => { println!("Item confirmed at: {:?}", ix); } ListEvent::Cancel => { println!("Selection cancelled"); } } }); ``` ### Different Item Styles ```rust // Basic item with hover effect ListItem::new(ix) .child(Label::new("Basic Item")) .selected(is_selected) // Item with check icon ListItem::new(ix) .child(Label::new("Checkable Item")) .check_icon(IconName::Check) .confirmed(is_confirmed) // Disabled item ListItem::new(ix) .child(Label::new("Disabled Item")) .disabled(true) // Separator item ListSeparatorItem::new() .child( div() .h_px() .w_full() .bg(cx.theme().border) ) ``` ### Drag and Drop Reordering `ListItem` implements GPUI's `InteractiveElement` and `StatefulInteractiveElement` traits, so all native interaction APIs such as `on_drag`, `on_drop`, `drag_over` and `on_hover` are directly available: ```rust #[derive(Clone)] struct DragItem { ix: IndexPath, name: SharedString, } impl Render for DragItem { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { // The preview element that follows the cursor while dragging. div() .px_2() .py_1() .bg(cx.theme().accent) .text_color(cx.theme().accent_foreground) .rounded(cx.theme().radius) .child(self.name.clone()) } } // In `render_item` of your `ListDelegate`: ListItem::new(ix) .child(Label::new(item.name.clone())) .on_drag(DragItem { ix, name: item.name.clone() }, |drag, _, _, cx| { cx.new(|_| drag.clone()) }) .drag_over::(|style, _, _, cx| style.bg(cx.theme().drop_target)) .on_drop(cx.listener(move |this, drag: &DragItem, _, cx| { this.delegate_mut().move_item(drag.ix, ix); cx.notify(); })) ``` ### Custom Empty State ```rust impl ListDelegate for MyListDelegate { fn render_empty(&mut self, _window: &mut Window, cx: &mut Context>) -> impl IntoElement { v_flex() .size_full() .justify_center() .items_center() .gap_2() .child(Icon::new(IconName::Search).size_16().text_color(cx.theme().muted_foreground)) .child( Label::new("No items found") .text_color(cx.theme().muted_foreground) ) .child( Label::new("Try adjusting your search terms") .text_sm() .text_color(cx.theme().muted_foreground.opacity(0.7)) ) } } ``` ## Configuration Options ### List Configuration ```rust List::new(&state) .max_h(px(400.)) // Set maximum height .scrollbar_visible(false) // Hide scrollbar .paddings(Edges::all(px(8.))) // Set internal padding ``` ### Scrolling Control ```rust // Scroll to specific item state.update(cx, |state, cx| { state.scroll_to_item( IndexPath::new(0).section(1), // Row 0 of section 1 ScrollStrategy::Center, window, cx, ); }); // Scroll to selected item state.update(cx, |state, cx| { state.scroll_to_selected_item(window, cx); }); // Set selected index without scrolling state.update(cx, |state, cx| { state.set_selected_index(Some(IndexPath::new(5)), window, cx); }); ``` ## Examples ### File Browser List ```rust struct FileBrowserDelegate { files: Vec, selected: Option, } #[derive(Clone)] struct FileInfo { name: String, is_directory: bool, size: Option, } impl ListDelegate for FileBrowserDelegate { type Item = ListItem; fn render_item(&mut self, ix: IndexPath, window: &mut Window, cx: &mut Context>) -> Option { self.files.get(ix.row).map(|file| { let icon = if file.is_directory { IconName::Folder } else { IconName::File }; ListItem::new(ix) .child( h_flex() .items_center() .justify_between() .w_full() .child( h_flex() .items_center() .gap_2() .child(Icon::new(icon)) .child(Label::new(file.name.clone())) ) .when_some(file.size, |this, size| { this.child( Label::new(format_size(size)) .text_sm() .text_color(cx.theme().muted_foreground) ) }) ) .selected(Some(ix) == self.selected) }) } } ``` ### Contact List with Sections ```rust struct ContactListDelegate { contacts_by_letter: BTreeMap>, selected: Option, } impl ListDelegate for ContactListDelegate { type Item = ListItem; fn sections_count(&self, _cx: &App) -> usize { self.contacts_by_letter.len() } fn render_section_header(&mut self, section: usize, _window: &mut Window, cx: &mut Context>) -> Option { let letter = self.contacts_by_letter.keys().nth(section)?; Some( div() .px_3() .py_2() .bg(cx.theme().background) .border_b_1() .border_color(cx.theme().border) .child( Label::new(letter.to_string()) .text_lg() .text_color(cx.theme().accent_foreground) .font_weight(FontWeight::BOLD) ) ) } } ``` --- # Skeleton Source: /versions/v0.7.1/component/skeleton License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/skeleton and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. The Skeleton component displays animated placeholder content while actual content is loading. It provides visual feedback to users that content is being loaded and helps maintain layout structure during loading states. ## Import ```rust use gpui_kit::component::skeleton::Skeleton; ``` ## Usage ### Basic Skeleton ```rust Skeleton::new() ``` ### Text Line Skeleton ```rust // Single line of text Skeleton::new() .w(px(250.)) .h_4() .rounded_md() // Multiple text lines v_flex() .gap_2() .child(Skeleton::new().w(px(250.)).h_4().rounded_md()) .child(Skeleton::new().w(px(200.)).h_4().rounded_md()) .child(Skeleton::new().w(px(180.)).h_4().rounded_md()) ``` ### Circle Skeleton ```rust // Avatar placeholder Skeleton::new() .size_12() .rounded_full() // Profile picture placeholder Skeleton::new() .w(px(64.)) .h(px(64.)) .rounded_full() ``` ### Rectangle Skeleton ```rust // Card image placeholder Skeleton::new() .w(px(250.)) .h(px(125.)) .rounded_md() // Button placeholder Skeleton::new() .w(px(120.)) .h(px(40.)) .rounded_md() ``` ### Different Shapes ```rust // Text content Skeleton::new().w(px(200.)).h_4().rounded_sm() // Square image Skeleton::new().size_20().rounded_md() // Wide banner Skeleton::new().w_full().h(px(200.)).rounded_lg() // Small icon Skeleton::new().size_6().rounded_md() ``` ### Secondary Variant ```rust // Use secondary color (more subtle) Skeleton::new() .secondary() .w(px(200.)) .h_4() .rounded_md() ``` ## Animation The Skeleton component includes a built-in pulse animation that: - Runs continuously with a 2-second duration - Uses a bounce easing function with ease-in-out - Animates opacity from 100% to 50% and back - Automatically repeats to indicate loading state The animation cannot be disabled as it's essential for indicating loading state. ## Sizes The Skeleton component doesn't have predefined size variants. Instead, use gpui's sizing utilities: ```rust // Height utilities Skeleton::new().h_3() // 12px height Skeleton::new().h_4() // 16px height Skeleton::new().h_5() // 20px height Skeleton::new().h_6() // 24px height // Width utilities Skeleton::new().w(px(100.)) // 100px width Skeleton::new().w(px(200.)) // 200px width Skeleton::new().w_full() // Full width Skeleton::new().w_1_2() // 50% width // Square sizes Skeleton::new().size_4() // 16x16px Skeleton::new().size_8() // 32x32px Skeleton::new().size_12() // 48x48px Skeleton::new().size_16() // 64x64px ``` ## Examples ### Loading Profile Card ```rust v_flex() .gap_4() .p_4() .border_1() .border_color(cx.theme().border) .rounded(cx.theme().radius_lg) .child( h_flex() .gap_3() .items_center() .child(Skeleton::new().size_12().rounded_full()) // Avatar .child( v_flex() .gap_2() .child(Skeleton::new().w(px(120.)).h_4().rounded_md()) // Name .child(Skeleton::new().w(px(100.)).h_3().rounded_md()) // Email ) ) .child( v_flex() .gap_2() .child(Skeleton::new().w_full().h_4().rounded_md()) // Bio line 1 .child(Skeleton::new().w(px(200.)).h_4().rounded_md()) // Bio line 2 ) ``` ### Loading Article List ```rust v_flex() .gap_6() .children((0..3).map(|_| { h_flex() .gap_4() .child(Skeleton::new().w(px(120.)).h(px(80.)).rounded_md()) // Thumbnail .child( v_flex() .gap_2() .flex_1() .child(Skeleton::new().w_full().h_5().rounded_md()) // Title .child(Skeleton::new().w(px(300.)).h_4().rounded_md()) // Excerpt line 1 .child(Skeleton::new().w(px(250.)).h_4().rounded_md()) // Excerpt line 2 .child(Skeleton::new().w(px(100.)).h_3().rounded_md()) // Date ) })) ``` ### Loading Table Rows ```rust v_flex() .gap_2() .children((0..5).map(|_| { h_flex() .gap_4() .p_3() .border_b_1() .border_color(cx.theme().border) .child(Skeleton::new().size_8().rounded_full()) // Status indicator .child(Skeleton::new().w(px(150.)).h_4().rounded_md()) // Name .child(Skeleton::new().w(px(200.)).h_4().rounded_md()) // Email .child(Skeleton::new().w(px(80.)).h_4().rounded_md()) // Role .child(Skeleton::new().w(px(60.)).h_4().rounded_md()) // Actions })) ``` ### Loading Button States ```rust h_flex() .gap_3() .child(Skeleton::new().w(px(80.)).h(px(36.)).rounded_md()) // Primary button .child(Skeleton::new().w(px(70.)).h(px(36.)).rounded_md()) // Secondary button .child(Skeleton::new().size_9().rounded_md()) // Icon button ``` ### Loading Form Fields ```rust v_flex() .gap_4() .child( v_flex() .gap_1() .child(Skeleton::new().w(px(60.)).h_4().rounded_md()) // Label .child(Skeleton::new().w_full().h(px(40.)).rounded_md()) // Input ) .child( v_flex() .gap_1() .child(Skeleton::new().w(px(80.)).h_4().rounded_md()) // Label .child(Skeleton::new().w_full().h(px(120.)).rounded_md()) // Textarea ) ``` ### Conditional Loading ```rust if loading { Skeleton::new().w(px(200.)).h_4().rounded_md() } else { div().child("Actual content here") } ``` ## Theming The Skeleton component uses the theme's `skeleton` color, which defaults to the `secondary` color if not specified. You can customize it in your theme: ```json { "skeleton.background": "#e2e8f0" } ``` The `secondary(true)` variant applies 50% opacity to the skeleton color for more subtle loading indicators. --- # Label Source: /versions/v0.7.1/component/label License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/label and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A versatile label component for displaying text with support for secondary text, highlighting, masking, and customizable styling. Perfect for form labels, captions, and general text display with optional/required indicators. ## Import ```rust use gpui_kit::component::label::{Label, HighlightsMatch}; ``` ## Usage ### Basic Label ```rust Label::new("This is a label") ``` ### Label with Secondary Text ```rust // Label with optional indicator Label::new("Company Address") .secondary("(optional)") // Label with required indicator Label::new("Email Address") .secondary("(required)") ``` ### Text Alignment ```rust // Left aligned (default) Label::new("Text align left") // Center aligned Label::new("Text align center") .text_center() // Right aligned Label::new("Text align right") .text_right() ``` ### Text Highlighting ```rust // Full text highlighting (finds all matches) Label::new("Hello World Hello") .highlights("Hello") // Prefix highlighting (only matches at start) Label::new("Hello World") .highlights(HighlightsMatch::Prefix("Hello".into())) // Highlight with secondary text Label::new("Company Name") .secondary("(optional)") .highlights("Company") ``` ### Color and Styling ```rust use gpui_kit::component::green_500; // Custom text color Label::new("Color Label") .text_color(green_500()) // Font styling Label::new("Font Size Label") .text_size(px(20.)) .font_semibold() .line_height(rems(1.8)) ``` ### Masked Labels ```rust // For sensitive information Label::new("9,182,1 USD") .text_2xl() .masked(true) // Shows as "•••••••••••" // Toggle masking programmatically Label::new("500 USD") .text_xl() .masked(self.masked) ``` ### Multi-line Text ```rust // Text wrapping with line height div().w(px(200.)).child( Label::new( "Label should support text wrap in default, \ if the text is too long, it should wrap to the next line." ) .line_height(rems(1.8)) ) ``` ### Different Sizes ```rust // Using text size utilities Label::new("Extra Large").text_2xl() Label::new("Large").text_xl() Label::new("Medium").text_base() // default Label::new("Small").text_sm() Label::new("Extra Small").text_xs() ``` ## API Reference ### Label | Method | Description | | ------------------- | ------------------------------------------------------------- | | `new(text)` | Create a new label with text | | `secondary(text)` | Add secondary text (usually for optional/required indicators) | | `masked(bool)` | Show/hide text with bullet characters | | `highlights(match)` | Highlight matching text | ### HighlightsMatch | Variant | Description | | -------------- | ------------------------------------------------ | | `Full(text)` | Highlights all occurrences of the text | | `Prefix(text)` | Highlights only if text appears at the beginning | | Method | Description | | ------------- | ------------------------------- | | `as_str()` | Get the search text as string | | `is_prefix()` | Check if this is a prefix match | ### Styling Methods (via Styled trait) | Method | Description | | --------------------- | --------------------------- | | `text_color(color)` | Set text color | | `text_size(size)` | Set font size | | `text_center()` | Center align text | | `text_right()` | Right align text | | `font_semibold()` | Set font weight to semibold | | `font_bold()` | Set font weight to bold | | `line_height(height)` | Set line height | | `text_xs()` | Extra small text size | | `text_sm()` | Small text size | | `text_base()` | Base text size (default) | | `text_lg()` | Large text size | | `text_xl()` | Extra large text size | | `text_2xl()` | 2x large text size | ## Examples ### Form Labels ```rust // Required field Label::new("Email Address") .secondary("*") .text_color(cx.theme().destructive) // Optional field Label::new("Phone Number") .secondary("(optional)") // Field with description Label::new("Password") .secondary("(minimum 8 characters)") ``` ### Search Highlighting ```rust // Interactive search highlighting let search_term = "Hello"; Label::new("Hello World Hello Universe") .highlights(search_term) // Highlights all "Hello" occurrences ``` ### Sensitive Information ```rust // Financial data with toggle h_flex() .child( Label::new("$9,182.50 USD") .text_2xl() .masked(self.is_masked) ) .child( Button::new("toggle-mask") .ghost() .icon(if self.is_masked { IconName::EyeOff } else { IconName::Eye }) .on_click(|this, _, _, _| { this.is_masked = !this.is_masked; }) ) ``` ### Multi-language Support ```rust // Supports Unicode text Label::new("这是一个标签") // Chinese text Label::new("こんにちは世界") // Japanese text Label::new("🌍 Hello World 🚀") // Emojis ``` ### Status Indicators ```rust // Success status Label::new("✓ Verified") .text_color(cx.theme().success) // Warning status Label::new("⚠ Pending Review") .text_color(cx.theme().warning) // Error status Label::new("✗ Failed") .text_color(cx.theme().destructive) ``` ### Custom Layouts ```rust // Flex layout with labels h_flex() .justify_between() .child(Label::new("Total Amount")) .child(Label::new("$1,234.56").font_semibold()) // Grid layout v_flex() .gap_2() .child(Label::new("Name:").font_semibold()) .child(Label::new("John Doe")) .child(Label::new("Email:").font_semibold()) .child(Label::new("john@example.com")) ``` --- # Collapsible Source: /versions/v0.7.1/component/collapsible License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/collapsible and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. An interactive element which expands/collapses. ## Import ```rust use gpui_kit::component::collapsible::Collapsible; ``` ## Usage ### Basic Use ```rust Collapsible::new() .max_w_128() .gap_1() .open(self.open) .child( "This is a collapsible component. \ Click the header to expand or collapse the content.", ) .content( "This is the full content of the Collapsible component. \ It is only visible when the component is expanded. \n\ You can put any content you like here, including text, images, \ or other UI elements.", ) .child( h_flex().justify_center().child( Button::new("toggle1") .icon(IconName::ChevronDown) .label("Show more") .when(open, |this| { this.icon(IconName::ChevronUp).label("Show less") }) .xsmall() .link() .on_click({ cx.listener(move |this, _, _, cx| { this.open = !this.open; cx.notify(); }) }), ), ) ``` We can use `open` method to control the collapsed state. If false, the `content` method added child elements will be hidden. ### Animated reveal Opt into a reversible, measured height reveal with a stable motion ID: ```rust Collapsible::new() .motion_id("advanced-options") .open(self.open) .content(options) ``` The content remains mounted while closed so it can be measured and immediately reverse if toggled mid-animation. Without `motion_id`, the component keeps the immediate mount/unmount behavior. See the [GPUI Base Motion guide](/versions/v0.7.1/base/motion) for timing, reduced-motion, and performance details. [Collapsible]: https://docs.rs/gpui-component/latest/gpui_component/collapsible/struct.Collapsible.html --- # Calendar Source: /versions/v0.7.1/component/calendar License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/calendar and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A standalone calendar component that provides a rich interface for date selection and navigation. The Calendar component supports single date selection, date range selection, multiple month views, custom disabled dates, and comprehensive keyboard navigation. - [CalendarState]: For managing calendar state and selection. - [Calendar]: For rendering the calendar UI. ## Import ```rust use gpui_kit::component::{ calendar::{Calendar, CalendarState, CalendarEvent, Date, Matcher}, }; ``` ## Usage ### Basic Calendar ```rust let state = cx.new(|cx| CalendarState::new(window, cx)); Calendar::new(&state) ``` ### Calendar with Initial Date ```rust use chrono::Local; let state = cx.new(|cx| { let mut state = CalendarState::new(window, cx); state.set_date(Local::now().naive_local().date(), window, cx); state }); Calendar::new(&state) ``` ### Date Range Calendar ```rust use chrono::{Local, Days}; let state = cx.new(|cx| { let mut state = CalendarState::new(window, cx); let now = Local::now().naive_local().date(); state.set_date( Date::Range(Some(now), now.checked_add_days(Days::new(7))), window, cx ); state }); Calendar::new(&state) ``` ### Multiple Months Display ```rust // Show 2 months side by side Calendar::new(&state) .number_of_months(2) // Show 3 months Calendar::new(&state) .number_of_months(3) ``` ### Calendar Sizes ```rust Calendar::new(&state).large() Calendar::new(&state) // medium (default) Calendar::new(&state).small() ``` ## Date Restrictions ### Disabled Weekends ```rust let state = cx.new(|cx| { CalendarState::new(window, cx) .disabled_matcher(vec![0, 6]) // Sunday=0, Saturday=6 }); Calendar::new(&state) ``` ### Disabled Specific Weekdays ```rust // Disable Sundays, Wednesdays, and Saturdays let state = cx.new(|cx| { CalendarState::new(window, cx) .disabled_matcher(vec![0, 3, 6]) }); Calendar::new(&state) ``` ### Disabled Date Range ```rust use chrono::{Local, Days}; let now = Local::now().naive_local().date(); let state = cx.new(|cx| { CalendarState::new(window, cx) .disabled_matcher(Matcher::range( Some(now), now.checked_add_days(Days::new(7)), )) }); Calendar::new(&state) ``` ### Disabled Date Interval ```rust // Disable dates outside the interval (before/after specified dates) let state = cx.new(|cx| { CalendarState::new(window, cx) .disabled_matcher(Matcher::interval( Some(now.checked_sub_days(Days::new(30)).unwrap()), now.checked_add_days(Days::new(30)) )) }); Calendar::new(&state) ``` ### Custom Disabled Dates ```rust // Disable first 5 days of each month let state = cx.new(|cx| { CalendarState::new(window, cx) .disabled_matcher(Matcher::custom(|date| { date.day0() < 5 // day0() returns 0-based day })) }); Calendar::new(&state) // Disable all Mondays let state = cx.new(|cx| { CalendarState::new(window, cx) .disabled_matcher(Matcher::custom(|date| { date.weekday() == chrono::Weekday::Mon })) }); Calendar::new(&state) // Disable past dates let state = cx.new(|cx| { CalendarState::new(window, cx) .disabled_matcher(Matcher::custom(|date| { *date < Local::now().naive_local().date() })) }); Calendar::new(&state) ``` ## Month/Year Navigation The Calendar automatically provides navigation controls: - **Previous/Next Month**: Arrow buttons in the header - **Month Selection**: Click on month name to open month picker - **Year Selection**: Click on year to open year picker - **Year Pages**: Navigate through 20-year pages in year view ### Custom Year Range ```rust let state = cx.new(|cx| { CalendarState::new(window, cx) .year_range((2020, 2030)) // Limit to specific year range }); Calendar::new(&state) ``` ## Handle Selection Events ```rust let state = cx.new(|cx| CalendarState::new(window, cx)); cx.subscribe(&state, |view, _, event, _| { match event { CalendarEvent::Selected(date) => { match date { Date::Single(Some(selected_date)) => { println!("Date selected: {}", selected_date); } Date::Range(Some(start), Some(end)) => { println!("Range selected: {} to {}", start, end); } Date::Range(Some(start), None) => { println!("Range start: {}", start); } _ => { println!("Selection cleared"); } } } } }); Calendar::new(&state) ``` ## Advanced Examples ### Business Days Only Calendar ```rust use chrono::Weekday; let state = cx.new(|cx| { CalendarState::new(window, cx) .disabled_matcher(Matcher::custom(|date| { matches!(date.weekday(), Weekday::Sat | Weekday::Sun) })) }); Calendar::new(&state) ``` ### Holiday Calendar ```rust use chrono::NaiveDate; use std::collections::HashSet; // Define holidays let holidays: HashSet = [ NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), // New Year NaiveDate::from_ymd_opt(2024, 7, 4).unwrap(), // Independence Day NaiveDate::from_ymd_opt(2024, 12, 25).unwrap(), // Christmas ].into_iter().collect(); let state = cx.new(|cx| { CalendarState::new(window, cx) .disabled_matcher(Matcher::custom(move |date| { holidays.contains(date) })) }); Calendar::new(&state) ``` ### Multi-Month Range Selector ```rust let state = cx.new(|cx| { let mut state = CalendarState::new(window, cx); state.set_date(Date::Range(None, None), window, cx); // Range mode state }); Calendar::new(&state) .number_of_months(3) // Show 3 months for easier range selection ``` ### Quarterly View Calendar ```rust let state = cx.new(|cx| CalendarState::new(window, cx)); // Update to show current quarter's months Calendar::new(&state) .number_of_months(3) ``` ## Custom Styling ```rust use gpui_kit::{px, relative}; Calendar::new(&calendar) .p_4() // Custom padding .bg(cx.theme().secondary) // Custom background .border_2() // Custom border .border_color(cx.theme().primary) // Custom border color .rounded(px(12.)) // Custom border radius .w(px(400.)) // Custom width .h(px(350.)) // Custom height ``` ## API Reference - [Calendar] - [CalendarState] - [RangeMatcher] ## Examples ### Event Planning Calendar ```rust let event_calendar = cx.new(|cx| { let mut state = CalendarState::new(window, cx); // Disable past dates and weekends state = state.disabled_matcher(Matcher::custom(|date| { let now = Local::now().naive_local().date(); *date < now || matches!(date.weekday(), Weekday::Sat | Weekday::Sun) })); state }); Calendar::new(&event_calendar) .large() // Easier to see and interact with ``` ### Vacation Booking Calendar ```rust let vacation_calendar = cx.new(|cx| { let mut state = CalendarState::new(window, cx); state.set_date(Date::Range(None, None), window, cx); // Range mode state }); Calendar::new(&vacation_calendar) .number_of_months(2) // Show 2 months for range selection ``` ### Report Date Range Selector ```rust let report_calendar = cx.new(|cx| { let mut state = CalendarState::new(window, cx) .year_range((2020, 2025)); // Limit to business years state.set_date(Date::Range(None, None), window, cx); state }); Calendar::new(&report_calendar) .number_of_months(3) .small() // Compact for dashboard use ``` ### Availability Calendar ```rust use std::collections::HashSet; let unavailable_dates: HashSet = get_unavailable_dates(); let availability_calendar = cx.new(|cx| { CalendarState::new(window, cx) .disabled_matcher(Matcher::custom(move |date| { unavailable_dates.contains(date) })) }); Calendar::new(&availability_calendar) .number_of_months(2) ``` The Calendar component provides a foundation for any date-related UI requirements, from simple date pickers to complex scheduling interfaces. [Calendar]: https://docs.rs/gpui-component/latest/gpui_component/calendar/struct.Calendar.html [CalendarState]: https://docs.rs/gpui-component/latest/gpui_component/calendar/struct.CalendarState.html [RangeMatcher]: https://docs.rs/gpui-component/latest/gpui_component/calendar/struct.RangeMatcher.html --- # Sheet Source: /versions/v0.7.1/component/sheet License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/sheet and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A Sheet (also known as a sidebar or slide-out panel) is a navigation component that slides in from the edges of the screen. It provides additional space for content without taking up the main view, and can be used for navigation menus, forms, or any supplementary content. ## Import ```rust use gpui_kit::component::WindowExt; use gpui_kit::component::Placement; ``` ## Usage ### Where sheets render The window's [Root](/versions/v0.7.1/component/root) automatically mounts and renders sheets. Open the window with `gpui_kit::open_window`, or wrap the application view in `Root::new`. Application views do not render overlay layers themselves. ### Basic Sheet ```rust window.open_sheet(cx, |sheet, _, _| { sheet .title("Navigation") .child("Sheet content goes here") }) ``` ### Sheet with Placement ```rust // Left sheet (default) window.open_sheet_at(Placement::Left, cx, |sheet, _, _| { sheet.title("Left Sheet") }) // Right sheet window.open_sheet_at(Placement::Right, cx, |sheet, _, _| { sheet.title("Right Sheet") }) // Top sheet window.open_sheet_at(Placement::Top, cx, |sheet, _, _| { sheet.title("Top Sheet") }) // Bottom sheet window.open_sheet_at(Placement::Bottom, cx, |sheet, _, _| { sheet.title("Bottom Sheet") }) ``` ### Sheet with Custom Size ```rust window.open_sheet(cx, |sheet, _, _| { sheet .title("Wide Sheet") .size(px(500.)) // Custom width for left/right, height for top/bottom .child("This sheet is 500px wide") }) ``` ### Sheet with Form Content ```rust let input = cx.new(|cx| InputState::new(window, cx)); let date = cx.new(|cx| DatePickerState::new(window, cx)); window.open_sheet(cx, |sheet, _, _| { sheet .title("User Profile") .child( v_flex() .gap_4() .child("Enter your information:") .child(Input::new(&input).placeholder("Full Name")) .child(DatePicker::new(&date).placeholder("Date of Birth")) ) .footer( h_flex() .gap_3() .child(Button::new("save").primary().label("Save")) .child(Button::new("cancel").label("Cancel")) ) }) ``` ### Overlay Options ```rust window.open_sheet(cx, |sheet, _, _| { sheet .title("Settings") .overlay(true) // Show overlay background (default: true) .overlay_closable(true) // Click overlay to close (default: true) .child("Sheet settings content") }) // No overlay window.open_sheet(cx, |sheet, _, _| { sheet .title("Side Panel") .overlay(false) // No overlay background .child("This sheet has no overlay") }) ``` ### Resizable Sheet ```rust window.open_sheet(cx, |sheet, _, _| { sheet .title("Resizable Panel") .resizable(true) // Allow user to resize (default: true) .size(px(300.)) .child("You can resize this sheet by dragging the edge") }) ``` ### Custom Margin and Positioning ```rust window.open_sheet(cx, |sheet, _, _| { sheet .title("Below Title Bar") .margin_top(px(32.)) // Space for window title bar .child("This sheet appears below the title bar") }) ``` ### Sheet with List ```rust let delegate = ListDelegate::new(items); let list = cx.new(|cx| List::new(delegate, window, cx)); window.open_sheet_at(Placement::Left, cx, |sheet, _, _| { sheet .title("File Explorer") .size(px(400.)) .child( div() .border_1() .border_color(cx.theme().border) .rounded(cx.theme().radius) .size_full() .child(list.clone()) ) }) ``` ### Close Event Handling ```rust window.open_sheet(cx, |sheet, _, _| { sheet .title("Sheet with Handler") .child("This sheet has a custom close handler") .on_close(|_, window, cx| { window.push_notification("Sheet was closed", cx); }) }) ``` ### Navigation Sheet ```rust window.open_sheet_at(Placement::Left, cx, |sheet, _, _| { sheet .title("Navigation") .size(px(280.)) .child( v_flex() .gap_2() .child(Button::new("home").ghost().label("Home").w_full()) .child(Button::new("profile").ghost().label("Profile").w_full()) .child(Button::new("settings").ghost().label("Settings").w_full()) .child(Button::new("logout").ghost().label("Logout").w_full()) ) }) ``` ### Custom Styling ```rust window.open_sheet(cx, |sheet, _, cx| { sheet .title("Styled Sheet") .bg(cx.theme().accent) .text_color(cx.theme().accent_foreground) .border_color(cx.theme().primary) .child("Custom styled sheet content") }) ``` ### Programmatic Close ```rust // Close sheet from inside Button::new("close") .label("Close Sheet") .on_click(|_, window, cx| { window.close_sheet(cx); }) // Close sheet from outside window.close_sheet(cx); ``` ## API Reference ### Window Extensions | Method | Description | | ---------------------------------- | ----------------------------------------- | | `open_sheet(cx, fn)` | Open sheet with default placement (Right) | | `open_sheet_at(placement, cx, fn)` | Open sheet at specific placement | | `close_sheet(cx)` | Close current sheet | ### Sheet Builder | Method | Description | | ------------------------ | --------------------------------------- | | `title(str)` | Set sheet title | | `child(el)` | Add content to sheet body | | `footer(el)` | Set footer content | | `size(px)` | Set sheet size (width or height) | | `margin_top(px)` | Set top margin (for title bars) | | `resizable(bool)` | Allow resizing (default: true) | | `overlay(bool)` | Show overlay background (default: true) | | `overlay_closable(bool)` | Click overlay to close (default: true) | | `on_close(fn)` | Close event callback | ### Placement Options | Value | Description | | ------------------- | ----------------------------------- | | `Placement::Left` | Slides in from left edge | | `Placement::Right` | Slides in from right edge (default) | | `Placement::Top` | Slides in from top edge | | `Placement::Bottom` | Slides in from bottom edge | ### Styling Methods | Method | Description | | --------------------- | ------------------------ | | `bg(color)` | Set background color | | `text_color(color)` | Set text color | | `border_color(color)` | Set border color | | `px_*()/py_*()` | Custom padding | | `gap_*()` | Spacing between children | ## Examples ### Settings Panel ```rust window.open_sheet_at(Placement::Right, cx, |sheet, _, _| { sheet .title("Settings") .size(px(350.)) .child( v_flex() .gap_4() .child("Appearance") .child(Checkbox::new("dark-mode").label("Dark Mode")) .child(Checkbox::new("animations").label("Enable Animations")) .child("Notifications") .child(Checkbox::new("push-notifications").label("Push Notifications")) ) .footer( h_flex() .justify_end() .gap_2() .child(Button::new("apply").primary().label("Apply")) .child(Button::new("cancel").label("Cancel")) ) }) ``` ### File Browser ```rust window.open_sheet_at(Placement::Left, cx, |sheet, _, _| { sheet .title("Files") .size(px(300.)) .child( v_flex() .size_full() .child( h_flex() .gap_2() .p_2() .child(Button::new("new-folder").small().icon(IconName::FolderPlus)) .child(Button::new("upload").small().icon(IconName::Upload)) ) .child( div() .flex_1() .overflow_hidden() .child(file_tree_list) ) ) }) ``` ### Help Panel ```rust window.open_sheet_at(Placement::Bottom, cx, |sheet, _, _| { sheet .title("Help & Documentation") .size(px(200.)) .child( h_flex() .gap_4() .child("Keyboard Shortcuts") .child(Kbd::new("⌘").child("K")) .child("Search") .child(Kbd::new("⌘").child("P")) .child("Command Palette") ) }) ``` ## Best Practices 1. **Appropriate Placement**: Use left/right for navigation, top/bottom for temporary content 2. **Consistent Sizing**: Maintain consistent sheet sizes across your application 3. **Clear Headers**: Always provide descriptive titles 4. **Close Options**: Provide multiple ways to close (ESC, overlay click, close button) 5. **Content Organization**: Use proper spacing and grouping for sheet content 6. **Responsive Design**: Consider sheet behavior on smaller screens 7. **Performance**: Lazy load sheet content when possible for better performance --- # Select Source: /versions/v0.7.1/component/select License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/select and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. This component was named `Dropdown` in `<= 0.3.x`. It has been renamed to `Select` to better reflect its purpose. A select component that allows users to choose from a list of options. Supports search functionality, grouped items, custom rendering, and various states. Built with keyboard navigation and accessibility in mind. For richer selection UIs with custom trigger rendering or multi-select, see [Combobox](combobox). ## Import ```rust use gpui_kit::component::select::{ Select, SelectState, SelectItem, SelectDelegate, SelectEvent, SearchableVec, SelectGroup }; ``` ## Usage ### Basic Select You can create a basic select dropdown by initializing a `SelectState` with a list of items. The first type parameter of `SelectState` is the items for the state, which must implement the [SelectItem] trait. The built-in implementations of `SelectItem` include common types like `String`, `SharedString`, and `&'static str`. ```rust let state = cx.new(|cx| { SelectState::new( vec!["Apple", "Orange", "Banana"], Some(IndexPath::default()), // Select first item window, cx, ) }); Select::new(&state) ``` ### Placeholder ```rust let state = cx.new(|cx| { SelectState::new( vec!["Rust", "Go", "JavaScript"], None, // No initial selection window, cx, ) }); Select::new(&state) .placeholder("Select a language...") ``` ### Accessibility Give the control a name that stays the same when the selection changes: ```rust Select::new(&state) .accessibility_label("Programming language") .placeholder("Choose a language") ``` The accessible value uses the committed item's `title()` and any `title_prefix`. A custom `display_title()` remains visual presentation. Searching does not change that committed value. With no selection, the accessible value uses the placeholder. Enabled controls expose accessible activation to open or close the popup. ### Searchable Use `searchable(true)` to enable search functionality within the dropdown. ```rust let fruits = SearchableVec::new(vec![ "Apple", "Orange", "Banana", "Grape", "Pineapple", ]); let state = cx.new(|cx| { SelectState::new(fruits, None, window, cx).searchable(true) }); Select::new(&state) .icon(IconName::Search) // Shows search icon ``` ### Impl SelectItem By default, we have implmemented `SelectItem` for common types like `String`, `SharedString` and `&'static str`. You can also create your own item types by implementing the `SelectItem` trait. This is useful when you want to display complex data structures, and also want get that data type from `select_value` method. You can also customize the search logic by overriding the `matches` method. ```rust #[derive(Debug, Clone)] struct Country { name: SharedString, code: SharedString, } impl SelectItem for Country { type Value = SharedString; fn title(&self) -> SharedString { self.name.clone() } fn display_title(&self) -> Option { // Custom display for selected item Some(format!("{} ({})", self.name, self.code).into_any_element()) } fn value(&self) -> &Self::Value { &self.code } fn matches(&self, query: &str) -> bool { // Custom search logic self.name.to_lowercase().contains(&query.to_lowercase()) || self.code.to_lowercase().contains(&query.to_lowercase()) } } ``` ### Group Items ```rust let mut grouped_items = SearchableVec::new(vec![]); // Group countries by first letter grouped_items.push( SelectGroup::new("A") .items(vec![ Country { name: "Australia".into(), code: "AU".into() }, Country { name: "Austria".into(), code: "AT".into() }, ]) ); grouped_items.push( SelectGroup::new("B") .items(vec![ Country { name: "Brazil".into(), code: "BR".into() }, Country { name: "Belgium".into(), code: "BE".into() }, ]) ); let state = cx.new(|cx| { SelectState::new(grouped_items, None, window, cx) }); Select::new(&state) ``` ### Sizes ```rust Select::new(&state).large() Select::new(&state) // medium (default) Select::new(&state).small() ``` ### Disabled State ```rust Select::new(&state).disabled(true) ``` ### Cleanable ```rust Select::new(&state) .cleanable(true) // Show clear button when item is selected ``` ### Custom Appearance ```rust Select::new(&state) .w(px(320.)) // Set dropdown width .menu_width(px(400.)) // Set menu popup width .menu_max_h(rems(10.)) // Set menu max height (default: 20rem) .appearance(false) // Remove default styling .title_prefix("Country: ") // Add prefix to selected title ``` ### Empty State ```rust let state = cx.new(|cx| { SelectState::new(Vec::::new(), None, window, cx) }); Select::new(&state) .empty( h_flex() .h_24() .justify_center() .text_color(cx.theme().muted_foreground) .child("No options available") ) ``` ### Events ```rust cx.subscribe_in(&state, window, |view, state, event, window, cx| { match event { SelectEvent::Confirm(value) => { if let Some(selected_value) = value { println!("Selected: {:?}", selected_value); } else { println!("Selection cleared"); } } } }); ``` ### Mutating ```rust // Set by index state.update(cx, |state, cx| { state.set_selected_index(Some(IndexPath::default().row(2)), window, cx); }); // Set by value (requires PartialEq on Value type) state.update(cx, |state, cx| { state.set_selected_value(&"US".into(), window, cx); }); // Get current selection let current_value = state.read(cx).selected_value(); ``` Update items: ```rust state.update(cx, |state, cx| { let new_items = vec!["New Option 1".into(), "New Option 2".into()]; state.set_items(new_items, window, cx); }); ``` ## Examples ### Language Selector ```rust let languages = SearchableVec::new(vec![ "Rust".into(), "TypeScript".into(), "Go".into(), "Python".into(), "JavaScript".into(), ]); let state = cx.new(|cx| { SelectState::new(languages, None, window, cx) }); Select::new(&state) .placeholder("Select language...") .title_prefix("Language: ") ``` ### Country/Region Selector ```rust #[derive(Debug, Clone)] struct Region { name: SharedString, code: SharedString, flag: SharedString, } impl SelectItem for Region { type Value = SharedString; fn title(&self) -> SharedString { self.name.clone() } fn display_title(&self) -> Option { Some( h_flex() .items_center() .gap_2() .child(self.flag.clone()) .child(format!("{} ({})", self.name, self.code)) .into_any_element() ) } fn value(&self) -> &Self::Value { &self.code } } let regions = vec![ Region { name: "United States".into(), code: "US".into(), flag: "🇺🇸".into() }, Region { name: "Canada".into(), code: "CA".into(), flag: "🇨🇦".into() }, ]; let state = cx.new(|cx| { SelectState::new(regions, None, window, cx) }); Select::new(&state) .placeholder("Select country...") ``` ### Integrated with Input Field ```rust // Combined country code + phone input h_flex() .border_1() .border_color(cx.theme().input) .rounded(cx.theme().radius_lg) .w_full() .gap_1() .child( div().w(px(140.)).child( Select::new(&country_state) .appearance(false) // No border/background .py_2() .pl_3() ) ) .child(Separator::vertical()) .child( div().flex_1().child( Input::new(&phone_input) .appearance(false) .placeholder("Phone number") .pr_3() .py_2() ) ) ``` ### Multi-level Grouped Select ```rust let mut grouped_countries = SearchableVec::new(vec![]); for (continent, countries) in countries_by_continent { grouped_countries.push( SelectGroup::new(continent) .items(countries) ); } let state = cx.new(|cx| { SelectState::new(grouped_countries, None, window, cx) }); Select::new(&state) .menu_width(px(350.)) .placeholder("Select country...") ``` ## Keyboard Shortcuts | Key | Action | | --------- | --------------------------------------- | | `Tab` | Focus dropdown | | `Enter` | Open menu or select current item | | `Up/Down` | Navigate options (opens menu if closed) | | `Escape` | Close menu | | `Space` | Open menu | ## Theming The dropdown respects the current theme and uses the following theme tokens: - `background` - Dropdown input background - `input` - Border color - `foreground` - Text color - `muted_foreground` - Placeholder and disabled text - `accent` - Selected item background - `accent_foreground` - Placeholder text color - `border` - Menu border - `radius` - Border radius [SelectItem]: https://docs.rs/gpui-component/latest/gpui_component/select/trait.SelectItem.html --- # Clipboard Source: /versions/v0.7.1/component/clipboard License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/clipboard and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. The Clipboard component provides an easy way to copy text or other data to the user's clipboard. It renders as a button with a copy icon that changes to a checkmark when content is successfully copied. The component supports both static values and dynamic content through callback functions. The icon button has a localized "Copy" accessible name by default. Use `accessibility_label` when the context needs a more specific name, such as "Copy API key". A tooltip is a separate hint and does not set the accessible name. ## Import ```rust use gpui_kit::component::clipboard::Clipboard; ``` ## Usage ### Basic Clipboard ```rust Clipboard::new("my-clipboard") .value("Text to copy") .on_copied(|value, window, cx| { window.push_notification(format!("Copied: {}", value), cx) }) ``` ### Using Dynamic Values The `value_fn` method allows you to provide a closure that generates the content to be copied at the time of the copy action. - This is useful when the content to be copied depends on the current state of the application. - And in some cases, it may have a larger overhead to compute, so you only want to do it when the user actually clicks the copy button. ```rust let state = some_state.clone(); Clipboard::new("dynamic-clipboard") .value_fn(move |_, cx| { state.read(cx).get_current_value() }) .on_copied(|value, window, cx| { window.push_notification(format!("Copied: {}", value), cx) }) ``` ### With Custom Content ```rust use gpui_kit::component::label::Label; h_flex() .gap_2() .child(Label::new("Share URL")) .child(Icon::new(IconName::Share)) .child( Clipboard::new("custom-clipboard") .value("https://example.com") ) ``` ### In Input Fields The Clipboard component is commonly used as a suffix in input fields: ```rust use gpui_kit::component::input::{InputState, Input}; let url_state = cx.new(|cx| InputState::new(window, cx).default_value("https://github.com")); Input::new(&url_state) .suffix( Clipboard::new("url-clipboard") .value_fn({ let state = url_state.clone(); move |_, cx| state.read(cx).value() }) .on_copied(|value, window, cx| { window.push_notification(format!("URL copied: {}", value), cx) }) ) ``` ## API Reference - [Clipboard] ## Examples ### Simple Text Copy ```rust Clipboard::new("simple") .value("Hello, World!") ``` ### With User Feedback ```rust h_flex() .gap_2() .child(Label::new("Your API Key:")) .child( Clipboard::new("feedback") .value("sk-1234567890abcdef") .accessibility_label("Copy API key") .on_copied(|_, window, cx| { window.push_notification("API key copied to clipboard", cx) }) ) ``` ### Form Field Integration ```rust use gpui_kit::component::{ input::{InputState, Input}, h_flex, label::Label }; let api_key = "sk-1234567890abcdef"; h_flex() .gap_2() .items_center() .child(Label::new("API Key:")) .child( Input::new(&input_state) .value(api_key) .readonly(true) .suffix( Clipboard::new("api-key-copy") .value(api_key) .on_copied(|_, window, cx| { window.push_notification("API key copied!", cx) }) ) ) ``` ### Dynamic Content Copy ```rust struct AppState { current_url: String, } let app_state = cx.new(|_| AppState { current_url: "https://example.com".to_string() }); Clipboard::new("current-url") .value_fn({ let state = app_state.clone(); move |_, cx| { SharedString::from(state.read(cx).current_url.clone()) } }) .on_copied(|url, window, cx| { window.push_notification(format!("Shared: {}", url), cx) }) ``` ## Data Types The Clipboard component currently supports copying text strings to the clipboard. It uses GPUI's `ClipboardItem::new_string()` method, which handles: - Plain text strings - UTF-8 encoded content - Cross-platform clipboard integration [Clipboard]: https://docs.rs/gpui-component/latest/gpui_component/clipboard/struct.Clipboard.html --- # Theme Source: /versions/v0.7.1/component/theme License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/theme and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. All components support theming through the built-in Theme system, the [ActiveTheme] trait provides access to the current theme colors: ```rs use gpui_kit::component::{ActiveTheme as _}; // Access theme colors in your components cx.theme().primary cx.theme().background cx.theme().foreground ``` So if you want use the colors from the current theme, you should keep your component or view have [App] context. ## Gradient Backgrounds Theme color values remain backward compatible with the existing string format: ```json { "colors": { "button.primary.background": "#4F46E5" } } ``` Background tokens that opt in to gradient rendering can also use CSS-style two-stop linear gradients: ```json { "colors": { "button.primary.background": "linear-gradient(135deg, #4F46E5, #06B6D4)", "button.primary.hover.background": "linear-gradient(to right, red-500 25%, blue-600 75%)" } } ``` Top-level theme fields, such as `cx.theme().button_primary`, remain solid `Hsla` values for compatibility. Code that needs the full resolved token can use `cx.theme().tokens.button_primary`; its `.color` field is the solid representative color, and its `.background` field contains the configured `Background`, including gradients. ## Theme Registry There have more than 20 built-in themes available in [themes](https://github.com/longbridge/gpui-kit/tree/main/themes) folder. https://github.com/longbridge/gpui-kit/tree/main/themes And we have a [ThemeRegistry] to help us to load themes. Use the `name` of an entry in the `themes` array, such as `Ayu Light`, when looking up a theme from the registry. ```rs use std::path::PathBuf; use gpui_kit::{App, SharedString}; use gpui_kit::component::{Theme, ThemeRegistry}; pub fn init(cx: &mut App) { let theme_name = SharedString::from("Ayu Light"); // Load and watch themes from ./themes directory if let Err(err) = ThemeRegistry::watch_dir(PathBuf::from("./themes"), cx, move |cx| { if let Some(theme) = ThemeRegistry::global(cx) .themes() .get(&theme_name) .cloned() { Theme::update(cx, |current| current.apply_config(&theme)); } }) { tracing::error!("Failed to watch themes directory: {}", err); } } ``` [ActiveTheme]: https://docs.rs/gpui-component/latest/gpui_component/theme/trait.ActiveTheme.html [ThemeRegistry]: https://docs.rs/gpui-component/latest/gpui_component/theme/struct.ThemeRegistry.html [App]: https://docs.rs/gpui/latest/gpui/struct.App.html --- # Progress Source: /versions/v0.7.1/component/progress License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/progress and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Progress components visually represent the completion percentage of a task. The library provides two variants: - **[Progress](#progress)** - A linear horizontal progress bar - **[ProgressCircle](#progresscircle)** - A circular progress indicator Both components feature smooth transition animations when the value changes, a loading (indeterminate) animation mode, customizable colors, and automatic styling that adapts to the current theme. ## Progress ```rust use gpui_kit::component::progress::Progress; ``` ### Usage ```rust Progress::new("my-progress") .value(75.0) // 75% complete ``` ### Different Progress Values ```rust Progress::new("progress-0").value(0.0) Progress::new("progress-25").value(25.0) Progress::new("progress-75").value(75.0) Progress::new("progress-100").value(100.0) ``` ### Loading State Use `.loading(true)` to show an indeterminate animation when the actual progress is unknown. The `value` is ignored while loading is active. ```rust // Indeterminate loading animation Progress::new("loading").loading(true) // Toggle between loading and determinate Progress::new("my-progress") .loading(self.is_loading) .value(self.progress) ``` ### Sizes `Progress` implements the `Sizable` trait: ```rust Progress::new("xs").value(50.0).xsmall() // 4px height Progress::new("sm").value(50.0).small() // 6px height Progress::new("md").value(50.0) // 8px height (default) Progress::new("lg").value(50.0).large() // 10px height ``` ### Custom Style The component implements the `Styled` trait, allowing custom height, border radius, color, and border: ```rust Progress::new("custom") .value(32.0) .h(px(16.)) .rounded(px(2.)) .color(cx.theme().green_light) .border_2() .border_color(cx.theme().green) ``` ### Dynamic Progress Updates ```rust struct MyView { value: f32, is_loading: bool, } impl Render for MyView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .gap_3() .child( h_flex() .gap_2() .child( Button::new("toggle-loading") .label("Loading") .selected(self.is_loading) .on_click(cx.listener(|this, _, _, cx| { this.is_loading = !this.is_loading; cx.notify(); })), ) .child(Button::new("inc").icon(IconName::Plus).on_click( cx.listener(|this, _, _, _| { this.value = (this.value + 10.).min(100.); }), )), ) .child( Progress::new("progress") .value(self.value) .loading(self.is_loading), ) } } ``` ### API Reference | Method | Type | Description | |---|---|---| | `new(id)` | `ElementId` | Create a new progress bar | | `value(v)` | `f32` | Set progress value (0–100), clamped automatically | | `loading(v)` | `bool` | Enable indeterminate loading animation; ignores `value` when `true` | | `color(c)` | `impl Into` | Override the fill color (defaults to `theme.progress_bar`) | | `xsmall()` / `small()` / `large()` | — | Set predefined height via `Sizable` | | `Styled` trait methods | — | Custom height, border radius, border, etc. | ## ProgressCircle A circular progress indicator that displays progress as an arc. Ideal for compact spaces, inline labels, or as a download/upload indicator. ```rust use gpui_kit::component::progress::ProgressCircle; ``` ### Usage ```rust ProgressCircle::new("circle").value(50.0) ``` ### Loading State ```rust // Indeterminate rotating arc animation ProgressCircle::new("loading").loading(true) // Toggle between loading and determinate ProgressCircle::new("circle") .loading(self.is_loading) .value(self.progress) ``` ### Sizes `ProgressCircle` implements the `Sizable` trait. Named sizes map to fixed pixel dimensions; use `.size(px(n))` for custom sizes: ```rust ProgressCircle::new("xs").value(50.0).xsmall() // size_2 ProgressCircle::new("sm").value(50.0).small() // size_3 ProgressCircle::new("md").value(50.0) // size_4 (default) ProgressCircle::new("lg").value(50.0).large() // size_5 ProgressCircle::new("xl").value(50.0).size_20() // 80px ``` ### Custom Color ```rust ProgressCircle::new("green").value(75.0).color(cx.theme().green) ProgressCircle::new("yellow").value(40.0).color(cx.theme().yellow) ProgressCircle::new("primary").value(60.0).color(cx.theme().primary) ``` ### With Inner Content `ProgressCircle` implements `ParentElement`, so you can place content inside the circle: ```rust ProgressCircle::new("circle-with-label") .value(self.value) .size_20() .child( v_flex() .size_full() .items_center() .justify_center() .gap_1() .child( div() .child(format!("{}%", self.value as i32)) .text_color(cx.theme().progress_bar), ) .child(div().child("Loading").text_xs()), ) ``` ### Inline with Label ```rust h_flex() .gap_2() .items_center() .child( ProgressCircle::new("download") .color(cx.theme().primary) .value(self.progress) .size_4(), ) .child("Downloading...") ``` ### API Reference | Method | Type | Description | |---|---|---| | `new(id)` | `ElementId` | Create a new circular progress indicator | | `value(v)` | `f32` | Set progress value (0–100), clamped automatically | | `loading(v)` | `bool` | Enable indeterminate loading animation; ignores `value` when `true` | | `color(c)` | `impl Into` | Override the arc color (defaults to `theme.progress_bar`) | | `xsmall()` / `small()` / `large()` | — | Set predefined size via `Sizable` | | `size(px(n))` | `Pixels` | Set custom size | | `ParentElement` | — | Place content inside the circle | ## Examples ### File Upload ```rust struct FileUpload { uploaded: u64, total: u64, } impl FileUpload { fn progress(&self) -> f32 { if self.total == 0 { return 0.0; } (self.uploaded as f32 / self.total as f32) * 100.0 } fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { v_flex() .gap_2() .child( h_flex() .justify_between() .child("Uploading...") .child(format!("{:.0}%", self.progress())), ) .child(Progress::new("upload").value(self.progress())) } } ``` ### Initialization with Loading State ```rust struct AppInit { loading: bool, progress: f32, } impl Render for AppInit { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .gap_3() .child( h_flex() .gap_2() .items_center() .child( ProgressCircle::new("init-circle") .loading(self.loading) .value(self.progress) .size_4(), ) .child(if self.loading { "Initializing..." } else { "Ready" }), ) .child( Progress::new("init-bar") .loading(self.loading) .value(self.progress), ) } } ``` ### Multi-Step Process ```rust struct Install { step: usize, // current package index total: usize, // total packages step_progress: f32, } impl Install { fn overall(&self) -> f32 { if self.total == 0 { return 0.0; } (self.step as f32 + self.step_progress / 100.0) / self.total as f32 * 100.0 } fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { v_flex() .gap_2() .child( h_flex() .justify_between() .child(format!("Package {}/{}", self.step + 1, self.total)) .child(format!("{:.0}%", self.overall())), ) .child(Progress::new("overall").value(self.overall())) .child( h_flex() .gap_2() .items_center() .child(Progress::new("package").value(self.step_progress).small()) .child("Current package"), ) } } ``` --- # OtpInput Source: /versions/v0.7.1/component/otp-input License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/otp-input and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A specialized input component for one-time passwords (OTP) that displays multiple input fields in a grid layout. Perfect for SMS verification codes, authenticator app codes, and other numeric verification scenarios. ## Import ```rust use gpui_kit::component::input::{OtpInput, OtpState}; ``` ## Usage ### Basic OTP Input ```rust let otp_state = cx.new(|cx| OtpState::new(6, window, cx)); OtpInput::new(&otp_state) ``` ### With Default Value ```rust let otp_state = cx.new(|cx| OtpState::new(6, window, cx) .default_value("123456") ); OtpInput::new(&otp_state) ``` ### Masked OTP Input ```rust let otp_state = cx.new(|cx| OtpState::new(6, window, cx) .masked(true) .default_value("123456") ); OtpInput::new(&otp_state) ``` ### Different Sizes ```rust // Small size OtpInput::new(&otp_state).small() // Medium size (default) OtpInput::new(&otp_state) // Large size OtpInput::new(&otp_state).large() // Custom size OtpInput::new(&otp_state).with_size(px(55.)) ``` ### Grouped Layout ```rust // Single group (all fields together) OtpInput::new(&otp_state).groups(1) // Two groups (default) - splits fields in half OtpInput::new(&otp_state).groups(2) // Three groups - splits fields into thirds OtpInput::new(&otp_state).groups(3) ``` ### Disabled State ```rust OtpInput::new(&otp_state).disabled(true) ``` ### Different Length Codes ```rust // 4-digit PIN let pin_state = cx.new(|cx| OtpState::new(4, window, cx)); OtpInput::new(&pin_state).groups(1) // 6-digit SMS code (most common) let sms_state = cx.new(|cx| OtpState::new(6, window, cx)); OtpInput::new(&sms_state) // 8-digit authenticator code let auth_state = cx.new(|cx| OtpState::new(8, window, cx)); OtpInput::new(&auth_state).groups(2) ``` ### Handle OTP Events ```rust let otp_state = cx.new(|cx| OtpState::new(6, window, cx)); cx.subscribe(&otp_state, |this, state, event: &InputEvent, cx| { match event { InputEvent::Change => { let code = state.read(cx).value(); if code.len() == 6 { println!("Complete OTP: {}", code); // Automatically submit when complete this.verify_otp(&code, cx); } } InputEvent::Focus => println!("OTP input focused"), InputEvent::Blur => println!("OTP input lost focus"), _ => {} } }); ``` ### Programmatic Control ```rust // Set value programmatically otp_state.update(cx, |state, cx| { state.set_value("123456", window, cx); }); // Toggle masking otp_state.update(cx, |state, cx| { state.set_masked(true, window, cx); }); // Focus the input otp_state.update(cx, |state, cx| { state.focus(window, cx); }); // Get current value let current_value = otp_state.read(cx).value(); ``` ## API Reference ### OtpState | Method | Description | | ------------------------------ | -------------------------------------------- | | `new(length, window, cx)` | Create a new OTP state with specified length | | `default_value(str)` | Set initial value | | `masked(bool)` | Enable masked display (shows asterisks) | | `set_value(str, window, cx)` | Set OTP value programmatically | | `value()` | Get current OTP value | | `set_masked(bool, window, cx)` | Toggle masked display | | `focus(window, cx)` | Focus the OTP input | | `focus_handle(cx)` | Get focus handle | ### OtpInput | Method | Description | | ---------------- | ---------------------------------------- | | `new(state)` | Create OTP input with state entity | | `groups(n)` | Set number of visual groups (default: 2) | | `disabled(bool)` | Set disabled state | | `small()` | Small size (6x6 px fields) | | `large()` | Large size (11x11 px fields) | | `with_size(px)` | Custom field size | ### InputEvent | Event | Description | | -------- | ------------------------------------------------- | | `Change` | Emitted when OTP is complete (all digits entered) | | `Focus` | Input received focus | | `Blur` | Input lost focus | ## Examples ### SMS Verification ```rust struct SmsVerification { otp_state: Entity, phone_number: String, is_verifying: bool, } impl SmsVerification { fn new(window: &mut Window, cx: &mut Context) -> Self { let otp_state = cx.new(|cx| OtpState::new(6, window, cx)); cx.subscribe(&otp_state, |this, state, event: &InputEvent, cx| { if let InputEvent::Change = event { let code = state.read(cx).value(); this.verify_sms_code(&code, cx); } }); Self { otp_state, phone_number: "+1234567890".to_string(), is_verifying: false, } } fn verify_sms_code(&mut self, code: &str, cx: &mut Context) { self.is_verifying = true; // API call to verify SMS code println!("Verifying SMS code: {}", code); cx.notify(); } } impl Render for SmsVerification { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .gap_4() .child(format!("Enter the 6-digit code sent to {}", self.phone_number)) .child(OtpInput::new(&self.otp_state)) .when(self.is_verifying, |this| { this.child("Verifying...") }) } } ``` ### Two-Factor Authentication ```rust struct TwoFactorAuth { otp_state: Entity, is_masked: bool, } impl TwoFactorAuth { fn new(window: &mut Window, cx: &mut Context) -> Self { let otp_state = cx.new(|cx| OtpState::new(6, window, cx) .masked(true) ); Self { otp_state, is_masked: true, } } fn toggle_visibility(&mut self, window: &mut Window, cx: &mut Context) { self.is_masked = !self.is_masked; self.otp_state.update(cx, |state, cx| { state.set_masked(self.is_masked, window, cx); }); } } impl Render for TwoFactorAuth { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .gap_4() .child("Enter your authenticator code") .child(OtpInput::new(&self.otp_state)) .child( Button::new("toggle-visibility") .label(if self.is_masked { "Show" } else { "Hide" }) .on_click(cx.listener(Self::toggle_visibility)) ) } } ``` ### PIN Entry ```rust struct PinEntry { pin_state: Entity, attempts: usize, max_attempts: usize, } impl PinEntry { fn new(window: &mut Window, cx: &mut Context) -> Self { let pin_state = cx.new(|cx| OtpState::new(4, window, cx) .masked(true) ); cx.subscribe(&pin_state, |this, state, event: &InputEvent, cx| { if let InputEvent::Change = event { let pin = state.read(cx).value(); this.verify_pin(&pin, cx); } }); Self { pin_state, attempts: 0, max_attempts: 3, } } fn verify_pin(&mut self, pin: &str, cx: &mut Context) { self.attempts += 1; // Simulate PIN verification if pin == "1234" { println!("PIN verified successfully!"); } else { println!("Incorrect PIN. Attempts: {}/{}", self.attempts, self.max_attempts); // Clear PIN on incorrect attempt self.pin_state.update(cx, |state, cx| { state.set_value("", window, cx); }); } cx.notify(); } } impl Render for PinEntry { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let is_locked = self.attempts >= self.max_attempts; v_flex() .gap_4() .child("Enter your 4-digit PIN") .child( OtpInput::new(&self.pin_state) .groups(1) .disabled(is_locked) ) .when(is_locked, |this| { this.child("Too many attempts. Please try again later.") }) .when(self.attempts > 0 && !is_locked, |this| { this.child(format!( "Incorrect PIN. {} attempts remaining.", self.max_attempts - self.attempts )) }) } } ``` ## Behavior ### Input Handling - **Numeric Only**: Accepts only digits (0-9) - **Auto-Focus**: Automatically moves to next field when digit is entered - **Backspace**: Removes current digit and moves to previous field - **Length Limit**: Prevents input beyond specified length - **Auto-Complete**: Emits `Change` event when all fields are filled ### Visual Feedback - **Focus Indicator**: Blue border and blinking cursor on active field - **Masking**: Shows asterisk icons instead of numbers when enabled - **Grouping**: Visual separation of fields into groups for better readability - **Disabled State**: Grayed out appearance when disabled ### Keyboard Navigation - **Arrow Keys**: Navigate between fields - **Tab**: Move to next focusable element - **Shift+Tab**: Move to previous focusable element - **Backspace**: Delete current digit and move backward - **Delete**: Clear current field ## Common Patterns ### Auto-Submit on Complete ```rust cx.subscribe(&otp_state, |this, state, event: &InputEvent, cx| { if let InputEvent::Change = event { let code = state.read(cx).value(); if code.len() == 6 { // Auto-submit when complete this.submit_verification_code(&code, cx); } } }); ``` ### Clear on Focus ```rust cx.subscribe(&otp_state, |this, state, event: &InputEvent, cx| { if let InputEvent::Focus = event { // Clear previous value when user starts entering new code state.update(cx, |state, cx| { state.set_value("", window, cx); }); } }); ``` ### Resend Code Timer ```rust struct OtpWithResend { otp_state: Entity, resend_timer: Option, can_resend: bool, } // Implementation would include timer logic for resend functionality ``` --- # TitleBar Source: /versions/v0.7.1/component/title-bar License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/title-bar and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. TitleBar provides a customizable window title bar that can replace the default OS title bar. It includes platform-specific window controls (minimize, maximize, close) and supports custom content and styling. The component automatically adapts to different operating systems (macOS, Windows, Linux) with appropriate behaviors and visual styles. ## Import ```rust use gpui_kit::component::TitleBar; ``` ## Usage ### Basic Title Bar ```rust TitleBar::new() .child(div().child("My Application")) ``` ### Title Bar with Custom Content ```rust TitleBar::new() .child( div() .flex() .items_center() .gap_3() .child("App Name") .child(Badge::new().count(5)) ) .child( div() .flex() .items_center() .gap_2() .child(Button::new("settings").icon(IconName::Settings)) .child(Button::new("profile").icon(IconName::User)) ) ``` ### Title Bar with Menu Bar ```rust TitleBar::new() .child( div() .flex() .items_center() .child(AppMenuBar::new(window, cx)) ) .child( div() .flex() .items_center() .justify_end() .gap_2() .child(Button::new("github").icon(IconName::GitHub)) .child(Button::new("notifications").icon(IconName::Bell)) ) ``` ### Title Bar with Window Controls (Linux only) ```rust TitleBar::new() .on_close_window(|_, window, cx| { // Custom close behavior window.push_notification("Saving before close...", cx); // Perform cleanup window.remove_window(); }) .child(div().child("Custom Close Behavior")) ``` ### Styled Title Bar ```rust TitleBar::new() .bg(cx.theme().primary) .border_color(cx.theme().primary_border) .child( div() .text_color(cx.theme().primary_foreground) .child("Styled Title Bar") ) ``` ### Title Bar Options for Window Use `TitleBar::window_options()` as the base of the window options, it sets up everything the title bar needs, including letting the title bar own dragging and double clicking instead of the system. ```rust use gpui_kit::WindowOptions; WindowOptions { window_bounds: Some(window_bounds), ..TitleBar::window_options() } ``` If you build the [`WindowOptions`] yourself, set both fields: ```rust use gpui_kit::WindowOptions; WindowOptions { titlebar: Some(TitleBar::title_bar_options()), // Required on macOS, otherwise the system also handles title bar double // clicks and delays title bar clicks to disambiguate double clicks. app_owns_titlebar_drag: true, ..Default::default() } ``` ## Platform Differences ### macOS - Uses native traffic light buttons (minimize, maximize, close) - Traffic light position is automatically set to `(9px, 9px)` - Double-click behavior calls `window.titlebar_double_click()` - Left padding accounts for traffic light buttons (80px) - Appears transparent by default ### Windows - Custom window control buttons with system integration - Uses `WindowControlArea` for proper window management - Control buttons have hover and active states - Fixed button width of 34px each - Left padding is 12px ### Linux - Custom window control buttons with manual event handling - Supports custom close window callback via `on_close_window()` - Double-click to maximize/restore window - Right-click shows window context menu - Window dragging supported in title bar area ## API Reference ### TitleBar | Method | Description | | --------------------- | ---------------------------------------- | | `new()` | Create a new title bar | | `child(element)` | Add child element to the title bar | | `on_close_window(fn)` | Custom close window handler (Linux only) | | `title_bar_options()` | Get default titlebar options for window | | `window_options()` | Get default window options for the title bar | ### Window Configuration | Property | Description | | ------------------------ | -------------------------------------------------------------- | | `appears_transparent` | Make title bar transparent (default: true) | | `traffic_light_position` | Position of macOS traffic lights | | `title` | Window title (optional when using custom title bar) | | `app_owns_titlebar_drag` | Let the title bar own dragging and double clicking (macOS only) | ### Title Bar Element (Internal) The `TitleBarElement` provides window dragging functionality on Linux platforms. ### Constants | Constant | Value | Description | | ------------------------ | ------------------------------- | ------------------------- | | `TITLE_BAR_HEIGHT` | `34px` | Standard title bar height | | `TITLE_BAR_LEFT_PADDING` | `80px` (macOS), `12px` (others) | Left padding for content | ## Examples ### Application Title Bar ```rust use gpui_kit::component::{TitleBar, button::Button, menu::AppMenuBar}; struct AppTitleBar { app_menu_bar: Entity, } impl Render for AppTitleBar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { TitleBar::new() .child( div() .flex() .items_center() .child(self.app_menu_bar.clone()) ) .child( div() .flex() .items_center() .justify_end() .gap_2() .child( Button::new("settings") .ghost() .icon(IconName::Settings) ) .child( Button::new("help") .ghost() .icon(IconName::HelpCircle) ) ) } } ``` ### Title Bar with Breadcrumbs ```rust TitleBar::new() .child( div() .flex() .items_center() .gap_2() .child("Home") .child(IconName::ChevronRight) .child("Documents") .child(IconName::ChevronRight) .child("Project") ) .child( div() .flex() .items_center() .gap_1() .child(Button::new("search").icon(IconName::Search).ghost()) .child(Button::new("more").icon(IconName::MoreHorizontal).ghost()) ) ``` ### Custom Themed Title Bar ```rust TitleBar::new() .h(px(40.)) // Custom height .bg(cx.theme().accent) .border_b_2() .border_color(cx.theme().accent_border) .child( div() .flex() .items_center() .text_color(cx.theme().accent_foreground) .font_weight_semibold() .child("Custom Theme App") ) ``` ### Title Bar with Status ```rust TitleBar::new() .child( div() .flex() .items_center() .gap_3() .child("My Editor") .child( div() .text_xs() .text_color(cx.theme().muted_foreground) .child("● Unsaved changes") ) ) .child( div() .flex() .items_center() .gap_2() .child( div() .text_xs() .text_color(cx.theme().muted_foreground) .child("Line 42, Col 12") ) .child( Button::new("sync") .small() .ghost() .icon(IconName::RotateCcw) .tooltip("Sync changes") ) ) ``` ### Minimal Title Bar ```rust TitleBar::new() .child( div() .text_center() .flex_1() .child("Document.txt") ) ``` ### Title Bar with Search ```rust TitleBar::new() .child( div() .flex() .items_center() .gap_3() .child("File Explorer") .child( Input::new("search") .placeholder("Search files...") .w(px(200.)) .small() ) ) ``` ## Notes - The title bar automatically handles platform-specific styling and behavior - Window controls are only rendered on Windows and Linux platforms - The component integrates with GPUI's window management system - Custom styling should consider platform conventions - Window dragging is handled automatically in appropriate areas --- # Rating Source: /versions/v0.7.1/component/rating License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/rating and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A star rating component that allows users to select a rating value. Supports different sizes, custom colors, disabled state, and click handlers. ## Import ```rust use gpui_kit::component::rating::Rating; ``` ## Usage ### Basic Rating ```rust Rating::new("my-rating") .value(3) .max(5) .on_click(|value, _, _| { println!("Rating changed to: {}", value); }) ``` ### Controlled Rating ```rust struct MyView { rating: usize, } impl Render for MyView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { Rating::new("rating") .value(self.rating) .max(5) .on_click(cx.listener(|view, value: &usize, _, cx| { view.rating = *value; cx.notify(); })) } } ``` ### Different Sizes The Rating component supports the [Sizable] trait for different sizes. ```rust Rating::new("rating").xsmall().value(3).max(5) Rating::new("rating").small().value(3).max(5) Rating::new("rating").value(3).max(5) // default (Medium) Rating::new("rating").large().value(3).max(5) ``` ### Custom Color By default, the rating uses the theme's `yellow` color. You can customize it with the `color` method. ```rust Rating::new("rating") .value(4) .max(5) .color(cx.theme().green) ``` ### Disabled State ```rust Rating::new("rating") .value(2) .max(5) .disabled(true) ``` ### Custom Maximum The default maximum is 5 stars, but you can set a different maximum value. ```rust Rating::new("rating") .value(7) .max(10) ``` ### Click Behavior The rating component has special click behavior: - Clicking on a star that's already filled will reduce the rating by 1 - Clicking on an unfilled star will set the rating to that star's value The `on_click` callback receives the new rating value as `&usize`. ```rust Rating::new("rating") .value(3) .max(5) .on_click(|new_value, _, _| { println!("New rating: {}", new_value); }) ``` ## API Reference - [Rating] ### Methods - `new(id: impl Into)` - Create a new Rating component - `with_size(size: impl Into)` - Set the star size (implements [Sizable]) - `value(value: usize)` - Set the initial rating value (0..=max) - `max(max: usize)` - Set the maximum number of stars (default: 5) - `color(color: impl Into)` - Set the active color (default: theme yellow) - `disabled(disabled: bool)` - Disable interaction (implements [Disableable]) - `on_click(handler: Fn(&usize, &mut Window, &mut App))` - Set click handler ## Examples ### Read-only Display ```rust Rating::new("rating") .value(4) .max(5) .disabled(true) ``` ### Interactive Rating with State ```rust struct ProductView { user_rating: usize, } impl Render for ProductView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .gap_3() .child( Rating::new("product-rating") .value(self.user_rating) .max(5) .on_click(cx.listener(|view, value: &usize, _, cx| { view.user_rating = *value; // Save rating to backend, etc. cx.notify(); })) ) .child(format!("Your rating: {}/5", self.user_rating)) } } ``` ### Large Rating with Custom Color ```rust Rating::new("rating") .large() .value(5) .max(5) .color(cx.theme().orange) ``` [Rating]: https://docs.rs/gpui-component/latest/gpui_component/rating/struct.Rating.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html [Disableable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Disableable.html --- # TimeField Source: /versions/v0.7.1/component/time-field License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/time-field and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A segmented input for a time of day. Each part — hour, minute, optional second and optional AM/PM — is edited on its own with the keyboard, and the field keeps its width while digits are typed. [DatePicker](date-picker) uses it to edit the time of a date. ## Import ```rust use gpui_kit::component::time_field::{ HourCycle, TimeField, TimeFieldEvent, TimeFieldState, TimePrecision, }; ``` ## Usage ### Basic Time Field ```rust let time = cx.new(|cx| TimeFieldState::new(window, cx)); TimeField::new(&time) ``` The field starts at `00:00`. Set a value with `set_time`, which does not emit an event: ```rust use chrono::NaiveTime; time.update(cx, |state, cx| { state.set_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap(), window, cx); }); ``` ### With Seconds ```rust let time = cx.new(|cx| { TimeFieldState::new(window, cx).precision(TimePrecision::Second) }); TimeField::new(&time) // 09:30:15 ``` ### 12-Hour Clock The field uses a 24-hour clock by default. `HourCycle::H12` shows hours from `12` to `11` followed by an AM/PM segment: ```rust let time = cx.new(|cx| { TimeFieldState::new(window, cx).hour_cycle(HourCycle::H12) }); TimeField::new(&time) // 09:30 PM ``` ### Sizes, Disabled and Invalid ```rust TimeField::new(&time).small() TimeField::new(&time).large() TimeField::new(&time).disabled(true) // Show the owner's validation result; edits are not rejected. TimeField::new(&time).invalid(true) ``` ## Handle Changes User edits emit `TimeFieldEvent::Change` with the new time: ```rust cx.subscribe(&time, |this, _, event, cx| match event { TimeFieldEvent::Change(time) => { this.reminder = *time; cx.notify(); } }); ``` ## Keyboard | Key | Action | | --- | --- | | `Up` / `Down` | Step the selected segment; it wraps without changing the next unit | | `Left` / `Right` | Move between segments | | `Tab` / `Shift-Tab` | Move between segments, then leave the field | | `0`–`9` | Type the selected segment and move on once it is complete | | `a` / `p` | Set AM or PM on a 12-hour clock | | `Backspace` / `Delete` | Reset the selected segment | --- # VirtualList Source: /versions/v0.7.1/component/virtual-list License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/virtual-list and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. VirtualList is a high-performance component designed for efficiently rendering large datasets by only rendering visible items. Unlike uniform lists, VirtualList supports variable item sizes, making it perfect for complex layouts like tables with different row heights or dynamic content. ## Import ```rust use gpui_kit::component::{ v_virtual_list, h_virtual_list, VirtualListScrollHandle, scroll::{Scrollbar, ScrollbarState, ScrollbarAxis}, }; use std::rc::Rc; use gpui_kit::{px, size, ScrollStrategy, Size, Pixels}; ``` ## Usage ### Basic Vertical Virtual List ```rust use std::rc::Rc; use gpui_kit::{px, size, Size, Pixels}; pub struct ListViewExample { items: Vec, item_sizes: Rc>>, scroll_handle: VirtualListScrollHandle, } impl ListViewExample { fn new(cx: &mut Context) -> Self { let items = (0..5000).map(|i| format!("Item {}", i)).collect::>(); let item_sizes = Rc::new(items.iter().map(|_| size(px(200.), px(30.))).collect()); Self { items, item_sizes, scroll_handle: VirtualListScrollHandle::new(), } } } impl Render for ListViewExample { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_virtual_list( cx.entity().clone(), "my-list", self.item_sizes.clone(), |view, visible_range, _, cx| { visible_range .map(|ix| { div() .h(px(30.)) .w_full() .bg(cx.theme().secondary) .child(format!("Item {}", ix)) }) .collect() }, ) .track_scroll(&self.scroll_handle) } } ``` ### Horizontal Virtual List ```rust h_virtual_list( cx.entity().clone(), "horizontal-list", item_sizes.clone(), |view, visible_range, _, cx| { visible_range .map(|ix| { div() .w(px(120.)) // Width is used for horizontal lists .h_full() .bg(cx.theme().accent) .child(format!("Card {}", ix)) }) .collect() }, ) .track_scroll(&scroll_handle) ``` ### Variable Item Sizes VirtualList excels at handling items with different sizes: ```rust let item_sizes = Rc::new( (0..1000) .map(|i| { // Different heights based on index let height = if i % 5 == 0 { px(60.) // Header items are taller } else if i % 3 == 0 { px(45.) // Some items are medium } else { px(30.) // Regular items }; size(px(300.), height) }) .collect::>() ); v_virtual_list( cx.entity().clone(), "variable-list", item_sizes.clone(), |view, visible_range, _, cx| { visible_range .map(|ix| { let content = if ix % 5 == 0 { format!("Header {}", ix / 5) } else { format!("Item {}", ix) }; let bg_color = if ix % 5 == 0 { cx.theme().accent } else { cx.theme().secondary }; div() .w_full() .h(item_sizes[ix].height) .bg(bg_color) .flex() .items_center() .px_4() .child(content) }) .collect() }, ) ``` ### Table-like Layout with Multiple Columns VirtualList can render complex layouts like tables: ```rust v_virtual_list( cx.entity().clone(), "table-list", item_sizes.clone(), |view, visible_range, _, cx| { visible_range .map(|row_ix| { h_flex() .w_full() .h(px(40.)) .border_b_1() .border_color(cx.theme().border) .children( // Multiple columns per row (0..5).map(|col_ix| { div() .flex_1() .h_full() .px_3() .flex() .items_center() .child(format!("R{}C{}", row_ix, col_ix)) }) ) }) .collect() }, ) ``` ## Scroll Handling ### Basic Scroll Control ```rust pub struct ScrollableList { scroll_handle: VirtualListScrollHandle, scroll_state: ScrollbarState, } impl Render for ScrollableList { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .relative() .size_full() .child( v_virtual_list(/* ... */) .track_scroll(&self.scroll_handle) .p_4() .border_1() .border_color(cx.theme().border) ) .child( // Add scrollbars div() .absolute() .top_0() .left_0() .right_0() .bottom_0() .child( Scrollbar::both(&self.scroll_state, &self.scroll_handle) .axis(ScrollbarAxis::Vertical) ) ) } } ``` ### Programmatic Scrolling ```rust impl ScrollableList { // Scroll to specific item fn scroll_to_item(&self, index: usize) { self.scroll_handle.scroll_to_item(index, ScrollStrategy::Top); } // Center item in view fn center_item(&self, index: usize) { self.scroll_handle.scroll_to_item(index, ScrollStrategy::Center); } // Scroll to bottom fn scroll_to_bottom(&self) { self.scroll_handle.scroll_to_bottom(); } // Get current scroll position fn get_scroll_offset(&self) -> Point { self.scroll_handle.offset() } // Set scroll position manually fn set_scroll_position(&self, offset: Point) { self.scroll_handle.set_offset(offset); } } ``` ### Both Axis Scrolling For content that scrolls in both directions: ```rust v_virtual_list( cx.entity().clone(), "both-axis", item_sizes.clone(), |view, visible_range, _, cx| { visible_range .map(|ix| { // Wide content that requires horizontal scrolling h_flex() .gap_2() .children((0..20).map(|col| { div() .min_w(px(100.)) .h(px(30.)) .bg(cx.theme().secondary) .child(format!("R{}C{}", ix, col)) })) }) .collect() }, ) .track_scroll(&scroll_handle) .child( Scrollbar::both(&scroll_state, &scroll_handle) .axis(ScrollbarAxis::Both) ) ``` ## Performance Optimization ### Efficient Item Rendering Only visible items are rendered, making VirtualList highly performant: ```rust // The render function is only called for visible items v_virtual_list( cx.entity().clone(), "efficient-list", item_sizes.clone(), |view, visible_range, _, cx| { // visible_range contains only the items currently visible // This typically contains 10-20 items, not all 10,000 println!("Rendering {} items out of {}", visible_range.len(), view.total_items); visible_range .map(|ix| { // Complex rendering logic here // Only executed for visible items expensive_item_renderer(ix, cx) }) .collect() }, ) ``` ### Memory Management VirtualList automatically manages memory by: - Only rendering visible items - Reusing rendered elements when scrolling - Calculating precise visible ranges ```rust // Large dataset - only visible items use memory let large_dataset = (0..1_000_000).map(|i| format!("Item {}", i)).collect(); // Memory usage remains constant regardless of dataset size v_virtual_list(/* render only visible items */) ``` ### Variable Heights with Caching For dynamic content with calculated heights: ```rust struct DynamicItem { content: String, calculated_height: Option, } impl MyView { fn calculate_item_size(&mut self, ix: usize) -> Size { if let Some(height) = self.items[ix].calculated_height { return size(px(300.), height); } // Calculate height based on content let content_lines = self.items[ix].content.lines().count(); let height = px(20. + content_lines as f32 * 16.); // Cache the calculated height self.items[ix].calculated_height = Some(height); size(px(300.), height) } } ``` ## Examples ### File Explorer with Virtual Scrolling ```rust pub struct FileExplorer { files: Vec, item_sizes: Rc>>, scroll_handle: VirtualListScrollHandle, selected_index: Option, } impl FileExplorer { fn calculate_item_heights(&mut self) { let sizes = self.files.iter().map(|file| { // Different heights for different file types let height = match file.file_type { FileType::Directory => px(40.), FileType::Image => px(60.), // Larger for thumbnails FileType::Document => px(35.), _ => px(30.), }; size(px(400.), height) }).collect(); self.item_sizes = Rc::new(sizes); } } impl Render for FileExplorer { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_virtual_list( cx.entity().clone(), "file-list", self.item_sizes.clone(), |view, visible_range, _, cx| { visible_range .map(|ix| { let file = &view.files[ix]; let is_selected = view.selected_index == Some(ix); div() .w_full() .h(view.item_sizes[ix].height) .px_3() .py_1() .flex() .items_center() .gap_2() .bg(if is_selected { cx.theme().accent } else { Color::transparent() }) .hover(|style| style.bg(cx.theme().secondary_hover)) .child(file_icon(&file.file_type)) .child(file.name.clone()) .child( div() .flex_1() .text_right() .text_xs() .text_color(cx.theme().muted_foreground) .child(format_file_size(file.size)) ) .on_click(cx.listener(move |view, _, _, cx| { view.selected_index = Some(ix); cx.notify(); })) }) .collect() }, ) .track_scroll(&self.scroll_handle) } } ``` ### Chat Messages with Auto-scroll ```rust pub struct ChatWindow { messages: Vec, scroll_handle: VirtualListScrollHandle, auto_scroll: bool, } impl ChatWindow { fn add_message(&mut self, message: ChatMessage, cx: &mut Context) { self.messages.push(message); // Recalculate item sizes self.update_item_sizes(); if self.auto_scroll { // Scroll to bottom for new messages self.scroll_handle.scroll_to_bottom(); } cx.notify(); } fn update_item_sizes(&mut self) { let sizes = self.messages.iter().map(|msg| { // Calculate height based on message content let lines = msg.content.lines().count().max(1); let height = px(40. + (lines.saturating_sub(1)) as f32 * 16.); size(px(350.), height) }).collect(); self.item_sizes = Rc::new(sizes); } } impl Render for ChatWindow { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .size_full() .child( v_virtual_list( cx.entity().clone(), "chat-messages", self.item_sizes.clone(), |view, visible_range, _, cx| { visible_range .map(|ix| { let msg = &view.messages[ix]; div() .w_full() .px_4() .py_2() .child( v_flex() .gap_1() .child( h_flex() .justify_between() .child( div() .text_sm() .font_weight(FontWeight::SEMIBOLD) .child(msg.author.clone()) ) .child( div() .text_xs() .text_color(cx.theme().muted_foreground) .child(format_timestamp(msg.timestamp)) ) ) .child( div() .text_sm() .child(msg.content.clone()) ) ) }) .collect() }, ) .track_scroll(&self.scroll_handle) .flex_1() ) .child( // Chat input at bottom div() .w_full() .h(px(60.)) .border_t_1() .border_color(cx.theme().border) .child("Chat input here...") ) } } ``` ### Data Grid with Fixed Headers ```rust pub struct DataGrid { headers: Vec, data: Vec>, column_widths: Vec, scroll_handle: VirtualListScrollHandle, } impl Render for DataGrid { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .size_full() .child( // Fixed header h_flex() .w_full() .h(px(40.)) .bg(cx.theme().secondary) .border_b_1() .border_color(cx.theme().border) .children( self.headers.iter().zip(&self.column_widths).map(|(header, &width)| { div() .w(width) .h_full() .px_3() .flex() .items_center() .font_weight(FontWeight::SEMIBOLD) .child(header.clone()) }) ) ) .child( // Virtual list for data rows v_virtual_list( cx.entity().clone(), "data-rows", Rc::new(vec![size(px(800.), px(32.)); self.data.len()]), |view, visible_range, _, cx| { visible_range .map(|row_ix| { h_flex() .w_full() .h(px(32.)) .border_b_1() .border_color(cx.theme().border.opacity(0.5)) .children( view.data[row_ix].iter().zip(&view.column_widths).map(|(cell, &width)| { div() .w(width) .h_full() .px_3() .flex() .items_center() .child(cell.clone()) }) ) }) .collect() }, ) .track_scroll(&self.scroll_handle) .flex_1() ) } } ``` ## Best Practices 1. **Item Sizing**: Pre-calculate item sizes when possible for best performance 2. **Memory Management**: Use VirtualList for any list with >50 items 3. **Scroll Performance**: Avoid heavy computations in render functions 4. **State Management**: Keep item state separate from rendering logic 5. **Error Handling**: Handle edge cases like empty lists gracefully 6. **Testing**: Test with various data sizes and scroll positions ## Performance Tips 1. **Pre-calculate Sizes**: Calculate item sizes upfront rather than during render 2. **Minimize Re-renders**: Use stable item keys and avoid recreating render functions 3. **Batch Updates**: Group multiple data changes together 4. **Efficient Rendering**: Keep item render functions lightweight 5. **Memory Monitoring**: Monitor memory usage with very large datasets --- # DatePicker Source: /versions/v0.7.1/component/date-picker License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/date-picker and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A flexible date picker component with calendar interface that supports single date selection, date range selection, custom date formatting, disabled dates, and preset ranges. ## Import ```rust use gpui_kit::component::{ date_picker::{DatePicker, DatePickerState, DateRangePreset, DatePickerEvent, DateTime}, calendar::{Date, Matcher}, time_field::{HourCycle, TimePrecision}, }; ``` ## Usage ### Basic Date Picker ```rust let date_picker = cx.new(|cx| DatePickerState::new(window, cx)); DatePicker::new(&date_picker) ``` ### With Initial Date ```rust use chrono::Local; let date_picker = cx.new(|cx| { let mut picker = DatePickerState::new(window, cx); picker.set_date(Local::now().naive_local().date(), window, cx); picker }); DatePicker::new(&date_picker) ``` ### Date Range Picker ```rust use chrono::{Local, Days}; // Range mode picker let range_picker = cx.new(|cx| DatePickerState::range(window, cx)); DatePicker::new(&range_picker) .number_of_months(2) // Show 2 months for easier range selection // With initial range let range_picker = cx.new(|cx| { let now = Local::now().naive_local().date(); let mut picker = DatePickerState::new(window, cx); picker.set_date( (now, now.checked_add_days(Days::new(7)).unwrap()), window, cx, ); picker }); DatePicker::new(&range_picker) .number_of_months(2) ``` ### Date and Time Set a `time_precision` to edit the time of day as well. The popup then shows a time field below the calendar and stays open after a date is picked, so the time can be adjusted next. Every edit is reported as it happens. Clicking the selected date again — or double-clicking a date — confirms it and closes the popup, as do Enter, Escape and a click outside. ```rust use chrono::NaiveTime; let date_time_picker = cx.new(|cx| { DatePickerState::new(window, cx) .time_precision(TimePrecision::Minute) // or TimePrecision::Second .default_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap()) }); DatePicker::new(&date_time_picker) ``` The time uses a 24-hour clock by default. Use `hour_cycle` for a 12-hour clock with an AM/PM segment: ```rust DatePickerState::new(window, cx) .time_precision(TimePrecision::Minute) .hour_cycle(HourCycle::H12) // 09:30 PM ``` `default_time` is the time a date gets before the user edits it, `00:00` unless configured. The display format follows the precision and hour cycle (for example `%Y/%m/%d %H:%M` or `%Y/%m/%d %I:%M %p`) unless `date_format` is set. Read and write the whole value with `date_time` and `set_date_time`; `date` and `set_date` still address the date part and keep the current time. ```rust use chrono::Local; date_time_picker.update(cx, |state, cx| { state.set_date_time(Local::now().naive_local(), window, cx); }); if let DateTime::Single(Some(at)) = date_time_picker.read(cx).date_time() { println!("Selected {at}"); } ``` A range picker edits dates only, even with a `time_precision`. For a range with times, place two pickers side by side and validate the order in the owner: ```rust h_flex() .gap_2() .child(DatePicker::new(&start_picker)) .child("–") .child(DatePicker::new(&end_picker)) ``` In the time field, Up/Down change the selected segment, Left/Right and Tab/Shift-Tab move between segments, digits type a value and advance to the next segment, `a`/`p` set AM or PM, and Backspace resets the segment. ### With Custom Date Format ```rust let date_picker = cx.new(|cx| { DatePickerState::new(window, cx) .date_format("%Y-%m-%d") // ISO format }); DatePicker::new(&date_picker) // Other format examples: // "%m/%d/%Y" -> 12/25/2023 // "%B %d, %Y" -> December 25, 2023 // "%d %b %Y" -> 25 Dec 2023 ``` ### With Placeholder ```rust DatePicker::new(&date_picker) .placeholder("Select a date...") ``` ### Cleanable Date Picker ```rust DatePicker::new(&date_picker) .cleanable(true) // Show clear button when date is selected ``` ### Different Sizes ```rust DatePicker::new(&date_picker).large() DatePicker::new(&date_picker) // medium (default) DatePicker::new(&date_picker).small() ``` ### Disabled State ```rust DatePicker::new(&date_picker).disabled(true) ``` ### Custom Appearance ```rust // Without default styling DatePicker::new(&date_picker).appearance(false) // Use in custom container div() .border_b_2() .px_6() .py_3() .border_color(cx.theme().border) .bg(cx.theme().secondary) .child(DatePicker::new(&date_picker).appearance(false)) ``` ## Date Restrictions ### Disabled Weekends ```rust use gpui_kit::component::calendar; let date_picker = cx.new(|cx| { DatePickerState::new(window, cx) .disabled_matcher(vec![0, 6]) // Sunday=0, Saturday=6 }); DatePicker::new(&date_picker) ``` ### Disabled Date Range ```rust use chrono::{Local, Days}; let now = Local::now().naive_local().date(); let date_picker = cx.new(|cx| { DatePickerState::new(window, cx) .disabled_matcher(calendar::Matcher::range( Some(now), now.checked_add_days(Days::new(7)), )) }); DatePicker::new(&date_picker) ``` ### Disabled Date Interval ```rust let date_picker = cx.new(|cx| { DatePickerState::new(window, cx) .disabled_matcher(calendar::Matcher::interval( Some(now), now.checked_add_days(Days::new(5)) )) }); DatePicker::new(&date_picker) ``` ### Custom Disabled Dates ```rust // Disable first 5 days of each month let date_picker = cx.new(|cx| { DatePickerState::new(window, cx) .disabled_matcher(calendar::Matcher::custom(|date| { date.day0() < 5 })) }); DatePicker::new(&date_picker) // Disable all Mondays let date_picker = cx.new(|cx| { DatePickerState::new(window, cx) .disabled_matcher(calendar::Matcher::custom(|date| { date.weekday() == chrono::Weekday::Mon })) }); ``` ## Custom Year Range By default, the date picker shows 50 years before and after the current year in year selection mode. Use `set_year_range` to configure a different range — for example, a birthday picker that goes back to 1900. The `range` argument uses a **half-open interval** `(start, end)` where `end` is **exclusive**. Pass `(1900, current_year + 1)` to include `current_year`. ```rust use chrono::Datelike; // Birthday picker: allow years from 1900 to the current year (inclusive) let birthday_picker = cx.new(|cx| { let current_year = chrono::Local::now().year(); let mut picker = DatePickerState::new(window, cx) .date_format("%Y-%m-%d"); picker.set_year_range((1900, current_year + 1), window, cx); picker }); DatePicker::new(&birthday_picker) .cleanable(true) .placeholder("Select birthday") ``` `set_year_range` works for both single-date and range-mode pickers. ## Preset Ranges ### Single Date Presets ```rust use chrono::{Utc, Duration}; let presets = vec![ DateRangePreset::single( "Yesterday", (Utc::now() - Duration::days(1)).naive_local().date(), ), DateRangePreset::single( "Last Week", (Utc::now() - Duration::weeks(1)).naive_local().date(), ), DateRangePreset::single( "Last Month", (Utc::now() - Duration::days(30)).naive_local().date(), ), ]; DatePicker::new(&date_picker) .presets(presets) ``` ### Date Range Presets ```rust let range_presets = vec![ DateRangePreset::range( "Last 7 Days", (Utc::now() - Duration::days(7)).naive_local().date(), Utc::now().naive_local().date(), ), DateRangePreset::range( "Last 30 Days", (Utc::now() - Duration::days(30)).naive_local().date(), Utc::now().naive_local().date(), ), DateRangePreset::range( "Last 90 Days", (Utc::now() - Duration::days(90)).naive_local().date(), Utc::now().naive_local().date(), ), ]; DatePicker::new(&date_picker) .number_of_months(2) .presets(range_presets) ``` ## Handle Date Selection Events ```rust let date_picker = cx.new(|cx| DatePickerState::new(window, cx)); cx.subscribe(&date_picker, |view, _, event, _| { match event { DatePickerEvent::Change(value) => { match value.date() { Date::Single(Some(selected_date)) => { println!("Single date selected: {}", selected_date); } Date::Range(Some(start), Some(end)) => { println!("Date range selected: {} to {}", start, end); } Date::Range(Some(start), None) => { println!("Range start selected: {}", start); } _ => { println!("Date cleared"); } } } } }); ``` ## Multiple Months Display ```rust // Show 2 months side by side (useful for date ranges) DatePicker::new(&date_picker) .number_of_months(2) // Show 3 months DatePicker::new(&date_picker) .number_of_months(3) ``` ## Advanced Examples ### Business Days Only ```rust use chrono::Weekday; let business_days_picker = cx.new(|cx| { DatePickerState::new(window, cx) .disabled_matcher(calendar::Matcher::custom(|date| { matches!(date.weekday(), Weekday::Sat | Weekday::Sun) })) }); DatePicker::new(&business_days_picker) .placeholder("Select business day") ``` ### Date Range with Max Duration ```rust use chrono::Days; let max_30_days_picker = cx.new(|cx| DatePickerState::range(window, cx)); cx.subscribe(&max_30_days_picker, |view, picker, event, _| { match event { DatePickerEvent::Change(value) => { let Date::Range(Some(start), Some(end)) = value.date() else { return; }; let duration = end.signed_duration_since(start).num_days(); if duration > 30 { // Reset to start date only if range exceeds 30 days picker.update(cx, |state, cx| { state.set_date(Date::Range(Some(start), None), window, cx); }); } } } }); DatePicker::new(&max_30_days_picker) .number_of_months(2) .placeholder("Select up to 30 days") ``` ### Quarter Presets ```rust use chrono::{NaiveDate, Datelike}; fn quarter_start(year: i32, quarter: u32) -> NaiveDate { let month = (quarter - 1) * 3 + 1; NaiveDate::from_ymd_opt(year, month, 1).unwrap() } fn quarter_end(year: i32, quarter: u32) -> NaiveDate { let month = quarter * 3; let start = NaiveDate::from_ymd_opt(year, month, 1).unwrap(); NaiveDate::from_ymd_opt(year, month, start.days_in_month()).unwrap() } let year = Local::now().year(); let quarterly_presets = vec![ DateRangePreset::range("Q1", quarter_start(year, 1), quarter_end(year, 1)), DateRangePreset::range("Q2", quarter_start(year, 2), quarter_end(year, 2)), DateRangePreset::range("Q3", quarter_start(year, 3), quarter_end(year, 3)), DateRangePreset::range("Q4", quarter_start(year, 4), quarter_end(year, 4)), ]; DatePicker::new(&date_picker) .presets(quarterly_presets) ``` ## Examples ### Event Date Picker ```rust let event_date = cx.new(|cx| { let mut picker = DatePickerState::new(window, cx) .date_format("%B %d, %Y") .disabled_matcher(calendar::Matcher::custom(|date| { // Disable past dates *date < Local::now().naive_local().date() })); picker }); DatePicker::new(&event_date) .placeholder("Choose event date") .cleanable(true) ``` ### Booking System Date Range ```rust let booking_range = cx.new(|cx| DatePickerState::range(window, cx)); let booking_presets = vec![ DateRangePreset::range("This Weekend", /* weekend dates */), DateRangePreset::range("Next Week", /* next week dates */), DateRangePreset::range("This Month", /* this month dates */), ]; DatePicker::new(&booking_range) .number_of_months(2) .presets(booking_presets) .placeholder("Select check-in and check-out dates") ``` ### Financial Period Selector ```rust let financial_period = cx.new(|cx| { DatePickerState::range(window, cx) .date_format("%Y-%m-%d") }); DatePicker::new(&financial_period) .number_of_months(3) .presets(quarterly_presets) .placeholder("Select reporting period") ``` --- # Resizable Source: /versions/v0.7.1/component/resizable License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/resizable and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. The resizable component system provides a flexible way to create layouts with resizable panels. It supports both horizontal and vertical resizing, nested layouts, size constraints, and drag handles. Perfect for creating paned interfaces, split views, and adjustable dashboards. ## Import ```rust use gpui_kit::component::resizable::{ h_resizable, v_resizable, resizable_panel, ResizablePanelGroup, ResizablePanel, ResizableState, ResizablePanelEvent }; ``` ## Usage Use `h_resizable` to create a horizontal layout, `v_resizable` to create a vertical layout. The first argument is the `id` for this [ResizablePanelGroup]. In GPUI, the `id` must be unique within the layout scope (The nearest parent has presents `id`). ```rust h_resizable("my-layout") .on_resize(|state, window, cx| { // Handle resize event // You can read the panel sizes from the state. let state = state.read(cx); let sizes = state.sizes(); }) .child( // Use resizable_panel() to create a sized panel. resizable_panel() .size(px(200.)) .child("Left Panel") ) .child( // Or you can just add AnyElement without a size. div() .child("Right Panel") .into_any_element() ) ``` The `v_resizable` component is used to create a vertical layout. ```rust v_resizable("vertical-layout") .child( resizable_panel() .size(px(100.)) .child("Top Panel") ) .child( div() .child("Bottom Panel") .into_any_element() ) ``` ### Resize Handle Appearance A divider rests as a hairline. As the pointer engages it, an indicator grows and solidifies on top of that line through three levels — hovered, pressed, dragging — so a drag stays readable after the pointer has left the handle's own band, which happens within a pixel or two of the drag starting. `h_resizable`, `v_resizable` and the Dock install this appearance. `resize_handle_appearance()` is exported for a handle you build yourself, or to pass explicitly to `with_handle_appearance`: ```rust gpui_kit::base::h_resizable("my-layout") .with_handle_appearance(resize_handle_appearance()) ``` The indicator's duration and easing come from the theme's motion tokens, and a system reduced-motion preference takes it straight to its target. ### Panel Size Constraints ```rust resizable_panel() .size(px(200.)) // Initial size .size_range(px(150.)..px(400.)) // Min and max size .child("Constrained Panel") ``` ### Multiple Panels ```rust h_resizable("multi-panel", state) .child( resizable_panel() .size(px(200.)) .size_range(px(150.)..px(300.)) .child("Left Panel") ) .child( resizable_panel() .child("Center Panel") ) .child( resizable_panel() .size(px(250.)) .child("Right Panel") ) ``` ### Nested Layouts ```rust v_resizable("main-layout", window, cx) .child( resizable_panel() .size(px(300.)) .child( h_resizable("nested-layout", window, cx) .child( resizable_panel() .size(px(200.)) .child("Top Left") ) .child( resizable_panel() .child("Top Right") ) ) ) .child( resizable_panel() .child("Bottom Panel") ) ``` ### Nested Panel Groups ```rust h_resizable("outer", window, cx) .child( resizable_panel() .size(px(200.)) .child("Left Panel") ) .group( v_resizable("inner", window, cx) .child( resizable_panel() .size(px(150.)) .child("Top Right") ) .child( resizable_panel() .child("Bottom Right") ) ) ``` ### Conditional Panel Visibility ```rust resizable_panel() .visible(self.show_sidebar) .size(px(250.)) .child("Sidebar Content") ``` ### Panel with Size Limits ```rust // Panel with minimum size only resizable_panel() .size_range(px(100.)..Pixels::MAX) .child("Flexible Panel") // Panel with both min and max resizable_panel() .size_range(px(200.)..px(500.)) .child("Constrained Panel") // Panel with exact constraints resizable_panel() .size(px(300.)) .size_range(px(300.)..px(300.)) // Fixed size .child("Fixed Panel") ``` ## Examples ### File Explorer Layout ```rust struct FileExplorer { show_sidebar: bool, } impl Render for FileExplorer { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { h_resizable("file-explorer", window, cx) .child( resizable_panel() .visible(self.show_sidebar) .size(px(250.)) .size_range(px(200.)..px(400.)) .child( v_flex() .p_4() .child("📁 Folders") .child("• Documents") .child("• Pictures") .child("• Downloads") ) ) .child( v_flex() .p_4() .child("📄 Files") .child("file1.txt") .child("file2.pdf") .child("image.png") .into_any_element() ) } } ``` ### IDE Layout ```rust struct IDELayout { main_state: Entity, sidebar_state: Entity, bottom_state: Entity, } impl Render for IDELayout { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { h_resizable("ide-main", self.main_state.clone()) .child( resizable_panel() .size(px(300.)) .size_range(px(200.)..px(500.)) .child( v_resizable("sidebar", self.sidebar_state.clone()) .child( resizable_panel() .size(px(200.)) .child("File Explorer") ) .child( resizable_panel() .child("Outline") ) ) ) .child( resizable_panel() .child( v_resizable("editor-area", self.bottom_state.clone()) .child( resizable_panel() .child("Code Editor") ) .child( resizable_panel() .size(px(150.)) .size_range(px(100.)..px(300.)) .child("Terminal / Output") ) ) ) } } ``` ### Dashboard with Widgets ```rust struct Dashboard { layout_state: Entity, widget_state: Entity, } impl Render for Dashboard { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { v_resizable("dashboard", self.layout_state.clone()) .child( resizable_panel() .size(px(120.)) .child("Header / Navigation") ) .child( resizable_panel() .child( h_resizable("widgets", self.widget_state.clone()) .child( resizable_panel() .size(px(300.)) .child("Chart Widget") ) .child( resizable_panel() .child("Data Table") ) .child( resizable_panel() .size(px(250.)) .child("Stats Panel") ) ) ) .child( resizable_panel() .size(px(60.)) .child("Footer") ) } } ``` ### Settings Panel ```rust struct SettingsPanel { settings_state: Entity, } impl SettingsPanel { fn new(cx: &mut Context) -> Self { let settings_state = ResizableState::new(cx); // Listen for resize events to save layout preferences cx.subscribe(&settings_state, |this, _, event: &ResizablePanelEvent, cx| { match event { ResizablePanelEvent::Resized => { this.save_layout_preferences(cx); } } }); Self { settings_state } } fn save_layout_preferences(&self, cx: &mut Context) { let sizes = self.settings_state.read(cx).sizes(); // Save to preferences println!("Saving layout: {:?}", sizes); } } impl Render for SettingsPanel { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { h_resizable("settings", self.settings_state.clone()) .child( resizable_panel() .size(px(200.)) .size_range(px(150.)..px(300.)) .child( v_flex() .gap_2() .p_4() .child("Categories") .child("• General") .child("• Appearance") .child("• Advanced") ) ) .child( resizable_panel() .child( div() .p_6() .child("Settings Content Area") ) ) } } ``` ## Best Practices 1. **State Management**: Use separate ResizableState for independent layouts 2. **Size Constraints**: Always set reasonable min/max sizes for panels 3. **Event Handling**: Subscribe to ResizablePanelEvent for layout persistence 4. **Nested Layouts**: Use `.group()` method for clean nested structures 5. **Performance**: Avoid excessive nesting for better performance 6. **User Experience**: Provide adequate handle padding for easier interaction --- # ColorPicker Source: /versions/v0.7.1/component/color-picker License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/color-picker and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A versatile color picker component that provides an intuitive interface for color selection. Features include color palettes, hex input, featured colors, and support for various color formats including RGB, HSL, and hex values with alpha channel support. ## Import ```rust use gpui_kit::component::color_picker::{ ColorPicker, ColorPickerEvent, ColorPickerState, ColorSelect, }; ``` ## Usage ### Basic Color Picker ```rust use gpui_kit::{Entity, Window, Context}; // Create color picker state let color_picker = cx.new(|cx| ColorPickerState::new(window, cx) .default_value(cx.theme().primary) ); // Create the color picker component ColorPicker::new(&color_picker) ``` ### With Event Handling ```rust use gpui_kit::{Subscription, Entity}; let color_picker = cx.new(|cx| ColorPickerState::new(window, cx)); let _subscription = cx.subscribe(&color_picker, |this, _, ev, _| match ev { ColorPickerEvent::Change(color) => { if let Some(color) = color { println!("Selected color: {}", color.to_hex()); // Handle color change } } }); ColorPicker::new(&color_picker) ``` ### Setting Default Color ```rust use gpui_kit::Hsla; let color_picker = cx.new(|cx| ColorPickerState::new(window, cx) .default_value(cx.theme().blue) // Set default color ); ``` ### Different Sizes ```rust // Small color picker ColorPicker::new(&color_picker).small() // Medium color picker (default) ColorPicker::new(&color_picker) // Large color picker ColorPicker::new(&color_picker).large() // Extra small color picker ColorPicker::new(&color_picker).xsmall() ``` ### With Custom Featured Colors ```rust use gpui_kit::Hsla; let featured_colors = vec![ cx.theme().red, cx.theme().green, cx.theme().blue, cx.theme().yellow, // Add your custom colors ]; ColorPicker::new(&color_picker) .featured_colors(featured_colors) ``` ### With Icon Instead of Color Square ```rust use gpui_kit::component::IconName; ColorPicker::new(&color_picker) .icon(IconName::Palette) ``` ### With Label ```rust ColorPicker::new(&color_picker) .label("Background Color") ``` ### Custom Anchor Position ```rust use gpui_kit::Anchor; ColorPicker::new(&color_picker) .anchor(Anchor::TopRight) // Dropdown opens to top-right ``` ### Color Select `ColorSelect` draws the picker as a framed field, like a `Select`: a swatch of the current color, its hex value and a caret. Clicking anywhere on the field opens the same popover. Use it in forms, where the control should share the height and frame of the inputs around it; keep `ColorPicker` for a compact swatch in a toolbar. ```rust use gpui_kit::component::{Sizable as _, form::field}; field() .label("Theme color") .child(ColorSelect::new(&color_picker)) // Follows the same sizes as Input and Select. ColorSelect::new(&color_picker).large() // Shown while no color is selected. ColorSelect::new(&color_picker).placeholder("Pick a color") ``` ## Color Selection Interface ### Color Palettes The color picker includes predefined color palettes organized by color family: - **Stone**: Neutral grays and stone colors - **Red**: Red color variations from light to dark - **Orange**: Orange color variations - **Yellow**: Yellow color variations - **Green**: Green color variations - **Cyan**: Cyan color variations - **Blue**: Blue color variations - **Purple**: Purple color variations - **Pink**: Pink color variations Each palette provides multiple shades and tints of the base color, allowing for precise color selection. ### Featured Colors Section A customizable section at the top of the picker that displays frequently used or brand colors. If not specified, defaults to theme colors: - Primary colors from the current theme - Light variants of theme colors - Essential UI colors (red, blue, green, yellow, cyan, magenta) ### Hex Input Field A text input field that allows direct entry of hex color values: - Supports standard 6-digit hex format (#RRGGBB) - Real-time validation and preview - Updates color picker state automatically - Press Enter to confirm selection ## Color Formats ### RGB (Red, Green, Blue) Colors are internally represented using GPUI's `Hsla` format but can be converted to RGB: ```rust let color = cx.theme().blue; // Access RGB components through Hsla methods ``` ### HSL (Hue, Saturation, Lightness) Native format used by the color picker: ```rust use gpui_kit::Hsla; // Create HSL color let color = Hsla::hsl(240.0, 100.0, 50.0); // Blue color // Access components let hue = color.h; let saturation = color.s; let lightness = color.l; ``` ### Hex Format Standard web hex format with # prefix: ```rust // Convert color to hex let hex_string = color.to_hex(); // Returns "#3366FF" // Parse hex string to color if let Ok(color) = Hsla::parse_hex("#3366FF") { // Use parsed color } ``` ## Alpha Channel Full alpha channel support for transparency: ```rust use gpui_kit::hsla; // Create color with alpha let semi_transparent = hsla(0.5, 0.8, 0.6, 0.7); // 70% opacity // Modify existing color opacity let transparent_blue = cx.theme().blue.opacity(0.5); ``` The color picker preserves alpha values when selecting colors and allows modification through the alpha component of HSLA colors. ## API Reference - [ColorPicker] - [ColorSelect] - [ColorPickerState] - [ColorPickerEvent] ## Examples ### Color Theme Editor ```rust struct ThemeEditor { primary_color: Entity, secondary_color: Entity, accent_color: Entity, } impl ThemeEditor { fn new(window: &mut Window, cx: &mut Context) -> Self { let primary_color = cx.new(|cx| ColorPickerState::new(window, cx) .default_value(cx.theme().primary) ); let secondary_color = cx.new(|cx| ColorPickerState::new(window, cx) .default_value(cx.theme().secondary) ); let accent_color = cx.new(|cx| ColorPickerState::new(window, cx) .default_value(cx.theme().accent) ); Self { primary_color, secondary_color, accent_color, } } fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .gap_4() .child( h_flex() .gap_2() .items_center() .child("Primary Color:") .child(ColorPicker::new(&self.primary_color)) ) .child( h_flex() .gap_2() .items_center() .child("Secondary Color:") .child(ColorPicker::new(&self.secondary_color)) ) .child( h_flex() .gap_2() .items_center() .child("Accent Color:") .child(ColorPicker::new(&self.accent_color)) ) } } ``` ### Brand Color Selector ```rust use gpui_kit::component::{Sizable as _}; let brand_colors = vec![ Hsla::parse_hex("#FF6B6B").unwrap(), // Brand Red Hsla::parse_hex("#4ECDC4").unwrap(), // Brand Teal Hsla::parse_hex("#45B7D1").unwrap(), // Brand Blue Hsla::parse_hex("#96CEB4").unwrap(), // Brand Green Hsla::parse_hex("#FFEAA7").unwrap(), // Brand Yellow ]; ColorPicker::new(&color_picker) .featured_colors(brand_colors) .label("Brand Color") .large() ``` ### Toolbar Color Picker ```rust use gpui_kit::component::{Sizable as _, IconName); ColorPicker::new(&text_color_picker) .icon(IconName::Type) .small() .anchor(Anchor::BottomLeft) ``` ### Color Palette Builder ```rust struct ColorPalette { colors: Vec>, } impl ColorPalette { fn add_color(&mut self, window: &mut Window, cx: &mut Context) { let color_picker = cx.new(|cx| ColorPickerState::new(window, cx)); // Subscribe to color changes cx.subscribe(&color_picker, |this, _, ev, _| match ev { ColorPickerEvent::Change(color) => { if let Some(color) = color { this.update_palette_preview(); } } }); self.colors.push(color_picker); cx.notify(); } fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { h_flex() .gap_2() .children( self.colors.iter().map(|color_picker| { ColorPicker::new(color_picker).small() }) ) .child( Button::new("add-color") .icon(IconName::Plus) .ghost() .on_click(cx.listener(|this, _, window, cx| { this.add_color(window, cx); })) ) } } ``` ### With Color Validation ```rust let color_picker = cx.new(|cx| ColorPickerState::new(window, cx)); let _subscription = cx.subscribe(&color_picker, |this, _, ev, _| match ev { ColorPickerEvent::Change(color) => { if let Some(color) = color { // Validate color accessibility if this.validate_contrast(color) { this.apply_color(color); } else { this.show_contrast_warning(); } } } }); ``` [ColorPicker]: https://docs.rs/gpui-component/latest/gpui_component/color_picker/struct.ColorPicker.html [ColorSelect]: https://docs.rs/gpui-component/latest/gpui_component/color_picker/struct.ColorSelect.html [ColorPickerState]: https://docs.rs/gpui-component/latest/gpui_component/color_picker/struct.ColorPickerState.html [ColorPickerEvent]: https://docs.rs/gpui-component/latest/gpui_component/color_picker/enum.ColorPickerEvent.html --- # Textarea Source: /versions/v0.7.1/component/textarea License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/textarea and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `Textarea` is the styled control for ordinary multi-line text. Use [`Input`](/versions/v0.7.1/component/input) for a single line and [`Editor`](/versions/v0.7.1/component/editor) for source code. ## Import ```rust use gpui_kit::component::input::{Textarea, TextareaState}; ``` ## Basic usage ```rust let notes = cx.new(|cx| { TextareaState::new(window, cx) .rows(5) .placeholder("Notes") }); Textarea::new(¬es) ``` ## Auto-grow ```rust let message = cx.new(|cx| { TextareaState::new(window, cx) .auto_grow(2, 8) .placeholder("Write a message") }); Textarea::new(&message) ``` The control grows until `max_rows`; overflowing content then scrolls. ## Value and events ```rust let value = notes.read(cx).value(); notes.update(cx, |state, cx| { state.set_value("Updated notes", window, cx); }); cx.subscribe(¬es, |this, state, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { this.notes = state.read(cx).value(); cx.notify(); } }); ``` `insert`, `replace`, `cursor_position`, `soft_wrap`, `searchable`, and `submit_on_enter` are available on `TextareaState`. ## Sizes ```rust Textarea::new(¬es).large() Textarea::new(¬es) // medium (default) Textarea::new(¬es).small() ``` The size changes the text size and the padding around the text together. It does not set the height: use `h` for a fixed height, and let `rows` or `auto_grow` decide the height of a growing textarea. ## Appearance ```rust Textarea::new(¬es) .h(px(160.)) .bordered(true) .disabled(false) .readonly(false) .aria_label("Notes") ``` Unlike `disabled`, a read-only textarea keeps the normal appearance and still can be focused, selected and copied, it only rejects the changes made by the user. `Textarea` deliberately does not expose Input-only adornments such as `prefix`, `suffix`, mask toggle, or the clear button. Compose related actions beside the textarea. ## Atomic inline tokens Use tokens to include mentions, file references or commands in a multi-line message. Insert a token with `TextareaState::replace_with_token`, or restore a saved draft with `set_value`. The default label is ready to use; add a renderer when you want an icon or other custom content: ```rust Textarea::new(&state) .token(|token, _, _| InputToken::new(token).icon(IconName::File)) ``` Import `InputToken` from `gpui_kit::component::input` and `IconName` from `gpui_kit::component`. A token wraps onto the next line as a whole, and auto-grow adjusts the textarea height to fit. Put line breaks in the text between tokens. Use `on_token_hover` to show a tooltip or preview without selecting or editing; disabled tokens never report hover entry; disabling a hovered token sends its exit. See [Input: atomic inline tokens](/versions/v0.7.1/component/input#atomic-inline-tokens) for editing, activation, draft persistence, mode restrictions and JavaScript APIs. --- # Switch Source: /versions/v0.7.1/component/switch License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/switch and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A toggle switch component for binary on/off states. Features smooth animations, different sizes, labels, disabled state, and customizable positioning. Use `on_change` for requested values. The owner stores the value and calls `cx.notify()`. The existing `on_click` name remains a compatibility alias; setting either replaces the same handler, so the last call wins. ## Import ```rust use gpui_kit::component::switch::Switch; ``` ## Usage ### Basic Switch ```rust Switch::new("my-switch") .checked(false) .on_change(|checked, _, _| { println!("Switch is now: {}", checked); }) ``` ### Controlled Switch ```rust struct MyView { is_enabled: bool, } impl Render for MyView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { Switch::new("switch") .checked(self.is_enabled) .on_change(cx.listener(|view, checked, _, cx| { view.is_enabled = *checked; cx.notify(); })) } } ``` ### With Label ```rust Switch::new("notifications") .label("Enable notifications") .checked(true) .on_change(|checked, _, _| { println!("Notifications: {}", if *checked { "enabled" } else { "disabled" }); }) ``` ### Different Sizes ```rust // Small switch Switch::new("small-switch") .small() .label("Small switch") // Medium switch (default) Switch::new("medium-switch") .label("Medium switch") // Using explicit size Switch::new("custom-switch") .with_size(Size::Small) .label("Custom size") ``` ### Disabled State ```rust // Disabled unchecked Switch::new("disabled-off") .label("Disabled (off)") .disabled(true) .checked(false) // Disabled checked Switch::new("disabled-on") .label("Disabled (on)") .disabled(true) .checked(true) ``` ### Custom Color Use `.color()` to override the checked-state background color. The disabled alpha is applied automatically on top of the custom color. ```rust // Success color when checked Switch::new("switch") .label("Success") .checked(true) .color(cx.theme().success) // Danger color when checked Switch::new("switch") .label("Danger") .checked(true) .color(cx.theme().danger) // Custom color + disabled: color is shown at 50% opacity Switch::new("switch") .label("Disabled") .checked(true) .color(cx.theme().success) .disabled(true) ``` ### With Tooltip ```rust Switch::new("switch") .label("Airplane mode") .tooltip("Enable airplane mode to disable all wireless connections") .checked(false) ``` ### Keyboard Focus A switch is a tab stop and draws the theme's focus ring around its track when it is focused, the same ring `Checkbox` and `Button` draw. Pass `focus_ring(false)` when the ring is drawn elsewhere, and `tab_stop` / `tab_index` to change its place in the tab order. ```rust Switch::new("switch") .label("Custom tab order") .tab_index(2) .tab_stop(true) // The row around it draws its own focus treatment. Switch::new("switch") .label("Quiet focus") .focus_ring(false) ``` ## API Reference ### Switch | Method | Description | | ------------------ | ----------------------------------------------------------- | | `new(id)` | Create a new switch with the given ID | | `checked(bool)` | Set the checked/toggled state | | `label(text)` | Set label text for the switch | | `label_side(side)` | Position label (Side::Left or Side::Right) | | `disabled(bool)` | Set disabled state | | `tooltip(text)` | Add tooltip text | | `color(color)` | Set background color when checked (default: `theme.primary`) | | `on_change(fn)` | Requested checked value, receives `&bool` | | `focus_ring(bool)` | Draw the focus ring around the track when focused (default: `true`) | | `tab_stop(bool)` | Take part in Tab traversal (default: `true`) | | `tab_index(isize)` | Position in the tab order within a tab group (default: `0`) | ### Styling Implements `Sizable` and `Disableable` traits: - `small()` - Small switch size (28x16px toggle area) - `medium()` - Medium switch size (36x20px toggle area, default) - `with_size(size)` - Set explicit size - `disabled(bool)` - Disabled state ### Styling Properties The switch can also be styled using GPUI's styling methods: - `w(width)` - Custom width - `h(height)` - Custom height - Standard margin, padding, and positioning methods ## Examples ### Settings Panel ```rust struct SettingsView { marketing_emails: bool, security_emails: bool, push_notifications: bool, } v_flex() .gap_4() .child( // Setting with description v_flex() .gap_2() .child( h_flex() .items_center() .justify_between() .child( v_flex() .child(Label::new("Marketing emails").text_lg()) .child( Label::new("Receive emails about new products and features") .text_color(theme.muted_foreground) ) ) .child( Switch::new("marketing") .checked(self.marketing_emails) .on_change(cx.listener(|view, checked, _, cx| { view.marketing_emails = *checked; cx.notify(); })) ) ) ) .child( // Simple setting h_flex() .items_center() .justify_between() .child(Label::new("Push notifications")) .child( Switch::new("push") .checked(self.push_notifications) .on_change(cx.listener(|view, checked, _, cx| { view.push_notifications = *checked; cx.notify(); })) ) ) ``` ### Compact Settings List ```rust v_flex() .gap_3() .child( Switch::new("wifi") .label("Wi-Fi") .label_side(Side::Left) .checked(true) .small() ) .child( Switch::new("bluetooth") .label("Bluetooth") .label_side(Side::Left) .checked(false) .small() ) .child( Switch::new("airplane") .label("Airplane Mode") .label_side(Side::Left) .checked(false) .disabled(true) .small() ) ``` ### Form Integration ```rust struct FormData { subscribe_newsletter: bool, enable_notifications: bool, remember_me: bool, } v_flex() .gap_4() .p_4() .border_1() .border_color(theme.border) .rounded(theme.radius) .child( Switch::new("newsletter") .label("Subscribe to newsletter") .checked(self.subscribe_newsletter) .tooltip("Receive monthly updates about new features") .on_change(cx.listener(|view, checked, _, cx| { view.subscribe_newsletter = *checked; cx.notify(); })) ) .child( Switch::new("notifications") .label("Enable notifications") .checked(self.enable_notifications) .on_change(cx.listener(|view, checked, _, cx| { view.enable_notifications = *checked; cx.notify(); })) ) .child( Switch::new("remember") .label("Remember me") .checked(self.remember_me) .small() .on_change(cx.listener(|view, checked, _, cx| { view.remember_me = *checked; cx.notify(); })) ) ``` ### Custom Styling ```rust Switch::new("custom") .label("Custom styled switch") .w(px(200.)) .checked(true) .on_change(|checked, _, _| { println!("Custom switch: {}", checked); }) ``` ## Animation The switch features smooth animations: - **Toggle animation**: 150ms duration when switching states - **Background color transition**: Changes from switch color to primary color - **Position animation**: Smooth movement of the toggle indicator - **Disabled state**: Animations are disabled when the switch is disabled --- # MessageScroller Source: /versions/v0.7.1/component/message-scroller License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/message-scroller and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `MessageScroller` coordinates a variable-height virtual list with the behavior conversation screens usually need: follow the live tail, keep the current anchor while older history is inserted, navigate to an unread row, and expose whether the reader has left the tail. The application owns the message collection, stable message IDs, unread meaning, row renderer, composer, and empty/error states. The component owns only virtual-list bookkeeping and the optional jump-to-latest affordance. There is one state entity per scroller. ## Import ```rust use std::{rc::Rc, time::Duration}; use gpui_kit::{ IntoElement as _, ParentElement as _, StyleRefinement, Styled as _, prelude::FluentBuilder as _, }; use gpui_kit::component::{ ActiveTheme as _, button::ButtonVariants as _, message_scroller::{MessageScroller, MessageScrollerState}, Sizable as _, v_flex, }; ``` ## Create state and choose the starting position Create the state beside the application-owned message vector. The constructor receives the entity context because GPUI's list scroll handler notifies the entity after the list releases its internal borrow: ```rust let scroller = cx.new(|cx| MessageScrollerState::new(messages.len(), cx)); cx.observe(&scroller, |_, _, cx| cx.notify()).detach(); ``` `MessageScrollerState::new(...)` starts with tail following enabled. That is the expected starting position for a live conversation. A saved thread or a deep link can choose a row after the initial data has been installed: ```rust let saved_index = messages .iter() .position(|message| message.id == saved_message_id) .unwrap_or(messages.len().saturating_sub(1)); scroller.update(cx, |state, cx| { state.reset(messages.len(), cx); let _ = state.scroll_to_item(saved_index, cx); }); ``` There is no `starting_position` field or persisted scroll-offset API. Keep the application's saved message ID or index, then resolve it to the current index after records are loaded. `reset(...)` replaces the known row count and re-engages tail following; call `scroll_to_item(...)` after it when the product needs a different initial row. ## Render rows and an empty state Pass an indexed renderer. GPUI virtualizes the rows, so the closure is called for the rows needed by the current viewport and overdraw region: ```rust let messages = Rc::new(messages.clone()); MessageScroller::new( "conversation", scroller.clone(), move |index, _window, _cx| { let Some(message) = messages.get(index) else { return gpui_kit::div().into_any_element(); }; gpui_kit::div() .id(("message-row", message.id)) .min_w_0() .child(render_message(message)) .into_any_element() }, ) .w_full() .h_96() ``` The row ID in this example belongs to the application. `MessageScroller` does not retain an index-to-ID map; the ID lets an application-owned row keep its own element-local state when data changes. An empty list is valid (`MessageScrollerState::new(0, cx)`), but the scroller does not invent an empty placeholder. Render the empty, loading, error, or permission-denied state in the surrounding view and mount the scroller once there are rows: ```rust if messages.is_empty() { empty_conversation_view.into_any_element() } else { MessageScroller::new("conversation", scroller.clone(), render_message) .into_any_element() } ``` Keep the empty state separate from the scroll region so it can provide a meaningful action such as “Start a new conversation” without pretending there is a message to scroll to. ## Append, streaming, and follow-tail behavior Update application data and virtual-list count together. Appending while the reader follows the tail keeps the latest row visible. Appending after the reader scrolls up preserves the reader's position and makes the built-in jump button available: ```rust messages.push(new_message); scroller.update(cx, |state, cx| { let _ = state.append(1, cx); }); cx.notify(); ``` Streaming token growth changes a row's height without changing the item count. Update the message body, then remeasure that row: ```rust messages[index].body.push_str(next_token); scroller.update(cx, |state, cx| { let _ = state.remeasure_items(index..index + 1, cx); }); cx.notify(); ``` `remeasure_items(...)` preserves an item anchor while recalculating the selected rows. Use `remeasure(...)` after a global width, typography, or theme change that can affect many row heights. When streaming creates a new message rather than growing an existing one, call `append(1, cx)` first; remeasure the row again if its first render and later content have different heights. The state readers make the follow-tail decision visible to the surrounding view: ```rust let following_tail = scroller.read(cx).is_following_tail(); let show_new_messages = scroller.read(cx).is_scrolled_up(); ``` `is_following_tail()` is true when the list follows appended content. `is_scrolled_up()` is true when there is scrollable content below the current viewport and the reader is away from the end. The component does not decide whether to show a toast, unread count, or “new messages” copy; use these readers to drive an application-owned indicator. Resume following and move to the latest row explicitly: ```rust scroller.update(cx, |state, cx| state.scroll_to_end(cx)); ``` Normal scrolling to the end also allows GPUI's list to resume tail following. ## Prepend earlier history Insert older records at the front and tell the state the number of inserted rows. `prepend(...)` uses GPUI list splicing to preserve the visible item anchor: ```rust let earlier_messages = load_earlier_messages(); let count = earlier_messages.len(); messages.splice(0..0, earlier_messages); scroller.update(cx, |state, cx| { let _ = state.prepend(count, cx); }); cx.notify(); ``` Use `splice(old_range, count, cx)` for replacements or deletions elsewhere in the collection. The range is half-open and must stay within the current item count; invalid ranges return `false` and leave the state unchanged. For a “Load earlier” control, keep the loading state in the application, fetch the records, splice the vector, then call `prepend`. Do not call `reset` for ordinary history pagination because reset intentionally returns to tail following and loses the incremental anchor semantics. ## Unread and arbitrary navigation Unread identity belongs to the application. Resolve a stable message ID to the current vector index, then use `scroll_to_item(...)`: ```rust if let Some(index) = messages.iter().position(|message| message.id == unread_id) { scroller.update(cx, |state, cx| { let _ = state.scroll_to_item(index, cx); }); } ``` `scroll_to_item(...)` returns `false` for an out-of-range index. It is the single navigation primitive: an unread boundary, a search result, a bookmarked message, a reply target, and a deep link all resolve to an index in the application first. The current API does not expose turn anchors, peek previews, visible IDs, or stable-ID navigation. Map domain IDs to the current index in the application; keep an index map if lookup cost matters. The scroller does not know whether a row is a turn, a reply, an unread boundary, or a search result. ## Dynamic row heights and structural updates Rows may contain multiline text, attachments, streamed content, or an application-owned composer and can therefore have different heights. The underlying GPUI list measures rendered rows. Keep the renderer's height-affecting data in the owning view and notify the state after a mutation: | Change | State operation | | --- | --- | | Add rows at the tail | `append(count, cx)` | | Add rows at the front | `prepend(count, cx)` | | Replace/delete a range | `splice(range, count, cx)` | | Token growth in known rows | `remeasure_items(range, cx)` | | Global width/font/theme change | `remeasure(cx)` | | Replace the whole conversation | `reset(item_count, cx)` | Do not mutate the vector length without the matching state operation. The renderer receives an index, so data and virtual-list count must remain aligned for the same render pass. ## Jump-to-latest controls The built-in jump button is enabled by default and appears when `is_scrolled_up()` becomes true. It is a configured `Button` with a secondary variant, icon-button sizing, full radius, arrow-down icon, theme border/background, and a localized tooltip label. It keeps the scroll action owned by the state: ```rust MessageScroller::new("conversation", scroller.clone(), render_message) .with_jump_button_label("Jump to newest") .with_jump_button_transition(Duration::from_millis(250)) ``` Use `Duration::ZERO` to disable the enter/leave transition. Reduced-motion preferences use the final state immediately regardless of the configured duration. Refine its style after the built-in defaults: ```rust MessageScroller::new("conversation", scroller.clone(), render_message) .with_jump_button_style( StyleRefinement::default() .bg(cx.theme().primary) .border_color(cx.theme().primary) .text_color(cx.theme().primary_foreground), ) ``` Use the renderer callback when the application needs a different Button variant, size, icon, or instance style. The callback receives the fully configured button and must return a `Button`; the built-in scroll action stays attached: ```rust MessageScroller::new("conversation", scroller.clone(), render_message) .with_jump_button_renderer(|button| button.outline().small().label("Latest")) ``` During its leave transition the built-in button is rendered disabled while its opacity reaches zero. A renderer should preserve that state rather than force a disabled button to be active. The button's accessible name comes from its `.label(...)` value; `with_jump_button_label(...)` supplies the tooltip label only. Set a visible label in `with_jump_button_renderer(...)` when the jump action needs a named accessible control. The current public renderer callback can change Button styling and content, but it does not expose a separate accessibility-label builder. Disable the built-in affordance when the surrounding view provides its own: ```rust MessageScroller::new("conversation", scroller.clone(), render_message) .jump_button(false) ``` Compose an application-owned button from `is_scrolled_up()` and `scroll_to_end(...)` when its placement, text, or accessibility contract needs to be product-specific. ## Scrollbar and style slots The root implements `Styled`, and the internal regions have separate style refinements: ```rust MessageScroller::new("conversation", scroller.clone(), render_message) .p_2() .bg(cx.theme().group_box) .with_content_style(StyleRefinement::default().bg(cx.theme().background)) .with_list_style(StyleRefinement::default().p_4()) .with_row_style(StyleRefinement::default().pb_6()) .scrollbar(false) ``` The boundaries are: - Root `Styled` methods refine the full-width element that owns the viewport. - `with_content_style(...)` refines the viewport containing the list and optional vertical scrollbar. - `with_list_style(...)` refines the GPUI virtual list after its default `px_3()` / `py_2()` padding. GPUI lists offset rows only vertically, so the horizontal padding component — the default and any refinement — is carried by every row wrapper. - `with_row_style(...)` refines the full-width wrapper around each rendered row; the default wrapper includes `pb_8()` between rows, like a CSS gap. The list's own bottom padding owns the gap between the last row and whatever sits below the transcript. - `scrollbar(false)` hides the built-in vertical scrollbar; it does not disable scrolling or remove keyboard/wheel interaction. - `with_bottom_fade(color)` fades the transcript's bottom edge into the given color, so a partially visible row melts into the surrounding surface instead of clipping mid-line. It shows only while the reader is away from the live edge — at the bottom nothing is clipped. Pass the color of the surface behind the scroller; the fade is off by default. Use theme roles such as `group_box`, `background`, `border`, and `foreground` for custom surfaces. Keep content padding in the surrounding conversation shell when it belongs to the shell's header/composer relationship; use list or row style when it belongs to every transcript row. ## Virtualization, accessibility, and application boundaries `MessageScroller` delegates viewport layout, variable-height measurement, scroll anchoring, and overdraw to GPUI's `ListState`. Only visible rows and the configured overdraw region need rendering. Keep row closures deterministic and avoid doing network work or mutating the message collection during rendering. For keyboard and screen-reader behavior: - Keep the scroller inside a layout with a real height and `min_h_0()` so the scroll region can receive wheel and keyboard navigation. - The transcript viewport announces itself as a log region (`Role::Log`), so assistive technology can treat appended rows as live additions.- Wheel scrolling over the transcript is contained: while the list can move, the event never scrolls an ancestor scroller; at the top or bottom edge it chains to the ancestor, matching platform scroll containers. - Give rows meaningful text and stable application IDs; an index by itself is not a user-facing label. - Give the jump control an explicit visible label when it must be exposed as a named accessible action. Its tooltip is supplemental. - Place “Load earlier”, retry, composer, and unread controls outside the list in semantic `Button` or `Link` controls. - Keep empty, loading, error, and permission states readable without relying on animation or scrollbar position. The component intentionally has no React-style Provider, Viewport, Content, or Item exports. GPUI's list already supplies those layers; an indexed renderer is the item boundary. It also has no turn-anchor, peek, visible-range, or stable-ID API. Those concepts vary by product and belong in the application model around this component. ## API reference ### `MessageScrollerState` | Method | Default/return | Purpose | | --- | --- | --- | | `new(item_count, cx)` | tail following enabled | Create state for the current row count. | | `item_count()` | current count | Read the virtual-list row count. | | `is_scrolled_up()` | `false` until away from tail | Report whether a jump/new-content affordance is useful. | | `is_following_tail()` | `true` initially | Report whether appended rows are followed. | | `reset(item_count, cx)` | re-engages tail | Replace the row count and reset list state. | | `splice(range, count, cx)` | `true` if valid | Replace a half-open range while preserving list bookkeeping. | | `append(count, cx)` | `splice` at tail | Add rows at the end. | | `prepend(count, cx)` | `splice` at index 0 | Add earlier rows while preserving the current anchor. | | `remeasure(cx)` | — | Remeasure all rows after global layout changes. | | `remeasure_items(range, cx)` | `true` if valid | Remeasure selected dynamic rows. | | `scroll_to_item(index, cx)` | `false` if out of range | Navigate to an arbitrary row index. | | `scroll_to_end(cx)` | tail following enabled | Move to the latest row and resume following. | ### `MessageScroller` | Method | Default | Purpose | | --- | --- | --- | | `new(id, state, renderer)` | scrollbar and jump button enabled | Create a virtualized scroller. | | `scrollbar(bool)` | `true` | Show or hide the internal scrollbar. | | `jump_button(bool)` | `true` | Show or hide the built-in jump control. | | `with_jump_button_label(label)` | `Jump to latest` | Set the jump tooltip/localized label. | | `with_content_style(style)` | empty refinement | Style the viewport and scrollbar region. | | `with_list_style(style)` | list `px_3()` / `py_2()` | Style the virtual list. | | `with_row_style(style)` | row `pb_8()` | Style every rendered row wrapper. | | `with_jump_button_style(style)` | themed secondary button | Refine the built-in button. | | `with_jump_button_renderer(callback)` | default Button | Adjust the configured button while keeping its action. | | `with_jump_button_transition(duration)` | 200 ms | Set enter/leave duration; reduced motion skips it. | | `with_bottom_fade(color)` | off | Fade the bottom edge into the surrounding surface color. | | `Styled` methods | full-size, clipped root | Style the outer scroller element. | [MessageScroller]: https://docs.rs/gpui-component/latest/gpui_component/message_scroller/struct.MessageScroller.html [MessageScrollerState]: https://docs.rs/gpui-component/latest/gpui_component/message_scroller/struct.MessageScrollerState.html --- # Table Source: /versions/v0.7.1/component/table License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/table and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A simple, stateless, composable table component for rendering tabular data. Unlike [DataTable], this component does not include virtual scrolling, sorting, or column management — it is designed for straightforward data display using a declarative API. ## Import ```rust use gpui_kit::component::table::{ Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption, }; ``` ## Usage ### Basic Table ```rust Table::new() .child(TableHeader::new().child( TableRow::new() .child(TableHead::new().child("Name")) .child(TableHead::new().child("Email")) .child(TableHead::new().text_right().child("Amount")) )) .child(TableBody::new() .child(TableRow::new() .child(TableCell::new().child("John")) .child(TableCell::new().child("john@example.com")) .child(TableCell::new().text_right().child("$100.00"))) .child(TableRow::new() .child(TableCell::new().child("Jane")) .child(TableCell::new().child("jane@example.com")) .child(TableCell::new().text_right().child("$200.00"))) ) .child(TableCaption::new().child("A list of recent invoices.")) ``` ### With Footer ```rust Table::new() .child(TableHeader::new().child( TableRow::new() .child(TableHead::new().child("Invoice")) .child(TableHead::new().child("Status")) .child(TableHead::new().text_right().child("Amount")) )) .child(TableBody::new() .child(TableRow::new() .child(TableCell::new().child("INV001")) .child(TableCell::new().child("Paid")) .child(TableCell::new().text_right().child("$250.00"))) ) .child(TableFooter::new().child( TableRow::new() .child(TableCell::new().child("Total")) .child(TableCell::new().child("")) .child(TableCell::new().text_right().child("$250.00")) )) ``` ### Column Widths Use `.w()` on `TableHead` and `TableCell` to set fixed column widths: ```rust TableRow::new() .child(TableHead::new().w(px(80.)).child("ID")) .child(TableHead::new().child("Name")) // flex-1 .child(TableHead::new().w(px(120.)).child("Date")) ``` ### Text Alignment ```rust // Center-aligned header TableHead::new().text_center().child("Status") // Right-aligned cell (e.g., for numbers) TableCell::new().text_right().child("$1,000.00") ``` ### Without Border (via Styled) All table sub-components implement the `Styled` trait, so you can customize styles directly: ```rust // Remove border and rounded corners Table::new() .border_0() .rounded_none() .child(/* ... */) ``` ### Custom Styling Since all components implement `Styled`, you can apply any GPUI style: ```rust // Custom row hover TableRow::new() .bg(cx.theme().table_even) .child(/* ... */) // Custom cell padding TableCell::new() .px_4() .child("Custom padded content") ``` ## Sub-components | Component | Description | |-----------|-------------| | `Table` | Root container with border, rounded corners, and background | | `TableHeader` | Header section with distinct background and font weight | | `TableBody` | Body section wrapping data rows | | `TableFooter` | Footer section with top border | | `TableRow` | A flex row with bottom border | | `TableHead` | Header cell with alignment and width options | | `TableCell` | Data cell with alignment and width options | | `TableCaption` | Caption text below the table | ## API Reference ### Table - `new()` - Create a new table - Implements `Styled`, `ParentElement`, `Sizable`, `RenderOnce` ### TableHead / TableCell - `new()` - Create a new head/cell - `w(width)` - Set fixed width (otherwise flex-1) - `text_center()` - Center-align content - `text_right()` - Right-align content - Implements `Styled`, `ParentElement`, `RenderOnce` ### TableHeader / TableBody / TableFooter / TableRow / TableCaption - `new()` - Create a new instance - Implements `Styled`, `ParentElement`, `RenderOnce` ## Table vs DataTable | Feature | Table | DataTable | |---------|-------|-----------| | Virtual scrolling | No | Yes | | Column sorting | No | Yes | | Column resizing | No | Yes | | Column moving | No | Yes | | Cell selection | No | Yes | | Row selection | No | Yes | | Infinite loading | No | Yes | | Keyboard navigation | No | Yes | | State management | Stateless | TableState | | Best for | Small, static data | Large, interactive datasets | [DataTable]: ./data-table.md --- # Sidebar Source: /versions/v0.7.1/component/sidebar License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/sidebar and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A flexible sidebar component that provides navigation structure for applications. Features collapsible states, nested menu items, header and footer sections, and responsive design. Perfect for creating application navigation panels, admin dashboards, and complex hierarchical interfaces. ## Import ```rust use gpui_kit::component::sidebar::{ Sidebar, SidebarHeader, SidebarFooter, SidebarGroup, SidebarMenu, SidebarMenuItem, SidebarToggleButton }; ``` ## Usage ### Basic Sidebar ```rust use gpui_kit::component::{sidebar::*, Side}; Sidebar::new() .header( SidebarHeader::new() .child("My Application") ) .child( SidebarGroup::new("Navigation") .child( SidebarMenu::new() .child( SidebarMenuItem::new("Dashboard") .icon(IconName::LayoutDashboard) .on_click(|_, _, _| println!("Dashboard clicked")) ) .child( SidebarMenuItem::new("Settings") .icon(IconName::Settings) .on_click(|_, _, _| println!("Settings clicked")) ) ) ) .footer( SidebarFooter::new() .child("User Profile") ) ``` ### Collapsible Sidebar ```rust let mut collapsed = false; Sidebar::new() .collapsed(collapsed) .collapsible(true) .header( SidebarHeader::new() .child( h_flex() .child(Icon::new(IconName::Home)) .when(!collapsed, |this| this.child("Home")) ) ) .child( SidebarGroup::new("Menu") .child( SidebarMenu::new() .child( SidebarMenuItem::new("Files") .icon(IconName::Folder) ) ) ) // Toggle button SidebarToggleButton::new() .collapsed(collapsed) .on_click(|_, _, _| { collapsed = !collapsed; }) ``` ### Nested Menu Items ```rust SidebarMenuItem::new("Projects") .icon(IconName::FolderOpen) .active(true) .children([ SidebarMenuItem::new("Web App") .active(false) .on_click(|_, _, _| println!("Web App selected")), SidebarMenuItem::new("Mobile App") .active(true) .on_click(|_, _, _| println!("Mobile App selected")), SidebarMenuItem::new("Desktop App") .on_click(|_, _, _| println!("Desktop App selected")), ]) .on_click(|_, _, _| { // Toggle project group }) ``` ### Multiple Groups ```rust Sidebar::new() .child( SidebarGroup::new("Main") .child( SidebarMenu::new() .child(SidebarMenuItem::new("Dashboard").icon(IconName::Home)) .child(SidebarMenuItem::new("Analytics").icon(IconName::BarChart)) ) ) .child( SidebarGroup::new("Content") .child( SidebarMenu::new() .child(SidebarMenuItem::new("Posts").icon(IconName::FileText)) .child(SidebarMenuItem::new("Media").icon(IconName::Image)) .child(SidebarMenuItem::new("Comments").icon(IconName::MessageCircle)) ) ) .child( SidebarGroup::new("Settings") .child( SidebarMenu::new() .child(SidebarMenuItem::new("General").icon(IconName::Settings)) .child(SidebarMenuItem::new("Users").icon(IconName::Users)) ) ) ``` ### With Badges and Suffixes ```rust use gpui_kit::component::{Badge, Switch}; SidebarMenuItem::new("Notifications") .icon(IconName::Bell) .suffix( Badge::new() .count(5) .child("5") ) SidebarMenuItem::new("Dark Mode") .icon(IconName::Moon) .suffix( Switch::new("dark-mode") .checked(true) .xsmall() ) SidebarMenuItem::new("Settings") .icon(IconName::Settings) .suffix(IconName::ChevronRight) ``` ### Right-Side Placement ```rust Sidebar::new() .side(Side::Right) .width(300) .header( SidebarHeader::new() .child("Right Panel") ) .child( SidebarGroup::new("Tools") .child( SidebarMenu::new() .child(SidebarMenuItem::new("Inspector").icon(IconName::Search)) .child(SidebarMenuItem::new("Console").icon(IconName::Terminal)) ) ) ``` ### Context Menus Add right-click context menus to sidebar menu items for additional actions: ```rust use gpui_kit::component::menu::PopupMenu; SidebarMenuItem::new("Project Files") .icon(IconName::Folder) .context_menu(|menu, _, _| { menu.link("Open in Editor", "https://editor.example.com") .separator() .menu_with_description("Rename", "Rename this project", Box::new(RenameAction)) .menu_with_description("Delete", "Delete this project", Box::new(DeleteAction)) .separator() .submenu("Share", |submenu| { submenu.menu("Copy Link", Box::new(CopyLinkAction)) .menu("Send via Email", Box::new(EmailAction)) }) }) // Multiple items with context menus SidebarMenu::new() .child( SidebarMenuItem::new("Documentation") .icon(IconName::BookOpen) .context_menu(|menu, _, _| { menu.menu("View Online", Box::new(ViewOnlineAction)) .menu("Download PDF", Box::new(DownloadPdfAction)) }) ) .child( SidebarMenuItem::new("Settings") .icon(IconName::Settings) .children([ SidebarMenuItem::new("General") .context_menu(|menu, _, _| { menu.menu("Reset to Defaults", Box::new(ResetAction)) }), SidebarMenuItem::new("Advanced") .context_menu(|menu, _, _| { menu.menu("Export Settings", Box::new(ExportAction)) .menu("Import Settings", Box::new(ImportAction)) }) ]) ) ``` ### Custom Width and Styling ```rust Sidebar::new() .width(280) // Custom width in pixels .border_width(2) // Custom border width .header( SidebarHeader::new() .p_4() // Custom padding .rounded(cx.theme().radius) .child("Custom Styled Sidebar") ) ``` ### Interactive Header with Popup Menu ```rust use gpui_kit::component::menu::DropdownMenu; SidebarHeader::new() .child( h_flex() .gap_2() .child(Icon::new(IconName::Building)) .child("Company Name") .child(Icon::new(IconName::ChevronsUpDown)) ) .dropdown_menu(|menu, _, _| { menu.menu("Acme Corp", Box::new(SelectCompany("acme"))) .menu("Tech Solutions", Box::new(SelectCompany("tech"))) .separator() .menu("Switch Organization", Box::new(SwitchOrg)) }) ``` ### Footer with User Information ```rust SidebarFooter::new() .justify_between() .child( h_flex() .gap_2() .child(Icon::new(IconName::User)) .when(!collapsed, |this| { this.child( v_flex() .child("John Doe") .child(div().text_xs().text_color(cx.theme().muted_foreground).child("john@example.com")) ) }) ) .when(!collapsed, |this| { this.child(Icon::new(IconName::MoreHorizontal)) }) ``` ### Responsive Sidebar ```rust let is_mobile = window_width < 768; Sidebar::new() .collapsed(is_mobile || manually_collapsed) .width(if is_mobile { 60 } else { 240 }) .header( SidebarHeader::new() .child( div() .when(!is_mobile, |this| this.child("Full App Name")) .when(is_mobile, |this| this.child(Icon::new(IconName::Menu))) ) ) ``` ## Theming The sidebar uses dedicated theme colors: ```rust // Theme colors used by sidebar cx.theme().sidebar // Background cx.theme().sidebar_foreground // Text color cx.theme().sidebar_border // Border color cx.theme().sidebar_accent // Hover/active background cx.theme().sidebar_accent_foreground // Hover/active text cx.theme().sidebar_primary // Primary elements cx.theme().sidebar_primary_foreground // Primary text ``` ## Examples ### File Explorer Sidebar ```rust Sidebar::new() .header( SidebarHeader::new() .child( h_flex() .gap_2() .child(IconName::Folder) .child("Explorer") ) ) .child( SidebarGroup::new("Folders") .child( SidebarMenu::new() .child( SidebarMenuItem::new("src") .icon(IconName::FolderOpen) .active(true) .children([ SidebarMenuItem::new("components") .icon(IconName::Folder), SidebarMenuItem::new("utils") .icon(IconName::Folder), SidebarMenuItem::new("main.rs") .icon(IconName::FileCode) .active(true), ]) ) .child( SidebarMenuItem::new("tests") .icon(IconName::Folder) ) .child( SidebarMenuItem::new("Cargo.toml") .icon(IconName::FileText) ) ) ) ``` ### Admin Dashboard Sidebar ```rust Sidebar::new() .header( SidebarHeader::new() .child( h_flex() .gap_2() .child( div() .size_8() .rounded_full() .bg(cx.theme().primary) .child(Icon::new(IconName::Crown)) ) .child("Admin Panel") ) ) .child( SidebarGroup::new("Overview") .child( SidebarMenu::new() .child( SidebarMenuItem::new("Dashboard") .icon(IconName::LayoutDashboard) .active(true) ) .child( SidebarMenuItem::new("Analytics") .icon(IconName::TrendingUp) .suffix(Badge::new().count(2)) ) ) ) .child( SidebarGroup::new("Management") .child( SidebarMenu::new() .child( SidebarMenuItem::new("Users") .icon(IconName::Users) .suffix("1,234") ) .child( SidebarMenuItem::new("Orders") .icon(IconName::ShoppingCart) .suffix(Badge::new().dot().variant_destructive()) ) .child( SidebarMenuItem::new("Products") .icon(IconName::Package) ) ) ) .footer( SidebarFooter::new() .child( h_flex() .gap_2() .child(IconName::User) .child("Administrator") ) .child(IconName::LogOut) ) ``` ### Settings Sidebar ```rust Sidebar::new() .width(300) .header( SidebarHeader::new() .child("Settings") ) .child( SidebarGroup::new("General") .child( SidebarMenu::new() .child( SidebarMenuItem::new("Appearance") .icon(IconName::Palette) .active(true) ) .child( SidebarMenuItem::new("Notifications") .icon(IconName::Bell) .suffix( Switch::new("notifications") .checked(true) .xsmall() ) ) .child( SidebarMenuItem::new("Privacy") .icon(IconName::Shield) ) ) ) .child( SidebarGroup::new("Advanced") .child( SidebarMenu::new() .child( SidebarMenuItem::new("Developer") .icon(IconName::Code) .children([ SidebarMenuItem::new("Debug Mode") .suffix( Switch::new("debug") .checked(false) .xsmall() ), SidebarMenuItem::new("Console") .on_click(|_, _, _| println!("Open console")), ]) ) .child( SidebarMenuItem::new("Performance") .icon(IconName::Zap) ) ) ) ``` --- # Shimmer Source: /versions/v0.7.1/component/shimmer License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/shimmer and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `ShimmerText` renders readable text with a moving highlight for short-lived loading or generated-content states. `ShimmerStyle` is the reusable appearance and timing value shared by `ShimmerText`, `Marker`, and attachment titles. The utility keeps text as the layout owner, so typography, wrapping, and truncation remain ordinary GPUI text behavior. It does not replace text with a skeleton block, own loading state, or announce progress to assistive technology. Keep a meaningful label in the text and let the surrounding application own the operation state. ## When to use - “Thinking…” or “Generating…” while an AI response is being produced. - File titles in an `Uploading` or `Processing` state. - Lightweight text placeholders for short background work. Use `Skeleton` for placeholder layout blocks, `Spinner` for a rotating indeterminate control, and plain text when the state does not need motion. ## Import ```rust use std::time::Duration; use gpui_kit::{ParentElement as _, Styled as _}; use gpui_kit::component::{ shimmer::{ShimmerStyle, ShimmerText}, ActiveTheme as _, }; ``` ## Basic usage Use the default theme-aware shimmer for a loading label: ```rust ShimmerText::new("Thinking…") ``` `ShimmerText` implements `Styled`, so it inherits the surrounding text style and can be refined like other GPUI elements: ```rust ShimmerText::new("Generating a response…") .text_sm() .text_color(cx.theme().muted_foreground) .max_w_full() ``` The default configuration is: | Property | Default | Meaning | | --- | --- | --- | | Duration | 2 seconds | One complete sweep. | | Highlight color | theme-aware | Derived from the active text/background/theme. | | Spread | relative `0.3` | Highlight half-width as a fraction of text width; a fixed `Pixels` width is also accepted. | | Direction | left to right | `reverse(false)`. | | Repetition | looping | `once(false)`. | | Reduced motion | static text | Animation frames are skipped while text remains visible. | The highlight follows the active theme rather than assuming a white highlight. This keeps the effect legible in both light and dark themes. An explicit color is available when a product's semantic accent requires it. ## Configure `ShimmerStyle` Create a reusable style when multiple labels should share the same motion: ```rust let loading_style = ShimmerStyle::new() .duration(Duration::from_secs(3)) .highlight_color(cx.theme().primary) .spread(0.45) .reverse(true) .once(false); ShimmerText::new("Indexing files…").with_shimmer_style(loading_style); ShimmerText::new("Building response…").with_shimmer_style(loading_style); ``` The individual configuration methods are also available directly on `ShimmerText`: ```rust ShimmerText::new("Uploading…") .duration(Duration::from_secs(4)) .spread(0.5) .reverse(true) .once(true) ``` Use `with_shimmer_style(...)` when the style is shared or built conditionally; use the direct methods when a one-off label is clearer. ### Duration `duration(...)` sets one complete sweep. Values below one millisecond are clamped to one millisecond, so a zero duration does not disable animation. Use `once(true)` for a one-shot effect or render ordinary text when the operation is not loading. ```rust ShimmerText::new("Preparing preview…") .duration(Duration::from_millis(900)) ``` ### Highlight color Leave `highlight_color` unset to use the theme-aware default. Use a semantic theme color when the loading state belongs to a product accent: ```rust ShimmerText::new("Syncing…") .highlight_color(cx.theme().primary) ``` An explicit color is painted over the text and must have enough contrast in both themes. Avoid raw palette values in component call sites; use `cx.theme().primary`, `muted_foreground`, or another semantic token. ### Spread `spread(...)` controls the highlight half-width. A bare `f32` is relative to the text width: finite values are clamped to the inclusive `0.05..=1.0` range. A `Pixels` value is an absolute half-width with a one-pixel minimum, keeping the band the same physical width across labels of different lengths. Non-finite values leave the current spread unchanged: ```rust ShimmerText::new("Loading a narrow label…").spread(0.15); ShimmerText::new("Loading a broad label…").spread(0.7); ShimmerText::new("Fixed-width highlight…").spread(px(48.)); ``` Use a smaller spread for dense status rows and a broader spread for a short assistant label. Prefer the relative form so the text's width remains the scale; use an absolute spread when aligned labels should share one band width. ### Direction and play-once `reverse(true)` sweeps from right to left. `once(true)` completes one sweep and does not loop: ```rust ShimmerText::new("Finalizing…") .reverse(true) .once(true) ``` There is no public angle, pause, progress, or RTL-specific builder. If a product needs those behaviors, keep the loading text static or own a separate animation component until the API is intentionally extended. ## Compose with Marker `Marker` uses `ShimmerStyle` only when it is loading with the `Shimmer` style. Use `MarkerContent::text(...)` to give the component a text run that can receive the highlight: ```rust use gpui_kit::component::marker::{Marker, MarkerContent, MarkerLoadingStyle}; Marker::new() .loading(true) .with_loading_style(MarkerLoadingStyle::Shimmer) .with_shimmer_style( ShimmerStyle::new() .duration(Duration::from_secs(3)) .spread(0.4) .reverse(true), ) .content(MarkerContent::new().text("Searching conversation history…")) ``` If `MarkerContent` contains only arbitrary elements, Marker uses a gentle opacity animation for the content slot instead of trying to repaint those elements as text. Icons and separator lines remain static. The spinner loading style does not use shimmer. ## Compose with Attachment An attachment title automatically shimmers while its inherited or explicit status is `Uploading` or `Processing`. Customize that title without replacing the attachment composition: ```rust use gpui_kit::component::attachment::{ Attachment, AttachmentContent, AttachmentDescription, AttachmentStatus, AttachmentTitle, }; Attachment::new() .status(AttachmentStatus::Processing) .content( AttachmentContent::new() .title( AttachmentTitle::new("transcript.pdf").with_shimmer_style( ShimmerStyle::new() .highlight_color(cx.theme().primary) .spread(0.45), ), ) .description(AttachmentDescription::new("Processing document…")), ) ``` The title's explicit status overrides the parent status. Generic children added with `AttachmentContent::child(...)` do not inherit attachment state because their concrete type is erased; use the typed title builder when the loading behavior matters. ## Use with messages and bubbles `ShimmerText` is an ordinary element and can be placed anywhere a text child is accepted: ```rust use gpui_kit::component::{ bubble::{Bubble, BubbleContent, BubbleVariant}, message::{Message, MessageContent}, }; Message::new() .content( MessageContent::new().bubble( Bubble::new() .with_variant(BubbleVariant::Ghost) .content(BubbleContent::new().child( ShimmerText::new("The assistant is thinking…"), )), ), ) ``` The application should switch from shimmer text to the final message content when generation completes. Do not leave an animated label running after the operation has ended. ## Styling, theme, and reduced motion `ShimmerText` implements `Styled`; style its font, size, color, wrapping, and layout at the call site: ```rust ShimmerText::new("Loading project data…") .text_base() .font_medium() .text_color(cx.theme().foreground) .max_w_full() ``` The animation reads the active theme's foreground, background, and dark/light mode when no explicit highlight color is provided. A custom theme therefore changes the default shimmer without requiring per-label overrides. Explicit colors remain the caller's responsibility for contrast. When `cx.reduce_motion()` is true, `ShimmerText` renders `StyledText` without requesting animation frames. Marker follows the same rule for typed text and keeps arbitrary content static. This is a rendering behavior, not a separate builder option; applications should keep the label meaningful in both modes. ## Accessibility guidance - Keep a meaningful text label visible to assistive technology. “Thinking…” or “Uploading report.pdf…” is more useful than an unlabeled animated band. - Do not rely on the highlight color, direction, or motion to communicate success, failure, or percentage. - Stop or replace the shimmer when the operation completes, fails, or is cancelled. - Respect reduced-motion preferences. The utility leaves static text in place, so no separate motion-only fallback is required. - Use semantic `Button` or `Link` controls for cancel, retry, and navigation; shimmer itself is not interactive. - Verify an explicit highlight color in both light and dark themes and avoid low-contrast combinations. ## When not to use Shimmer Shimmer communicates activity, not progress. - Use `Progress` for a known percentage. - Use `Spinner` for a compact rotating indicator. - Use `Skeleton` for multi-line placeholder layout. - Render ordinary text once a stable, completed, or failed state exists; do not leave the animation running. ## API reference ### `ShimmerStyle` | Method | Default | Purpose | | --- | --- | --- | | `new()` | same as `Default` | Create a theme-aware two-second looping style. | | `duration(Duration)` | 2 seconds | Set one sweep duration; clamps below 1 ms. | | `highlight_color(Hsla)` | theme-aware | Override the highlight color. | | `spread(f32 \| Pixels)` | relative `0.3` | Set half-width: `f32` is relative and clamps to `0.05..=1.0`; `Pixels` is absolute with a 1px minimum. | | `reverse(bool)` | `false` | Reverse the sweep direction. | | `once(bool)` | `false` | Play one sweep instead of looping. | ### `ShimmerText` | Method | Default | Purpose | | --- | --- | --- | | `new(text)` | default style, generated identity | Create loading text. | | `id(ElementId)` | text-based identity | Distinguish identical sibling labels. | | `with_shimmer_style(ShimmerStyle)` | default style | Apply a reusable configuration. | | `duration(Duration)` | 2 seconds | Set duration directly. | | `highlight_color(Hsla)` | theme-aware | Set color directly. | | `spread(f32 \| Pixels)` | relative `0.3` | Set spread directly. | | `reverse(bool)` | `false` | Set direction directly. | | `once(bool)` | `false` | Set repetition directly. | | `Styled` methods | inherited text style | Refine typography, color, wrapping, and layout. | ### Related components - [`Marker`] — status rows with spinner or shimmer loading styles. - [`AttachmentTitle`] — status-aware file title with shimmer customization. - [`Progress`] — determinate progress. - [`Spinner`] — compact indeterminate progress. [ShimmerStyle]: https://docs.rs/gpui-component/latest/gpui_component/shimmer/struct.ShimmerStyle.html [ShimmerText]: https://docs.rs/gpui-component/latest/gpui_component/shimmer/struct.ShimmerText.html [Marker]: https://docs.rs/gpui-component/latest/gpui_component/marker/struct.Marker.html [AttachmentTitle]: https://docs.rs/gpui-component/latest/gpui_component/attachment/struct.AttachmentTitle.html [Progress]: https://docs.rs/gpui-component/latest/gpui_component/progress/struct.Progress.html [Spinner]: https://docs.rs/gpui-component/latest/gpui_component/spinner/struct.Spinner.html --- # DescriptionList Source: /versions/v0.7.1/component/description-list License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/description-list and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A versatile component for displaying key-value pairs in a structured, organized layout. Supports both horizontal and vertical layouts, multiple columns, borders, and different sizes. Perfect for showing detailed information like metadata, specifications, or summary data. ## Import ```rust use gpui_kit::component::description_list::{DescriptionList, DescriptionItem, DescriptionText}; ``` ## Usage ### Basic Description List ```rust DescriptionList::new() .item("Name", "GPUI Kit", 1) .item("Version", "0.1.0", 1) .item("License", "Apache-2.0", 1) ``` ### Using DescriptionItem Builder ```rust DescriptionList::new() .children([ DescriptionItem::new("Name").value("GPUI Kit"), DescriptionItem::new("Description").value("UI components for building desktop applications"), DescriptionItem::new("Version").value("0.1.0"), ]) ``` ### Different Layouts ```rust // Horizontal layout (default) DescriptionList::horizontal() .item("Platform", "macOS, Windows, Linux", 1) .item("Repository", "https://github.com/longbridge/gpui-kit", 1) // Vertical layout DescriptionList::vertical() .item("Name", "GPUI Kit", 1) .item("Description", "A comprehensive Rust desktop framework", 1) ``` ### Multiple Columns with Spans ```rust DescriptionList::new() .columns(3) .child(DescriptionItem::new("Name").value("GPUI Kit").span(1)) .children([ DescriptionItem::new("Version").value("0.1.0").span(1), DescriptionItem::new("License").value("Apache-2.0").span(1), DescriptionItem::new("Description") .value("Full-featured UI components for desktop applications") .span(3), // Spans all 3 columns DescriptionItem::new("Repository") .value("https://github.com/longbridge/gpui-kit") .span(2), // Spans 2 columns ]) ``` ### With Separators ```rust DescriptionList::new() .item("Name", "GPUI Kit", 1) .item("Version", "0.1.0", 1) .separator() // Add a visual separator .item("Author", "Longbridge", 1) .item("License", "Apache-2.0", 1) ``` ### Different Sizes ```rust // Large size DescriptionList::new() .large() .item("Title", "Large Description List", 1) // Medium size (default) DescriptionList::new() .item("Title", "Medium Description List", 1) // Small size DescriptionList::new() .small() .item("Title", "Small Description List", 1) ``` ### Without Borders ```rust DescriptionList::new() .bordered(false) // Remove borders for a cleaner look .item("Name", "GPUI Kit", 1) .item("Type", "UI Library", 1) ``` ### Custom Label Width (Horizontal Layout) ```rust use gpui_kit::px; DescriptionList::horizontal() .label_width(px(200.0)) // Set custom label width .item("Very Long Label Name", "Short Value", 1) .item("Short", "Very long value that needs more space", 1) ``` ### Rich Content with Custom Elements ```rust use gpui_kit::component::text::markdown; DescriptionList::new() .columns(2) .children([ DescriptionItem::new("Name").value("GPUI Kit"), DescriptionItem::new("Description").value( markdown( "UI components for building **fantastic** desktop applications.", ).into_any_element() ), ]) ``` ### Complex Example with Mixed Content ```rust DescriptionList::new() .columns(3) .label_width(px(150.0)) .children([ DescriptionItem::new("Project Name").value("GPUI Kit").span(1), DescriptionItem::new("Version").value("0.1.0").span(1), DescriptionItem::new("Status").value("Active").span(1), DescriptionItem::Separator, // Full-width separator DescriptionItem::new("Description").value( "A comprehensive Rust desktop framework built on GPUI" ).span(3), DescriptionItem::new("Repository").value( "https://github.com/longbridge/gpui-kit" ).span(2), DescriptionItem::new("License").value("Apache-2.0").span(1), DescriptionItem::new("Platforms").value("macOS, Windows, Linux").span(2), DescriptionItem::new("Language").value("Rust").span(1), ]) ``` ## Examples ### User Profile Information ```rust DescriptionList::new() .columns(2) .bordered(true) .children([ DescriptionItem::new("Full Name").value("John Doe"), DescriptionItem::new("Email").value("john@example.com"), DescriptionItem::new("Phone").value("+1 (555) 123-4567"), DescriptionItem::new("Department").value("Engineering"), DescriptionItem::Separator, DescriptionItem::new("Bio").value( "Senior software engineer with 10+ years of experience in Rust and system programming." ).span(2), ]) ``` ### System Information ```rust DescriptionList::vertical() .small() .bordered(false) .children([ DescriptionItem::new("Operating System").value("macOS 14.0"), DescriptionItem::new("Architecture").value("Apple Silicon (M2)"), DescriptionItem::new("Memory").value("16 GB"), DescriptionItem::new("Storage").value("512 GB SSD"), DescriptionItem::new("GPU").value("Apple M2 10-core GPU"), ]) ``` ### Product Specifications ```rust DescriptionList::new() .columns(3) .large() .children([ DescriptionItem::new("Model").value("MacBook Pro").span(1), DescriptionItem::new("Year").value("2023").span(1), DescriptionItem::new("Screen Size").value("14-inch").span(1), DescriptionItem::new("Processor").value("Apple M2 Pro").span(2), DescriptionItem::new("Base Price").value("$1,999").span(1), DescriptionItem::Separator, DescriptionItem::new("Key Features").value( "Liquid Retina XDR display, ProMotion technology, P3 wide color gamut" ).span(3), ]) ``` ### Configuration Settings ```rust DescriptionList::horizontal() .label_width(px(180.0)) .bordered(false) .children([ DescriptionItem::new("Theme").value("Dark Mode"), DescriptionItem::new("Font Size").value("14px"), DescriptionItem::new("Auto Save").value("Enabled"), DescriptionItem::new("Backup Frequency").value("Every 30 minutes"), DescriptionItem::new("Language").value("English (US)"), ]) ``` ## Design Guidelines - Use horizontal layout for simple key-value pairs - Use vertical layout when values are lengthy or complex - Limit columns to 3-4 for optimal readability - Use separators to group related information - Keep labels concise and descriptive - Use consistent spacing with the size prop - Consider removing borders for embedded contexts --- # Checkbox Source: /versions/v0.7.1/component/checkbox License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/checkbox and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A checkbox component for binary choices. Supports labels, disabled state, and different sizes. Use `on_change` for requested values. The owner stores the value and calls `cx.notify()`. The existing `on_click` name remains a compatibility alias; setting either replaces the same handler, so the last call wins. ## Import ```rust use gpui_kit::component::checkbox::Checkbox; ``` ## Usage ### Basic Checkbox ```rust Checkbox::new("my-checkbox") .label("Accept terms and conditions") .checked(false) .on_change(|checked, _, _| { println!("Checkbox is now: {}", checked); }) ``` The `on_change` callback is triggered when the user toggles the checkbox, receiving the **new checked state**. ### Controlled Checkbox This complete **Tested consumer recipe** keeps the value on the rendering owner and applies the requested value from `on_change` before notifying: ```rust use gpui_kit::component::checkbox::Checkbox; use gpui_kit::{Context, IntoElement, Render, Window}; pub struct ControlledCheckbox { checked: bool, } impl ControlledCheckbox { pub fn new() -> Self { Self { checked: false } } pub fn is_checked(&self) -> bool { self.checked } } impl Render for ControlledCheckbox { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { Checkbox::new("marketing-emails") .label("Receive product updates") .checked(self.checked) .on_change(cx.listener(|this, checked, _, cx| { this.checked = *checked; cx.notify(); })) } } ``` ### Different Sizes ```rust use gpui_kit::component::Sizable as _; Checkbox::new("cb").text_xs().label("Extra Small") Checkbox::new("cb").text_sm().label("Small") Checkbox::new("cb").label("Medium") // default Checkbox::new("cb").text_lg().label("Large") ``` ### Disabled State ```rust use gpui_kit::component::Disableable as _; Checkbox::new("checkbox") .label("Disabled checkbox") .disabled(true) .checked(false) ``` ### Without Label ```rust Checkbox::new("checkbox") .checked(true) ``` ### Custom Tab Order ```rust Checkbox::new("checkbox") .label("Custom tab order") .tab_index(2) .tab_stop(true) ``` ## API Reference - [Checkbox] ### Styling Implements `Sizable` and `Disableable` traits: - `text_xs()` - Extra small text - `text_sm()` - Small text - `text_base()` - Base text (default) - `text_lg()` - Large text - `disabled(bool)` - Disabled state ## Examples ### Checkbox List ```rust v_flex() .gap_2() .child(Checkbox::new("cb1").label("Option 1").checked(true)) .child(Checkbox::new("cb2").label("Option 2").checked(false)) .child(Checkbox::new("cb3").label("Option 3").checked(false)) ``` ### Form Integration ```rust struct FormView { agree_terms: bool, subscribe: bool, } v_flex() .gap_3() .child( Checkbox::new("terms") .label("I agree to the terms and conditions") .checked(self.agree_terms) .on_change(cx.listener(|view, checked, _, cx| { view.agree_terms = *checked; cx.notify(); })) ) .child( Checkbox::new("subscribe") .label("Subscribe to newsletter") .checked(self.subscribe) .on_change(cx.listener(|view, checked, _, cx| { view.subscribe = *checked; cx.notify(); })) ) ``` [Checkbox]: https://docs.rs/gpui-component/latest/gpui_component/checkbox/struct.Checkbox.html --- # Notification Source: /versions/v0.7.1/component/notification License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/notification and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A toast notification system for displaying temporary messages to users. Notifications appear at the top right of the window and can auto-dismiss after a timeout. Supports multiple variants (info, success, warning, error), custom content, titles, and action buttons. Perfect for status updates, confirmations, and user feedback. ## Import ```rust use gpui_kit::component::{ notification::{Notification, NotificationType}, WindowExt }; ``` ## Usage ### Where notifications render The window's [Root](/versions/v0.7.1/component/root) automatically mounts and renders notifications. Open the window with `gpui_kit::open_window`, or wrap the application view in `Root::new`. Application views do not render overlay layers themselves. ### Basic Notification ```rust // Simple string notification window.push_notification("This is a notification.", cx); // Using Notification builder Notification::new() .message("Your changes have been saved.") ``` ### Notification Types ```rust // Info notification (blue) window.push_notification( (NotificationType::Info, "File saved successfully."), cx, ); // Success notification (green) window.push_notification( (NotificationType::Success, "Payment processed successfully."), cx, ); // Warning notification (yellow/orange) window.push_notification( (NotificationType::Warning, "Network connection is unstable."), cx, ); // Error notification (red) window.push_notification( (NotificationType::Error, "Failed to save file. Please try again."), cx, ); ``` ### Notification with Title ```rust Notification::new() .title("Update Available") .message("A new version of the application is ready to install.") .with_type(NotificationType::Info) ``` ### Auto-hide Control ```rust // Disable auto-hide (manual dismiss only) Notification::new() .message("This notification stays until manually closed.") .autohide(false) // Default auto-hide after 5 seconds Notification::new() .message("This will disappear automatically.") .autohide(true) // default ``` The countdown pauses while the pointer is over the notifications or one of them has keyboard focus, and resumes when the pointer leaves or focus moves on. It keeps running while the window is inactive, so a message that must not be missed should disable auto-hide or use system delivery. ### Placement Notifications appear at the top right of the window by default. Set a global default for all notifications, or override it for a single notification. Notifications are stacked separately for each placement. ```rust use gpui_kit::Anchor; // Global default (default: Anchor::TopRight) Theme::update(cx, |theme| theme.notification.placement = Anchor::BottomRight); // Per-notification override Notification::info("Download complete.") .placement(Anchor::BottomLeft) ``` Supported values are `Anchor::TopLeft`, `Anchor::TopCenter`, `Anchor::TopRight`, `Anchor::LeftCenter`, `Anchor::RightCenter`, `Anchor::BottomLeft`, `Anchor::BottomCenter`, and `Anchor::BottomRight`. ### With Action Button ```rust Notification::new() .title("Connection Lost") .message("Unable to connect to server.") .with_type(NotificationType::Error) .autohide(false) .action(|_, cx| { Button::new("retry") .primary() .label("Retry") .on_click(cx.listener(|this, _, window, cx| { // Perform retry action println!("Retrying connection..."); this.dismiss(window, cx); })) }) ``` ### Clickable Notifications ```rust Notification::new() .message("Click to view details") .on_click(cx.listener(|_, _, _, cx| { println!("Notification clicked"); // Handle notification click cx.notify(); })) ``` ### Custom Content ```rust use gpui_kit::component::text::markdown; let markdown_content = r#" ## Custom Notification - **Feature**: New dashboard available - **Status**: Ready to use - [Learn more](https://example.com) "#; Notification::new() .content(|_, window, cx| { markdown(markdown_content).into_any_element() }) ``` ### Unique Notifications When you need to manage notifications manually, such as for long-running processes or persistent alerts, you can use unique IDs to push and remove notifications as needed. In this case, you can create a special `struct` in local scope, and use `id` methods with this struct to identify the notification. Then you can push the notification when needed, and later remove it using the same ID. Like this: ```rust // Using type-based ID for uniqueness struct UpdateNotification; Notification::new() .id::() .message("System update available") .autohide(false) // Using type + element ID for multiple unique notifications struct TaskNotification; Notification::warning("Task failed to complete") .id1::("task-123") .title("Task Failed") ``` Then remove the notification with `window.remove_notification::`, like this: ```rust // Later, dismiss the notification window.remove_notification::(cx); ``` ### System Notification A notification can also be delivered to the operating system's notification center. Use `NotificationDelivery` to choose where a notification goes: as an in-app toast (`InApp`, the default), in the OS notification center (`System`), or both (`InAppAndSystem`). ```rust use gpui_kit::component::notification::{Notification, NotificationDelivery}; // Per-notification override; `.system()` and `.in_app_and_system()` are // shorthands for `.delivery(NotificationDelivery::...)`. Notification::info("Your download is ready.") .title("Download complete") .system() // Or set a global default for all notifications Theme::update(cx, |theme| { theme.notification.delivery = NotificationDelivery::InAppAndSystem }); ``` The notification's title and message become the system notification's title and body; a notification with neither is not posted. Pushing again with the same `.id::()` replaces the previous system notification, and `window.remove_notification::(cx)` / `window.clear_notifications(cx)` retract it. When the toast auto-hides, the system notification stays in the notification center. Clicking the system notification activates the application and its window, closes the in-app toast (if any), and fires `on_click` with a default `ClickEvent`. With `NotificationDelivery::System` no toast exists, so `on_close` is never called. `gpui_kit::component::init` registers the app-global `on_system_notification_response` handler, so do not register your own after it — gpui keeps only one. Notifications your application posts directly via `cx.show_system_notification` are left untouched. Platform requirements: | Platform | Requirement | Retraction | | --- | --- | --- | | macOS | Must run from a bundled `.app` in a trusted location (e.g. `/Applications`); silently disabled under plain `cargo run`. The first post triggers the system authorization prompt; a denial is remembered and later posts fail silently | Supported | | Windows | Call `cx.set_app_identity(identifier, name)` early in startup | Supported | | Linux | An XDG notification daemon must be present | Unsupported (ages out) | ## Examples ### Form Validation Error ```rust Notification::error("Please correct the following errors before submitting.") .title("Validation Failed") .autohide(false) .action(|_, _, cx| { Button::new("review") .outline() .label("Review Form") .on_click(cx.listener(|this, _, window, cx| { // Navigate to form this.dismiss(window, cx); })) }) ``` ### File Upload Progress ```rust struct UploadNotification; // Start upload notification window.push_notification( Notification::info("Uploading file...") .id::() .title("File Upload") .autohide(false), cx, ); // Update to success when complete window.push_notification( Notification::success("File uploaded successfully!") .id::() .title("Upload Complete"), cx, ); ``` ### System Status Updates ```rust // Warning about maintenance Notification::warning("System maintenance will begin in 30 minutes.") .title("Scheduled Maintenance") .autohide(false) .action(|_, cx| { Button::new("details") .link() .label("View Details") .on_click(cx.listener(|this, _, window, cx| { // Show maintenance details this.dismiss(window, cx); })) }) ``` ### Batch Operation Results ```rust use gpui_kit::component::text::markdown; let results_content = r#" ## Batch Operation Complete **Processed**: 150 items **Success**: 147 items **Failed**: 3 items [View failed items](/) "#; Notification::success("Batch operation completed with some failures.") .title("Operation Results") .content(|window, cx| { markdown(results_content).into_any_element() }) .autohide(false) ``` ### Interactive Confirmation ```rust struct SaveConfirmation; Notification::new() .id::() .title("Unsaved Changes") .message("You have unsaved changes. Save before leaving?") .autohide(false) .action(|_, cx| { Button::new("save") .primary() .label("Save") .on_click(cx.listener(|this, _, window, cx| { // Perform save println!("Saving changes..."); this.dismiss(window, cx); })) }) .on_click(cx.listener(|_, _, _, cx| { println!("Save reminder clicked"); cx.notify(); })) ``` --- # Spinner Source: /versions/v0.7.1/component/spinner License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/spinner and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Spinner element displays an animated loading. Perfect for showing loading states, progress spinners, and other visual feedback during asynchronous operations. Features customizable icons, colors, sizes, and rotation animations. ## Import ```rust use gpui_kit::component::spinner::Spinner; ``` ## Usage ### Basic ```rust // Default loader icon Spinner::new() ``` ### Spinner with Custom Color ```rust use gpui_kit::component::ActiveTheme; // Blue spinner Spinner::new() .color(cx.theme().blue) // Green spinner for success states Spinner::new() .color(cx.theme().green) // Custom color Spinner::new() .color(cx.theme().cyan) ``` ### Spinner Sizes ```rust // Extra small spinner Spinner::new().xsmall() // Small spinner Spinner::new().small() // Medium spinner (default) Spinner::new() // Large spinner Spinner::new().large() // Custom size Spinner::new().with_size(px(64.)) ``` ### Spinner with Custom Icon ```rust use gpui_kit::component::IconName; // Loading circle icon Spinner::new() .icon(IconName::LoaderCircle) // Large loading circle with custom color Spinner::new() .icon(IconName::LoaderCircle) .large() .color(cx.theme().cyan) // Different loading icons Spinner::new() .icon(IconName::Loader) .color(cx.theme().primary) ``` ## Available Icons The Spinner component supports various loading and progress icons: ### Loading Icons - `Loader` (default) - Rotating line spinner - `LoaderCircle` - Circular loading spinner ### Other Compatible Icons - Any icon from the `IconName` enum can be used, though loading-specific icons work best with the rotation animation ## Animation The Spinner component features a built-in rotation animation: - **Duration**: 0.8 seconds (configurable via speed parameter) - **Easing**: Ease-in-out transition - **Repeat**: Infinite loop - **Transform**: 360-degree rotation ## Size Reference | Size | Method | Approximate Pixels | | ----------- | ------------------- | ------------------ | | Extra Small | `.xsmall()` | ~12px | | Small | `.small()` | ~14px | | Medium | (default) | ~16px | | Large | `.large()` | ~24px | | Custom | `.with_size(px(n))` | n px | ## Examples ### Loading States ```rust // Simple loading spinner Spinner::new() // Loading with custom color Spinner::new() .color(cx.theme().blue) // Large loading spinner Spinner::new() .large() .color(cx.theme().primary) ``` ### Different Loading Icons ```rust // Default loader (line spinner) Spinner::new() .color(cx.theme().muted_foreground) // Circle loader Spinner::new() .icon(IconName::LoaderCircle) .color(cx.theme().blue) // Large circle loader with custom color Spinner::new() .icon(IconName::LoaderCircle) .large() .color(cx.theme().green) ``` ### Status Spinners ```rust // Loading state Spinner::new() .small() .color(cx.theme().muted_foreground) // Processing state Spinner::new() .icon(IconName::LoaderCircle) .color(cx.theme().blue) // Success processing (still animating) Spinner::new() .icon(IconName::LoaderCircle) .color(cx.theme().green) ``` ### Size Variations ```rust // Extra small for inline text Spinner::new() .xsmall() .color(cx.theme().muted_foreground) // Small for buttons Spinner::new() .small() .color(cx.theme().primary_foreground) // Medium for general use (default) Spinner::new() .color(cx.theme().primary) // Large for prominent loading states Spinner::new() .large() .color(cx.theme().blue) // Custom size for specific requirements Spinner::new() .with_size(px(32.)) .color(cx.theme().orange) ``` ### In UI Components ```rust // In a button Button::new("submit-btn") .loading(true) .icon( Spinner::new() .small() .color(cx.theme().primary_foreground) ) .label("Loading...") // In a card header div() .flex() .items_center() .gap_2() .child("Processing...") .child( Spinner::new() .small() .color(cx.theme().muted_foreground) ) // Full-screen loading div() .flex() .items_center() .justify_center() .h_full() .w_full() .child( Spinner::new() .large() .color(cx.theme().primary) ) ``` ## Performance Considerations - The animation uses CSS transforms for optimal performance - Multiple spinners on the same page share the same animation timing - The component is lightweight and suitable for frequent updates - Consider using smaller sizes for better performance with many spinners ## Common Patterns ### Conditional Loading ```rust // Show spinner only when loading .when(is_loading, |this| { this.child( Spinner::new() .small() .color(cx.theme().muted_foreground) ) }) ``` ### Loading with Text ```rust // Loading text with spinner h_flex() .items_center() .gap_2() .child( Spinner::new() .small() .color(cx.theme().primary) ) .child("Loading data...") ``` ### Overlay Loading ```rust // Full overlay with spinner div() .absolute() .inset_0() .flex() .items_center() .justify_center() .bg(cx.theme().background.alpha(0.8)) .child( v_flex() .items_center() .gap_3() .child( Spinner::new() .large() .color(cx.theme().primary) ) .child("Loading...") ) ``` --- # Root View Source: /versions/v0.7.1/component/root License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/root and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. [Root] is the Base-owned root view of every GPUI Kit window. `gpui_kit::open_window` is the application entry point and always creates this type. Base does not expose a separate window helper; `component::Root` re-exports the Base type. Base owns the content and overlay host, keyboard traversal and selection copying. Calling `gpui_component::init` explicitly registers the styled window extension: dialogs, sheets, notifications, tooltips, menus, touch selection, theme and window chrome. Initialize it before creating windows. A Base-only application calls `gpui_base::init` and needs no Component or Kit dependency. Cargo feature unification does not change the root type. This complete **Tested consumer recipe** is compiled from the isolated `gpui-kit` consumer workspace. It initializes GPUI Kit, then opens a window whose root is a `Root` wrapping the application view. ```rust use gpui_kit::{ AppContext as _, Context, IntoElement, ParentElement as _, Render, Styled as _, Window, WindowOptions, div, }; pub fn run() { gpui_kit::application() .with_assets(gpui_kit::assets::Assets) .run(|cx| { gpui_kit::init(cx); // The window's root view is a `Root` wrapping the view, which // renders dialogs, sheets and notifications above it. gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| BootstrapView) }) .expect("failed to open window"); }); } struct BootstrapView; impl Render for BootstrapView { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div().size_full().child("My application") } } ``` `gpui_kit::open_window` is `cx.open_window` plus the `Root` wrapper. `Root` must be the window's root view so window-level facilities such as dialogs, sheets, notifications, focus traversal, and text selection remain available. Client-side window borders are selected from the window's decoration mode; server-decorated and layer-shell windows do not require Root configuration. `open_window` returns the window and the view, so a view that must be built inside the window (it owns an `InputState`, say) can still be kept: ```rust let (window, editor) = gpui_kit::open_window(WindowOptions::default(), cx, |window, cx| { cx.new(|cx| Editor::new(window, cx)) })?; ``` ## Closing windows and quitting Applications define their own quit and close-window actions and key bindings. `gpui_kit::init` does not install them. Handle unsaved changes and any confirmation in the application before closing a window or quitting. ## Overlays `Root` always mounts the dialog, sheet and notification layers above application content. Applications only call `window.open_dialog`, `window.open_sheet` or `window.push_notification`; no manual mounting or configuration is needed. Child view caching does not affect overlay rendering. ### Migrating to 0.7.0 `Root::render_dialog_layer`, `Root::render_sheet_layer` and `Root::render_notification_layer` have been removed. Delete their calls and the corresponding `.children(...)` expressions from application views. Previously customized layer positions now use the window-level Root's overlay placement. [Root]: https://docs.rs/gpui-base/latest/gpui_base/struct.Root.html --- # Command Source: /versions/v0.7.1/component/command License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/command and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A command palette is a filtered list of commands with groups, Action-derived keybinding hints, and keyboard navigation. Use it inline or compose it into an existing dialog for a `⌘K`-style menu. On invalidation, Command creates and layout-measures every flattened row; `v_virtual_list` then renders and paints only viewport rows. `Command` owns the entries and presentation policy. `CommandState` owns the interaction state: query input, focus, selection, scrolling, and loading. ## Import ```rust use gpui_kit::component::command::{Command, CommandEntry, CommandGroup, CommandItem, CommandState}; ``` ## Composition Build the palette structure directly on `Command`; create an empty state once and reuse it while the palette is shown. ```text Command ├── CommandItem // ungrouped ├── CommandGroup │ ├── CommandItem │ └── CommandItem ├── separator └── CommandGroup ├── CommandItem └── CommandItem CommandState // query, focus, selection, scrolling ``` ## Usage ### Inline Define Actions and bindings in application setup. The default row resolves an Action's active binding in the Command focus scope and then at application scope, rendering a `Kbd` hint only when it finds one. ```rust use gpui_kit::{actions, KeyBinding}; actions!(my_app, [OpenProfile, OpenBilling]); // During application setup: cx.bind_keys([ KeyBinding::new("cmd-p", OpenProfile, Some("Command")), KeyBinding::new("cmd-b", OpenBilling, Some("Command")), ]); let state = cx.new(|cx| CommandState::new(window, cx)); Command::new(&state) .group( CommandGroup::new().label("Suggestions") .item(CommandItem::new().label("Calendar").icon(IconName::Calendar)) .item(CommandItem::new().label("Search Emoji").icon(IconName::Search)) .item(CommandItem::new().label("Calculator").disabled(true)), ) .separator() .group( CommandGroup::new().label("Settings") .item( CommandItem::new().label("Profile") .icon(IconName::User) .action(Box::new(OpenProfile)), ) .item( CommandItem::new().label("Billing") .action(Box::new(OpenBilling)), ), ) .placeholder("Type a command or search...") .empty(|_, _, cx| { v_flex() .items_center() .gap_2() .child(Icon::new(IconName::Search).size_8()) .child("No results found.") }) .w(px(380.)) ``` Do not provide a manually formatted shortcut string. `CommandItem::action` provides both the executable behavior and, for the default row, the displayed binding. A custom row owns its complete presentation, including any key hint. ### Quick Actions Without Search Disable search for a compact action palette. It has no search field, retains all entries, and `state.focus(window, cx)` focuses the Command frame so its arrow, Enter, and Escape actions remain available. ```rust let actions = cx.new(|cx| CommandState::new(window, cx)); Command::new(&actions) .searchable(false) .items([ CommandItem::new().label("New File").icon(IconName::Plus), CommandItem::new().label("Duplicate").icon(IconName::Copy), CommandItem::new().label("Move to Trash").icon(IconName::Delete), ]) .w(px(380.)) ``` With the default `.searchable(true)`, `state.focus(window, cx)` and [`Focusable::focus_handle`] target the search input instead. A non-searchable palette never invokes `on_query`. ### In a Dialog Compose the palette with the existing [`WindowExt::open_dialog`] API. `header` renders above the optional search field and list; `footer` renders below the list. In a searchable palette, Escape clears a non-empty query. Otherwise— including a non-searchable palette with a hidden programmatic query—Command calls `on_cancel` and then propagates Cancel. Let the hosting Dialog perform dismissal—do not close it again in `on_cancel`. ```rust use gpui_kit::component::WindowExt as _; let state = self.command_state.clone(); window.open_dialog(cx, move |dialog, _, _| { let state = state.clone(); dialog.close_button(false).p_0().content(move |content, _, _| { content.child( Command::new(&state) .bordered(false) .placeholder("Type a command or search...") .items([ CommandItem::new().label("Profile"), CommandItem::new().label("Billing"), ]) .on_confirm(|index, window, cx| { window.push_notification(format!("Selected {index}"), cx); }) // Record local cleanup only; Dialog handles the propagated Cancel. .on_cancel(|window, cx| { window.push_notification("Command palette cancelled", cx); }) .header(|state, _, cx| { h_flex() .justify_between() .px_3() .py_2() .border_b_1() .border_color(cx.theme().border) .child("Commands") .child(format!("{} matches", state.matched_count())) }) .footer(|_, _, cx| { h_flex() .gap_3() .px_3() .py_2() .border_t_1() .border_color(cx.theme().border) .child("↑↓ Navigate") .child("Enter Select") .child("Escape Close") }), ) }) }); ``` ### Callbacks and Actions Callbacks are configured on `Command`, not subscribed from `CommandState`. They notify the palette owner directly: ```rust Command::new(&state) .items(entries) .on_query(|query, window, cx| { // Start or update an application-owned search. }) .on_select(|index, window, cx| { // Preview the newly highlighted IndexPath. }) .on_confirm(|index, window, cx| { // Finish with this IndexPath, whether or not it has an Action. }) .on_cancel(|window, cx| { // Clean up local palette state before Cancel propagates. }) ``` An `IndexPath` always addresses the model supplied by the latest `Command` render, before local filtering. Items passed to `.items(...)` are in section 0, with `row` equal to their position in that iterator. Explicit groups use their group and item positions; when both forms are mixed, they follow the implicit ungrouped section. Filtering changes what is visible, not these coordinates. `on_query` runs only when a searchable query actually changes. Refiltering can move the highlight, so its `on_select` runs first when the selected `IndexPath` changes; then `on_query` runs. These callbacks, and `on_confirm`, are delivered after the current `CommandState` update releases its lease. Keyboard and pointer highlight changes run `on_select` but never dispatch an Action. While the source window remains live, confirming an enabled item dispatches its Action first and then invokes `on_confirm`; if that Action closes the window, the callback cannot be delivered. An item without an Action still invokes `on_confirm`. In a searchable palette, Escape clears a non-empty query. Otherwise—including a non-searchable palette with a hidden programmatic query—it invokes `on_cancel`, then propagates Cancel. ### Dynamic Entries Keep asynchronous or changing entries in the owner view, then reconstruct the Command from the owner's current data when that view renders. Do not mutate the state with an entry builder or `set_entries`. ```rust struct StockSearch { state: Entity, results: Vec, } impl StockSearch { fn render_palette(&self, owner: WeakEntity) -> Command { let results = self.results.clone(); Command::new(&self.state) .items(results) .on_query(move |query, window, cx| { _ = owner.update(cx, |this, cx| this.search(query, window, cx)); }) } } ``` The installed model remains in `CommandState` while query, selection, and scrolling change, so those interactions do not need an owner rerender. A later owner render installs the new model, preserves the selected `IndexPath` when it is still present, and remeasures rows. ## Searching Command uses a case-insensitive substring match against each item's label and keywords. Empty queries match every item. A group whose items all filter out hides its heading; a separator left leading, trailing, or adjacent to another separator is omitted. ```rust CommandItem::new().label("Profile") .keywords(["account", "user"]) ``` For custom or remote search, update owner-held entries in `on_query` and call `state.set_loading(true, window, cx)` while waiting so the empty message is suppressed. Render the new entries when the response arrives. ## Custom Rows and Virtualization `CommandItem::child` replaces an item's icon and label content with a lazy child factory. The factory can run more than once for measurement, viewport entry, and typography or width invalidation, so it must be side-effect-free. On invalidation, Command creates and layout-measures every flattened row before supplying independent sizes to `v_virtual_list`. Custom rows may therefore have different intrinsic heights; `v_virtual_list` still renders and paints only viewport rows. Build them for the available list width and keep their rendered content stable until the owner updates the entries. ```rust Command::new(&state) .item(CommandItem::new().label("compact").child(|_, _| { h_flex().w_full().py_1().child("Compact custom row") })) .item(CommandItem::new().label("expanded").child(|_, cx| { v_flex() .w_full() .py_4() .child("Expanded custom row") .child(div().text_xs().text_color(cx.theme().muted_foreground).child("Extra detail")) })) ``` ## Command | Method | Signature and description | | --- | --- | | `new` | `new(&Entity) -> Command` creates a palette for a state. | | `item` / `items` | `item(CommandItem) -> Self` and `items(impl IntoIterator) -> Self` add ungrouped entries. | | `group` / `separator` | `group(CommandGroup) -> Self` adds a group; `separator() -> Self` adds a divider. | | `searchable` | `searchable(bool) -> Self` shows or hides the search field and local filtering. Default: `true`. | | `on_query` | `on_query(F) -> Self`, where `F: Fn(&str, &mut Window, &mut App) + 'static`, runs after a searchable query changes. | | `on_select` | `on_select(F) -> Self`, where `F: Fn(IndexPath, &mut Window, &mut App) + 'static`, runs when the highlighted path changes. | | `on_confirm` | `on_confirm(F) -> Self`, with the same `IndexPath` callback bounds; while the source window remains live, runs after the confirmed Action dispatches. | | `on_cancel` | `on_cancel(F) -> Self`, where `F: Fn(&mut Window, &mut App) + 'static`, runs before Cancel propagates when Escape does not clear a searchable query. | | `placeholder` | `placeholder(impl Into) -> Self` sets the search-field placeholder. | | `empty` | `empty(F) -> Self` renders custom content when there are no matches. | | `max_h` | `max_h(impl Into) -> Self` sets the list maximum. Default: `18.75rem` (300px). | | `bordered` | `bordered(bool) -> Self` draws the surrounding border and rounding. Default: `true`. | | `header` | `header(F) -> Self`, where `F: Fn(&CommandState, &mut Window, &mut App) -> E + 'static` and `E: IntoElement`; renders above search and list. | | `footer` | `footer(F) -> Self`, with the same callback bounds; renders below the list. | `Command` implements [`Styled`], so `w`, `max_w`, `bg`, and other styles apply to the palette frame. ## CommandItem | Method | Description | | --- | --- | | `new` | Creates an item; Command generates its internal rendering identity. | | `label` | Sets the visible label and default search text. | | `icon` | Sets the leading icon for the default row. | | `action` | `action(Box) -> Self` sets the behavior dispatched on click or confirm. The default row displays its resolved binding. | | `checked` | Draws a trailing check. A resolved Action binding uses that position instead. | | `keywords` | Adds default-match terms. | | `disabled` | `Disableable::disabled(bool) -> Self` makes the item non-interactive and skips it during keyboard navigation. | | `child` | `child(F) -> Self`, where `F: Fn(&mut Window, &mut App) -> E + 'static` and `E: IntoElement`; lazily replaces the default row content. | ## CommandGroup | Method | Description | | --- | --- | | `new` | Creates an unlabeled group. | | `label` | Sets the group heading, which hides when all items filter out. | | `item` / `items` | Add one or many `CommandItem`s to the group. | | `heading` | Returns the optional heading. | `CommandEntry` is the public enum for an item, group, or separator. It is useful when an owner stores a mixed dynamic entry collection; replay each variant onto a newly constructed `Command` during rendering. ## CommandState | Method | Signature and description | | --- | --- | | `new` | `new(&mut Window, &mut Context) -> Self` creates empty interaction state. | | `query` / `set_query` | Read the query, or `set_query(query, window, cx)` as if it were typed. | | `selected_index` | Returns the highlighted item's original `IndexPath`; section identifies the top-level entry and row identifies the item within a group. | | `matched_count` | Returns the number of matching items. | | `focus` | `focus(&self, &mut Window, &mut App)` focuses the input when searchable, otherwise the Command frame. | | `set_loading` / `is_loading` | Show or read the search spinner; loading suppresses the empty message. | ## Keyboard Shortcuts | Key | Action | | --- | --- | | `↑` / `↓` | Move the highlight, wrapping around and skipping disabled items. | | `Enter` | Confirm the highlighted item. | | `Escape` | In a searchable palette, clear a non-empty query; otherwise call `on_cancel` and propagate `Cancel`. | ## Best Practices 1. Build static entries, groups, separators, searchability, and filters on `Command`. 2. Keep dynamic entries and asynchronous results in the palette owner; rebuild `Command` from them when rendering. 3. Bind real `Action`s instead of supplying shortcut text, so hints and dispatch stay in sync. 4. Keep `child` factories side-effect-free and use them for rows that need custom presentation or variable heights. 5. Let a hosting Dialog own cancellation after `on_cancel`; use header and footer for application-owned status and hints. 6. Give each independently rendered palette its own [`CommandState`]. [Command]: https://docs.rs/gpui-component/latest/gpui_component/command/struct.Command.html [CommandState]: https://docs.rs/gpui-component/latest/gpui_component/command/struct.CommandState.html [CommandGroup]: https://docs.rs/gpui-component/latest/gpui_component/command/struct.CommandGroup.html [WindowExt::open_dialog]: https://docs.rs/gpui-component/latest/gpui_component/trait.WindowExt.html#tymethod.open_dialog [Focusable::focus_handle]: https://docs.rs/gpui/latest/gpui/trait.Focusable.html#tymethod.focus_handle [Styled]: https://docs.rs/gpui/latest/gpui/trait.Styled.html --- # Marker Source: /versions/v0.7.1/component/marker License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/marker and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `Marker` is a lightweight row for status text, timeline boundaries, unread labels, and system notices. It deliberately accepts arbitrary children instead of defining an application-specific status enum. `MarkerIcon` and `MarkerContent` are optional typed slots for the common icon-and-label shape; direct children remain available for custom composition. `Marker` is a layout and loading primitive. It does not own a notification record, an unread count, a click action, or a live status store. Compose those with application state and existing `Badge`, `Button`, `Link`, or navigation components. ## Import ```rust use gpui_kit::{ParentElement as _, StyleRefinement, Styled as _}; use gpui_kit::component::{ ActiveTheme as _, Icon, IconName, Sizable as _, badge::Badge, button::{Button, ButtonVariants as _}, marker::{ Marker, MarkerAlignment, MarkerContent, MarkerIcon, MarkerLoadingStyle, MarkerVariant, }, shimmer::{ShimmerStyle, ShimmerText}, spinner::Spinner, }; use std::time::Duration; ``` ## Anatomy and basic usage The typed form keeps icon and content style targets independent: ```rust Marker::new() .icon(MarkerIcon::new().child(Icon::new(IconName::CircleCheck))) .content(MarkerContent::new().text("Online")) ``` Direct children are useful when a marker needs an application-specific layout: ```rust Marker::new() .child(Icon::new(IconName::Info)) .child("Conversation archived") ``` The default state is: | Property | Default | Meaning | | --- | --- | --- | | Variant | `Plain` | A full-width status row without divider decoration. | | Alignment | unset | `Separator` centers its label; every other variant starts at the leading edge. | | Loading | `false` | No automatic loading effect. | | Loading style | `Spinner` | Used when loading is enabled. | | Icon slot | absent | A spinner is inserted only for spinner loading with no icon. | | Content | absent | Add text or arbitrary child content. | | Row minimum height | `rems(1.)` | Follows the shared typography scale. | | Row gap | `gap_2()` | Shared compact spacing. | Use `MarkerContent::text(...)` for text that should receive the loading shimmer. Use `.child(...)` for arbitrary elements or text that should keep its own rendering behavior. ## Variants ### Plain `Plain` is the default compact status row: ```rust Marker::new() .text_color(cx.theme().success) .icon(MarkerIcon::new().child(Icon::new(IconName::CircleCheck))) .content(MarkerContent::new().text("Synced")) ``` The library does not define `Online`, `Read`, `Typing`, or `Synced` values. The application supplies the words, icon, and semantic color so the same primitive can serve different domains. ### Separator `Separator` adds a flexible line on each side of the content: ```rust Marker::new() .with_variant(MarkerVariant::Separator) .content(MarkerContent::new().text("Today")) ``` The line is an internal 1-pixel decorative element. The label remains the semantic content. Use `separator_style(...)` to refine the two lines without having to recreate their layout: ```rust Marker::new() .with_variant(MarkerVariant::Separator) .separator_style( StyleRefinement::default() .bg(cx.theme().ring), ) .content(MarkerContent::new().text("Yesterday")) ``` ### Border `Border` adds a semantic bottom border and compact bottom padding: ```rust Marker::new() .with_variant(MarkerVariant::Border) .icon(MarkerIcon::new().child(Icon::new(IconName::Info))) .content(MarkerContent::new().text("3 unread messages")) ``` The border is a visual boundary. Keep the unread count and meaning in text so the state does not depend on color or a line alone. ## Alignment A marker spans the full row. `alignment(...)` decides where its children sit inside that row and how wrapped text lines align: ```rust Marker::new() .alignment(MarkerAlignment::Center) .icon(MarkerIcon::new().child(Icon::new(IconName::Info))) .content(MarkerContent::new().text("Messages are end-to-end encrypted")); Marker::new() .alignment(MarkerAlignment::End) .content(MarkerContent::new().text("Delivered")) ``` Unset, `Separator` centers its label between the two lines and every other variant starts at the leading edge. An explicit alignment applies to any variant; a separator then keeps only the line on the far side of its label, so `Start` draws the trailing line and `End` the leading one. Centered notices are the usual shape for a transcript's system rows, such as a stopped answer or a failed request with a retry action; an `End` marker trails a delivery state under an outgoing message. ## Loading styles Set `loading(true)` without changing the marker's variant or normal layout: ```rust Marker::new() .loading(true) .with_loading_style(MarkerLoadingStyle::Spinner) .content(MarkerContent::new().text("Loading messages…")); Marker::new() .loading(true) .with_loading_style(MarkerLoadingStyle::Shimmer) .content(MarkerContent::new().text("Thinking…")) ``` Spinner behavior is intentionally predictable: - `Spinner` is the default `MarkerLoadingStyle`. - If loading uses `Spinner` and no `MarkerIcon` was supplied, a compact `Spinner::new().xsmall()` is inserted automatically. - If the application supplies `MarkerIcon`, that icon wins and no automatic spinner is added. - `MarkerVariant::Separator` still renders its divider lines while loading. - `MarkerVariant::Border` still renders its border while loading. Shimmer is text-aware when content was added with `.text(...)`: ```rust Marker::new() .loading(true) .with_loading_style(MarkerLoadingStyle::Shimmer) .content(MarkerContent::new().text("Generating a response…")) ``` Arbitrary `MarkerContent` children are still supported. When there is no typed text child, the content slot receives a gentle opacity animation instead. Icons and separator lines stay static. When reduced motion is enabled, text is rendered without animation and the marker remains readable. ## Shimmer configuration Use one `ShimmerStyle` for a marker's text effect: ```rust Marker::new() .loading(true) .with_loading_style(MarkerLoadingStyle::Shimmer) .with_shimmer_style( ShimmerStyle::new() .duration(Duration::from_secs(3)) .highlight_color(cx.theme().primary) .spread(0.45) .reverse(true) .once(false), ) .content(MarkerContent::new().text("Processing files…")) ``` The `ShimmerStyle` defaults are a two-second repeating sweep, theme-aware highlight color, `0.3` normalized spread, left-to-right direction, and looping. `duration(...)` clamps values below one millisecond. `spread(...)` accepts a relative `f32` (clamped to `0.05..=1.0`) or an absolute `Pixels` half-width; non-finite values leave the current spread unchanged. `reverse(true)` changes the direction, and `once(true)` stops after one sweep. For a marker-independent loading label, use `ShimmerText` directly: ```rust ShimmerText::new("Uploading report.pdf…") .with_shimmer_style(ShimmerStyle::new().spread(0.4)) .text_sm() .text_color(cx.theme().muted_foreground) ``` `ShimmerText` inherits typography and text color through `Styled`, preserves wrapping and truncation, and uses the active theme's background and foreground to keep the highlight readable in light and dark modes. ## Icons, content, and interactive children `MarkerIcon` is a compact `size_4()` slot. `MarkerContent` is a `min_w_0()` slot, so a long label can choose its own wrapping or truncation: ```rust Marker::new() .icon(MarkerIcon::new().child(Icon::new(IconName::Bell))) .content( MarkerContent::new() .child("Unread notifications") .child(Badge::new().count(3)), ) ``` Interactive children are allowed, but `Marker` does not make the row itself a control: ```rust Marker::new() .content( MarkerContent::new() .text("New messages") .child(Button::new("open-messages").ghost().xsmall().label("Open")), ) ``` Use `Button` for an in-app command and `Link` for a URL. Keep focus and action semantics on those controls. If a whole marker should be clickable, compose a semantic control around the content at the application boundary instead of adding a click listener to this layout element. ## Custom styling and theme tokens `Marker`, `MarkerIcon`, and `MarkerContent` implement `Styled`. Refinements are applied after the default layout and theme colors: ```rust Marker::new() .px_3() .py_2() .rounded(cx.theme().radius) .bg(cx.theme().accent) .text_color(cx.theme().accent_foreground) .icon(MarkerIcon::new().child(Icon::new(IconName::Star))) .content(MarkerContent::new().text("Pinned message")) ``` The separator lines have a separate `StyleRefinement`, so their color and height can be customized without changing the content or marker's own surface: ```rust Marker::new() .with_variant(MarkerVariant::Separator) .separator_style( StyleRefinement::default() .bg(cx.theme().border), ) .content(MarkerContent::new().text("New day")) ``` Prefer semantic theme roles (`muted_foreground`, `border`, `ring`, `accent`, and their foreground tokens) to raw colors. Radius, spacing, typography, and separator geometry follow the shared design scale; typed style refinements can adapt a marker to a denser toolbar or a larger empty-state boundary. ## Accessibility and motion guidance - Include the status, boundary, or unread count in text. Icons, border lines, opacity, and color are supporting cues only. - A marker is presentational by default. Set `.id(...)` and `.role(Role::Status)` on a row that reports streaming or loading progress so assistive technology announces its updates; the role needs the stable identity an id provides. - Keep interactive content in `Button` or `Link` so it receives keyboard focus, activation, and disabled state. For the current `Button` API, use a visible `.label(...)` when the action needs an accessible name; a tooltip is supplemental. - Do not use `Marker` as an unlabeled icon-only status. Add a visible or accessible text label when the icon has meaning. - `MarkerContent::text(...)` remains visible when reduced motion is enabled; only the shimmer frame updates are skipped. Arbitrary children also retain their static content. - Loading text should describe the operation (“Generating…”, “Uploading…”) rather than communicate only through animation. - Keep sufficient contrast after custom styling in both light and dark themes. ## When to use another component - Use `Badge` for only a count, dot, or short classification. - Use `Separator::horizontal().label(...)` when the product needs only a labeled divider and no marker loading or icon composition. - Use `Tag` for a standalone labeled status that is not part of a conversation row. - Use `h_flex()` when the row has no shared marker behavior. - Use `Message` or `Bubble` when the content is a conversational message with sender identity or a message surface. ## API reference ### `Marker` | Method | Default | Purpose | | --- | --- | --- | | `new()` | `Plain`, not loading, spinner style | Create a marker. | | `with_variant(MarkerVariant)` | `Plain` | Choose plain, separator, or border treatment. | | `alignment(MarkerAlignment)` | unset: `Separator` centers, others start | Place the children at the leading edge, the center, or the trailing edge. | | `loading(bool)` | `false` | Enable or disable loading rendering. | | `with_loading_style(MarkerLoadingStyle)` | `Spinner` | Choose spinner or shimmer. | | `with_shimmer_style(ShimmerStyle)` | default style | Configure text shimmer. | | `separator_style(StyleRefinement)` | theme border line | Refine separator lines. | | `id(ElementId)` | none | Give the marker a stable identity for the accessibility tree. | | `role(Role)` | presentational | Announce the row to assistive technology, e.g. `Role::Status` for streaming updates; requires `id(...)`. | | `icon(MarkerIcon)` | none | Add a typed icon slot. | | `content(MarkerContent)` | none | Add a typed content slot. | | `.child(element)` | — | Add arbitrary children. | | `Styled` methods | compact themed row | Refine the marker's layout, colors, and typography. | ### `MarkerIcon` | Method | Default | Purpose | | --- | --- | --- | | `new()` | empty `size_4()` slot | Create an icon slot. | | `.child(element)` | — | Add an icon, badge, spinner, or custom element. | | `Styled` methods | `size_4()` compact slot | Refine icon geometry and layout. | ### `MarkerContent` | Method | Default | Purpose | | --- | --- | --- | | `new()` | empty `min_w_0()` slot | Create content. | | `text(text)` | static text until loading is enabled | Add text that can receive shimmer. | | `.child(element)` | — | Add arbitrary rich content. | | `Styled` methods | inherited text and compact layout | Refine wrapping, colors, spacing, and typography. | ### Related types - [`MarkerVariant`] — `Plain`, `Separator`, and `Border`. - [`MarkerAlignment`] — `Start`, `Center`, and `End`. - [`MarkerLoadingStyle`] — `Spinner` or `Shimmer`. - [`ShimmerStyle`] and [`ShimmerText`] — reusable loading text controls. [Marker]: https://docs.rs/gpui-component/latest/gpui_component/marker/struct.Marker.html [MarkerIcon]: https://docs.rs/gpui-component/latest/gpui_component/marker/struct.MarkerIcon.html [MarkerContent]: https://docs.rs/gpui-component/latest/gpui_component/marker/struct.MarkerContent.html [MarkerVariant]: https://docs.rs/gpui-component/latest/gpui_component/marker/enum.MarkerVariant.html [MarkerAlignment]: https://docs.rs/gpui-component/latest/gpui_component/marker/enum.MarkerAlignment.html [MarkerLoadingStyle]: https://docs.rs/gpui-component/latest/gpui_component/marker/enum.MarkerLoadingStyle.html [ShimmerStyle]: https://docs.rs/gpui-component/latest/gpui_component/shimmer/struct.ShimmerStyle.html [ShimmerText]: https://docs.rs/gpui-component/latest/gpui_component/shimmer/struct.ShimmerText.html --- # Toggle Source: /versions/v0.7.1/component/toggle License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/toggle and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A button-style toggle component that represents on/off or selected states. Unlike a traditional switch, toggles appear as buttons that can be pressed in or out. They're perfect for toolbar buttons, filter options, or any binary choice that benefits from a button-like appearance. ## Import ```rust use gpui_kit::component::button::{Toggle, ToggleGroup}; ``` ## Usage ### Basic Toggle ```rust Toggle::new("toggle1"). .label("Toggle me") .checked(false) .on_click(|checked, _, _| { println!("Toggle is now: {}", checked); }) ``` Here, we can use `on_click` to handle toggle state changes. The callback receives the **new checked state** as a `bool`. ### Icon Toggle ```rust use gpui_kit::component::IconName; Toggle::new("toggle2") .icon(IconName::Eye) .checked(true) .on_click(|checked, _, _| { println!("Visibility: {}", if *checked { "shown" } else { "hidden" }); }) ``` ### Controlled Toggle ```rust struct MyView { is_active: bool, } impl Render for MyView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { Toggle::new("active") .label("Active") .checked(self.is_active) .on_click(cx.listener(|view, checked, _, cx| { view.is_active = *checked; cx.notify(); })) } } ``` ### Toggle Variants ```rust // Ghost toggle (default) Toggle::new("ghost-toggle") .ghost() .label("Ghost") // Outline toggle Toggle::new("outline-toggle") .outline() .label("Outline") ``` ### Different Sizes ```rust // Extra small Toggle::new("xs-toggle") .icon(IconName::Star) .xsmall() // Small Toggle::new("small-toggle") .label("Small") .small() // Medium (default) Toggle::new("medium-toggle") .label("Medium") // Large Toggle::new("large-toggle") .label("Large") .large() ``` ### Disabled State ```rust // Disabled unchecked Toggle::new("disabled-toggle") .label("Disabled") .disabled(true) .checked(false) // Disabled checked Toggle::new("disabled-checked-toggle") .label("Selected (Disabled)") .disabled(true) .checked(true) ``` ## Toggle vs Switch | Feature | Toggle | Switch | | ---------------------- | ------------------------------------------- | ----------------------------------------- | | **Appearance** | Button-like, can be pressed in/out | Traditional switch with sliding indicator | | **Use Cases** | Toolbar buttons, filters, binary options | Settings, preferences, on/off states | | **Visual Style** | Rectangular button shape | Rounded switch track with thumb | | **State Indication** | Background color change, pressed appearance | Position of sliding thumb | | **Multiple Selection** | Supports groups with multiple selection | Individual switches only | **Use Toggle when you want:** - Button-like appearance for binary states - Grouping multiple related options - Toolbar or filter interfaces - Options that feel like "selections" rather than "settings" **Use Switch when you want:** - Traditional on/off control appearance - Settings or preferences interface - Clear visual indication of state with sliding animation - Individual boolean controls ## Integration with ToggleGroup Toggle buttons can be grouped together using `ToggleGroup` for related options: ### Basic Toggle Group ```rust ToggleGroup::new("filter-group") .child(Toggle::new(0).icon(IconName::Bell)) .child(Toggle::new(1).icon(IconName::Bot)) .child(Toggle::new(2).icon(IconName::Inbox)) .child(Toggle::new(3).label("Other")) .on_click(|checkeds, _, _| { println!("Selected toggles: {:?}", checkeds); }) ``` The `on_click` callback receives a `Vec` representing the **new checked state** of each toggle in the group. ### Toggle Group with Controlled State ```rust struct FilterView { notifications: bool, bots: bool, inbox: bool, other: bool, } impl Render for FilterView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { ToggleGroup::new("filters") .child(Toggle::new(0).icon(IconName::Bell).checked(self.notifications)) .child(Toggle::new(1).icon(IconName::Bot).checked(self.bots)) .child(Toggle::new(2).icon(IconName::Inbox).checked(self.inbox)) .child(Toggle::new(3).label("Other").checked(self.other)) .on_click(cx.listener(|view, checkeds, _, cx| { view.notifications = checkeds[0]; view.bots = checkeds[1]; view.inbox = checkeds[2]; view.other = checkeds[3]; cx.notify(); })) } } ``` ### Toggle Group Variants and Sizes ```rust // Outline variant, small size ToggleGroup::new("compact-filters") .outline() .small() .child(Toggle::new(0).icon(IconName::Filter)) .child(Toggle::new(1).icon(IconName::Sort)) .child(Toggle::new(2).icon(IconName::Search)) // Ghost variant (default), extra small ToggleGroup::new("mini-toolbar") .xsmall() .child(Toggle::new(0).icon(IconName::Bold)) .child(Toggle::new(1).icon(IconName::Italic)) .child(Toggle::new(2).icon(IconName::Underline)) ``` ### Segmented Toggle Group Use `segmented()` when a group should render as a connected segmented control. The group still uses the same multi-toggle behavior: `on_click` receives a `Vec` with the new checked state for each item. ```rust ToggleGroup::new("formatting") .segmented() .outline() .child(Toggle::new(0).label("Bold").checked(self.bold)) .child(Toggle::new(1).label("Italic").checked(self.italic)) .child(Toggle::new(2).label("Code").checked(self.code)) .on_click(cx.listener(|view, states, _, cx| { view.bold = states[0]; view.italic = states[1]; view.code = states[2]; cx.notify(); })) ``` By default, segmented groups use a zero gap so adjacent items share one outline. Pass a non-zero gap when you want the segmented sizing and variants but separated items: ```rust use gpui_kit::px; ToggleGroup::new("quick-actions") .segmented() .outline() .gap(px(8.)) .small() .child(Toggle::new(0).label("Star")) .child(Toggle::new(1).label("Watch")) .child(Toggle::new(2).label("Pin")) ``` If you need mutually exclusive behavior, keep that state in your view model and set only one child to `checked(true)` until a dedicated single-selection API is available. ## Event Handling ### Individual Toggle Events ```rust Toggle::new("subscribe-toggle") .label("Subscribe") .on_click(|checked, window, cx| { if *checked { // Handle subscription logic println!("Subscribed!"); } else { // Handle unsubscription logic println!("Unsubscribed!"); } }) ``` ## Examples ### Toolbar with Toggle Buttons ```rust struct EditorToolbar { bold: bool, italic: bool, underline: bool, strikethrough: bool, } h_flex() .gap_1() .p_2() .bg(cx.theme().background) .border_1() .border_color(cx.theme().border) .child( ToggleGroup::new("formatting") .small() .child(Toggle::new(0).icon(IconName::Bold).checked(self.bold)) .child(Toggle::new(1).icon(IconName::Italic).checked(self.italic)) .child(Toggle::new(2).icon(IconName::Underline).checked(self.underline)) .child(Toggle::new(3).icon(IconName::Strikethrough).checked(self.strikethrough)) .on_click(cx.listener(|view, states, _, cx| { view.bold = states[0]; view.italic = states[1]; view.underline = states[2]; view.strikethrough = states[3]; cx.notify(); })) ) ``` ### Filter Interface ```rust struct FilterPanel { show_completed: bool, show_pending: bool, show_cancelled: bool, show_urgent: bool, } v_flex() .gap_3() .p_4() .child(Label::new("Filter by status")) .child( ToggleGroup::new("status-filters") .outline() .child(Toggle::new(0).label("Completed").checked(self.show_completed)) .child(Toggle::new(1).label("Pending").checked(self.show_pending)) .child(Toggle::new(2).label("Cancelled").checked(self.show_cancelled)) .on_click(cx.listener(|view, states, _, cx| { view.show_completed = states[0]; view.show_pending = states[1]; view.show_cancelled = states[2]; cx.notify(); })) ) .child( Toggle::new("urgent-filter") .label("Show urgent only") .checked(self.show_urgent) .on_click(cx.listener(|view, checked, _, cx| { view.show_urgent = *checked; cx.notify(); })) ) ``` ### Settings with Individual Toggles ```rust struct NotificationSettings { email_notifications: bool, push_notifications: bool, marketing_emails: bool, } v_flex() .gap_4() .child( h_flex() .items_center() .justify_between() .child( v_flex() .child(Label::new("Email notifications")) .child( Label::new("Receive notifications via email") .text_color(cx.theme().muted_foreground) .text_sm() ) ) .child( Toggle::new("email-notifications") .icon(IconName::Mail) .checked(self.email_notifications) .on_click(cx.listener(|view, checked, _, cx| { view.email_notifications = *checked; cx.notify(); })) ) ) .child( h_flex() .items_center() .justify_between() .child(Label::new("Push notifications")) .child( Toggle::new("push-notifications") .icon(IconName::Bell) .checked(self.push_notifications) .on_click(cx.listener(|view, checked, _, cx| { view.push_notifications = *checked; cx.notify(); })) ) ) ``` ### Multi-select Options ```rust struct SelectionView { selected_categories: Vec, } impl SelectionView { fn categories() -> Vec<&'static str> { vec!["Technology", "Design", "Business", "Science", "Art"] } } v_flex() .gap_3() .child(Label::new("Select categories of interest")) .child( ToggleGroup::new("categories") .children( Self::categories() .into_iter() .enumerate() .map(|(i, category)| { Toggle::new(i) .label(category) .checked(self.selected_categories.get(i).copied().unwrap_or(false)) }) ) .on_click(cx.listener(|view, states, _, cx| { view.selected_categories = states.clone(); cx.notify(); })) ) ``` ## Best Practices 1. **Use meaningful labels**: Choose clear, descriptive text for toggle labels 2. **Group related options**: Use ToggleGroup for logically related binary choices 3. **Provide visual feedback**: The checked state should be clearly distinguishable 4. **Consider context**: Use toggles for options that feel like "selections" rather than "settings" 5. **Maintain state consistency**: Ensure toggle state reflects the actual application state 6. **Accessible labels**: Provide tooltips or ARIA labels for icon-only toggles --- # Attachment Source: /versions/v0.7.1/component/attachment License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/attachment and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `Attachment` presents one file or media item. It provides stable layout for a media preview, metadata, and optional actions, draws the lifecycle status, and offers the two controls every composer needs — remove and retry — while leaving upload state, selection, and navigation in the application. Each public slot is styleable and accepts arbitrary GPUI children. The component is intentionally a composition primitive. `AttachmentActions` does not invent an attachment-specific action model; put `Button`, `Link`, or another semantic control inside it. `AttachmentGroup` only owns horizontal spacing and scrolling. Selection and preview behavior remain application concerns. ## Import ```rust use gpui_kit::{Axis, ParentElement as _, Styled as _}; use gpui_kit::component::{ ActiveTheme as _, Colorize as _, Icon, IconName, Sizable as _, Size, attachment::{ Attachment, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentGroup, AttachmentMedia, AttachmentStatus, AttachmentTitle, }, button::{Button, ButtonVariants as _}, badge::Badge, progress::Progress, shimmer::ShimmerStyle, spinner::Spinner, }; ``` ## Anatomy and basic usage The typed builders make the common file shape explicit: ```rust Attachment::new() .media(AttachmentMedia::new().child(Icon::new(IconName::FileText))) .content( AttachmentContent::new() .title(AttachmentTitle::new("quarterly-report.pdf")) .description(AttachmentDescription::new("PDF · 2.4 MB")), ) .actions( AttachmentActions::new().child( Button::new("remove-report") .ghost() .xsmall() .icon(IconName::Close) .label("Remove"), ), ) ``` The slots are optional. A media-only attachment, metadata-only attachment, or action-only attachment is valid when the product needs it: ```rust Attachment::new() .media(AttachmentMedia::new().child(Icon::new(IconName::FileText))); Attachment::new().content( AttachmentContent::new() .title(AttachmentTitle::new("notes.txt")) .description(AttachmentDescription::new("TXT · 12 KB")), ) ``` The default state is: | Property | Default | Meaning | | --- | --- | --- | | Status | `Complete` | The item is ready. | | Size | `Medium` | Uses the standard conversation density. | | Axis | `Horizontal` | Media, metadata, and actions share one row. | | Media/content/actions | absent | Add only the slots the item needs. | | Surface | `background` and `foreground` | The card surface, separated by the border like shadcn's `bg-card`. | | Radius | `radius_tokens().lg` (`md` for `XSmall`) | Shared semantic radius. | | Geometry | 56 px tall, 232 px wide with content, 38 px media (`Medium`) | The composer chip; see [Sizes and axes](#sizes-and-axes). | `Attachment` never owns a product-level file model. Keep the file ID and state in the parent view, then render the current record into this element. ## Media and image previews Use children for an icon-style media slot and `src(...)` for an image preview: ```rust Attachment::new() .media( AttachmentMedia::new() .src("https://example.com/previews/sdk.svg") .overlay(Icon::new(IconName::Download)), ) .content( AttachmentContent::new() .title(AttachmentTitle::new("sdk-preview.svg")) .description(AttachmentDescription::new("SVG · 1280 × 720")), ) ``` The image is rendered with `ObjectFit::Cover` inside the media bounds. Children and `overlay(...)` are painted above the image. `overlay(...)` centers an element over the whole media area, which is useful for a spinner, play icon, or preview action: ```rust Attachment::new() .status(AttachmentStatus::Uploading) .axis(Axis::Vertical) .media( AttachmentMedia::new() .src(preview_url) .overlay(Spinner::new().small()), ) ``` The slot draws the lifecycle status itself. A source image keeps its colors and takes a scrim: a translucent dark layer with a white spinner while `Uploading` or `Processing`, a darker one with the retry control (see [Remove and retry controls](#remove-and-retry-controls)) or an alert glyph once `Failed`. Custom overlays are painted above the scrim. With no source, the media slot is a themed muted area that shows a spinner in the primary color while in progress and, once failed, the destructive semantic surface and foreground with an alert glyph; its children come back with `Complete`. An image tile — a vertical attachment without content — is a square the media fills edge to edge, its corners one border width tighter than the card's so the two stay concentric. `AttachmentMedia` is independently styleable. Use `with_size(...)` to override the inherited media size, or use normal GPUI refinements for a custom preview ratio and surface: ```rust AttachmentMedia::new() .with_size(Size::Large) .aspect_ratio(16. / 9.) .rounded(cx.theme().radius_lg) .child(Icon::new(IconName::Image)) ``` An explicit media size takes precedence over the attachment size. A vertical attachment makes the media full width and square by default; the media's own style can replace that geometry when the application has a different preview design. ## Lifecycle states `AttachmentStatus` has five explicit states. The parent status is passed to the typed title, description, media, and action layout during rendering: | State | Surface/layout behavior | Recommended content | | --- | --- | --- | | `Pending` | Dashed border; preview is not dimmed. | “Ready to upload” and a start action. | | `Uploading` | Media shows a spinner, or a progress ring with `progress(...)` (over a scrim on an image); a chip draws a bar along its bottom edge; typed title shimmers. | A description such as “Uploading”; the percentage is appended for you. | | `Processing` | Media shows a spinner (over a scrim on an image); typed title shimmers. | “Processing…” and a non-destructive wait state. | | `Failed` | Destructive border and description; media shows the retry control or alert glyph with `on_retry`, the ban glyph without one (a rejection). | Error reason plus `on_retry`, or `tooltip(...)` with the reason and `on_remove`. | | `Complete` | Ready surface; preview is full opacity. | File metadata and normal actions. | ```rust Attachment::new() .status(AttachmentStatus::Uploading) .media(AttachmentMedia::new().child(Icon::new(IconName::FileText))) .content( AttachmentContent::new() .title(AttachmentTitle::new("design-assets.zip")) .description(AttachmentDescription::new("Uploading · 68%")) .child(Progress::new("attachment-progress").value(68.)), ) .actions( AttachmentActions::new() .child(Button::new("cancel-upload").ghost().xsmall().label("Cancel")), ) ``` The status helpers are useful when application state maps to presentation: ```rust match status { AttachmentStatus::Pending => "Ready to upload", AttachmentStatus::Uploading => "Uploading…", AttachmentStatus::Processing => "Processing…", AttachmentStatus::Failed => "Upload failed", AttachmentStatus::Complete => "Ready", } ``` `is_pending()`, `is_uploading()`, `is_processing()`, `is_failed()`, `is_complete()`, and `is_in_progress()` are pure readers. They do not update the attachment or the application upload task. ## Status inheritance and overrides Titles and descriptions added through their typed builders inherit the parent status. An explicit child status wins over the inherited value: ```rust Attachment::new() .status(AttachmentStatus::Failed) .content( AttachmentContent::new() .title(AttachmentTitle::new("archive.zip")) .description( AttachmentDescription::new("Previous upload completed") .status(AttachmentStatus::Complete), ), ) ``` Use typed `.title(...)` and `.description(...)` whenever loading shimmer or failure coloring should follow the attachment. The generic `.child(...)` form still accepts arbitrary elements, but it cannot inspect the erased child's status and therefore does not inherit automatically: ```rust AttachmentContent::new() .title(AttachmentTitle::new("status-aware-title")) .description(AttachmentDescription::new("status-aware-description")) .child(custom_metadata_element) ``` Customize an in-progress title with a reusable shimmer style: ```rust AttachmentTitle::new("transcript.pdf") .with_shimmer_style( ShimmerStyle::new() .duration(std::time::Duration::from_secs(3)) .spread(0.45) .reverse(true) .once(false), ) ``` `AttachmentDescription` uses the destructive semantic color only for an explicit or inherited `Failed` status. The words in the description should still state what happened; color is a supporting cue. ## Sizes and axes `Attachment` implements `Sizable`. The convenience builders map to `Size`: ```rust Attachment::new().xsmall(); Attachment::new().small(); Attachment::new(); // medium (default) Attachment::new().large(); Attachment::new().w_auto() // let the chip hug its content instead of the fixed width ``` The named sizes set the whole geometry as one scale, in rems so it follows the root font size: | Size | Chip height | Chip width | Media | Title | | --- | --- | --- | --- | --- | | `XSmall` | 40 px | 176 px | 28 px | 11 px | | `Small` | 48 px | 200 px | 32 px | 12 px | | `Medium` | 56 px | 232 px | 38 px | 13 px | | `Large` | 64 px | 272 px | 44 px | 14 px | A horizontal card takes the fixed width only when it carries content, so a row of chips lines up and long names truncate instead of stretching the card. An image tile is a square with the chip's height. `Size::Size(...)` scales the `Medium` geometry from a custom base value. Refine with the normal GPUI width methods (`w_auto()`, `w_full()`, `w(...)`) when the product needs another measure; named sizes are preferable for a coherent theme. Horizontal is the default and keeps the media, metadata, and actions in one row. Vertical moves the preview above the metadata and places actions over the preview's upper trailing corner: ```rust Attachment::new() .axis(Axis::Vertical) .large() .media(AttachmentMedia::new().src(preview_url)) .content( AttachmentContent::new() .title(AttachmentTitle::new("presentation.png")) .description(AttachmentDescription::new("PNG · 1920 × 1080")), ) .actions( AttachmentActions::new() .child(Button::new("remove-presentation").ghost().xsmall().label("Remove")), ) ``` The vertical default is square media. Set a media aspect ratio or size when the content needs a landscape preview. `AttachmentContent` and `AttachmentActions` remain independent slots, so an application can omit one or place additional controls in either. ## Content and actions `AttachmentContent` keeps titles and descriptions in a vertical metadata stack. It also accepts custom children for progress, badges, or a second line: ```rust AttachmentContent::new() .title(AttachmentTitle::new("report.pdf")) .description(AttachmentDescription::new("PDF · 2.4 MB")) .child(Badge::new().count(3)) ``` Use `AttachmentActions` for one or more existing semantic controls: ```rust AttachmentActions::new() .child(Button::new("download").ghost().xsmall().label("Download")) .child(Button::new("remove").danger().xsmall().label("Remove")) ``` `AttachmentActions` only supplies layout and does not make its children focusable, clickable, or disabled. A tooltip is supplemental; the current `Button` implementation derives its accessibility label from `.label(...)`, so use a visible label when an action must have a named accessible control. An icon-only button with only `.tooltip(...)` is not a substitute for that label. ## Whole-card click Set `.id(...)` and `.on_click(...)` to make the whole card activate, e.g. to open a preview. The click layer is painted below `AttachmentActions`, so action buttons stay independently clickable: ```rust Attachment::new() .id("design-attachment") .on_click(|_, window, cx| { // Open the preview. }) .content( AttachmentContent::new() .title(AttachmentTitle::new("design-mockups.png")) .description(AttachmentDescription::new("PNG · 1.8 MB")), ) .actions( AttachmentActions::new() .child(Button::new("remove").ghost().xsmall().icon(IconName::Close)), ) ``` The handler takes effect only together with `.id(...)`; click state needs that stable identity. A clickable card shows a muted hover surface so it reads as interactive. What activation means — a dialog, a browser, a file viewer, or a selection — stays with the application. Keep destructive and secondary commands in `AttachmentActions` so they never depend on the card's primary activation, and offer the card's primary action as a `Button` or `Link` somewhere reachable from the keyboard: the click layer itself is a pointer convenience and takes no focus. ## Remove and retry controls A composer removes attachments and retries failed uploads. Both controls are built in, so they look the same in every product and need no wrapper: ```rust Attachment::new() .id(("attachment", item.id)) .status(item.status) .on_remove(cx.listener(move |this, _, _, cx| this.remove(item.id, cx))) .on_retry(cx.listener(move |this, _, _, cx| this.retry(item.id, cx))) .axis(Axis::Vertical) .media(AttachmentMedia::new().src(thumbnail)) ``` `on_remove` rides a small surface-colored disc with a hairline border on the card's upper trailing corner, the way a card's close control usually looks. It appears on hover on desktop and stays visible on touch platforms; the card reserves the overhang, so a row of cards keeps its alignment. `on_retry` takes effect only while the status is `Failed`: an image preview gets a round button in its scrim, and a typed description gets a localized “Retry” link after its text. A failed attachment without `on_retry` reads as a rejection and shows the ban glyph instead. All of these key their element state on `.id(...)`, so they take effect only together with it. What removing or retrying means stays with the application. Two more builders complete the composer picture: ```rust Attachment::new() .id(("attachment", item.id)) .status(AttachmentStatus::Uploading) .progress(item.percent) // 0..=100 .content( AttachmentContent::new() .title(AttachmentTitle::new("Q3 statement.pdf")) .description(AttachmentDescription::new("Uploading")), ); Attachment::new() .id(("attachment", item.id)) .status(AttachmentStatus::Failed) .tooltip("Image exceeds 20 MB limit · Remove to send") .on_remove(cx.listener(move |this, _, _, cx| this.remove(item.id, cx))) .axis(Axis::Vertical) .media(AttachmentMedia::new().src(thumbnail)) ``` `progress(percent)` turns the uploading spinner into a determinate ring, draws a thin primary bar along a horizontal card's bottom edge, and appends “· 62%” to a typed description; it is ignored in every other status. `tooltip(text)` shows the text while the card is hovered, which is where the reason for a failure or a rejection belongs. ## Groups `AttachmentGroup` provides a horizontally scrollable row with the shared group gap. Its ID is required because it owns GPUI's element-local scroll state: ```rust AttachmentGroup::new("message-attachments") .child(first_attachment) .child(second_attachment) .child(third_attachment) ``` The group is `w_full()`, `min_w_0()`, and uses horizontal scrolling. It does not provide selection, snapping, reorder handles, a “+N more” overflow label, or a preview dialog. Compose those behaviors in an application-owned wrapper. Keep the ID stable for the lifetime of the conversation row. Two builders help a composer or message row that overflows: ```rust AttachmentGroup::new("composer-attachments") // Fade each edge into the surface behind the row while it hides content. .with_edge_fade(cx.theme().background) // Drive the scrolling yourself, e.g. from paging buttons. .track_scroll(&self.attachments_scroll) .children(attachments) ``` `with_edge_fade(color)` draws a short gradient at an edge only while more attachments continue past it; a row that fits shows none. The fades sit above the attachments and take no pointer events. `track_scroll(&handle)` replaces the group's own scroll state with the caller's `ScrollHandle`, so the application can move the row and read its offset. ## Custom styling and theme tokens `Attachment`, `AttachmentGroup`, and every named slot implement `Styled`. Refinements are applied after component defaults, which gives developers control over the surface, spacing, media geometry, typography, and action layout: ```rust Attachment::new() .w_full() .rounded(cx.theme().radius_lg) .bg(cx.theme().group_box) .border_color(cx.theme().ring) .media( AttachmentMedia::new() .rounded(cx.theme().radius_lg) .bg(cx.theme().primary.opacity(0.12)) .text_color(cx.theme().primary) .child(Icon::new(IconName::FileText)), ) .content( AttachmentContent::new() .title(AttachmentTitle::new("custom-theme.json").text_color(cx.theme().primary)) .description(AttachmentDescription::new("JSON · 16 KB")), ) ``` Prefer semantic roles from `cx.theme()` (`background`, `muted`, `border`, `destructive`, `foreground`, and their foreground counterparts) to raw colors. The component's default radii, spacing, and typography follow the shared design scale; application-specific density can be expressed with `Size` and typed style refinements at the composition boundary. Use `AttachmentContent::title(...)` and `.description(...)` for status-aware metadata, `.child(...)` for arbitrary custom content, child `.status(...)` for an explicit override, `AttachmentTitle::with_shimmer_style(...)` for loading motion, and `AttachmentMedia::overlay(...)` for controls above an image. ## Accessibility and state guidance - Include the file name and useful type/size information in text. An icon-only media preview is not enough to identify the attachment. - Put upload, retry, remove, download, and preview actions in semantic `Button` or `Link` controls. A tooltip is supplemental; for the current `Button` API, use `.label(...)` when the action needs an accessible name. - Describe `Pending`, `Uploading`, `Processing`, and `Failed` in text or a control state. The dashed border, opacity, shimmer, and destructive color are supporting cues. - Keep progress determinate when the application knows a byte or item count; use `Progress` as a child rather than duplicating progress semantics in `Attachment`. - Loading shimmer is disabled by `ShimmerText` when reduced motion is enabled. Keep a readable title and description visible in that mode. - Ensure a vertical overlay action remains reachable from the keyboard; it must not be available only through image hover. ## Component boundaries These boundaries are deliberate: - Use `Button` directly instead of an attachment-specific action component. This preserves Button variants, sizes, loading, disabled behavior, focus, and event handling. - Use `Progress` directly instead of an attachment-specific progress wrapper. - Use `.id(...)` with `.on_click(...)` for whole-card activation. The card only reports the click; whether that opens a dialog, a browser, a file viewer, or toggles a selection stays with the application. - Use `AttachmentGroup` only for the shared horizontal row and overflow. Use an application-owned container for selection, reordering, snapping, or custom scroll controls. ## API reference ### `Attachment` | Method | Default | Purpose | | --- | --- | --- | | `new()` | `Complete`, `Medium`, `Horizontal`, no slots | Create an attachment. | | `id(ElementId)` | none | Stable identity for the built-in controls: click layer, remove, retry. | | `on_click(handler)` | none | Whole-card activation; requires `id(...)` and stays below the actions. | | `on_remove(handler)` | none | Corner remove control; requires `id(...)`. | | `on_retry(handler)` | none | Retry control while `Failed`; requires `id(...)`. | | `progress(percent)` | none | Determinate ring, bottom bar and “· 62%” while `Uploading`. | | `tooltip(text)` | none | Hover tooltip, e.g. the failure reason; requires `id(...)`. | | `status(AttachmentStatus)` | `Complete` | Set lifecycle styling. | | `axis(Axis)` | `Horizontal` | Choose horizontal or vertical layout. | | `with_size(Size)` | `Medium` | Set a named or custom size. | | `xsmall()` / `small()` / `large()` | — | Sizable shortcuts. | | `media(AttachmentMedia)` | none | Add a preview slot. | | `content(AttachmentContent)` | none | Add metadata. | | `actions(AttachmentActions)` | none | Add action controls. | ### `AttachmentMedia` | Method | Default | Purpose | | --- | --- | --- | | `new()` | no source, no children | Create a media slot. | | `src(ImageSource)` | none | Render an image preview. | | `with_size(Size)` | inherited attachment size | Override media density. | | `overlay(element)` | none | Center an element over the media, above the status treatment. | | `child(element)` | — | Add an icon or custom content above the preview. | | `Styled` methods | themed muted media | Refine geometry, radius, background, and typography. | ### `AttachmentContent`, `AttachmentTitle`, and `AttachmentDescription` | Method | Default | Purpose | | --- | --- | --- | | `AttachmentContent::new()` | empty vertical metadata stack | Create content. | | `.title(AttachmentTitle)` | — | Add a status-aware single-line title. | | `.description(AttachmentDescription)` | — | Add a status-aware single-line description. | | `AttachmentTitle::new(text)` | no explicit child status | Create a title. | | `AttachmentTitle::status(status)` | inherits parent | Override title loading state. | | `AttachmentTitle::with_shimmer_style(style)` | default shimmer | Customize title animation. | | `AttachmentDescription::new(text)` | no explicit child status | Create a description. | | `AttachmentDescription::status(status)` | inherits parent | Override description color state. | | `.child(element)` | — | Add progress, badges, or custom metadata. | ### `AttachmentActions` and `AttachmentGroup` | Method | Default | Purpose | | --- | --- | --- | | `AttachmentActions::new()` | empty action layout | Create the action slot. | | `.child(element)` | — | Add Button, Link, or another control. | | `AttachmentGroup::new(id)` | stable ID required | Create a horizontal scrolling group. | | `AttachmentGroup::track_scroll(&ScrollHandle)` | own scroll state | Scroll the row through the caller's handle. | | `AttachmentGroup::with_edge_fade(color)` | none | Fade an edge into `color` while it hides attachments. | | `AttachmentGroup::child(element)` | — | Add attachments to the group. | ### Related types - [`AttachmentStatus`] — `Pending`, `Uploading`, `Processing`, `Failed`, and `Complete`. - [`Size`] — `XSmall`, `Small`, `Medium`, `Large`, or a custom `Pixels` value. - [`Axis`] — `Horizontal` or `Vertical` from GPUI. - [`ShimmerStyle`] — shared loading animation configuration. [Attachment]: https://docs.rs/gpui-component/latest/gpui_component/attachment/struct.Attachment.html [AttachmentMedia]: https://docs.rs/gpui-component/latest/gpui_component/attachment/struct.AttachmentMedia.html [AttachmentContent]: https://docs.rs/gpui-component/latest/gpui_component/attachment/struct.AttachmentContent.html [AttachmentTitle]: https://docs.rs/gpui-component/latest/gpui_component/attachment/struct.AttachmentTitle.html [AttachmentDescription]: https://docs.rs/gpui-component/latest/gpui_component/attachment/struct.AttachmentDescription.html [AttachmentActions]: https://docs.rs/gpui-component/latest/gpui_component/attachment/struct.AttachmentActions.html [AttachmentGroup]: https://docs.rs/gpui-component/latest/gpui_component/attachment/struct.AttachmentGroup.html [AttachmentStatus]: https://docs.rs/gpui-component/latest/gpui_component/attachment/enum.AttachmentStatus.html [Size]: https://docs.rs/gpui-component/latest/gpui_component/enum.Size.html [Axis]: https://docs.rs/gpui/latest/gpui/enum.Axis.html [ShimmerStyle]: https://docs.rs/gpui-component/latest/gpui_component/shimmer/struct.ShimmerStyle.html --- # Tree Source: /versions/v0.7.1/component/tree License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/tree and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A versatile tree component for displaying hierarchical data with expand/collapse functionality, keyboard navigation, and custom item rendering. Perfect for file explorers, navigation menus, or any nested data structure. ## Import ```rust use gpui_kit::component::tree::{tree, TreeState, TreeItem, TreeEntry}; ``` ## Usage ### Basic Tree ```rust // Create tree state let tree_state = cx.new(|cx| { TreeState::new(cx).items(vec![ TreeItem::new("src", "src") .expanded(true) .child(TreeItem::new("src/lib.rs", "lib.rs")) .child(TreeItem::new("src/main.rs", "main.rs")), TreeItem::new("Cargo.toml", "Cargo.toml"), TreeItem::new("README.md", "README.md"), ]) }); // Render tree tree(&tree_state, |ix, entry, selected, window, cx| { ListItem::new(ix) .child( h_flex() .gap_2() .child(entry.item().label.clone()) ) }) ``` ### File Tree with Icons ```rust use gpui_kit::component::{ListItem, IconName, h_flex}; tree(&tree_state, |ix, entry, selected, window, cx| { let item = entry.item(); let icon = if !entry.is_folder() { IconName::File } else if entry.is_expanded() { IconName::FolderOpen } else { IconName::Folder }; ListItem::new(ix) .selected(selected) .pl(px(16.) * entry.depth() + px(12.)) // Indent based on depth .child( h_flex() .gap_2() .child(icon) .child(item.label.clone()) ) .on_click(cx.listener(move |_, _, _, _| { // Handle item click })) }) ``` ### Dynamic Tree Loading ```rust impl MyView { fn load_files(&mut self, path: PathBuf, cx: &mut Context) { let tree_state = self.tree_state.clone(); cx.spawn(async move |cx| { let items = build_file_items(&path).await; tree_state.update(cx, |state, cx| { state.set_items(items, cx); }) }).detach(); } } fn build_file_items(path: &Path) -> Vec { let mut items = Vec::new(); if let Ok(entries) = std::fs::read_dir(path) { for entry in entries.flatten() { let path = entry.path(); let name = path.file_name() .and_then(|n| n.to_str()) .unwrap_or("Unknown") .to_string(); if path.is_dir() { let children = build_file_items(&path); items.push(TreeItem::new(path.to_string_lossy(), name) .children(children)); } else { items.push(TreeItem::new(path.to_string_lossy(), name)); } } } items } ``` ### Tree with Selection Handling ```rust struct MyTreeView { tree_state: Entity, selected_item: Option, } impl MyTreeView { fn handle_selection(&mut self, item: TreeItem, cx: &mut Context) { self.selected_item = Some(item.clone()); println!("Selected: {} ({})", item.label, item.id); cx.notify(); } } // In render method tree(&self.tree_state, { let view = cx.entity(); move |ix, entry, selected, window, cx| { view.update(cx, |this, cx| { ListItem::new(ix) .selected(selected) .child(entry.item().label.clone()) .on_click(cx.listener({ let item = entry.item().clone(); move |this, _, _, cx| { this.handle_selection(item.clone(), cx); } })) }) } }) ``` ### Disabled Items ```rust TreeItem::new("protected", "Protected Folder") .disabled(true) .child(TreeItem::new("secret.txt", "secret.txt")) ``` ### Programmatic Tree Control ```rust // Get current selection if let Some(entry) = tree_state.read(cx).selected_entry() { println!("Current selection: {}", entry.item().label); } // Set selection programmatically (by selected_index) tree_state.update(cx, |state, cx| { state.set_selected_index(Some(2), cx); // Select third item }); // Set selection programmatically (by tree item) tree_state.update(cx, |state, cx| { state.set_selected_item(Some(item), cx); // Select third item }); // Scroll to specific item tree_state.update(cx, |state, _| { state.scroll_to_item(5, gpui_kit::ScrollStrategy::Center); }); // Clear selection (by selected_index) tree_state.update(cx, |state, cx| { state.set_selected_index(None, cx); }); // Clear selection (by tree item) tree_state.update(cx, |state, cx| { state.set_selected_item(None, cx); }); ``` ## API Reference ### TreeState | Method | Description | |--------------------------------|----------------------------------| | `new(cx)` | Create a new tree state | | `items(items)` | Set initial tree items | | `set_items(items, cx)` | Update tree items and notify | | `selected_index()` | Get currently selected index | | `set_selected_index(ix, cx)` | Set selected index | | `set_selected_item(item, cx)` | Set selected by tree item | | `selected_item(item, cx)` | Get currently selected tree item | | `selected_entry()` | Get currently selected entry | | `scroll_to_item(ix, strategy)` | Scroll to specific item | ### TreeItem | Method | Description | | ----------------- | -------------------------------------- | | `new(id, label)` | Create new tree item with ID and label | | `child(item)` | Add single child item | | `children(items)` | Add multiple child items | | `expanded(bool)` | Set expanded state | | `disabled(bool)` | Set disabled state | | `is_folder()` | Check if item has children | | `is_expanded()` | Check if item is expanded | | `is_disabled()` | Check if item is disabled | ### TreeEntry | Method | Description | | --------------- | --------------------------- | | `item()` | Get the source TreeItem | | `depth()` | Get item depth in tree | | `is_folder()` | Check if entry has children | | `is_expanded()` | Check if entry is expanded | | `is_disabled()` | Check if entry is disabled | ### tree() Function | Parameter | Description | | ------------- | ------------------------------------- | | `state` | `Entity` for managing tree | | `render_item` | Closure for rendering each item | #### Render Item Closure ```rust Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem ``` - `usize`: Item index in flattened tree - `&TreeEntry`: Tree entry with item and metadata - `bool`: Whether item is currently selected - `&mut Window`: Current window context - `&mut App`: Application context - Returns: `ListItem` for rendering ## Examples ### Lazy Loading Tree ```rust struct LazyTreeView { tree_state: Entity, loaded_paths: HashSet, } impl LazyTreeView { fn load_children(&mut self, item_id: &str, cx: &mut Context) { if self.loaded_paths.contains(item_id) { return; } let path = PathBuf::from(item_id); if path.is_dir() { let tree_state = self.tree_state.clone(); let item_id = item_id.to_string(); cx.spawn(async move |cx| { let children = load_directory_children(&path).await; tree_state.update(cx, |state, cx| { // Update specific item with loaded children state.update_item_children(&item_id, children, cx); }) }).detach(); self.loaded_paths.insert(item_id.to_string()); } } } ``` ### Search and Filter ```rust struct SearchableTree { tree_state: Entity, original_items: Vec, search_query: String, } impl SearchableTree { fn filter_tree(&mut self, query: &str, cx: &mut Context) { self.search_query = query.to_string(); let filtered_items = if query.is_empty() { self.original_items.clone() } else { filter_tree_items(&self.original_items, query) }; self.tree_state.update(cx, |state, cx| { state.set_items(filtered_items, cx); }); } } fn filter_tree_items(items: &[TreeItem], query: &str) -> Vec { items.iter() .filter_map(|item| { if item.label.to_lowercase().contains(&query.to_lowercase()) { Some(item.clone().expanded(true)) // Auto-expand matches } else { // Check if any children match let filtered_children = filter_tree_items(&item.children, query); if !filtered_children.is_empty() { Some(item.clone() .children(filtered_children) .expanded(true)) } else { None } } }) .collect() } ``` ### Multi-Select Tree ```rust struct MultiSelectTree { tree_state: Entity, selected_items: HashSet, } impl MultiSelectTree { fn toggle_selection(&mut self, item_id: &str, cx: &mut Context) { if self.selected_items.contains(item_id) { self.selected_items.remove(item_id); } else { self.selected_items.insert(item_id.to_string()); } cx.notify(); } fn is_selected(&self, item_id: &str) -> bool { self.selected_items.contains(item_id) } } // In render method tree(&self.tree_state, { let view = cx.entity(); move |ix, entry, _selected, window, cx| { view.update(cx, |this, cx| { let item = entry.item(); let is_multi_selected = this.is_selected(&item.id); ListItem::new(ix) .selected(is_multi_selected) .child( h_flex() .gap_2() .child(checkbox().checked(is_multi_selected)) .child(item.label.clone()) ) .on_click(cx.listener({ let item_id = item.id.clone(); move |this, _, _, cx| { this.toggle_selection(&item_id, cx); } })) }) } }) ``` ## Keyboard Navigation The Tree component supports comprehensive keyboard navigation: | Key | Action | | ------- | ----------------------------------------- | | `↑` | Select previous item | | `↓` | Select next item | | `←` | Collapse current folder or move to parent | | `→` | Expand current folder | | `Enter` | Toggle expand/collapse for folders | | `Space` | Custom action (configurable) | ```rust // Custom keyboard handling tree(&tree_state) .key_context("MyTree") .on_action(cx.listener(|this, action: &MyCustomAction, _, cx| { // Handle custom actions })) ``` --- # Bubble Source: /versions/v0.7.1/component/bubble License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/bubble and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `Bubble` is the surface-level primitive for a conversation. It owns the alignment, the maximum content width, and the position of an optional reaction region. `BubbleContent` owns the visible surface. Keeping those responsibilities separate lets an application replace the content layout without reimplementing message alignment. `Bubble` is a presentational element. It does not own a message record, a collapsed state, a reaction model, or a click action. Compose those behaviors with application state and existing controls such as `Button`, `Link`, `Collapsible`, `Tooltip`, and `Popover`. ## Import ```rust use gpui_kit::{div, ParentElement as _, Styled as _}; use gpui_kit::component::{ ActiveTheme as _, Colorize as _, Sizable as _, bubble::{ Bubble, BubbleContent, BubbleGroup, BubbleReactionSide, BubbleReactions, BubbleVariant, }, button::{Button, ButtonVariants as _}, message::MessageAlignment, }; ``` ## Anatomy and basic usage The shortest form adds children to the typed content slot: ```rust Bubble::new() .alignment(MessageAlignment::Start) .child("Can you review this draft?") ``` Use `content(...)` when the surface needs its own layout or style target: ```rust Bubble::new() .alignment(MessageAlignment::Start) .content( BubbleContent::new().child( gpui_kit::component::h_flex() .gap_2() .child("Can you review this draft?") .child("📎"), ), ) ``` The root is `min_w_0`, grows to the available width only for `Ghost`, and otherwise has a maximum width of 80% of its parent. Long text wraps inside the content slot when its child allows wrapping. An application that needs a different conversation measure can refine the root with `w(...)`, `max_w(...)`, or a child-specific layout. The default state is: | Property | Default | Meaning | | --- | --- | --- | | Alignment | unset | The parent may supply alignment; a standalone bubble does not force an edge. | | Variant | `Filled` | Primary semantic surface. | | Reactions | none | No reaction region is rendered. | | Maximum width | `0.8` of the parent | Applies to regular variants. | | Surface radius | `cx.theme().radius_2xl()` | Follows the active theme. | | Content padding | `px_3()` / `py_2()` | Applied by `BubbleContent` for regular variants. | ## Alignment `MessageAlignment::Start` and `MessageAlignment::End` are shared with `Message`: ```rust Bubble::new() .alignment(MessageAlignment::Start) .with_variant(BubbleVariant::Secondary) .child("Incoming message"); Bubble::new() .alignment(MessageAlignment::End) .child("Outgoing message") ``` When a bubble is placed in `MessageContent::bubble(...)`, `Message` propagates its alignment to the content surface. Leave the bubble alignment unset in that case so the message remains the single owner of horizontal placement. Set it explicitly when a bubble is used on its own or when a custom parent intentionally overrides the message row. ## Variants `BubbleVariant` selects semantic colors and surface treatment. It does not change the content model: ```rust Bubble::new() .with_variant(BubbleVariant::Filled) .child("Primary response"); Bubble::new() .with_variant(BubbleVariant::Secondary) .child("Neutral incoming response"); Bubble::new() .with_variant(BubbleVariant::Muted) .child("Low-emphasis context"); Bubble::new() .with_variant(BubbleVariant::Tinted) .child("Subtle selected or emphasized response"); Bubble::new() .with_variant(BubbleVariant::Outline) .child("A response that needs a visible boundary"); Bubble::new() .with_variant(BubbleVariant::Ghost) .child("A full-width, unframed message surface"); Bubble::new() .with_variant(BubbleVariant::Destructive) .child("The operation failed; explain what the user can do next.") ``` `Filled` is the default. `Ghost` removes the surface padding, border, and radius, can occupy the full row, and does not clip its children: there is no surface to clip against, so the shadows and overhanging controls of rich content stay visible. `Destructive` uses the semantic destructive color with a theme-aware translucent surface; its meaning must also be present in text or another non-color cue. All other variants keep the regular content surface and derive colors from the active theme. ## Rich content and long messages Bubble children are arbitrary GPUI elements. Compose text, code, files, buttons, or custom layouts without a bubble-specific content enum: ```rust use gpui_kit::{div, Styled as _}; use gpui_kit::component::{h_flex, v_flex, Icon, IconName}; Bubble::new() .content( BubbleContent::new().child( h_flex() .gap_3() .items_start() .child(Icon::new(IconName::FileText)) .child( v_flex() .min_w_0() .child("design-notes.pdf") .child(div().text_sm().child("PDF · 2.4 MB")), ), ), ) ``` A `TextView` child follows the variant's text color, including links, code and tables on the `primary` surface of `Filled`, so Markdown replies need no per-variant style. For a long response, keep the child `min_w_0()` and choose wrapping or truncation at the content boundary. `Bubble` does not truncate arbitrary children. An application layout can expose a `Show more` affordance by wrapping the content in `Collapsible`; the bubble itself has no hidden-text state. ```rust // The state and trigger belong to the application. The same Bubble can be // rendered in the expanded and collapsed states. Bubble::new() .with_variant(BubbleVariant::Ghost) .content(BubbleContent::new().child(long_response_element)) ``` ## Groups `BubbleGroup` is a styleable vertical stack. It does not infer sender identity or remove headers; the application decides which consecutive bubbles belong to one sender: ```rust BubbleGroup::new() .child( Bubble::new() .alignment(MessageAlignment::Start) .with_variant(BubbleVariant::Secondary) .child("The first paragraph belongs to Alice."), ) .child( Bubble::new() .alignment(MessageAlignment::Start) .with_variant(BubbleVariant::Secondary) .child("The second paragraph uses the same group."), ) ``` Use `MessageGroup` when the repeated unit is a complete message with avatar, header, body, and footer. Use `BubbleGroup` when only the surface stack is being repeated. ## Reactions and interactive content `BubbleReactions` positions a region at the top or bottom edge. Put semantic controls inside it. Use the typed `action(Button)` builder for a button that should read as part of the reaction surface: ```rust Bubble::new() .alignment(MessageAlignment::Start) .with_variant(BubbleVariant::Outline) .child("This response has feedback.") .reactions( BubbleReactions::new() .side(BubbleReactionSide::Bottom) .alignment(MessageAlignment::End) .action( Button::new("bubble-like") .ghost() .small() .label("Like · 2"), ) .action( Button::new("bubble-copy") .ghost() .small() .label("Copy"), ), ) ``` The defaults for `BubbleReactions` are `Bottom` and `End`. For a top-attached region aligned to the leading edge: ```rust BubbleReactions::new() .side(BubbleReactionSide::Top) .alignment(MessageAlignment::Start) .action(Button::new("bubble-more").ghost().xsmall().label("More")) ``` `action(Button)` tells `BubbleReactions` that the child is a semantic action. When a reaction region contains any typed action, the container removes its decorative content padding and applies the active theme's full/pill radius to each typed button, so the buttons and reaction surface read as one control group. The supplied `Button` remains customizable: its variant, size, icon, `.on_click(...)` callback, and `.tooltip(...)` are preserved. The typed action owns the pill corner radius so the button stays joined to the reaction surface; use the generic path below when a button needs a different radius. Multiple actions can be added with repeated `.action(...)` calls. Use `.child(...)` for emoji, text, a custom element, or an overlay composition that is not a direct `Button`. This generic path remains backward-compatible and does not opt that child into the compact action treatment. If the same region also contains any `.action(...)`, the whole reaction region still uses the compact surface layout: ```rust BubbleReactions::new() .child("👍 2") .action( Button::new("bubble-reply") .ghost() .xsmall() .label("Reply"), ) ``` Nested interactive wrappers such as `Popover` remain on the generic `.child(...)` path because `action(...)` accepts a direct `Button`. If the wrapper's trigger should share the reaction geometry, opt into that layout explicitly with `p_0()` on the reaction region and a theme-derived full radius on the trigger button. The same escape hatch gives an arbitrary button its own radius or surface treatment. ```rust BubbleReactions::new().p_0().child( gpui_kit::component::popover::Popover::new("bubble-more") .trigger( Button::new("bubble-more-trigger") .ghost() .xsmall() .label("More") .rounded(cx.theme().radius_full()), ) .child(Button::new("bubble-copy").label("Copy")), ) ``` The reaction container supplies the default spacing, rounded semantic surface, and contrast border. Caller `Styled` refinements are applied after those defaults, so an application can customize the reaction surface or the `Button` itself. There is no separate `BubbleAction` component or reaction data model; the application owns counts, selected state, and submitted actions. Button focus, disabled state, and keyboard activation remain the responsibility of `Button`. The current Button accessibility label comes from its visible `.label(...)` value; a tooltip is supplemental. For a URL inside a bubble use `Link`; for an in-app command use `Button`. A tooltip or popover can wrap the relevant child using the existing overlay components. ## Custom styling and theme tokens `Bubble`, `BubbleContent`, `BubbleGroup`, and `BubbleReactions` implement `Styled`. Refinements are applied after the component defaults, so callers can adjust spacing, width, typography, borders, backgrounds, and shadows at the appropriate part boundary: ```rust Bubble::new() .w_full() .content( BubbleContent::new() .rounded(cx.theme().radius_lg) .bg(cx.theme().group_box) .text_color(cx.theme().group_box_foreground) .border_1() .border_color(cx.theme().border) .px_4() .py_3() .child("Application-owned surface treatment"), ) ``` Use semantic theme roles (`primary`, `muted`, `group_box`, `border`, `destructive`, and their foreground colors) instead of raw palette values. Radii come from the active theme, so a custom theme can make all conversation surfaces more square or more rounded consistently. The component uses the shared spacing and typography scale; a product-specific scale should be owned by the surrounding design-system layer and passed through its own builders. Style the group and reaction region independently when the composition needs a different rhythm: ```rust BubbleGroup::new() .gap_3() .child(Bubble::new().child("First")) .child(Bubble::new().child("Second")); BubbleReactions::new() .px_2() .bg(cx.theme().background) .border_color(cx.theme().ring) .action(Button::new("bubble-reaction").ghost().xsmall().label("👍")) ``` ## Accessibility and state guidance - Use visible text, an icon with a label, or an accessible `Button` label to communicate reactions and actions. Color and a bubble variant are not sufficient status announcements. - Keep keyboard actions inside `Button`, `Link`, `Collapsible`, `Tooltip`, or `Popover`. `Bubble` and `BubbleReactions` are layout elements and do not create focus targets themselves. - Preserve readable contrast when overriding a surface. Pair a custom background with the matching semantic foreground token or an explicitly verified theme role. - For loading or generated content, render a meaningful text label and use `ShimmerText` or `Marker` for motion. Respect the application's reduced-motion behavior; the shimmer utility renders static text when reduced motion is requested. - A failed or destructive bubble should include the error and the next action, not only a red surface. ## When to use another component Use `Message` when sender identity, metadata, or a footer belongs to the same row. Use `Marker` for a compact status or timeline boundary. Use `GroupBox` or an application-owned surface for a non-conversational document. Use a plain `div()`/`h_flex()` when the row has no shared bubble behavior; adding a bubble only to obtain padding makes the hierarchy harder to read. ## API reference ### `Bubble` | Method | Default | Purpose | | --- | --- | --- | | `new()` | filled, no alignment, no reactions | Create a bubble. | | `alignment(MessageAlignment)` | unset | Place the bubble at the leading or trailing edge. | | `with_variant(BubbleVariant)` | `Filled` | Select the semantic surface treatment. | | `content(BubbleContent)` | empty typed content | Replace the visible content surface; direct children move into it. | | `reactions(BubbleReactions)` | none | Attach a reaction region. | `Bubble` also implements `ParentElement` for the direct `.child(...)` form and `Styled` for root layout refinements. ### `BubbleContent` | Method | Default | Purpose | | --- | --- | --- | | `new()` | empty | Create the visible surface slot. | | `.child(...)` | — | Add arbitrary GPUI elements. | | `Styled` methods | component defaults | Refine padding, radius, colors, typography, and layout. | The parent `Bubble` supplies its variant and alignment to this slot. A standalone `BubbleContent` therefore has the default `Filled` treatment. ### `BubbleGroup` | Method | Default | Purpose | | --- | --- | --- | | `new()` | empty vertical stack | Create a group. | | `.child(...)` | — | Add consecutive bubbles. | | `Styled` methods | `gap_2()` | Refine group spacing and layout. | ### `BubbleReactions` | Method | Default | Purpose | | --- | --- | --- | | `new()` | bottom, end aligned | Create a reaction region. | | `side(BubbleReactionSide)` | `Bottom` | Attach it above or below the bubble. | | `alignment(MessageAlignment)` | `End` | Align children along the bubble edge. | | `action(Button)` | — | Add a typed action that shares the reaction surface and full/pill radius. | | `.child(...)` | — | Add emoji, text, or arbitrary GPUI elements. | | `Styled` methods | themed reaction surface | Refine spacing, colors, and layout. | ### Related types - [`BubbleVariant`] — `Filled`, `Secondary`, `Muted`, `Tinted`, `Outline`, `Ghost`, and `Destructive`. - [`BubbleReactionSide`] — `Top` or `Bottom`. - [`MessageAlignment`] — `Start` or `End`. [Bubble]: https://docs.rs/gpui-component/latest/gpui_component/bubble/struct.Bubble.html [BubbleContent]: https://docs.rs/gpui-component/latest/gpui_component/bubble/struct.BubbleContent.html [BubbleGroup]: https://docs.rs/gpui-component/latest/gpui_component/bubble/struct.BubbleGroup.html [BubbleReactions]: https://docs.rs/gpui-component/latest/gpui_component/bubble/struct.BubbleReactions.html [BubbleVariant]: https://docs.rs/gpui-component/latest/gpui_component/bubble/enum.BubbleVariant.html [BubbleReactionSide]: https://docs.rs/gpui-component/latest/gpui_component/bubble/enum.BubbleReactionSide.html [MessageAlignment]: https://docs.rs/gpui-component/latest/gpui_component/message/enum.MessageAlignment.html --- # Focus Trap Source: /versions/v0.7.1/component/focus-trap License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/focus-trap and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Focus trap utility for constraining keyboard focus within a specific container. Essential for modal dialogs, sheets, and overlay components to provide proper keyboard navigation accessibility. **Note:** [Dialog](/versions/v0.7.1/component/dialog) and [Sheet](/versions/v0.7.1/component/sheet) components have focus trap built-in. You only need to manually use `focus_trap()` for custom modal-like components. ## Import ```rust use gpui_kit::component::FocusTrapElement; ``` ## Usage ### Basic Focus Trap ```rust let container_handle = cx.focus_handle(); v_flex() .child(Button::new("btn1").label("Button 1")) .child(Button::new("btn2").label("Button 2")) .child(Button::new("btn3").label("Button 3")) .focus_trap("trap1", &container_handle) // Pressing Tab will cycle: btn1 -> btn2 -> btn3 -> btn1 // Focus will not escape to elements outside this container ``` ### Multiple Focus Traps You can have multiple independent focus trap areas in your application. Each trap operates independently: ```rust let trap1_handle = cx.focus_handle(); let trap2_handle = cx.focus_handle(); v_flex() .gap_4() // First focus trap area .child( h_flex() .gap_2() .child(Button::new("trap1-1").label("Area 1 - Button 1")) .child(Button::new("trap1-2").label("Area 1 - Button 2")) .child(Button::new("trap1-3").label("Area 1 - Button 3")) .focus_trap("trap1", &trap1_handle) ) // Second focus trap area .child( h_flex() .gap_2() .child(Button::new("trap2-1").label("Area 2 - Button 1")) .child(Button::new("trap2-2").label("Area 2 - Button 2")) .focus_trap("trap2", &trap2_handle) ) ``` ### Focus Trap with Dialog [Dialog] components have focus trap built-in automatically. You don't need to manually add `focus_trap()`: ```rust window.open_dialog(cx, |dialog, _, _| { dialog .title("Settings") .child( v_flex() .gap_3() .child(Button::new("save").label("Save")) .child(Button::new("cancel").label("Cancel")) .child(Button::new("reset").label("Reset")) ) // Dialog internally uses focus_trap() // Tab navigation automatically cycles: save -> cancel -> reset -> save }) ``` ### Focus Trap with Sheet [Sheet] components also have focus trap built-in automatically: ```rust window.open_sheet(cx, |sheet, _, _| { sheet .title("Filter Options") .child( v_flex() .gap_2() .child(Checkbox::new("option1").label("Option 1")) .child(Checkbox::new("option2").label("Option 2")) .child(Button::new("apply").label("Apply Filters")) ) // Sheet internally uses focus_trap() // Focus automatically cycles within the sheet panel }) ``` ## How It Works The focus trap system consists of three key components: 1. **FocusTrapContainer**: Wraps any container element and registers it as a focus trap area 2. **FocusTrapManager**: Global state manager that tracks all active focus traps 3. **Root Integration**: The [Root] view intercepts Tab/Shift-Tab events and enforces focus cycling When Tab or Shift-Tab is pressed: 1. [Root] detects if the currently focused element is inside a focus trap 2. If yes, it calculates the next focusable element within the same trap 3. If focus would escape the trap, it cycles back to the beginning (Tab) or end (Shift-Tab) 4. This prevents focus from leaving the trapped container ### Built-in Focus Trap Components The following components have focus trap functionality built-in and don't require manual `focus_trap()` calls: - **[Dialog]** - Modal dialogs automatically trap focus (see `dialog.rs:437`) - **[Sheet]** - Side panels automatically trap focus (see `sheet.rs:197`) ## API Reference - [FocusTrapElement](https://docs.rs/gpui-component/latest/gpui_component/trait.FocusTrapElement.html) - [FocusTrapContainer](https://docs.rs/gpui-component/latest/gpui_component/struct.FocusTrapContainer.html) ## Examples ### Custom Modal with Focus Trap ```rust struct CustomModal { container_handle: FocusHandle, } impl CustomModal { fn new(cx: &mut App) -> Self { Self { container_handle: cx.focus_handle(), } } } impl Render for CustomModal { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .absolute() .inset_0() .flex() .items_center() .justify_center() .child( v_flex() .gap_4() .p_6() .bg(cx.theme().background) .rounded(cx.theme().radius_lg) .shadow_lg() .border_1() .border_color(cx.theme().border) .child("This is a modal dialog") .child( h_flex() .gap_2() .child(Button::new("ok").primary().label("OK")) .child(Button::new("cancel").label("Cancel")) ) .focus_trap("modal", &self.container_handle) ) } } ``` ### Nested Focus Traps Focus traps support nesting. When multiple traps are active, the innermost trap takes precedence: ```rust let outer_handle = cx.focus_handle(); let inner_handle = cx.focus_handle(); div() .child( v_flex() .gap_4() .p_4() .border_1() .border_color(cx.theme().border) .child(Button::new("outer-1").label("Outer Button 1")) .child( // Inner trap takes precedence when focused h_flex() .gap_2() .p_4() .bg(cx.theme().accent.opacity(0.1)) .child(Button::new("inner-1").label("Inner Button 1")) .child(Button::new("inner-2").label("Inner Button 2")) .focus_trap("inner", &inner_handle) ) .child(Button::new("outer-2").label("Outer Button 2")) .focus_trap("outer", &outer_handle) ) ``` ### Conditional Focus Trap You can conditionally apply focus trapping based on application state: ```rust struct ModalView { is_modal: bool, container_handle: FocusHandle, } impl Render for ModalView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let content = v_flex() .gap_2() .child(Button::new("btn1").label("Button 1")) .child(Button::new("btn2").label("Button 2")) .child(Button::new("btn3").label("Button 3")); if self.is_modal { // Apply focus trap when in modal mode content.focus_trap("conditional", &self.container_handle) .into_any_element() } else { // Normal behavior without focus trap content.into_any_element() } } } ``` ## Accessibility Notes - Focus trapping is essential for modal dialogs and overlays to meet WCAG accessibility guidelines - Always provide a way to close or dismiss trapped focus areas (ESC key, close button) - The first focusable element in the trap should receive focus when the trap is activated - Use focus traps sparingly - only for truly modal interactions - Ensure keyboard navigation order is logical within the trapped area ## See Also - [Root View System](/versions/v0.7.1/component/root) - Manages focus trap behavior at the window level - [Dialog](/versions/v0.7.1/component/dialog) - Uses focus trap automatically - [Sheet](/versions/v0.7.1/component/sheet) - Uses focus trap automatically - [focus-trap-react](https://github.com/focus-trap/focus-trap-react) - Similar concept for React applications [Root]: https://docs.rs/gpui-component/latest/gpui_component/struct.Root.html [FocusTrapElement]: https://docs.rs/gpui-component/latest/gpui_component/trait.FocusTrapElement.html [Dialog]: ./dialog.md [Sheet]: ./sheet.md --- # Popover Source: /versions/v0.7.1/component/popover License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/popover and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Popover component for displaying floating content that appears when interacting with a trigger element. Supports multiple positioning options, custom content, different trigger methods, and automatic dismissal behaviors. Perfect for tooltips, menus, forms, and other contextual information. ## Import ```rust use gpui_kit::component::popover::{Popover}; ``` ## Usage ### Basic Popover Any element that implements [Selectable] can be used as a trigger, for example, a [Button]. Any element that implements [RenderOnce] or [Render] can be used as popover content, use `.child(...)` to add children directly. ```rust use gpui_kit::ParentElement as _; use gpui_kit::component::{button::Button, popover::Popover}; Popover::new("basic-popover") .trigger(Button::new("trigger").label("Click me").outline()) .child("Hello, this is a popover!") .child("It appears when you click the button.") ``` ### Popover with Custom Positioning The `anchor` method names the **popover's own** anchor, not the trigger's corner. `Top*` anchors open below the trigger, `Bottom*` anchors open above it, `LeftCenter` opens to the right, and `RightCenter` opens to the left. The popup clamps to the window without changing its anchor or flipping. ```rust use gpui_kit::{Anchor, px}; use gpui_kit::component::popover::Popover; Popover::new("anchored") .anchor(Anchor::TopCenter) .offset(px(8.)) .arrow(true) .trigger(Button::new("details").label("Details")) .child("Contextual details") ``` | Option | Meaning | Default | | --- | --- | --- | | `anchor(Anchor)` | Popup anchor, including `TopCenter` and `BottomCenter` | `TopLeft` | | `offset(Pixels)` | Gap from trigger to surface, or to arrow tip when enabled | `0.25rem` | | `arrow(bool)` | Show an arrow on the edge selected by the anchor | `false` | The arrow follows the anchor's leading, center, or trailing alignment and is inset as needed to avoid rounded corners. It adds `0.375rem` to the surface distance and uses the surface background, falling back to the theme's popover color. Neither `offset` nor `arrow` changes the positioning strategy. For example, `Anchor::TopLeft` places the popover just below the trigger, left-aligned to it: ```text [ Trigger ] ┌──────────────┐ │ Popover │ └──────────────┘ ``` ```rust use gpui_kit::component::Anchor; // Below the trigger: name the popover's top anchor Popover::new("top-left") .anchor(Anchor::TopLeft) .trigger(Button::new("btn").label("Top Left").outline()) .child("Below the trigger, aligned left") Popover::new("top-center") .anchor(Anchor::TopCenter) .trigger(Button::new("btn").label("Top Center").outline()) .child("Below the trigger, centered") Popover::new("top-right") .anchor(Anchor::TopRight) .trigger(Button::new("btn").label("Top Right").outline()) .child("Below the trigger, aligned right") // Above the trigger: name the popover's bottom anchor Popover::new("bottom-left") .anchor(Anchor::BottomLeft) .trigger(Button::new("btn").label("Bottom Left").outline()) .child("Above the trigger, aligned left") Popover::new("bottom-center") .anchor(Anchor::BottomCenter) .trigger(Button::new("btn").label("Bottom Center").outline()) .child("Above the trigger, centered") Popover::new("bottom-right") .anchor(Anchor::BottomRight) .trigger(Button::new("btn").label("Bottom Right").outline()) .child("Above the trigger, aligned right") ``` ### View in Popover You can add any `Entity` that implemented [Render] as the popover content. ```rust let view = cx.new(|_| MyView::new()); Popover::new("form-popover") .anchor(Anchor::BottomLeft) .trigger(Button::new("show-form").label("Open Form").outline()) .child(view.clone()) ``` ### Add content by `content` method The `content` method allows you to create more complex popover content using a closure. This is useful when you need to build dynamic content or need access to the popover's context. This method will let us to have `&mut PopoverState`, `&mut Window` and `&mut Context` parameters in the closure is to allow you to interact with the popover's state and the overall application context if needed. This `content` callback will called every time on render the popover. So, you should avoid creating new elements or entities in the content closure or other heavy operations that may impact performance. And `content` will works with `child`, `children` methods together. ```rust use gpui_kit::ParentElement as _; use gpui_kit::component::popover::Popover; Popover::new("complex-popover") .anchor(Anchor::BottomLeft) .trigger(Button::new("complex").label("Complex Content").outline()) .content(|_, _, _| { div() .child("This popover has complex content.") .child( Button::new("action-btn") .label("Perform Action") .outline() ) }) ``` ### Right-Click Popover Sometimes you may want to show a popover on right-click, for example, to create a special your ownen context menu. The `mouse_button` method allows you to specify which mouse button triggers the popover. ```rust use gpui_kit::MouseButton; Popover::new("context-menu") .anchor(Anchor::BottomRight) .mouse_button(MouseButton::Right) .trigger(Button::new("right-click").label("Right Click Me").outline()) .child("Context Menu") .child(Separator::horizontal()) .child("This is a custom context menu.") ``` ### Dismiss Popover manually If you want to dismiss the popover programmatically from within the content, you can emit a `DismissEvent`. In this case, you should use `content` method to create the popover content so you have access to the `cx: &mut Context`. ```rust use gpui_kit::component::{DismissEvent, popover::Popover}; Popover::new("dismiss-popover") .trigger(Button::new("dismiss").label("Dismiss Popover").outline()) .content(|_, cx| { div() .child("Click the button below to dismiss this popover.") .child( Button::new("close-btn") .label("Close Popover") .on_click(cx.listener(|_, _, _, cx| { // NOTE: Here `cx` is `&mut Context` type, so we can emit DismissEvent. cx.emit(DismissEvent); })) ) }) ``` ### Styling Popover Like the others components in GPUI Component, the `appearance(false)` method can be used to disable the default styling of the popover, allowing you to fully customize its appearance. And the `Popover` has implemented the [Styled] trait, so you can use all the styling methods provided by GPUI to style the popover content as you like. ```rust // For custom styled popovers or when you want full control Popover::new("custom-popover") .appearance(false) .trigger(Button::new("custom").label("Custom Style")) .bg(cx.theme().accent) .text_color(cx.theme().accent_foreground) .p_6() .rounded_xl() .shadow_2xl() .child("Fully custom styled popover") ``` ### Control Open State There have `open` and `on_open_change` methods to control the open state of the popover programmatically. This is useful when you want to synchronize the popover's open state with other UI elements or application state. When you use `open` to control the popover's open state, that means you have take full control of it, so you need to update the state in `on_open_change` callback to keep the popover working correctly. ```rust use gpui_kit::component::popover::Popover; struct MyView { popover_open: bool, } Popover::new("controlled-popover") .open(self.open) .on_open_change(cx.listener(|this, open: &bool, _, cx| { this.popover_open = *open; cx.notify(); })) .trigger(Button::new("control-btn").label("Control Popover").outline()) .child("This popover's open state is controlled programmatically.") ``` ### Default Open The `default_open` method allows you to set the initial open state of the popover when it is first rendered. Please note that if you use the `open` method to control the popover's open state, the `default_open` setting will be ignored. ```rust use gpui_kit::component::popover::Popover; Popover::new("default-open-popover") .default_open(true) .trigger(Button::new("default-open-btn").label("Default Open").outline()) .child("This popover is open by default when first rendered.") ``` ### Custom Trigger A trigger is any element that implements [Selectable]. While the popover is open, it calls `open(true)` on the trigger — not `selected(true)` — so a trigger can tell "my popover is showing" apart from "I am the selected item". `open` and `is_open` default to `selected` and `is_selected`, so a trigger that only implements the selected state keeps working unchanged, and a [Button] trigger looks the same open as it does selected. Override them when your element already uses `selected` for something else, such as a sidebar row that is selected when it is the current view: ```rust use gpui_kit::component::Selectable; struct SidebarRow { /// This row is the current view. selected: bool, /// This row's account popover is showing. open: bool, } impl Selectable for SidebarRow { fn selected(mut self, selected: bool) -> Self { self.selected = selected; self } fn is_selected(&self) -> bool { self.selected } fn open(mut self, open: bool) -> Self { self.open = open; self } fn is_open(&self) -> bool { self.open } } ``` [Button]: https://docs.rs/gpui-component/latest/gpui_component/button/struct.Button.html [Selectable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Selectable.html [Render]: https://docs.rs/gpui/latest/gpui/trait.Render.html [RenderOnce]: https://docs.rs/gpui/latest/gpui/trait.RenderOnce.html [Styled]: https://docs.rs/gpui/latest/gpui/trait.Styled.html [`Anchor`]: https://docs.rs/gpui-component/latest/gpui_component/enum.Anchor.html --- # Toolbar Source: /versions/v0.7.1/component/toolbar License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/toolbar and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Toolbar is a transparent horizontal container for actions — buttons, separators, and short labels — used inside panel headers, tab strips, and custom command surfaces. The surrounding container owns its background and border. The design mirrors the toolbars found in native UI frameworks: macOS `NSToolbar` and Windows `ToolStrip`. ## Import ```rust use gpui_kit::component::toolbar::Toolbar; ``` ## Composition Use `child` for `Sizable` controls. Toolbar applies its final size to those controls even when `.small()` or another size method appears after them in the builder chain. Use `content` for strings, separators, flexible spacers, and custom layout that should keep its own dimensions. Items render in source order. - For a **command**, pass a `Button`; Toolbar applies its size and forces the quiet `ghost + compact` presentation. Chain `label`, `icon`, `tooltip`, `on_click`, etc. as needed. - For an **icon-only button**, always add a `tooltip`; it is the accessible name as well. - For a **separator**, use `content` with `Separator::vertical()` and an explicit height. - For a **non-interactive label**, use one of the content methods with a plain string. ## Usage ### Commands ```rust Toolbar::new("toolbar") .child( Button::new("new") .icon(IconName::Plus) .label("New") .on_click(|_, window, cx| { /* ... */ }), ) .content(Separator::vertical().h_5()) .child( Button::new("undo") .icon(IconName::Undo2) .tooltip("Undo") .on_click(|_, window, cx| { /* ... */ }), ) .content(div().flex_1()) .child( Button::new("more") .icon(IconName::Ellipsis) .tooltip("More options") .on_click(|_, window, cx| { /* ... */ }), ) ``` ### Sizes Use `Sizable` to change the container height, spacing, text size, and hosted controls together: `xsmall` (28px), `small` (32px, default), and `medium` (48px). Builder order does not matter. ```rust Toolbar::new("toolbar") .child(Button::new("new").icon(IconName::Plus).label("New")) .child(Button::new("find").icon(IconName::Search).tooltip("Find")) .small() ``` ### Labels and custom elements ```rust Toolbar::new("toolbar") .content("Dashboard") .content(Separator::vertical().h_5()) .content( h_flex() .items_center() .gap_1() .child(Icon::new(IconName::CircleCheck).xsmall()) .child("Saved"), ) .content(div().flex_1()) .child(Button::new("settings").icon(IconName::Settings2).tooltip("Settings")) ``` ### Custom styling `Toolbar` is transparent and borderless by default. It implements `Styled`, so a standalone command surface can add its own appearance. ```rust Toolbar::new("toolbar") .bg(cx.theme().secondary) .border_color(cx.theme().border) .content("Ready") ``` ## Groups Wrap related controls in `ToolbarGroup` to give them an accessible name, so assistive technology reads a run of controls as one unit. The group implements `Sizable`, and a parent Toolbar propagates its final size through the group to every control: ```rust use gpui_kit::component::toolbar::ToolbarGroup; Toolbar::new("document-toolbar") .child( ToolbarGroup::new("history-group") .label("History") .gap_2() // match the bar's own item spacing .child(Button::new("undo").icon(IconName::Undo2).tooltip("Undo")) .child(Button::new("redo").icon(IconName::Redo2).tooltip("Redo")), ) ``` Unlike Base UI's `Toolbar.Group`, a group cannot disable its children: that API propagates through React context into Base UI's own button primitives, which has no equivalent for arbitrary GPUI children. Disabling the hosted controls is the caller's job. Separators and other non-sized elements use `content`. Sized controls use `child` so the toolbar can propagate its size. ## Keyboard The toolbar exposes `Toolbar` semantics to assistive technology and owns roving keyboard focus, matching the ARIA toolbar pattern and Base UI's `Toolbar`: | Key | Behavior | | --- | --- | | `←` / `→` | Move focus to the previous / next control (horizontal toolbar) | | `↑` / `↓` | Move focus to the previous / next control (vertical toolbar) | | `Tab` | Enter or leave the toolbar; the bar itself is not a tab stop | Focus wraps around at the ends. Hosted inputs keep their own arrow-key caret behavior; place inputs at the trailing end of the bar. This behavior comes from the unstyled `gpui_base::Toolbar` primitive, so applications building custom toolbars on the base layer get the same contract. ## API Reference ### Toolbar | Method | Description | | ----------------- | ---------------------------------------------------- | | `new()` | Create a new, empty toolbar (small size) | | `child(c)` / `children(cs)` | Add sized control(s) in source order | | `content(c)` / `contents(cs)` | Add non-sized content in source order | | `with_size(size)` | Set the bar size — `xsmall`, `small`, or `medium` | | `disabled(value)` | Disable roving navigation; the owner also disables hosted controls | Control methods require `Sizable + IntoElement`; content methods accept general elements. `Toolbar` also implements `Styled` and `Sizable`. ## Notes - Use `content(div().flex_1())` when later items need to align to the trailing edge. - Keep the primary command visible; move low-frequency actions into a dropdown or overflow menu rather than hiding them behind hover. - Toolbar has no default background or border; its host surface supplies them. --- # Radio Source: /versions/v0.7.1/component/radio License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/radio and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Radio buttons allow users to select a single option from a set of mutually exclusive choices. Use radio buttons when you want to give users a choice between multiple options and only one selection is allowed. Use `on_change` for requested values. The owner stores the value and calls `cx.notify()`. The existing `on_click` name remains a compatibility alias; setting either replaces the same handler, so the last call wins. ## Import ```rust use gpui_kit::component::radio::{Radio, RadioGroup}; ``` ## Usage ### Basic Radio Button ```rust Radio::new("radio-option-1") .label("Option 1") .checked(false) .on_change(|checked, _, _| { println!("Radio is now: {}", checked); }) ``` ### Controlled Radio Button ```rust struct MyView { radio_checked: bool, } impl Render for MyView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { Radio::new("radio") .label("Select this option") .checked(self.radio_checked) .on_change(cx.listener(|view, checked, _, cx| { view.radio_checked = *checked; cx.notify(); })) } } ``` ### Radio Group (Recommended) ```rust struct MyView { selected_option: Option, } impl Render for MyView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { RadioGroup::horizontal("options") .children(["Option 1", "Option 2", "Option 3"]) .selected_index(self.selected_option) .on_change(cx.listener(|view, selected_index: &usize, _, cx| { view.selected_option = Some(*selected_index); cx.notify(); })) } } ``` ### Different Sizes ```rust Radio::new("small").label("Small").xsmall() Radio::new("medium").label("Medium") // default Radio::new("large").label("Large").large() ``` ### Disabled State ```rust Radio::new("disabled") .label("Disabled option") .disabled(true) .checked(false) Radio::new("disabled-checked") .label("Disabled and checked") .checked(true) .disabled(true) ``` ### Multi-line Label with Custom Content ```rust Radio::new("custom") .label("Primary option") .child( div() .text_color(cx.theme().muted_foreground) .child("This is additional descriptive text that provides more context.") ) .w(px(300.)) ``` ### Custom Tab Order ```rust Radio::new("radio") .label("Custom tab order") .tab_index(2) .tab_stop(true) ``` ## Radio Group Usage ### Horizontal Layout ```rust RadioGroup::horizontal("horizontal-group") .children(["First", "Second", "Third"]) .selected_index(Some(0)) .on_change(cx.listener(|view, index, _, cx| { println!("Selected index: {}", index); cx.notify(); })) ``` ### Vertical Layout ```rust RadioGroup::vertical("vertical-group") .child(Radio::new("option1").label("United States")) .child(Radio::new("option2").label("Canada")) .child(Radio::new("option3").label("Mexico")) .selected_index(Some(1)) .disabled(false) ``` ### Styled Radio Group ```rust RadioGroup::vertical("styled-group") .w(px(220.)) .p_2() .border_1() .border_color(cx.theme().border) .rounded(cx.theme().radius) .child(Radio::new("option1").label("Option 1")) .child(Radio::new("option2").label("Option 2")) .child(Radio::new("option3").label("Option 3")) .selected_index(Some(0)) ``` ### Disabled Radio Group ```rust RadioGroup::vertical("disabled-group") .children(["Option A", "Option B", "Option C"]) .selected_index(Some(1)) .disabled(true) // Disables all radio buttons in the group ``` ## API Reference ### Radio | Method | Description | | ------------------ | ----------------------------------------------------------- | | `new(id)` | Create a new radio button with the given ID | | `label(text)` | Set label text | | `checked(bool)` | Set checked state | | `disabled(bool)` | Set disabled state | | `on_change(fn)` | Requested checked value, receives `&bool` | | `tab_stop(bool)` | Enable/disable tab navigation (default: true) | | `tab_index(isize)` | Set tab order index (default: 0) | ### RadioGroup | Method | Description | | ------------------------------- | ------------------------------------------------------------------- | | `new(id)` | Create a vertical radio group with no selection | | `horizontal(id)` | Create a new horizontal radio group | | `vertical(id)` | Create a new vertical radio group | | `layout(Axis)` | Set layout direction (Vertical or Horizontal) | | `child(Radio)` | Add a single radio button to the group | | `children(items)` | Add multiple radio buttons from an iterator | | `selected_index(Option)` | Set the selected option by index | | `disabled(bool)` | Disable all radio buttons in the group | | `on_change(fn)` | Requested selected index, receives `&usize` | ### Styling Both Radio and RadioGroup implement `Styled` trait for custom styling: Radio also implements `Sizable` trait: - `xsmall()` - Extra small size - `small()` - Small size - `medium()` - Medium size (default) - `large()` - Large size ## Examples ### Settings Panel ```rust struct SettingsView { theme: Option, // 0: Light, 1: Dark, 2: Auto language: Option, // 0: English, 1: Spanish, 2: French } impl Render for SettingsView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .gap_6() .child( v_flex() .gap_2() .child(div().text_sm().font_semibold().child("Theme")) .child( RadioGroup::vertical("theme") .child(Radio::new("light").label("Light")) .child(Radio::new("dark").label("Dark")) .child(Radio::new("auto").label("Auto")) .selected_index(self.theme) .on_change(cx.listener(|view, index, _, cx| { view.theme = Some(*index); cx.notify(); })) ) ) .child( v_flex() .gap_2() .child(div().text_sm().font_semibold().child("Language")) .child( RadioGroup::horizontal("language") .children(["English", "Español", "Français"]) .selected_index(self.language) .on_change(cx.listener(|view, index, _, cx| { view.language = Some(*index); cx.notify(); })) ) ) } } ``` ### Survey Form ```rust struct SurveyView { satisfaction: Option, recommendation: Option, } impl Render for SurveyView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .gap_8() .child( v_flex() .gap_3() .child( div() .text_base() .font_medium() .child("How satisfied are you with our service?") ) .child( RadioGroup::vertical("satisfaction") .child(Radio::new("very-satisfied").label("Very satisfied")) .child(Radio::new("satisfied").label("Satisfied")) .child(Radio::new("neutral").label("Neutral")) .child(Radio::new("dissatisfied").label("Dissatisfied")) .child(Radio::new("very-dissatisfied").label("Very dissatisfied")) .selected_index(self.satisfaction) .on_change(cx.listener(|view, index, _, cx| { view.satisfaction = Some(*index); cx.notify(); })) ) ) .child( v_flex() .gap_3() .child( div() .text_base() .font_medium() .child("How likely are you to recommend us?") ) .child( RadioGroup::horizontal("recommendation") .children((0..=10).map(|i| i.to_string())) .selected_index(self.recommendation) .on_change(cx.listener(|view, index, _, cx| { view.recommendation = Some(*index); cx.notify(); })) ) ) } } ``` ### Payment Method Selection ```rust struct PaymentView { payment_method: Option, } impl Render for PaymentView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .gap_4() .child( div() .text_lg() .font_semibold() .child("Select Payment Method") ) .child( RadioGroup::vertical("payment") .child( Radio::new("credit-card") .label("Credit Card") .child( div() .text_color(cx.theme().muted_foreground) .child("Visa, MasterCard, American Express") ) ) .child( Radio::new("paypal") .label("PayPal") .child( div() .text_color(cx.theme().muted_foreground) .child("Pay with your PayPal account") ) ) .child( Radio::new("bank-transfer") .label("Bank Transfer") .child( div() .text_color(cx.theme().muted_foreground) .child("Direct bank account transfer") ) ) .selected_index(self.payment_method) .on_change(cx.listener(|view, index, _, cx| { view.payment_method = Some(*index); cx.notify(); })) ) } } ``` ## Best Practices 1. **Use RadioGroup**: Always prefer `RadioGroup` over individual `Radio` components for mutually exclusive choices 2. **Clear Labels**: Provide descriptive labels that clearly indicate what each option represents 3. **Default Selection**: Consider providing a sensible default selection, especially for required fields 4. **Logical Order**: Arrange options in a logical order (alphabetical, frequency of use, or importance) 5. **Limit Options**: Keep the number of radio options reasonable (typically 2-7 options) 6. **Group Related Options**: Use visual grouping and clear headings for multiple radio groups 7. **Responsive Design**: Consider using horizontal layout for fewer options and vertical for more options --- # Form Source: /versions/v0.7.1/component/form License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/form and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Form lays out typed fields and an optional footer. The application owns values, validation, submission, and responsive column choices. ## Import ```rust use gpui_kit::component::form::{field, v_form, h_form, Form, Field}; ``` ## Predictable composition `Form::new()` defaults to one column with labels above controls. `label_layout(Axis::Horizontal)` places labels beside controls; `columns(2)` independently creates two field columns. Existing `horizontal()`, `vertical()`, `layout(Axis)`, `h_form()`, and `v_form()` remain available. ```rust Form::new() .label_layout(Axis::Horizontal) .columns(2) .child(Field::new().label("Name").child(Input::new(&name_input))) .child(Field::new().label("Email").child(Input::new(&email_input))) .footer(Button::new("save").label("Save")) ``` `child` accepts a Field. Put commands in `footer`, which spans all columns and aligns its content to the trailing edge. Attach submission behavior to the supplied Button; Form does not submit automatically. See the [complete application recipe](https://github.com/longbridge/gpui-kit/tree/main/examples/ai_recipes) for retained state, callbacks, and window setup. ## Usage ### Basic Form ```rust v_form() .child( field() .label("Name") .child(Input::new(&name_input)) ) .child( field() .label("Email") .child(Input::new(&email_input)) .required(true) ) ``` ### Horizontal Form Layout ```rust h_form() .label_width(px(120.)) .child( field() .label("First Name") .child(Input::new(&first_name)) ) .child( field() .label("Last Name") .child(Input::new(&last_name)) ) ``` ### Multi-Column Form ```rust v_form() .columns(2) // Two-column layout .child( field() .label("First Name") .child(Input::new(&first_name)) ) .child( field() .label("Last Name") .child(Input::new(&last_name)) ) .child( field() .label("Bio") .col_span(2) // Span across both columns .child(Input::new(&bio_input)) ) ``` ## Form Container and Layout ### Vertical Layout (Default) ```rust v_form() .gap(px(12.)) .child(field().label("Name").child(input)) .child(field().label("Email").child(email_input)) ``` ### Horizontal Layout ```rust h_form() .label_width(px(100.)) .child(field().label("Name").child(input)) .child(field().label("Email").child(email_input)) ``` ### Custom Sizing ```rust v_form() .large() // Large form size .label_text_size(rems(1.2)) .child(field().label("Title").child(input)) v_form() .small() // Small form size .child(field().label("Code").child(input)) ``` ## Form Validation ### Required Fields ```rust field() .label("Email") .required(true) // Shows asterisk (*) next to label .child(Input::new(&email_input)) ``` ### Field Descriptions ```rust field() .label("Password") .description("Must be at least 8 characters long") .child(Input::new(&password_input)) ``` ### Dynamic Descriptions ```rust field() .label("Bio") .description_fn(|_, _| { div().child("Use at most 100 words to describe yourself.") }) .child(Input::new(&bio_input)) ``` ### Field Visibility ```rust field() .label("Admin Settings") .visible(user.is_admin()) // Conditionally show field .child(Switch::new("admin-mode")) ``` ## Submit Handling ### Basic Submit Pattern ```rust struct FormView { name_input: Entity, email_input: Entity, } impl FormView { fn submit(&mut self, cx: &mut Context) { let name = self.name_input.read(cx).value(); let email = self.email_input.read(cx).value(); // Validate inputs if name.is_empty() || email.is_empty() { // Show validation error return; } // Submit form data self.handle_submit(name, email, cx); } } // Form with submit button v_form() .child(field().label("Name").child(Input::new(&self.name_input))) .child(field().label("Email").child(Input::new(&self.email_input))) .child( field() .label_indent(false) .child( Button::new("submit") .primary() .child("Submit") .on_click(cx.listener(|this, _, _, cx| this.submit(cx))) ) ) ``` ### Form with Action Buttons ```rust v_form() .child(field().label("Title").child(Input::new(&title))) .child(field().label("Content").child(Input::new(&content))) .child( field() .label_indent(false) .child( h_flex() .gap_2() .child(Button::new("save").primary().child("Save")) .child(Button::new("cancel").child("Cancel")) .child(Button::new("preview").outline().child("Preview")) ) ) ``` ## Field Groups ### Related Fields ```rust v_form() .child( field() .label("Name") .child( h_flex() .gap_2() .child(div().flex_1().child(Input::new(&first_name))) .child(div().flex_1().child(Input::new(&last_name))) ) ) .child( field() .label("Address") .items_start() // Align to start for multi-line content .child( v_flex() .gap_2() .child(Input::new(&street)) .child( h_flex() .gap_2() .child(div().flex_1().child(Input::new(&city))) .child(div().w(px(100.)).child(Input::new(&zip))) ) ) ) ``` ### Custom Field Components ```rust field() .label("Theme Color") .child(ColorPicker::new(&color_state).small()) field() .label("Birth Date") .description("We'll send you a birthday gift!") .child(DatePicker::new(&date_state)) field() .label("Notifications") .child( v_flex() .gap_2() .child(Switch::new("email").label("Email notifications")) .child(Switch::new("push").label("Push notifications")) .child(Switch::new("sms").label("SMS notifications")) ) ``` ### Conditional Fields ```rust v_form() .child( field() .label("Account Type") .child(Select::new(&account_type)) ) .child( field() .label("Company Name") .visible(is_business_account) // Show only for business accounts .child(Input::new(&company_name)) ) .child( field() .label("Tax ID") .visible(is_business_account) .required(is_business_account) .child(Input::new(&tax_id)) ) ``` ## Grid Layout and Positioning ### Column Spanning ```rust v_form() .columns(3) // Three-column grid .child(field().label("First").child(input1)) .child(field().label("Second").child(input2)) .child(field().label("Third").child(input3)) .child( field() .label("Full Width") .col_span(3) // Spans all three columns .child(Input::new(&full_width)) ) ``` ### Column Positioning ```rust v_form() .columns(4) .child(field().label("A").child(input_a)) .child(field().label("B").child(input_b)) .child( field() .label("Positioned") .col_start(1) // Start at column 1 .col_span(2) // Span 2 columns .child(input_positioned) ) ``` ### Responsive Layout ```rust v_form() .columns(if is_mobile { 1 } else { 2 }) .child(field().label("Name").child(name_input)) .child(field().label("Email").child(email_input)) .child( field() .label("Bio") .when(!is_mobile, |field| field.col_span(2)) .child(bio_input) ) ``` ## Examples ### User Registration Form ```rust struct RegistrationForm { first_name: Entity, last_name: Entity, email: Entity, password: Entity, confirm_password: Entity, terms_accepted: bool, } impl Render for RegistrationForm { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_form() .large() .child( field() .label("Personal Information") .label_indent(false) .child( h_flex() .gap_3() .child( div().flex_1().child( Input::new(&self.first_name) .placeholder("First name") ) ) .child( div().flex_1().child( Input::new(&self.last_name) .placeholder("Last name") ) ) ) ) .child( field() .label("Email") .required(true) .child(Input::new(&self.email)) ) .child( field() .label("Password") .required(true) .description("Must be at least 8 characters") .child(Input::new(&self.password)) ) .child( field() .label("Confirm Password") .required(true) .child(Input::new(&self.confirm_password)) ) .child( field() .label_indent(false) .child( Checkbox::new("terms") .label("I agree to the Terms of Service") .checked(self.terms_accepted) .on_click(cx.listener(|this, checked, _, cx| { this.terms_accepted = *checked; cx.notify(); })) ) ) .child( field() .label_indent(false) .child( Button::new("register") .primary() .large() .w_full() .child("Create Account") ) ) } } ``` ### Settings Form with Sections ```rust v_form() .column(2) .child( field() .label("Profile") .label_indent(false) .col_span(2) .child(Separator::horizontal()) ) .child( field() .label("Display Name") .child(Input::new(&display_name)) ) .child( field() .label("Email") .child(Input::new(&email)) ) .child( field() .label("Bio") .col_span(2) .items_start() .child(Input::new(&bio)) ) .child( field() .label("Preferences") .label_indent(false) .col_span(2) .child(Separator::horizontal()) ) .child( field() .label("Theme") .child(Select::new(&theme_state)) ) .child( field() .label("Language") .child(Select::new(&language_state)) ) .child( field() .label_indent(false) .child(Switch::new("notifications").label("Enable notifications")) ) .child( field() .label_indent(false) .child(Switch::new("marketing").label("Marketing emails")) ) ``` ### Contact Form ```rust v_form() .child( field() .label("Contact Information") .child( h_flex() .gap_2() .child( Select::new(&prefix_state) .w(px(80.)) ) .child( div().flex_1().child( Input::new(&name_input) .placeholder("Your name") ) ) ) ) .child( field() .label("Email") .required(true) .child(Input::new(&email_input)) ) .child( field() .label("Subject") .child(Select::new(&subject_state)) ) .child( field() .label("Message") .required(true) .items_start() .description("Please describe your inquiry in detail") .child(Input::new(&message_input)) ) .child( field() .label_indent(false) .child( h_flex() .gap_2() .justify_between() .child( Checkbox::new("copy") .label("Send me a copy") ) .child( h_flex() .gap_2() .child(Button::new("cancel").child("Cancel")) .child(Button::new("send").primary().child("Send Message")) ) ) ) ``` --- # Message Source: /versions/v0.7.1/component/message License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/message and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `Message` is the row-level composition primitive for a conversation. It owns the horizontal alignment and the vertical stack that contains optional sender identity, metadata, content, and footer slots. It does not own a sender model, timestamp formatting, delivery state, reaction state, or message actions. Applications provide those values and compose existing components inside the slots. This keeps the message layout reusable across direct messages, group chat, assistant responses, system notices, and generated content. ## Import ```rust use gpui_kit::{ParentElement as _, StyleRefinement, Styled as _}; use gpui_kit::component::{ ActiveTheme as _, Colorize as _, Sizable as _, attachment::{Attachment, AttachmentContent, AttachmentTitle}, avatar::Avatar, bubble::{Bubble, BubbleVariant}, button::{Button, ButtonVariants as _}, message::{ Message, MessageAlignment, MessageAvatar, MessageContent, MessageFooter, MessageGroup, MessageHeader, }, }; ``` ## Anatomy and basic usage All named slots are optional, so a minimal message can contain only a body: ```rust Message::new().content( MessageContent::new().bubble(Bubble::new().child("Can you review this?")), ) ``` A complete message commonly combines sender identity, metadata, a bubble, and a delivery footer: ```rust Message::new() .avatar_slot( MessageAvatar::new() .child(Avatar::new().name("Alice").size_8()), ) .header( MessageHeader::new() .child("Alice") .child("10:24 AM"), ) .content( MessageContent::new().bubble( Bubble::new() .with_variant(BubbleVariant::Secondary) .child("Can you review this draft?"), ), ) .footer(MessageFooter::new().child("Read")) ``` The default state is: | Property | Default | Meaning | | --- | --- | --- | | Alignment | `MessageAlignment::Start` | Place the message at the leading edge. | | Avatar/header/content/footer | absent | Add only the slots needed by the product. | | Outer layout | full width, `min_w_0()`, `gap_2()` | Keeps rows usable in a virtual list. | | Inner stack gap | `rems(0.625)` | Separates metadata, body, and footer. | | Header/footer inset | enabled, `px_3()` | Aligns metadata with a regular bubble surface. | | Typography | inherited | The header and footer set their own; content typography belongs to the bubble or the caller. | | Identity and role | none, presentational | `id(...)` gives the row a stable identity; `role(...)` announces it. | | Avatar baseline | `min_w_8()`, circular muted surface | Gives sender identity a stable column. | `Message` applies its alignment to the complete row and to the named content stack. It reverses the outer row for `End`, so the avatar and message stack remain a single aligned unit. ## Alignment Use `Start` for incoming content and `End` for outgoing content: ```rust Message::new() .alignment(MessageAlignment::Start) .avatar(Avatar::new().name("Alice").size_8()) .header(MessageHeader::new().child("Alice").child("10:24 AM")) .content(MessageContent::new().bubble( Bubble::new() .with_variant(BubbleVariant::Secondary) .child("Incoming message"), )); Message::new() .alignment(MessageAlignment::End) .avatar(Avatar::new().name("You").size_8()) .header(MessageHeader::new().child("You").child("10:25 AM")) .content(MessageContent::new().bubble(Bubble::new().child("Outgoing message"))) .footer(MessageFooter::new().child("Delivered")) ``` `MessageAlignment` is also accepted by `Bubble`. When the body is a typed `MessageContent::bubble(...)`, the message propagates its alignment to that bubble's surface. Leave the bubble's own alignment unset in this composition so the row has one clear owner of placement. ## Avatar, header, content, and footer ### Avatar `.avatar(...)` wraps any element in `MessageAvatar`. Use `.avatar_slot(...)` when the slot itself needs styling or multiple children: ```rust Message::new() .avatar_slot( MessageAvatar::new() .bg(cx.theme().transparent) .child(Avatar::new().name("System").size_8()), ) .content(MessageContent::new().child("A system update")) ``` The avatar reserves the shared `size-8` baseline and always sits flush with the bottom edge of the message content; the footer renders below the avatar row, indented to the content column. The message does not require an avatar; omit it for assistant messages, compact group chat, or system rows where identity is already present elsewhere. ### Header `MessageHeader` is an arbitrary horizontal metadata row. It defaults to an extra-small, medium-weight, muted style with a `px_3()` inset: ```rust MessageHeader::new() .child("Alice") .child("·") .child("10:24 AM") ``` The header does not format dates or infer sender names. Use application-owned formatting and compose a `Tooltip` around timestamps when the full date is useful. ### Content `MessageContent` is a full-width, minimum-width-safe vertical stack. It accepts arbitrary elements and has a typed `.bubble(...)` convenience that records whether a ghost bubble is present: ```rust MessageContent::new() .bubble(Bubble::new().child("First paragraph")) .bubble(Bubble::new().child("Second paragraph")) ``` Use `.child(...)` for attachments, code blocks, images, or custom rich content: ```rust MessageContent::new() .bubble(Bubble::new().child("Here is the file:")) .child( Attachment::new().content( AttachmentContent::new() .title(AttachmentTitle::new("quarterly-report.pdf")), ), ) ``` Typed bubbles are useful when the surrounding header and footer should respond to the `Ghost` variant. Arbitrary `.child(...)` values are still fully composable, but their concrete type is erased and they do not set that ghost-surface metadata. ### Footer `MessageFooter` is another arbitrary horizontal metadata row. Use it for delivery state, reactions, or actions composed from existing controls: ```rust MessageFooter::new() .child("Delivered") .child(Button::new("reply").ghost().xsmall().label("Reply")) .child(Button::new("copy").ghost().xsmall().label("Copy")) ``` Footer uses the same extra-small muted default and `px_3()` inset as the header. The footer does not own a delivery-state enum or action semantics. ## Rich content and actions Compose the existing component that owns each behavior: ```rust Message::new() .content( MessageContent::new() .bubble(Bubble::new().child("The export is ready.")) .child( Attachment::new() .content(AttachmentContent::new().title(AttachmentTitle::new("export.zip"))), ), ) .footer( MessageFooter::new() .child(Button::new("download-export").label("Download")) .child(Button::new("share-export").ghost().label("Share")), ) ``` Use `Button` for commands, `Link` for URLs, `Attachment` for files, and `Bubble` for conversational surfaces. This keeps disabled, loading, focus, keyboard, and accessible-name behavior on the control that owns it. A message does not become clickable merely because it contains a button. Long or multiline content remains the responsibility of the child element. Keep custom children `min_w_0()` when they contain long text or horizontal layouts; `Message` already applies `w_full()` and `min_w_0()` to its own row and stack. ## Grouping `MessageGroup` is a styleable vertical stack for consecutive messages. It does not decide which sender owns a message or automatically remove metadata: ```rust MessageGroup::new() .child( Message::new() .avatar(Avatar::new().name("Alice").size_8()) .header(MessageHeader::new().child("Alice")) .content(MessageContent::new().bubble( Bubble::new() .with_variant(BubbleVariant::Secondary) .child("The first message."), )), ) .child( Message::new() .avatar_slot(MessageAvatar::new().bg(cx.theme().transparent)) .content(MessageContent::new().bubble( Bubble::new() .with_variant(BubbleVariant::Secondary) .child("The follow-up keeps the same sender context."), )), ) ``` Use `BubbleGroup` when only the bubbles are grouped and there is no message header, avatar, or footer. Use `MessageGroup` when each item is a full row. ## Ghost surfaces and content insets The typed `MessageContent::bubble(...)` builder records a ghost bubble. In that case, `Message` removes the default header and footer insets so metadata lines up with the unframed content: ```rust Message::new() .header(MessageHeader::new().child("System").child("Just now")) .content(MessageContent::new().bubble( Bubble::new() .with_variant(BubbleVariant::Ghost) .child("The conversation has been archived."), )) .footer(MessageFooter::new().child("No further action required")) ``` Override this behavior explicitly on either named metadata slot: ```rust MessageHeader::new() .content_inset(true) .child("Keep the regular header inset"); MessageFooter::new() .content_inset(false) .child("Align the footer with a custom surface") ``` `content_inset(...)` takes precedence over inherited ghost behavior. A typed ghost bubble is required for automatic inheritance; an arbitrary child that happens to look like a ghost surface cannot be inspected by `Message`. The inner slot stack can also be refined independently: ```rust Message::new() .with_stack_style(StyleRefinement::default().gap_3()) .content(MessageContent::new().child("A wider message rhythm")) ``` ## Custom styling and theme tokens `Message`, `MessageGroup`, `MessageAvatar`, `MessageHeader`, `MessageContent`, and `MessageFooter` implement `Styled`. Style the part that owns the visual decision: ```rust Message::new() .p_3() .rounded(cx.theme().radius_lg) .bg(cx.theme().muted.opacity(0.35)) .header(MessageHeader::new().px_0().child("System")) .content(MessageContent::new().child("Archived")) .footer(MessageFooter::new().px_0().child("Just now")) ``` Use `with_stack_style(...)` for the vertical stack, slot refinements for header/content/footer typography and spacing, and the child component's own API for bubble, attachment, or button surfaces. `Message` itself sets no text size or line height, so Markdown or other rich content placed in a `Ghost` bubble keeps the typography of its renderer. Radius, spacing, typography, and colors should come from the active semantic theme or shared scale. Avoid raw colors at message call sites so the same composition works in light and dark themes. ## Accessibility and state guidance - Keep sender identity and message content in readable text. An avatar alone should not be the only indication of who sent a message. - A message is presentational by default. Give transcript rows `.id(...)` and `.role(Role::ListItem)` so assistive technology can move between them; the role needs the stable identity an id provides. - Put commands in semantic `Button` or `Link` controls. For the current `Button` API, use a visible `.label(...)` when a footer action needs an accessible name; a tooltip is supplemental. - Delivery, failure, streaming, and unread states belong in text or semantic controls. Do not communicate them with alignment, color, or opacity alone. - Preserve the header/footer inset when it is the visual relationship that aligns metadata with the surface. If a custom surface removes it, verify the reading order and keyboard order still match the visual order. - Keep multiline content readable at the application's minimum window width; use `min_w_0()` on nested horizontal content and avoid hover-only actions. - Motion for generated content belongs to `ShimmerText` or another motion-aware component. Reduced-motion behavior should leave the message text present and understandable. ## Component boundaries The GPUI component intentionally does not add provider or domain layers: - `Message` owns row alignment and slot layout. - The application owns sender records, timestamps, delivery state, reactions, permissions, message IDs, and persistence. - `Bubble`, `Attachment`, `Button`, `Link`, and `Marker` own their own visual or behavioral primitives and are composed through message slots. - `MessageGroup` only supplies a vertical stack. It does not infer sender changes or collapse headers. If a product needs a specific “assistant message” or “group chat message” with fixed metadata policy, wrap `Message` in an application component. Keep that domain policy out of the general-purpose primitive. ## API reference ### `Message` | Method | Default | Purpose | | --- | --- | --- | | `new()` | `Start`, no slots | Create a message row. | | `alignment(MessageAlignment)` | `Start` | Set leading or trailing alignment. | | `id(ElementId)` | none | Give the row a stable identity for element state and the accessibility tree. | | `role(Role)` | presentational | Announce the row to assistive technology, e.g. `Role::ListItem`; requires `id(...)`. | | `with_stack_style(StyleRefinement)` | component stack defaults | Refine the inner vertical stack. | | `avatar(element)` | none | Wrap an element in `MessageAvatar`. | | `avatar_slot(MessageAvatar)` | none | Set a fully configured avatar slot. | | `header(MessageHeader)` | none | Set sender and metadata content. | | `content(MessageContent)` | none | Set the message body. | | `footer(MessageFooter)` | none | Set delivery, reactions, or actions. | `Message` also implements `Styled` for the outer row. ### `MessageGroup` | Method | Default | Purpose | | --- | --- | --- | | `new()` | empty vertical stack | Create a message group. | | `.child(element)` | — | Add complete messages. | | `Styled` methods | `gap_2()` | Refine group spacing and layout. | ### `MessageAvatar` | Method | Default | Purpose | | --- | --- | --- | | `new()` | empty circular `size_8` baseline | Create an identity slot. | | `.child(element)` | — | Add Avatar or another identity element. | | `Styled` methods | muted surface and full radius | Refine size, background, and alignment. | ### `MessageHeader` and `MessageFooter` | Method | Default | Purpose | | --- | --- | --- | | `new()` | empty extra-small metadata row | Create the slot. | | `content_inset(bool)` | inherited or `true` | Keep or remove the default `px_3()` inset. | | `.child(element)` | — | Add text, metadata, reactions, or controls. | | `Styled` methods | muted, medium-weight, `text_xs()` | Refine the slot. | ### `MessageContent` | Method | Default | Purpose | | --- | --- | --- | | `new()` | empty full-width vertical stack | Create the body slot. | | `bubble(Bubble)` | — | Add a typed bubble and propagate ghost metadata. | | `.child(element)` | — | Add arbitrary rich content. | | `Styled` methods | `min_w_0()`, `gap(rems(0.625))` | Refine body layout. | ### Related types - [`MessageAlignment`] — `Start` or `End`. - [`Bubble`] — conversational surface content. - [`Attachment`] — files and media. - [`MessageScroller`] — virtualized conversation rows and tail following. [Message]: https://docs.rs/gpui-component/latest/gpui_component/message/struct.Message.html [MessageGroup]: https://docs.rs/gpui-component/latest/gpui_component/message/struct.MessageGroup.html [MessageAvatar]: https://docs.rs/gpui-component/latest/gpui_component/message/struct.MessageAvatar.html [MessageHeader]: https://docs.rs/gpui-component/latest/gpui_component/message/struct.MessageHeader.html [MessageContent]: https://docs.rs/gpui-component/latest/gpui_component/message/struct.MessageContent.html [MessageFooter]: https://docs.rs/gpui-component/latest/gpui_component/message/struct.MessageFooter.html [MessageAlignment]: https://docs.rs/gpui-component/latest/gpui_component/message/enum.MessageAlignment.html [Bubble]: https://docs.rs/gpui-component/latest/gpui_component/bubble/struct.Bubble.html [Attachment]: https://docs.rs/gpui-component/latest/gpui_component/attachment/struct.Attachment.html [MessageScroller]: https://docs.rs/gpui-component/latest/gpui_component/message_scroller/struct.MessageScroller.html --- # Menu Source: /versions/v0.7.1/component/menu License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/menu and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. # PopupMenu The Menu component provides both context menus (right-click menus) and popup menus with comprehensive features including icons, keyboard shortcuts, submenus, separators, checkable items, and custom elements. Built with accessibility and keyboard navigation in mind. ## Import ```rust use gpui_kit::component::{ menu::{PopupMenu, PopupMenuItem, ContextMenuExt, DropdownMenu}, Button }; use gpui_kit::{actions, Action}; ``` ## Usage ### ContextMenu Context menus appear when right-clicking on an element: ```rust use gpui_kit::component::menu::ContextMenuExt; div() .id("my-element") .child("Right click me") .context_menu(|menu, window, cx| { menu.menu("Copy", Box::new(Copy)) .menu("Paste", Box::new(Paste)) .separator() .menu("Delete", Box::new(Delete)) }) ``` ### DropdownMenu Dropdown menus are triggered by buttons or other interactive elements: ```rust use gpui_kit::component::popup_menu::{PopupMenuExt as _, PopupMenuItem}; let view = cx.entity(); Button::new("menu-btn") .label("Open Menu") .dropdown_menu(|menu, window, cx| { menu.menu("New File", Box::new(NewFile)) .menu("Open File", Box::new(OpenFile)) .link("Documentation", "https://gpui-kit.com/") .separator() .item(PopupMenuItem::new("Custom Action") .on_click(window.listener_for(&view, |this, _, window, cx| { // Custom action logic here this. }) ) .separator() .menu("Exit", Box::new(Exit)) }) ``` As you see, the each menu item is associated with an [Action], we choice this design to better integrate with GPUI's action and key binding system, allowing menu items to automatically display keyboard shortcuts when applicable. So, the [Action] is the recommended way to define menu item behaviors. However, if you prefer not to use [Action]s, you can create custom menu items using the `item` method along with [PopupMenuItem]. There have a `on_click` callback to handle the click event directly. ### Anchor Position Control where the dropdown menu appears relative to the trigger: ```rust use gpui_kit::Anchor; Button::new("menu-btn") .label("Options") .dropdown_menu_with_anchor(Anchor::TopRight, |menu, window, cx| { menu.menu("Option 1", Box::new(Action1)) .menu("Option 2", Box::new(Action2)) }) ``` ### Icons Add icons to menu items for better visual clarity: ```rust use gpui_kit::component::IconName; menu.menu_with_icon("Search", IconName::Search, Box::new(Search)) .menu_with_icon("Settings", IconName::Settings, Box::new(OpenSettings)) .separator() .menu_with_icon("Help", IconName::Help, Box::new(ShowHelp)) ``` ### Disabled State Create disabled menu items that cannot be activated: ```rust menu.menu("Available Action", Box::new(Action1)) .menu_with_disabled("Disabled Action", Box::new(Action2), true) .menu_with_icon_and_disabled( "Unavailable", IconName::Lock, Box::new(Action3), true ) ``` ### Check state Create menu items that show a check state: ```rust let is_enabled = true; menu.menu_with_check("Enable Feature", is_enabled, Box::new(ToggleFeature)) .menu_with_check("Show Sidebar", sidebar_visible, Box::new(ToggleSidebar)) ``` By default, the check icon will be shown on the left side of the menu item, if this menu item has an icon, the check icon will replace the icon on the left side. There also have a `check_side` option for you to config the check icon to be shown on the right side: ```rust menu.check_size(Side::Right) .menu_with_check("Enable Feature", is_enabled, Box::new(ToggleFeature)) ``` ### Separators Use separators to group related menu items: ```rust menu.menu("New", Box::new(NewFile)) .menu("Open", Box::new(OpenFile)) .separator() // Groups file operations .menu("Copy", Box::new(Copy)) .menu("Paste", Box::new(Paste)) .separator() // Groups edit operations .menu("Exit", Box::new(Exit)) ``` ### Labels Add non-interactive labels to organize menu sections: ```rust menu.label("File Operations") .menu("New", Box::new(NewFile)) .menu("Open", Box::new(OpenFile)) .separator() .label("Edit Operations") .menu("Copy", Box::new(Copy)) .menu("Paste", Box::new(Paste)) ``` ### Link MenuItem Create menu items that open external links: ```rust menu.link("Documentation", "https://docs.example.com") .link_with_icon( "GitHub", IconName::GitHub, "https://github.com/example/repo" ) .separator() .external_link_icon(false) // Hide external link icons .link("Support", "https://support.example.com") ``` ### Custom Element Create custom menu items with complex content: ```rust use gpui_kit::component::{h_flex, v_flex}; menu.menu_element(Box::new(CustomAction), |window, cx| { v_flex() .child("Custom Element") .child( div() .text_xs() .text_color(cx.theme().muted_foreground) .child("This is a subtitle") ) }) .menu_element_with_icon( IconName::Info, Box::new(InfoAction), |window, cx| { h_flex() .gap_1() .child("Status") .child( div() .text_sm() .text_color(cx.theme().success) .child("✓ Connected") ) } ) ``` ### Keyboard Shortcuts Menu items automatically display keyboard shortcuts if they're bound to actions: ```rust // First define your actions and key bindings actions!(my_app, [Copy, Paste, Cut]); // In your app initialization cx.bind_keys([ KeyBinding::new("ctrl-c", Copy, Some("editor")), KeyBinding::new("ctrl-v", Paste, Some("editor")), KeyBinding::new("ctrl-x", Cut, Some("editor")), ]); // The menu will automatically show shortcuts menu.action_context(focus_handle) // Set context for shortcuts .menu("Copy", Box::new(Copy)) // Will show "Ctrl+C" .menu("Paste", Box::new(Paste)) // Will show "Ctrl+V" .menu("Cut", Box::new(Cut)) // Will show "Ctrl+X" ``` A shortcut is shown where the item's action will be dispatched: the `action_context` when one is set, otherwise the key contexts the menu's trigger sits in. The hints appear on the same frame as the menu items. ### Submenus Create nested menus with submenu support: ```rust menu.submenu("File", window, cx, |submenu, window, cx| { submenu.menu("New", Box::new(NewFile)) .menu("Open", Box::new(OpenFile)) .separator() .menu("Recent", Box::new(ShowRecent)) }) .submenu("Edit", window, cx, |submenu, window, cx| { submenu.menu("Undo", Box::new(Undo)) .menu("Redo", Box::new(Redo)) }) ``` ### Submenus with Icons Add icons to submenu headers: ```rust menu.submenu_with_icon( Some(IconName::Folder.into()), "Project", window, cx, |submenu, window, cx| { submenu.menu("Open Project", Box::new(OpenProject)) .menu("Close Project", Box::new(CloseProject)) } ) ``` ### Scrollable Menus For menus with many items, enable scrolling. Submenus open from a scrollable menu the same way as from any other menu: ```rust Button::new("large-menu") .label("Many Options") .dropdown_menu(|menu, window, cx| { let mut menu = menu .scrollable(true) .max_h(px(300.)) .label("Select an option"); for i in 0..100 { menu = menu.menu( format!("Option {}", i), Box::new(SelectOption(i)) ); } menu }) ``` ### Menu Sizing Control menu dimensions: ```rust menu.min_w(px(200.)) // Minimum width .max_w(px(400.)) // Maximum width .max_h(px(300.)) // Maximum height .scrollable(true) // Enable scrolling when content exceeds max height ``` ### Action Context Set the focus context for handling menu actions: ```rust let focus_handle = cx.focus_handle(); menu.action_context(focus_handle) .menu("Copy", Box::new(Copy)) .menu("Paste", Box::new(Paste)) ``` ## API Reference - [PopupMenu] - [context_menu] - [PopupMenuItem] ## Examples ### File Manager Context Menu ```rust div() .id("file-manager") .child("Right-click for options") .context_menu(|menu, window, cx| { menu.menu_with_icon("Open", IconName::FolderOpen, Box::new(Open)) .separator() .menu_with_icon("Copy", IconName::Copy, Box::new(Copy)) .menu_with_icon("Cut", IconName::Scissors, Box::new(Cut)) .menu_with_icon("Paste", IconName::Clipboard, Box::new(Paste)) .separator() .submenu("New", window, cx, |submenu, window, cx| { submenu.menu_with_icon("File", IconName::File, Box::new(NewFile)) .menu_with_icon("Folder", IconName::Folder, Box::new(NewFolder)) }) .separator() .menu_with_icon("Delete", IconName::Trash, Box::new(Delete)) .separator() .menu("Properties", Box::new(ShowProperties)) }) ``` ### Add MenuItem without action Sometimes you may not like to define an action for a menu item, you just want add a `on_click` handler, in this case, the `item` and [PopupMenuItem] can help you: ```rust use gpui_kit::component::{menu::PopupMenuItem, Button}; Button::new("custom-item-menu") .label("Options") .dropdown_menu(|menu, window, cx| { menu.item( PopupMenuItem::new("Custom Action") .disabled(false) .icon(IconName::Star) .on_click(|window, cx| { // Custom click handler logic println!("Custom Action Clicked!"); }) ) .separator() .menu("Standard Action", Box::new(StandardAction)) }) ``` ### Editor Menu with Shortcuts ```rust // Define actions with keyboard shortcuts actions!(editor, [Save, SaveAs, Find, Replace, ToggleWordWrap]); // Set up key bindings cx.bind_keys([ KeyBinding::new("ctrl-s", Save, Some("editor")), KeyBinding::new("ctrl-shift-s", SaveAs, Some("editor")), KeyBinding::new("ctrl-f", Find, Some("editor")), KeyBinding::new("ctrl-h", Replace, Some("editor")), ]); // Create menu with automatic shortcuts let editor_focus = cx.focus_handle(); Button::new("editor-menu") .label("Edit") .dropdown_menu(|menu, window, cx| { menu.action_context(editor_focus) .menu("Save", Box::new(Save)) // Shows "Ctrl+S" .menu("Save As...", Box::new(SaveAs)) // Shows "Ctrl+Shift+S" .separator() .menu("Find", Box::new(Find)) // Shows "Ctrl+F" .menu("Replace", Box::new(Replace)) // Shows "Ctrl+H" .separator() .menu_with_check("Word Wrap", true, Box::new(ToggleWordWrap)) }) ``` ### Settings Menu with Custom Elements ```rust Button::new("settings") .label("Settings") .dropdown_menu(|menu, window, cx| { menu.label("Display") .menu_element_with_check(dark_mode, Box::new(ToggleDarkMode), |window, cx| { h_flex() .gap_2() .child("Dark Mode") .child( div() .text_xs() .text_color(cx.theme().muted_foreground) .child(if dark_mode { "On" } else { "Off" }) ) }) .separator() .label("Account") .menu_element_with_icon( IconName::User, Box::new(ShowProfile), |window, cx| { v_flex() .child("John Doe") .child( div() .text_xs() .text_color(cx.theme().muted_foreground) .child("john@example.com") ) } ) .separator() .link_with_icon("Help Center", IconName::Help, "https://help.example.com") .menu("Sign Out", Box::new(SignOut)) }) ``` ## Keyboard Shortcuts | Key | Action | | ----------------- | --------------------------------- | | `↑` / `↓` | Navigate menu items | | `←` / `→` | Navigate submenus | | `Enter` / `Space` | Activate menu item | | `Escape` | Close menu | | `Tab` | Close menu and focus next element | ## Best Practices 1. **Group Related Items**: Use separators to group related functionality 2. **Consistent Icons**: Use consistent iconography across your application 3. **Logical Order**: Place most common actions at the top 4. **Keyboard Shortcuts**: Provide shortcuts for frequently used actions 5. **Context Awareness**: Show only relevant items for the current context 6. **Progressive Disclosure**: Use submenus for complex hierarchies 7. **Clear Labels**: Use descriptive, action-oriented labels 8. **Reasonable Limits**: Use scrollable menus for more than 10-15 items [PopupMenu]: https://docs.rs/gpui-component/latest/gpui_component/menu/struct.PopupMenu.html [PopupMenuItem]: https://docs.rs/gpui-component/latest/gpui_component/menu/struct.PopupMenuItem.html [context_menu]: https://docs.rs/gpui-component/latest/gpui_component/menu/trait.ContextMenuExt.html#method.context_menu [Action]: https://docs.rs/gpui/latest/gpui/trait.Action.html --- # Settings Source: /versions/v0.7.1/component/settings License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/component/settings and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. > Since: v0.5.0 The Settings component provides a UI for managing application settings. It includes grouped setting items and pages. We can search by title, description, and custom keywords to filter the settings to display only relevant settings (Like this macOS, iOS Settings). ## Import ```rust use gpui_kit::component::setting::{Settings, SettingPage, SettingGroup, SettingItem, SettingField}; ``` ## Usage ### Build a settings Here we have components that can be used to build a settings page. - [Settings] - The main settings component that holds multiple setting pages. - [SettingPage] - A page of related setting groups. - [SettingGroup] - A group of related setting items based on [GroupBox] style. - [SettingItem] - A single setting item with title, description, and field. - [SettingField] - Provide different field types like Input, Dropdown, Switch, etc. The layout of the settings is like this: ``` Settings SettingPage SettingGroup SettingItem Title Description (optional) SettingField ``` ### Basic Settings ```rust use gpui_kit::component::setting::{Settings, SettingPage, SettingGroup, SettingItem, SettingField}; Settings::new("my-settings") .pages(vec![ SettingPage::new("General") .group( SettingGroup::new() .title("Basic Options") .item( SettingItem::new( "Enable Feature", SettingField::switch( |cx: &App| true, |val: bool, cx: &mut App| { println!("Feature enabled: {}", val); }, ) ) ) ) ]) ``` ### With Multiple Pages When you want default expland a page, you can use `default_open(true)` on the [SettingPage]. ```rust Settings::new("app-settings") .pages(vec![ SettingPage::new("General") .default_open(true) .group(SettingGroup::new().title("Appearance").items(vec![...])), SettingPage::new("Software Update") .group(SettingGroup::new().title("Updates").items(vec![...])), SettingPage::new("About") .group(SettingGroup::new().items(vec![...])), ]) ``` ### Selection While Searching Search keeps the current page selected while it contains matching settings. If it no longer matches, the first matching page is selected. A matching selected group is preserved; otherwise selection falls back to its page. Clearing the search keeps the current page rather than restoring an earlier selection. When no settings match, no page content is shown and the selection is retained for when results return. ### Group Variants ```rust use gpui_kit::component::group_box::GroupBoxVariant; Settings::new("my-settings") .with_group_variant(GroupBoxVariant::Outline) .pages(vec![...]) Settings::new("my-settings") .with_group_variant(GroupBoxVariant::Fill) .pages(vec![...]) ``` A group can override the settings-level variant, for example to present one page's items directly while the other pages keep the global card surface: ```rust SettingGroup::new() .variant(GroupBoxVariant::Normal) .items(vec![...]) ``` ## Setting Page ### Basic Page ```rust SettingPage::new("General") .group(SettingGroup::new().title("Options").items(vec![...])) ``` ### Multiple Groups ```rust SettingPage::new("General") .groups(vec![ SettingGroup::new().title("Appearance").items(vec![...]), SettingGroup::new().title("Font").items(vec![...]), SettingGroup::new().title("Other").items(vec![...]), ]) ``` ### Icon ```rust SettingPage::new("General") .icon(IconName::Settings) .groups(vec![...]) ``` ### Title Suffix Use `title_suffix` to render a custom element after the title in the page header, for example an info icon button that opens the help documentation: ```rust SettingPage::new("General") .title_suffix(|_, _| { Button::new("help") .icon(IconName::Info) .ghost() .xsmall() .on_click(|_, _, cx| cx.open_url("https://example.com/help")) }) .groups(vec![...]) ``` ### Default Open ```rust SettingPage::new("General") .default_open(true) .groups(vec![...]) ``` ### resettable Enable reset functionality for a page: ```rust SettingPage::new("General") .resettable(true) .groups(vec![...]) ``` ## Setting Group ### Basic Group ```rust SettingGroup::new() .title("Appearance") .items(vec![ SettingItem::new(...), SettingItem::new(...), ]) ``` ### Single Item ```rust SettingGroup::new() .title("Font") .item(SettingItem::new(...)) ``` ### Without Title ```rust SettingGroup::new() .items(vec![...]) ``` ### Footer outside the group surface Use `footer` to render supporting content below the group's background or border. It aligns with the group title and renders as small muted text like a description, so plain text is enough; the callback receives the current window and application context for richer content. It scrolls and is filtered with the group; it is not an independently searchable setting or a sidebar entry, and a group still needs at least one item to be shown. ```rust SettingGroup::new() .item(SettingItem::new( "Update source", SettingField::render(|_, _, _| "GitHub Releases"), )) .footer(|_, _| "Changes apply to this device only.") ``` ## Setting Item ### Basic Item ```rust SettingItem::new("Title", SettingField::switch(...)) .description("Description text") ``` ### Custom Item with a render closure You can create a fully custom setting item using `SettingItem::render`: ```rust SettingItem::render(|options, _, _| { h_flex() .w_full() .justify_between() .child("Custom content") .child( Button::new("action") .label("Action") .with_size(options.size) ) .into_any_element() }) ``` ### Vertical Layout By default, setting items use horizontal layout. Use `layout(Axis::Vertical)` for vertical layout: ```rust SettingItem::new( "CLI Path", SettingField::input(...) ) .layout(Axis::Vertical) .description("This item uses vertical layout.") ``` ### With Markdown Description ```rust use gpui_kit::component::text::markdown; SettingItem::new( "Documentation", SettingField::element(...) ) .description(markdown("Rust doc for the `gpui-component` crate.")) ``` ### Disabled Use `disabled(true)` to render a setting item in a non-interactive state. The whole row is dimmed and the built-in field (Switch, Checkbox, Input, Dropdown, NumberInput) is automatically disabled. ```rust SettingItem::new( "Dark Mode", SettingField::switch(...) ) .description("Switch between light and dark themes.") .disabled(true) ``` For [SettingItem::render] custom items, the row is still dimmed automatically, but the renderer is responsible for honoring the disabled state on any interactive controls inside it via `options.disabled`: ```rust SettingItem::render(|options, _, _| { h_flex() .child("Custom content") .child( Button::new("action") .label("Action") .with_size(options.size) .disabled(options.disabled) ) .into_any_element() }) .disabled(true) ``` ### Search Keywords Use `keywords` to attach additional search terms to an item. They are only used for search matching and are never rendered. For example, an item titled "Enable Two-factor auth" can be made searchable via "MFA": ```rust SettingItem::new( "Enable Two-factor auth", SettingField::switch(...) ) .keywords(["MFA", "2FA"]) ``` This is also useful for [SettingItem::render] custom items that have no title or description but should still appear in search results: ```rust SettingItem::render(|options, _, _| { h_flex().child("Custom content").into_any_element() }) .keywords(["Advanced", "Network"]) ``` ## Setting Fields The [SettingField] enum provides different field types for various input needs. ### Switch The switch field represents a `boolean` on/off state. ```rust SettingItem::new( "Dark Mode", SettingField::switch( |cx: &App| cx.theme().mode.is_dark(), |val: bool, cx: &mut App| { // Handle value change }, ) .default_value(false) ) ``` ### Checkbox Like the switch, but uses a checkbox UI. ```rust SettingItem::new( "Auto Switch Theme", SettingField::checkbox( |cx: &App| AppSettings::global(cx).auto_switch_theme, |val: bool, cx: &mut App| { AppSettings::global_mut(cx).auto_switch_theme = val; }, ) .default_value(false) ) ``` ### Input Display a single line text input. ```rust SettingItem::new( "CLI Path", SettingField::input( |cx: &App| AppSettings::global(cx).cli_path.clone(), |val: SharedString, cx: &mut App| { AppSettings::global_mut(cx).cli_path = val; }, ) .default_value("/usr/local/bin/bash".into()) ) .layout(Axis::Vertical) .description("Path to the CLI executable.") ``` ### Dropdown A dropdown with a list of options. ```rust SettingItem::new( "Font Family", SettingField::dropdown( vec![ ("Arial".into(), "Arial".into()), ("Helvetica".into(), "Helvetica".into()), ("Times New Roman".into(), "Times New Roman".into()), ], |cx: &App| AppSettings::global(cx).font_family.clone(), |val: SharedString, cx: &mut App| { AppSettings::global_mut(cx).font_family = val; }, ) .default_value("Arial".into()) ) ``` ### NumberInput ```rust use gpui_kit::component::setting::NumberFieldOptions; SettingItem::new( "Font Size", SettingField::number_input( NumberFieldOptions { min: 8.0, max: 72.0, ..Default::default() }, |cx: &App| AppSettings::global(cx).font_size, |val: f64, cx: &mut App| { AppSettings::global_mut(cx).font_size = val; }, ) .default_value(14.0) ) ``` ### Custom Field by Render Closure The `SettingField::render` method allows you to create a custom field using a closure that returns an element. ```rust SettingItem::new( "GitHub Repository", SettingField::render(|options, _window, _cx| { Button::new("open-url") .outline() .label("Repository...") .with_size(options.size) .on_click(|_, _window, cx| { cx.open_url("https://github.com/example/repo"); }) }) ) ``` ### Custom Field Element You may have a complex field that you want to reuse, you may want split the element into a separate struct to do the complex logic. In this case, the [SettingFieldElement] trait can help you to create a custom field element. ```rust use gpui_kit::component::setting::{SettingFieldElement, RenderOptions}; struct OpenURLSettingField { label: SharedString, url: SharedString, } impl SettingFieldElement for OpenURLSettingField { type Element = Button; fn render_field(&self, options: &RenderOptions, _: &mut Window, _: &mut App) -> Self::Element { let url = self.url.clone(); Button::new("open-url") .outline() .label(self.label.clone()) .with_size(options.size) .on_click(move |_, _window, cx| { cx.open_url(url.as_str()); }) } } ``` Then use it in the setting item: ```rust SettingItem::new( "GitHub Repository", SettingField::element(OpenURLSettingField { label: "Repository...".into(), url: "https://github.com/longbridge/gpui-kit".into(), }) ) ``` ## API Reference - [Settings] - [SettingPage] - [SettingGroup] - [SettingItem] - [SettingField] - [NumberFieldOptions] ### Sizing Implements [Sizable] trait: - `xsmall()` - Extra small size - `small()` - Small size - `medium()` - Medium size (default) - `large()` - Large size - `with_size(Size)` - Set specific size ## Examples ### Complete Settings Example ```rust use gpui_kit::{App, SharedString}; use gpui_kit::component::{ Settings, SettingPage, SettingGroup, SettingItem, SettingField, setting::NumberFieldOptions, group_box::GroupBoxVariant, Size, }; Settings::new("app-settings") .with_size(Size::Medium) .with_group_variant(GroupBoxVariant::Outline) .pages(vec![ SettingPage::new("General") .resettable(true) .default_open(true) .groups(vec![ SettingGroup::new() .title("Appearance") .items(vec![ SettingItem::new( "Dark Mode", SettingField::switch( |cx: &App| cx.theme().mode.is_dark(), |val: bool, cx: &mut App| { // Handle theme change }, ) ) .description("Switch between light and dark themes."), ]), SettingGroup::new() .title("Font") .items(vec![ SettingItem::new( "Font Family", SettingField::dropdown( vec![ ("Arial".into(), "Arial".into()), ("Helvetica".into(), "Helvetica".into()), ], |cx: &App| "Arial".into(), |val: SharedString, cx: &mut App| { // Handle font change }, ) ), SettingItem::new( "Font Size", SettingField::number_input( NumberFieldOptions { min: 8.0, max: 72.0, ..Default::default() }, |cx: &App| 14.0, |val: f64, cx: &mut App| { // Handle size change }, ) ), ]), ]), SettingPage::new("Software Update") .resettable(true) .group( SettingGroup::new() .title("Updates") .items(vec![ SettingItem::new( "Auto Update", SettingField::switch( |cx: &App| true, |val: bool, cx: &mut App| { // Handle auto update }, ) ) .description("Automatically download and install updates."), ]) ), ]) ``` [Settings]: https://docs.rs/gpui-component/latest/gpui_component/setting/struct.Settings.html [SettingPage]: https://docs.rs/gpui-component/latest/gpui_component/setting/struct.SettingPage.html [SettingGroup]: https://docs.rs/gpui-component/latest/gpui_component/setting/struct.SettingGroup.html [SettingItem]: https://docs.rs/gpui-component/latest/gpui_component/setting/struct.SettingItem.html [SettingField]: https://docs.rs/gpui-component/latest/gpui_component/setting/enum.SettingField.html [SettingFieldElement]: https://docs.rs/gpui-component/latest/gpui_component/setting/trait.SettingFieldElement.html [NumberFieldOptions]: https://docs.rs/gpui-component/latest/gpui_component/setting/struct.NumberFieldOptions.html [GroupBox]: ./group-box.md [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html --- # Examples Source: /versions/v0.7.1/shell/examples License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/shell/examples and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Three examples ship with the repository, and together they cover a standalone application, a script backed by host state, and motion whose frames never enter JavaScript. | | Runs as | Shows | | --- | --- | --- | | [Todo list](#the-todo-list) | A standalone application | The whole script surface: retained input, a dialog, a toast, gated storage, assets, types | | [Workspace](#the-workspace) | A standalone application | A dockable layout: panels that survive a restart, and every piece of its chrome drawn by script | | [Quote board](#the-quote-board) | A panel inside the gallery | The host half: HostModule registrations, one entity read from two languages, live cost counters | | [Native motion](#native-motion) | A separate gallery script View | Pixel target transitions and springs retained and sampled by GPUI | ## A complete application The examples here are each built to show one thing. For a whole product in one repository — OAuth, a live WebSocket quote feed, a virtualized watchlist, retained nested Views for the price chart, and its own Rust host binary — see [**longbridge/longbridge-lite**](https://github.com/longbridge/longbridge-lite). It is a read-only Longbridge desktop client of a few thousand lines of JavaScript, and it is the largest thing written against this runtime. ## The todo list ```bash cargo run -p gpui-shell -- examples/js_todolist ``` `examples/js_todolist/` exists to exercise the whole runtime rather than to be minimal — if something in `gpui-shell` is broken, this is where it shows first. ```text main.js the View: state, filtering, every handler ui.js the presentation layer, exported as functions storage.js persistence, and what to do when it is not granted confirm.js the confirmation dialog, a View of its own icons/ four SVGs, resolved against the application root gpui-kit.d.ts generated; jsconfig.json and types.d.ts wire up typing ``` Four things in it are worth copying. **`ui.js` is a component library made of functions.** It exports `label`, `muted`, `title`, `button`, `iconButton`, `checkbox`, `field`, `row`, `surface`, `rule` and `emptyState`, and `main.js` reads like it is using a component library: ```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` passes the current `cx` to these helpers, which read tokens directly through `cx.theme()`. That costs nothing, because [a fresh description is exactly what a function call produces](/versions/v0.7.1/shell/elements). It is also the answer to "the base layer ships no styled widgets" — you write the styled layer once, in your own file, and stop repeating it. **Storage absorbs a refusal instead of checking for permission.** `store` throws when the host did not grant it, and that is a fact about the host rather than an error in the application: ```js export function load() { try { const saved = store.get(KEY); return Array.isArray(saved) ? saved : []; } catch (error) { console.warn(`todolist: storage unavailable, starting empty (${error.message})`); return []; } } ``` `save()` returns whether the write landed, and the footer says so on screen — "Not saved — this host did not grant storage, so the list lasts for this run only". Absorb the refusal at the boundary, then tell the user the truth. **A dialog is a function, not an element.** `confirm.js` default-exports a function that returns the content function; `main.js` opens it with `window.open_dialog(confirmClear(count, onConfirm))`. The count and the callback are closed over rather than handed across. See [Overlays](/versions/v0.7.1/shell/overlays). **Types are set up, and it is three files.** `jsconfig.json` turns on `checkJs`, `gpui-kit.d.ts` is generated by `gpui-shell types`, and `types.d.ts` holds the application's own shapes — `Todo`, `Filter`. Editor completion and `checkJs` errors work from there with no build step. ## The workspace ```bash cargo run -p gpui-shell -- examples/js_dock ``` `examples/js_dock/` is a dockable workspace — a file list on the left, documents in the center, and a layout that comes back the way you left it. ```text main.js the workspace: panels, the dock, and persistence ui.js the chrome: tabs, the dock frame, the drop hint ``` Three things in it are the point. **Base draws no chrome, so all of it is in `ui.js`.** The tab bar, the dock's title strip, the collapse control, the resize handle and the drop hint are ordinary elements written with the ordinary style surface. An area with none of them still docks, drags, resizes and persists; it simply paints nothing but the panels. **A tab carries commands, not handlers.** A chrome description is cached until its native state changes, so a script event handler inside it would have no sound lifetime. `select_tab(group, tab.index)` and `close_panel(group, tab.id)` carry no script value at all — they name a container and what to ask it. **A panel is a View with two extra methods.** `Document.serialize()` returns its caption and its edit count; `deserialize(data)` takes them back after a restart. Everything else about the panel — where it sits, whether it is displayed — is the layout's business and never reaches the script. See [Dock and Panels](/versions/v0.7.1/shell/dock) for the whole surface. ## The quote board ```bash cargo run -- shell ``` The gallery's Shell story runs two panels side by side: the left one drawn by `shell_story.rs` in Rust, the right one by `crates/story/js/quotes/main.js` in JavaScript, reading the same data. The script owns no state at all. The board is a Rust `Entity`, imported from the [HostModule](/versions/v0.7.1/shell/host-module) the story registered before the runtime started: ```text import { quotes, ticks, watch, watch_all } from "market"; ``` Theme values come from the call-scoped `cx.theme()` Snapshot, not a second HostModule. Because both panels read one entity, any disagreement between them is visible immediately — which is what makes this a test rather than a demo. Editing `main.js` changes the right-hand panel with no `cargo build` in between; the story has a "Reload script" button next to the panel. Underneath sits the counter readout this documentation quotes throughout: script runs a second against frames a second, with a feed selector to move one without the other. That is [the performance claim](/versions/v0.7.1/shell#performance-the-script-is-not-in-the-frame) made visible in a running window. ## Native motion `crates/story/js/motion/main.js` is intentionally a separate `ScriptView` from the quote benchmark, so animation activity cannot contaminate the render-frequency measurement. It lets you switch between `.transition(...)` and `.spring(...)`, then retargets opacity and pixel-valued width, height, left, and top. The script runs once to publish the new target. GPUI schedules and samples every following animation frame natively, with no JavaScript re-entry. The example uses only numeric pixel targets — no `rem`, percentages, or `auto` — and stable ids so retained channels survive description rebuilds. ## Where to start Copy `examples/js_todolist` into a directory of your own and run it — it is a complete application with types already wired. Strip `main.js` back to a `View` with an `init` and a `render`, keep `ui.js`, and build up from there. For a host, `crates/story/src/stories/shell_story.rs` is a working reference for the other side: it builds a runtime, exports HostModule registrations, mounts a `ScriptView`, and reloads it on demand. [Hosting](/versions/v0.7.1/shell/hosting) walks through the same calls. --- # API Reference Source: /versions/v0.7.1/shell/api License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/shell/api and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. An inventory of the script surface: what exists, and which module it comes from. The other pages explain why each thing works the way it does — this one is for looking a name up. The authority is not this page. The runtime generates `gpui-kit.d.ts` for its own version and refreshes it beside your source when the application loads. That refresh is best-effort; `gpui-shell types ` performs the same write and reports a failure. The generated header names the `gpui-shell` version and includes that application's HostModule registrations. Keep the file ignored, and put `// @ts-check` at the top of a script to have an editor check against it. The manifest's Git dependencies are not listed here either: they are linked into `node_modules` by the same refresh, and their names, signatures and documentation come from the packages themselves. See [Dependencies](/versions/v0.7.1/shell/dependencies). ## The modules Each built-in module names the public Rust layer it exposes, so an import says which layer a script depends on. The `gpui-kit` module also carries the shell bridge needed to use GPUI from JavaScript: Views, retained entities, scheduling and shared types. `gpui` is a compatibility alias for this module and exposes the same bindings, including `div`. ```js import { View, div } from "gpui-kit"; import { div as gpuiDiv } from "gpui"; import { Button, v_flex } from "gpui-base"; import { fps_monitor } from "gpui-fps"; ``` | Module | Provides | | ------------ | -------------------------------------------------------------------------------------- | | `gpui-kit` | GPUI's own elements, plus what this runtime adds: Views, the style surface, scheduling | | `gpui-base` | Layout helpers, components and the theme | | `gpui-shell` | Type-only concepts owned by the shell bridge; it has no run-time exports | | `gpui-fps` | The performance overlay | Two names are never imported, for two different reasons. `window` is a real global: nothing hands it to you, it is simply in scope. `cx` is the opposite — it is never a global, and only ever arrives as an argument: `render(cx)`, `init(props, cx)`, the second argument of every handler, the parameter of a `cx.spawn` body. The standard-runtime modules — `fs/promises`, `path`, `crypto`, `process`, `net`, `websocket` and the rest — are gated by the host's grant and are documented in [Capabilities](/versions/v0.7.1/shell/capabilities). API shape follows the Rust original: a method on `App` is a method on `cx`, a method on `Window` is on the `window` global, an associated constructor is `Type.new(...)`, and a free function stays lowercase. Names with no direct GPUI or Base original belong to the module for the layer that implements them. Type-only names appear in these tables too, but are never run-time values. ## The `gpui-kit` module ### Elements | Name | What it is | | ----------------- | ---------------------------------------------------------------------------------------------------- | | `Element` | A render-pass-owned description built by chaining methods | | `div()` | An element with no layout of its own | | `svg(path)` | A vector image from the application root, tinted by the surrounding text color | | `image(path)` | A full-color image from the application root, colors preserved | | `list(…)` | GPUI's lazy list: rows of any height, measured as they are drawn | | `uniform_list(…)` | GPUI's uniform list: one row measured, every row placed by it | | `PathBuilder` | The GPUI path-builder type and its factory: `fill()` and `stroke(width)` each return a `PathBuilder` | | `Background` | `solid`, `stop`, `linear_gradient`, `pattern_slash`, `checkerboard` | `PathBuilder.fill()` and `.stroke(width)` return a handle that chains `move_to`, `line_to`, `curve_to`, `cubic_bezier_to`, `arc_to`, `add_polygon`, `close` and `dash_array`, and ends in `build()`. Paint the result with `window.paint_path(path, background)` — the one element constructor reached through an object, because the thing it mirrors is a method on the window. `list` and `uniform_list` are GPUI's own lazy lists and take `(id, item_count, get_key, render)`, the shape of `gpui-base`'s `v_virtual_list` without its `item_sizes`: no sizes, because GPUI measures the items itself. `uniform_list` measures one row and places every row by it, so `render(range, cx)` returns one element per item in the range as a virtual list's does. `list` measures each item it draws and keeps the sizes, so `render(index, cx)` returns one element for one item, and rows — or panels — of unequal height need not say how tall they are. Both draw only what is on screen plus a short band past the fold, scroll themselves, and pair with a `Scrollbar` by name; neither takes a `VirtualListScrollHandle`. A string is an element too, exactly as `&str` implements `IntoElement` in GPUI: `.child("hello")` is how text is written, and the style comes from the element holding it. ### Views | Name | What it is | | ----------- | ------------------------------------------------------------------------- | | `View` | The base class of every View; subclass it and default-export the subclass | | `ViewClass` | A concrete `View` subclass, as `cx.new` takes it | | `Entity` | Retained ownership of one nested View: `set_props(props)`, `release()` | A subclass defines `init?(props, cx)`, which runs once, and `render(cx)`, which returns one `Element`, `Entity` or string and runs when the View is invalidated. An optional `update(props)` runs when a parent changes a nested View's props. ### Scheduling | Name | What it is | | ------- | ----------------------------------------------------------- | | `Task` | A running task: `cancel()`, `is_done()` | | `Timer` | `after(ms, handler, opts?)` and `every(ms, handler, opts?)` | ### Focus | Name | What it is | | ------------- | ----------------------------------------------------------- | | `FocusHandle` | A focus target the script owns; [its members](#focushandle) | ### Shared types | Name | What it is | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | | `Length` | A number (pixels), `"12px"`, `"1.5rem"`, `"50%"` or `"auto"` | | `DefiniteLength` | The same without `"auto"` | | `AbsoluteLength` | Pixels or rems only | | `Axis` | `"horizontal"` or `"vertical"`, mirroring `gpui_kit::Axis` | | `Color` | A `gpui-base` `ColorToken`, or a `#rgb` / `#rrggbb` / `#rrggbbaa` literal | | `Role` | An accessibility role, mirroring `gpui_kit::Role` in snake_case | | `Anchor` | Which corner of an anchored surface is pinned to its trigger | | `MouseButton` | `"left"`, `"right"` or `"middle"` | | `ClickEvent` | `click_count`, `modifiers` | | `MouseMoveEvent` | `position`, `local_position`, `bounds`, `modifiers` | | `MouseButtonEvent` | `button`, `click_count`, `position`, `modifiers`, and the local geometry once painted | | `ScrollWheelEvent` | `delta` in pixels, `delta_lines` when the device reported lines, `touch_phase` | | `KeyEvent` | `keystroke` (the whole chord; the platform modifier is spelled `cmd` on every platform), `key`, `key_char`, `modifiers`, `is_held` | | `ActionEvent` | `action` — the script's own name for it | | `KeyBinding` | One entry of `cx.bind_keys`: `keystroke`, `action`, optional `context` | | `Size` | `width`, `height` | | `Modifiers` | `shift`, `control`, `alt`, `platform` | | `Point` | `x`, `y` | | `Path` | Immutable native geometry produced by `PathBuilder.build()` | | `Background` | A reusable native background from `Background.solid(...)` or another factory: `opacity(factor)`, `color_space(space)` | | `BackgroundStop` | One gradient stop, from `Background.stop(color, percentage)` | #### `FocusHandle` Created with `cx.focus_handle()`, handed to an element with `track_focus(handle)`, and released with `release()`. | Method | What it does | | ----------------------- | ------------------------------------------------- | | `focus(): void` | Moves the keyboard onto the element tracking it | | `is_focused(): boolean` | Whether that element currently has it | | `release(): boolean` | Releases it and reports whether it was still live | ## The `gpui-shell` module These are type-only concepts introduced by the JavaScript bridge itself. Import them only for type checking; the module has no run-time values. | Name | What it is | | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `LengthString` | The string forms accepted by the shell's length bridge | | `PathCoordinate` | Pixels, or a percentage of the painted element's bounds | | `Props` | The property bag carried across the JavaScript View bridge | | `ElementBounds` | A shell event `Point` with `width` and `height` | | `ScopePhase` | `"render"`, `"event"`, `"task"`, `"layout"` or `"none"` | | `TaskOptions` | `{ owner?: View \| null }` — the View the task is cancelled with. Defaults to the running View; `null` outlives every View | | `DialogOptions` | `{ escape_dismissable?: boolean, backdrop_dismissable?: boolean }`, both `true` by default | | `ToastOptions` | `{ title: string, description?: string, level?: "info" \| "success" \| "warning" \| "error", timeout?: number \| null, id?: string }`. `level` defaults to `"info"`; `timeout` to five seconds, and `null` keeps it until dismissed | | `MotionProperty` | `"opacity"`, `"width"`, `"height"`, `"left"`, `"top"` | | `MotionEasing` | `"linear"`, `"ease-in"`, `"ease-out"`, `"ease-in-out"` | | `TransitionPolicy` | `duration`, `delay`, `easing` | | `SpringPolicy` | `response`, `damping`, `epsilon` | `ScopePhase` describes which shell call owns the current `Context`. It is unrelated to GPUI's `DispatchPhase`, which controls capture and bubble ordering during event dispatch. ## The `cx` context There are two context lifetimes with the same methods. `Context`, received by `render` and event handlers, belongs to that host call; retaining it beyond the call, including across an `await`, reports a stale-context error. `AsyncContext`, described below, is the flavour intended to survive an `await`. | Member | What it is | | -------------------------- | -------------------------------------------------------------------------------------------------- | | `notify()` | Requests a re-render; throws during `render`, because notifying yourself while rendering is a loop | | `bind_keys(bindings)` | Installs key bindings and answers how many; `App::bind_keys` | | `stop_propagation()` | Keeps this event from reaching the handlers above; `App::stop_propagation` | | `propagate()` | Undoes that within the same dispatch; `App::propagate` | | `phase()` | Which `ScopePhase` the call is in | | `theme()` | The current `gpui_kit::base::Theme` semantic token projection | | `open_url(url)` | Hands an absolute `http`/`https` URL to the system handler | | `read_from_clipboard()` | The clipboard's text, or `undefined` when it holds none | | `write_to_clipboard(text)` | Replaces the clipboard's text | | `focus_handle()` | A new `FocusHandle`; belongs in `init` or an event handler, never in `render` | | `new(Class, props?)` | Creates a retained nested View and answers the `Entity` that owns it | | `spawn(body, opts?)` | Runs `body(cx)` and adopts the promise it returns, so a rejection is reported | | `sleep(ms?)` | Resolves after `ms` on GPUI's foreground executor | | `timer` | The `Timer`: `after` and `every` | Several of these name the GPUI method they mirror: `open_url` is `App::open_url`, `read_from_clipboard` and `write_to_clipboard` are `App::read_from_clipboard` and `App::write_to_clipboard`, `focus_handle` is `App::focus_handle` (GPUI has no `FocusHandle::new`, and neither does this), `new` is `AppContext::new`, and `spawn` is `App::spawn`. ### `AsyncContext` `AsyncContext` extends `Context` and adds no members. The difference is lifetime, not surface: an ordinary `Context` speaks for one host call and reports clearly once that call has returned, while an `AsyncContext` names no call at all — it resolves whichever is running when a member is used, and refuses only when none is. It is the mirror of GPUI's `AsyncApp`. Three places hand one out: `init`, the body of `cx.spawn`, and the callbacks of `cx.timer`. Those are the three whose job is to set up or continue work that outlives the call it was started from. ## The `window` global The global has the `Window` type exported by `gpui-kit`. Nothing hands it to you and there is nothing to import at the call site. Every call reads the host call that is running now and throws outside one, so there is no handle to hold and nothing that can go stale. An overlay belongs to the window rather than to the View that opened it, which is why these are here and not on `Context`. | Member | What it is | | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | `open_dialog(content, options?)` | Opens a dialog and answers the stack's new depth | | `close_dialog()` | Closes the topmost dialog, and answers whether it found one | | `close_all_dialogs()` | Closes every dialog, and answers how many | | `has_active_dialog()` | Whether any dialog is open; legal from `render`, unlike the rest | | `open_sheet(content)` | Opens the sheet on the right, replacing whatever was there | | `open_sheet_at(placement, content)` | The same, anchored at the `gpui-base` `Placement` you name | | `close_sheet()` | Closes the sheet, and answers whether one was open | | `has_active_sheet()` | Whether the sheet is open; legal from `render` | | `push_toast(options)` | Posts a toast and answers its id | | `remove_toast(id)` | Retracts one toast, and answers whether it was still showing | | `clear_toasts()` | Retracts every toast, and answers how many | | `paint_path(path, background)` | Paints immutable geometry with a native background; `Window::paint_path` | | `dispatch_action(action)` | Dispatches an action down this window's focus path; `Window::dispatch_action` | | `rem_size()` / `line_height()` | The window's type metrics, in pixels | | `viewport_size()` / `bounds()` | The drawable area, and where the window sits on screen | | `mouse_position()` | Where the pointer is, in window coordinates | | `appearance()` | `"light"` or `"dark"` | | `is_window_active()` / `is_fullscreen()` / `is_maximized()` | The platform window's state | | `set_rem_size(size)` | Rescales everything expressed in rems | | `refresh()` | Redraws every View in the window | | `focus_next()` / `focus_prev()` | Moves the keyboard one tab stop | | `activate_window()` / `minimize_window()` / `zoom_window()` / `toggle_fullscreen()` | Platform window controls | | `localStorage` | Web Storage backed by a file the host placed; survives a restart | | `sessionStorage` | Web Storage held in memory; goes with the process | The measurements — everything from `rem_size()` down to `is_maximized()` — are legal from `render`, because a View that sizes itself from the window has to ask during the pass that draws it. Everything that _changes_ the window is refused there, for the reason `cx.notify()` is: a frame that changes the window it is drawing into is a frame arguing with itself. `open_dialog`, `open_sheet` and `open_sheet_at` take a **function returning an element**, not an element: a dialog outlives the call that opened it, and the function runs again whenever it redraws. Everything here except the two `has_active_*` queries and `paint_path` is illegal from `render`. See [Overlays](/versions/v0.7.1/shell/overlays). ### Storage The [Web Storage API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API), unchanged. Both stores are also bare globals — `localStorage.getItem(k)` and `window.localStorage.getItem(k)` are the same call — because that is true in a browser too. | Member | What it is | | --------------------- | ---------------------------------------------- | | `length` | How many keys are stored | | `key(index)` | The key at that position, or `null` | | `getItem(key)` | The value, or `null` when the key is unset | | `setItem(key, value)` | Stores it, converting the value to a string | | `removeItem(key)` | Forgets one key | | `clear()` | Forgets all of them | | `flush()` | Resolves once the writes have reached the disk | Values are strings, so structure goes through `JSON.stringify` and `JSON.parse` exactly as it would on the web. `flush()` is the one addition: a browser never needs it, because its storage is synchronous all the way down. `localStorage` is capability-gated and throws when the host did not grant it; `sessionStorage` never is, because nothing it holds leaves the process. See [Capabilities](/versions/v0.7.1/shell/capabilities#storage). ## The `gpui-base` module The components here own behavior, focus and what a screen reader hears, and draw next to nothing themselves. The picture is the script's, written with the [style surface](/versions/v0.7.1/shell/styling). Each name links to the component's own page in the [gpui-base documentation](/versions/v0.7.1/base), which is where its full Rust surface and its behavior are described. ### Layout | Name | What it is | | ------------------------------------------------------ | ----------------------------------------------------------------------------- | | `h_flex()` | A row | | `v_flex()` | A column | | [`h_resizable(id)`](/versions/v0.7.1/base/primitives/resizable) | A row of panes with draggable dividers; sizes live in the window under the id | | [`v_resizable(id)`](/versions/v0.7.1/base/primitives/resizable) | The same, stacked | | [`resizable_panel()`](/versions/v0.7.1/base/primitives/resizable) | One pane of a resizable group, and legal nowhere else | ### Controls | Name | What it is | | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | [`Button`](/versions/v0.7.1/base/primitives/button) | Activation, focus, disabled and selected state | | [`Link`](/versions/v0.7.1/base/primitives/link) | An external HTTP(S) resource opened through the system browser | | [`Checkbox`](/versions/v0.7.1/base/primitives/checkbox) | A controlled toggle; draw the indicator yourself | | [`Switch`](/versions/v0.7.1/base/primitives/switch) | A controlled switch | | [`Radio`](/versions/v0.7.1/base/primitives/radio) | One option in a group; reports `true` only, never a deselection | | [`Toggle`](/versions/v0.7.1/base/primitives/toggle) | A button that stays down | | [`RadioGroup`](/versions/v0.7.1/base/primitives/radio-group) | A set of radios announced as one group; holds no selection | | [`ToggleGroup`](/versions/v0.7.1/base/primitives/toggle-group) | A set of toggles announced as a toolbar | | [`Tabs`](/versions/v0.7.1/base/primitives/tabs) | A tab list that holds no selection of its own | | [`Tab`](/versions/v0.7.1/base/primitives/tabs) | One tab: `selected(...)` in, `on_click(...)` out | | [`Progress`](/versions/v0.7.1/base/primitives/progress) | The announcement, not the bar; `Progress.new(...)` alone draws nothing | | [`ProgressTrack`](/versions/v0.7.1/base/primitives/progress) | The groove: a plain element you size and color | | [`ProgressIndicator`](/versions/v0.7.1/base/primitives/progress) | The filled part; set its width from the percentage you announced | | [`Avatar`](/versions/v0.7.1/base/primitives/avatar) | Renders its `image` slot, or its `fallback` when there is none; no circle, size or background of its own | | [`AvatarImage`](/versions/v0.7.1/base/primitives/avatar) | The image slot: `AvatarImage.new(path)`, and legal nowhere else | | [`AvatarFallback`](/versions/v0.7.1/base/primitives/avatar) | The fallback slot: an ordinary box holding initials, a shape or an `svg` | | [`Pagination`](/versions/v0.7.1/base/primitives/pagination) | A navigation landmark carrying the announced label; the page buttons are yours | | `pagination_items(current, total, visible?)` | Which page numbers to draw and where the gaps fall. `visible` defaults to 7, floors at 5; one page or fewer answers nothing | | [`Accordion`](/versions/v0.7.1/base/primitives/accordion) | A group holding items | | [`AccordionItem`](/versions/v0.7.1/base/primitives/accordion) | One item: `open(...)` in, the trigger's `on_change(...)` out; it passes its `open` down to both halves | | [`AccordionHeader`](/versions/v0.7.1/base/primitives/accordion) | The heading: `AccordionHeader.new(trigger)`, with `aria_level(n)` announcing its level (default 3) | | [`AccordionPanel`](/versions/v0.7.1/base/primitives/accordion) | The revealed region. Out of the tree while shut, unless `keep_mounted(true)` | | [`AccordionTrigger`](/versions/v0.7.1/base/primitives/accordion) | The button: announces the expanded state, and `on_change` asks for the other one | | [`CalendarState`](/versions/v0.7.1/base/primitives/calendar) | Retained calendar state: the month grid, the month being shown, and the chosen date | | [`SliderState`](/versions/v0.7.1/base/primitives/slider) | Retained slider state, and where a drag writes | | [`Slider`](/versions/v0.7.1/base/primitives/slider) | The root: announces the value and owns the release | | [`SliderTrack`](/versions/v0.7.1/base/primitives/slider) | The press and drag surface | | [`SliderIndicator`](/versions/v0.7.1/base/primitives/slider) | The groove, and the box every pointer position is measured against | | [`SliderThumb`](/versions/v0.7.1/base/primitives/slider) | The knob; the shell gives it a place, you give it a look | All four slider parts take the same `SliderState`, and all four are needed — a slider with no `SliderIndicator` cannot be moved at all. ### Text editing | Name | What it is | | --------------------------------------------------- | ------------------------------------------------------------------------------- | | [`InputState`](/versions/v0.7.1/base/primitives/input) | Retained text state: `InputState.new({ placeholder, value })` | | [`Input`](/versions/v0.7.1/base/primitives/input) | The frame around retained text state | | [`NumberInput`](/versions/v0.7.1/base/primitives/number-input) | A spinbutton over the same `InputState`, with three slots that all carry weight | | [`TextareaState`](/versions/v0.7.1/base/primitives/textarea) | Retained multi-line text state; `rows` is an option | | [`Textarea`](/versions/v0.7.1/base/primitives/textarea) | The frame around retained multi-line state | | [`OtpState`](/versions/v0.7.1/base/primitives/otp-input) | Retained one-time-code state; the length is fixed when it is created | | [`OtpInput`](/versions/v0.7.1/base/primitives/otp-input) | A fixed-length code whose cells the shell draws and the script styles | There is no numeric state type: an `InputState` becomes a number state by being given `set_step`, `set_min` and `set_max`. ### Containers and overlays | Name | What it is | | -------------------------------------------------- | --------------------------------------------------------------------------------------- | | [`Collapsible`](/versions/v0.7.1/base/primitives/collapsible) | Renders its `content` slot only while `open`; no role, chevron or trigger | | [`Popover`](/versions/v0.7.1/base/primitives/popover) | A surface anchored to a trigger and opened by a press | | [`HoverCard`](/versions/v0.7.1/base/primitives/hover-card) | The same, opened by resting the pointer, with its own open state | | [`Popup`](/versions/v0.7.1/base/primitives/popup) | The bare anchored surface: `Popup.new(id, trigger)`, opened by filling `content` | | [`Select`](/versions/v0.7.1/base/primitives/select) | A combobox root: the role, the announced open state, the keyboard — none of the picture | | [`Combobox`](/versions/v0.7.1/base/primitives/combobox) | The same root, announced as a combobox whose trigger is an editable field | | [`DatePicker`](/versions/v0.7.1/base/primitives/date-picker) | A date-picker root: `DatePicker.new(id, focus_handle)`; it holds no date | Two gaps are worth knowing before you build on these: arrow-key navigation of an open `Select` or `Combobox` list is yours to wire (the pieces are there — see below), and Enter and Escape do not reach a `DatePicker`. Both are described where they bite, in the declarations for each type. ### Tables and lists | Name | What it is | | ---------------------------------------------------- | ---------------------------------------------------------------------- | | [`Table`](/versions/v0.7.1/base/primitives/table) | A semantic table root, composed the way HTML composes one | | [`TableHeader`](/versions/v0.7.1/base/primitives/table) | The header row group | | [`TableBody`](/versions/v0.7.1/base/primitives/table) | The body row group | | [`TableRow`](/versions/v0.7.1/base/primitives/table) | One row: `.new(id, row_index)`, one-based | | [`TableHead`](/versions/v0.7.1/base/primitives/table) | One column header: `.new(id, column_index)`, one-based | | [`TableCell`](/versions/v0.7.1/base/primitives/table) | One data cell: `.new(id, column_index)`, one-based | | [`TableCaption`](/versions/v0.7.1/base/primitives/table) | The visual slot a caption belongs in; it carries no caption role | | [`v_virtual_list(…)`](/versions/v0.7.1/base/virtual-list) | A vertical list that describes only what is on screen | | [`h_virtual_list(…)`](/versions/v0.7.1/base/virtual-list) | The same along the other axis; `item_sizes` are widths | | [`VirtualListScrollHandle`](/versions/v0.7.1/base/virtual-list) | A virtual list's scroll position, kept across frames | | [`Scrollbar`](/versions/v0.7.1/base/primitives/scrollbar) | `new(id)`, `horizontal(id)`, `vertical(id)` — a bar you place yourself | Both virtual lists take `(id, item_count, item_sizes, get_key, render)`. `render(range, cx)` is the only callback in this API that the host calls _during_ a frame, which is why handlers, retained state and `cx.notify()` are all refused inside it. ### Dock | Name | What it is | | -------------------------------------- | -------------------------------------------------------------------------------------------- | | `DockArea.new(id, options?)` | A dockable layout, retained: `options` is `{ version?: number }` | | `DockArea.register_panel(name, Class)` | Teaches the runtime to rebuild `name`'s panel from `Class`; answers with the namespaced name | | `dock_area(area)` | Draws one, and carries the six chrome handlers | | `dock_content()` | Where a dock's own panels go inside the chrome drawn around them | The area's methods are `add_panel(view, options)`, `remove_panel(id)`, `panels()`, `dump()`, `load(state)`, `has_dock`, `is_dock_open`, `toggle_dock`, `remove_dock`, `dock_size`, `set_dock_size`, `set_dock_collapsible`, `is_locked`, `set_locked`, `is_zoomed`, `zoom_out`, `on("layout_changed", handler)` and `release()`. **Every edit is applied once the call that made it has returned**, in the order the calls were made — a panel's body comes from `cx.new(Class)`, which is still being constructed — so `panels()` and `dump()` read the layout as it was before this turn's edits. See [Dock and Panels](/versions/v0.7.1/shell/dock). ### Retained handles Each is created once — in `init` or an event handler, never in `render` — and every one of them has `release(): boolean`, which returns whether it was still live. Using a handle after releasing it throws. `on(...)` replaces the handler for that event rather than adding a second one, and answers whether there was one before. #### `InputState` From `InputState.new(options?)`, where `options` is `{ placeholder?: string, value?: string }`. | Method | What it does | | -------------------------------------- | ----------------------------------------------------------------------------------------- | | `value(): string` | The current text | | `set_value(next: string): void` | Replaces it | | `on(event, handler): boolean` | `event` is `"change"`, `"submit"`, `"focus"` or `"blur"`; the handler takes `(event, cx)` | | `set_step(step: number \| null): void` | The `NumberInput` step, or `null` for none | | `set_min(min: number \| null): void` | The numeric floor, or `null` | | `set_max(max: number \| null): void` | The numeric ceiling, or `null` | | `set_masked(masked: boolean): void` | Whether the text is drawn as a password | | `set_loading(loading: boolean): void` | Whether the field shows its loading state | #### `TextareaState` From `TextareaState.new(options?)`, where `options` is `{ placeholder?: string, value?: string, rows?: number }`. | Method | What it does | | --------------------------------------------------------- | -------------------------------------------------------------------- | | `value(): string` | The current text | | `set_value(next: string): void` | Replaces it | | `on(event, handler): boolean` | `"change"`, `"submit"`, `"focus"` or `"blur"`, handler `(event, cx)` | | `set_rows(rows: number): void` | The visible row count | | `set_auto_grow(min_rows: number, max_rows: number): void` | Grows with its content between the two | | `set_soft_wrap(wrap: boolean): void` | Whether long lines wrap | #### `SliderState` From `SliderState.new(options?)`, where `options` is `{ min?, max?, step?, scale?: "linear" | "logarithmic", value?: SliderValue }`. The defaults are `0..100` in steps of `1`, starting at `min`. A `"logarithmic"` scale needs a `min` above zero. | Method | What it does | | ------------------------------------ | -------------------------------------------------------------------------- | | `value(): SliderValue` | The current value: a number, or `[start, end]` for a range | | `set_value(next: SliderValue): void` | Replaces it | | `min_value(): number` | The floor it was built with | | `max_value(): number` | The ceiling | | `step_value(): number` | The step | | `on(event, handler): boolean` | `"change"` while dragging or `"release"` at the end; handler `(value, cx)` | #### `OtpState` From `OtpState.new(length, options?)`, where `options` is `{ value?: string, masked?: boolean }`. The length is fixed at creation. | Method | What it does | | ----------------------------------- | ---------------------------------------------------------------------------------------------------- | | `value(): string` | The digits entered so far | | `set_value(next: string): void` | Replaces them | | `len(): number` | How many digits it holds | | `is_masked(): boolean` | Whether they are drawn masked | | `set_masked(masked: boolean): void` | Changes that | | `focus(): void` | Moves the keyboard into it | | `on(event, handler): boolean` | `"change"` after each edit, `"complete"` when filled, or `"focus"` / `"blur"`; handler `(event, cx)` | #### `VirtualListScrollHandle` From `VirtualListScrollHandle.new()`, handed to a list with `track_scroll(handle)`. | Method | What it does | | ------------------------------------------------ | ------------------------------------------------------------------------------------------- | | `scroll_to_item(index: number, strategy?): void` | Puts an item on screen before the next frame; `strategy` is `"top"` (default) or `"center"` | | `scroll_to_bottom(): void` | Scrolls to the end | ### Calendar `CalendarState` exists for `month_days()` — which dates fall in which week, where the neighbouring months' days go, and how many weeks this month needs. You draw the cells. ```js const grid = this.calendar.month_days()[0]; v_flex().children( grid.map((week) => h_flex().children( week.map((day) => Button.new(day) .selected(day === this.calendar.value()) .on_click((_, cx) => { this.calendar.set_value(day); cx.notify(); }) .child(String(Number(day.slice(8)))), ), ), ), ); ``` Base's `Calendar` element is **not** bound, and that is a decision rather than an omission: it walks the same grid calling a renderer once per cell — up to forty-two crossings into JavaScript per frame, from inside GPUI's layout pass, for cells that carry no behavior. Reading the grid here and drawing it yourself is the same work without them. Dates are `"YYYY-MM-DD"`: sorting them as text sorts them by time, and `new Date(s)` reads one — which is where a weekday name or a localized month label comes from. | Method | What it does | | ------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `month_days()` | The grid, as months of weeks of days. Every week is seven days; the first and last carry the neighbouring months' | | `year()` / `month()` | The year and month (1–12) the grid is for | | `today()` | Today, as the state read it when it was created | | `value()` / `set_value(next)` | The selection: one day, a `[start, end]` range, or `null` | | `next_month()` / `prev_month()` | Moves the grid a month either way; illegal from `render` | | `on("change", handler)` | The only event, reporting a date being selected | ### Theme | Name | What it is | | --------------------- | ------------------------------------------------------------------------------ | | `set_theme(theme)` | Replaces `gpui-base`'s active semantic tokens with an application-owned theme | | `ColorToken` | The semantic color names defined by the installed palette | | `Theme` | What `cx.theme()` answers: the semantic tokens plus `appearance` and `is_dark` | | `SemanticThemeTokens` | `colors`, `spacing`, `radius` | | `ColorTokens` | One `Color` per semantic role | | `SpacingTokens` | `xxs` `xs` `sm` `md` `lg` `xl` `xxl` | | `RadiusTokens` | `none` `sm` `md` `lg` `xl` `full` | Reading the theme is `cx.theme()`. `set_theme` remains in `gpui-base` because the theme belongs to that layer. Mutation still requires a live host call and is legal only from an event handler or task, never from `render` or layout. ### Other types | Name | What it is | | ----------------------- | ------------------------------------------------------------------------------------------------------------- | | `ScrollbarMode` | `"scrolling"`, `"hover"` or `"always"` | | `ItemRange` | A virtual list's visible items, as a half-open `[start, end)` | | `SliderValue` | A number, or `[start, end]` for a range slider | | `InputEvent` | The text-state event payload; submit events carry optional `secondary` and `shift` flags | | `OtpEvent` | The currently empty OTP event payload; read the value from `OtpState` | | `PartType` | The shared `new()` shape used by `gpui-base` sub-parts without their own identity | | `Placement` | `"top"`, `"bottom"`, `"left"` or `"right"`, mirroring `gpui_kit::base::Placement` | | `ComponentType` | The shared `new(id)` shape used by identity-bearing `gpui-base` component constructors | | `DockPlacement` | `"center"`, `"left"`, `"right"` or `"bottom"` | | `DockPanel` | One panel as `panels()` reports it: `id`, `name`, `placement`, `node`, `index`, `active`, and its three flags | | `DockGroup` / `DockTab` | A tab group and one of its tabs, as `tab_bar` and `empty_group` are given them | | `DockRegion` | One dock, as the `dock` handler is given it | | `DockDrop` | Where a dragged panel would land | ### Composition patterns Five of these components are not one element but an arrangement, and the tables above cannot say so. Each snippet below is the smallest thing that works; all of them were checked against the runtime. **A controlled control.** `Checkbox`, `Switch`, `Radio` and `Toggle` hold no state: you read the value in and write it back out. Nothing is drawn for you, so the indicator is a child. ```js Checkbox.new("done") .checked(this.checked) .on_change((checked, cx) => { this.checked = checked; cx.notify(); }) .child(this.checked ? "done" : "not done"); ``` **`Progress` announces; the bar is yours.** The root carries the role and the `0..=100` a screen reader reads, and draws nothing at all. ```js Progress.new("upload") .value(62) .child( ProgressTrack.new() .w(200) .h(6) .bg(cx.theme().colors.muted) .child(ProgressIndicator.new().w(124).h(6).bg(cx.theme().colors.primary)), ); ``` **A slider is four parts, and all four are needed** — a slider with no `SliderIndicator` cannot be moved, because that is the box every pointer position is measured against. All four take the same state. ```js Slider.new(this.volume).child( SliderTrack.new(this.volume) .w(200) .h(16) .child(SliderIndicator.new(this.volume).h(4).bg(cx.theme().colors.primary)) .child( SliderThumb.new(this.volume).w(12).h(12).bg(cx.theme().colors.background), ), ); ``` **`Select` owns the keyboard, `Popup` owns the surface.** The root holds the combobox role and the open state; the list is a `Popup` inside it. It needs two focus handles — one for the trigger, one for the content — and without the first nothing on screen has the keyboard. ```js Select.new("mode") .accessibility_label("Mode") .open(this.open) .track_focus(this.trigger) .content_focus_handle(this.list) .on_open_change((open, cx) => { this.open = open; cx.notify(); }) .child( Popup.new("mode-list", trigger) .anchor("bottom_left") .when(this.open, (el) => el.content(list)), ); ``` Arrow-key navigation of an open list is yours to write: base expects whatever is inside to run the highlight from its own key bindings, and nothing does that for you. The pieces are here — put `on_key_down` on the content element the keyboard was moved to, or bind ↑ / ↓ to actions under a `key_context` of your own. Out of the box the pointer works, Escape closes, Enter and ↓ open, and the highlight does not move. **A virtual list and its scrollbar are paired by name.** The list paints no bar of its own, and nothing checks the pairing before it runs, so both halves are needed. ```js v_flex() .relative() .h(200) .child( v_virtual_list( "rows", rows.length, 28, (index) => rows[index].id, (range) => rows.slice(range.start, range.end).map((row) => div().child(row.name)), ).size_full(), ) .child(Scrollbar.vertical("rows").absolute().inset_0()); ``` **A nested View is created once and mounted as a child.** `cx.new` belongs in `init` or an event handler; the entity is a child wherever a child is taken. ```js init(props, cx) { this.chart = cx.new(PriceChart, { symbol }); } render() { return v_flex().child(this.chart); } ``` ## The `gpui-fps` module | Name | What it is | | --------------- | ----------------------------------------------------------------------------- | | `fps_monitor()` | The native `gpui-fps` HUD, shared once per window and pinned to the top right | Its parent must be `relative()`. The HUD owns its own presentation; ordinary styles and children do not apply to it. ## Element methods Every element shares one prototype, so every method below type-checks on every element — which component a method actually suits is not expressed by the types. A behavior builder handed to a component that does not honour it is reported in the log rather than dropped in silence. Element builder methods answer the same element, so a chain is one expression. `map` is the exception: like GPUI's `FluentBuilder.map`, it returns exactly what its callback returns. An element is consumed when it is used as a child and belongs to the render pass that built it. ### Composition | Method | What it does | | ------------------------- | ------------------------------------------------------------------------------------------------------- | | `map(transform)` | Passes the current element to `transform` and returns its result, matching GPUI's fluent builder helper | | `child(value)` | Adds one child: an element, an `Entity`, or a string, number or boolean | | `children(iterable)` | Adds several, in order | | `when(condition, branch)` | Applies `branch` when `condition` is truthy, keeping the chain in one piece | | `id(name)` | A stable name for this element, used as its identity | ### Slots A slot is not a child: the element is consumed by the component and rendered where the component decides. | Method | What it does | | --------------------------- | ------------------------------------------------------------------------------------------ | | `content(element)` | The content of a `Collapsible`, `Popover`, `HoverCard` or `Popup` | | `image(element)` | An `Avatar`'s image slot; takes an `AvatarImage` | | `fallback(element)` | An `Avatar`'s fallback slot; takes an `AvatarFallback` | | `header(element)` | An `AccordionItem`'s header slot; takes an `AccordionHeader` | | `panel(element)` | An `AccordionItem`'s panel slot; takes an `AccordionPanel` | | `trigger(element)` | The trigger of a `Popover` or `HoverCard` | | `input(element)` | The editor slot of a `NumberInput`; empty draws the bare editor | | `decrement_button(element)` | The look of a `NumberInput`'s decrement button — replayed onto base's button, not rendered | | `increment_button(element)` | The increment button, replayed the same way | | `controls_right()` | Stacks both step buttons to the right of the text | ### Events | Method | What it delivers | | -------------------------------- | ----------------------------------------------------------------------------------- | | `on_click(handler)` | `(ClickEvent, cx)` on activation | | `on_mouse_move(handler)` | `(MouseMoveEvent, cx)` while the element is hovered | | `on_hover(handler)` | `(hovered, cx)` on both pointer entry and exit | | `on_key_down(handler)` | `(KeyEvent, cx)` while this element holds the keyboard | | `on_key_up(handler)` | `(KeyEvent, cx)` on the same focus path | | `on_mouse_down(button, handler)` | `(MouseButtonEvent, cx)` on a press of that button | | `on_mouse_up(button, handler)` | `(MouseButtonEvent, cx)` on its release | | `on_mouse_down_out(handler)` | `(MouseButtonEvent, cx)` on a press anywhere outside this element | | `on_scroll_wheel(handler)` | `(ScrollWheelEvent, cx)` on wheel or trackpad scrolling | | `on_action(action, handler)` | `(ActionEvent, cx)` when that named action is dispatched to this element or into it | | `on_change(handler)` | `(checked, cx)` on a toggle; the script owns the new value | | `on_step(handler)` | `("increment" \| "decrement", cx)`, and it **replaces** built-in stepping | | `on_item_click(handler)` | `(key, cx)` when a virtual list row is clicked, keyed rather than indexed | | `on_open_change(handler)` | `(open, cx)` when something other than the script changed a `Popover`'s open state | | `on_confirm(handler)` | Enter in an open `Select` or `Combobox`; no payload | | `on_dismiss(handler)` | Escape in an open `Select` or `Combobox`, before `on_open_change(false)` | | `on_resize(handler)` | `(sizes, cx)` once a resizable group's drag has ended | ### Actions and key bindings An action is the level above a keystroke. `cx.bind_keys` says which chord means `"save"`, in which context; `on_action("save", ...)` on an element says what `"save"` does. A menu item or a toolbar button dispatching the same name through `window.dispatch_action("save")` reaches the same handler, and neither end has to know about the other. ```js init(_props, cx) { cx.bind_keys([{ keystroke: "cmd-s", action: "save", context: "Editor" }]); } render(_cx) { return div() .key_context("Editor") .track_focus(this.handle) .on_action("save", (event, cx) => this.save(cx)); } ``` `context` is a predicate matched against the `key_context(...)` an element declares, so one chord can mean one thing in a list and another in an editor. Registering several `on_action`s on one element is fine and they are independent; an action none of them claims carries on to an element further out. That group — `on_key_down`, `on_key_up`, the four pointer handlers, `on_action` and `key_context` — is wired on `div`, `h_flex`, `v_flex`, `Button`, `Link`, `Checkbox`, `Switch`, `Radio`, `Toggle`, `Tabs` and `Tab`. On any other component the handler is recorded and never reaches GPUI, and the log says so — wrap it and write the handler on the wrapper. Wired is not the same as reachable. A key travels the focus path, so a component that accepts no focus handle — `Tab` — hears presses and never hears keys, however well both are wired. ### Control state | Method | What it sets | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `disabled(value)` | Blocks activation and reports the state; draw it yourself | | `selected(value)` | The selected state of a `Button` | | `checked(value)` | The controlled value of a `Checkbox`, `Switch` or `Radio` | | `pressed(value)` | The controlled state of a `Toggle` | | `value(percent)` | The announced progress percentage, clamped to `0..=100`; it moves nothing on screen | | `indeterminate(value)` | Withdraws a `Progress` value from the accessibility tree | | `open(value)` | Whether a `Collapsible` renders its content, or a surface is showing | | `default_open(value)` | Whether an uncontrolled `Popover` starts open | | `keep_mounted(value)` | Whether a shut `AccordionPanel` stays in the tree. Off by default; on, its content keeps a scroll position or a half-typed field across a close | | `start(value)` | Which thumb of a range slider a `SliderThumb` is | | `href(url)` | The absolute HTTP(S) target of a `Link` | ### Accessibility | Method | What it announces | | ------------------------------ | ----------------------------------------------------------------------------------------- | | `accessibility_label(text)` | What a screen reader says; an icon-only control announces nothing without it | | `role(name)` | What this element announces itself as — plain elements, `Button` and `Checkbox` only | | `aria_selected(value)` | The selected state of an option in a list the script built | | `aria_active_descendant()` | This element as the focused one while an ancestor holds the keyboard | | `set_position(position, size)` | One-based position and total size — "tab 2 of 5" | | `row_count(count)` | A `Table`'s total rows, including unrendered ones | | `column_count(count)` | A `Table`'s total columns | | `aria_level(level)` | An `AccordionHeader`'s announced heading level, default 3; it announces, it sizes nothing | | `axis(value)` | A `RadioGroup`'s or `ToggleGroup`'s orientation; semantic only, it lays out nothing | | `tooltip(text)` | A pointer-only hover label, and no substitute for `accessibility_label` | ### Focus and keyboard | Method | What it does | | ------------------------------ | --------------------------------------------------------------------- | | `track_focus(handle)` | Makes this element what the handle means | | `content_focus_handle(handle)` | Where a `Select` or `Combobox` moves the keyboard when it opens | | `tab_index(index)` | Where this element sits in the Tab order; it also makes it a tab stop | | `tab_stop(value)` | Whether Tab can land here, without changing its place in the order | ### Scrolling and panels | Method | What it does | | --------------------------------------------------- | ---------------------------------------------------------------- | | `overflow_scroll()` | Owns wheel and touch scrolling on both axes | | `overflow_x_scroll()` / `overflow_y_scroll()` | The same on one axis | | `overflow_scrollbar()` | Scrolls both axes and paints base-layer bars | | `overflow_x_scrollbar()` / `overflow_y_scrollbar()` | The same on one axis | | `mode(value)` | A `Scrollbar`'s visibility policy; omitted, it follows the theme | | `scroll_size(width, height)` | The content size a `Scrollbar` measures its thumb against | | `viewport_from_layout()` | Makes a `Scrollbar` take its viewport from its own box | | `track_scroll(handle)` | Gives a virtual list a scroll position the script can drive | | `with_item_to_measure_index(index)` | Which item a virtual list measures across the axis it scrolls | | `size_range(min, max?)` | How far a `resizable_panel()` may be dragged, in pixels | ### Anchored surfaces | Method | What it sets | | ------------------------- | ------------------------------------------------------------------------- | | `anchor(value)` | Which corner is pinned to the trigger; clamped into the window either way | | `mouse_button(value)` | Which pointer button opens a `Popover` | | `open_delay(ms)` | How long the pointer must rest on a `HoverCard` trigger; default 600 | | `close_delay(ms)` | How long a `HoverCard` waits before closing; default 300 | | `overlay_closable(value)` | Whether pressing outside an open `Popover` closes it | ### Dock commands What an element a dock's chrome drew _does_. A cached chrome description has no script event-handler lifetime, so it may not register one — a command carries no script value instead, and base does the work. Every one takes the object its handler was given as its first argument, and they belong on a `div`, an `h_flex` or a `v_flex`. | Method | On | What it does | | ------------------------------ | ----- | ------------------------------------------------- | | `select_tab(group, index)` | click | Displays that tab | | `close_panel(group, panel_id)` | click | Closes the panel, if its group allows it | | `toggle_zoom(group)` | click | Zooms the group in, or back out | | `drag_tab(group, index)` | drag | Makes the element the drag source for that tab | | `drop_tab(group, index?)` | drop | Accepts a dragged panel here; no index appends | | `toggle_dock(dock)` | click | Opens or closes the dock | | `resize_dock(dock)` | drag | Drags the dock's edge; base clamps every position | ### Dock chrome Four handlers, all optional, and legal only on a `dock_area(...)`. Each is first called from inside GPUI's layout pass and given base's resolved state. Its description is cached until that state or handler changes. | Method | Draws | | ------------------------------ | --------------------------------------------------------------------- | | `tab_bar(handler)` | The tab bar above a group's displayed panel | | `empty_group(handler)` | What a group with no displayed panel shows | | `drop_indicator(handler)` | Where a dragged panel would land | | `dock(handler)` | One dock's frame around its content; place `dock_content()` inside it | ### Motion | Method | What it does | | ------------------------------ | ---------------------------------------------------------- | | `transition(property, policy)` | Animates later target changes entirely in native GPUI code | | `spring(property, policy?)` | Springs them instead | The property is one of `"opacity"`, `"width"`, `"height"`, `"left"`, `"top"`, and the frames never enter JavaScript. ### Style templates Each takes a function that receives a detached element to collect styles on; its return value is ignored, so a chain and a block body both work. | Method | What it styles | | ---------------------------- | ------------------------------------------------------------------------ | | `hover(declare)` | While the pointer is over the element | | `active(declare)` | While the element is pressed | | `focus(declare)` | While the element has focus | | `range_style(declare)` | The filled part of a `SliderIndicator` — how it looks, never where it is | | `cell_style(declare)` | Every cell of an `OtpInput`; without it there is nothing on screen | | `cell_active_style(declare)` | Layered on top, for the cell the next digit lands in | | `caret_style(declare)` | The blinking mark in that cell while it is empty | ### Style methods Everything else on an element is a style. There are two families, and they never overlap: - **Methods that take an argument**, bound by hand: the size, padding, margin, position, flex, border, radius and paint families. Which length type each accepts follows its Rust signature, so `.p("auto")` is a type error for the same reason it throws at run time. - **No-argument methods**, generated from GPUI's reflection table: `flex_col`, `items_center`, `gap_2`, `rounded_md`, `text_sm`, `size_full`, `truncate` and the rest. The generated declarations are the inventory for the GPUI version in your build. Both are covered in [Styling](/versions/v0.7.1/shell/styling), along with the length and color grammars and the tokens the palette defines. ## HostModule registrations A module the host registered in Rust is imported by name, like any other module: ```js import { quotes } from "market"; ``` It is not part of any built-in module. The generated declarations carry one `declare module` per registered module, so both the module name and every export name are checked. See [HostModule](/versions/v0.7.1/shell/host-module). --- # Dock and Panels Source: /versions/v0.7.1/shell/dock License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/shell/dock and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A View that can only fill a window is not much of an application. A **dock area** turns a script View into a _panel_: draggable, dockable, zoomable, and still where the user left it after a restart. ```js import { View, div } from "gpui-kit"; import { DockArea, dock_area, v_flex } from "gpui-base"; class Notes extends View { render() { return div().p(16).child("Notes"); } } export default class Workspace extends View { init(_props, cx) { DockArea.register_panel("notes", Notes); this.dock = DockArea.new("workspace"); this.dock.add_panel(cx.new(Notes), { name: "notes", placement: "left", size: 240, }); } render() { return dock_area(this.dock).size_full(); } } ``` That already docks, drags, resizes, zooms and persists. It draws no tab bar, because **base draws no chrome at all** — see [Drawing the chrome](#drawing-the-chrome). ## What base brings, and what it does not `gpui_kit::base::dock` has the hard half of a docking system: a layout that is **pure data**, a `PanelRegistry` that rebuilds a panel from a name in a persisted file, and a per-panel payload that rides along with it. Containers are addressed by a stable node id and panels by a stable panel id, so a drag rearranges a value rather than tearing down and rebuilding Views. What it does not have is a look. The engine paints nothing — no tab bar, no dock frame, no drag handle, no drop hint — and hands every one of those back to you as a callback that returns elements. That is not a limitation to work around; it is why the whole thing is usable from a script at all. Appearance is not a set of overrides on a default look, because there is no default look. ## The area is retained `DockArea.new(id)` creates state that lives across frames, like `InputState` does, and for a reason none of the other handles share: **the layout is what the user changed.** A drag, a resize, a closed tab and a collapsed dock all happen without your script rendering. A dock rebuilt from a description would put every one of them back the way the last render described it. So it is created once, in `init`, and `render` only _draws_ it: ```js init() { this.dock = DockArea.new("workspace", { version: 1 }); } render() { return dock_area(this.dock).size_full(); } ``` `DockArea.new` needs a live host call, so it belongs in `init` or an event handler — never in `render`. So does every method that changes the layout; calling one from `render` is refused where it was written rather than producing a frame that draws one layout and describes another. ## Edits take effect when the call returns A panel's body comes from `cx.new(Class)`, which is itself still being constructed when you hand it over, and `load` builds panels of its own. Neither can happen while your script is running. So **every edit is queued and applied once the call that made it has returned**, in the order the calls were made. The practical consequence is one line long: `panels()` and `dump()` read the layout as it was _before_ this turn's edits. ```js init(_props, cx) { this.dock = DockArea.new("workspace"); this.dock.add_panel(cx.new(Notes), { name: "notes" }); this.dock.panels(); // still empty — the add has not been applied this.dock.on("layout_changed", (cx) => { this.dock.panels(); // three panels, a dock size, a moved tab cx.notify(); }); } ``` `layout_changed` fires on every edit, so save on a timer rather than on the event. ## Panels A panel is a View that a dock happens to be holding. `add_panel` takes the View and says where it goes: ```js this.dock.add_panel(cx.new(Editor, { file }), { name: "editor", // required — what a saved layout files it under placement: "center", // "center" | "left" | "right" | "bottom" size: 240, // seeds the dock's extent when the panel is the first in it closable: true, zoomable: true, visible: true, }); ``` `name` is required because it is not decoration: it is what a saved layout writes and what `register_panel` finds the class again by. It is namespaced for you — `shell:/` — so two applications that both call a panel `inbox` never collide, and no script panel can shadow a host one. `panels()` reports what is there, including where: ```js this.dock.panels(); // [{ id, name, placement, node, index, active, visible, closable, zoomable }, …] ``` `id` is what `remove_panel(id)` takes, and what a close button hands to `close_panel`. ## Surviving a restart Two halves, and you need both. **Register the class**, so a saved layout can rebuild it: ```js DockArea.register_panel("editor", Editor); ``` **Save and restore the layout**, which is plain data: ```js init(_props, cx) { DockArea.register_panel("editor", Editor); this.dock = DockArea.new("workspace", { version: 1 }); const saved = localStorage.getItem("layout"); if (saved) this.dock.load(JSON.parse(saved)); else this.dock.add_panel(cx.new(Editor), { name: "editor" }); this.dock.on("layout_changed", () => localStorage.setItem("layout", JSON.stringify(this.dock.dump()))); } ``` A panel's own state rides along with its position. Two optional methods on the View class carry it: | Method | When | Note | | ------------------- | ------------------------------- | --------------------------------------------------------------------------------------------- | | `serialize()` | The layout is saved | Runs **without a host call**: return plain data and touch nothing else — no entities, no `cx` | | `deserialize(data)` | Right after the View is rebuilt | A real host call, so this one may touch entities | `version` is yours to bump when the shape of what you save changes; base refuses to load a layout written under a different one, so an old file is ignored rather than half-understood. ### An uninstalled application keeps its place This is the property worth designing around. If nothing is registered under a panel's name — the application was uninstalled, or a class was renamed — the panel is **not dropped**. A draw-nothing placeholder stands in and reports the state it was handed, so the next save writes the panel — name, payload and position — back out unchanged. Uninstall an application, use the window for a week, reinstall it: its panels come back where they were, with the state they had. The same holds one step further in. A panel that _is_ registered but whose class throws on construction is carried forward the same way, so a broken script costs that panel's contents for the session rather than its place in the layout. ## Drawing the chrome Four handlers, all optional, hung on the `dock_area(...)` element: | Handler | Draws | | -------------------------------- | ------------------------------------------- | | `tab_bar(group => …)` | The tab bar above a group's displayed panel | | `empty_group(group => …)` | What a group with no displayed panel shows | | `drop_indicator(drop => …)` | Where a dragged panel would land | | `dock(dock => …)` | One dock's frame around its content | Each is first called from inside GPUI's layout pass and is given base's **resolved** state — never a drag event, a mouse position or a hit test, because base attaches all of that to the elements it gets back. The resulting description is cached by handler and resolved state, so unchanged frames replay it in Rust without entering JavaScript. ```js dock_area(this.dock) .size_full() .tab_bar((group, cx) => h_flex() .h(30) .bg(cx.theme().colors.secondary) .children( group.tabs .filter((tab) => tab.visible) .map((tab) => h_flex() .id("tab-" + tab.id) .px(10) .items_center() .bg( tab.active ? cx.theme().colors.background : cx.theme().colors.secondary, ) .select_tab(group, tab.index) .drag_tab(group, tab.index) .child(tab.name) .child( div() .id("x-" + tab.id) .close_panel(group, tab.id) .child("×"), ), ), ), ); ``` ### Commands, not callbacks Look at that tab again: it carries `select_tab` and `drag_tab`, not `on_click`. That is the one rule of this API worth understanding rather than memorising. A chrome description is cached and can outlive the handler call that produced it. A script callback registered inside one would therefore have no sound event lifetime, and every changed native state could create another. Registering one is refused where it is written; chrome uses native commands instead. A **command** carries no script value at all. It names a container in the area and what to ask it, and base does the work: | Command | On | Does | | ------------------------------ | ----- | ---------------------------------------------- | | `select_tab(group, index)` | click | Displays that tab | | `close_panel(group, panel_id)` | click | Closes the panel, if its group allows it | | `toggle_zoom(group)` | click | Zooms the group in, or back out | | `drag_tab(group, index)` | drag | Makes the element the drag source for that tab | | `drop_tab(group, index?)` | drop | Accepts a dragged panel here; no index appends | | `toggle_dock(dock)` | click | Opens or closes the dock | | `resize_dock(dock)` | drag | Drags the dock's edge | Every one takes the object its handler was given as its first argument. They belong on a `div`, an `h_flex` or a `v_flex`: a `Button` builds its own interior and has nowhere to put one. Base clamps, snaps and rounds everything a drag produces before the next frame sees it, so a resize handle is a hit area and a colour and nothing else. ### The dock handler places its own content `dock` is the only handler handed an element as well as state, and whatever it returns _replaces_ the dock's content. Put `dock_content()` where the panels belong: ```js .dock((dock, cx) => v_flex() .size_full() .relative() .child( h_flex() .h(30) .justify_between() .child(dock.placement.toUpperCase()) .child(div().id("collapse").toggle_dock(dock).child(dock.open ? "–" : "+")), ) .child(dock_content().flex_1()) .child(div().absolute().right(0).w(4).h_full().cursor_col_resize().resize_dock(dock)), ) ``` A handler that forgets `dock_content()` still shows its panels — they are drawn after what it returned, with a warning — rather than silently losing them. ## The whole surface ```js area.add_panel(view, options); area.remove_panel(id); area.panels(); area.dump(); area.load(state); area.has_dock(placement); area.is_dock_open(placement); area.toggle_dock(placement); area.remove_dock(placement); area.dock_size(placement); area.set_dock_size(placement, size); area.set_dock_collapsible(placement, collapsible); area.is_locked(); area.set_locked(locked); area.is_zoomed(); area.zoom_out(); area.on("layout_changed", handler); area.release(); ``` A locked area cannot be rearranged or dropped into. Dock resizing stays available, so “lock layout” freezes where panels live without freezing their usable size. ## A complete example ```bash cargo run -p gpui-shell -- examples/js_dock ``` `examples/js_dock/` is a workspace: a file list in the left dock, documents in the center, a tab bar and dock frame drawn in `ui.js`, and a layout written to `localStorage` on a timer. It is the shortest complete thing that uses every part of this page. ## From Rust `gpui_kit::shell::dock` is public, so a host can reach the same seam without a script. `ScriptPanel` wraps a `ScriptView` as a `gpui_kit::base::dock::Panel`; `register_panel(application, panel, script, cx)` teaches the registry to rebuild it from a `PanelScript`; `ScriptDockSkin` forwards both of base's renderer traits to one `DockChrome`. `tab_group_data`, `dock_data` and `drop_indicator_data` are the JSON conversions the engine hands to script code, and are useful to a host writing its own binding. --- # Hosting Source: /versions/v0.7.1/shell/hosting License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/shell/hosting and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. [Getting Started](/versions/v0.7.1/shell/getting-started) shows the four lines that put a script View on screen. This page is the rest of the Rust surface: what to call, when, and the two or three places where the obvious call is the wrong one. ## The runtime One `ShellRuntime` owns one VM. It is an `Rc` with interior mutability — neither `Send` nor `Sync` — so it lives on the thread that owns the `App`. ```rust gpui_kit::shell::init(cx); // gpui-base, the token palette, the style table let runtime = ShellRuntime::new(cx)?; // one VM, installed as this App's default ``` `new(cx)` lets callbacks, HostModule registrations and hot reload find the default runtime without the host threading a handle through every layer. A host deliberately managing more than one VM can create additional runtimes with `new_isolated()` and retain those handles itself. `gpui-shell` uses GPUI's inspector reflection table to expose the fluent style methods, including in release builds. Depending on this crate therefore enables the `gpui-base/inspector` feature for the unified Cargo dependency graph. This is required for the JavaScript style surface; embedders should account for the additional release-build instrumentation and dependencies. ## Loading and instantiating For the usual application window, loading is one operation and returns its `ShellRoot` directly: ```rust cx.open_window(options, move |window, cx| { let root = runtime.load(&app_root, window, cx); #[cfg(debug_assertions)] if let Ok(watch) = runtime.watch(&root, window, cx) { watch.forget(); } root })?; ``` If `gpui-shell.json` exists, `load` validates its identity metadata and applies its entry. Its capabilities are requests, not approval: both paths run under the host's current default policy, and without a manifest the entry is `main.js`. Either path refreshes `gpui-kit.d.ts`; a load failure renders the selectable error surface instead of panicking the host. A host that needs to handle the structured error itself uses `try_load`. A failure root has no application to watch, so `watch` returns `Err`; ignoring that error here keeps the selectable failure surface mounted. The lower-level methods below are for a host that needs to assemble a script View into an existing Rust composition. Loading turns source into a **View type** — the class the script default-exports. Instantiating turns that type into a **View object**, one live instance: ```rust let view_type = runtime.load_app(&root, "main.js")?; // a directory let view_type = runtime.load_source("inline", source)?; // a string, for tests let object = runtime.instantiate(&view_type, window, cx)?; ``` `load_app` resolves the directory, reads the entry file, and evaluates the module. Every failure here is a `ShellError` carrying the script's own stack — a syntax error, an import that resolves outside the application root, a missing or misshapen default export. Instantiating runs the script's `init`, which means it needs a live `Window`: it may create retained state such as an `InputState`. ## Mounting A script View is a GPUI View like any other, and it goes **under a `ShellRoot`**: ```rust cx.open_window(options, move |window, cx| { let object = runtime.instantiate(&view_type, window, cx).expect("view"); let content = cx.new(|_| ScriptView::new(runtime.clone(), object)); cx.new(|cx| ShellRoot::new(content.into(), window, cx)) }) ``` `ShellRoot` owns the dialog stack, the sheet, the toast stack, focus restoration and Tab navigation — the same role `Root` plays for a `gpui-component` window. `window.open_dialog` and friends reach it, so a script mounted under any other root View gets a refusal naming the reason rather than a silent no-op. The host can drive the same surfaces directly, which is how a plugin panel and the host's own UI end up in one stack: ```rust root.update(cx, |root, cx| { root.open_dialog(view.into(), window, cx); root.push_toast(ToastRequest::new("Saved").with_level(ToastLevel::Success), window, cx); root.close_all_dialogs(window, cx); }); ``` ## Refreshing a View from host state This is the one call that is easy to get wrong, and the mistake is silent. ```text cx.notify() ── draw this View again (no script runs) view.refresh(cx) ── and its description is stale (the script runs) ``` Because a script `render` is [not a frame render](/versions/v0.7.1/shell/state#when-render-runs), a plain `cx.notify()` repaints the Snapshot that already exists. If the host changed something the script _reads_ — an entity behind a HostModule, a setting, a document — the View must be told the description itself is out of date: ```rust runtime.refresh(&root, cx)?; ``` The runtime checks that `root` contains one of its applications, then invalidates that script View and schedules a repaint. Keeping the typed `ScriptView` private prevents host code from downcasting the root content or refreshing a View from another runtime by mistake. Getting it wrong in the other direction is visible immediately — the interface simply does not update — which is the same failure mode as a forgotten `cx.notify()` in GPUI itself. ## What a script may reach The three host settings have different lifetimes. Capabilities are frozen into each newly loaded View. The store handle and HostModule registry are live host configuration shared with that View, so replacing either affects its next call: ```rust gpui_kit::shell::set_capabilities( Capabilities::new() .read_roots([app_root.clone()]) .write_roots([data_dir.clone()]) .store(true), ); gpui_kit::shell::set_store_path(data_dir.join("store.json")); gpui_kit::shell::export_module(market_module(&market))?; ``` All three default to nothing: no file access, no storage location, no HostModule registrations. See [Capabilities](/versions/v0.7.1/shell/capabilities) and [HostModule](/versions/v0.7.1/shell/host-module). The standalone binary also checks `/gpui-shell.json`. Its recognized fields supply application identity, optional application/Shell version metadata, the entry point, and capability requests; only `id`, `name`, and `entry` are required. Embedders may instead construct a `Policy` directly when each loaded application needs a distinct grant and module registry. ## Watching what it costs The runtime counts two events separately, and the gap between them is the point: ```rust let reading = runtime.read_metrics(); reading.script_renders(); // follows cx.notify(), reloads, theme changes reading.materializations(); // follows frames reading.script_render_time(); // total time inside script `render` reading.native_time(); // of which, inside HostModule registrations reading.slowest_script_render(); reading.structure_repeat_rate(); // how often a rebuild described the shape it replaced ``` `RuntimeMetrics::since(&earlier)` gives the delta between two readings, which is how a per-second rate is built. There is no reset: the counters belong to the runtime, and zeroing them would move them under anything else that is reading. To measure one stretch, keep a baseline and subtract — the Shell story takes one whenever its feed changes, so its readout answers "what is this feed costing" rather than "what has this window done since it opened". A regression test can assert on `script_renders` directly; that is what keeps [the benchmark's third figure](/versions/v0.7.1/shell/engine#the-measurement) honest. `structure_repeats()` and `structure_changes()` answer a different question: of the rebuilds that had a previous description to compare with, how many produced the same _shape_ — the same components, the same builder methods, the same tree — and differed only in the values inside it. Nothing in the runtime acts on the answer; it is there to size [where the Snapshot cache stops](/versions/v0.7.1/shell/performance#where-the-snapshot-cache-stops). A View's first build has no predecessor and is counted in neither. ## Building for development A debug build of a host is roughly **three times slower per script render** than a release build, and the whole difference is in two dependencies. Measured on a live application — a market terminal re-rendering on every quote tick — with the runtime's own [`RuntimeMetrics`](#watching-what-it-costs): | `[profile.dev.package]` | mean script render | mean materialize | | -------------------------------- | ------------------ | ---------------- | | nothing, or `rquickjs` alone | 31.5 ms | 3.9 ms | | `rquickjs-sys` + `rquickjs-core` | **11.3 ms** | **1.2 ms** | | release, for comparison | 11.0 ms | 1.2 ms | So: ```toml [profile.dev.package] rquickjs-sys = { opt-level = 3 } rquickjs-core = { opt-level = 3 } ``` **`rquickjs` on its own does nothing**, which is the trap: it is a thin facade that re-exports `rquickjs-core`, so naming it optimises neither the interpreter nor the bindings. `rquickjs-sys` compiles QuickJS itself — C source, built through `cc`, which reads the profile's optimisation level for _that_ package — and `rquickjs-core` is where every value that crosses the boundary is converted. An unoptimised interpreter is what makes an unoptimised build feel like a different product. The `llrt_*` crates do **not** need this. They were measured with the same application and made no difference beyond the noise: `fs`, `net`, `crypto` and the rest are not on the render path, so optimising them buys nothing a script author would feel. These settings only take effect in the **workspace root that builds the binary**. A library cannot impose a profile on the application that depends on it, so `gpui-shell` cannot set this for you — every host has to write it down itself. ## Exit requests `process.exit(code)` from a script is **a request, never `exit(2)`**. One plugin must not be able to take the host process down, and the host may have unsaved state. The runtime hands the request to the host, and the host decides: ```rust gpui_kit::shell::on_exit_request(|request, window, cx| { match request.view() { Some(view) => close_the_panel_showing(view, window, cx), None => cx.quit(), } }); ``` `request.code()` is the exit code the script asked for, and `request.view()` names the View it came from, when there is one — a plugin host closes _that_ plugin's panel, where one that quit the window would let a plugin end someone else's work. **A host that grants exit without installing a handler is told at the call**, not never: `process.exit()` throws, naming `on_exit_request`. A request nobody answers is a lie told in the flattering direction — the script gets a success and nothing happens. ## Hot-reload One call starts it, and it is the same one the `--watch` flag uses: ```rust runtime.watch(&root, window, cx)?.forget(); ``` `runtime.watch` reads the resolved directory and manifest entry retained by the loaded root, so there is no second copy of that metadata to drift. It has no hidden build-mode policy: the CLI enables watching after `--watch`, while an embedded host can put the call behind `#[cfg(debug_assertions)]`. The returned `Watcher` owns the watch: dropping it stops the loop, which is what a host unmounting a panel wants, while `.forget()` lets it run for as long as the View does. The loop also ends on its own when the View, the runtime or the window goes away, because it holds all three weakly. A reload re-reads **every** module, entry point included — a hot-reload that quietly served a stale import would be worse than none, because it looks like it worked. It does all of its fallible work before touching the live View: if the new code fails to load, the previous View keeps running, the error goes to `tracing`, and a toast with a stable id reports it in the window. The next successful reload retracts that toast. The View survives a reload. `ScriptView::replace_object` swaps what the script produced while keeping the entity, and with it the window, the focus and the element identities. Plugin unload is a stronger lifecycle boundary than removing one View: the manager cancels every outstanding task carrying that plugin's `Policy`, including owner-less work, before dropping the plugin. No continuation may retain or exercise an unloaded plugin's authority. ## When a script fails A script that throws does not take the interface with it. The last good Snapshot stays mounted and the failure is reported over it, so the reader keeps their scroll, their focus, and whatever they were reading. The runtime does not re-run a failing `render` until something invalidates the View again. Install a `tracing` subscriber. The runtime reports script errors, unhandled promise rejections and illegal-phase calls through `tracing` with the target `gpui_kit::shell::script`; with no subscriber every one of them is discarded, and the symptom is a View that quietly stopped responding. ## Not there yet - **A supervisor for scripts that hang.** The interpreter's own interrupt cuts a call off, but nothing restarts a runtime that keeps hitting it. --- # Overlays Source: /versions/v0.7.1/shell/overlays License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/shell/overlays and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Dialogs, the sheet and toasts are **host** capabilities, reached through the global `window`. They are not something a script draws. A dialog is not a floating `div`. It is a place in the window's stacking order, a focus trap, an Escape target, and a promise about what pressing the backdrop means — all of which the window's root has to decide, because only something that sees every overlay at once can order them. A script drawing its own dialog would own none of that, and two scripts drawing two dialogs would own even less. So the script says **what** to put in front of the user, and the root says where it goes and how it leaves. What crosses the boundary is small: a function returning an element, a side to anchor to, a sentence to show. They are on `window` rather than on `cx` because a dialog belongs to the window, not to the View that opened it: `cx.notify()` re-renders one View, `window.open_dialog()` changes what the user is looking at. `gpui-component` draws the same line, so the two halves of an application read as one vocabulary — and `window` is somewhere to grow. Overlays are what it carries today; `Window` in Rust also answers focus, size and appearance, and those land in a namespace that already exists. ## The surface `window` is a **global**. There is nothing to import — and unlike `cx`, which every host call hands you as an argument, nothing hands you `window` either. It is simply in scope. A callback parameter named `window` shadows it, which is ordinary scoping and not an error — and if a future callback ever hands one in, it would be this same object, because `window` is ambient: it reads the call that is running. That is also why it is not a parameter today. In Rust it has to be one, since Rust has no ambient state to read; here the read is available, which is the same reason `fs` and `store` are not parameters either. **Do not copy Rust's `|event, window, cx|`** A script handler takes `(event, cx)`. A three-parameter version binds `window` to the context and leaves `cx` undefined, and the failure reads as `close_dialog is not a function`. With `// @ts-check` the generated declarations catch it at the line where you wrote it. ```js const depth = window.open_dialog(() => confirmClear(count), { escape_dismissable: false, backdrop_dismissable: false, }); window.close_dialog(); // -> did anything close? window.close_all_dialogs(); // -> how many closed window.has_active_dialog(); window.open_sheet(() => filters()); // right, the default side window.open_sheet_at("left", () => nav()); window.close_sheet(); // -> did anything close? window.has_active_sheet(); window.push_toast({ title: "Saved", description: "3 files", level: "success", timeout: 4000, id: "save" }); window.remove_toast("save"); window.clear_toasts(); ``` ## Dialogs `window.open_dialog(content, options?)` takes a **function returning an element**, not an element: ```text expected a function returning an element; open_dialog and open_sheet take a function, not an element and not a View class ``` The reason is lifetime, not taste. An element belongs to the arena of the render pass that built it, and a dialog outlives the call that opened it — so an element built at open time would belong to the wrong pass. The function runs when the dialog draws, and again whenever it redraws, which is the same contract `render` has. **Whatever it closes over is the dialog's state.** There is no `props`: a dialog receives what it shows the way every other value in the script arrives, by being in scope. ```js // confirm.js import { v_flex, h_flex } from "gpui-base"; export default (count, onConfirm) => () => v_flex() .w(360) .p(24) .gap(12) .child(`Delete ${count} completed items?`) .child("This cannot be undone.") .child( h_flex() .justify_end() .gap(8) .child(cancelButton(() => window.close_dialog())) .child(deleteButton((_event, cx) => { onConfirm(cx); window.close_dialog(); })), ); ``` ```js // main.js window.open_dialog(confirmClear(this.completed, (cx) => this.deleteCompleted(cx))); ``` Note what the root supplies and what it does not. It supplies the backdrop, the position, the layering, the focus trap and the surface it sits on; the width, the padding, the border, the type and the buttons are the script's, like everything else in this runtime. | Option | Default | Effect | | --- | --- | --- | | `escape_dismissable` | `true` | Whether Escape closes it | | `backdrop_dismissable` | `true` | Whether pressing the backdrop closes it | An unknown option is refused rather than ignored, which is the point: ```text unknown option `escapeDismissable` for window.open_dialog(content, options); expected escape_dismissable or backdrop_dismissable ``` A silently ignored `escapeDismissable` would look like it worked, and the dialog would be dismissable anyway. `open_dialog` returns the **new depth of the stack**, not a handle. The root addresses dialogs by position and never by identity, so a handle would have to promise "close *this* dialog", which is not an operation that exists. The depth is what a script can use — to assert one opened, or to unwind to a known level. `close_dialog` returns whether it found one to close; `close_all_dialogs` returns how many it closed. **Do not carry `cx` into the dialog** The `cx` in the handler that opened the dialog belongs to that handler. By the time the dialog's own button is pressed, it is stale, and using it reports a stale-context error. Close over **data**, and take `cx` from the dialog's own callback arguments — which is why the example above passes `onConfirm` a `cx` rather than capturing one. The overlay calls themselves have no such hazard: they are ambient, like `fs` and `store`, so there is no handle to hold past its call. ## The sheet ```js window.open_sheet(() => filtersPanel(filters)); window.open_sheet_at("left", () => navigation()); ``` At most one sheet is open at a time. `window.open_sheet` anchors it to the right; `window.open_sheet_at` takes `"left"`, `"right"`, `"top"` or `"bottom"`. It has no options at all, because there is only ever one and it is dismissed by Escape or by its overlay whenever no dialog is above it. ```text unknown sheet placement `middle`; expected left, right, top or bottom ``` ## Toasts A toast is the one overlay that is **data rather than a View** — no function, no instance, nothing for the script to render — which is why its whole content crosses the boundary as an options object. | Field | Default | Meaning | | --- | --- | --- | | `title` | required | The sentence the user reads | | `description` | — | A second line | | `level` | `info` | `info`, `success`, `warning` or `error` | | `timeout` | 5 s | Milliseconds, or `null` to stay until dismissed | | `id` | generated | Identity, for replacing and dismissing | An omitted `timeout` keeps the default and an explicit `null` makes the toast sticky, so the two cannot be collapsed into one option. The `id` is what turns a repeated failure into one standing message instead of a pile. The `--watch` loop uses exactly this: a failed reload posts a sticky error toast with a fixed id, so saving a broken file five times replaces the message rather than stacking five of them, and the next successful reload retracts it with `remove_toast`. ```text unknown toast level `fatal`; expected info, success, warning or error ``` Three toasts are mounted at once. Older ones stay in the manager and reappear as newer ones leave, so a burst is throttled rather than lost. ## The window itself The same `window` global answers questions about the window, not only about what is floating over it. ```js render(cx) { const { width, height } = window.viewport_size(); return v_flex() .when(width < 600, (el) => el.flex_col()) .text_size(window.rem_size() * 0.875); } ``` **Measurements are legal from `render`**, and that is the point of them: a View that lays itself out from the window's size has to ask during the pass that draws it. | Member | What it answers | | --- | --- | | `rem_size()` / `line_height()` | The window's type metrics, in pixels | | `viewport_size()` | The drawable area | | `bounds()` | Where the window sits on screen and how big it is — larger than the viewport by its title bar | | `mouse_position()` | Where the pointer is, in window coordinates | | `appearance()` | `"light"` or `"dark"` | | `is_window_active()` / `is_fullscreen()` / `is_maximized()` | The platform window's state | **Calls that change the window are refused from `render`**, for the reason `cx.notify()` is: a frame that changes the window it is drawing into is a frame arguing with itself. | Member | What it does | | --- | --- | | `set_rem_size(size)` | Rescales everything expressed in rems | | `refresh()` | Redraws every View in the window | | `focus_next()` / `focus_prev()` | Moves the keyboard one tab stop | | `dispatch_action(action)` | Dispatches an action down this window's focus path | | `activate_window()` / `minimize_window()` / `zoom_window()` / `toggle_fullscreen()` | Platform window controls | `zoom_window()` is the platform's own zoom, not a scale factor — `set_rem_size` is the one that rescales. ## Stacking and dismissal Painted back to front: 1. **Content** — the script's root View. 2. **Sheet** — at most one, anchored to an edge. A sheet is a *place* in the window, so it sits below the dialog stack: a dialog raised from inside a sheet must be readable, and a sheet raised under a dialog must not cover it. 3. **Dialog stack** — in open order, oldest at the bottom. 4. **Toasts** — above everything. A toast reports the outcome of the action the user just took, and an open dialog is exactly the situation where that outcome matters most, so it is the one layer that is never occluded. Only the topmost dialog draws a backdrop: a stack of three dims the window once, not three times, and that single backdrop is what separates the live dialog from the inert ones behind it. Dismissal is always **one layer, never a cascade**: - **Escape** closes the topmost dialog only. Lower dialogs render with keyboard handling disabled, so repeated Escapes unwind the stack one dialog per press and never reach the sheet while a dialog is open. - `escape_dismissable: false` withdraws the **key binding**, not the underlying cancel action. A close control the script puts inside the dialog still works — which is what makes an undismissable dialog one the user must answer rather than one they cannot leave. - **Backdrop press** closes the topmost dialog, and only if it was opened with `backdrop_dismissable`. - **Enter does nothing** at this layer. Base's dialog host treats Enter as "confirm and close"; that belongs to the dialog's own primary button, which the script owns, so the root vetoes the built-in confirmation rather than guessing which content wanted it. - A **sheet** is dismissed by Escape or by its overlay only when no dialog is open, because a dialog above it holds focus. - `close_all_dialogs` is the one operation that unwinds the whole stack, and it leaves the sheet alone. **Focus** is restored through the stack's own history. Opening an overlay records what was focused and focuses the overlay; closing it restores that handle. Closing the second dialog returns focus to the first, and closing the first returns it to whatever the window was on before either opened. Tab and Shift-Tab honour the focus trap, so tabbing inside an overlay cycles within it rather than walking into the content behind it. ## The `ScopePhase` rule **An overlay may only be opened or closed from an event handler or a task.** ```text window.open_dialog(content, options) is not allowed during the `render` phase; overlays may only be opened or closed while handling an event or a task ``` Opening or closing an overlay mutates the window, and the render pass is reading it. GPUI's borrow model has no way to express "the script may notify from here but not from there", so the runtime carries the [`ScopePhase`](/versions/v0.7.1/shell/state#scope-phases) explicitly and every overlay entry point refuses `render`, `layout`, and being called from outside any host call at all — in the last case there is no window to reach either. The refusal names the phase it came from, because that is the only clue the author has. `window.has_active_dialog()` and `window.has_active_sheet()` are the exception, and read the same rule: they ask a question rather than change anything, and a View that draws itself differently while a dialog is up has to ask during the pass that draws it. ## Overlays need a `ShellRoot` Every one of these calls reaches the window's root View. A window whose first View is not a `ShellRoot` refuses them, and says which mistake it was — a host wiring problem, not a script one: ```text window.open_dialog(content, options) needs a ShellRoot as the window's first View; this window was opened with another View ``` See [Getting started](/versions/v0.7.1/shell/getting-started#add-the-runtime-to-a-rust-application). ## Not there yet - **A result from a dialog.** `open_dialog` returns a depth, not a promise that settles when the dialog closes. Close over a callback, as the example above does, or have the dialog write back to state the opener reads. - **Tooltips and context menus.** Popover and HoverCard are available as anchored surfaces; dedicated tooltip and context-menu APIs are not yet exposed. - **Positioning options.** A dialog is centred and a sheet is edge-anchored; neither can be placed. --- # Elements Source: /versions/v0.7.1/shell/elements License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/shell/elements and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. An element in `gpui-shell` is a **description**, not an object. It exists for one `render` call and is consumed when it is used. This page covers what you can build, how to compose it, and what the runtime does when a description is used twice. ## Constructors Each module carries what its own crate provides: ```js import { div, svg, image } from "gpui-kit"; import { h_flex, v_flex, Button, Link, Checkbox, Switch, Input, InputState, } from "gpui-base"; import { fps_monitor } from "gpui-fps"; ``` Functions are lowercase, and component types are capitalized and constructed through `.new`. That mirrors the Rust side one for one: `div()` is a free function there too, and `Button::new(id)` is an associated function on a type. | Constructor | From | Produces | | ------------------ | ----------- | --------------------------------------------------------------------------- | | `div()` | `gpui-kit` | An element with no layout of its own | | `"a string"` | `gpui-kit` | Text. A string is an element, so it goes straight into `.child(...)` | | `svg(path)` | `gpui-kit` | A theme-tinted vector icon from the application's own directory | | `image(path)` | `gpui-kit` | A full-colour image from the application's own directory | | `h_flex()` | `gpui-base` | A row | | `v_flex()` | `gpui-base` | A column | | `Button.new(id)` | `gpui-base` | A base `Button`: activation, focus, disabled and selected state, no styling | | `Link.new(id)` | `gpui-base` | A focusable external HTTP(S) link; set its target with `.href(url)` | | `Checkbox.new(id)` | `gpui-base` | A base controlled checkbox, no styling and no indicator | | `Switch.new(id)` | `gpui-base` | A base controlled switch, no styling | | `Input.new(state)` | `gpui-base` | A text field backed by an [`InputState`](/versions/v0.7.1/shell/state#retained-state) | | `fps_monitor()` | `gpui-fps` | The native `gpui-fps` performance HUD, shared once per window | This is the set you need to get started, not the whole of it. The full inventory of bound components — `Select`, `Combobox`, `Tabs`, `Table`, `VirtualList`, `Slider`, `Popover`, `Avatar`, `Accordion`, `Pagination`, `CalendarState` and the rest — is in the [API reference](/versions/v0.7.1/shell/api#the-gpui-base-module). ### Performance monitor `fps_monitor()` exposes the native `gpui-fps` HUD without moving its sampling or painting into JavaScript. The monitor is created on first use and reused per window. Render it at most once in a window, inside a `relative()` parent: ```js div().relative().size_full().child(content).child(fps_monitor()); ``` It is pinned to the top right by default. Use the existing anchor vocabulary to move it, for example `fps_monitor().anchor("bottom_left")`. The HUD owns its presentation; ordinary element styles, children, and interaction states do not apply to it. ### Why `.new(id)` and not `new Button(id)` The JavaScript habit would be `new Button(id)`. The runtime does not offer it, and the reason is the whole subject of this page: `new` promises an object with an identity — something you can keep, store on the instance, and use again. That is exactly what a description is not. `Button.new(id)` reads as "construct a description", which is what it does, and it matches the Rust spelling character for character. Views are the opposite case, and use the standard form: `class Counter extends View`. A View genuinely does have an identity and cross-frame state, and it is owned by GPUI. Two construction shapes in one file, because the two kinds of thing have different lifetimes. ### Ids The `id` given to `Button`, `Link`, `Checkbox` and `Switch` identifies the element across renders, which is how GPUI preserves focus and element state. Keep it stable and unique among siblings — `` `item-${item.id}` `` rather than an array index that shifts when the list is filtered. Anything else — a `div`, an `h_flex` — is identified by **where it sits in the tree your render built**. That is enough while the tree keeps its shape, and stops being enough the moment a conditional child appears above it: every element below shifts, and the pressed state, the focus and anything else keyed by identity shift with them. `.id(name)` is how you say which element this is rather than where it landed: ```js div() .id("toolbar") .active((el) => el.opacity(0.7)); ``` Name anything whose identity has to survive its neighbours changing. `Button`, `Link`, `Checkbox` and `Switch` already have an identity from `new(id)` and ignore this one (with a warning, rather than silently). ### Text **A string is an element.** GPUI implements `IntoElement` for `&str`, `String` and `SharedString`, so text is written by handing the string to whatever holds it, and there is no `text()` to call: ```js v_flex().child(`${this.remaining} of ${this.items.length} remaining`).child(42); ``` The style comes from the element holding it, exactly as it does in Rust: ```js div().text_size(12).font_semibold().child("AAPL"); ``` A string child is materialized as a `div` containing it, which is what `div().child(s)` already says. ### Images ```js svg("icons/check.svg").w(14).h(14).flex_none(); image("images/brand.png").w(120).h(40); ``` Both paths resolve against the **application root** — the directory handed to `gpui-shell` — not against the file that called the constructor. That asymmetry surprises people, so it is worth stating plainly: `import "./ui.js"` resolves relative to the importing file, the way every JavaScript module system does, while `svg("icons/check.svg")` and `image("images/brand.png")` resolve relative to the application root, the way a web application's public directory does. The runtime cannot tell which module called an asset constructor, so per-file asset paths are not available to it. Paths outside the application directory are rejected. A missing file is reported once per path with the location it was looked for, rather than silently drawing nothing. One asset may contain at most 16 MiB. Listing the asset tree is bounded at 10,000 entries and 1 MiB of UTF-8 name bytes, so asset discovery cannot grow memory without limit. Use `svg()` for a monochrome icon: it inherits the surrounding text colour, so an icon inside a dark button comes out light without the script saying so twice. Use `image()` when the source colours must be preserved, such as a logo, photo or illustration. ```js renderIcon(cx) { return div() .bg(cx.theme().colors.foreground) .text_color(cx.theme().colors.surface) .child(svg("icons/check.svg").w(11).h(11)); // draws in `surface` } ``` ## Composition | Method | Effect | | -------------------------- | ------------------------------------------------ | | `.child(element)` | Adds one child. The child is consumed | | `.children(iterable)` | Adds several, in order | | `.when(condition, branch)` | Applies `branch` only when `condition` is truthy | ```js v_flex() .gap(8) .child(this.header()) .children(this.visible().map((item) => this.row(item))) .when(this.items.length === 0, (el) => el.child("Nothing yet")); ``` `.when` exists so a conditional does not break the chain in two. `branch` **must return the element** — a branch that returns nothing throws immediately, rather than quietly dropping everything it built: ```text when(...) must return the element ``` This mirrors GPUI's own `FluentBuilder` and the repository's Rust style rule: keep element construction as one fluent chain. For a condition that chooses between two elements, an ordinary ternary is clearer than `when`: ```js .child( visible.length === 0 ? emptyState("No items yet", "Type above and press Add.") : v_flex().children(visible.map((item) => this.row(item))), ) ``` ## Behavior methods These are not styles; they report state to the base layer, which handles the interaction and leaves the appearance to you. | Method | On | Effect | | --------------------------------- | ------------------------------------------ | ------------------------------------------------------------- | | `.on_click(handler)` | `Button` | `handler(event, cx)`, on click **and** on keyboard activation | | `.on_change(handler)` | `Checkbox`, `Switch` | `handler(checked, cx)`; the script stores the value | | `.disabled(value)` | `Button`, `Checkbox`, `Switch` | Blocks activation and reports the state | | `.selected(value)` | `Button` | Reports the selected state | | `.checked(value)` | `Checkbox`, `Switch` | The controlled value | | `.accessibility_label(text)` | `Button`, `Checkbox` | What a screen reader announces | | `.tooltip(text)` | `div`, `h_flex`, `v_flex`, `Button` | A label shown after the pointer rests on the element | | `.id(name)` | `div`, `h_flex`, `v_flex` | A stable identity, instead of position in the tree | | `.overflow_scrollbar()` | `div`, `h_flex`, `v_flex` | Scrolls both axes and paints native scrollbars | | `.overflow_x_scrollbar()` | `div`, `h_flex`, `v_flex` | Scrolls horizontally and paints a native scrollbar | | `.overflow_y_scrollbar()` | `div`, `h_flex`, `v_flex` | Scrolls vertically and paints a native scrollbar | | `.on_key_down(handler)` | [input-capable](#where-input-is-installed) | `handler(event, cx)` while this element holds the keyboard | | `.on_key_up(handler)` | [input-capable](#where-input-is-installed) | The same on release | | `.on_mouse_down(button, handler)` | [input-capable](#where-input-is-installed) | A press of `"left"`, `"right"` or `"middle"` | | `.on_mouse_up(button, handler)` | [input-capable](#where-input-is-installed) | Its release | | `.on_mouse_down_out(handler)` | [input-capable](#where-input-is-installed) | A press anywhere **outside** this element | | `.on_scroll_wheel(handler)` | [input-capable](#where-input-is-installed) | Wheel and trackpad scrolling over it | | `.on_action(action, handler)` | [input-capable](#where-input-is-installed) | A named action dispatched to it or into it | | `.key_context(name)` | [input-capable](#where-input-is-installed) | The key-binding context this element and its subtree sit in | Disabled, selected and checked **appearance** is yours to draw. The base layer only reports the state; nothing changes on screen unless the script says so: ```js Button.new("clear") .disabled(this.completed === 0) .when(this.completed === 0, (el) => el.opacity(0.4)) .child("Clear completed"); ``` `.accessibility_label` matters most on an icon-only control, which announces nothing without it: ```js Button.new(`remove-${item.id}`) .accessibility_label(`Remove “${item.caption}”`) .child(svg("icons/trash.svg").w(14).h(14)); ``` ### Controlled values report intent A base checkbox does not change its own state. It reports what the user asked for, and the script decides: ```js Checkbox.new(`item-${item.id}`) .checked(item.done) // the value comes from script state .on_change((done, cx) => { // the callback is a request this.toggle(item.id, done, cx); }) .child(indicator(item.done)) .child(label(item.caption)); ``` The runtime never quietly maintains a checked flag on the script's behalf. If it did, script authors and Rust authors would hold different mental models of the same control inside one application. ### Event objects An `on_click` handler receives a plain object whose field names mirror the Rust struct: ```js .on_click((event, cx) => { // event.click_count === 1 // event.modifiers === { shift, control, alt, platform } }); ``` `platform` is Command on macOS and the Windows key elsewhere. Only semantics the base layer has already normalized are exposed — Base treats "Enter activates the button" and "the button was clicked" as the same callback, and the script does not see the difference. A key handler receives the chord twice over. `keystroke` is the whole thing in the spelling a binding is written in; `key` and `modifiers` are the same chord taken apart, for when only one half matters: ```js .on_key_down((event, cx) => { if (event.keystroke === "cmd-s") { this.save(); cx.stop_propagation(); } }); ``` **The platform modifier is spelled `cmd` everywhere**, including Linux and Windows. GPUI spells it for the platform it was built for — `cmd-`, `super-`, `win-` — which is right for a keymap a person reads and wrong for a string a program compares: one script file runs on all three, so `event.keystroke === "cmd-s"` has to mean the same thing in all three. A pointer handler receives the button, how many presses are in the current sequence, and where it landed. `local_position` and `bounds` are absent until the element has been painted once: ```js .on_mouse_down("right", (event, cx) => { // event.button === "right" // event.click_count === 1 // event.local_position?.x — relative to this element this.openMenuAt(event.position, cx); }); ``` A scroll handler receives pixels either way, and the original line count when the device reported lines: ```js .on_scroll_wheel((event, cx) => { this.offset += event.delta.y; // always pixels // event.delta_lines?.y — only when the device said lines cx.notify(); }); ``` ### Where input is installed The eight methods above are GPUI's own `InteractiveElement` builders, and the shell installs them on `div`, `h_flex`, `v_flex`, `Button`, `Link`, `Checkbox`, `Switch`, `Radio`, `Toggle`, `Tabs` and `Tab`. Every other component builds its own base type and hangs its own listeners on it, so a handler written on one of those is recorded in the description and never reaches GPUI. The log says so rather than leaving you to find it: ```text `on_key_down` is not wired on a Select: the shell installs GPUI's input listeners on the element it owns outright, which is a plain `div`, `h_flex` or `v_flex`. Wrap it and write `on_key_down` on the wrapper ``` **Wired is not the same as reachable.** A key event travels the focus path and a pointer event travels the hitbox, so a component that accepts no focus handle — `Tab` is one — hears presses and never hears keys, however well both are wired. Which components accept a focus handle is covered under [Focus and accessibility](#focus-and-accessibility). ### Actions and key bindings An action is the level above a keystroke. `cx.bind_keys` says which chord means `"save"`, in which context; `on_action` says what `"save"` does. A menu item or a toolbar button dispatching the same name reaches the same handler, and neither end knows about the other: ```js init(_props, cx) { cx.bind_keys([ { keystroke: "cmd-s", action: "save", context: "Editor" }, { keystroke: "ctrl-k ctrl-c", action: "comment", context: "Editor" }, ]); } render(_cx) { return div() .key_context("Editor") .track_focus(this.handle) .on_action("save", (_event, cx) => this.save(cx)) .child( Button.new("save") .on_click(() => window.dispatch_action("save")) .child("Save"), ); } ``` `context` is a predicate matched against the `key_context(...)` an element declares, so one chord can mean one thing in a list and another in an editor. The keymap belongs to the application rather than to a window, so a chord bound in one View is live wherever its predicate matches. Registering several `on_action`s on one element is fine and they are independent. An action none of them claims carries on to an element further out, which is what lets an inner pane handle Save while the window around it handles Quit. The whole binding list is validated before any of it is installed: a keymap half-applied because the fourth entry had a typo is a worse state than one not applied, and a script cannot see which half made it. **Use arrow functions for handlers** An arrow function does not bind its own `this`, so `this` inside the handler is still the View instance. A `function () {}` handler gets the wrong `this`. This is the single most common mistake in scripts written for this runtime, by people and by models alike. ## Focus and accessibility A script owns its own focus targets. `cx.focus_handle()` creates one — `App::focus_handle` in GPUI, which has no `FocusHandle::new` for this to mirror — it lives on the View the way an [`InputState`](/versions/v0.7.1/shell/state#retained-state) does, and `.track_focus(handle)` gives it to an element: ```js init(props, cx) { this.search = cx.focus_handle(); } render() { return Button.new("search") .tab_index(1) .track_focus(this.search) .child("Search"); } ``` `cx.focus_handle()` needs a live host call, and a handle created inside `render` would be a new one on every frame — so the focus it tracked would be dropped by the next repaint. It belongs in `init` or in an event handler; calling it in `render` throws. | On the handle | Answers | | --------------------- | ----------------------------------------------- | | `handle.focus()` | Moves the keyboard onto the element tracking it | | `handle.is_focused()` | Whether that element currently has the keyboard | | `handle.release()` | Drops the handle | `Tab` and `Shift-Tab` are handled by the window root, which walks the order below in both directions and honours the focus trap of an open dialog or sheet. | Method | On | Effect | | --------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | | `.track_focus(handle)` | `div`, `h_flex`, `v_flex`, `Button`, `Checkbox`, `Radio`, `Toggle` | Binds the element to a handle the script owns | | `.tab_index(n)` | those, and `Link`, `Switch` | Where the element sits in the window's Tab order; it also makes the element a tab stop | | `.tab_stop(value)` | the same set | Whether Tab can land on it at all. `false` keeps its place in the order without making it reachable | | `.role(name)` | `div`, `h_flex`, `v_flex`, `Button`, `Checkbox` | What the element announces itself as | | `.aria_selected(value)` | `div`, `h_flex`, `v_flex` | The selected state of an option in a list the script built | | `.aria_active_descendant()` | `div`, `h_flex`, `v_flex` | Announces this element as the focused one while an ancestor holds the keyboard — the highlighted option of a combobox whose input keeps focus | The sets differ because the components differ. `Button`, `Checkbox`, `Radio` and `Toggle` build their focus handle from a value you can replace; `Link` and `Switch` build their own and have no builder to replace it. Every component except `Button` and `Checkbox` announces a role of its own — a `Tab` is a tab, a `Radio` is a radio — and only those two treat the role as an override, which is what lets a button announce itself as a menu item. A call a component cannot honour is **reported in the log**, not silently dropped: ```text `role` is not wired on a Tab: base's Tab owns this part of its own focus and accessibility. Put it on an element around it ``` The plain elements take all six, which is how a script builds the listbox, toolbar or dialog the base layer has no component for: ```js div() .id(`cadence-${index}`) .role("list_box_option") .aria_selected(index === this.chosen) .when(index === this.chosen, (el) => el.aria_active_descendant()) .child(name); ``` Role names mirror `gpui_kit::Role` in snake_case — `list_box`, `list_box_option`, `combo_box`, `menu_item` — and the whole set is in `gpui-kit.d.ts` as the `Role` union, so an editor completes them and a name that is not one fails at the call site: ```text unknown accessibility role `listbox`; the names mirror gpui_kit::Role in snake_case — see the Role type in gpui-kit.d.ts ``` ## Elements are single-use This is the rule that most often surprises a new reader, so here is what it looks like and why it holds. ```js const row = h_flex().child("hello"); v_flex().child(row).child(row); // throws ``` ```text element `h_flex` was already added to a parent; elements are single-use values ``` Storing one across frames fails the same way: ```js init() { this.header = h_flex().child("Todo"); // wrong } render() { return v_flex().child("Todo list").child(this.header); } ``` ```text this element belongs to a previous render pass; elements are single-use values and must be rebuilt each time render runs ``` One rough edge worth knowing about: the arena is cleared and its indices reused on every pass, so a stale element occasionally holds the index the runtime has just handed to the node it is being attached to. The misuse is still caught, but the message reads `an element cannot be added to itself` instead. Both mean the same thing — the element belongs to a pass that has ended. ### Why The restriction comes from GPUI itself: `RenderOnce::render` takes `self` **by value**, and `.child()` takes its child by value. In Rust the compiler enforces that with move semantics: using a moved value is a compile error. JavaScript has no move semantics and no compiler, so the runtime enforces the same rule at run time — and the description arena already has the bookkeeping needed to do it, because it marks a node as parented the moment it is attached. The alternative would be to copy the description on reuse. That was rejected: it would make the same script mean different things in Rust and in JavaScript, and reuse is almost always a mistake rather than an intention. ### The shape that works Build in `render`, and factor repetition into **functions that return a new element each time**: ```js const label = (value, cx) => div().text_size(12).text_color(cx.theme().colors.foreground).child(value); render(cx) { return v_flex() .child(label("first", cx)) .child(label("second", cx)); } ``` That is how the [example application](https://github.com/longbridge/gpui-kit/tree/main/examples/js_todolist) is written: `ui.js` exports `button`, `label`, `icon`, `checkbox` and the rest as functions, and `main.js` calls them. It reads like a component library and costs nothing, because a function call is where a fresh description comes from. ## Callbacks belong to their render A handler passed to `.on_click` belongs to the description that render produced — not to a frame. That description is [replayed by every frame until something invalidates it](/versions/v0.7.1/shell/state#when-render-runs), and the handler stays callable for all of them. The description records only an id; the closure Rust assembles holds a weak reference to the runtime plus that id. The description a render replaced is kept one generation longer, because an event can be dispatched against a frame that has already been superseded. An event that arrives later than that is dropped with a `debug` log rather than an error — the author did nothing wrong, and there is nothing for them to fix. The practical consequence is that a rendered callback is not a subscription. For something that must outlive the pass that created it — reacting to an input's `change` event, say — see [State and Views](/versions/v0.7.1/shell/state#input-events). ## Unknown methods are errors A method that is neither a style nor one of the behavior methods above fails at the call site, with a suggestion when there is a close one: ```text unknown element method `items_centre` (did you mean `items_center`?) ``` ```text unknown element method `on_clicked`; it is neither a style method nor one of child, children, when, on_click, on_change, disabled, selected, checked, id ``` This matters more than it looks. A mistyped style name changes nothing on screen — it simply fails to — and without a diagnostic it is invisible. See [Styling](/versions/v0.7.1/shell/styling#unknown-methods) for how the runtime produces that message without paying for it on every render. ## Not there yet The element surface now includes Tabs, Table, Progress, form controls, anchored Popover/HoverCard surfaces, Textarea, Scrollbar, PathBuilder, VirtualList, and a [dock area](/versions/v0.7.1/shell/dock) whose chrome the script draws. Still missing, deliberately: - the higher-level List and Tree systems and the remaining `gpui-base` components; - `gpui.memo`, which would let an unchanged subtree skip the script work that rebuilds its description. Focus is now the script's to own, but not all of it. Still missing: - **Keyboard navigation inside a composite, which is yours to write.** Tab and Shift-Tab move between controls; the arrow keys that move _within_ a listbox, a menu or a tab list do not appear on their own. The pieces exist now — `on_key_down`, `cx.bind_keys` and `key_context` — but turning ↑ / ↓ into a moving highlight is still the script's job. - **The first Tab into an unfocused window.** While nothing at all holds focus, the root's Tab binding has no dispatch path to reach; focus has to arrive some other way first — a click, or `handle.focus()`. - **`Tab`, `Tabs`, and the table, group and progress parts** stay out of the Tab order. Base leaves them out of keyboard focus, and `tab_index` on one of them is reported rather than honoured. - **`track_focus` on `Link` and `Switch`**, for the same reason: they build their own handle and expose no builder to replace it. --- # Dependencies Source: /versions/v0.7.1/shell/dependencies License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/shell/dependencies and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. An application imports its own files by relative path. Every other import it writes comes from one of two places: a **built-in module** the runtime provides — `gpui-kit`, `gpui-base`, `gpui-shell`, `gpui-fps`, and the standard runtime's `fs/promises`, `path`, `crypto`, `net`, `websocket` — or a **dependency**, a JavaScript package the manifest declares and gpui-shell fetches from Git before the entry module is evaluated. There is no registry, no package manager and no install step. A dependency is a Git remote, a ref, and the name a script imports it by. ## Shell package A dependency is any Git repository the manifest points at. `omarchy-ui` is a particular kind of one, and that kind has a name: **a shell package** — a JavaScript package written for gpui-shell rather than for Node or a browser, the way a crate is written for Cargo. Five things make a repository one: | A shell package | Because | | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | Ships ES modules, and needs no build step | The runtime evaluates the checkout's files as they are, and `require` does not exist | | Has a root `package.json` with `"type": "module"` and a `main` | It makes the declaration one line, and names the entry for the runtime and the editor | | Imports only the built-in modules and its own files | Nothing else resolves — it cannot reach the application that imported it | | Treats `gpui-kit` and `gpui-base` as provided, never as dependencies | They come from the runtime that loaded it, at the version the host chose | | Declares no capabilities of its own | Its `fs` and `fetch` calls run under the consuming application's grants | Nothing reads the name: a dependency is recognized by being declared, not by being labelled. It is what one author writes so another can find the package, and `gpui-shell` is the repository topic that spells it for a search engine. [`omarchy-ui`](https://github.com/huacnlee/omarchy-ui) is one, and it is the example on the rest of this page. ## Declaring a dependency `omarchy-ui` is a package of presentation components and theme utilities. One line in `gpui-shell.json` adds it: ```json { "id": "com.example.projects", "name": "Projects", "entry": "main.js", "dependencies": { "omarchy-ui": "huacnlee/omarchy-ui" } } ``` The map key is the bare module name — nothing inside the package chooses it. The manifest names the package the way an `as` clause names an import, so two applications may reach the same remote under different names, and renaming a repository does not rename the import: ```js import { AppShell, Button, CenteredWorkspace, MutedText, PageColumn, Surface, Title, } from "omarchy-ui"; export function render(cx) { const card = new Surface() .children([ new Title("Projects").build(cx), new MutedText("Choose a project to continue").build(cx), new Button("project-create") .label("Create project…") .onClick((_event, context) => context.notify()) .build(cx), ]) .build(cx); const page = new PageColumn("projects-page").child(card).build(cx); return new AppShell() .content( new CenteredWorkspace("projects-workspace").content(page).build(cx), ) .build(cx); } ``` Nothing else changes. The script is still the script from [Getting Started](/versions/v0.7.1/shell/getting-started); the dependency only widens what it may import. ## What resolves, and to what | Written | Resolves to | | -------------------------------------------------------- | -------------------------------------------------------------------------- | | `"omarchy-ui"` | The package entry — see [Package entry](#package-entry) | | `"omarchy-ui/src/style"` | That file inside the checkout; the `.js` extension is optional | | `"./theme.js"` inside a package | A file inside that package's own checkout | | `"gpui-kit"` inside a package | The built-in module, exactly as in application code | | Another declared dependency name | The other package's entry — declared packages can see one another | | An application file, by bare name, from inside a package | Refused: a package cannot reach back into the application that imported it | A specifier that resolves outside the checkout it started in is refused before the module is loaded, so `../` cannot walk out of a package and into the cache beside it. Inside the application directory the same boundary is the application root, which is the rule [the sandbox](/versions/v0.7.1/shell/capabilities#the-sandbox) already applies to relative imports. **A dependency is not a second sandbox.** It is evaluated in the application's own context and holds exactly the grants the manifest holds: a package that reads a file is reading it under your `fs.read` scope. Declaring a dependency is trusting its code the way importing a Rust crate is, and the ref you pin is what decides which code that is. ## Selecting a version The string form is a strict GitHub shorthand or a full Git URL, each with an optional `#ref`: ```json { "dependencies": { "default-main": "huacnlee/omarchy-ui", "named-ref": "huacnlee/omarchy-ui#v1.2.0", "commit": "https://github.com/huacnlee/omarchy-ui#0123456789abcdef0123456789abcdef01234567", "remote-head": "https://github.com/huacnlee/omarchy-ui" } } ``` | Form | Selects | | -------------------------- | ------------------------------ | | `owner/repository` | `main` | | `owner/repository#ref` | That branch, tag or commit-ish | | `https://…/repository` | The remote's `HEAD` | | `https://…/repository#ref` | That branch, tag or commit-ish | Shorthand is deliberately strict: exactly one `owner/repository` pair of alphanumerics, `.`, `-` and `_`, at most one `#`, no surrounding whitespace, and a fragment that is a valid Git ref name. Anything else is a manifest error rather than a URL guessed from a typo. A full URL may be any Git transport, `ssh://` and `git@host:owner/repo` included. **A branch, a tag or a remote `HEAD` is re-fetched and re-resolved on every application load; a commit ID always selects that commit.** Depending on a branch means the code changes under you the next time the window opens, which is convenient while you develop a package and a supply-chain decision once you ship one. Pin a tag or a commit for anything you do not control. Fetching needs `git` on the host's `PATH`, and it happens before script capabilities exist — it is gpui-shell running Git on the application's behalf, not the script reaching the network, so it is not covered by `capabilities.network` and does not need `fs.execute`. With nothing to fetch (the cache already holds the commit) a load performs no network access at all; with a moving ref and no network, the fetch fails and the application does not load. ## Package entry After the checkout exists, gpui-shell reads the package's root `package.json` and takes a string `main` as the entry. `omarchy-ui` publishes: ```json { "type": "module", "main": "src/index.js", "types": "src/index.d.ts" } ``` So `import { Title } from "omarchy-ui"` evaluates `src/index.js`. When `package.json` is missing, or has no `main`, the entry is the root `index.js`. The runtime reads `main`; an editor reads `types`. Both come out of the same file, which is why a package that ships `.d.ts` files needs nothing from the application to be fully typed at the call site. Malformed JSON, a non-string `main`, and an entry that is absent, not a file, or escapes the checkout all fail the load before any application JavaScript runs. ## The object form The original object form remains supported unchanged. It requires exactly one explicit `branch` or `tag`, and its repository-relative `entry` defaults to `index.js`: ```json { "dependencies": { "omarchy-ui": { "git": "https://github.com/huacnlee/omarchy-ui", "tag": "v1.2.0", "entry": "src/index.js" } } } ``` Existing manifests do not need to migrate. Moving to the string form means the package publishes its entry itself, through `package.json` `main` or a root `index.js`, instead of every consumer repeating it. ## The cache ```text ~/.gpui-shell/cache/dependencies/ ├── locks/.lock ├── mirrors/.git └── checkouts/// ``` `` is a SHA-256 of the exact fragment-free URL, which is both the remote's identity and its cache identity. A per-remote lock serializes mirror updates. Checkouts are commit-addressed and never rewritten, so concurrent launches and an older hot-reload generation each keep reading the tree they started with. The mirror's configured origin is verified against the manifest on every use, and it is the raw configured value that is compared — Git's `url.*.insteadOf` may still choose a different effective fetch URL, which is how a mirror or an internal host substitution keeps working. Git runs non-interactively, with credential prompts disabled and a 30-second limit per command, so a repository that wants a password fails with a message instead of hanging a window that is waiting to open. Nothing prunes this cache automatically. It is content-addressed, so deleting it is safe: the next load re-fetches what it needs. ## What an editor sees The runtime answers `import { Title } from "omarchy-ui"` from the manifest. An editor answers it by walking `node_modules` up from the importing file, and it has never heard of `gpui-shell.json`. Left alone, a correct import is underlined as a module that cannot be found, and every name behind it loses its type, its parameter hints and its documentation. So every load — and `gpui-shell types` — links each materialized checkout into the application's `node_modules` under the name the manifest gave it: ```text projects/ ├── gpui-shell.json ├── main.js ├── gpui-kit.d.ts generated by the runtime — ignore it ├── jsconfig.json scaffolded once, then yours └── node_modules/ └── omarchy-ui → ~/.gpui-shell/cache/dependencies/checkouts// ``` The editor then reads the same files the runtime is about to execute, so the signatures and JSDoc it shows are the package's own and cannot drift from what runs. Only entries gpui-shell wrote are ever replaced or removed — a symlink into its own dependency cache, or a directory carrying its marker file. An installed package of the same name is left alone, and the link of a dependency the manifest no longer declares goes away. Where the platform refuses a symlink, such as an unprivileged Windows process without developer mode, gpui-shell writes a small package that re-exports the checkout instead: a bare import types the same way, and only a package-subpath import is left unresolved. A `jsconfig.json` is scaffolded when the directory has neither that nor a `tsconfig.json`, and it is written once — an existing configuration is never replaced. It is not decoration. An inferred `moduleResolution` can land on the one that never looks in `node_modules`, which underlines a dependency the runtime resolves fine; and the default `lib` hands a script the browser's globals, whose declarations collide with the ones `gpui-kit.d.ts` makes, so the file describing the API is itself reported as the error. `node_modules` is generated, like `gpui-kit.d.ts`. Ignore both: ```text gpui-kit.d.ts node_modules/ ``` The directory is called `node_modules` because that is the one place every editor looks; no package manager is involved and nothing comes from a registry. It also buys quiet: TypeScript treats what it resolves there as an external library, so a dependency's own implicit-`any` diagnostics stay out of your own. ## When fetching and linking happen | Invocation | Fetches and links | On failure | | ----------------------------------------------- | ----------------- | ---------------------------------------- | | `gpui-shell ` | Yes | Load fails; linking alone is best-effort | | `gpui-shell check ` | Yes | Reported as a check failure | | `gpui-shell types ` | Yes | Reported, with an exit status | | An embedded host's `ShellRuntime::load` | Yes | Load fails; linking alone is best-effort | | `gpui_kit::shell::write_dependency_links(root)` | Yes | Returned as an error to the caller | Fetching is what a load depends on, so a dependency that cannot be materialized fails the load. Writing the editor links is not: a read-only application directory is a reason to lose editor types, not a reason to refuse to run. `gpui-shell types` exists for exactly the case where that difference matters — it does the same work and reports what it could not do. Hot-reload picks up a package the same way it picks up an application file: each load is a new module generation, so restarting the application is enough to move a branch dependency forward. ## What can fail Every one of these is reported before the application's JavaScript is evaluated: | Message | Cause | | ---------------------------------------------------------- | ----------------------------------------------------------------- | | `GitHub shorthand must contain exactly owner/repository …` | A shorthand with a path, a scheme, or an invalid character | | `a string dependency #Git ref must not be empty` | A trailing `#` | | `could not clone Git dependency …` | Git failed: no such remote, no credentials, no network | | `git timed out after 30 seconds …` | A hung fetch, usually an interactive credential prompt | | `Git dependency … cache origin is …, expected …` | Two manifests disagree about one cache entry; remove it and retry | | `Git dependency … package.json main must be a string` | A `main` that is an object, or a path escaping the checkout | | `Git dependency … has no entry …` | `main`, or an object form's `entry`, names nothing | | `cannot resolve module … from …` | A subpath import with no such file, or one leaving the checkout | ## Publishing a shell package A shell package is a plain Git repository; `omarchy-ui` has no build output, no lockfile and no publish step. Beyond the five things that make it one, what makes it comfortable to depend on: - **A root `package.json` with `main`**, so consumers write one line and no `entry`. `"type": "module"` alongside it keeps the editor and the runtime agreeing that the source is ES modules. - **A single entry that re-exports the public surface.** `src/index.js` decides what a consumer can name; anything else in the checkout stays reachable by subpath, which is a useful escape hatch and a poor public API. - **Types beside the source**, through `types` in `package.json`. Generated `.d.ts` files or JSDoc both work, and both arrive at the call site through the link — a consumer's `jsconfig.json` needs no `paths` entry. - **Tags for releases**, so consumers can pin `#v1.2.0` instead of tracking `main`. - **A `gpui-shell` topic on the repository**, so someone looking for a shell package can find it. ## Read next | Page | What it covers | | --------------------------------------- | ------------------------------------------------------------------------- | | [Capabilities](/versions/v0.7.1/shell/capabilities) | The rest of the manifest: identity, versions, and what a script may reach | | [Getting Started](/versions/v0.7.1/shell/getting-started) | `gpui-shell types`, `check`, and the declarations a dependency joins | | [API Reference](/versions/v0.7.1/shell/api) | The built-in modules a package imports alongside yours | --- # Getting Started Source: /versions/v0.7.1/shell/getting-started License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/shell/getting-started and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `gpui-shell` is first of all a way to give a Rust GPUI application JavaScript extension points: the host builds the runtime, decides what a script may reach, and mounts script Views where it wants them. Running a script directory on its own — the `gpui-shell` binary below — is the development convenience that comes with that, not the point of it. ## Add the runtime to a Rust application A host does four things: initialize the library, build a runtime, grant the capabilities it is willing to grant, and mount a script View under a `ShellRoot`. The `gpui-shell` binary is itself just a thin host that does exactly this. ```rust use gpui_kit::shell::{Capabilities, ShellRuntime}; gpui_platform::application() .with_assets(gpui_kit::shell::AppAssets::new(root.clone())) .run(move |cx| { // Initializes gpui-base, the shell's default token palette, and the // style reflection table. gpui_kit::shell::init(cx); let runtime = ShellRuntime::new(cx).expect("script runtime"); // Nothing is permitted until the host says so. gpui_kit::shell::set_store_path(store_directory.join("store.json")); gpui_kit::shell::set_capabilities( Capabilities::new() .read_roots([root.clone()]) .write_roots([store_directory.clone()]) .store(true), ); cx.open_window(Default::default(), move |window, cx| { runtime.load(&root, window, cx) }) .expect("window"); }); ``` Two of those lines carry rules rather than mechanics. **`runtime.load(...)` returns the window's `ShellRoot`**, the same role `Root` has in a `gpui-component` window. It owns the dialog stack, the sheet, the toast stack, focus restoration and Tab navigation. A manifest selects the application entry and records capability requests; it never approves those requests. Both manifest-backed and bare directories run under the host's current default policy, and a bare directory uses `main.js`. **Capabilities default to empty.** `Capabilities::default()` grants nothing at all — no file, no storage, no clipboard, no process. The host decides, because only the host knows how far it trusts the code it is about to run. See [Capabilities](/versions/v0.7.1/shell/capabilities). Install a `tracing` subscriber too. The runtime reports script errors, unhandled promise rejections and illegal-phase calls through `tracing`; with no subscriber, every one of them is discarded and the symptom is a View that quietly stopped responding. ## The script it loads One file is enough. Create a directory with a `main.js` in it: ```js // hello/main.js import { View } from "gpui-kit"; import { v_flex, Button } from "gpui-base"; export default class Hello extends View { init() { this.clicks = 0; } render(cx) { return v_flex() .size_full() .items_center() .justify_center() .gap(12) .bg(cx.theme().colors.background) .child( div() .text_color(cx.theme().colors.foreground) .child(`Clicked ${this.clicks} times`), ) .child( Button.new("click") .h(28) .px(12) .items_center() .justify_center() .border(1) .border_color(cx.theme().colors.border) .bg(cx.theme().colors.surface) .text_color(cx.theme().colors.foreground) .on_click((_event, cx) => { this.clicks += 1; cx.notify(); }) .child("Click me"), ); } } ``` ```bash cargo run -p gpui-shell -- hello ``` Four things in that file are worth naming now, because everything else builds on them. **One module per crate that provides it.** `"gpui-kit"` holds GPUI's own elements and what the runtime adds — `View`, `div`, `text`, storage, scheduling. `"gpui"` is a compatibility alias for the same module, so `import { div } from "gpui"` is also supported. `"gpui-base"` holds gpui-base's layout helpers, components and theme — `v_flex`, `Button`, `InputState`. `"gpui-fps"` holds its performance overlay. The runtime also supplies a deliberately small JavaScript-standard layer: `buffer`, `path`, `url`, `crypto`, `zlib`, `console`, `process`, `os`, `fs/promises`, `net`, `websocket`, and global `fetch`. Application-relative imports remain confined to the application directory. Node-prefixed aliases such as `node:fs`, package lookup, and CommonJS `require` are not part of the contract. **`main.js` must `export default` a class extending `View`.** `init` runs once when the View is created; `render` returns one element, retained `Entity` or string, and runs when the View is invalidated rather than on every frame — see [When `render` runs](/versions/v0.7.1/shell/state#when-render-runs). **Style methods are `snake_case`, your own code is `camelCase`.** `items_center`, `on_click`, `text_color`, `gap_2` keep their Rust spelling, because the no-argument style surface is generated from GPUI's reflection table rather than written by hand. Anything the application declares itself — variables, methods, object keys — is ordinary JavaScript. The contrast is deliberate: a `snake_case` call is host surface, a `camelCase` one is your code. **Nothing repaints on its own.** There are no signals, no `useState`, no dependency arrays. Change state, then call `cx.notify()`. ## Running a script on its own A script directory can also be run directly, without writing a host. This is how the bundled example runs, and how a script is usually developed before it is loaded by the application that will own it. `gpui-shell` is not published to crates.io, so clone the repository and run it from the root: ```bash cargo run -p gpui-shell -- examples/js_todolist ``` That opens a window with a working todo list: a text field with retained state, controlled checkboxes, a confirmation dialog, a toast, icons loaded from the application's own directory, and storage that falls back to memory when it has not been granted. It exists to exercise the runtime rather than to be minimal — if something is broken, it shows there first. The argument is a **directory**, not a file. The runtime resolves that directory, reads `main.js` by default, takes the class that module default-exports, constructs one instance, and mounts it as the window's root View. If the directory contains `gpui-shell.json`, the binary validates that manifest first and uses its declared `entry` and capabilities. ## Check a script without running it JavaScript has no compiler, and this runtime does not add one. What it adds is the thing a compiler would have done for you: ```bash cargo run -p gpui-shell -- check hello ``` `check` loads the application and renders one frame into a window that is never shown, then exits `0` on success and `1` on failure. Because the script surface is dynamic — an unknown style method, a wrongly typed argument and a reused element are all runtime facts — building and rendering once is the only honest way to check it. What it catches: - syntax errors, with the script's own stack; - unresolved imports, and imports that escape the application directory; - a missing or malformed default export; - unknown style methods, with a `did you mean` suggestion; - wrongly typed style arguments, such as `.p("auto")`; - an element used twice. It opens no window, so it is usable from an editor, from CI, or from an agent loop. Add `--print-spec` to print the element description that was built: ```bash cargo run -p gpui-shell -- check hello --print-spec ``` That output is the arena's own debug dump — the tree of components and recorded operations, before anything is materialized. It is useful when the question is "what did my chain actually record?". ## Generate TypeScript declarations ```bash cargo run -p gpui-shell -- types hello ``` This writes `gpui-kit.d.ts` next to the application. Put `// @ts-check` at the top of a script and an editor will complete the whole API and reject a mistyped style method, a colour token that does not exist, or `.p("auto")` — at the call site, before it runs. It also sets up everything else the editor needs: each Git dependency the manifest declares is fetched and linked into `node_modules` under its declared name, so `import { style } from "omarchy-ui"` resolves to the same files the runtime will execute and carries the package's own types, parameters and JSDoc; and a `jsconfig.json` is scaffolded when the directory has neither that nor a `tsconfig.json`. See [Dependencies](/versions/v0.7.1/shell/dependencies). The declarations can be trusted because they are **generated from the tables the runtime dispatches through**, not transcribed from this documentation: - style method names come from the same list the JavaScript prelude loops over to build the element prototype; - each parametric method's argument type is _probed_ — the generator asks the runtime which literals that method accepts, so the difference between a length, a definite length, an absolute length, a colour and a bare number is decided by the code that enforces it; - the colour union comes from the installed palette's token names. Three things the declarations deliberately do not express, because no type could: whether a capability is **granted** (a denied `fs.readFile` still type-checks), the **lifetime** of an element or a `cx` (TypeScript has no affine types, so reusing an element still type-checks and still throws), and **which component a method suits** (every element shares one prototype, so `.checked(true)` is declared on all of them and is simply inert on a `div`). Regenerate the file after upgrading the runtime; the output is deterministic, so the diff is reviewable. ## Hot-reload ```bash cargo run -p gpui-shell -- hello --watch cargo run -p gpui-shell -- hello --dev # implies --watch ``` `--watch` polls the application directory four times a second, debounces a burst of writes for 200 ms, and reloads. A reload re-reads **every** module, entry point included — a hot-reload that quietly served a stale import would be worse than none, because it looks like it worked. A reload does all of its fallible work before it touches the live View. If the new code fails to load, the previous View keeps running, the error goes to stderr, and a toast with a stable id reports it in the window; the next successful reload retracts that toast. A broken save never costs you the window. `--dev` implies `--watch` and enables development mode before the runtime is constructed. It restores dynamic-code constructors and leaves built-in prototypes writable, while capability checks remain unchanged. See [Capabilities](/versions/v0.7.1/shell/capabilities#the-sandbox). ## Command reference ```text gpui-shell [--watch] [--dev] gpui-shell check [--print-spec] gpui-shell types gpui-shell --help | --version ``` | Argument | Meaning | | -------------- | ------------------------------------------------------------------------ | | `` | The application root, or the `main.js` inside it | | `check` | Load and render once without a window; exit `0` or `1` | | `types` | Write `gpui-kit.d.ts`, link the manifest's dependencies, scaffold config | | `--watch` | Reload when the sources change | | `--dev` | Development mode; implies `--watch` | | `--print-spec` | With `check`, also print the element description that was built | --- # GPUI Shell Source: /versions/v0.7.1/shell License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/shell and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `gpui-shell` exists to make a Rust GPUI application **extensible in JavaScript**. **The primary goal is plugin extension.** A host application compiles and ships once. After that, a new panel, a side tool or a piece of business logic arrives as a script loaded into the same process — no rebuild, no binary to redistribute, and no fork for a contributor who only wants to add a panel. **The secondary goal is writing a whole application in JavaScript.** The CLI runs an application directory on its own, which is a usable path in itself and also how a plugin is developed: get the script running standalone, then mount it in a host. **It is not an Electron or a Tauri.** There is no WebView, no DOM, no HTML or CSS, no browser engine, and no Node.js. A script View describes an interface when invalidated; GPUI can reuse that description on later frames without rerunning its script `render`. Those frames use the same element model and renderer as a Rust application on `gpui-base`. This does not mean an idle window continuously draws, or that all frame work is free of JavaScript: virtual-list item and dock chrome callbacks are exceptions. Taking the whole runtime costs [+13.5 MiB of binary in the measured build](/versions/v0.7.1/engine#what-linking-it-costs). Both goals rest on the same split. `gpui-shell` is built directly on [`gpui-base`](/versions/base), with [QuickJS](https://github.com/quickjs-ng/quickjs) running on the host's own thread. The host builds the runtime and grants what a script may reach; the script draws real interface inside the same process. Rust keeps rendering, layout, text editing, virtualization, focus, overlays and every system capability; the script owns composition, presentation and business logic. ```js import { View } from "gpui-kit"; import { v_flex, Button } from "gpui-base"; export default class Counter extends View { init() { this.count = 0; } render(cx) { return v_flex() .size_full() .items_center() .justify_center() .gap(20) .bg(cx.theme().colors.background) .child( div() .text_3xl() .text_color(cx.theme().colors.foreground) .child(`${this.count}`), ) .child( Button.new("increment") .h(32) .px(14) .items_center() .justify_center() .bg(cx.theme().colors.primary) .text_color(cx.theme().colors.primary_foreground) .rounded(6) .on_click((_event, cx) => { this.count += 1; cx.notify(); }) .child("Increment"), ); } } ``` ## Why plugins come first `crates/base/src/dock` already holds half of what a plugin system needs: a layout that is pure data, a `PanelRegistry` that rebuilds a panel from a name in a persisted file, and a per-panel `serde_json::Value` that rides along with it. The missing half is that a panel's implementation has to be compiled into the host binary — nobody can contribute one without forking it. `gpui-shell` supplies that half. Plugins-first is not a positioning statement. It is the reason behind decisions that would each have gone another way for a runtime aimed only at standalone scripts: | Decision | Why it follows from plugins | | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `Capabilities::default()` is the empty set, and the host grants | A plugin is code someone else wrote; the grant has to be the host's, not a self-declaration in the plugin's own manifest | | A separate `Policy` per plugin, and unload cancels every task carrying it | Several plugins share one runtime, so grants must not bleed between them | | A script fault is a recoverable exception, and the host process survives | One broken plugin should not take the application with it | | A repaint replays a Snapshot and never enters the VM | The host answers for the frame budget, so a plugin's JavaScript cannot sit on it | | `HostModule` lends the host's own Rust to a script | Only meaningful when the script runs inside a host — a standalone application has no host to borrow from | | Dock panels keep their place and state across an uninstall | Plugins get installed and removed; a panel comes back where it was, with what it had | | The foundation ships no presentation, so the script owns all of it | A plugin has to look like part of its host, which takes control of every pixel | A standalone script application uses few of these. What it gains is the iteration speed — hot reload, `check`, and a generated `gpui-kit.d.ts` — which is why it sits second: it is where a plugin is developed and proven, rather than the point of the runtime. Text editing, syntax highlighting, LSP, virtualization and motion sampling stay in Rust. That line is a division of responsibility rather than a limit on the script: the host owns everything that has to sit close to the GPU and the system, so a plugin never becomes a variable in the application's performance or stability. **Plugins are the goal, not yet the whole interface** The machinery below a plugin is built and tested — manifest parsing and discovery, load and unload, a per-plugin policy and data directory. A script now contributes panels and draws a dock's chrome: `DockArea`, `dock_area(...)` and `DockArea.register_panel` are public, and a layout with a script's panels in it survives a restart. What is still missing is the rest of the contribution registry (`gpui.command`, `gpui.keymap`), the authorization UI, and a CLI that uses `PluginManager`. **What runs end to end today is the standalone path, docks included.** See [Dock and Panels](/versions/v0.7.1/dock). ## What defines it ### Architecture: the script describes, the host renders A script never holds a GPUI element. It records a **description** of one — every call in a builder chain writes an operation into an arena, and Rust replays those operations into real elements when a frame needs them. Layout, painting, hit testing, scrolling, IME and text editing stay in Rust and never call back into the script. [How a script becomes an interface](#how-a-script-becomes-an-interface) traces one pass of that. The engine is a parameter of the design rather than a part of it. QuickJS is the only one today, but everything above the seam — the arena, the materializer, the call scope, the style table, the theme, the capability model, the overlay host, hot-reload — names no VM anywhere in its source. See [The engine seam](/versions/v0.7.1/engine). ### Capability: a whole application layer, not a widget set A script gets what a Rust application built on `gpui-base` gets: elements and layout, links and controls, a fluent style surface over semantic theme tokens, View state through `init` / `render` / `cx.notify()`, retained host state such as a text input's rope and selection, dialogs, a sheet and toasts, asynchronous tasks, native transitions and springs, and gated filesystem, storage, clipboard, process, HTTP, TCP and WebSocket surfaces. Around that: `--watch` hot-reloads on save, `gpui-shell.json` declares identity and least-privilege capabilities before code runs, a generated `gpui-kit.d.ts` describes the whole API to an editor or a model, and `check` reports mistakes before the application runs. `gpui-kit.d.ts` can go in `.gitignore` — it is generated. ### Performance: the script is not in the frame `render` does **not** run once per frame. It describes a script View into a Snapshot when that View is invalidated. On a later requested frame, GPUI can use the Snapshot without running that View's `render`. Pointer hover, cursor blinking, scrolling and native animation need not invalidate the script View; frame-path callbacks such as virtual-list item renderers are separate. The display's refresh rate does not determine how many frames an idle window requests. The runtime counts the two events separately, and the gallery's Shell story (`cargo run -- shell`) puts both counters on screen: Illustrative one-second timeline with 60 requested frames. If script data does not change, the View's JavaScript render track stays empty. If prices change every 50 milliseconds, the View is rebuilt about 20 times while other frames reuse its Snapshot. This does not describe idle frame cadence. Illustrative one-second timeline with 60 requested frames. If script data does not change, the View's JavaScript render track stays empty. If prices change every 50 milliseconds, the View is rebuilt about 20 times while other frames reuse its Snapshot. This does not describe idle frame cadence. | What the interface is doing | Frames a second | JavaScript runs a second | | ------------------------------------------------- | --------------- | ------------------------ | | Repainting, with nothing JavaScript reads changed | 60 | 0 | | Prices moving every 50 ms | 60 | 19 | This is an illustrative interval in which 60 frames were requested, not an idle behavior or a guaranteed frame rate. The number of frames actually produced depends on scheduling and workload; the script render count depends on invalidations and coalescing. In the second row, 41 frames use a description that already exists. The script description cost is paid when that View is invalidated, rather than on every frame. In the measured 443-node benchmark, running `render` and recording the interface into a Snapshot took 1.1 ms; a cached frame took 1.3 ms for GPUI work, with no script `render`. These are timings for that workload and machine, not a sustained FPS guarantee for a complex application. | | Cost per requested benchmark frame | | ------------------ | ------------------------------------------------------------------- | | Without a Snapshot | 1.1 ms (JS render) + 1.3 ms (Rust render) = **2.4 ms/frame render** | | With a Snapshot | **1.3 ms** | Growing the panel does not change the fact that a cached frame does not rerun the script View. The [benchmark](/versions/v0.7.1/engine#the-measurement) covers sizes up to 8,403 nodes; its cached-frame test asserts that the View's script `render` does not run. Its timings still grow substantially with panel size. ### Size: a script runtime for +13.5 MiB A host that runs a real script application ships a **26.1 MiB** binary and holds **81 MiB** resident, QuickJS and the whole Standard Runtime included. Taking the dependency costs **+13.5 MiB of binary and +14 MiB of memory** over the same application without it. That figure is a constant, not a proportion: the component gallery — five times the size — adds the same 13.5 MiB. [What linking it costs](/versions/v0.7.1/engine#what-linking-it-costs) gives the pair it was measured on, and where the megabytes go. All figures here were taken on a MacBook Pro (M3, 8 cores, 24 GB): the frame and run counts from the Shell story, the milliseconds from a release build of the benchmark, the binary and memory figures from release builds of `examples/hello_world` and the `gpui-shell` CLI. ### Security: nothing by default, and a language trimmed to match `Capabilities::default()` is the empty set — no file access, no storage, no clipboard, no process execution, no network. The host decides the grant before loading a View, which then keeps that grant for its lifetime; every path in the `fs` surface goes through **one** resolver that refuses anything landing outside a granted root. Below the grants, the sandbox trims the language itself, because one VM will eventually host several plugins: `eval` and all four function compilers are gone, the built-in prototypes are frozen so one plugin cannot change `Object.prototype` for another, module resolution is confined to the application directory, and the heap (256 MiB), interpreter stack (1 MiB) and time in a single call (50 ms in `render`) are capped. That time limit is an interrupt a `catch` block cannot swallow, which is measured by a test. See [Capabilities](/versions/v0.7.1/capabilities). ## How a script becomes an interface How a script becomes an interface: the script describes elements, Rust materializes them, GPUI paints How a script becomes an interface: the script describes elements, Rust materializes them, GPUI paints The diagram traces one frame, and the shape of it explains most of this documentation. GPUI elements are values that are **consumed** when used: `RenderOnce::render` takes `self` by value, `.child()` takes its child by value, and a View rebuilds its whole element tree on every redraw. A JavaScript object can therefore never _be_ a GPUI element — there is nothing for it to hold onto. So the script does not build elements. It **describes** them. Every call in a builder chain records one operation into an arena of element descriptions; the object the script holds carries nothing but an integer index into that arena. When GPUI asks the View to render, Rust replays the recorded operations into real elements, hands them to GPUI, and clears the arena. Layout, painting, hit testing, scrolling and IME never return to the script. Three consequences follow directly, and each has a page below: - **Elements are single-use.** The description is gone at the end of the pass, so a stored element throws on its next use rather than drawing something unexpected. See [Elements](/versions/v0.7.1/elements). - **The `cx` handed to a call belongs to that call.** It carries a generation number, checked against the live call stack, so a `cx` kept across an `await` reports a clear error instead of touching a dead stack frame. See [State and Views](/versions/v0.7.1/state). - **Callbacks belong to the render that registered them.** They are replaced wholesale by the next render, which is what keeps script closures from accumulating in the host. See [Elements](/versions/v0.7.1/elements). All three fall out of binding a script to an element model that consumes its values. ## Presentation belongs to the script Most scripting layers hand a script a set of finished widgets and let it arrange them. This one has none to hand over, because the layer underneath it has none either. `gpui-base` controls carry no visual style at all. `Button::new("save")` in Rust has no padding, no background, no radius and no size, and that is the contract. The JavaScript bindings preserve it exactly: `Button.new("save")` with no styling draws nothing but its children. The consequence is the point. **Because the foundation ships no presentation, the script owns all of it** — every colour, every pixel of spacing, every hover state, every corner radius. That is the same trade a Rust application makes when it builds on `gpui-base` instead of `gpui-component`; the difference is that here the trade is made in a file you can save and see the result of immediately, with no `cargo build` in between. What the script gains in exchange for the extra typing is the whole application layer. Changing a button's radius does not mean going back to Rust. ## Where it fits - **Adding plugin support to an existing GPUI application — the primary case.** Plugins run inside the host process under capabilities the host grants one at a time, starting from none. Extending the product stops meaning a fork or a new release: interface and business logic ship as script and change without recompiling or redistributing a binary, and a failing plugin surfaces as a recoverable error rather than taking the host down. - **Writing a complete application in JavaScript on `gpui-shell` — the secondary case.** The whole application layer — elements, styling, View state, overlays and system APIs — while rendering, text editing, virtualization and every animation frame stay in Rust. It is also where a plugin is written and proven before it is mounted in a host. ## Where it sits ```text JavaScript application main.js · Views · styles · business logic │ import { … } from "gpui-kit" ▼ gpui-shell engine seam · element descriptions · call scope style table · theme tokens · capabilities ShellRoot (dialogs, sheet, toasts) · scheduler │ ▼ gpui-base behavior · state · infrastructure (no style) │ ▼ gpui elements · styling · rendering · GPU · platform ``` `gpui-shell` sits beside `gpui-component` rather than beneath it: both are consumers of `gpui-base`, and both supply a presentation layer that Base does not. `gpui-component` supplies one in Rust, finished and coherent. `gpui-shell` supplies the machinery for a script to supply its own. ## Read next | Page | What it covers | | --------------------------------------- | ------------------------------------------------------------------------------------------------------ | | [Getting started](/versions/v0.7.1/getting-started) | Running the example, the smallest application, `check` and `types` | | [Examples](/versions/v0.7.1/examples) | The two applications in the repository, and what to copy from them | | [Elements](/versions/v0.7.1/elements) | Constructors, `child` / `children` / `when`, and why an element is single-use | | [Styling](/versions/v0.7.1/styling) | The fluent style surface, lengths, colour tokens and state styles | | [State and Views](/versions/v0.7.1/state) | `init` / `render`, `cx.notify()`, retained state, async | | [Overlays](/versions/v0.7.1/overlays) | Dialogs, the sheet, toasts, and the phase rule | | [Capabilities](/versions/v0.7.1/capabilities) | `gpui-shell.json`, default deny, filesystem, storage, process and network APIs | | [Dependencies](/versions/v0.7.1/dependencies) | Shell packages: what makes one, how a manifest names and pins it, and the types an editor gets | | [Hosting](/versions/v0.7.1/hosting) | The Rust side in full: mounting, refreshing, metrics, exit, hot-reload | | [HostModule](/versions/v0.7.1/host-module) | Lending the host's own Rust to a script, and the plain-data boundary | | [Dock and Panels](/versions/v0.7.1/dock) | A script View as a dockable panel, the chrome you draw for it, and what survives a restart | | [Performance](/versions/v0.7.1/performance) | What a script costs: invalidation against description size, the View as the boundary, and the counters | | [The engine seam](/versions/v0.7.1/engine) | QuickJS, why the seam exists, and the measurements that tell script cost from frame cost | ## Status The crate is at milestone **M0**: a feasibility baseline, not a stable interface. It is not published to crates.io, and the script API is expected to change. What is documented here exists and works; what is missing is called out on the page where you would go looking for it. The design is specified in the [GPUI Shell design document](https://github.com/longbridge/gpui-kit/blob/main/docs/gpui-shell.md), and the crate lives at [`crates/shell`](https://github.com/longbridge/gpui-kit/tree/main/crates/shell). --- # Performance Source: /versions/v0.7.1/shell/performance License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/shell/performance and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. [The script is not in the frame](/versions/v0.7.1/shell#performance-the-script-is-not-in-the-frame) is the claim the runtime is built on. This page is what follows from it: once a repaint no longer runs JavaScript, the cost that is left has a shape small enough to write down. ```text script cost = how often a View is invalidated × what describing that View costs ``` Neither factor is the display refresh rate by itself. A window that repaints at 120 Hz does not run the View's JavaScript merely because more frames are presented; a View nobody has invalidated runs no script `render`. The left factor depends on when you call `cx.notify()` and on other invalidations such as a theme change; the right one is how much interface sits behind one call. Display refresh rate is a ceiling or target for scheduling, not a promise that an idle window redraws continuously. Everything below is one of those two, or a way of telling which is the problem. ## Every View has its own Snapshot GPUI Shell gives each JavaScript View a Snapshot of its own: the description that View's `render` produced, kept in Rust. **A View's Snapshot is reused until that View changes.** When GPUI does request another frame, it can use that Snapshot without running the View's JavaScript `render`. This says nothing by itself about how often an idle window presents a frame, or about the CPU and GPU work in the rest of that frame. ```text the View changed ──▶ render() ──▶ a new Snapshot ──▶ frame the View did not ─────────────────▶ the Snapshot it has ──▶ frame ``` Snapshots are per View, not per window. A window holding a hundred Views holds a hundred Snapshots, and each one is invalidated on its own: | What happens | What runs | | --- | --- | | `Watchlist` calls `cx.notify()` | `Watchlist.render`, and nothing else | | The parent calls `cx.notify()` | The parent's `render`. Each child answers the frame from its own Snapshot | | `this.chart.set_props({ symbol })` | That child's `update` and `render`. The parent is not rebuilt | | A child of a child calls `cx.notify()` | That child's `render`. Invalidation does not travel upward | | The theme changes | Every View, because a Snapshot bakes in the colours it was built with | A window drawn as nested Views: a sidebar, a watchlist holding four rows that are Views of their own, a chart, and a detail pane holding two more. Three phases repeat. A price ticks and only the MSFT row is marked as running its script, while every other View replays the description it already published. The list reorders and the watchlist itself runs while its four rows do not, because a parent records a handle per child rather than the child's description. The theme changes and every View runs at once, because a Snapshot bakes in the colours it was built with. A window drawn as nested Views: a sidebar, a watchlist holding four rows that are Views of their own, a chart, and a detail pane holding two more. Three phases repeat. A price ticks and only the MSFT row is marked as running its script, while every other View replays the description it already published. The list reorders and the watchlist itself runs while its four rows do not, because a parent records a handle per child rather than the child's description. The theme changes and every View runs at once, because a Snapshot bakes in the colours it was built with. ## Split a large View into small ones A View is rebuilt whole. There is no partial rebuild inside one: if a View's description is four hundred nodes, any change rebuilds all four hundred, however small the change was. That is what makes a large View expensive. Everything it draws shares one Snapshot, so the data that changes most often invalidates the parts that never change along with it. In a market terminal, one price moving re-describes the chart, the sidebar and the order book too — not because they changed, but because they sit inside the same View. Splitting is the fix. Give each part that changes on its own a View of its own with `cx.new`, and a change reaches one Snapshot instead of all of them: ```js import { View } from "gpui-kit"; export default class Terminal extends View { init(props, cx) { this.sidebar = cx.new(Sidebar); this.watchlist = cx.new(Watchlist, { symbols: props.symbols }); this.chart = cx.new(PriceChart, { symbol: props.symbols[0] }); this.detail = cx.new(Detail, { symbol: props.symbols[0] }); } render() { return h_flex() .child(this.sidebar) .child(this.watchlist) .child(v_flex().child(this.chart).child(this.detail)); } } ``` On the 40-row watchlist this page measures, describing the whole panel costs **0.315 ms** and describing one row costs **0.012 ms** — 361 nodes against 9. Nesting itself costs almost nothing to weigh against that: a parent records a handle per child, not the child's description. So a complex interface is not, by itself, a performance problem. A large View is. And splitting for performance means splitting into **Views** — not into plugins, applications or processes. Reach for a second application when you want a second *authority*, which is [Capabilities](/versions/v0.7.1/shell/capabilities), not when you want a second cache. ## Notify what a reader can see `cx.notify()` is the whole dependency system, and it means one specific thing: **my description is out of date.** It is not an event notification, and using it as one is the most common way to make a script expensive. A feed handler is the usual case: ```js onQuote(quote, cx) { this.quotes.set(quote.symbol, quote); cx.notify(); // every tick, including the ones nobody is looking at } ``` If the View draws twenty symbols out of a subscription of two thousand, that `notify` pays for a full description of the panel on every tick of every symbol it does not draw. The fix is a condition, not a faster render: ```js onQuote(quote, cx) { this.quotes.set(quote.symbol, quote); if (this.visible.has(quote.symbol)) cx.notify(); } ``` Three rules follow from the same idea: - **Invalidate the View that changed.** State that belongs to one child should live on that child and be notified there, rather than on the parent that mounts it. - **Several notifications before the next frame can share one script render.** See below. The handlers and their other work still run, so condition notifications on changes the View actually displays. - **From the host, `cx.notify()` and `ScriptView::refresh` are different requests.** A bare `notify` repaints the description that already exists. If Rust changed state the script reads through a [HostModule](/versions/v0.7.1/shell/host-module), the description is stale and only `refresh` says so. See [Hosting](/versions/v0.7.1/shell/hosting#refreshing-a-view-from-host-state). ### What `notify` does, and what coalesces it `cx.notify()` rebuilds nothing. It sets a flag on the View saying its description may be stale, and asks GPUI to draw. The rebuild happens later, inside the frame, and only if the flag is still set. So every notify between two frames collapses into one `render` — whether they came from three event handlers, from a task in a loop, or from the host: ```text notify notify notify ──▶ one frame ──▶ one render() ``` Setting a flag three times is setting it once. Nothing is dropped: all three handlers ran and all three changed state; what they share is the single rebuild that follows. **Coalescing bounds script renders to at most one per View per frame.** For example, if a window presents 120 frames in a measured second while a feed delivers 1,000 notifications, that View can render at most 120 times in those frames. It does not make the 1,000 handlers free, establish an actual 120 FPS rate, or imply that every display refresh produces a frame. The runtime adds no separate script-render throttle to tune. GPUI's scheduling coalesces these requests into the next frame it processes; how soon that frame is presented depends on the window, platform, and workload. ### What the cache costs in memory A View holds **two** descriptions: the one it published, and the one it replaced. The second is kept a moment longer because an event can still be dispatched against a frame that has already been superseded, and the handlers that frame needs belong to that older description. There is no third. Publishing a new description drops the oldest, and dropping it retires the callbacks registered with it. So the ceiling is two descriptions per live View, and nothing accumulates with time: a View that has re-rendered a million times holds exactly what a View that rendered twice holds. Closing a panel drops its View, and both of its descriptions go with it. This is the other reason to split a large View rather than fear splitting: a hundred small Views hold a hundred small pairs, which together are the same interface described twice — not a hundred times. ## Frame rate and presentation latency are different failures Two things can be wrong with a running interface, and only one of them shows up as FPS: ```text Rendering FPS is the frame smooth? State → presentation how long after state changes does the reader see it? ``` Missing a `cx.notify()` may leave the displayed data stale without causing a dropped frame. If some other activity causes frames, GPUI can keep using the last published description, and a frame-rate counter can look healthy while the data is wrong. If nothing requests a frame, the window may simply remain still. The stale data becomes visible only after another invalidation refreshes that View; frame-rate measurements alone cannot detect the missing notification. | Symptom | Which number is wrong | Usual cause | | --- | --- | --- | | The window stutters during repaints even though script state is unchanged | Frame time / FPS | Frame work may be too large, for example layout, paint or virtual-list item work; see [the measurement](/versions/v0.7.1/shell/engine#the-measurement) | | The window stutters while a feed is running | FPS *and* invalidation | One boundary being rebuilt too often, too large, or both | | The window is smooth and the data is late | Presentation latency | A `notify` that was skipped, deferred behind an `await`, or issued as a host `cx.notify()` where `refresh` was meant | Diagnose them separately. An FPS reading that never dropped is not evidence that invalidation is correct. ## Reading the counters The runtime counts the two events apart, and the host can read them with `runtime.read_metrics()` — see [Watching what it costs](/versions/v0.7.1/shell/hosting#watching-what-it-costs) for the API and the delta-against-a-baseline pattern that turns them into per-second rates. | Reading | The question it answers | | --- | --- | | `script_renders()` | How often JavaScript ran. Follows `cx.notify()`, reloads and theme changes — never frames | | `materializations()` | How often a dirty Snapshot became elements. Clean window frames reuse the cached GPUI subtree | | `mean_script_render()` | What one description costs, host calls included | | `mean_native()` | How much of that was inside HostModule functions rather than describing | | `slowest_script_render()` | The worst single build in the run | | `frame_script_calls()` | Entries into the VM from the frame path — [virtual list](/versions/v0.7.1/shell/elements) item renderers and [dock](/versions/v0.7.1/shell/dock) chrome handlers, which are the only two | | `structure_repeat_rate()` | Of the rebuilds that had a predecessor, what fraction described the same *shape* — see below | What the shape of a reading says: - **`script_renders` per second far above the rate the data actually changes** — a `notify` is firing on things the reader cannot see. Condition it. - **`script_renders` reasonable, `mean_script_render` high** — the boundary is too large. Split the View. - **`mean_native` most of `mean_script_render`** — the cost is in the host functions the description calls, not in the description. Read them once into fields before `render`, not per node. - **`slowest_script_render` far above the mean** — one build is paying for something the rest are not: a collection materialized on first render, or a rarely-taken branch that describes far more than the common one. A mean that drifts as a whole is system load instead. Repeated `cx.theme()` calls do not repeatedly cross the native snapshot boundary. The runtime synchronizes a lightweight theme revision before a description starts; components then share one frozen JavaScript object until the semantic tokens or appearance change. Reading the theme in each component is therefore a cache lookup, not a reason to serialize the palette again. ## Where the Snapshot cache stops The Snapshot removes the cost of **no change**. It does not remove the cost of a **small change**. A Snapshot holds structure and values together: ```text StockRow ├── Symbol("AAPL") ├── Price("230.42") └── Change("+1.42%") ``` When the price becomes `230.51`, the structure is identical and only one leaf differs — but a new description is the only way to say so, so the whole View is described again: every `div()`, every `.gap()`, every `.bg()`, every crossing into Rust. That is the dirty-render path, and on a fast feed it is the one that runs. Three lanes, bars to the same scale. Nothing the View reads changed: no bar, no script runs, the frame replays the description already published. A value changed, which is what happens today: the whole panel is described again at 0.315 milliseconds, however small the change. The same change with the row a retained View of its own: 0.012 milliseconds, about a twenty-sixth as long, because nine nodes are described instead of 361. Three lanes, bars to the same scale. Nothing the View reads changed: no bar, no script runs, the frame replays the description already published. A value changed, which is what happens today: the whole panel is described again at 0.315 milliseconds, however small the change. The same change with the row a retained View of its own: 0.012 milliseconds, about a twenty-sixth as long, because nine nodes are described instead of 361. The lever is the one this page opens with: **shrink the boundary that has to be rebuilt.** On the watchlist above, describing the whole panel costs 0.315 ms and describing one row costs 0.012 ms — 361 nodes against 9. Putting the row behind a View of its own is what turns the first number into the second, and it is available today. `structure_repeats()` and `structure_changes()` are how you check that the boundary is doing what you think. They count how often a rebuild produced the same *shape* as the description it replaced, differing only in the values inside it. A panel reporting a low rate is worth knowing about on its own: something in it is changing structure when you thought only a number was. --- # State and Views Source: /versions/v0.7.1/shell/state License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/shell/state and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A View is the one thing in this runtime that has an identity, survives a frame, and is owned by GPUI. Everything else — elements, callbacks, the `cx` handed to a call — belongs to the pass that created it. ## Defining a View ```js import { View } from "gpui-kit"; export default class Counter extends View { init(props) { this.count = props?.start ?? 0; } render(cx) { return v_flex().child(`${this.count}`); } } ``` `init` runs once, when the View is created. It is where state that survives frames is set up — plain fields, and any [retained entity](#retained-state) the View needs. `render` **returns one element, retained `Entity` or string**, and runs when the View has been invalidated rather than on every frame — see [When `render` runs](#when-render-runs). Returning anything else fails immediately: ```text render(cx) must return an element, an Entity, or a string ``` `main.js` must `export default` a View class. The host constructs one instance and mounts it as the window's root View; a module whose default export is not a class is refused with a message saying so. Never store an element on the instance. See [Elements](/versions/v0.7.1/shell/elements#elements-are-single-use). ## `cx.notify()` Nothing repaints on its own. There are no signals, no observables and no automatic dependency tracking. Change state, then ask for a re-render: ```js add(cx) { this.items = [...this.items, { id: this.nextId, caption, done: false }]; this.nextId += 1; cx.notify(); } ``` This runs against the whole default assumption of the front-end ecosystem, so it is worth stating flatly: **there is no `useState` here, and no dependency array.** Three reasons the runtime does not add one. GPUI is itself an explicit-`notify` model, and two reactive mental models inside one application interfere with each other rather than compose. Automatic tracking would mean wrapping every View instance in a `Proxy`, which is a permanent cost on the render path — and QuickJS has no JIT to amortize it. And a missing `notify` has a determinate symptom: the interface does not update. That is far cheaper to find than an automatic system that fires too often. Several `notify` calls inside one event handler collapse into a single repaint — and into a single `render`. ## When `render` runs `render` does **not** run once per frame. GPUI can request a frame for reasons unrelated to a script View's data — a pointer moving over a button, a text cursor blinking, a list scrolling, or an animation advancing. Those events need not rerun that View's script `render`. They also do not imply that an idle window draws at the display's full refresh rate. Frame-path callbacks such as virtual-list item renderers are a separate case; see [Performance](/versions/v0.7.1/shell/performance#reading-the-counters). So a `render` call does not describe *this frame*. It describes the interface once, into a Snapshot the runtime keeps: ```text cx.notify() ──▶ render() ──▶ Snapshot ──┬──▶ frame ├──▶ frame └──▶ frame … ``` The Snapshot is rebuilt when, and only when, something invalidates it: - `cx.notify()` from an event handler or an async task - a [hot-reload](/versions/v0.7.1/shell/getting-started) replacing the script - a theme change, because `bg(cx.theme().colors.surface)` records a real colour while `render` runs and bakes it into the Snapshot - the Host calling `ScriptView::refresh`, which is how Rust says it changed state your script reads through a [HostModule](/versions/v0.7.1/shell/host-module). A plain `cx.notify()` from the Host is a repaint and runs no script — the two are different requests Everything else replays the description you already produced, in Rust, without running any JavaScript. Three consequences worth holding on to: **Your script `render` cost follows invalidation, not the display refresh rate alone.** A View notified ten times a second needs at most ten rebuilds in that second, and notifications before the next frame may be coalesced. Repaints that reuse its Snapshot do not redescribe that View in JavaScript; other frame work still has a cost. **Hover, focus and active styles never call back into script.** `.hover(s => s.opacity(0.8))` is resolved into a native style description while the Snapshot is built, and GPUI applies it from there. A pointer moving across your interface runs no JavaScript at all. The same is true of an [`Input`](#retained-state)'s cursor and selection. **A failed `render` does not destroy the interface.** A Snapshot is published only after `render` returns successfully, so a script that throws leaves the previous description — and the handlers registered with it — exactly as they were. The failure appears as a banner **over** the interface that still works, saying it is one version behind and offering the detail for pasting somewhere; you keep your scroll position and your focus. A View whose very first render failed has nothing to keep, and gets the full error surface instead. Either way the failing `render` is not re-run until something invalidates the View again. ## Scope phases Every call from Rust into the script opens a scope carrying a **phase**, and the phase decides what the `cx` for that call may do. | `ScopePhase` | When | May | May not | | --- | --- | --- | --- | | `render` | Building an element tree | Read state, build elements, register callbacks | `notify`, open overlays, create retained state | | `event` | Handling a click or a change | Everything | Block | | `task` | Resuming asynchronous work | Everything | Block | | `layout` | Rendering one virtualized item inside GPUI's layout pass | Read state, build elements | `notify`, open overlays, create retained state | `cx.phase()` reports the current one, and `"none"` outside any host call. `cx.theme()` returns a deeply read-only Snapshot of gpui-base's current semantic theme for this call: direct color roles as well as `colors`, `spacing`, `radius`, `appearance`, and `is_dark`. Each refusal is a specific message, not undefined behaviour: ```text cx.notify() is not allowed during the `render` phase; request a re-render from an event handler instead ``` Notifying yourself while rendering is a loop, which is why it is refused rather than deferred. ## Two kinds of `cx` `&mut Window` and `&mut App` are borrows in GPUI: they live exactly as long as one call. A script object outlives any borrow, so the script-side `cx` cannot hold them. GPUI has a second flavour for the code that needs one anyway — `AsyncApp`, which `cx.spawn` hands its closure — and so does this. **`Context`** is what `render` and every event handler receive. It holds a **generation number**, checked against the live scope stack on every use, so keeping one past its call is an error rather than a corrupted frame: ```text cx is no longer valid: it was captured during an earlier call and used later. Use cx.spawn or take cx from the callback arguments instead. ``` **`AsyncContext`** is what `init` receives, and what `cx.spawn` and `cx.timer` hand their callbacks. It names no call at all — it resolves whichever one is running when you use it — so an `await` does not take it away: ```js async save(cx) { await cx.sleep(100); cx.notify(); // the same cx, still the right one } ``` Those three are exactly the places whose job is to set up or continue work that outlives the call they started in. Everywhere else the strict flavour is what you want, and being told you kept it too long is the point. `cx` exposes nothing but functions — `Object.keys(cx)` shows the methods and no generation — so a script cannot forge one. There is no third way to get one. A module's top level and a bare `constructor` are handed no context and cannot ask for one — which is the point rather than a gap: GPUI has no module top level either, and work started there would belong to no View, so nothing would own it and nothing would cancel it. Start it in `init`, which is where a View is handed its context. ## Retained state A View's own fields hold plain data. Anything with cross-frame machinery of its own — a text field's content, cursor position and undo history — lives in a GPUI entity, and the script holds a **handle** to it. ```js import { InputState, Input } from "gpui-base"; init() { this.draft = InputState.new({ placeholder: "What needs doing?" }); this.draft.on("submit", (_event, cx) => this.add(cx)); } render(cx) { return Input.new(this.draft) .flex_1() .h(28) .px(8) .border(1) .border_color(cx.theme().colors.input) .bg(cx.theme().colors.surface) .text_size(12); } ``` | Call | Effect | | --- | --- | | `InputState.new({ placeholder, value })` | Creates the state; both options are optional | | `state.value()` | The current text | | `state.set_value(text)` | Replaces it | | `state.on(event, handler)` | Subscribes; see below | | `state.release()` | Drops the handle | | `Input.new(state)` | The element that renders it | **Create it in `init` or an event handler, never in `render`.** Creating an entity needs a live window, and the render pass is the one place where doing so would be wrong anyway: ```text InputState.new(...) cannot run during render; create state in init() or in an event handler and keep it on the View ``` The script holds a handle, not the entity — GPUI owns that. Using a released handle throws rather than returning `undefined`, because an `undefined` in JavaScript travels a long way before it fails and by then the origin is gone: ```text this input state has been released ``` `Input` is the one element the runtime gives defaults to, and only three: a centred row, full width, and a click anywhere in the frame focuses it. Each is a default a script can override but should not have to remember — without the first, text sits at the top of whatever height the frame was given, which on screen looks like a bug rather than a missing style. ### Input events ```js this.draft.on("submit", (event, cx) => this.add(cx)); ``` | Event | Fires on | | --- | --- | | `change` | The text changed | | `submit` | Enter was pressed; `event.secondary` and `event.shift` say how | | `focus` | The field gained focus | | `blur` | It lost focus | Unlike a rendered `on_click`, this subscription **outlives the render that created it**. The subscription is owned by the runtime's handle store rather than by the script, because a script has nowhere to keep it and a handler that stops firing because a value was garbage collected is the kind of bug nobody finds. It is released when the handle is. A misspelled event name lists the valid ones: ```text unknown input event `changed`; expected one of: change, submit, focus, blur ``` ### Calendar state `CalendarState` is the same pattern holding something different: which month is being looked at, which date is chosen, and the day grid that follows from both. ```js init(_props, cx) { this.calendar = CalendarState.new(); this.calendar.on("change", (date, cx) => this.pick(date, cx)); } render(cx) { const grid = this.calendar.month_days()[0]; return v_flex().children( grid.map((week) => h_flex().gap(4).children( week.map((day) => Button.new(day) .selected(day === this.calendar.value()) .on_click((_e, cx) => { this.calendar.set_value(day); cx.notify(); }) .child(String(Number(day.slice(8)))), ), ), ), ); } ``` `month_days()` is why it exists: which dates fall in which week, where the neighbouring months' days go, and how many weeks this month needs. You draw the cells — base's `Calendar` element is **not** bound, because it walks the same grid calling a renderer once per cell, up to forty-two crossings into JavaScript per frame from inside GPUI's layout pass, for cells that carry no behavior. Dates are `"YYYY-MM-DD"`, a range is `[start, end]`, and nothing selected is `null`. A range stays a pair even before its end is chosen — `["2026-08-03", null]` does not collapse to its start — because "one day is selected" and "a range has been started" are different states to base, and its own logic branches on the difference. | Call | Effect | | --- | --- | | `CalendarState.new()` | Creates the state; like every retained handle, only in `init` or an event handler | | `month_days()` | The grid, as months of weeks of days; every week is seven days | | `year()` / `month()` / `today()` | The year and month the grid is for, and today as it was read at creation | | `value()` / `set_value(next)` | The selection | | `next_month()` / `prev_month()` | Moves the grid a month either way; illegal from `render` | | `on("change", handler)` | The only event, reporting a date being selected | | `release()` | Drops the handle | ## Asynchronous work Script code is asynchronous in the ordinary JavaScript way — `async` functions and native promises. The runtime supplies the parts a bare QuickJS does not have: a clock, an owner for pending work, and something to pump the job queue. | Call | Effect | | --- | --- | | `cx.sleep(ms)` | A promise resolved after `ms` on GPUI's foreground executor | | `cx.spawn(body, opts?)` | Calls `body(cx)` and adopts the promise it returns | | `cx.timer.after(ms, handler, opts?)` | Calls `handler(cx)` once | | `cx.timer.every(ms, handler, opts?)` | Calls `handler(cx)` repeatedly | Scheduling is on `cx` because that is where GPUI keeps it — `App::spawn`, and a timer from the executor a context hands out. There is nothing to import. All of them return work that runs on the main thread. Nothing script-visible ever leaves it: there is no `Worker`, and the VM and GPUI's `App` are both main-thread only. ```js flash(cx) { this.saved = true; cx.notify(); cx.spawn(async (cx) => { await cx.sleep(1500); this.saved = false; cx.notify(); }); } ``` Nothing is imported for that: `cx` arrives as the handler's second argument, and the `cx` its body receives is the async flavour that survives the `await`. **`spawn` adopts the promise, and that is the point.** An unhandled rejection is JavaScript's most common silent failure: the work stops, the interface keeps the state it had, and nothing is written anywhere. Here it reaches `tracing::error!` with the script's own stack. ### Ownership and cancellation Every task belongs to a View — `opts.owner`, or the View that is running when it is created. The task holds a weak reference, so when the panel that started the work goes away the callback is skipped rather than writing into state nothing will render again. ```js const handle = cx.timer.every(1000, (cx) => this.tick(cx)); handle.cancel(); handle.is_done(); ``` `owner: null` opts out and outlives every View; it is the only value other than the current View the runtime accepts today. Cancelling a `cx.sleep` leaves its promise **pending for ever**. That is what cancellation means for a promise: the continuation does not run, and no error is invented for code that asked to stop. `cx.timer.every` measures its interval from the end of one call, so a slow handler delays the next tick rather than stacking ticks behind it. ### Timers and standard host APIs ```text setTimeout -> cx.timer.after(ms, callback) setInterval -> cx.timer.every(ms, callback) clearTimeout / clearInterval -> cancel() the Task returned by after / every ``` `setTimeout`, `setInterval`, `clearTimeout` and `clearInterval` are throwing stubs. Use `cx.timer.after` for one-shot work, `cx.timer.every` for repeated work, and call `cancel()` on the returned `Task` to stop either one. Global `fetch` and the safe standard modules documented under [Capabilities](/versions/v0.7.1/shell/capabilities), including `websocket`, are real asynchronous host APIs. CommonJS `require` remains unavailable; use ES modules. Browser DOM and storage are absent: there is no `document` or `localStorage`. The global `window` is gpui-shell's overlay host for dialogs, sheets and toasts; it is not a browser `Window` and exposes no DOM. ## Not there yet - **Global and cross-view state.** There is no store beyond the persistence layer in [Capabilities](/versions/v0.7.1/shell/capabilities) and ordinary module scope. - **Actions and key bindings.** `gpui.action` and `gpui.keymap` are designed but not bound; the only key handling today is what `ShellRoot` installs (Tab, Shift-Tab, Escape). - **Multiple windows.** The host opens the window; there is no `gpui.open_window`. - **`gpui.gc_stats()`**, and the debug panel that would read it. --- # HostModule Source: /versions/v0.7.1/shell/host-module License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/shell/host-module and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. [Capabilities](/versions/v0.7.1/shell/capabilities) is the half that says what a script may **not** reach. This is the other half: what the host chooses to hand it. A script cannot load a native extension. `dlopen`-ed Rust has no stable ABI, and once it is inside the process it holds every permission the process holds — a sandbox that permits that does not mean anything. So the direction is reversed. **The host registers, at compile time, the Rust it is willing to expose**, and a script reaches exactly that and nothing else. ```rust use gpui_kit::shell::{HostModule, HostValue}; gpui_kit::shell::export_module( HostModule::new("workspace") .function("project_name", |_| Ok(HostValue::from("gpui-component"))) .function("version", |_| Ok(HostValue::from("0.1.0"))), )?; ``` ```js import { project_name } from "workspace"; project_name(); // "gpui-component" ``` A registered module is an ordinary ES module, resolved by the same loader that answers `gpui-kit` and `path`. One call registers one module, and a repeated name replaces the earlier module rather than merging into it — a host with three of them calls `export_module` three times. The rest of this page is what that costs and what it refuses. ## Why an import rather than a lookup The obvious alternative is a runtime registry lookup answering with a bag of functions: ```js // The shape this does not have. const workspace = native("workspace"); workspace.projectName(); // typo: throws, eventually ``` ```js // The shape it has. import { projectName } from "workspace"; // typo: fails to link ``` It loses twice, and both times on _when_ you find out: - **A misspelled export would be a run-time failure.** `workspace.projectName()` type-checks, loads, renders, and then throws on the frame that first reaches it — which, for a name only one branch touches, can be a long way from the edit that caused it. An import is resolved when the module graph is linked, so the same typo stops the application before its first line runs, naming the module and the export. - **The type declarations would have nothing to say.** Only the Host knows what it registered, so a lookup could offer no better than `Record any>` — leaving an application that wants real types to hand-write a `.d.ts` that nothing checks against the registry. A module specifier is a name declarations _can_ be written against, so they are [generated from the registry itself](#typing-them) and the typo is red in the editor. What the import does **not** freeze is the function behind the name. Every export is a forwarding stub that resolves through the registry on each call, so withdrawing a module still takes effect immediately: a script holding an imported function gets a refusal, not the withdrawn closure. Only the _set of names_ is fixed, at the moment the importing module is linked — which is why a host calls `export_module` **before** it loads an application. ## The registry is the grant The default registry is **empty**, the same shape as `Capabilities::default()`. A host that registers nothing has granted no extension surface, and a script that imports a module is told so by name: ```text HostModule `market` is not available: this Host registered none. HostModule access is granted by the embedding application, with gpui_kit::shell::export_module(...). ``` Register something and the message changes to name what does exist: ```text unknown HostModule `marker`; this Host registered: market, theme ``` ```text HostModule `market` has no function `quote`; it provides: quotes, ticks, watch, watch_all ``` There is deliberately no per-module capability to grant on top of this. The host chose the list, so **the list is the grant** — and revoking one is a matter of exporting a module of the same name, or clearing the set, which takes effect on the next call rather than the next restart. For a multi-application host, each public `Policy` carries its own frozen capabilities and its own module registry — built with `Policy::with_host_module`, one module at a time, the same way. That is how two plugins in one runtime receive different authority without swapping thread-local state across `await` boundaries. Identity and requested system permissions live in `gpui-shell.json`; HostModule registrations do not, because contributions are executable behavior registered by the host. ## Names the runtime keeps A HostModule shares one specifier namespace with the built-in modules and the [Standard Runtime](/versions/v0.7.1/shell/engine), and the resolver reaches those first. So registering `path` would not shadow the real `path` — it would register a module nothing can ever import, silently. `export_module` refuses such a name instead, and says who owns it: ```text `path` is one of the runtime's own module names and cannot be registered: a script importing it reaches the runtime, never this module. The reserved names are: gpui, gpui-base, gpui-fps, buffer, console, crypto, fs/promises, net, os, path, process, url, websocket, zlib ``` The full list is `gpui_kit::shell::RESERVED_SPECIFIERS`. Everything else is yours — and cannot be shadowed by a file in the application directory either, because HostModule registrations resolve before the application's own files. ## The boundary is plain data A Host function receives `HostArguments` and returns a `HostValue`: null, boolean, number, string, array, or object. Those six cases are the intersection of what a script engine and JSON can both carry, which is what lets one registry serve any engine behind the [seam](/versions/v0.7.1/shell/engine). It never receives a script handle. A handle would let the host keep a reference to a script value past the call that produced it — and past the call scope that made the surrounding context valid. Arguments come out by position, with the type check and the error message included: | Call | Yields | | ---------------------- | -------------------------------------------------------------------- | | `arguments.string(0)` | `&str`, or an error naming what arrived instead | | `arguments.number(0)` | `f64` | | `arguments.integer(0)` | `i64`, refusing a fractional number | | `arguments.boolean(0)` | `bool` | | `arguments.value(0)` | The raw `HostValue`, for a function that accepts more than one shape | | `arguments.get(0)` | `Option<&HostValue>`, for an optional argument | Returning a record is a builder rather than a map, because an object frequently _is_ the row a script renders and insertion order should be the host's to decide: ```rust use gpui_kit::shell::HostObject; HostObject::new() .field("symbol", "AAPL.US") .field("last", 224.22) .field("watched", true) ``` An error is a message, not a type: `HostError::new("no such symbol")` reaches the script as a thrown `Error` the script can catch. ## Three rules a Host function runs under **It must not call back into the script engine.** A host call happens inside a script call, which is inside a host call; re-entering the VM from there would run script code with an engine frame already on the stack, in the middle of a render pass. Holding no script handle makes that hard to express by accident, and the dispatcher refuses a nested call outright so a host that finds another route gets a diagnosable error rather than undefined behavior. **Reading and writing host state is the point.** A function reaches the ambient `App` through `gpui_kit::shell::with_current_app`, which is `None` outside a live call: ```rust fn with_app(read: impl FnOnce(&mut App) -> R) -> Result { gpui_kit::shell::with_current_app(read) .ok_or_else(|| HostError::new("only reachable while a script call is in progress")) } ``` **`cx.notify()` from inside one is delivered after the call unwinds.** So a Host function may mutate an entity and ask the Views watching it to re-render, without that re-render happening underneath the script that called it. ## Work that should not hold the thread `function` is synchronous: it returns a value, and the script gets that value. A slow one holds the thread that renders. `async_function` returns a future instead, and the script gets a promise: ```rust HostModule::new("db") .declarations("export function query(sql: string): Promise;") .async_function("query", |arguments| { // Synchronous half: on the main thread, inside the caller's scope. It // may read host state, and refusing here throws at the call site. let sql = arguments.string(0)?.to_owned(); let pool = with_app(|cx| cx.global::().handle())?; // Asynchronous half: on GPUI's background executor. Ok(async move { Ok(pool.query(&sql).await?.into_host_value()) }) }) ``` ```js import { query } from "db"; const rows = await query("select 1"); ``` ### The split is the design The closure runs on the main thread and returns the future. So the arguments are checked, and whatever the work needs is copied out, while `with_current_app` still answers. The future is then `Send + 'static` and driven elsewhere, where there is no `App` and no script engine to reach for. That is the same rule as [the three above](#three-rules-a-host-function-runs-under), made physical rather than enforced. A synchronous body is held to "do not re-enter the engine" by a run-time guard; an asynchronous one cannot express the violation, because on a background thread there is nothing to re-enter. ### What the script sees - **A refusal from the synchronous half throws at the call site.** `arguments.string(0)?` failing is a `TypeError` where the call was written, not a rejected promise the script has to await to hear about. - **A failure from the future rejects the promise**, carrying `module.function` in the message, so `try`/`catch` around the `await` works normally. - **A cancelled call stays pending for ever.** If the View goes away or its application is reloaded, the continuation never runs and no error is invented for code that was asked to stop — the same answer `cx.sleep` gives. Declare the return type as a `Promise` yourself. The registry checks that the names on both sides agree; it does not read signatures, so nothing catches a declaration that leaves the `Promise` off. ## Typing them A module describes its own TypeScript face, in Rust, beside the registration: ```rust HostModule::new("market") .declarations(r#" /** One row of the board, as it crosses the boundary. */ export interface Quote { symbol: string; last: string; watched: boolean } /** Every row on the board. */ export function quotes(): Quote[]; /** Flips one row's watched flag and answers the new value. */ export function watch(symbol: string): boolean; "#) .function("quotes", /* … */) .function("watch", /* … */) ``` The generated `gpui-kit.d.ts` emits that verbatim inside `declare module "market"`, so `import { quotes } from "market"` is checked exactly the way `import { div } from "gpui-kit"` is. Writing it here rather than in a `.d.ts` beside the script is what keeps the two halves one thing. A `.d.ts` would be a second file, in a second language, with nothing holding it to the registry. `export_module` compares the declared exports with the registered ones and refuses a mismatch: ```text HostModule `market` declares a different set of functions than it registers; registered but not declared: quotes; declared but not registered: prices ``` Renaming a function on one side is now a sentence at start-up rather than an editor that keeps completing a function the host deleted. Declaring nothing is allowed and costs only precision. An undeclared module is emitted with permissive signatures: ```ts declare module "audit" { import { HostValue } from "gpui-kit"; export function observe(...args: HostValue[]): HostValue; } ``` which still checks the module name and every export name — and is honest about the shape, since `HostValue` is exactly what crosses the boundary. `any` would be wider than the runtime: a script passing a function would type-check and then be refused at the call. ## A real one The gallery's Shell story registers one market module, and it is the entire extension surface its script has. Theme values come from `cx.theme()` instead. This is the host side: ```rust fn market_module(market: &Entity) -> HostModule { let read = market.clone(); let flip = market.clone(); HostModule::new("market") .declarations(MARKET_TYPES) .function("quotes", move |_| with_app(|cx| read.read(cx).to_host_value())) .function("watch", move |arguments| { let symbol = arguments.string(0)?; with_app(|cx| { flip.update(cx, |market, cx| { let watched = market.watch(&symbol)?; // Delivered after this call unwinds, so it cannot re-enter // the engine: the story and the script view re-render together. cx.notify(); Ok(HostValue::from(watched)) }) })? }) } gpui_kit::shell::export_module(market_module(&market))?; ``` And this is the script that uses it — the same `Market` entity a Rust panel beside it is rendering from: ```js import { quotes, watch } from "market"; const rows = quotes(); const watched = rows.filter((quote) => quote.watched).length; ``` Run it with `cargo run -- shell`. The two panels read one entity through two paths, which is what makes a mismatch between them visible immediately. ## Not there yet - **Classes and object identity.** A module exports functions. Exporting a class would mean handing the script a live host object, which the plain-data boundary above rules out; a factory function returning a record does the same work today. - **Per-function grants inside one registry.** A policy grants the registry the host assembled; it does not add another permission switch for each function. - **Streaming or callbacks into the host.** A script cannot hand a function to a HostModule; the module can only be called. --- # The Engine Seam Source: /versions/v0.7.1/shell/engine License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/shell/engine and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. The scripting engine sits behind one internal interface. Everything above it — the element description arena, the materializer, the call scope, the style table, the theme, the capability model, the overlay host, hot-reload — is engine independent, and only the engine module knows what a script value is. ```bash cargo run -p gpui-shell -- examples/js_todolist ``` [QuickJS](https://github.com/quickjs-ng/quickjs), via [`rquickjs`](https://github.com/DelSkayn/rquickjs) — which vendors the `quickjs-ng` fork — is the engine that ships and the only one today. It sits behind a `quickjs` cargo feature all the same, and building with no engine is a **compile error** rather than a crate that exports nothing. ## Why there is a seam at all The engine choice is the one decision in this runtime that could not be settled on paper. Everything else in the design follows from GPUI's element model and can be argued about with a whiteboard. The engine cannot, because the whole approach stands or falls on a single number: **how long it takes script code to describe a realistic interface.** Every method call in a builder chain is one crossing of the language boundary, and if that per-call cost is too high, no amount of design fixes it. What the number is *compared against* changed once a script `render` stopped being a frame render. A description is rebuilt when its View is invalidated and [reused on later requested frames](/versions/v0.7.1/shell/state#when-render-runs), so the script cost is paid per invalidation rather than per repaint. That makes the boundary cost matter less than it did — but it does not make it free, and it is still the number that would decide a second engine. So the seam is a way of not having to be right in advance. The decision is made by measurement, and a second engine would be a new module rather than a rewrite. JavaScript is the default for one reason, and it is a product reason rather than a technical one: **application code reads better in it.** With presentation owned by the script, the vast majority of an application is composing elements, writing styles and handling events — and the readability of that code decides whether the runtime is worth using. Classes, arrow functions, template literals and destructuring land squarely on that kind of code. The secondary benefit is that JavaScript is the best-covered language in model training data, which matters for one of the [three settings](/versions/v0.7.1/shell#where-it-fits). The cost is stated rather than glossed over. QuickJS **has no JIT** — it is a bytecode interpreter, so hot loops and per-call costs will not beat a JIT-compiled engine on principle. That is a real trade, and the benchmark below is where it would show up if it mattered. ## The measurement There are three costs here, and treating them as one was the original mistake. The benchmark describes a 40 × 5 grid of styled cells — 443 description nodes, roughly ten recorded operations each — and reports each cost separately: ```bash cargo test -p gpui-shell --release --lib benchmark -- --nocapture ``` | | What it measures | 443 nodes | Paid | | --- | --- | --- | --- | | **A** | script → Snapshot | **1.4 ms** | each measured invalidation | | **B** | Snapshot → GPUI elements | **0.7 ms** | each measured materialization | | **C** | a full cached repaint | **1.8 ms**, **no script `render`** | each measured repaint | Run it in release or the figures mean nothing. Every absolute number on this page comes from a release build on a MacBook Pro (M3, 8 cores, 24 GB), and moves with the machine. **C includes an assertion as well as a timing.** Fifty repaints of an unchanged View do not run that View's script `render`. If one does, the benchmark fails rather than merely getting slower. This test does not measure an idle window's repaint cadence or rule out other frame-path callbacks in a different interface. One size cannot show which of the three costs scale, so a fourth test walks the same panel up to 8,403 nodes. It sits behind `--ignored` because the largest size takes seconds: ```bash cargo test -p gpui-shell --release --lib benchmark -- --ignored --nocapture ``` Describing costs 1.1 ms at 443 nodes, then 5.1, 10.3 and 20.5 ms as the panel grows to 2,103, 4,203 and 8,403. A measured cached repaint — including GPUI layout and paint — costs 1.3, 5.9, 12.0 and 27.0 ms. Both timings grow roughly with node count. The cached repaints in this test do not rerun the View's JavaScript. Three things that shows for this workload: - **At 4,203 nodes, the Snapshot materially changes the frame budget.** A measured 12 ms cached repaint fits within the 16.67 ms budget of 60 Hz; hypothetically adding the 10.3 ms description cost to every frame would exceed it. This is a budget comparison for this benchmark, not a sustained 60 FPS result for an application. - **The description cost did not vanish, it moved.** About 20 ms for 8,403 nodes is paid when that View is invalidated rather than on every cached repaint, but it is still about 20 ms — which is why the per-call cost remains the number a second engine would be judged on. - **At the largest tested size, cached-frame cost is substantial without script `render`.** A 27 ms measured repaint includes GPUI element, layout and paint work. A View that large is a candidate for virtualization; a faster script engine alone would not remove that measured frame cost. Read A against the design's own budget — 1.5 ms for one script `render` — and it clears it, but with less room than hoped: the budget was derived from roughly 150 ns per recorded operation across 800 nodes, and the measurement reports about 320 ns across 443. A panel three times this size would not fit in one pass. What changed is how often that matters. In a hypothetical model that rebuilt this 1.4 ms description on each of 120 frames in one second, description alone would take 168 ms; the current cached-frame path pays that script cost on invalidation instead. The levers the design names for genuinely enormous panels — driving the per-call cost down, memoizing unchanged subtrees, virtualizing long lists — are still [not implemented](/versions/v0.7.1/shell/elements#not-there-yet), and are now optimizations rather than prerequisites. Two implementation choices came out of the same measurement and are visible in the runtime today: - **Elements are plain objects sharing one prototype**, with the style methods installed on that prototype by a JavaScript prelude that loops over the name list. Not one class per element, not a fresh closure per property access, and not 3,000 Rust closures. - **The diagnostic `Proxy` prototype is not the default.** Wrapping the prototype in a `Proxy` so a mistyped method can be named costs about 30% of the whole description pass, so the runtime keeps a plain prototype and re-runs a failed render once against the diagnostic one purely to produce the message. See [Styling](/versions/v0.7.1/shell/styling#unknown-methods). ### A live market-data workload The synthetic benchmark isolates costs; a Longbridge market terminal exercises them together. The following sample used a release build in the active window on a 3,840 × 2,160 display running at 144 Hz. Its watchlist received live quote updates while the selected instrument's details and five-day price chart were visible. A 120 FPS target gives **8.33 ms** per frame; the display refresh rate and the target are different numbers, and neither guarantees the achieved frame rate. Opt-in runtime counters sampled one-second intervals and separated script description work from native materialization: | Measurement | Observed range | | --- | --- | | Full JavaScript `render` plus Spec recording | **12.0–13.5 ms** per dirty render | | Snapshot materialization | **0.93–1.08 ms** per materialization | | Script renders caused by quote updates | **8–20 per second** | | Materializations while the window was active | **59–78 per second** | One active-window FPS HUD sample reported **69 FPS**, **10.9 ms** frame time and **18.3%** dropped frames. That HUD measurement includes GPUI layout and paint and therefore is not directly interchangeable with either runtime counter, but it confirms the end-to-end workload was missing the 8.33 ms target. The useful conclusion is narrower than “JavaScript is slow.” A clean Snapshot can be materialized in about 1 ms, comfortably inside the frame budget. A quote-driven dirty update, however, spends roughly 12–13.5 ms before that materialization is complete because the application rebuilds and records its full description. Repeatedly invalidating the root script View therefore dominates this workload; optimizing only the native materializer would not recover 120 FPS. These figures deliberately exclude debug builds and samples taken after the window lost active status. Both change scheduling and frame presentation enough to make their FPS readings unsuitable for an architectural comparison. They are also a workload measurement, not a replacement for the reproducible crate benchmark above: quote frequency, visible content, hardware and display timing all affect the absolute result. ## Threads and memory The VM and GPUI's `App` share one thread — the main one — inside one process. `ShellRuntime` is an `Rc` with `RefCell` interiors, so it is neither `Send` nor `Sync`. There is no worker and no second VM. The host process. On the main thread, GPUI's App and the QuickJS VM exchange plain function calls across the FFI boundary. Background workers handle timers and blocking I/O, then settle work on the foreground executor without touching the VM. Memory splits four ways: the JavaScript heap capped at 256 MiB, the description arena owned by the Snapshot, the callback arena keyed by Snapshot generation, and GPUI's frame arena which lasts one draw. The host process. On the main thread, GPUI's App and the QuickJS VM exchange plain function calls across the FFI boundary. Background workers handle timers and blocking I/O, then settle work on the foreground executor without touching the VM. Memory splits four ways: the JavaScript heap capped at 256 MiB, the description arena owned by the Snapshot, the callback arena keyed by Snapshot generation, and GPUI's frame arena which lasts one draw. Background work never touches the VM. Timers (`cx.sleep`, `cx.timer`) count down there, and filesystem, process, fetch, TCP and WebSocket operations hand off their blocking work there. Results settle on the foreground executor, so JavaScript continuations still run on the main thread in a `Task` scope. GPUI also does its own work on its own threads once the elements exist. Three consequences matter when profiling: - **A builder call is a function call.** It crosses the FFI boundary and nothing else — no serialization, no IPC round trip, no copy beyond the conversion of the argument itself. The benchmark reports that cost per recorded operation, and across the four panel sizes it lands at **240–340 ns**. - **Script work still shares the UI thread.** Filesystem, process, fetch, TCP and WebSocket operations hand blocking work to background workers and settle on the foreground executor, but JavaScript computation and HostModule calls run beside GPUI and must stay bounded. - **A runaway script cannot be preempted from another thread.** What cuts it off is the interpreter's own interrupt — 50 ms inside `render`, 500 ms inside an event handler — and a `catch` block cannot swallow it. Memory splits four ways, each with a different owner and a different moment of release: | What | Where it lives | Released when | | --- | --- | --- | | Objects, closures, module scope | The QuickJS heap, capped at 256 MiB | Its GC runs, or the runtime drops | | The element description arena | Rust; moved into the Snapshot it produced | That Snapshot drops | | Registered callbacks | A Rust arena keyed by Snapshot generation | That Snapshot drops and retires its generation | | GPUI elements | GPUI's own frame arena | The draw that built them ends | A View holds **two** Snapshots rather than one: the live description, and the one it replaced. The previous is kept a generation longer because a frame already in flight may still be reading it, and releasing it early would retire callbacks that frame still needs. Nothing that crosses the boundary is an object. An element handle is an integer index into the arena, retained host state — an `InputState`'s rope, cursor and selection — lives in a GPUI entity the script addresses through a handle, and every argument and result is plain data. ## What linking it costs Two numbers a host has to know before it takes the dependency: how much bigger the binary gets, and how much more memory it holds. Measured on the two smallest real programs in this repository, so the figures are the cost of this crate rather than the cost of whatever else an application happens to contain: | | `hello_world` | `gpui-shell` running `js_todolist` | Added | | --- | --- | --- | --- | | Binary, stripped | 12.6 MiB | 26.1 MiB | **+13.5 MiB** | | Binary, unstripped | 16.5 MiB | 33.8 MiB | +17.3 MiB | | Resident memory | 67 MiB | 81 MiB | **+14 MiB** | `hello_world` is 41 lines of Rust over `gpui` and `gpui-component` — a window and a counter. The `gpui-shell` CLI is the smallest host that can run a script application; here it is running `examples/js_todolist`, 519 lines of JavaScript across four modules, with a live QuickJS runtime behind it. Memory is the median of four runs, discarding a first run that reads high while caches are cold; the binaries are `--release` with the workspace's default profile, stripped with `strip(1)`. **The +13.5 MiB is a constant, and that is the most useful thing here.** The same pair measured on the component gallery — a program five times the size — adds the same 13.5 MiB stripped, where it is +19.8% rather than +107%. Two independent measurements agreeing to three significant figures is what makes this a fact about `gpui-shell` rather than a reading of one application. The memory rows look like they disagree and do not. On the gallery the difference is *within measurement noise*: that build reads 194–208 MiB across runs, and 14 MiB is simply below its own spread. The minimal program can resolve it because 67 MiB has less to hide it in. ### Where the binary goes Not mostly QuickJS. The interpreter is one to two megabytes; the rest is the Standard Runtime it arrives with. `fetch`, `websocket` and `crypto` bring `hyper`, `rustls`, `ring`, `h2`, a `webpki` root store and the compression crates, and `gpui-component` alone brings none of them — `hello_world` links no HTTP, no TLS and no `tokio`. The whole stack enters through this crate. That also explains why an older measurement of this table read +4.7 MiB: it predates the Standard Runtime. `fs`, `net`, `crypto`, `fetch`, `websocket` and `zlib` all arrived after it. There is no configuration that takes the element surface without them. `quickjs` is the only engine feature and it is `default`; building with `--no-default-features` is a `compile_error!`, and the Standard Runtime is inside that same feature. Splitting the two would be new work rather than exposing a switch that already exists. Two savings that look available and are not, both measured rather than reasoned about: - **Dropping the five upstream crates Shell does not register** — `llrt_fetch`, `llrt_fs`, `llrt_net`, `llrt_os` and `llrt_console`, which were dependencies only for a compile-time assertion — changes the binary by **zero bytes**. Their heavy features resolve to crates other dependencies already pull. They are gone anyway, because 14 crates that cost nothing in bytes still cost compile time and supply-chain surface, and depending on an upstream `fetch` that Shell deliberately does not use reads as though it does. - **Narrowing `reqwest` to what `fetch.rs` actually uses** — dropping `charset`, `multipart`, `socks`, `stream` and `macos-system-configuration`, none of which that file can reach — saves **0.1 MiB**. So the 13.5 MiB is not slack. It is `hyper`, `rustls`, `ring` and the interpreter, and a host that wants any of `fetch`, `websocket` or `crypto` links all of it. ### Per runtime The figures above are for one runtime. A host that mounts several — a plugin host with one per plugin — pays the engine's construction each time: a QuickJS runtime and context, the module registry, the globals, the host installers, and a 43 KB prelude that is parsed on every construction. The 256 MiB heap cap from [Capabilities](/versions/v0.7.1/shell/capabilities#the-sandbox) is a ceiling, not a reservation; nothing is committed until a script allocates it. ## What is on each side The proportion is itself the argument for the seam: above it is the actual design, below it is "what does a script value look like". | Above the seam — engine independent | Below the seam — what an engine implements | | --- | --- | | The render Snapshot: what one script `render` produces and what frames replay | Converting an engine value to the runtime's neutral value type | | The element description arena, single-use checking, and the debug tree | The module system's shape — ES modules and a resolver, versus `require` and a path list | | Materialization: descriptions into real GPUI elements, pure Rust | Method dispatch — functions on a shared prototype, versus an `__index` metamethod | | The call scope: phases, generations, and the crate's only `unsafe` | The callback handle type | | The style table, parametric styles and spelling suggestions | Converting the neutral error type into the language's own exception | | The default token palette and colour token resolution | How a View is defined — `class extends View`, versus a metatable | | The capability model and path resolution | The language-specific part of the sandbox | | Length and colour coercion | | | The neutral error type, the callback arena, the error overlay | | | `ScriptView`, `ShellRoot`, hot-reload | | None of the modules on the left names a VM anywhere in its source. That is what makes the seam real: it is not a trait, it is the fact that the rest of the crate reaches the engine through about a dozen entry points and nothing else. A trait would actually be worse here. The two handle types — a View class and a View instance — carry lifetimes of their own on the QuickJS side, and forcing them through a trait would move that complexity into the type system without removing any of it. The contract's load-bearing rule is about *when*, not what: **the engine's `build_snapshot` is the only entry into script `render`, and nothing calls it per frame.** An engine that rendered opportunistically — on a repaint, on a hover, on a timer — would put script cost back on the frame budget, which is the coupling the seam exists to prevent. Benchmark C is what would catch it. ## Portability If a second engine is ever added, **scripts will not be portable between them.** They would be different languages: a View is `class Counter extends View` in JavaScript and would be something else anywhere else. What has to be the same is everything around them — the binding surface, the render protocol, the phase rules, the capability model, the error messages. The requirement the design imposes is behavioural: the same use case must produce the **same description tree** under either engine, and the same application activity must trigger the **same number of script `render` calls**. That is what would keep the seam from rotting into two divergent runtimes. ## Known gap: async is not fully behind the seam The seam's contract does not yet cover asynchronous work. QuickJS requires the host to drain its job queue itself — nothing after an `await` runs until somebody asks — and that is not a shape every engine shares. So the scheduler cannot sit entirely above the seam. It needs two operations from an engine: turning a host task into something the script can await, and running the pending jobs. Promise jobs are drained at host-call boundaries, and a render that merely notices pending jobs queues a foreground drain instead of executing arbitrary continuations on the paint path. That preserves the central invariant: an async continuation may invalidate a View, but a frame never re-enters JavaScript just because it is a frame. Until both are addressed, the scheduler is QuickJS-specific. The rule it will be held to is the one that applies to any new capability: it goes above the seam unless it genuinely cannot be expressed there. ## Why not WebAssembly, or a separate process Two questions the seam invites. `gpui-shell` runs the VM **in the host process, on the main thread**, alongside GPUI's `App`. That is what makes the 240–340 ns per recorded call possible at all. A separate process would put an IPC round trip on every recorded builder call, and there is no budget for one even at the reduced frequency Snapshots buy. For the same reason there is no `Worker`: the VM and the `App` are both main-thread only. The wasm target is the other reason the seam is drawn where it is. QuickJS is plain C and compiles to WebAssembly; not every candidate engine does, and some generate machine code, which is a constraint on platforms that forbid writable-executable memory. Neither fact decides today's engine, but they are why "the engine is a parameter, not a part of the architecture" is written down at all. --- # Capabilities Source: /versions/v0.7.1/shell/capabilities License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/shell/capabilities and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A script gets **nothing** by default. No file access, no clipboard, no process execution, no network. `Capabilities::default()` is the empty set, and an assertion holds it there. The one exception is storage, and only at the manifest layer: an application that does not mention `storage` gets its own `localStorage`, the way a browser hands one to every origin without being asked. That is a convention about what an author has to _write_, not a hole in the model — the Rust `Capabilities` still deny it until a host says otherwise, and a manifest may still say `"storage": false`. See [Storage](#storage). The host grants what it grants, because only the host knows how far it trusts the code it is about to run. What it hands _out_ — its own Rust, exposed on purpose — is [HostModule](/versions/v0.7.1/shell/host-module). A View freezes its capabilities when it is loaded; changing the default affects applications loaded afterward, never code that is already running under an approved grant. ```rust gpui_kit::shell::set_capabilities( Capabilities::new() .read_roots([application_root.clone()]) .write_roots([data_directory.clone()]) .storage(true) .exit(true), ); ``` ## What a locally run application is granted Running a directory from the command line is an explicit act of trust — the same as `node app.js` — so `gpui-shell ` grants a specific, narrow set: | | | | ----------------- | -------------------------------------------------------- | | Read | The application directory, and its own storage directory | | Write | Its own storage directory | | Storage | Granted | | Clipboard | **Not** granted | | Process execution | **Not** granted | | Exit request | Granted | | Network | **Not** granted | An application can therefore read its own sources and assets and use its own storage, and nothing else. It is deliberately narrower than "everything", because an installed plugin will one day run through the same code path with a manifest deciding instead — and a grant that is generous for a local run would be the wrong default to inherit. ## Refusals name the fix Every denial ends in the thing to declare, not just the fact of the refusal: ```text filesystem read is not granted; declare capabilities.fs.read in the manifest ``` ```text `/etc/passwd` is outside every granted read root; add its directory to capabilities.fs.read in the manifest ``` ```text storage is not granted; set capabilities.storage to true ``` ```text running `git` is not granted; add it to capabilities.fs.execute in the manifest ``` ```text process.exit() is not granted; set capabilities.process.exit to true in the manifest ``` ## The manifest A directory is recognized by **`gpui-shell.json`**. The manifest is inert data — discovery reads identity, optional version metadata, Git dependencies, and requested permissions without executing the entry module. It recognizes `id`, `name`, `version`, `shell-version`, `entry`, `dependencies`, and `capabilities`; only `id`, `name`, and `entry` are required: ```json { "id": "com.example.quotes", "name": "Quotes", "version": "1.0.0", "shell-version": "0.7.1", "entry": "main.js", "dependencies": { "omarchy-ui": "huacnlee/omarchy-ui" }, "capabilities": { "fs": { "read": ["${pluginDir}"], "write": ["${dataDir}"] }, "network": { "hosts": ["stream.example.com"], "http": [ { "scheme": "https", "host": "api.example.com", "methods": ["GET"], "path_prefixes": ["/v1/"] } ] }, "storage": true, "clipboard": { "read": false, "write": true }, "process": { "exit": false } } } ``` `dependencies` maps a bare module name to a JavaScript package fetched from Git before the entry module runs — `import { Title } from "omarchy-ui"`. The string form takes strict GitHub shorthand or a full Git URL with an optional `#ref`; the object form with an explicit `branch` or `tag` remains supported. Every load also links the package where an editor finds it, so the import carries the package's own types and documentation. See [Dependencies](/versions/v0.7.1/shell/dependencies) for version selection, the package entry, the cache, and what an editor sees. Every grant in that block defaults to _denied_ when omitted, except `storage`, which defaults to granted — write `"storage": false` to refuse it. Unknown fields, invalid reverse-DNS ids, invalid explicitly declared SemVer values, incompatible `shell-version` values, escaping entries, and unknown `${...}` placeholders invalidate the manifest before code runs. Omitted `version` is reported as `unknown`. Omitted `shell-version` accepts the current runtime; when present, it names the oldest compatible gpui-shell release the application requires. Any runtime at or above that version is accepted. The standalone CLI refuses an invalid manifest instead of executing its entry with silently different assumptions. Each scoped `network.http` rule binds the request scheme and effective port as well as its host, method and path. `scheme` defaults to `https`; `port` defaults to that scheme's standard port and only needs to be written for a non-default endpoint. ## `fs` ```js import * as fs from "fs/promises"; ``` Every call returns a promise. `await` them, or chain `.then` — and see the note below about `render`. | Call | Resolves to | | ------------------------------------------- | ------------------------------- | | `fs.readFile(path)` | `Uint8Array` | | `fs.readFile(path, "utf8")` | UTF-8 text | | `fs.writeFile(path, contents)` | — | | `fs.readdir(path)` | Names sorted by name | | `fs.readdir(path, { withFileTypes: true })` | `Dirent[]` with `isDirectory()` | | `fs.exists(path)` | `true` / `false` | | `fs.unlink(path)` | — | | `fs.rmdir(path)` | — | | `fs.mkdir(path, options?)` | — | ```js const source = await fs.readFile("notes.md", "utf8"); await fs.writeFile("notes.md", source + "\n"); ``` A relative path resolves against a granted root; an absolute one must already be inside one. Every path in the surface goes through **one resolver**, so there is no second place for a traversal bug to hide. It normalizes the path — `../../etc/passwd` is rejected before it reaches the filesystem — and then settles containment against the filesystem rather than against the string, because a grant is a promise about a _directory_: `data/escape/passwd` is lexically inside the root and reads `/etc/passwd` if `escape` is a symlink. The deepest part of the path that exists is resolved, links and all, and has to still be under the root; a symlink that resolves to nothing is refused rather than guessed at. **The grant is a handle, not a string.** The resolver hands back an open directory that cannot be made to name anything outside itself, and every read, write, listing, removal and mkdir runs against _that_ — so a path is never resolved twice and there is no window between deciding it is allowed and using it. That matters because the obvious implementation does not work. Checking the path and then calling `std::fs` resolves it twice: a link already in place is caught by the check, and one that replaces a directory component _between_ the two is followed out of the root by the second resolution. This is [`cap-std`](https://docs.rs/cap-std), which is `openat2(RESOLVE_BENEATH)` on Linux and a per-component `openat` walk elsewhere. Three of these behave in a way worth stating, each for the same reason: **A denied path throws rather than answering `false`.** "You may not look" and "it is not there" are different facts, and collapsing them would let a script map the filesystem outside its roots one boolean at a time. **Removing a file and removing a directory are two calls**, as they are in Rust, because "remove" alone does not say whether a directory is in scope. `remove_dir` takes an empty one and nothing else: write access is granted per root, so a recursive remove would turn one mistyped path into the loss of an application's whole data directory. A script that means it walks the tree itself. **`mkdir` means what it means everywhere else.** Bare, it creates one directory and fails if the parent is missing; `{ recursive: true }` creates the parents too. It was `create_dir_all` — a name that said what it did, but only by not being the name every script author already knows. **`read_dir` is sorted.** A script that renders a listing should not have to sort it, and should not inherit the filesystem's arbitrary order. **Every call returns a promise.** The syscall runs off the main thread, because a disk has no bound on how long it takes and blocking here would stop the frame and the VM together — somewhere the interrupt budget cannot even see, since the time is spent in the kernel. A **denial still throws at the call site** rather than rejecting. The capability check costs nothing and stays on the calling thread, and a rejected promise nobody awaited is a denial nobody sees. `readFile` refuses a file over 64 MiB, naming it and the limit. The alternative to a ceiling is a string that has to fit in the JavaScript heap — which is itself capped — so the failure without one is an out-of-memory inside the VM rather than a sentence you can act on. `writeFile` accepts at most 8 MiB per call. `readdir` stops at 10,000 entries or 1 MiB of UTF-8 name bytes, whichever comes first, so an adversarial directory cannot turn one promise into unbounded allocation. **Still do not read a file from `render`** `render` describes the interface; it cannot await. Read in `init` or an event handler, keep the result on the View, and `cx.notify()` when it arrives. ## Storage The [Web Storage API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API), as a browser has it. There is nothing to import: `localStorage` and `sessionStorage` are globals, and also live on `window`. ```js localStorage.setItem("todolist.items", JSON.stringify(items)); const saved = localStorage.getItem("todolist.items"); // null when the key is unset localStorage.removeItem("todolist.items"); localStorage.length; localStorage.key(0); localStorage.clear(); ``` | Member | Description | | ------------------- | ---------------------------------------------- | | `length` | How many keys are stored | | `key(index)` | The key at that position, or `null` | | `getItem(key)` | The value, or `null` when the key is unset | | `setItem(key, val)` | Stores it, converting the value to a string | | `removeItem(key)` | Forgets one key | | `clear()` | Forgets all of them | | `flush()` | Resolves once the writes have reached the disk | **The two differ only in how long they last.** `localStorage` is a file the host placed, and it survives a restart. `sessionStorage` is memory that goes with the process. That is also why only one of them is a capability: nothing `sessionStorage` holds ever leaves the process, so there is nothing to grant, and it works on a host that granted nothing. **Values are strings**, exactly as on the web — `setItem` converts whatever it is handed. Anything with structure goes through `JSON.stringify` on the way in and `JSON.parse` on the way out, which is the same code you would write in a browser: ```js localStorage.setItem( "window", JSON.stringify({ title: "Notes", size: [640, 480] }), ); const window = JSON.parse(localStorage.getItem("window") ?? "{}"); ``` Every member is synchronous, and deliberately: `getItem` is reachable from `render`, so the values are cached in memory and a read answers from there. A file read per render would be absurd. **A mutation schedules the write rather than performing it.** The file is written on a background thread — to a temporary file, renamed over the target, so a crash mid-write leaves the previous settings intact rather than a truncated one — and one write is in flight at a time, so a burst of `setItem` calls becomes one file rather than one file each. Whatever changed while a write was on its way is written by the next one. `await localStorage.flush()` when you need to know it landed. This is the one addition to the browser's interface, and it exists because a browser never has to answer the question — its storage is synchronous all the way down. It is a **barrier, not a second writer**: it waits for everything written so far to reach the disk and rejects with the write's own error if it does not. Starting its own write instead would race the automatic one through the same temporary file, with nothing ordering them — and the older revision could land last and undo the newer. The cache and its wait queue are bounded: one storage file may serialize to at most 8 MiB, contain at most 4,096 keys, and hold at most 1 MiB in any one value. At most 1,024 unresolved `flush()` barriers may wait at once; another is rejected instead of growing an unbounded waiter list. ### Where storage lives Storage is per application, and the host chooses the location — an application cannot name its own, or two applications could collide on purpose. **The host names the application, and its data follows that name:** ```rust let data = gpui_kit::shell::set_bundle_id("com.example.notes")?; gpui_kit::shell::set_capabilities(Capabilities::new().write_roots([data])); ``` | Platform | Location | | -------------------- | -------------------------------------------------------------------------------- | | Linux and other Unix | `$XDG_DATA_HOME/gpui-shell/apps//store.json`, defaulting to `~/.local/share` | | macOS | `~/Library/Application Support/gpui-shell/apps//store.json` | | Windows | `%APPDATA%\gpui-shell\apps\\store.json` | The id is the identity, so the data survives the directory being renamed, moved, or replaced by an upgrade — which is what a user means by "my settings". Keying on the path instead means an upgrade silently starts them over. **The runtime does not go looking for the id in a file.** Only the layer that installed the application knows what it is called; a runtime that read it out of a manifest of its own choosing would be claiming authority over something it does not own. A host that was merely _pointed at_ a directory — this command line, a dev server — has no such name, and there the path really is the identity. `gpui_kit::shell::bundle_id_for_path(root)` builds one from the directory's name and a digest of its full path, so the same directory always reaches the same data and two checkouts of one source stay apart. That is right while you are editing something and wrong once it is installed, which is exactly the difference declaring a real id makes. The id may hold `a-z`, `0-9`, `.`, `-` and `_`, and no `..`. That is not tidiness: it is joined onto the user's data directory, so an unchecked one reaches the rest of it. Data lives there rather than inside the application because an application directory may be read-only, is often a git checkout, and is not where a user expects their data to be. ### Degrading when it is not granted `localStorage` that has not been granted throws, and a well-written application treats that as a fact about its host rather than an error: ```js // storage.js — from the bundled example export function load() { try { const saved = localStorage.getItem(KEY); if (saved === null) return []; const items = JSON.parse(saved); return Array.isArray(items) ? items : []; } catch (error) { console.warn( `todolist: storage unavailable, starting empty (${error.message})`, ); return []; } } ``` The example's footer then says so on screen — "Not saved — this host did not grant storage, so the list lasts for this run only" — which is the right shape: absorb the refusal at the boundary, and tell the user the truth. ## The clipboard ```js cx.write_to_clipboard("copied"); const text = cx.read_from_clipboard(); // undefined when the clipboard holds no text ``` Named after `App::write_to_clipboard` and `App::read_from_clipboard`, and on `cx` because that is where GPUI keeps them. Nothing to import. Read and write are **separate grants**, and a denial names the half that is missing: ```text writing the clipboard is not granted; declare capabilities.clipboard.write in the manifest ``` The clipboard needs a live host call — GPUI's `App` only exists for the duration of one — so a `cx` with none reports that plainly instead of panicking: ```text cx.read_from_clipboard() needs a live host call; call it from render, an event handler or a task ``` ## `console` ```js console.info("loaded", count, { source: "disk" }); console.warn("could not save"); ``` `debug`, `log`, `info`, `warn` and `error`. A global, as it is in every other JavaScript runtime, and nothing to import — the shell used to export the same object a second time as `gpui.log`, which bought a name and nothing else. **No capability is required**: a script that can run can already say something, and denying it would cost the author their diagnostics and nothing else. Extra arguments are appended space-separated, the way `console.log` behaves. Structured values print as JSON, because that is what an author reading a log wants to see. Output goes through `tracing` with the target `gpui_kit::shell::script`, so script output is separable from host output in a log filter. **A host with no `tracing` subscriber installed discards all of it** — along with the runtime's own reports of throwing handlers, unhandled rejections and illegal-phase calls. The `gpui-shell` binary installs a stderr sink at `INFO`, or `DEBUG` under `--dev`. ## `process` ```js import process from "process"; // also available as a bare global const { code, stdout, stderr } = await process.run("git", ["status"]); process.exit(0); ``` `process.run` returns a promise, for a sharper version of the reason `fs` does. A file read has no bound; a child process has less — it can compute for minutes, wait on input that never comes, or outlive the window. Waiting for one on this thread would stop the frame and the VM together, in the kernel, where the interrupt budget cannot see it. Output is **captured, not inherited**: a script that runs a command almost always wants what it said, and in a windowed application a child writing to the host's stdout is writing somewhere no user will look. `code` is `0` on success and `-1` when a signal killed it, which has no exit code of its own. Execution is bounded: 30 seconds, 8 MiB of stdout and 8 MiB of stderr. Reaching a bound kills and reaps the child and rejects the promise. Cancelling owned work or tearing down its runtime also terminates the child. The child starts with a cleared environment rather than inheriting host secrets; the shell does not expose an option to add environment variables. It is gated on an execute grant, which is one of three: denied (the default), an allowlist of command names, or unrestricted. A denied command **throws at the call** rather than rejecting, like a denied `fs` path — a rejected promise nobody awaited is a denial nobody sees. `process.exit` is **a request, never `exit(2)`** inside the runtime. It hands the code to a handler the host installed, which decides what to do — close the plugin's panel, close the window, end the process. One plugin must not be able to take the host process down, and the host may have unsaved state. The handler is not optional: a host that grants the capability without installing one makes the call **fail**, naming the omission. A request nobody answers is worse than a denial, because a script cannot tell the two apart. The `gpui-shell` binary installs the policy that suits a host which _is_ the process — it ends it, with the code the script asked for. The name is a deliberate collision. `process` is what a JavaScript author — or a model generating JavaScript — reaches for, so the runtime puts its own capability-gated surface there rather than leaving the name free to look like Node's and behave differently. `process.exit` has its own `capabilities.process.exit` grant. Filesystem access never implies permission to close a panel, window, or process. ## The sandbox Beyond the capability grants, the runtime trims the language itself. All of it applies **unless development mode is on**. **No dynamic code.** `globalThis.eval` is deleted outright — a `ReferenceError` cannot be mistaken for a working `eval` by feature detection, which a throwing stub could be. All four function compilers are replaced: `Function`, and the constructors reachable through `(async function(){}).constructor`, `(function*(){}).constructor` and the async-generator equivalent. `Function` is _replaced_ rather than deleted, keeping the real `Function.prototype`, so `x instanceof Function` and `.call` / `.apply` / `.bind` keep working and only construction throws. **Frozen built-in prototypes.** `Object`, `Array`, `Function`, `String` and `Number` prototypes are frozen. One VM will host several plugins, which makes those prototypes shared mutable state: one plugin adding an enumerable property to `Object.prototype` changes `for...in` for every other plugin and for the runtime's own prelude. The cost is real — a library that patches `Array.prototype` stops working, at import time — so a host that knowingly runs one can turn the freeze off and keep every other part of the sandbox. **Module resolution is confined to the application root.** `import "./ui.js"` resolves relative to the importing file; anything that resolves outside the application directory is refused. Dynamic `import()` stays callable on purpose — it is how lazy loading will work — and is confined by the same resolver. **Resource limits**, so a runaway script reports rather than taking the window with it: | Limit | Value | | ------------------------------------------------------------- | -------------------------------------------------------------------------- | | Heap | 256 MiB — a leak becomes a catchable JavaScript exception, not an OOM kill | | Interpreter stack | 1 MiB — deep recursion becomes a `RangeError`, not a native stack overflow | | Loaded JavaScript module | 8 MiB per source file | | Outstanding host tasks | 1,024 per runtime | | Time in one call: render and layout | 50 ms | | Time in one call: event and task | 500 ms | | Time in one call: outside any call, such as module evaluation | 5 s | The clock restarts on every host call, which is what lets the render path have a tighter budget than an event handler. **The interrupt cannot be swallowed by a `catch` block** — that is measured by a test, because if it could be, the interrupt would not be a defence at all. Each WebSocket also has an 8-command queue shared by `read`, `write`, and `close`; when full, a new operation rejects and tells the caller to wait for outstanding work. There is no quickjs-libc `std`: quickjs-libc is not compiled into the build. The runtime does provide the small audited `os` module listed below. **Development mode** `--dev` enables source watching and calls `gpui_kit::shell::set_development_mode(true)` before constructing the runtime. That restores dynamic-code constructors and leaves built-in prototypes writable. Development mode never relaxes capability gating. It makes the language easier to poke at; it does not hand out access nobody declared, because a grant the author never wrote down is a grant that will be missing in production. ## Network and safe standard APIs Global `fetch(url, options?)` is promise-based and returns `{ status, ok, url, , json() }`. Its grant is narrower than raw networking: every request and redirect must match a declared HTTP host, method, and exact path or path prefix; HTTPS never downgrades to HTTP, and authorization or caller-supplied headers never cross origins. `net.connect(host, port)` and the named `WebSocket.connect(url, { headers? })` export from `websocket` use `capabilities.network.hosts`. `WebSocket` is not installed as a browser global and is not a constructor. Raw TCP `read()` returns a `Uint8Array`, or `null` at EOF, so transport chunks never undergo lossy text decoding. WebSockets support text and `Uint8Array` messages and serialize writes through one actor. They do not follow redirects. Connect, handshake, and write operations have a 30-second timeout. A socket permits one outstanding `read()` at a time; a second is rejected immediately instead of competing for the next message. Credential and handshake-control headers are refused. Raw TCP and WebSocket access are intentionally broader than an HTTP request grant. DNS resolution is a bounded process-wide service: all applications share two resolver workers and a 64-request queue. Queueing observes each connection's existing deadline, so saturation fails as a timeout instead of growing memory or threads without limit. This is resource containment, not per-application quality-of-service; a host that runs mutually untrusted applications in one process does not get DNS fairness between them. The runtime also provides `buffer`, `path`, `url`, `crypto`, `zlib`, `console`, `process`, and `os`. These are the audited LLRT/host-backed subset declared in generated `gpui-kit.d.ts`; `node:` aliases and arbitrary Node built-ins are not part of the shell contract. ## Not there yet - **Prompting the user.** Grants are decided before the application loads; nothing asks at the moment of use. --- # Styling Source: /versions/v0.7.1/shell/styling License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/shell/styling and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. The script owns presentation, so this is where most of an application's code goes. Every element accepts the same style surface, written as one fluent chain — exactly what the Rust side writes: ```js render(cx) { return v_flex().size_full().bg(cx.theme().colors.surface).p(12).gap(8).rounded(6); } ``` ```rust // The same thing in Rust, on gpui-base. v_flex().size_full().bg(surface).p(px(12.)).gap(px(8.)).rounded(px(6.)) ``` ## Two halves, one surface The style surface has two halves, and they exist for different reasons. **No-argument methods come from GPUI's reflection table.** `flex_col`, `items_center`, `gap_2`, `rounded_md`, `text_sm`, `size_full`, `font_semibold`, `truncate`, `cursor_pointer` — the whole family, obtained from `gpui_kit::base::styled_ext_reflection_methods` and `gpui_kit::styled_reflection::methods` with no maintenance at all. Not one of these names is written down anywhere in the runtime. When upstream GPUI adds a style method, the script surface has it, and so does the generated `gpui-kit.d.ts`. The build these pages were written against exposes **3,148** of them. It is however many `fn(self) -> Self` style methods GPUI currently has, and it moves when GPUI moves. `gpui-shell types` prints the exact figure for your build. **Methods that take arguments cannot be reflected**, so there are **57** of them bound by hand. That list is the one hand-maintained table in the styling layer, and it is deliberately small. The two halves never overlap: a name is in one or the other, and a test fails the build if a name ever lands in both. ## Lengths A bare number is pixels. A string carries its unit. ```js .p(12) // 12px .w("50%") // half the parent .h("auto") .gap("0.5rem") ``` Which of those a given method accepts follows **its Rust signature**, because that signature is what rejects the bad ones. GPUI has three length types nested inside each other, and the runtime keeps the distinction rather than flattening it: | Type | Accepts | Rejects | | ---------------- | ------------------------------------------------- | --------------------- | | `Length` | a number, `"12px"`, `"1.5rem"`, `"50%"`, `"auto"` | — | | `DefiniteLength` | a number, `"12px"`, `"1.5rem"`, `"50%"` | `"auto"` | | `AbsoluteLength` | a number, `"12px"`, `"1.5rem"` | percentages, `"auto"` | ```text `p` cannot be "auto"; it expects a definite length such as 12 or "50%" ``` ```text `rounded` expects an absolute length such as 8 or "0.5rem"; percentages and "auto" are not allowed here ``` `"auto"` padding and a percentage radius have no meaning in the layout engine underneath, and a runtime that accepted them would have to invent one. ### The parametric methods | Family | Methods | Argument | | ---------- | -------------------------------------------------------------------------- | ---------------- | | Size | `w` `h` `size` `min_w` `min_h` `min_size` `max_w` `max_h` `max_size` | `Length` | | Padding | `p` `px` `py` `pt` `pb` `pl` `pr` | `DefiniteLength` | | Margin | `m` `mx` `my` `mt` `mb` `ml` `mr` | `Length` | | Position | `inset` `top` `bottom` `left` `right` | `Length` | | Flex | `gap` `gap_x` `gap_y` | `DefiniteLength` | | Flex | `flex_basis` | `Length` | | Flex | `flex_grow` `flex_shrink` | number | | Border | `border` `border_t` `border_b` `border_l` `border_r` `border_x` `border_y` | `AbsoluteLength` | | Radius | `rounded` and the `_t` `_b` `_l` `_r` `_tl` `_tr` `_bl` `_br` forms | `AbsoluteLength` | | Paint | `bg` `text_color` `text_bg` `border_color` | colour | | Paint | `text_size` | `AbsoluteLength` | | Paint | `line_height` | `DefiniteLength` | | Typography | `font_family` | string | | Paint | `opacity` | number | `line_height` is the one exception worth memorizing: a **bare number is a multiplier**, not pixels. `line_height(1.45)` means 1.45× the font size, because that is what it means everywhere else in the industry and 1.45px is never what anyone meant. A string still follows the ordinary grammar. ### What is deliberately not bound `shadow`, `cursor`, `text_align`, `text_overflow`, `font_weight` and `scrollbar_width` take Rust structs or enums rather than scalars, and are not exposed as parametric methods. Every one of them has a no-argument form that is reflected and works today: `shadow_sm`, `cursor_pointer`, `text_center`, `truncate`, `font_bold`. A real shadow API belongs with the token work, not as a positional argument list. ## Colours A colour is normally read from the call-scoped theme. Semantic token name strings remain accepted for compatibility, and hex literals are available for fixed colours: ```js render(cx) { return element .bg(cx.theme().colors.surface) // follows the theme .text_color("#1e88e5"); // does not } ``` The palette defines seventeen tokens: | | | | --------- | -------------------------------------------------------------------- | | Ground | `background`, `foreground` | | Surfaces | `surface`, `surface_foreground` | | Emphasis | `primary`, `primary_foreground`, `secondary`, `secondary_foreground` | | Recessive | `muted`, `muted_foreground` | | Highlight | `accent`, `accent_foreground`, `selection` | | Danger | `destructive`, `destructive_foreground` | | Chrome | `border`, `input`, `ring` | Hex literals accept `#rgb`, `#rrggbb` and `#rrggbbaa`. **Prefer a value from `cx.theme().colors`.** A literal bypasses the theme, and a theme switch will not reach it. The example application makes exactly this point: it follows the visual language of `crates/base/examples/showcase`, which has to write literal colours because Base ships no palette, and reads semantic tokens instead — so the same code follows a theme that the Rust showcase cannot. A mistyped token names the whole set rather than failing vaguely: ```text unknown color token `surfacee`; expected one of: background, foreground, surface, … — or a #rrggbb literal ``` ### Where the active tokens come from gpui-shell does not own a palette or theme file format. It reads the active `gpui_kit::base::Theme` supplied by the host. A JavaScript application may replace that same Base Snapshot with `set_theme({ appearance, tokens })`; theme names and any registry remain application state. ## State styles `hover`, `active` and `focus` take a function, which receives a detached element that collects the declarations: ```js renderSave(cx) { return Button.new("save") .bg(cx.theme().colors.surface) .border(1) .border_color(cx.theme().colors.border) .hover((style) => style.bg(cx.theme().colors.muted).border_color(cx.theme().colors.foreground)) .active((style) => style.bg(cx.theme().colors.border)) .focus((style) => style.border_color(cx.theme().colors.ring)) .child("Save"); } ``` The function's return value is ignored, so a chain and a block body both work. The declarations inside are the **ordinary style methods** — there is no second grammar for "what a style is", and every length and colour rule above applies unchanged. Two implementation facts leak far enough to be worth knowing: - **`active` and `focus` need a stable element identity.** A plain `div` acquires one lazily, derived from its position in the description, which is stable across renders for a stable tree. `Button`, `Checkbox` and `Input` already have one. - **A `Switch` ignores state styles.** The switch root is not the interactive element — its track is — so a state style on it has nowhere to land. The runtime logs a warning saying to style the row around it instead, rather than dropping the declaration silently. ## Scrolling overflow Scrolling is element behavior rather than a style declaration. Give the viewport a bounded width or height, then choose the axes it owns: ```js v_flex() .id("activity") .h(240) .overflow_y_scroll() .children(this.rows.map((row) => row)); ``` Use `.overflow_scroll()` for both axes, `.overflow_x_scroll()` for horizontal scrolling, or `.overflow_y_scroll()` for vertical scrolling. A stable `.id(...)` keeps the native scroll position attached to the same viewport across script renders. The corresponding `.overflow_scrollbar()`, `.overflow_x_scrollbar()` and `.overflow_y_scrollbar()` methods keep the same scrolling behavior and also paint gpui-component's native scrollbars. They require a stable `.id(...)` so each viewport keeps independent scrollbar and scroll-position state. ## Theme values Read semantic values from the context that is rendering or handling the event: ```js render(cx) { return v_flex() .gap(cx.theme().spacing.md) .rounded(cx.theme().radius.lg) .bg(cx.theme().colors.surface) .child(`${cx.theme().appearance}: ${cx.theme().is_dark ? "dark" : "light"}`); } ``` The Snapshot is deeply read-only. `theme()` remains as a compatibility accessor, but `cx.theme()` is preferred. An application may call `set_theme({ appearance, tokens })` from an event or task with its own complete color, spacing, and radius token Snapshot. gpui-shell writes that Snapshot into gpui-base and rebuilds token-backed script Views; it does not own theme names, palettes, or a file format. ## Native motion `.transition(property, policy)` and `.spring(property, policy?)` animate later target changes for `opacity`, `width`, `height`, `left`, and `top`. Motion is retained and advanced by native GPUI frames: after the script changes the target and calls `cx.notify()`, animation frames do **not** re-enter JavaScript. ```js div() .id("drawer") .left(this.open ? 320 : 16) .opacity(this.open ? 1 : 0.5) .transition("left", { duration: 220, easing: "ease-out" }) .spring("opacity", { response: 260, damping: 0.85 }); ``` Animated length targets are **numeric pixels only**. Relative values such as `"50%"`, `"1rem"`, and `"auto"` cannot be sampled into a stable native channel and are rejected. Give the element a stable `.id(...)` (controls already use their constructor id), otherwise a changing tree position changes the motion identity. ## Unknown methods ```text unknown style method `text_colour` (did you mean: text_color?) ``` The suggestion is a Levenshtein match against the full name list, with a tight budget — two edits, relaxed to a third of the name for longer identifiers. A wrong suggestion is worse than none. There is a nice piece of machinery behind that message, and it explains a number in the source. QuickJS reports a missing method as a bare `TypeError: not a function` **without naming the property**, so a mistyped style name would otherwise arrive with no clue at all. Wrapping the element prototype in a `Proxy` fixes that — and measured at roughly 30% of the entire description pass (1.09 ms → 1.42 ms for 443 nodes). So the runtime keeps a fast plain prototype as the default, and when a render fails with "not a function" it **re-runs that render once** against a diagnostic `Proxy` prototype, purely to produce the message. Errors are rare; a 30% tax on every render is not. ## Not there yet - **Semantic state styles.** `gpui-base` has a `state_style` layer with a defined priority order for checked, selected and disabled. It is not bound; use `.when(condition, …)` for those states today. - **Keyframe animation.** Target-value transitions and springs exist; arbitrary keyframes and per-frame JavaScript callbacks do not. - **Spacing and radius tokens in styles.** The palette carries spacing and radius scales, but style methods take lengths, not token names — only colours resolve a token. Applications define their own scale as a constant, the way the example's `SPACE` object does. --- # Dock Source: /versions/v0.7.1/base/dock License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/dock and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A dockable workspace: nested splits, tab groups with draggable tabs, and left/right/bottom docks that fold away. `gpui-base` owns all of the behavior and draws none of it. The layout is not a tree of views. It is a value — a `PaneTree` — that you can build, compare, serialize, and edit without a `Window` or an `App` in sight. `DockArea` reconciles that value into live entities, and two renderer traits supply every pixel. This page is long because Dock is the largest system in `gpui-base`. If you only need to stand one up, [Get started](#get-started) and [Supply the appearance](#supply-the-appearance) are enough. ## The model Two container shapes, and nothing else: | Container | Holds | Notes | | --------- | -------------------------------- | ------------------------------------------ | | `Split` | Other containers, along one axis | Each child slot has an optional fixed size | | `Tabs` | Panels, one displayed at a time | Carries the displayed index | There is no leaf variant, so **a panel can only ever live inside a `Tabs`**. A region whose center is a single panel is still a `Tabs` holding one panel. Four regions exist: the center, plus an optional left, right and bottom dock. Each is one independent `PaneTree`. Two identities, both stable: - **`NodeId`** addresses a container. It survives every edit and every normalization rule, so a container still present after a drag carries the id it had before. Ids are allocated globally, so a node id is unambiguous across all four regions. - **`PanelId`** addresses a panel. It wraps the panel entity's `EntityId`, so it identifies that panel for as long as the entity lives — across any number of moves between groups and regions. Neither the tree nor any node stores a GPUI entity handle. ### Key types | Type | Role | | --------------------------------------------------------- | --------------------------------------------------------------------------- | | `PaneTree` | One region's layout, as pure data | | `PaneNode` / `PaneRef` | A node, and the borrowed projection you `match` on | | `NodeId` / `PanelId` | Stable container and panel identity | | `DockArea` | Owns the trees, reconciles them into entities, routes drags and persistence | | `DockLayout` | Describes a layout without constructing anything | | `Panel` | What a dockable view implements — behavior only | | `PanelView` | Object-safe panel handle, `Arc` | | `TabGroup` | The entity behind a `Tabs` node | | `DockAreaRenderer` / `TabGroupRenderer` | Where every visual decision goes | | `DockContext` / `TabGroupContext` | Resolved state and callbacks handed to a renderer | | `DockAreaState` | The serializable form of a whole area | ## Get started ```rust use std::rc::Rc; use gpui_kit::base::dock::{DockArea, DockLayout, DockPlacement}; let area = cx.new(|cx| { DockArea::new("workspace", Some(1), window, cx).with_renderer(Rc::new(MySkin)) }); area.update(cx, |area, cx| { area.set_center( DockLayout::h_split() .child(DockLayout::tabs().panel(files.clone()), Some(px(240.))) .child(DockLayout::tabs().panel(editor.clone()), None), window, cx, ); area.set_dock( DockPlacement::Bottom, DockLayout::tabs().panel(terminal.clone()), window, cx, ); }); ``` `DockArea::new` takes an id (yours, for your own persistence) and an optional schema version. An area built without `.with_renderer(...)` still docks, drags, resizes and persists — it simply draws nothing but the panels themselves. ## Describing a layout `DockLayout` builds a tree without touching `window` or `cx`, because building a tree constructs no entities. ```rust DockLayout::h_split() .child(DockLayout::tabs().panel(explorer.clone()), Some(px(240.))) .child( DockLayout::v_split() .child( DockLayout::tabs() .panel(editor.clone()) .panel(diff.clone()) .active_index(1), None, ) .child(DockLayout::tabs().panel(console.clone()), Some(px(180.))), None, ) ``` | Builder | Produces | | ------------------------- | --------------------------------- | | `h_split()` / `v_split()` | A split along that axis | | `child(layout, size)` | Adds a child container to a split | | `tabs()` | A tab group | | `panel(entity)` | Adds a panel to a tab group | | `active_index(ix)` | Which tab starts displayed | Misuse — a panel added to a split, a child added to a tab group — trips a `debug_assert!` and is otherwise ignored. ### Slot sizes The `size` in `child(layout, size)` is the slot's extent **along the split's axis**: width in an `h_split`, height in a `v_split`. - `Some(px(240.))` fixes it. - `None` leaves it unconstrained — the slot shares what is left with its other unconstrained siblings. A layout with every slot `None` divides the space evenly. When a panel is later dropped beside an existing one with no size in mind, it takes half of what it lands next to. ### Normalization Every edit runs one collapse pass to a fixpoint before returning. The rules, applied bottom up: 1. An empty `Tabs` or `Split` is removed from its parent. 2. A `Split` with one child is replaced by that child, which keeps its own `NodeId` and inherits the slot size. 3. A `Split` whose child is a `Split` of the same axis splices that child's children into itself, scaling their sizes to fill the slot. 4. `active_ix` is clamped to the panel count. 5. The center's root stays a `Split` even when empty; a dock's root is unconstrained. Two consequences worth designing around. **You never need to avoid redundant nesting** — wrapping a node in a same-axis split is harmless, because rule 3 flattens it, which is why `split_at` needs no "reuse the parent" special case. And **there is no window in which a caller can observe a malformed tree**: no empty container, no one-child split, no out-of-range active index. Normalization is idempotent, so `normalize(normalize(t)) == normalize(t)`. ## Panels The whole of a panel's obligation to base is a stable name. ```rust struct FilesPanel { focus_handle: FocusHandle } impl Panel for FilesPanel { fn panel_name(&self) -> &'static str { "FilesPanel" } } impl EventEmitter for FilesPanel {} impl Focusable for FilesPanel { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } impl Render for FilesPanel { /* ... */ } ``` `panel_name` identifies the panel in persisted layouts. **Once chosen, never change it** — it is the key a saved file is read back through. ### Every hook | Method | Default | When it runs | | ------------------------ | ---------- | ---------------------------------------------------------- | | `panel_name()` | _required_ | Any time the panel is identified or written out | | `visible(cx)` | `true` | Every render pass | | `closable(cx)` | `true` | Before a close is offered or applied | | `zoomable(cx)` | `true` | Before a zoom is applied | | `on_added_to(group, ..)` | no-op | When the panel joins a tab group, with a weak handle on it | | `set_active(active, ..)` | no-op | On each real edge of "is the displayed tab" | | `set_zoomed(zoomed, ..)` | no-op | When the group displaying it zooms in or out | | `on_removed(..)` | no-op | When the panel leaves the dock for good | | `dump(cx)` | name only | On `DockArea::dump` | ### Lifecycle contracts These are precise, and worth reading once: **`set_active` fires on edges only.** It is called with the frame-end net state: exactly one notification per real change, delivered on the next tick. Never same-value repeats, never a false-then-true flip within one frame. A panel that is hidden but occupies the active slot still receives `true`, even though rendering falls back to the first visible panel. **A removed panel is not told `false`.** `on_removed` is the deactivation signal. If you release resources in `set_active(false)`, release them in `on_removed` too. **A moved panel never hears `on_removed`.** Dragging a panel from one group to another does not take it out of the dock, so it is told `on_added_to` again with the new group and nothing else. `on_removed` means gone: closed, or displaced by a wholesale `set_center`, `set_dock`, `remove_dock` or `load`. **`on_added_to` precedes any `set_active`,** so a panel can store the handle and act on its first activation. **`set_zoomed` reaches only the displayed panel.** A group has one zoom state and it is the visible panel that fills the dock. Panels in the group's other tabs hear nothing, and a panel that was not displayed when the zoom changed is never told retroactively. **`closable` is permission, not a guarantee.** A container can still refuse — the last group of a dock does, so a dock cannot be emptied by closing. **A hidden panel keeps its place.** `visible` returning `false` leaves the panel in the tree and in its group; it reappears where it was. A container whose panels are _all_ hidden gives up its slot, recursively — a nested split whose every leaf is hidden takes no space. ## The dock area ### Installing layouts ```rust area.set_center(layout, window, cx); area.set_dock(DockPlacement::Left, layout, window, cx); area.remove_dock(DockPlacement::Left, window, cx); ``` Each replaces whatever was there. Panels that were displaced — and are not part of the new layout — receive `on_removed`. ### Adding and moving panels ```rust area.add_panel(panel, DockPlacement::Left, Some(px(240.)), window, cx); area.remove_panel(panel, window, cx); area.move_panel(panel_id, target, window, cx); area.split_at(node, panel_id, Placement::Right, window, cx); ``` `add_panel` lands the panel in the region's first tab group, creating the region if it has none. It has an `add_panel_view` variant taking an `Arc` for callers holding an erased handle. ### Docks ```rust area.has_dock(DockPlacement::Left); area.is_dock_open(DockPlacement::Left); area.toggle_dock(DockPlacement::Left, window, cx); area.dock_size(DockPlacement::Left); area.set_dock_size(DockPlacement::Left, px(280.), window, cx); area.set_dock_collapsible(DockPlacement::Left, true, window, cx); ``` A closed dock keeps its tree and its size; reopening restores both. ### Zoom A zoom names a **container**, not a panel — a group survives its displayed panel closing, and the next tab takes over still zoomed. ```rust area.set_zoomed_in(node, window, cx); area.set_zoomed_out(window, cx); area.is_zoomed(); area.zoomed_group(); // Option ``` The usual entry point is not these but `TabGroupContext::toggle_zoom`, which a skin already has wherever it draws a zoom control. Zoom ends when the zoomed container leaves the dock, or when the container clears it — not when some unrelated panel is removed. ### Locking `area.set_locked(true, window, cx)` freezes rearrangement: no drags, no drops, no closes. Reads and rendering are unaffected. ### Queries ```rust area.layout(DockPlacement::Center); // Option<&PaneTree> area.panel(panel_id); // Option<&Arc> area.is_empty(DockPlacement::Left, cx); area.is_locked(); area.bounds(); ``` ## Editing the tree directly Everything above ultimately goes through `PaneTree`. You can drive it yourself — for a command palette, a keyboard shortcut, a restored session: ```rust tree.insert_panel(panel, InsertTarget::Tabs { node, ix: None, activate: true }); tree.remove_panel(panel); tree.move_panel(panel, target); tree.split(node, panel, Placement::Right, Some(px(320.))); tree.set_active(node, 2); tree.set_sizes(node, vec![Some(px(200.)), None]); ``` `InsertTarget` says where a panel lands: | Variant | Meaning | | --------------------------------- | -------------------------------------------------- | | `Tabs { node, ix, activate }` | Into an existing tab group, optionally at an index | | `Split { node, placement, size }` | Beside a node, in a new tab group | Every edit returns an `EditResult`: `changed()`, plus `created_nodes()`, `removed_nodes()`, `removed_panels()`, `activated()`, `deactivated()`. **`removed_panels` excludes moves** — a moved panel's entity survives, so it must not receive `on_removed`. Reading a tree: ```rust tree.root(); // &PaneNode tree.node_ids(); // Vec, pre-order tree.panels(); // impl Iterator tree.find_node(node_id); // Option<&PaneNode> tree.find_panel_node(panel_id); // Option match node.kind() { PaneRef::Split { axis, children, sizes } => { /* ... */ } PaneRef::Tabs { panels, active_ix } => { /* ... */ } } ``` ## Supply the appearance Nothing in `gpui_kit::base::dock` paints a color, a border, or a size. Two traits carry appearance in. ### `DockAreaRenderer` | Method | Supplies | Default | | --------------------- | ------------------------------------------------------------------ | ------------------------------ | | `frame` | The area's outermost element | Bare `div` | | `center_frame` | The column holding the center and bottom dock | Bare `div` | | `split_frame` | One split's frame | Bare `div` | | `render_split_handle` | The divider between two slots | `None` → base's one-pixel line | | `render_dock` | One dock's chrome: title strip, collapse affordance, resize handle | The content, unwrapped | | `build_placeholder` | The stand-in for a panel this build cannot construct | `None` → draws nothing | | `tab_group_renderer` | _required_ | — | ### `TabGroupRenderer` | Method | Supplies | Default | | ----------------------- | ---------------------------------------- | ---------------------------- | | `frame` | The group's outer element | Bare `div` | | `content_frame` | The element the displayed panel sits in | Bare `div` | | `render_tab_bar` | The tab strip | Nothing | | `render_active_panel` | How the displayed panel is placed | The panel, filling the frame | | `render_drop_indicator` | The highlight showing where a drop lands | Nothing | | `render_empty` | What an empty group shows | Nothing | ### Contexts A renderer never sees a drag event or a mouse position. Base attaches drag sources, drop hit-testing, focus and keyboard handling to the very elements the renderer returns, and hands it resolved state plus callbacks: **`TabGroupContext`** — `node()`, `panels()`, `active_ix()`, `active_panel()`, `drop_indicator()`, `is_zoomed()`, `is_collapsed()`, `can_close()`, `is_locked()`, `is_draggable()`, `is_droppable()`; and the actions `select_tab()`, `close()`, `toggle_zoom()`, `drag_panel()`, `drop_panel()`, `drop_item()`. **`DockContext`** — `placement()`, `size()`, `is_open()`, `is_collapsible()`; and `toggle()`, `resize_to()`. ```rust impl TabGroupRenderer for MySkin { fn render_tab_bar(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> AnyElement { h_flex() .children(group.panels().iter().enumerate().map(|(ix, panel)| { div() .child(my_title(panel, cx)) .when(ix == group.active_ix(), |this| this.font_semibold()) .on_click({ let group = group.clone(); move |_, window, cx| group.select_tab(ix, window, cx) }) })) .into_any_element() } } ``` Every hook is optional in the same way: decline one and you get base's minimum for it. `render_split_handle` is the clearest case — return `None` and the divider falls back to a one-pixel line colored from `Theme::resizable`, so a skin with no opinion about dividers implements nothing, while one that has an opinion replaces the paint without touching the hit area, the cursor, or the drag. ## Drag and drop A tab drag has three parts, and a skin supplies only the middle one. **Starting.** `TabGroupContext::drag_panel(ix, cx)` returns a `DragPanel` if that tab may be dragged (it declines when the group is locked, or when the panel is the last one holding a dock open). Hand it to GPUI's `on_drag`. The preview view you return is yours; base's own `DragPanel` renders nothing, because a preview is appearance. **Landing.** While a drag hovers, base resolves where it would land and exposes it as `TabGroupContext::drop_indicator()` — a `DropIndicator` carrying the rectangle the panel would occupy. Paint it in `render_drop_indicator`; the coordinates are relative to the content frame, so that frame must be positioned. **Applying.** `drop_panel()` on release turns the hover into a `TabGroupEvent::Drop { panel, source, target }`, which the area applies as a single `PaneTree::move_panel`. Dropping onto the middle merges into the group; dropping towards an edge splits there; dropping onto the tab strip inserts at that index. **Host-owned drags.** Anything of your own can be dropped into the dock. Wrap it in `AnyDrag`, and `drop_item()` reports it as `DockEvent::DragDrop { item, target }` where `target` names the tab group it landed on and the edge it resolved. The dock does not interpret the payload. ## Events | Emitter | Event | Meaning | | ------------ | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `DockArea` | `LayoutChanged` | Something changed. Fires on **every** edit — debounce before writing to disk | | `DockArea` | `DragDrop { item, target }` | A host-owned drag landed | | `TabGroup` | `Drop` / `DragDrop` / `ClosePanel` / `ActiveChanged` / `ZoomIn` / `ZoomOut` | A group's intent, applied by the area | | `Panel` | `ZoomIn` / `ZoomOut` / `LayoutChanged` | A panel's own signal | Container events are the container asking the area for something; the area is what actually edits the tree. A host normally subscribes only to `DockEvent`. ## Persistence ```rust let state: DockAreaState = area.read(cx).dump(cx); let json = serde_json::to_string(&state)?; let state: DockAreaState = serde_json::from_str(&json)?; area.update(cx, |area, cx| area.load(state, window, cx))?; ``` `DockAreaState` carries the version you passed to `DockArea::new`, the center, and each dock with its placement, size and open state. Every node writes its shape and every panel writes whatever its `dump` returned. Panels are rebuilt through a global registry, keyed by `panel_name`: ```rust register_panel(cx, "FilesPanel", |context, window, cx| { let state = context.state(); // the PanelState this panel dumped Arc::new(FilesPanel::restore(state, cx)) as Arc }); ``` Three behaviors worth knowing: - **An unregistered panel is not dropped.** It becomes a placeholder carrying the original state forward, so a layout saved by a build that had a panel yours does not know still round-trips intact instead of losing it. - **Slot sizes are resolved on the way out.** `dump` writes the sizes the split is actually drawn at, not the ones the tree was built from, and never writes a zero. - **`LayoutChanged` fires far more often than you want to save.** Debounce, or save on a timer or on window close. ## How this compares Docking layouts are well-trodden. Where implementations differ is how far the layout engine is separated from what draws it — and that choice has consequences you can observe. ### Architecture | Project | Stack | Engine and rendering | What a consumer can change | | ---------------------------------------------------------------- | ---------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------- | | [Qt](https://doc.qt.io/qt-6/qdockwidget.html) | C++, retained | `QMainWindow` owns four fixed dock areas; a `QDockWidget` _is_ a widget | Subclass the widget; styling via QSS | | [AvalonDock](https://github.com/Dirkster99/AvalonDock) | C#/WPF, retained | `LayoutRoot` tree of layout elements | XAML templates and themes | | [Dear ImGui](https://github.com/ocornut/imgui/wiki/Docking) | C++, immediate | `DockSpace()` is a region any window may dock into; nodes are engine-internal | Style vars and colors | | [egui_dock](https://docs.rs/egui_dock/) | Rust, immediate | `DockState` holds surfaces, each with a `Tree` of `Node`s | `TabViewer` renders tab bodies; a `Style` struct tunes the chrome | | [dockview](https://dockview.dev/) | TypeScript, web | Framework-agnostic engine behind thin adapters | CSS variables, theme object, replace the tab component | | [FlexLayout](https://github.com/caplin/FlexLayout) | TypeScript, web | JSON model beside a React renderer | `onRenderTab` callbacks and CSS | | [golden-layout](https://golden-layout.com/) | TypeScript, web | Engine owns its DOM outright | CSS overrides | | [rc-dock](https://github.com/ticlo/rc-dock) | TypeScript, web | `BoxData` / `PanelData` / `TabData` model | Custom tab rendering and CSS | | [VS Code](https://code.visualstudio.com/api/ux-guidelines/panel) | TypeScript, app | Workbench owns the layout | Contributed views, themed via CSS | | [Zed](https://zed.dev/) | Rust, app | `PaneGroup` built into the application | Not reusable outside its host | | **`gpui-base`** | Rust, retained | Pure-data `PaneTree`; the engine paints nothing | Renderer traits return elements — there is no default look to override | Three families are visible in that table. **Application-owned** engines (VS Code, Zed) are the most capable and the least reusable — you cannot lift them out of their host. **Widget-tree** engines (Qt, AvalonDock, golden-layout) make the dockable thing a widget, so the layout _is_ the view hierarchy. **Model-and-renderer** engines (FlexLayout, rc-dock, dockview, egui_dock, and this one) keep a separate description of the layout and hand rendering to something else. `gpui-base` sits at the far end of the third family: the engine paints nothing at all. In a library that draws its own chrome, customization is a set of overrides layered onto a default appearance, and you are limited to the seams it chose to expose. Here a renderer returns elements and base attaches behavior to _those_ elements, so two unrelated appearances can sit over one behavior — `crates/component/src/dock` and the example below are exactly that. ### The closest relative [egui_dock](https://docs.rs/egui_dock/) is worth a paragraph of its own, because it arrives at nearly the same shape from the other side of the retained/immediate divide. It keeps a `DockState` holding surfaces, each surface a `Tree`, each tree a hierarchy of `Node`s split into leaf and split variants — which is this design, down to the vocabulary, and it even calls its render entry point `DockArea`. Two differences matter. Its `TabViewer` renders **tab bodies**, while the crate itself draws the tab bars and splitters through a `Style` struct; here the split is the other way round — panels render themselves, and the skin draws all the chrome, with no `Style` struct because there is nothing built in to configure. And because egui is immediate-mode, its tree is walked and re-emitted every frame by construction; here the tree is a value that changes only when edited, and reconciliation against a stable `NodeId` cache is what keeps entities alive across edits. egui_dock also has something this does not: **undocking a tab into a floating OS window**, modeled as additional surfaces. That is a real capability gap, not a design difference. ### What the data model buys The layout being a value rather than a widget tree is not an aesthetic preference. Three properties follow: **A drag does not reset what it did not touch.** When containers _are_ views — the Qt and AvalonDock model — rearranging the layout means creating and dropping views, so a drag can reset state (scroll offsets, focus, in-progress input) in panels that merely shared a parent with the one being moved. Here identity is a `NodeId` that survives every edit and every normalization rule, so reconciliation is a diff against the entity cache: a steady-state pass creates and drops nothing. **Collapse is a pure function, not a deferred cascade.** When the last panel leaves a group, the group must remove itself from its parent, which may empty the parent in turn. With containers as views this is mutual recursion between two types reaching upward through parent handles — and those handles must be installed after construction, which in GPUI means a deferred pass, which means a window in which the tree disagrees with itself. `normalize` is one post-order pass to a fixpoint: no parent pointers, no deferred work, and the tree is self-consistent the instant an edit returns. **Editing costs no rendering.** `insert_panel`, `move_panel`, `split` and the rest operate on a value. They allocate no entities and request no layout, so a sequence of edits can run and be inspected before anything is drawn. The same property is why the whole layout algebra is tested as plain `#[test]` with no `TestAppContext` — which is why the collapse rules have the coverage they do. The cost, stated plainly: an edit clones the tree once to diff it, and normalization walks it until it reaches a fixpoint (two passes on realistic layouts, with a hard ceiling well above that). For dock-sized trees — tens of nodes — both are negligible against a frame, and they buy the three properties above. This would be the wrong shape for a structure with thousands of nodes. ### Naming The vocabulary follows the neighborhood where it can, which matters if you already know one of these systems: | Concept | Qt | egui_dock | dockview | VS Code | Zed | `gpui-base` | | ------------------------- | -------------------- | ----------- | ------------- | --------------- | ----------- | ----------- | | Window-level container | `QMainWindow` | `DockState` | `DockviewApi` | Workbench | `Workspace` | `DockArea` | | Tree arranging containers | — | `Tree` | Gridview | — | `PaneGroup` | `PaneTree` | | Tree node | — | `Node` | — | — | `Member` | `PaneNode` | | Tab group | (stacked docks) | `LeafNode` | Group | View Container | `Pane` | `TabGroup` | | Content in a tab | `QDockWidget` | `Tab` | `Panel` | `View` | `Item` | `Panel` | | Edge region | `Qt::DockWidgetArea` | — | — | Panel / Sidebar | `Dock` | `Dock` | One caution, because the field uses the word inconsistently: here a **`Panel` is the dockable content**, matching dockview. VS Code calls the _bottom region_ a panel; rc-dock calls the _tab container_ one. Translate the word before porting concepts from either. Qt is the outlier worth noting: it has no separate node type at all, because `QDockWidget` is both the content and the thing the layout arranges. That is the design this one is furthest from. ## Runnable example Everything above, on `gpui-base` alone — panels, a layout, and a skin implementing all three renderer traits. Nothing in it depends on `gpui-component`, which is the point: base is usable on its own, and a host that wants a different look writes a different skin. ```bash cargo run -p gpui-base dock ``` Source: [`showcase/components/dock.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/dock.rs). It is the same file the preview at the top of this page compiles to WebAssembly. ## Integration checklist - Give every panel a `panel_name` you will never change, and register a builder for it before calling `load`. - Install a renderer, or accept that nothing but the panels themselves is drawn. - Release resources in `on_removed`, not only in `set_active(false)` — a departing panel is never told `false`. - Debounce `DockEvent::LayoutChanged` before persisting; it fires on every step of a drag. - Prefer `visible` over removal when a panel should come back in the same place. - Position the element you return from `content_frame`, or the drop indicator will have nothing to anchor to. --- # TextView Source: /versions/v0.7.1/base/text-view License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/text-view and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `gpui-base` owns the complete `TextView` implementation for rendering Markdown and common HTML. It includes document parsing, links, images, lists, tables, code blocks, scrolling, line clamping, plugins, selection, and copying without depending on `gpui-component`. The live example above uses only `gpui-base`. Its fenced Rust block is intentionally unhighlighted: syntax highlighting is opt-in. ## Set up the window Call `gpui_kit::base::init` once during application startup and render one `TextSelectionLayer` per window. The layer coordinates selection across `TextView`, [`SelectableText`](/versions/v0.7.1/base/text-selection), and custom text renderers. ```rust use gpui_kit::prelude::*; use gpui_kit::{Context, Render, Window}; use gpui_kit::base::{TextSelectionLayer, TextView}; impl Render for AppView { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div() .size_full() .child(TextSelectionLayer) .child(TextView::markdown( "readme", "# Hello\n\nSelect and copy this **Markdown**.", )) } } ``` If the application already calls `gpui_kit::component::init`, Base initialization is included. A window using `gpui_base::Root`—including one opened by `gpui_kit::open_window`—installs the selection layer automatically; do not render a second layer in its content. TextView is selectable by default. While dragging a selection near a viewport edge, the shared selection layer scrolls the related `overflow_*_scroll` region automatically; no TextView scroll or selection parameter is required. Use `.selectable(false)` only to disable selection explicitly. ## Markdown and HTML Use the helpers for call-site-derived IDs, or constructors when an explicit stable ID is useful: ```rust use gpui_kit::base::{html, markdown, TextView}; let short_markdown = markdown("A **short** message."); let short_html = html("

A short message.

"); let preview = TextView::markdown("document-preview", markdown_source).scrollable(true); let article = TextView::html("article", html_source); ``` `scrollable(true)` makes the view fill its container and scroll vertically. Without it, the view grows to fit its content. `max_lines(n)` clamps a non-scrollable preview to at most `n` body-text lines. ## Complete default styling Every constructor starts with `TextViewStyle::default()`. The default contains readable neutral foreground, muted, link, selection, code-background, border, heading, paragraph, inline-code, and table styles. A Base-only application does not need to construct a style before rendering text. Override only the values owned by your design system: ```rust use gpui_kit::base::TextViewStyle; let style = TextViewStyle::default() .with_foreground(app_colors.foreground) .with_muted_foreground(app_colors.muted_foreground) .with_link(app_colors.link) .with_selection(app_colors.selection); TextView::markdown("themed", source).style(style) ``` Heading refinements receive the Markdown heading level (1-6) and are applied on top of the built-in size, weight, and spacing for that level: ```rust use gpui_kit::{StyleRefinement, Styled as _, rems}; let style = TextViewStyle::default().with_heading(|level| match level { 1 => StyleRefinement::default().pt(rems(1.)).pb(rems(0.75)), _ => StyleRefinement::default(), }); ``` `TextViewStyle::from_theme(&theme)` maps the semantic colors from a `gpui_kit::base::Theme`. Applications using the higher-level component theme can use `gpui_kit::component::text::text_view_style(cx.theme())`. A text view without an explicit `.style()` can follow the text color its container sets, so rich text stays readable on a filled surface such as a chat bubble. Turn it on in the application defaults: ```rust use gpui_kit::base::{TextViewDefaults, TextViewStyle}; TextViewDefaults::new() .with_style(TextViewStyle::default()) .with_inherit_text_color(true) .install(cx); ``` The body text takes the inherited color. When that color is far from the style's foreground in lightness, the surface is inverted from the page, so links, muted text, code and table backgrounds, borders and selection are derived from it too, and the installed syntax highlighter is left out. Enable this only when every window sets a text color at its root; GPUI Component's Root does, and Component turns it on. ## Syntax highlighting is opt-in `gpui-base` does not enable syntax highlighting and has no tree-sitter language dependency. Fenced code blocks use the neutral code surface and plain foreground until the application supplies `code_block_highlighter`. The callback receives a `CodeBlock` and returns byte ranges paired with GPUI `HighlightStyle` values: ```rust use gpui_kit::HighlightStyle; use gpui_kit::base::TextView; TextView::markdown("highlighted", source).code_block_highlighter(|block| { my_highlighter(block.lang(), block.code()) .into_iter() .map(|(range, color)| { ( range, HighlightStyle { color: Some(color), ..Default::default() }, ) }) .collect() }) ``` Ranges are UTF-8 byte ranges relative to `CodeBlock::code()`. Invalid ranges are discarded. The highlighter implementation and its language registrations remain entirely application-owned. ## Markdown extensions `MarkdownExtensions` starts with CommonMark/GFM-compatible parsing. YAML frontmatter is disabled by default because it is not part of either standard. Enable the construct explicitly when a block parser or plugin handles `markdown_ast::Node::Yaml`: ```rust use gpui_base::{MarkdownExtensions, TextView}; let extensions = MarkdownExtensions::default().frontmatter(); TextView::markdown("metadata", source) .markdown_extensions(extensions) ``` Without a matching plugin, enabled YAML frontmatter uses the existing YAML code-block fallback. A custom plugin can be attached with `.plugin(...)`; `gpui-component` provides a themed `FrontmatterPlugin`; Base remains independent of that presentation. ## Inline plugin Implement `MarkdownPlugin` and register it with `.plugin(...)`, just like a Block plugin. A `MarkdownPlugin` with the default `is_block() == false` uses `render_inline`; block plugins keep `render`. ```rust use gpui::{App, Styled, Window, div}; use gpui_base::{ InlineElement, InlineRenderContext, MarkdownNode, MarkdownParseContext, MarkdownPlugin, TextView, markdown_ast, }; struct FormulaPlugin; impl MarkdownPlugin for FormulaPlugin { fn name(&self) -> &str { "formula" } fn parse( &self, node: &markdown_ast::Node, _: &MarkdownParseContext<'_>, ) -> Option { let markdown_ast::Node::InlineMath(math) = node else { return None; }; Some( MarkdownNode::new("formula", math.value.clone()) .text(math.value.clone()) .accessibility_label(format!("Formula: {}", math.value)), ) } fn render_inline( &self, node: &MarkdownNode, _: &InlineRenderContext, _: &mut Window, _: &mut App, ) -> Option { Some(InlineElement::new(div().italic().child(node.as_text().to_string()))) } } TextView::markdown("inline-formulas", "Formulas $x^2$ and $y^2$") .plugin(FormulaPlugin) ``` `render_inline` returns `Some(InlineElement::new(element))` for any GPUI `IntoElement`, including styled text, images, and composed elements. Use native GPUI styling, hover handlers, and child events. The renderer receives `InlineRenderContext` with the effective text style, font size, line height, and rem size. These rendering types are independent of Markdown; parsing and registration in this example remain Markdown-specific. TextView measures the element's intrinsic size and lays it out as one atom. Set `.with_baseline(px(...))` on `InlineElement` when the content needs an explicit baseline, measured from its top edge in logical pixels. Objects wrap only before or after the whole element. Fixed-size elements retain their dimensions even when wider than a line; constrain their size with GPUI styles where needed. TextView does not scale the entire element subtree. Use `MarkdownExtensions::parser_revision(config_version)` when parser captures or plugin configuration change without changing the registered names. Keep the revision stable for equivalent registrations rebuilt during rendering; changing it reparses the existing source. Compose a native `HoverCard` around the trigger to show a profile card. The Markdown example uses a `StyledText` label with a muted `@`, an underlined username, and a `HoverCard` anchored at `Anchor::TopCenter`. Plain copy of `[@huacnlee](mention:huacnlee)` emits the handle; Markdown copy retains the original link syntax. Selection treats the rendered element as a whole. Double-click selects an object; triple-click selects its mixed text line. Drag selection can cross text and consecutive objects in either direction. Child events remain native GPUI events, so plugin authors should coordinate interactive controls with TextView's selection gestures. `source_range()` exposes full-document UTF-8 byte offsets including delimiters. `.text(...)` supplies plain copy and fallback text; `.markdown(...)` supplies Markdown copy, defaulting to the original node source. Missing plain text falls back to source. `.accessibility_label(...)` supplies the accessible name, defaulting to the plain text. Returning `None` from `render_inline` uses atomic text fallback. For images, the plugin supplies loading and failure content through `img(...).with_loading(...).with_fallback(...)`. For asynchronous resources, retain a `TextViewState`, update the application-owned cache, then call `state.invalidate_inline_layout(cx)` through the view's weak entity. This remeasures inline content and virtual-list heights without reparsing or dropping the current logical selection. Associate results with source/font/theme keys and discard obsolete completions. Render callbacks should read prepared resources; do not run an equation engine synchronously during layout. `examples/markdown` contains the formula implementation and a preview zoom control. Inline math syntax is parsed by default. Register a plugin to customize its rendering; no separate syntax switch is needed. Inline code continues to protect dollar signs from math parsing. When no plugin claims a math node, TextView renders its original `$...$` source as literal text, so prose that merely contains dollar signs — `spent $5 and $10` — reads and copies back unchanged. Block math is parsed too: a `$$` fence becomes a block node, which a block plugin (`is_block() == true`) renders, and which falls back to a code block when no plugin claims it. ## Retained state and streaming updates Use `TextViewState` when content changes without replacing the view: ```rust use gpui_kit::base::{TextView, TextViewState}; let document = cx.new(|cx| TextViewState::markdown(initial_source, cx)); // Render TextView::new(&document) // Later document.update(cx, |state, cx| state.set_text(updated_source, cx)); ``` `TextViewMotion` is the view's motion policy. Base plays it but ships no timing: every duration defaults to zero, so an unstyled view adopts streamed text at once. Give `stream_fade` a duration to fade the text an update appends in where it lands, and optionally `stream_fade_stagger` to start each further word of one update a little after the one before it: ```rust use std::time::Duration; use gpui_kit::base::{Easing, TextView, TextViewMotion}; TextView::new(&document).motion( TextViewMotion::default() .with_stream_fade(Duration::from_millis(350)) .with_stream_fade_stagger(Duration::from_millis(30)) .with_stream_fade_easing(Easing::EaseOut), ) ``` Without a stagger each update fades as one chunk. With one, appended text is split into words with their trailing whitespace, and CJK text into characters; a long update compresses its stagger so the last word starts within one fade. The tracker compares rendered text rather than source bytes, so a `set_text` whose text extends the current one counts as an append, and Markdown that completes as it streams (`**bo` becoming bold `bold`) fades the changed glyphs rather than the whole paragraph. Only the blocks the update reaches are compared, and frames are requested only while something is still fading. Reduced motion skips the fade. `TextViewState::set_range_highlights` paints backgrounds behind ranges of `rendered_text()`, the text plain copy produces, so an application can show its search results or citations without reparsing or restyling the document. The ranges are painted, not shaped, so they never change layout. Ranges of the Markdown source, such as those `selected_source_range()` returns, convert to it with `rendered_text().range_for_source(range)`. `reveal_range` scrolls the line a range starts on into view, through the view's own list, an enclosing `gpui::list`, or `TextView::on_reveal` for any other container; see [Highlight ranges](/versions/v0.7.1/component/text-view#highlight-ranges) and [Scroll to a range](/versions/v0.7.1/component/text-view#scroll-to-a-range). Selection can copy rendered text or Markdown source through `SelectionFormat`. Link routing, code-block actions, table actions, images, and custom Markdown plugins use the same builders as the compatibility API documented on the [gpui-component TextView page](/versions/v0.7.1/component/text-view). ## Runnable source The live preview and native command use the same Base-only source: ```rust use gpui_base::{TextView, TextViewStyle}; use super::*; use crate::showcase::palette::ExamplePalette; pub const MARKDOWN: &str = include_str!("../../../../../examples/fixtures/test.md"); fn text_view_style(palette: ExamplePalette) -> TextViewStyle { let is_dark = palette.canvas == ExamplePalette::for_dark(true).canvas; TextViewStyle::default() .with_foreground(gpui::rgb(palette.foreground).into()) .with_muted_foreground(gpui::rgb(palette.muted_foreground).into()) .with_link(gpui::rgb(palette.resolve(0x007fff)).into()) .with_code_background(gpui::rgb(palette.elevated).into()) .with_border(gpui::rgb(palette.border).into()) .with_inline_code(gpui::HighlightStyle { background_color: Some(gpui::rgb(palette.elevated).into()), ..Default::default() }) .with_dark(is_dark) } impl BaseShowcase { pub(in super::super) fn text_view(&self, window: &Window) -> impl IntoElement { let palette = ExamplePalette::from_window(window); let style = text_view_style(palette); div() .id("text-view-example") .debug_selector(|| "text-view-example".into()) .w_full() .h(px(560.)) .max_h_full() .text_color(gpui::rgb(palette.foreground)) .child( div() .debug_selector(|| "text-view-markdown".into()) .size_full() .min_h_0() .overflow_hidden() .child( TextView::new(&self.text_view) .size_full() .px_4() .scrollable(true) .style(style), ), ) } } #[cfg(test)] mod tests { use std::time::Duration; use gpui::{ Modifiers, MouseButton, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point, px, }; use gpui_base::{TextSelection, TextViewStyle}; use super::text_view_style; use crate::showcase::BaseShowcase; use crate::showcase::palette::ExamplePalette; #[test] fn text_view_style_uses_dark_palette_colors() { let style = text_view_style(ExamplePalette::for_dark(true)); assert_eq!(style.foreground(), gpui::rgb(0xffffff).into()); assert_eq!(style.muted_foreground(), gpui::rgb(0xa3a3a3).into()); assert_eq!(style.code_background(), gpui::rgb(0x262626).into()); assert_eq!(style.border(), gpui::rgb(0x404040).into()); assert_eq!(style.selection(), TextViewStyle::default().selection()); assert!(style.is_dark()); } #[gpui::test] fn text_view_showcase_renders_with_base_defaults(cx: &mut TestAppContext) { cx.update(gpui_base::init); let (view, cx) = cx.add_window_view(|window, cx| BaseShowcase::new("text-view", window, cx)); let cx: &mut VisualTestContext = cx; cx.run_until_parked(); let example = cx .debug_bounds("text-view-example") .expect("example bounds"); let markdown = cx .debug_bounds("text-view-markdown") .expect("Markdown bounds"); let document = view.read_with(cx, |view, cx| view.text_view.read(cx).bounds()); assert_eq!(markdown.left(), example.left()); assert_eq!(markdown.right(), example.right()); assert_eq!(document.left() - example.left(), px(16.)); assert_eq!(example.right() - document.right(), px(16.)); } #[gpui::test] fn text_view_showcase_drag_selection_settles(cx: &mut TestAppContext) { cx.update(gpui_base::init); let (_, cx) = cx.add_window_view(|window, cx| BaseShowcase::new("text-view", window, cx)); let cx: &mut VisualTestContext = cx; cx.run_until_parked(); let bounds = cx .debug_bounds("text-view-example") .expect("example bounds"); // Exercise selection inside the visible, virtualized Markdown blocks. let start = point(bounds.left() + px(36.), bounds.top() + px(36.)); let end = point(bounds.right() - px(36.), bounds.top() + px(180.)); cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default()); cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default()); assert!(cx.update(|window, cx| TextSelection::has_selection(window, cx))); } #[gpui::test] fn text_view_showcase_scrolls_the_document_inside_a_fixed_viewport(cx: &mut TestAppContext) { cx.update(gpui_base::init); let (view, cx) = cx.add_window_view(|window, cx| BaseShowcase::new("text-view", window, cx)); let cx: &mut VisualTestContext = cx; cx.run_until_parked(); let viewport = cx .debug_bounds("text-view-markdown") .expect("Markdown viewport bounds"); let example = cx .debug_bounds("text-view-example") .expect("TextView example bounds"); let scroll_before = view.read_with(cx, |view, cx| { let offset = view.text_view.read(cx).list_state().logical_scroll_top(); (offset.item_ix, offset.offset_in_item) }); cx.simulate_event(ScrollWheelEvent { position: example.center(), delta: ScrollDelta::Pixels(point(px(0.), px(-120.))), ..Default::default() }); cx.update(|window, cx| window.draw(cx).clear(cx)); let after = cx .debug_bounds("text-view-markdown") .expect("Markdown viewport bounds after scrolling"); let scroll_after = view.read_with(cx, |view, cx| { let offset = view.text_view.read(cx).list_state().logical_scroll_top(); (offset.item_ix, offset.offset_in_item) }); assert_eq!( after, viewport, "the TextView viewport itself must stay fixed" ); assert_ne!( scroll_after, scroll_before, "the TextView's virtual list must consume the wheel event" ); } #[gpui::test] fn dragging_selection_scrolls_the_containing_region_without_text_view_parameters( cx: &mut TestAppContext, ) { cx.update(gpui_base::init); let (view, cx) = cx.add_window_view(|window, cx| BaseShowcase::new("text-view", window, cx)); let cx: &mut VisualTestContext = cx; cx.run_until_parked(); let markdown = cx .debug_bounds("text-view-markdown") .expect("Markdown section bounds"); let scroll_before = view.read_with(cx, |view, cx| { let offset = view.text_view.read(cx).list_state().logical_scroll_top(); (offset.item_ix, offset.offset_in_item) }); let start = point(markdown.left() + px(24.), markdown.top() + px(24.)); let edge = point(markdown.left() + px(120.), markdown.bottom() - px(2.)); cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); cx.simulate_mouse_move(edge, MouseButton::Left, Modifiers::default()); cx.executor().advance_clock(Duration::from_millis(64)); cx.run_until_parked(); cx.simulate_mouse_up(edge, MouseButton::Left, Modifiers::default()); let scroll_after = view.read_with(cx, |view, cx| { let offset = view.text_view.read(cx).list_state().logical_scroll_top(); (offset.item_ix, offset.offset_in_item) }); assert!( scroll_after != scroll_before, "dragging at the viewport edge must scroll the TextView document" ); cx.executor().advance_clock(Duration::from_millis(64)); cx.run_until_parked(); let scroll_stopped = view.read_with(cx, |view, cx| { let offset = view.text_view.read(cx).list_state().logical_scroll_top(); (offset.item_ix, offset.offset_in_item) }); assert_eq!( scroll_stopped, scroll_after, "selection auto-scroll must stop on mouse-up" ); } } ``` ```bash cargo run -p gpui-base-examples -- text-view ``` --- # Plot Source: /versions/v0.7.1/base/plot License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/plot and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `gpui_kit::base::plot` holds the parts of a chart that are behavior and geometry rather than design: scales that map data to pixels, shapes that tessellate bars, lines, areas and arcs, axes, grids and labels, the element that turns a [`Plot`] into a child, and the hover tracking behind an interactive tooltip. It chooses no colors, fonts or timing. Every shape takes its fill and stroke from the caller, and hover motion is whatever the styled layer projects. The styled charts of GPUI Component — `LineChart`, `BarChart`, `PieChart` and the rest — are built on it, and a design system that does not use GPUI Component can build its own on the same foundation. GPUI Component re-exports this module unchanged as `gpui_kit::component::plot`, so existing imports keep working. ## Import ```rust use gpui_kit::base::plot::{ AxisText, Grid, Plot, PlotAxis, PlotElement, PlotHover, TooltipState, scale::{Scale, ScaleBand, ScaleLinear, ScaleOrdinal, ScalePoint}, shape::{Arc, Area, Bar, Line, Pie, Stack}, }; ``` ## Write a plot A plot implements [`Plot`]. `paint` receives the bounds the plot fills and draws into them with the window's painting API: ```rust use gpui_kit::base::plot::{Plot, PlotElement, scale::{Scale, ScaleLinear, ScalePoint}, shape::Line}; use gpui_kit::*; struct Sparkline { values: Vec, stroke: Hsla, } impl Plot for Sparkline { fn paint(&mut self, bounds: Bounds, window: &mut Window, _: &mut App) { let width = bounds.size.width.as_f32(); let height = bounds.size.height.as_f32(); let indexes: Vec = (0..self.values.len()).collect(); let x = ScalePoint::new(indexes.clone(), [0., width]); let y = ScaleLinear::new(self.values.clone(), [height, 0.]); let values = self.values.clone(); Line::new() .data(indexes) .x(move |i| x.tick(i)) .y(move |i| y.tick(&values[*i])) .stroke(self.stroke) .stroke_width(1.5) .paint(&bounds, window); } } ``` The plot becomes an element through [`PlotElement`], which fills its container, runs `prepaint`, `paint` and the hover pipeline, and paints the overlay the plot returns: ```rust impl IntoElement for Sparkline { type Element = PlotElement; fn into_element(self) -> Self::Element { PlotElement::new(self) } } div().h_16().w_full().child(Sparkline { values, stroke }) ``` GPUI Component's `#[derive(IntoPlot)]` writes that impl for you; without GPUI Component, write it by hand. A plot sizes itself to its parent, so give the parent a definite height. ## Scales Scales map a domain of data to a range of pixels, or of any other value. | Scale | Domain | Use | | --- | --- | --- | | `ScaleLinear` | Continuous numbers | Value axes; the range can run top-down (`[height, 0.]`) | | `ScaleBand` | Discrete categories | Bars: `band_width()` is each bar's width, `tick` its start | | `ScalePoint` | Discrete categories | Line and area charts over categorical x values | | `ScaleOrdinal` | Discrete categories | Mapping categories to colors | `ScaleLinear` and the charts accept any [`PlotValue`](https://docs.rs/gpui-base/latest/gpui_base/plot/trait.PlotValue.html): `f32`, `f64`, and `rust_decimal::Decimal` with the `decimal` feature. Integers are left out because a linear scale divides values. ## Shapes `Bar`, `Line`, `Area` and `Arc` paint themselves given accessors that turn a datum into pixels; `Pie` lays arcs out from values, and `Stack` computes stacked series for stacked bars and areas. `RadialLine` and the Sankey layout (`Sankey`, `SankeyLink`) serve radar and flow charts. See [Plot in GPUI Component](/versions/v0.7.1/component/plot) for a tour of each shape. `Line` and `Area` connect their points by a `Curve`, set with `.curve(..)`: `Curve::Natural` (the default) draws a smooth curve through every point, `Curve::Linear` straight segments, and `Curve::StepAfter` a step that holds each value until the next point. `Curve` was named `StrokeStyle` before 0.7.0, and `.curve` was `.stroke_style`. ```rust use gpui_kit::base::plot::{Curve, shape::Line}; Line::new() .data(&values) .x(|d| x.tick(&d.label)) .y(|d| y.tick(&d.value)) .curve(Curve::StepAfter) .stroke(color) .paint(&bounds, window); ``` Shapes that repaint every frame can keep their tessellated paths across frames with [`PathCaches`], keyed by the plot's id. ## Axes, grids and labels `PlotAxis` draws an x and y axis line with `AxisText` labels, `Grid` draws horizontal and vertical grid lines, solid or dashed, and `PlotLabel` paints free text at plot coordinates. `axis_gutter(font_size)` is the height an x-axis with labels of that size takes below the plot (18px at the default `TEXT_SIZE`). Builders only record values; labels are placed when the axis paints, so builder order does not matter. Colors and font sizes are arguments; the text face comes from the surrounding text style. ## Hover and tooltips A plot opts into hover by returning an id from `Plot::id`; one that returns `false` from `Plot::interactive` keeps the id's state and its appear, but no hitbox or hover. `PlotElement` then tracks the cursor each frame, occlusion-aware so an open popup above the plot clears it, and asks the plot three questions: 1. `tooltip_state` — map the cursor to a [`TooltipState`]: the hovered index, the crosshair point and the data dots, or `None`. 2. `hover` — receive the hovered [`PlotHover`] before painting. It lingers after the cursor leaves while `progress()` eases back to zero, so emphasis fades out over the last datum instead of vanishing. `is_entering()` is true on the first hovered frame. 3. `tooltip` — return the overlay: a crosshair, dots, a tooltip box. The overlay paints above the plot; a box that may overflow the plot should wrap itself in `deferred`. Base draws no overlay of its own; the styled layer builds it. Inside `Plot::tooltip`, an overlay renders within the plot's element scope, so it can read the hover without being handed it: | Function | Returns | | --- | --- | | `hover_progress(window, cx)` | How far the hover has faded in, `0..=1`; `1` outside a plot | | `is_hover_entering(window, cx)` | Whether this is the first hovered frame | | `pointer_spring(cx)` | The spring a pointer follows the hovered datum with | | `PlotHover::glide(id, target, window, cx)` | A position following `target` on the pointer spring, adopted at once on the entering frame | ## Motion Base installs no plot motion: by default the hover appears and disappears at once and pointers jump to each datum. The styled layer projects its timing through the Base theme: ```rust use std::time::Duration; use gpui_kit::base::{PlotMotion, PlotTheme, Spring, Theme, motion::Transition}; let motion = PlotMotion::default() .with_pointer(Spring::new(Duration::from_millis(120)).with_epsilon(0.1)) .with_enter(Transition::new(Duration::from_millis(120))) .with_exit(Transition::new(Duration::from_millis(120))) .with_appear(Transition::new(Duration::from_millis(1000))); Theme::global_mut(cx).plot = PlotTheme::new().with_motion(motion); ``` `with_appear` sets how a plot's data draws in the first time its id is painted. A plot receives the progress in `Plot::appear`, before `Plot::hover` and `Plot::paint`; `PlotAppear::staggered` gives each of several marks its own slice of it. A plot opts in by returning `Some` from `Plot::appear_generation`, and a new value replays the appear; the default `None` tracks nothing and asks for no frames. A plot with an id that is not `Plot::interactive` still appears, and hover tracking waits until the appear is done. ### Appear scope The appear lives in the plot's element state, so a plot that stops being painted — a row a virtual list scrolled away — draws in again when it comes back. `PlotAppearScope` remembers which plots inside it have finished, by global element id and appear generation, and hands those a complete appear at once, with no frames asked for: ```rust PlotAppearScope::new(("transcript", conversation_id), list(state, render_row).flex_1()) ``` A new generation still replays, and a plot taken away mid-appear draws in again from the start. The memory is the scope's own element state, so it lasts while the scope is painted every frame: close the view, or give the scope another id, and its plots draw in afresh. The innermost scope wins. The scope takes no part in layout, so a child that sizes itself, such as a `list`, keeps doing so. GPUI Component projects its motion tokens here whenever its theme changes. Motion honors the operating system's reduced-motion preference, under which every value adopts its target at once. --- # Motion Source: /versions/v0.7.1/base/motion License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/motion and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `gpui-base` owns deterministic motion sampling and lifecycle while leaving every visual choice to the application. It provides stable keyed state, interruption, reversal, animation-frame requests, and reduced-motion behavior without imposing product timing or styling. Run the interactive companion for this guide: ```bash cargo run -p gpui-base-examples --bin motion ``` The example contains five separate demos. Use the tabs at the top to inspect one capability at a time. ## Capability map | Demo | API | What it demonstrates | | --- | --- | --- | | Sliding time | `transition` | Four independently rolling digits, 08:00–20:00, with targets changing faster than the transition settles | | Spring | `spring` | A segmented-control indicator that preserves velocity when rapidly retargeted | | Keyframes | `Keyframes`, `Timing`, `animate_keyframes` | A repeating multi-stop activity signal | | Stagger | `Stagger` | Allocation-free timing offsets across a list | | Presence | `Presence` | Exit animation that keeps content mounted until it becomes absent | | Sequence | `Sequence` | Three chained steps — slide in, fill, rest then fade — each starting when the last one ends | The library also exposes `Easing`, `Discrete`, `MotionTransform`, and `MotionReveal`. They compose with the same primitives rather than requiring separate animation runtimes. ## Target transitions Use `transition` for a value moving toward a target over a known duration. Every independently animated value needs a stable ID. ```rust let opacity = transition( ("save-dialog", "opacity"), if open { 1.0 } else { 0.0 }, Transition::new(Duration::from_millis(180)).easing(Easing::EaseOut), window, cx, ); ``` Retargeting starts at the currently sampled value. Direct reversal shortens the return duration, so reversing early does not spend a full duration retracing a short distance. `transition_with_status` additionally returns `Idle`, `Delayed`, `Running`, or `Finished`. `Easing` includes CSS keyword curves, cubic Bézier curves, all CSS step positions, and piecewise `linear()` stops. Invalid parameters return typed errors. ## Springs Use `spring` when the target may change while moving. It preserves both position and velocity, which makes it suitable for selection indicators and settling spatial values. ```rust let x = spring( "selected-indicator", selected_x, Spring::new(Duration::from_millis(420)).with_damping(0.72), window, cx, ); ``` Do not make a pointer-controlled value chase the pointer through a spring. Set `with_travel(false)` during direct manipulation and restore travel after release. `with_damping` requires a finite, non-negative ratio; `with_epsilon` requires a finite value greater than zero and interprets it in the target's own units. The builders panic for invalid trusted constants. Use `try_with_damping` and `try_with_epsilon` for configuration or user-provided values. Normalized values normally keep the `0.001` default; pixel motion can use a coarser tolerance such as `0.1`. ## Keyframes and timing `Keyframes` describes validated value stops. `Timing` uses absolute elapsed time and supports signed delays, finite or infinite iterations, and normal, reverse, or alternating playback. ```rust let frames = Keyframes::try_new([ Keyframe::new(0.0, 0.25), Keyframe::new(0.45, 1.0).ease(Easing::EaseOut), Keyframe::new(1.0, 0.25), ])?; let opacity = animate_keyframes( "activity", &frames, Timing::new(Duration::from_millis(1400)) .iterations(IterationCount::Infinite), window, cx, ).value; ``` Offsets must start at `0`, end at `1`, and be monotonic. Use `Discrete` when a value cannot be interpolated. `animate_keyframes` retains its playback start time under the supplied stable ID. Re-rendering with the same ID continues the current sequence. To replay it, include an application-owned generation in the ID, such as `("notification-enter", generation)`, and increment that generation for each replay. ## Presence and stagger `Presence` separates logical visibility from physical mounting. Its phases are entering, present, exiting, and absent. Render while `should_render()` is true and use `progress` for the chosen visual properties. Reopening during exit reverses from the current sample. `Stagger` calculates a delay for an index from the first, last, center, or a chosen origin. It does not allocate a schedule or own list identity: ```rust let stagger = Stagger::new(Duration::from_millis(80), StaggerOrigin::First); let delay = stagger.delay(index, item_count); ``` ## Sequences `Sequence` chains transitions so each step starts when the previous one ends. It begins at `from` on the first frame it is sampled and plays once per ID; its sample reports the value, the step being played, and a `MotionStatus` that reads `Finished` only after the last step. ```rust let opacity = Sequence::new(("toast", "opacity"), 0.0) .with_step(1.0, Transition::new(Duration::from_millis(160))) .with_step(0.0, Transition::new(Duration::from_millis(200)).delay(Duration::from_secs(3))) .sample(window, cx); div().opacity(*opacity.value()) ``` A step ends at an absolute instant and the next one starts there, not on the frame that noticed it, so a skipped frame does not start a step late. Zero-duration steps complete within one frame. Changing the target of the step being played restarts the sequence from its first step at the value sampled at that instant; steps not yet reached are read when the sequence gets to them. To replay, put an application-owned generation in the ID. Reduced motion adopts the last target at once with no pending frame. `Stagger` composes with a sequence as a delay on its first step: ```rust Sequence::new(("row", index), px(12.)) .with_step(px(0.), Transition::new(Duration::from_millis(120)).delay(stagger.delay(index, count))) .sample(window, cx) ``` ## Measured reveal `MotionReveal` measures a child at its natural size and clips its visible height by progress. `Collapsible::motion_id(...)` is the convenient control-level facade. Without a motion ID, the control keeps immediate mount/unmount behavior. ## Reduced motion and performance Transitions, springs, keyframes, presence, and reveal-compatible controls honor GPUI's reduced-motion preference. Finite motion snaps to the target, synchronizes retained state, and leaves no pending animation frame. Motion must never be the only way state is communicated. The preference is the operating system's. `gpui_base::init` (and so `gpui_component::init`) reads the system setting into `App::set_reduce_motion` — macOS's "Reduce motion" (`NSWorkspace.accessibilityDisplayShouldReduceMotion`), Windows' "Animation effects" (`SPI_GETCLIENTAREAANIMATION`, off means reduce), and on Linux the XDG desktop portal's `org.freedesktop.appearance` `reduced-motion` key, which arrives over D-Bus a moment after `init` and is then followed as it changes. Other targets, wasm included, leave the flag alone. An application that calls `cx.set_reduce_motion(...)` itself owns the flag from then on: Base only writes it while it still holds what Base last wrote. macOS and Windows are read once, at `init`; call `gpui_base::apply_system_reduce_motion(cx)` to read them again. The pure steady sampling paths measured by the benchmark—timing/easing, keyframe lookup, analytic spring integration, and stagger delay calculation—are allocation-free. Keyed transition, spring, presence, and reveal lifecycles are covered by GPUI retained-state and frame-request tests because those updates belong to the framework lifecycle rather than the pure sampler. Sampling uses absolute elapsed time, and keyframe lookup uses binary search. Run the release benchmark with: ```bash cargo bench -p gpui-base --bench motion ``` Choose the smallest suitable primitive: `transition` for duration-based targets, `spring` for changing spatial targets, keyframes for authored sequences, `Presence` for exit-before-unmount, `Sequence` for steps that follow one another, and `Stagger` for list choreography. ## Benchmark results Measured on Linux x86_64 with a release build, 31 batches, and 200 iterations per batch: | Workload | Median | P95 | Worst | Allocations | | --- | ---: | ---: | ---: | ---: | | 1,000 scalar timing + easing samples | 26.490 µs | 26.567 µs | 27.290 µs | 0 | | 1,000 keyframe samples, 2 frames | 21.656 µs | 21.707 µs | 21.729 µs | 0 | | 1,000 keyframe samples, 8 frames | 25.197 µs | 25.251 µs | 25.269 µs | 0 | | 1,000 keyframe samples, 32 frames | 27.932 µs | 27.969 µs | 27.971 µs | 0 | | 1,000 analytic spring integration samples | 6.042 µs | 6.106 µs | 6.216 µs | 0 | | 1,000 stagger delay calculations | 0.574 µs | 0.583 µs | 0.587 µs | 0 | The scalar timing/easing workload remains below its 100 µs median budget. These figures are a reproducible development baseline rather than a cross-platform guarantee; run the benchmark on each target platform when platform-specific performance matters. --- # Getting Started Source: /versions/v0.7.1/base/getting-started License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/getting-started and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Applications use `gpui_kit::open_window(options, cx, build)`, which creates a Base `Root` and returns the window handle and content entity. Base owns window structure and overlay hosting without depending on Component. Initialize Component explicitly before opening windows when styled overlays are needed. ## Install Use the repository revision of GPUI that matches `gpui-base`: ```toml [dependencies] gpui-base = { git = "https://github.com/longbridge/gpui-kit" } gpui = { git = "https://github.com/zed-industries/zed" } gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit"] } ``` ## Initialize Call `gpui_kit::base::init` once before opening windows. If the application already calls `gpui_kit::component::init`, base initialization is included. ```rust use gpui_kit::AppContext as _; fn main() { gpui_platform::application().run(|cx| { gpui_kit::base::init(cx); // Open your application window here. }); } ``` ## Render and style a control Base controls intentionally have no product-specific padding, colors, or radius. Style them with ordinary GPUI methods: ```rust use gpui_kit::prelude::*; use gpui_kit::{px, rgb}; use gpui_kit::base::Button; Button::new("save") .px_3() .py_2() .rounded(px(6.)) .bg(rgb(0x2563eb)) .text_color(rgb(0xffffff)) .on_click(|_, _, _| println!("save")) .child("Save") ``` Keep each `ElementId` stable across renders so GPUI can preserve element and focus state. Controlled components such as Checkbox, Switch, Radio, and Toggle report the next value through callbacks; store that value in your view and pass it back on the next render. ## Default color tokens `gpui-base` provides readable light and dark semantic palettes through `ColorTokens::light()` and `ColorTokens::dark()`. `ColorTokens::default()` uses the light palette. Both palettes use `Hsla` values and match the semantic roles of the default `gpui-component` themes. ```rust use gpui_kit::base::{ColorTokens, SemanticThemeTokens, Theme}; // Pick the palette that matches the application's current appearance. let colors = if is_dark { ColorTokens::dark() } else { ColorTokens::light() }; Theme::global_mut(cx).tokens = SemanticThemeTokens { colors, ..Default::default() }; ``` The palette contains semantic roles rather than component-specific colors: `background` and `foreground`, `surface` and `surface_foreground`, `primary`, `secondary`, `muted`, `accent`, `destructive`, `border`, `input`, `ring`, and `selection`, including the corresponding foreground roles. Base components derive what they can from these roles — a link takes `primary`, for instance — rather than adding a component-specific token for it. `selection` is its own role because no other one can stand in for it: it is painted under the glyphs and has to stay legible there, which neither `accent` nor `ring` guarantees. Calling `gpui_kit::component::init` projects its active light or dark theme into the same Base tokens automatically. Applications that use only `gpui-base` should install the matching palette when their appearance mode changes. ## Run the shared examples The examples used by this website also run as a native GPUI application: ```sh cargo run -p gpui-base-examples -- button ``` Replace `button` with a primitive slug from the [primitive catalog](/versions/v0.7.1/base/primitives). The website compiles the same showcase for `wasm32-unknown-unknown` and loads it on each primitive page. --- # GPUI Base Source: /versions/v0.7.1/base License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `gpui-base` is the unstyled foundation of GPUI Kit, the Rust desktop application framework. It provides interaction behavior, controlled state, focus management, accessibility semantics, animation, virtual lists, and theme tokens while leaving layout and visual design to your application. ## Choose the right layer | Use | When | | --- | --- | | `gpui-base` | You are building a design system and want to own every visual choice. | | `gpui-component` | You want a complete set of styled, ready-to-use desktop components. | The dependency points one way: `gpui-component` builds on `gpui-base`. Applications can use either layer directly. ## Principles - **Behavior is built in.** Controls provide consistent pointer, keyboard, focus, and state behavior. - **Presentation is yours.** Compose GPUI style methods and children without fighting default visuals. - **Parts stay composable.** Primitives expose their meaningful subparts instead of hiding markup behind a monolith. - **State stays explicit.** Controlled inputs report changes and your view owns the resulting state. ## Start building Follow [Getting started](/versions/v0.7.1/getting-started), render selectable Markdown and HTML with [TextView](/versions/v0.7.1/text-view), learn how to add [window-level text selection](/versions/v0.7.1/text-selection) to custom renderers, then explore the [primitive catalog](/versions/v0.7.1/primitives). Each page includes Rust snippets and a live WASM example backed by the same example crate that can run natively. Three systems are larger than a primitive and have pages of their own. [Motion](/versions/v0.7.1/motion) provides typed transitions, springs, keyframes, presence, and sequencing. [Virtual List](/versions/v0.7.1/virtual-list) renders lists of any length by drawing only what is on screen, with per-item sizes rather than a uniform row height. [Dock](/versions/v0.7.1/dock) is a full workspace shell — nested splits, tab groups and edge docks — whose layout is pure data you can build and serialize without a window, and whose every pixel comes from renderer traits you implement. [History](/versions/v0.7.1/history) covers two smaller, deliberately distinct structures: `History` is a root/current/back/forward navigation trail, while `UndoHistory` records grouped undo and redo transactions. [Plot](/versions/v0.7.1/plot) is the unstyled foundation for charts: scales, shapes, axes, the element that turns a `Plot` into a child, and hover tracking, with colors and timing left to your design system. --- # History Source: /versions/v0.7.1/base/history License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/history and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `History` and `UndoHistory` keep two different kinds of application state. Both are independent of GPUI and leave applying a returned value to the caller, but their operations intentionally have different meanings: - `History` is a browser-style linear trail of locations, with back and forward navigation. - `UndoHistory` records changes as undo transactions, including changes grouped into one user action. ## Import ```rust use gpui_kit::base::{History, UndoHistory}; ``` ## Which one to use Choose the type from the meaning of the state, not from the names of the UI commands that operate on it: - Use `History` when each entry is a location and moving backward or forward returns the location reached. The current root must remain available. - Use `UndoHistory` when each entry is a reversible change and undo or redo must return every change in one user transaction. - Use a domain-specific manager when grouping depends on richer semantics than time or explicit boundaries. The Input component, for example, has a private transaction manager that understands typing, deletion, selection, and IME composition. Within gpui-component, `NavStack` uses `History` for page navigation, and `UndoHistory` is available to any state that wants grouped undo and redo. Input deliberately keeps its specialized private undo manager. ## `History`: a navigation trail Push every location the user visits. The current entry is the last value in the trail. For example, after visiting `A -> B -> C`, `C` is current and going back returns the new current entry, `B`: ```rust let mut history = History::new(); history.push("A"); history.push("B"); history.push("C"); assert_eq!(history.back(), Some("B")); assert_eq!(history.current(), Some(&"B")); ``` `back()` never moves past the root entry; it returns `None` there. `forward()` restores the nearest entry that was left behind. Pushing a new entry after going back drops that forward branch, just as a browser does after opening a new page. `max_entries` bounds the root-to-current entries: lowering it removes the oldest active entries immediately, and moving forward at the limit removes the oldest active entry before restoring the next one. `entries()` iterates from the root to the current entry. With the full `A -> B -> C` trail, it yields `A`, `B`, then `C`; `entries().rev()` yields `C`, `B`, then `A`. `forward_entries()` iterates from the nearest forward entry to the furthest. Use `retain` to remove invalid locations, `replace_current` to update the current location in place, and `remove_current` to remove it without discarding the forward branch. | Method | Does | | -------------------------------------------- | ------------------------------------------------------------------------------- | | `new()` | Creates an empty trail. `max_entries` defaults to 1000. | | `max_entries(n)` | Caps root-to-current entries and immediately removes the oldest excess entries. | | `push(entry)` | Makes `entry` current and drops the forward branch. | | `back()`, `forward()` | Move through the trail and return the resulting current entry. | | `current()` | Returns the current entry. | | `can_back()`, `can_forward()` | Report whether movement in that direction is available. | | `entries()`, `forward_entries()` | Iterate the current trail and forward branch in navigation order. | | `replace_current(entry)`, `remove_current()` | Update or remove the current entry. | | `retain(keep)`, `clear()` | Remove rejected entries from both sides, or empty the trail. | ## `UndoHistory`: grouped undo and redo Push a value for each change your application must reverse. To make a drag one undoable action, explicitly group all of its updates. `undo()` returns the group's changes newest first so the most recent change is reverted first; `redo()` returns the same group oldest first so it is applied in its original order: ```rust let mut history = UndoHistory::new(); history.start_grouping(); history.push("move from x=0 to x=10"); history.push("move from x=10 to x=20"); history.end_grouping(); assert_eq!( history.undo(), Some(vec!["move from x=10 to x=20", "move from x=0 to x=10"]), ); assert_eq!( history.redo(), Some(vec!["move from x=0 to x=10", "move from x=10 to x=20"]), ); ``` For changes whose boundary is not explicit, `group_interval` combines consecutive pushes close enough in time. A successful undo or redo ends that timed grouping window, so the next push starts a new transaction. Explicit grouping is separate: while it is active, a push still appends to the current transaction, including after an undo. A new push clears redo transactions. While replaying changes, use `set_ignoring(true)` to prevent the replay itself from being recorded. | Method | Does | | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `new()` | Creates an empty undo history. `max_undos` defaults to 1000. | | `max_undos(n)` | Caps undo transactions and immediately removes the oldest excess transactions. Redo preserves this cap. | | `group_interval(duration)` | Groups consecutive nearby pushes into one transaction. | | `start_grouping()`, `end_grouping()` | Make subsequent pushes append to the current transaction; ending grouping stops that explicit append behavior. On an empty history, as in the example above, the first push starts the transaction. | | `push(change)` | Records a change in the current or a new transaction and clears redo. | | `undo()`, `redo()` | Return the latest transaction newest-first for undo, oldest-first for redo. | | `can_undo()`, `can_redo()` | Report whether a transaction is available. | | `set_ignoring(bool)`, `is_ignoring()` | Control whether pushes are recorded. | | `clear()` | Empties undo and redo transactions. | --- # VirtualList Source: /versions/v0.7.1/base/virtual-list License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/virtual-list and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. # Virtual List Render a list of any length by drawing only the items currently on screen. Unlike `gpui_kit::uniform_list`, **each item may have a different size** — which is what makes it usable for tables with variable row heights, chat transcripts, and outline trees. Virtual List is infrastructure rather than a component: it has no appearance of its own, contributes no chrome, and imposes nothing on the items you return. You give it the sizes up front and a closure that renders a range. ## Why sizes up front Virtualization needs to know the total extent of the list and which items intersect the viewport **without rendering anything**. Two designs solve this: | Approach | How | Cost | | ------------------------ | ---------------------------------------------------------- | ----------------------------------------------------------------------------- | | `gpui_kit::uniform_list` | Every item is the same size, so offsets are multiplication | No per-item data, but no variable sizes | | Measure as you scroll | Render, measure, correct | Scrollbar jumps; scroll position drifts | | **`VirtualList`** | You supply every item's size | Exact offsets and a stable scrollbar, at the cost of knowing sizes in advance | The third is why `item_sizes` is a required argument rather than a callback. If your items are genuinely unmeasurable until drawn, compute a good estimate, or use a fixed row height and let content clip. ## Get started ```rust use std::rc::Rc; use gpui_kit::base::{v_virtual_list, VirtualListScrollHandle}; use gpui_kit::{px, size}; let sizes = Rc::new(vec![size(px(280.), px(32.)); 100_000]); v_virtual_list( cx.entity(), "customers", sizes, |_this, range, _window, _cx| { range .map(|ix| div().h_8().px_2().child(format!("Customer {ix}"))) .collect() }, ) .track_scroll(&self.scroll_handle) .size_full() ``` The closure is handed a `Range` — only the visible slice, plus a small overdraw — and returns one element per index in that range. It receives `&mut V` for the entity you passed, so it can read your data without cloning it into the closure. Use `h_virtual_list` for a horizontal list; everything else is identical. ## The size contract Three rules, and breaking any of them shows up as misplaced items rather than a panic. **Only one dimension is read.** A vertical list uses each `Size`'s `height` and ignores its `width`; a horizontal list uses `width` and ignores `height`. The value you pass for the unused axis is free. **The cross axis is measured, not declared.** A vertical list gets its width by laying out one item and measuring it — by default item 0. If your first item is not representative (an unusually short label, say), point it at one that is: ```rust v_virtual_list(entity, "rows", sizes, render) .with_item_to_measure_index(3) ``` **`item_sizes.len()` is the item count.** The list renders exactly that many items; there is no separate count argument. If the vector disagrees with your data, the extra indices are still requested from your closure. `Rc>>` is shared rather than owned so that rebuilding the element every frame does not reallocate the size table. Keep the `Rc` in your entity and clone the handle, rather than constructing the vector inside `render`. ## Scrolling `VirtualListScrollHandle` owns the scroll position and survives re-renders, so it belongs in your entity, not in `render`. ```rust struct CustomerList { scroll: VirtualListScrollHandle, } // Jump to an item self.scroll.scroll_to_item(4_200, ScrollStrategy::Top); // Follow a growing list self.scroll.scroll_to_bottom(); ``` `scroll_to_item` takes a `ScrollStrategy` — `Top`, `Center`, or `Bottom` — and works on indices that have never been rendered, because the offset comes from the size table rather than from measurement. `base_handle()` exposes the underlying GPUI `ScrollHandle` when you need it. Attach the handle with `.track_scroll(&handle)`. ## With a scrollbar `VirtualListScrollHandle` implements `ScrollbarHandle`, so the base `Scrollbar` reads it directly. The list draws no scrollbar of its own. ```rust div() .relative() .child( v_virtual_list(cx.entity(), "rows", sizes, render) .track_scroll(&self.scroll) .size_full(), ) .child(Scrollbar::vertical(&self.scroll)) ``` The container needs `relative()` because the scrollbar positions itself against it. ## Sizing behavior `with_sizing_behavior` controls whether the list computes a size of its own: - `ListSizingBehavior::Auto` (default) — the list does not calculate a fixed size, and takes the space its parent gives it. - `ListSizingBehavior::Infer` — the list calculates its size from its items. The default plus a bounded parent is what almost every layout wants. A virtual list inside an unbounded parent has nothing to virtualize against, so it would try to lay out every item. ## The render closure The closure runs on **every frame that the visible range changes**, so treat it as a hot path: - Do no I/O, sorting, or filtering inside it. Keep the prepared data in your entity and index into it. - Return elements, not entities. Creating a GPUI entity per row defeats virtualization — the entity outlives the frame, so a hundred thousand rows would create a hundred thousand entities. - Element ids, if you set them, should derive from the item index or a stable key, not from position within the returned vector. State lives outside the closure. Update it in your own callbacks and call `cx.notify()`; do not mutate it during render. ## What it costs Work per frame is proportional to the number of **visible** items, not total items — the example on this page holds 100,000 rows and draws about a dozen. The size table is the one part that scales with the total: it is one `Size` per item, held once behind an `Rc`. The tradeoff is that the size table must exist before the first frame. For a million rows of uniform height that is a megabyte of sizes for information a single number could carry — that is the case `gpui_kit::uniform_list` exists for, and it is the better choice there. ## Complete Rust example ```bash cargo run -p gpui-base-examples -- virtual-list ``` ```rust use super::*; const ITEM_COUNT: usize = 100_000; impl BaseShowcase { pub(in super::super) fn virtual_list(&self, cx: &mut Context) -> impl IntoElement { let sizes = Rc::new(vec![size(px(280.), px(32.)); ITEM_COUNT]); div() .relative() .w_72() .h_48() .overflow_hidden() .border_1() .border_color(super::example_rgb(0x171717)) .child( v_virtual_list( cx.entity(), "example-virtual-list", sizes, |_, range, _, _| { range .map(|ix| { div() .w_full() .h_8() .px_2() .text_xs() .flex() .items_center() .border_b_1() .border_color(gpui::black()) .justify_between() .child( div() .flex() .items_center() .gap_2() .child( div() .size(px(18.)) .flex_none() .flex() .items_center() .justify_center() .line_height(px(18.)) .border_1() .border_color(gpui::black()) .child(format!("{}", (ix % 9) + 1)), ) .child(format!("Customer {:06}", ix + 1)), ) .child(format!("ID-{:06}", 100_000 + ix)) }) .collect() }, ) .track_scroll(&self.virtual_scroll) .size_full(), ) .child(Scrollbar::vertical(&self.virtual_scroll).mode(ScrollbarMode::Always)) } } ``` ## Checklist - Hold `item_sizes` and the scroll handle in your entity; rebuild neither during render. - Keep `item_sizes.len()` equal to your data length. - Point `with_item_to_measure_index` at a representative item if item 0 is not one. - Give the list a bounded parent, and `relative()` on that parent if you add a scrollbar. - Preserve logical order, item counts, and stable identity so assistive technology sees a coherent list across virtualization. --- # Number Input Source: /versions/v0.7.1/base/primitives/number-input License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/number-input and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A numeric input with reusable increment, decrement, and step behavior. Like every `gpui-base` primitive, Number Input supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- number-input ``` ## Import ```rust use gpui_kit::base::{Decrement, Increment, NumberInput, NumberInputText}; ``` ## Anatomy and API The example composes `Decrement`, `Increment`, `NumberInput`, `NumberInputText`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/number_input.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/number_input.rs). Native and browser previews compile this same file. ## State and events The backing input state owns numeric text/value; step actions apply the configured limits. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use gpui::{ AnyElement, Context, InteractiveElement, IntoElement, ParentElement as _, Styled as _, div, px, relative, }; use gpui_base::{Button, NumberInput}; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn number_input(&self, cx: &mut Context) -> impl IntoElement { let valid = self.input.read(cx).value().parse::().is_ok(); fn render_btn(this: Button, icon: AnyElement) -> Button { this.w(px(24.)) .flex_1() .min_h_0() .line_height(relative(1.)) .flex() .items_center() .justify_center() .bg(gpui::black()) .text_color(gpui::white()) .hover(|this| this.bg(gpui::black().opacity(0.8))) .child(icon) } fn minus_icon() -> AnyElement { div() .w(px(8.)) .h(px(1.)) .bg(gpui::white()) .into_any_element() } fn plus_icon() -> AnyElement { div() .relative() .size(px(8.)) .child( div() .absolute() .top(px(3.5)) .left_0() .w_full() .h(px(1.)) .bg(gpui::white()), ) .child( div() .absolute() .left(px(3.5)) .top_0() .h_full() .w(px(1.)) .bg(gpui::white()), ) .into_any_element() } div() .w(px(200.)) .flex() .flex_col() .gap_1() .text_xs() .child(div().text_xs().child("Quantity")) .child( NumberInput::new(&self.input) .controls_right() .w_full() .h_7() .flex() .items_center() .border_1() .border_color(if valid { super::example_rgb(0x171717) } else { super::example_rgb(0x737373) }) .input(div().w_full().px_2().child(self.input.clone())) .decrement_button(|button| render_btn(button, minus_icon())) .increment_button(|button| render_btn(button, plus_icon())), ) .child( div() .text_xs() .text_color(super::example_rgb(0x737373)) .child(if valid { "Step: 1" } else { "Enter a number" }), ) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Expose label, value, bounds, and keyboard-accessible step actions. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Input Source: /versions/v0.7.1/base/primitives/input License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/input and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `Input` is the single-line text control in `gpui-base`. It owns editing behavior, focus, selection, keyboard input, IME, masking, validation, and events while the application supplies presentation. Use [Textarea](/versions/v0.7.1/base/primitives/textarea) for ordinary multi-line text and [Editor](/versions/v0.7.1/base/primitives/editor) for source code. ## Import ```rust use gpui_kit::base::input::{Input, InputEvent, InputState}; ``` ## Basic usage Create the persistent state once, then render `Input` with that entity: ```rust let input = cx.new(|cx| { InputState::new(window, cx) .placeholder("Account name") .default_value("Ada") }); Input::new(&input) ``` Read and update the value through the state: ```rust let value = input.read(cx).value(); input.update(cx, |state, cx| { state.set_value("Grace", window, cx); }); ``` ## Masking and validation ```rust let password = cx.new(|cx| { InputState::new(window, cx) .placeholder("Password") .masked(true) .validate(|value, _| value.chars().count() >= 8) }); ``` For formatted values, combine `mask_pattern`, `pattern`, `min`, `max`, `step`, or `step_by` as appropriate. `unmask_value()` returns the underlying value of a masked input. ## Events `InputState` emits `InputEvent::Change`, `PressEnter`, `Focus`, and `Blur`. ```rust cx.subscribe(&input, |this, state, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { this.value = state.read(cx).value(); cx.notify(); } }); ``` ## Presentation `gpui-base` does not install product styling. Supply `InputEditorStyle` to the state and compose the control inside your own frame. If you want the ready-made theme, sizing, borders, prefix/suffix slots, and clear button, use the styled [`gpui-component` Input](/versions/v0.7.1/component/input). ## Runnable example ```bash cargo run -p gpui-base-examples -- input ``` The implementation is in [`crates/base/examples/showcase/components/input.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/input.rs). ## Atomic inline tokens Use tokens for mentions or references that users select and delete as a whole. Insert one through the InputState you already use for this control: ```rust use gpui_kit::base::input::InlineToken; input.update(cx, |state, cx| { state.replace_with_token( InlineToken::new("person-1", "@alice").with_label("Alice"), window, cx, ).expect("valid reference"); }); ``` Tokens display as unstyled labels. Use the `token` slot to supply your own single-row element and `on_token_click` to open a reference. Use `on_token_hover` to report pointer entry and exit (`InlineTokenHoverEvent::is_hovered`) without selecting or editing, for tooltips or previews; disabled tokens never report hover entry, but disabling a hovered token sends its exit. Copy and `value()` return the real text, such as `@alice`. Save drafts with `content()` and restore them with `set_value(content)` to keep their references. See [Input's token examples](/versions/v0.7.1/component/input#atomic-inline-tokens) for custom rendering, draft restoration and range units. Import the data types from `gpui_kit::base::input`. In JavaScript, use `InputState.new()` from `gpui-base`; it provides the same token methods. --- # Slider Source: /versions/v0.7.1/base/primitives/slider License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/slider and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A state-driven range input with independently styleable track, indicator, and thumb. Like every `gpui-base` primitive, Slider supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- slider ``` ## Import ```rust use gpui_kit::base::{Slider, SliderState}; ``` ## Anatomy and API The example composes `Slider`, `SliderState`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/slider.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/slider.rs). Native and browser previews compile this same file. ## State and events `SliderState` owns bounds and value; track, indicator, and thumb are separate visual parts. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use super::*; use gpui::relative; impl BaseShowcase { pub(in super::super) fn slider(&self, cx: &mut Context) -> impl IntoElement { let percentage = self.slider.read(cx).percentage().end; let thumb_size = 14.; div() .w_56() .text_xs() .child( div() .mb_2() .flex() .justify_between() .child("Volume") .child("Drag to adjust"), ) .child( Slider::new(&self.slider).w_full().h_7().child( SliderTrack::new(&self.slider) .relative() .w_full() .h_full() .child( div() .absolute() .top(px(13.)) .left_0() .w_full() .h(px(2.)) .bg(super::example_rgb(0xd4d4d4)), ) .child( SliderIndicator::new(&self.slider) .absolute() .top(px(13.)) .left_0() .w_full() .h(px(2.)) .child( div() .absolute() .top_0() .bottom_0() .left_0() .right(relative(1. - percentage)) .bg(super::example_rgb(0x171717)), ), ) .child( SliderThumb::new(&self.slider) .absolute() .top(px(7.)) .left(relative(percentage)) .ml(px(-thumb_size / 2.)) .size(px(thumb_size)) .bg(super::example_rgb(0xffffff)) .border_1() .border_color(super::example_rgb(0x171717)), ), ), ) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Expose label, current value, and bounds; support keyboard increments. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Alert Dialog Source: /versions/v0.7.1/base/primitives/alert-dialog License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/alert-dialog and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A modal confirmation surface for actions that need an explicit decision. Like every `gpui-base` primitive, Alert Dialog supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- alert-dialog ``` ## Import ```rust use gpui_kit::base::{AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogDescription, AlertDialogPopup, AlertDialogTitle, AlertDialogTrigger}; ``` ## Anatomy and API The example composes `AlertDialog`, `AlertDialogAction`, `AlertDialogCancel`, `AlertDialogDescription`, `AlertDialogPopup`, `AlertDialogTitle`, `AlertDialogTrigger`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/alert_dialog.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/alert_dialog.rs). Native and browser previews compile this same file. ## State and events Opening and dismissal are managed by `AlertDialog`; application action buttons decide when destructive work is committed. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use gpui::relative; use super::*; impl BaseShowcase { pub(in super::super) fn alert_dialog(&self, cx: &mut Context) -> impl IntoElement { let open = self.alert_dialog_open; let entity = cx.entity().downgrade(); let open_entity = entity.clone(); let ok_entity = entity.clone(); let cancel_entity = entity.clone(); let action_entity = entity.clone(); div() .child( Button::new("open-alert-dialog") .h_7() .line_height(relative(1.)) .px_3() .flex() .items_center() .justify_center() .bg(gpui::black()) .text_color(gpui::white()) .on_click(move |_, _, cx| { _ = open_entity.update(cx, |this, cx| { this.alert_dialog_open = true; cx.notify(); }); }) .child("Delete project"), ) .child( AlertDialog::new(cx) .open(open) .on_open_change(move |open, _, _, cx| { _ = entity.update(cx, |this, cx| { this.alert_dialog_open = open; cx.notify(); }); }) .on_ok(move |_, _, cx| { _ = ok_entity.update(cx, |this, cx| { this.alert_dialog_open = false; cx.notify(); }); true }) .backdrop( AlertDialogBackdrop::new() .absolute() .inset_0() .bg(super::example_rgb(0x000000)) .opacity(0.18), ) .popup( AlertDialogPopup::new() .w_72() .p_3() .bg(super::example_rgb(0xffffff)) .border_1() .border_color(super::example_rgb(0x171717)) .child(AlertDialogTitle::new().child("Delete project?")) .child( AlertDialogDescription::new() .mt_2() .text_xs() .text_color(super::example_rgb(0x525252)) .child( "This permanently deletes Acme Studio and all of its data.", ), ) .child( div() .mt_3() .flex() .justify_end() .gap_2() .child( AlertDialogCancel::new().child( Button::new("cancel-delete") .px_3() .h_7() .flex() .items_center() .text_xs() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .on_click(move |_, _, cx| { _ = cancel_entity.update(cx, |this, cx| { this.alert_dialog_open = false; cx.notify(); }); }) .child("Cancel"), ), ) .child( AlertDialogAction::new().child( Button::new("confirm-delete") .px_3() .h_7() .flex() .items_center() .text_xs() .border_1() .border_color(super::example_rgb(0x171717)) .bg(super::example_rgb(0x171717)) .text_color(super::example_rgb(0xffffff)) .on_click(move |_, _, cx| { _ = action_entity.update(cx, |this, cx| { this.alert_dialog_open = false; cx.notify(); }); }) .child("Delete"), ), ), ), ), ) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Provide title and description, trap focus, offer cancel, and restore focus to the opener. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Dialog Source: /versions/v0.7.1/base/primitives/dialog License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/dialog and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A composable modal surface with focus management, backdrop, title, and close parts. Like every `gpui-base` primitive, Dialog supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- dialog ``` ## Import ```rust use gpui_kit::base::{Dialog, DialogBackdrop, DialogClose, DialogDescription, DialogPopup, DialogTitle, DialogTrigger}; ``` ## Anatomy and API The example composes `Dialog`, `DialogBackdrop`, `DialogClose`, `DialogDescription`, `DialogPopup`, `DialogTitle`, `DialogTrigger`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/dialog.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/dialog.rs). Native and browser previews compile this same file. ## State and events `Dialog` manages modal presentation and dismissal; application callbacks own submitted work. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use super::*; use gpui::{MouseButton, relative}; impl BaseShowcase { pub(in super::super) fn dialog(&self, cx: &mut Context) -> impl IntoElement { let open = self.dialog_open; let entity = cx.entity().downgrade(); let open_entity = entity.clone(); div() .child( Button::new("open-dialog") .h_7() .line_height(relative(1.)) .px_3() .flex() .items_center() .justify_center() .bg(gpui::black()) .text_color(gpui::white()) .on_click(move |_, _, cx| { _ = open_entity.update(cx, |this, cx| { this.dialog_open = true; cx.notify(); }); }) .child("Edit profile"), ) .child( Dialog::new(cx) .open(open) .on_open_change(move |open, _, _, cx| { _ = entity.update(cx, |this, cx| { this.dialog_open = open; cx.notify(); }); }) .backdrop( DialogBackdrop::new() .absolute() .inset_0() .bg(super::example_rgb(0x000000)) .opacity(0.2), ) .popup( DialogPopup::new() .w_72() .p_3() .flex() .flex_col() .items_stretch() .text_xs() .bg(super::example_rgb(0xffffff)) .border_1() .border_color(super::example_rgb(0xd4d4d4)) .child( DialogTitle::new() .font_weight(gpui::FontWeight::SEMIBOLD) .child("Edit profile"), ) .child( DialogDescription::new() .mt_2() .text_color(super::example_rgb(0x737373)) .child("Update the public details shown on your profile."), ) .child(div().mt_3().text_sm().child("Display name")) .child( InputBase::new("dialog-name") .mt_2() .w_full() .h_7() .px_2() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .on_mouse_down(MouseButton::Left, { let input = self.input.clone(); move |_, window, cx| { input.update(cx, |state, cx| state.focus(window, cx)); } }) .child(self.input.clone()), ) .child( div() .mt_3() .flex() .justify_end() .gap_2() .child( gpui_base::DialogClose::new().child( Button::new("dialog-cancel") .h_7() .line_height(relative(1.)) .px_3() .flex() .items_center() .justify_center() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .child("Cancel"), ), ) .child( Button::new("dialog-save") .h_7() .line_height(relative(1.)) .px_3() .flex() .items_center() .justify_center() .bg(super::example_rgb(0x171717)) .text_color(super::example_rgb(0xffffff)) .on_click({ let entity = cx.entity().downgrade(); move |_, _, cx| { _ = entity.update(cx, |this, cx| { this.dialog_open = false; cx.notify(); }); } }) .child("Save changes"), ), ), ), ) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Provide title, initial and return focus, a focus trap, Escape policy, and explicit close action. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Tabs Source: /versions/v0.7.1/base/primitives/tabs License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/tabs and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A tab list and accessible tab controls with controlled selection. Like every `gpui-base` primitive, Tabs supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- tabs ``` ## Import ```rust use gpui_kit::base::{Tab, Tabs}; ``` ## Anatomy and API The example composes `Tab`, `Tabs`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/tabs.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/tabs.rs). Native and browser previews compile this same file. ## State and events The parent owns selected index/value; each tab reflects it and click handlers update the parent. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use super::*; impl BaseShowcase { pub(in super::super) fn tabs(&self, cx: &mut Context) -> impl IntoElement { let selected = self.selected_tab; div() .w_72() .text_xs() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .child( Tabs::new("example-tabs") .flex() .px_2() .pt_1() .border_b_1() .border_color(super::example_rgb(0xd4d4d4)) .children( ["Overview", "Activity", "Settings"] .into_iter() .enumerate() .map(|(index, label)| { let entity = cx.entity().downgrade(); Tab::new(index) .selected(self.selected_tab == index) .px_2() .h_7() .flex() .items_center() .border_b_2() .border_color(if self.selected_tab == index { super::example_rgb(0x171717) } else { super::example_rgb(0xffffff) }) .when(self.selected_tab == index, |this| { this.font_weight(gpui::FontWeight::SEMIBOLD) }) .on_click(move |_, _, cx| { _ = entity.update(cx, |this, cx| { this.selected_tab = index; cx.notify(); }); }) .child(label) }), ), ) .child( div().min_h_20().p_3().child(match selected { 0 => div().child("Workspace overview").child( div() .mt_1() .text_color(super::example_rgb(0x737373)) .child("12 components · 4 contributors · updated today"), ), 1 => div().child("Recent activity").child( div() .mt_1() .text_color(super::example_rgb(0x737373)) .child("Button example was updated 8 minutes ago."), ), _ => div().child("Project settings").child( div() .mt_1() .text_color(super::example_rgb(0x737373)) .child("Manage notifications and member access."), ), }), ) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Associate tabs with panels, expose selection, and support keyboard traversal. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Pagination Source: /versions/v0.7.1/base/primitives/pagination License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/pagination and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A controlled page navigator with explicit current and total page state. Like every `gpui-base` primitive, Pagination supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- pagination ``` ## Import ```rust use gpui_kit::base::{Pagination, PaginationState}; ``` ## Anatomy and API The example composes `Pagination`, `PaginationState`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/pagination.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/pagination.rs). Native and browser previews compile this same file. ## State and events `PaginationState` owns current and total pages; `on_change` reports valid requested pages. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use gpui::{ Context, IntoElement, ParentElement as _, Styled as _, div, prelude::FluentBuilder as _, px, }; use gpui_base::{Button, Pagination, PaginationItem, PaginationState}; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn pagination(&self, cx: &mut Context) -> impl IntoElement { let entity = cx.entity().downgrade(); let state = PaginationState::new(self.page, 8).on_change(move |page, _, cx| { _ = entity.update(cx, |this, cx| { this.page = page; cx.notify(); }); }); let items = state.items(); Pagination::new("example-pagination", state.clone()) .flex() .items_center() .gap_2() .text_xs() .children(items.into_iter().map(move |item| { match item { PaginationItem::Page(page) => { let state = state.clone(); Button::new(("page", page)) .size_7() .p_0() .flex() .items_center() .justify_center() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .when(page == state.current_page(), |this| { this.bg(super::example_rgb(0x171717)) .text_color(super::example_rgb(0xffffff)) }) .on_click(move |_, window, cx| state.request_page(page, window, cx)) .child(page.to_string()) .into_any_element() } PaginationItem::Ellipsis(_) => div() .w(px(20.)) .h_7() .flex() .items_center() .justify_center() .child("…") .into_any_element(), } })) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Identify current page, label previous/next, and disable unavailable boundary actions. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Avatar Source: /versions/v0.7.1/base/primitives/avatar License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/avatar and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. An image with composable fallback content for a person or entity. Like every `gpui-base` primitive, Avatar supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- avatar ``` ## Import ```rust use gpui_kit::base::{Avatar, AvatarFallback, AvatarImage}; ``` ## Anatomy and API The example composes `Avatar`, `AvatarFallback`, `AvatarImage`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/avatar.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/avatar.rs). Native and browser previews compile this same file. ## State and events `Avatar` is presentational. Supply fallback content for the image-loading and image-error paths. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use super::*; impl BaseShowcase { pub(in super::super) fn avatar(&self) -> impl IntoElement { div().flex().items_start().gap_2().children( [ ("AM", 0xf5f5f5), ("JL", 0xe5e5e5), ("SK", 0xd4d4d4), ("+3", 0xffffff), ] .into_iter() .map(|(initials, background)| { Avatar::new() .size(px(34.)) .overflow_hidden() .border_1() .border_color(super::example_rgb(0xa3a3a3)) .fallback( AvatarFallback::new() .flex() .size_8() .items_center() .justify_center() .bg(super::example_rgb(background)) .text_xs() .text_color(super::example_rgb(0x262626)) .child(initials), ) }), ) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Fallback text should identify the entity; decorative avatars should not duplicate nearby labels. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Accordion Source: /versions/v0.7.1/base/primitives/accordion License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/accordion and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A disclosure group composed from independently styleable header, trigger, and panel parts. Like every `gpui-base` primitive, Accordion supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- accordion ``` ## Import ```rust use gpui_kit::base::{Accordion, AccordionHeader, AccordionItem, AccordionPanel, AccordionTrigger}; ``` ## Anatomy and API The example composes `Accordion`, `AccordionHeader`, `AccordionItem`, `AccordionPanel`, `AccordionTrigger`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/accordion.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/accordion.rs). Native and browser previews compile this same file. ## State and events Controlled by `AccordionItem::open`; `AccordionTrigger::on_change` reports the next expanded state. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use super::*; impl BaseShowcase { pub(in super::super) fn accordion(&self, cx: &mut Context) -> impl IntoElement { let items = [ ( "What is GPUI Base?", "Unstyled, accessible primitives for building native GPUI interfaces.", ), ( "Can I bring my own theme?", "Yes. Every visual detail remains application-owned.", ), ( "Does it support keyboard input?", "Focus, activation, and semantic state are built into the primitives.", ), ]; Accordion::new("example-accordion") .w(px(270.)) .border_t_1() .border_color(super::example_rgb(0xd4d4d4)) .children( items .into_iter() .enumerate() .map(|(index, (question, answer))| { let open = self.accordion_items[index]; let entity = cx.entity().downgrade(); AccordionItem::new() .open(open) .header(AccordionHeader::new( AccordionTrigger::new(format!("accordion-trigger-{index}")) .on_change(move |next, _, _, cx| { _ = entity.update(cx, |this, cx| { this.accordion_items[index] = next; cx.notify(); }); }) .w_full() .flex() .items_center() .justify_between() .h_7() .border_b_1() .border_color(super::example_rgb(0xd4d4d4)) .text_xs() .child(question) .child( div() .text_color(super::example_rgb(0x737373)) .child(if open { "−" } else { "+" }), ), )) .panel( AccordionPanel::new() .px_1() .py_1() .border_b_1() .border_color(super::example_rgb(0xd4d4d4)) .text_xs() .text_color(super::example_rgb(0x525252)) .child(answer), ) }), ) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Name every trigger, expose expanded state, and remove collapsed panel content from the focus order. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Combobox Source: /versions/v0.7.1/base/primitives/combobox License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/combobox and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A text input paired with keyboard-navigable suggestions and selection behavior. Like every `gpui-base` primitive, Combobox supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- combobox ``` ## Import ```rust use gpui_kit::base::{Combobox}; ``` ## Anatomy and API The example composes `Combobox`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/combobox.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/combobox.rs). Native and browser previews compile this same file. ## State and events The input state owns query text while the delegate supplies choices, filtering, and selection. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use super::*; use gpui::MouseButton; impl BaseShowcase { pub(in super::super) fn combobox( &self, _window: &mut Window, cx: &mut Context, ) -> impl IntoElement { let open = self.combobox_open; let query = self.combobox_query.read(cx).value().to_lowercase(); let selected = self.combobox_selection.clone(); let entity = cx.entity().downgrade(); let query_state = self.combobox_query.clone(); let open_query_state = self.combobox_query.clone(); let trigger_entity = cx.entity().downgrade(); let trigger_query_state = self.combobox_query.clone(); let combobox = Combobox::new("example-combobox") .open(open) .on_open_change(move |open, window, cx| { _ = entity.update(cx, |this, cx| { this.combobox_open = open; cx.notify(); }); if open { open_query_state.update(cx, |state, cx| state.focus(window, cx)); } }) .w_56() .child( div() .id("combobox-trigger") .w_full() .h_7() .px_2() .flex() .items_center() .justify_between() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .text_xs() .bg(super::example_rgb(0xffffff)) .on_click(move |_, window, cx| { _ = trigger_entity.update(cx, |this, cx| { this.combobox_open = !open; cx.notify(); }); if !open { trigger_query_state.update(cx, |state, cx| state.focus(window, cx)); } }) .child(selected) .child( div() .text_color(super::example_rgb(0x737373)) .child(super::chevron(false)), ), ); let popup = div() .w_56() .text_xs() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .bg(super::example_rgb(0xffffff)) .child( InputBase::new("combobox-search") .w_full() .h_7() .px_2() .border_b_1() .border_color(super::example_rgb(0xe5e5e5)) .on_mouse_down(MouseButton::Left, move |_, window, cx| { query_state.update(cx, |state, cx| state.focus(window, cx)); }) .child(self.combobox_query.clone()), ) .child( div().p_1().children( ["GPUI", "React", "SwiftUI", "Vue"] .into_iter() .filter(|label| query.is_empty() || label.to_lowercase().contains(&query)) .map(|label| { let entity = cx.entity().downgrade(); div() .id(format!("combobox-{label}")) .px_2() .h_7() .flex() .items_center() .text_xs() .hover(|s| s.bg(super::example_rgb(0xf5f5f5))) .on_click(move |_, _, cx| { _ = entity.update(cx, |this, cx| { this.combobox_selection = label.into(); this.combobox_open = false; cx.notify(); }); }) .child(label) }), ), ); Popup::new("example-combobox-popup", combobox).when(open, |this| this.content(popup)) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Synchronize input, popup, active option, and selected value; make every option keyboard reachable. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Button Source: /versions/v0.7.1/base/primitives/button License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/button and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. An unstyled, accessible pressable with semantic state and keyboard activation. Like every `gpui-base` primitive, Button supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- button ``` ## Import ```rust use gpui_kit::base::{Button}; ``` ## Anatomy and API The example composes `Button`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/button.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/button.rs). Native and browser previews compile this same file. ## State and events Activation uses GPUI click handling. Styling for hover, active, focus, and disabled states remains application-owned. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use gpui::relative; use super::*; impl BaseShowcase { pub(in super::super) fn button(&self) -> impl IntoElement { div() .flex() .items_center() .gap_2() .child( Button::new("primary-button") .px_3() .h_7() .line_height(relative(1.)) .flex() .items_center() .text_xs() .border_1() .border_color(super::example_rgb(0x171717)) .bg(super::example_rgb(0x171717)) .text_color(super::example_rgb(0xffffff)) .hover(|style| style.bg(super::example_rgb(0x404040))) .child("Save changes"), ) .child( Button::new("secondary-button") .px_3() .h_7() .line_height(relative(1.)) .flex() .items_center() .text_xs() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .bg(super::example_rgb(0xffffff)) .hover(|style| style.bg(super::example_rgb(0xf5f5f5))) .child("Cancel"), ) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Provide an accessible name, preserve keyboard activation, and expose disabled state. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Tooltip Source: /versions/v0.7.1/base/primitives/tooltip License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/tooltip and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A delayed, positioned description associated with a trigger element. Like every `gpui-base` primitive, Tooltip supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- tooltip ``` ## Import ```rust use gpui_kit::base::{Tooltip}; ``` ## Anatomy and API The example composes `Tooltip`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/tooltip.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/tooltip.rs). Native and browser previews compile this same file. ## State and events Hover or focus schedules it and exit or blur dismisses it; content is descriptive. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use super::*; impl BaseShowcase { pub(in super::super) fn tooltip(&self, cx: &mut Context) -> impl IntoElement { let visible = self.tooltip_visible; let entity = cx.entity().downgrade(); let trigger = div() .id("tooltip-trigger") .on_hover(move |hovered, _, cx| { _ = entity.update(cx, |this, cx| { this.tooltip_visible = *hovered; cx.notify(); }); }) .child( Button::new("tooltip-anchor") .h_7() .px_2() .flex() .items_center() .justify_center() .border_1() .border_color(super::example_rgb(0x171717)) .bg(super::example_rgb(0xffffff)) .child("Command menu"), ); Popup::new("example-tooltip-popup", trigger) .text_xs() .when(visible, |this| { this.content( Tooltip::new("example-tooltip") .px_2() .h_7() .flex() .items_center() .justify_center() .border_1() .border_color(super::example_rgb(0x171717)) .bg(super::example_rgb(0x171717)) .text_color(super::example_rgb(0xffffff)) .child("Open command menu · ⌘K"), ) }) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Show on focus as well as hover; tooltips supplement names and contain no required controls. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Toggle Group Source: /versions/v0.7.1/base/primitives/toggle-group License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/toggle-group and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Coordinates a set of toggle controls as a single- or multiple-selection group. Like every `gpui-base` primitive, Toggle Group supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- toggle-group ``` ## Import ```rust use gpui_kit::base::{Toggle, ToggleGroup}; ``` ## Anatomy and API The example composes `Toggle`, `ToggleGroup`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/toggle_group.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/toggle_group.rs). Native and browser previews compile this same file. ## State and events The group coordinates single or multiple selection while children reflect group state. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use super::*; impl BaseShowcase { pub(in super::super) fn toggle_group(&self, cx: &mut Context) -> impl IntoElement { let italic = self.toggle_group_selection & 1 != 0; let underline = self.toggle_group_selection & 2 != 0; let entity = cx.entity().downgrade(); ToggleGroup::new("example-toggle-group") .flex() .text_xs() .gap_0() .child(self.toggle(cx)) .child( Toggle::new("italic-toggle") .pressed(italic) .size_7() .flex() .items_center() .justify_center() .border_1() .border_l_0() .border_color(super::example_rgb(0x171717)) .when(italic, |this| { this.bg(super::example_rgb(0x171717)) .text_color(super::example_rgb(0xffffff)) }) .accessibility_label("Italic") .child("I") .on_change({ let entity = entity.clone(); move |next, _, _, cx| { _ = entity.update(cx, |this, cx| { if next { this.toggle_group_selection |= 1 } else { this.toggle_group_selection &= !1 }; cx.notify(); }); } }), ) .child( Toggle::new("underline-toggle") .pressed(underline) .size_7() .flex() .items_center() .justify_center() .border_1() .border_l_0() .border_color(super::example_rgb(0x171717)) .when(underline, |this| { this.bg(super::example_rgb(0x171717)) .text_color(super::example_rgb(0xffffff)) }) .accessibility_label("Underline") .child("U") .on_change(move |next, _, _, cx| { _ = entity.update(cx, |this, cx| { if next { this.toggle_group_selection |= 2 } else { this.toggle_group_selection &= !2 }; cx.notify(); }); }), ) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Label the group, expose each selection state, and keep focus order predictable. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Hover Card Source: /versions/v0.7.1/base/primitives/hover-card License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/hover-card and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A delayed floating card associated with a pointer or keyboard trigger. Like every `gpui-base` primitive, Hover Card supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. On iOS and Android, the trigger toggles the card on click and an outside click dismisses it. Hover and its open/close delays are ignored. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- hover-card ``` ## Import ```rust use gpui_kit::base::{HoverCard}; ``` ## Anatomy and API The example composes `HoverCard`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/hover_card.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/hover_card.rs). Native and browser previews compile this same file. ## State and events Pointer or focus entry schedules opening and exit schedules dismissal. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use super::*; impl BaseShowcase { pub(in super::super) fn hover_card(&self) -> impl IntoElement { HoverCard::new("example-hover-card") .trigger( div() .id("hover-trigger") .px_3() .py_1() .text_xs() .text_color(super::example_rgb(0x171717)) .underline() .child("Hover over gpui-base"), ) .content(|_, _, _| { div() .id("hover-content") .w(px(210.)) .p_2() .text_xs() .bg(super::example_rgb(0xffffff)) .border_1() .border_color(super::example_rgb(0xd4d4d4)) .child( div() .flex() .items_center() .gap_2() .child( div() .size_7() .flex() .items_center() .justify_center() .border_1() .border_color(super::example_rgb(0x171717)) .text_sm() .child("G"), ) .child( div().text_sm().child("gpui-base").child( div() .text_sm() .text_color(super::example_rgb(0x737373)) .child("@gpui-base"), ), ), ) .child( div() .mt_2() .text_sm() .text_color(super::example_rgb(0x737373)) .child("Unstyled primitives for GPUI."), ) }) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Expose it from keyboard focus and duplicate essential information outside hover-only content. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Primitives Source: /versions/v0.7.1/base/primitives License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. GPUI Base primitives provide behavior without prescribing presentation. Each page documents the public import and the smallest useful composition. The live example above the page is built from `crates/base/examples` and can also run as a native GPUI application. ## Primitive catalog - [Accordion](/versions/v0.7.1/base/accordion) — A disclosure group composed from independently styleable header, trigger, and panel parts. - [Alert Dialog](/versions/v0.7.1/base/alert-dialog) — A modal confirmation surface for actions that need an explicit decision. - [Avatar](/versions/v0.7.1/base/avatar) — An image with composable fallback content for a person or entity. - [Button](/versions/v0.7.1/base/button) — An unstyled, accessible pressable with semantic state and keyboard activation. - [Calendar](/versions/v0.7.1/base/calendar) — A state-driven date grid with selection matchers and custom item rendering. - [Checkbox](/versions/v0.7.1/base/checkbox) — A controlled tri-state check control with a separately styled indicator. - [Collapsible](/versions/v0.7.1/base/collapsible) — A composable region that shows or hides content without prescribing its trigger styling. - [Color Picker](/versions/v0.7.1/base/color-picker) — State and interaction foundations for selecting colors in a custom picker UI. - [Combobox](/versions/v0.7.1/base/combobox) — A text input paired with keyboard-navigable suggestions and selection behavior. - [Date Picker](/versions/v0.7.1/base/date-picker) — A focus-aware date input that composes calendar behavior with a popup. - [Dialog](/versions/v0.7.1/base/dialog) — A composable modal surface with focus management, backdrop, title, and close parts. - [Hover Card](/versions/v0.7.1/base/hover-card) — A delayed floating card associated with a pointer or keyboard trigger. - [Input](/versions/v0.7.1/base/input) — A single-line text input with selection, masking, validation, and number stepping. - [Textarea](/versions/v0.7.1/base/textarea) — A multi-line text field with fixed rows, wrapping, and auto-grow behavior. - [Editor](/versions/v0.7.1/base/editor) — A source-code editor foundation with highlighting, gutter, folding, decorations, and LSP hooks. - [Link](/versions/v0.7.1/base/link) — An accessible link-like control with application-defined styling. - [Nav Stack](/versions/v0.7.1/base/nav-stack) — A navigation stack of views with push, pop, forward, and replace, and an animatable transition lifecycle. - [Number Input](/versions/v0.7.1/base/number-input) — A numeric input with reusable increment, decrement, and step behavior. - [OTP Input](/versions/v0.7.1/base/otp-input) — A multi-cell one-time-code input driven by a shared text state. - [Pagination](/versions/v0.7.1/base/pagination) — A controlled page navigator with explicit current and total page state. - [Popover](/versions/v0.7.1/base/popover) — An anchored floating surface with controlled or internally managed open state. - [Popup](/versions/v0.7.1/base/popup) — A low-level trigger and anchored floating-content host. - [Progress](/versions/v0.7.1/base/progress) — Composable track and indicator parts for reporting task completion. - [Radio](/versions/v0.7.1/base/radio) — A controlled single-choice item with selectable and disabled semantics. - [Radio Group](/versions/v0.7.1/base/radio-group) — Groups radio items and provides keyboard navigation for a single selection. - [Resizable](/versions/v0.7.1/base/resizable) — Panel groups and resize handles for user-adjustable split layouts. - [Scrollbar](/versions/v0.7.1/base/scrollbar) — An unstyled scrollbar connected to GPUI scroll or uniform-list handles. - [Select](/versions/v0.7.1/base/select) — A button-like selection control backed by an anchored, keyboard-navigable popup. - [Sheet](/versions/v0.7.1/base/sheet) — A modal surface that enters from an edge while managing dismissal and focus. - [Slider](/versions/v0.7.1/base/slider) — A state-driven range input with independently styleable track, indicator, and thumb. - [Switch](/versions/v0.7.1/base/switch) — A controlled on/off control with separately styleable track and thumb. - [Table](/versions/v0.7.1/base/table) — Semantic table primitives for composing headers, bodies, rows, and cells. - [Tabs](/versions/v0.7.1/base/tabs) — A tab list and accessible tab controls with controlled selection. - [Time Field](/versions/v0.7.1/base/time-field) — A segmented time-of-day editor with a complete keyboard model and 24- or 12-hour clocks. - [Toast](/versions/v0.7.1/base/toast) — A managed, animated stack of temporary status messages. - [Toggle](/versions/v0.7.1/base/toggle) — A controlled two-state pressable for persistent choices such as formatting. - [Toggle Group](/versions/v0.7.1/base/toggle-group) — Coordinates a set of toggle controls as a single- or multiple-selection group. - [Tooltip](/versions/v0.7.1/base/tooltip) — A delayed, positioned description associated with a trigger element. - [Tree](/versions/v0.7.1/base/tree) — A virtualized hierarchical list with explicit expansion and selection state. --- # Editor Source: /versions/v0.7.1/base/primitives/editor License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/editor and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `Editor` is the source-code editing control. It builds on the shared text engine and adds a language, line-number gutter, folding, whitespace display, text decorations, highlighting, search infrastructure, diagnostics, and LSP hooks. Use [Input](/versions/v0.7.1/base/primitives/input) for single-line values and [Textarea](/versions/v0.7.1/base/primitives/textarea) for ordinary multi-line text. ## Language editing rules The Base editor accepts `LanguageConfig` and independent `auto_close` / `smart_indent` preferences. It loads registered language configurations without a parser; Component installs a `LanguageProvider` for built-in names, defaults, and syntax providers during initialization. Base clients can install their own service with `set_language_provider`; configurations are set with `set_language_config`. See [Language editing rules](/versions/v0.7.1/component/editor#language-editing-rules) for the configuration fields and language registration; import the same types from `gpui_kit::base::input` when using Base directly. ## Keyboard shortcuts The base and styled editors share keyboard and mouse behavior. See [Keyboard shortcuts and column selection](/versions/v0.7.1/component/editor#keyboard-shortcuts-and-column-selection) for the macOS, Linux, and Windows bindings, multi-cursor editing, and column-selection details. ## Search The editor has a built-in search panel. Press `Ctrl-F` (Windows/Linux) or `Cmd-F` (macOS) while the editor is focused to open it. See [Search](/versions/v0.7.1/component/editor#search) for the programmatic API (`open_search`, `close_search`, `set_searchable`) and read-only behavior. ## Import ```rust use gpui_kit::base::input::{Editor, EditorState, TabSize}; ``` ## Basic usage ```rust let editor = cx.new(|cx| { EditorState::new(window, cx) .language("rust") .line_number(true) .folding(true) .tab_size(TabSize { tab_size: 4, hard_tabs: false, }) .default_value("fn main() {\n println!(\"Hello\");\n}") }); Editor::new(&editor) ``` ## Whitespace and decorations ```rust let editor = cx.new(|cx| { EditorState::new(window, cx) .language("rust") .show_whitespaces(true) .default_value(source) }); let decorations = editor.update(cx, |state, cx| { state.create_decorations_collection(initial_decorations, cx) }); ``` Decoration collections track ranges as the text changes. Keep the returned handle to update or clear its entries; dropping it does not remove decorations. `create_range_decorations_collection` creates an independent collection of paint-only `RangeDecoration` fills or frames. Text and geometric collections share UTF-8 normalization and edit tracking. See [Geometric range decorations](/versions/v0.7.1/component/editor#geometric-range-decorations) for ownership, boundary affinity, deletion, undo/redo, folding, layering, and indexing semantics; import the same types from `gpui_kit::base::input`. ## Highlighting and language features `InputHighlighterFactory`, `InputHighlighter`, diagnostic types, and the LSP provider traits are low-level extension seams for design-system authors. They operate on the shared `InputBaseState`; applications using the styled component normally configure these through their editor integration rather than through ordinary text fields. The runnable showcase demonstrates this seam with `syntect`. It selects the WASM-compatible `fancy-regex` backend rather than the native Oniguruma backend, so the same Rust highlighting adapter runs in the desktop example and the Base WASM example. Syntect only identifies syntax scopes: the adapter maps those to semantic names and resolves their styles through `HighlightStyleResolver`, so the application theme remains the source of colors and font styles. The adapter is intentionally simple and reparses the short sample after each edit; production integrations can keep incremental parser state in their `InputHighlighter` implementation. ## Font The editor has no font setting of its own: it paints with the ambient text style, so the family, size, weight, and line height come from the element the application wraps it in. ```rust div() .font_family("JetBrains Mono") .text_size(px(13.)) .child(Editor::new(&editor)) ``` A relative `line_height` keeps the rows in step with the glyphs at any size; an absolute one stays put. For a ready-made monospace treatment, see the [`gpui-component` Editor](/versions/v0.7.1/component/editor). ## Presentation The application owns editor colors, gutter appearance, fold icons, and overlay content. Use `InputEditorStyle`, `FoldIconRenderer`, and the provider traits to connect those adapters. For the repository's ready-made visual treatment, see the [`gpui-component` Editor](/versions/v0.7.1/component/editor). ## Runnable example ```bash cargo run -p gpui-base-examples -- editor ``` --- # Popup Source: /versions/v0.7.1/base/primitives/popup License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/popup and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `Popup` owns trigger measurement, anchor positioning, deferred rendering, and window-edge snapping. The application owns open state, content, appearance, and motion. Higher-level primitives such as Popover build on the same floating-surface ideas. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- popup ``` ## Import ```rust use gpui_kit::base::Popup; ``` ## Anatomy and API The example composes `Popup`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/popup.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/popup.rs). Native and browser previews compile this same file. ## State and events The caller owns trigger, anchor, open state, content, and dismissal policy. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use gpui::{ Context, IntoElement, ParentElement as _, Styled as _, div, prelude::FluentBuilder as _, relative, }; use gpui_base::{Button, Popup}; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn popup(&self, cx: &mut Context) -> impl IntoElement { let open = self.popup_open; let entity = cx.entity().downgrade(); Popup::new( "example-popup", Button::new("popup-trigger") .h_7() .line_height(relative(1.)) .px_3() .flex() .items_center() .justify_center() .bg(gpui::black()) .text_color(gpui::white()) .on_click(move |_, _, cx| { _ = entity.update(cx, |this, cx| { this.popup_open = !this.popup_open; cx.notify(); }); }) .child(if open { "Close popup" } else { "Open popup" }), ) .when(open, |this| { this.content( div() .w_64() .p_2() .text_xs() .bg(super::example_rgb(0xffffff)) .border_1() .border_color(super::example_rgb(0x171717)) .child("Anchored surface") .child( div() .mt_1() .text_sm() .text_color(super::example_rgb(0x737373)) .child("Popup positions content relative to its trigger."), ), ) }) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility The caller must supply suitable menu, listbox, or dialog semantics and focus policy. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Nav Stack Source: /versions/v0.7.1/base/primitives/nav-stack License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/nav-stack and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A last-in-first-out stack of views, one visible at a time: push a view over the current one, pop back to the one below, or replace the top. It is SwiftUI's `NavigationStack`, Qt's `StackView`, and WinUI's `Frame`. Underneath it is a [History](/versions/v0.7.1/base/history) whose active entries run from the root through the current page. A popped page becomes a forward entry until the next push discards that forward branch, so `forward` brings it back the way WinUI's `GoForward` does. Like every `gpui-base` primitive, Nav Stack supplies behavior and semantic structure without imposing a product visual language. The pages are views you create, and how a change between them moves is decided by your item renderer. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- nav-stack ``` ## Import ```rust use gpui_kit::base::{NavMotion, NavOperation, NavPage, NavStack, NavStackState}; use gpui_kit::base::motion::{PresencePhase, Transition}; ``` ## Anatomy and API `NavStackState` is the stack. It lives in a GPUI entity, holds `AnyView`s root first, and emits `NavStackEvent` after every change. | Method | Does | | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | | `push(view, motion, cx)` | Pushes over the current top. Into an empty stack it is immediate, like Qt's `initialItem`. | | `pop(motion, cx)` | Pops the top and returns it. The root is never popped, so this returns `None` at a depth of one. | | `pop_to_root(motion, cx)` | Pops everything above the root in one transition and returns those views. | | `forward(motion, cx)` | Brings back the most recently popped view over the current top and returns it. `None` when nothing has been popped since the last push. | | `replace(view, motion, cx)` | Swaps the top for `view` and returns the one replaced, keeping the forward views. On an empty stack it pushes. | | `clear(cx)` | Empties the stack and the forward views immediately. | | `depth()`, `is_empty()`, `current()`, `views()`, `forward_views()` | Read the stack. Show a back button when `depth() > 1`, a forward button when `forward_views()` is not empty. | `NavStack` is the element. It holds the entity, takes a `transition` to run each change under, and hands every mounted view to the `item` renderer as a `NavPage`. Style the element for size, background and clipping; it is positioned so that the two pages of a change can overlap. `NavPage` is what the renderer receives. It already fills the container. Read `phase()` (`Entering`, `Present` or `Exiting`), `operation()` (`Push`, `Pop` or `Replace`, or `None` once settled) and `progress()` (eased, `0.0` to `1.0`, shared by both pages of one change), refine the page with GPUI styles, and return it. The authoritative module is [`components/nav_stack.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/nav_stack.rs). Native and browser previews compile this same file. ## Animation Animation is decided at two levels, and both default to none: - **The stack.** `NavStack` without a `transition` never animates; every change switches on the spot. Give it a `Transition` to animate changes, and an `item` renderer to say how. - **The change.** Each `push`, `pop`, `pop_to_root` and `replace` takes a `NavMotion`, as UIKit's `animated:` and Qt's `StackView.Immediate` do per call. `NavMotion::Animated` runs the stack's transition; `NavMotion::Immediate` switches on the spot even on an animated stack, which is what restoring a stack at launch or jumping to a page from a command wants. ```rust stack.update(cx, |stack, cx| stack.push(detail, NavMotion::Animated, cx)); stack.update(cx, |stack, cx| stack.push(restored, NavMotion::Immediate, cx)); ``` ## Transitions After a push, pop or replace, the outgoing view stays mounted until the element's `Transition` finishes. Paint order follows the operation: a pushed or replacing page paints over the page it covers, and a popped page paints over the page it reveals, so a slide reads correctly in both directions. ```rust NavStack::new(&self.stack) .size_full() .overflow_hidden() .transition(Transition::new(Duration::from_millis(220))) .item(|page, _, _| { let offset = match (page.phase(), page.operation()) { (PresencePhase::Entering, Some(NavOperation::Push)) => 1.0 - page.progress(), (PresencePhase::Exiting, Some(NavOperation::Pop)) => page.progress(), _ => 0.0, }; page.left(relative(offset)).into_any_element() }) ``` The stack also switches immediately when the platform asks for reduced motion, whatever the renderer would have drawn. A new operation while a transition is running supersedes it, and the pages reverse from where they are rather than jumping. While a change runs, neither page takes pointer input. ## State and events Keep the `NavStackState` entity on the view that renders the stack and observe it, so a push from anywhere re-renders the host. A page that needs to navigate holds a `WeakEntity` of the stack, as the showcase page does. `views()` and `forward_views()` are enough for a history menu: list both, and pop or forward until the chosen page is current. The showcase page draws that list as a trail of page numbers, the pages ahead greyed out. Focus is not moved by the stack. `AnyView` carries no focus handle; a page that wants focus takes it when it is pushed, as it would anywhere else. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use super::*; use gpui::{AnyElement, WeakEntity, relative}; use gpui_base::NavPage; use gpui_base::motion::{PresencePhase, Transition}; use std::time::Duration; /// One page of the stack. A page knows its depth and holds the stack it lives /// in, so its own buttons can push over it, replace it, or pop it. pub(in super::super) struct ShowcasePage { depth: usize, stack: WeakEntity, } impl ShowcasePage { pub(in super::super) fn new(depth: usize, stack: WeakEntity) -> Self { Self { depth, stack } } /// A click handler that builds a page at `depth` and hands it to `apply`: /// a pushed page sits one deeper, a replacement at the same depth. fn navigate( &self, depth: usize, apply: impl Fn(&mut NavStackState, gpui::Entity, &mut Context) + 'static, ) -> impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static { let stack = self.stack.clone(); move |_, _, cx| { _ = stack.update(cx, |state, cx| { let page = cx.new(|_| ShowcasePage::new(depth, stack.clone())); apply(state, page, cx); }); } } } impl Render for ShowcasePage { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let depth = self.depth; // The trail is the stack's `History`: the pages behind this one, then // the pages popped off it, which `forward` brings back one at a time. let (behind, ahead) = self .stack .upgrade() .map(|stack| { let stack = stack.read(cx); (stack.depth(), stack.forward_views().len()) }) .unwrap_or((depth, 0)); let button = |id: &'static str, label: &'static str| { Button::new(id) .h_7() .px_2() .flex() .items_center() .border_1() .border_color(example_rgb(0x171717)) .bg(example_rgb(0xffffff)) .child(label) }; div() .size_full() .flex() .flex_col() .gap_3() .p_3() .bg(example_rgb(if depth % 2 == 1 { 0xffffff } else { 0xf5f5f5 })) .child( div() .font_weight(gpui::FontWeight::SEMIBOLD) .child(format!("Page {depth}")), ) .child( div() .flex() .gap_1() .text_color(example_rgb(0x737373)) .children((1..=behind + ahead).map(|page| { div() .px_1() .when(page == depth, |this| { this.text_color(example_rgb(0x171717)) .font_weight(gpui::FontWeight::SEMIBOLD) }) .when(page > behind, |this| this.text_color(example_rgb(0xd4d4d4))) .child(page.to_string()) })), ) .child( div() .flex() .gap_2() .child(button("push", "Push").on_click( self.navigate(depth + 1, |stack, page, cx| { stack.push(page, NavMotion::Animated, cx) }), )) .child(button("replace", "Replace").on_click(self.navigate( depth, |stack, page, cx| { stack.replace(page, NavMotion::Animated, cx); }, ))) .when(depth > 1, |this| { let stack = self.stack.clone(); this.child(button("pop", "Pop").on_click(move |_, _, cx| { _ = stack.update(cx, |stack, cx| { stack.pop(NavMotion::Animated, cx); }); })) }) .when(ahead > 0, |this| { let stack = self.stack.clone(); this.child(button("forward", "Forward").on_click(move |_, _, cx| { _ = stack.update(cx, |stack, cx| { stack.forward(NavMotion::Animated, cx); }); })) }), ) } } impl BaseShowcase { pub(in super::super) fn nav_stack(&self) -> impl IntoElement { NavStack::new(&self.stack) .w_72() .h_40() .overflow_hidden() .border_1() .border_color(example_rgb(0xd4d4d4)) .transition(Transition::new(Duration::from_millis(220))) .item(|page, _, _| slide(page)) } } /// A pushed page slides in from the right and slides back out when popped; /// the page underneath drifts a little to show depth. A replacement slides in /// over the page it replaces. The showcase's own shell uses this too. pub(in super::super) fn slide(page: NavPage) -> AnyElement { let offset = match (page.phase(), page.operation()) { (PresencePhase::Entering, Some(NavOperation::Push | NavOperation::Replace)) => { 1.0 - page.progress() } (PresencePhase::Exiting, Some(NavOperation::Pop)) => page.progress(), (PresencePhase::Exiting, Some(NavOperation::Push)) => -0.3 * page.progress(), (PresencePhase::Entering, Some(NavOperation::Pop)) => -0.3 * (1.0 - page.progress()), _ => 0.0, }; page.left(relative(offset)).into_any_element() } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Announce the page change in the page itself: a heading at the top of each page gives assistive technology a landmark to land on after a push. The stack keeps only the current page interactive once a transition has finished. ## Notes Pages are entities. The stack retains the ones on it and the ones popped since the last push, which `forward` can bring back, so a page's own subscriptions and timers live until a push discards it or the stack is cleared. Verify reduced-motion behavior in the consuming design system. --- # Radio Group Source: /versions/v0.7.1/base/primitives/radio-group License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/radio-group and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Groups radio items and provides keyboard navigation for a single selection. Like every `gpui-base` primitive, Radio Group supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- radio-group ``` ## Import ```rust use gpui_kit::base::{Radio, RadioGroup}; ``` ## Anatomy and API The example composes `Radio`, `RadioGroup`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/radio_group.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/radio_group.rs). Native and browser previews compile this same file. ## State and events The group coordinates one selected value and keyboard movement; `Radio` renders each option. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use gpui::{ Context, IntoElement, ParentElement as _, Styled as _, div, prelude::FluentBuilder as _, px, }; use gpui_base::{Radio, RadioGroup}; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn radio_group(&self, cx: &mut Context) -> impl IntoElement { let entity = cx.entity().downgrade(); RadioGroup::new("example-radio-group") .w_56() .text_xs() .flex() .flex_col() .gap_2() .child(self.radio(cx)) .child( Radio::new("express-radio") .checked(self.radio_selected == 1) .on_change(move |next, _, _, cx| { if next { _ = entity.update(cx, |this, cx| { this.radio_selected = 1; cx.notify(); }); } }) .flex() .items_start() .gap_2() .child( div() .mt(px(2.)) .flex() .items_center() .justify_center() .size(px(14.)) .border_1() .border_color(super::example_rgb(0x171717)) .when(self.radio_selected == 1, |this| { this.child(div().size(px(6.)).bg(super::example_rgb(0x171717))) }), ) .child( div().child("Express").child( div() .text_xs() .text_color(super::example_rgb(0x737373)) .child("Next business day"), ), ), ) .child( Radio::new("pickup-radio") .disabled(true) .flex() .items_start() .gap_2() .opacity(0.45) .child( div() .mt(px(2.)) .size(px(14.)) .border_1() .border_color(super::example_rgb(0x171717)), ) .child( div() .child("Local pickup") .child(div().text_xs().child("Currently unavailable")), ), ) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Label the group, expose one checked item, and support arrow keys among enabled choices. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Collapsible Source: /versions/v0.7.1/base/primitives/collapsible License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/collapsible and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A composable region that shows or hides content without prescribing its trigger styling. Like every `gpui-base` primitive, Collapsible supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- collapsible ``` ## Import ```rust use gpui_kit::base::{Collapsible}; ``` ## Anatomy and API The example composes `Collapsible`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/collapsible.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/collapsible.rs). Native and browser previews compile this same file. ## State and events Pass the controlled expanded value to `open`; update it from the trigger callback. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use super::*; impl BaseShowcase { pub(in super::super) fn collapsible(&self, cx: &mut Context) -> impl IntoElement { let open = self.collapsible_open; let entity = cx.entity().downgrade(); Collapsible::new() .open(open) .w_64() .child( div() .flex() .items_center() .justify_between() .child(div().text_xs().child("@gpui/base · 3 repositories")) .child( Button::new("collapsible-trigger") .size_7() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .flex() .items_center() .justify_center() .on_click(move |_, _, cx| { _ = entity.update(cx, |this, cx| { this.collapsible_open = !this.collapsible_open; cx.notify(); }); }) .child(if open { "−" } else { "+" }), ), ) .child( div() .mt_2() .px_2() .h_7() .flex() .items_center() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .text_xs() .child("gpui-component"), ) .content(div().mt_2().flex().flex_col().gap_2().children( ["gpui-base", "gpui-storybook"].into_iter().map(|name| { div() .px_2() .h_7() .flex() .items_center() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .text_xs() .child(name) }), )) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Name the trigger, expose expanded state, and remove hidden content from focus order. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Calendar Source: /versions/v0.7.1/base/primitives/calendar License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/calendar and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A state-driven date grid with selection matchers and custom item rendering. Like every `gpui-base` primitive, Calendar supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- calendar ``` ## Import ```rust use gpui_kit::base::{Calendar, CalendarState}; ``` ## Anatomy and API The example composes `Calendar`, `CalendarState`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/calendar.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/calendar.rs). Native and browser previews compile this same file. ## State and events Selection lives in `CalendarState`; configure matching and update the state from calendar item interaction. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use super::*; impl BaseShowcase { pub(in super::super) fn calendar(&self) -> impl IntoElement { Calendar::new("example-calendar", &self.calendar) // 7 × 32px cells + 12px padding on each side + 1px borders. .w(px(250.)) .p_3() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .item(|item, state, _, _| { match state.kind() { CalendarItemKind::Previous | CalendarItemKind::Next => item .size_7() .flex() .items_center() .justify_center() .hover(|s| s.bg(super::example_rgb(0xf5f5f5))), CalendarItemKind::MonthToggle | CalendarItemKind::YearToggle => item .px_1() .h_7() .flex() .items_center() .justify_center() .text_xs() .hover(|s| s.bg(super::example_rgb(0xf5f5f5))), CalendarItemKind::Weekday => item .size_8() .flex() .items_center() .justify_center() .text_xs() .text_color(super::example_rgb(0x737373)), CalendarItemKind::Day => item .size_8() .flex() .items_center() .justify_center() .text_xs() .when(state.is_muted(), |s| { s.text_color(super::example_rgb(0xa3a3a3)) }) .when(state.is_today() && !state.is_active(), |s| { s.border_1().border_color(super::example_rgb(0xd4d4d4)) }) .when(state.is_active(), |s| { s.bg(super::example_rgb(0x171717)) .text_color(super::example_rgb(0xffffff)) }) .when(!state.is_disabled() && !state.is_active(), |s| { s.hover(|s| s.bg(super::example_rgb(0xf5f5f5))) }), CalendarItemKind::Month | CalendarItemKind::Year => item .w(px(74.)) .h_7() .flex() .items_center() .justify_center() .text_xs() .when(state.is_active(), |s| { s.bg(super::example_rgb(0x171717)) .text_color(super::example_rgb(0xffffff)) }) .when(!state.is_active(), |s| { s.hover(|s| s.bg(super::example_rgb(0xf5f5f5))) }), } .into_any_element() }) .label(|kind, value| match kind { CalendarItemKind::Previous => "‹".into(), CalendarItemKind::Next => "›".into(), CalendarItemKind::MonthToggle | CalendarItemKind::Month => [ "", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ][value as usize] .into(), CalendarItemKind::Weekday => { ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][value as usize].into() } _ => value.to_string().into(), }) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Label dates and selected, disabled, and today states; retain arrow-key navigation. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Sheet Source: /versions/v0.7.1/base/primitives/sheet License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/sheet and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A modal surface that enters from an edge while managing dismissal and focus. Like every `gpui-base` primitive, Sheet supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- sheet ``` ## Import ```rust use gpui_kit::base::{Sheet}; ``` ## Anatomy and API The example composes `Sheet`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/sheet.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/sheet.rs). Native and browser previews compile this same file. ## State and events Open and dismissal mirror a dialog while placement chooses the entering edge. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use gpui::relative; use super::*; impl BaseShowcase { pub(in super::super) fn sheet(&self, cx: &mut Context) -> impl IntoElement { let open = self.sheet_open; let entity = cx.entity().downgrade(); let open_sheet = entity.clone(); let trigger = Button::new("open-sheet") .h_7() .px_2() .text_xs() .flex() .items_center() .justify_center() .border_1() .border_color(super::example_rgb(0x171717)) .bg(super::example_rgb(0xffffff)) .child("Open settings") .on_click(move |_, _, cx| { _ = open_sheet.update(cx, |this, cx| { this.sheet_open = true; cx.notify(); }); }); div() .size_full() .min_h_64() .text_xs() .flex() .items_center() .justify_center() .child(trigger) .when(open, |this| { this.child( Sheet::new(cx) .request_close({ let entity = entity.clone(); move |_, cx| { _ = entity.update(cx, |this, cx| { this.sheet_open = false; cx.notify(); }); } }) .overlay( div() .absolute() .inset_0() .bg(super::example_rgb(0x000000)) .opacity(0.15), ) .surface( div() .absolute() .right_0() .top_0() .h_full() .w(px(210.)) .p_3() .bg(super::example_rgb(0xffffff)) .border_1() .border_color(super::example_rgb(0x171717)) .child( div() .font_weight(gpui::FontWeight::SEMIBOLD) .child("Settings"), ) .child( div().mt_4().child("Workspace name").child( div() .mt_1() .h_7() .px_2() .flex() .items_center() .border_1() .border_color(super::example_rgb(0xa3a3a3)) .child("Acme Studio"), ), ) .child( div() .mt_2() .text_color(super::example_rgb(0x525252)) .child("Update the workspace preferences for your team."), ) .child( div() .mt_4() .py_1() .border_t_1() .border_color(super::example_rgb(0xd4d4d4)) .child("Notifications · Enabled"), ) .child( div().mt_3().flex().justify_end().child( Button::new("close-sheet") .h_7() .line_height(relative(1.)) .px_3() .flex() .items_center() .justify_center() .bg(gpui::black()) .text_color(gpui::white()) .child("Done") .on_click({ let entity = entity.clone(); move |_, _, cx| { _ = entity.update(cx, |this, cx| { this.sheet_open = false; cx.notify(); }); } }), ), ), ), ) }) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Apply dialog semantics: title it, trap and restore focus, and provide close. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Select Source: /versions/v0.7.1/base/primitives/select License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/select and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A button-like selection control backed by an anchored, keyboard-navigable popup. Like every `gpui-base` primitive, Select supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- select ``` ## Import ```rust use gpui_kit::base::{Select}; ``` ## Anatomy and API The example composes `Select`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/select.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/select.rs). Native and browser previews compile this same file. ## State and events The delegate/state owns items and selection; activation opens the list and selection closes it. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use super::*; impl BaseShowcase { pub(in super::super) fn select( &self, combobox: bool, cx: &mut Context, ) -> impl IntoElement { let open = self.select_open; let selected = self.select_index.min(3); let labels = ["GPUI", "React", "SwiftUI", "Vue"]; let entity = cx.entity().downgrade(); let trigger_entity = entity.clone(); let trigger = div() .id("select-trigger") .h_7() .px_2() .text_xs() .flex() .items_center() .justify_between() .border_1() .border_color(super::example_rgb(0x171717)) .on_click(move |_, _, cx| { _ = trigger_entity.update(cx, |this, cx| { this.select_open = !open; cx.notify(); }); }) .child(labels[selected]) .child(super::chevron(open)); let options = div() .mt_1() .p_1() .border_1() .border_color(super::example_rgb(0x171717)) .bg(super::example_rgb(0xffffff)) .children(labels.into_iter().enumerate().map(|(ix, label)| { let entity = entity.clone(); div() .id(("select-option", ix)) .px_2() .py_1() .flex() .justify_between() .hover(|this| this.bg(super::example_rgb(0xf5f5f5))) .child(label) .when(ix == selected, |this| this.child("✓")) .on_click(move |_, _, cx| { _ = entity.update(cx, |this, cx| { this.select_index = ix; this.select_open = false; cx.notify(); }); }) })); if combobox { let root = Combobox::new("example-combobox") .open(open) .w_56() .child(trigger); Popup::new("example-combobox-options", root) .when(open, |this| this.content(options)) .into_any_element() } else { let root = Select::new("example-select") .open(open) .on_open_change({ let entity = entity.clone(); move |next, _, cx| { _ = entity.update(cx, |this, cx| { this.select_open = next; cx.notify(); }); } }) .accessibility_label("Framework") .w_56() .child(trigger); Popup::new("example-select-options", root) .when(open, |this| this.content(options)) .into_any_element() } } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Set `.accessibility_label(...)` on the controlled root and `.accessibility_value(...)` to its committed selection, not a temporary search cursor. The root exposes its expanded state and accessible activation. Activation requests an open-state change and moves focus between the trigger and content. Disabled controls do not expose activation. The styled `Select` supplies its committed value automatically, falling back to its placeholder when unselected. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Progress Source: /versions/v0.7.1/base/primitives/progress License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/progress and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Composable track and indicator parts for reporting task completion. Like every `gpui-base` primitive, Progress supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- progress ``` ## Import ```rust use gpui_kit::base::{Progress, ProgressIndicator, ProgressTrack}; ``` ## Anatomy and API The example composes `Progress`, `ProgressIndicator`, `ProgressTrack`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/progress.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/progress.rs). Native and browser previews compile this same file. ## State and events Set the value on `Progress`; size and position `ProgressIndicator` inside `ProgressTrack`. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use gpui::{IntoElement, ParentElement as _, Styled as _, div, px}; use gpui_base::{Progress, ProgressIndicator, ProgressTrack}; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn progress(&self) -> impl IntoElement { div() .w_64() .flex() .flex_col() .gap_2() .text_xs() .child( div() .flex() .justify_between() .child("Uploading assets") .child("68%"), ) .child( Progress::new("example-progress").value(68.).child( ProgressTrack::new() .w_full() .h(px(7.)) .border_1() .border_color(super::example_rgb(0x171717)) .child( ProgressIndicator::new() .w(px(177.)) .h_full() .bg(super::example_rgb(0x171717)), ), ), ) .child( div() .flex() .justify_between() .text_sm() .text_color(super::example_rgb(0x737373)) .child("Optimizing bundle") .child("32%"), ) .child( Progress::new("example-progress-secondary") .value(32.) .child( ProgressTrack::new() .w_full() .h(px(6.)) .border_1() .border_color(super::example_rgb(0xa3a3a3)) .child( ProgressIndicator::new() .w(px(83.)) .h_full() .bg(super::example_rgb(0x737373)), ), ), ) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Expose task label and numeric value; use indeterminate state only when progress is unknown. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # OTP Input Source: /versions/v0.7.1/base/primitives/otp-input License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/otp-input and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A multi-cell one-time-code input driven by a shared text state. Like every `gpui-base` primitive, OTP Input supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- otp-input ``` ## Import ```rust use gpui_kit::base::{OtpInput, OtpState}; ``` ## Anatomy and API The example composes `OtpInput`, `OtpState`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/otp_input.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/otp_input.rs). Native and browser previews compile this same file. ## State and events `OtpState` owns the complete code and active cell; visual cells share that state. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use gpui::{Context, IntoElement, ParentElement as _, Styled as _, div}; use gpui_base::OtpInput; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn otp_input(&self, cx: &mut Context) -> impl IntoElement { let value: Vec = self.otp.read(cx).value().chars().collect(); let active = value.len().min(5); div() .w_56() .flex() .flex_col() .gap_1() .text_xs() .child(div().text_xs().child("Verification code")) .child( div().child( OtpInput::new(&self.otp) .flex() .gap_1() .children((0..6).map(|ix| { div() .size_7() .flex() .items_center() .justify_center() .border_1() .border_color(if ix == active { super::example_rgb(0x171717) } else { super::example_rgb(0xd4d4d4) }) .child(value.get(ix).copied().unwrap_or(' ').to_string()) })), ), ) .child( div() .text_xs() .text_color(super::example_rgb(0x737373)) .child("Enter the 6-digit code."), ) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Label the whole code, announce length/errors, and support paste. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Time Field Source: /versions/v0.7.1/base/primitives/time-field License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/time-field and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A segmented time-of-day editor with a complete keyboard model and 24- or 12-hour clocks. Like every `gpui-base` primitive, Time Field supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- time-field ``` ## Import ```rust use gpui_kit::base::{HourCycle, TimeField, TimeFieldEvent, TimeFieldState, TimePrecision}; ``` ## Anatomy and API The example composes `TimeField` over a `TimeFieldState`. The field renders one `TimeFieldSegment` per hour, minute, optional second and optional AM/PM part, separated by `:`. Lay out and style the root with `Styled`, and decorate each segment through `TimeField::render_segment`; the slot receives a `TimeFieldSegmentState` with the segment, its value and whether it is selected. The authoritative module is [`components/time_field.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/time_field.rs). Native and browser previews compile this same file. ## State and events `TimeFieldState` owns the time, its precision (`TimePrecision::Minute` or `Second`) and hour cycle (`HourCycle::H23` by default, or `H12`). `set_time` replaces the value without emitting; user edits emit `TimeFieldEvent::Change`. The field is one Tab stop. Up/Down step the selected segment and wrap within it without carrying into the next unit, Left/Right and Tab/Shift-Tab move between segments, digits type a value with a two-digit buffer and advance once no further digit fits, `a`/`p` set AM or PM, and Backspace/Delete reset the segment. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use gpui::{ Context, IntoElement, ParentElement as _, Styled as _, div, prelude::FluentBuilder as _, }; use gpui_base::TimeField; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn time_field(&self, cx: &mut Context) -> impl IntoElement { let value = self.time_field.read(cx).time(); div() .w_56() .flex() .flex_col() .gap_1() .text_xs() .child(div().child("Reminder time")) .child( TimeField::new("example-time-field", &self.time_field) .flex() .items_center() .h_7() .px_2() .border_1() .border_color(super::example_rgb(0xa3a3a3)) .bg(super::example_rgb(0xffffff)) .render_segment(|segment, state, _, _| { segment .px_0p5() .when(state.is_selected(), |this| { this.bg(super::example_rgb(0xdbeafe)) }) .into_any_element() }), ) .child( div() .text_color(super::example_rgb(0x737373)) .child(format!("Selected {}", value.format("%H:%M:%S"))), ) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility The root exposes `Role::TimeInput` with the formatted time as its value. Label the field, and keep the selected segment visibly distinct from the others. ## Notes Use tabular figures or fixed segment widths so the field does not change width while digits are typed. Verify focus, selected, disabled, and high-contrast appearances in the consuming design system. --- # Date Picker Source: /versions/v0.7.1/base/primitives/date-picker License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/date-picker and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A focus-aware date input that composes calendar behavior with a popup. Like every `gpui-base` primitive, Date Picker supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- date-picker ``` ## Import ```rust use gpui_kit::base::{DatePicker}; ``` ## Anatomy and API The example composes `DatePicker`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/date_picker.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/date_picker.rs). Native and browser previews compile this same file. ## State and events The picker combines focus/input state with calendar selection. Retain its entities on the parent view. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use super::*; impl BaseShowcase { pub(in super::super) fn date_picker(&self, cx: &mut Context) -> impl IntoElement { let open = self.date_open; let entity = cx.entity().downgrade(); let trigger_entity = entity.clone(); let trigger = Button::new("date-trigger") .w_full() .h_7() .px_3() .flex() .items_center() .justify_between() .border_1() .border_color(super::example_rgb(0xa3a3a3)) .bg(super::example_rgb(0xffffff)) .on_click(move |_, _, cx| { _ = trigger_entity.update(cx, |this, cx| { this.date_open = !open; cx.notify(); }); }) .child("Aug 12, 2026") .child(super::chevron(false)); let popup = Popup::new("date-picker-popup", trigger).when(open, |this| { this.content( div() .w(px(250.)) .bg(super::example_rgb(0xffffff)) .child(self.calendar()), ) }); DatePicker::new("example-date-picker", &self.date_focus) .open(open) .on_open_change(move |open, _, cx| { _ = entity.update(cx, |this, cx| { this.date_open = open; cx.notify(); }); }) .w(px(250.)) .text_xs() .child(popup) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Label the input, announce locale-appropriate dates, and make the calendar keyboard operable. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Resizable Source: /versions/v0.7.1/base/primitives/resizable License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/resizable and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Panel groups and resize handles for user-adjustable split layouts. Like every `gpui-base` primitive, Resizable supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- resizable ``` ## Import ```rust use gpui_kit::base::{ResizablePanel, ResizablePanelGroup, ResizableState, h_resizable, resizable_panel}; ``` ## Anatomy and API The example composes `ResizablePanel`, `ResizablePanelGroup`, `ResizableState`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/resizable.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/resizable.rs). Native and browser previews compile this same file. ## State and events Panel sizes live in resizable state; dragging handles updates adjacent panels subject to minimums. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Handle appearance Base owns a handle's hit band, its cursor and the drag; what is painted inside it is the consumer's. `ResizeHandleRenderer` is handed a `ResizeHandleContext` carrying the axis and a `ResizeHandleState` — `Idle`, `Hovered`, `Pressed` or `Dragging`. The last two are tracked by base because a drag takes the pointer out of the nine-pixel band almost at once, so GPUI's hover reads false for most of a drag. Returning `None` keeps base's own one-pixel line, so a renderer can override some handles and leave the rest alone. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use gpui::{IntoElement, ParentElement as _, Styled as _, div, px}; use gpui_base::{h_resizable, resizable_panel}; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn resizable(&self) -> impl IntoElement { div() .w_72() .h_40() .text_xs() .border_1() .border_color(super::example_rgb(0x171717)) .child( h_resizable("example-resizable") .child( resizable_panel() .size(px(124.)) .size_range(px(116.)..px(210.)) .child( div() .size_full() .flex() .items_center() .justify_center() .border_r_1() .border_color(super::example_rgb(0x171717)) .p_2() .items_start() .justify_start() .flex_col() .gap_1() .child( div() .text_xs() .text_color(super::example_rgb(0x737373)) .child("PROJECT"), ) .children(["Overview", "Components", "Settings"].map( |label| { div() .w_full() .h(px(26.)) .px_2() .flex() .items_center() .whitespace_nowrap() .child(label) }, )), ), ) .child( resizable_panel().child( div() .size_full() .flex() .items_center() .justify_center() .bg(super::example_rgb(0xffffff)) .p_2() .items_start() .justify_start() .flex_col() .gap_2() .child(div().child("Workspace")) .child( div() .text_color(super::example_rgb(0x737373)) .child("Drag the divider to resize navigation."), ), ), ), ) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Provide keyboard alternatives for handles and preserve usable minimum panel sizes. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Color Picker Source: /versions/v0.7.1/base/primitives/color-picker License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/color-picker and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. State and interaction foundations for selecting colors in a custom picker UI. Like every `gpui-base` primitive, Color Picker supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- color-picker ``` ## Import ```rust use gpui_kit::base::{ColorPicker, ColorPickerEvent, ColorPickerState, ColorSwatch}; ``` ## Anatomy and API The example composes `ColorPicker`, `ColorSwatch`, and `ColorPickerState`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. `ColorPicker` is the controlled root: it carries the trigger's accessibility semantics and focus, opens on Confirm, and dismisses on Cancel. `ColorSwatch` is one selectable color in a palette, carrying radio semantics, an accessible hex name, and the hover and activation callbacks a picker previews and commits with. The authoritative module is [`components/color_picker.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/color_picker.rs). Native and browser previews compile this same file. ## State and events `ColorPickerState` owns the committed color, the transient preview shown while the user hovers or edits, the controlled open state, and the active panel. It also owns a hex `InputState` and four component `SliderState`s and keeps all of them in sync, so an application renders those with its own input and slider presentation rather than reconciling them itself. Committing a color emits `ColorPickerEvent::Change`. A color supplied to `default_value` cannot reach the hex field and sliders without a window, so call `sync_pending_value` from render; it is a no-op once nothing is pending. Retain the state's entity on the parent view. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use super::*; use gpui::{Focusable as _, Hsla, MouseButton}; impl BaseShowcase { pub(in super::super) fn color_picker( &self, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { // A builder-supplied default cannot reach the hex field and the sliders // without a window, so flush it on the first render. self.color_picker .update(cx, |state, cx| state.sync_pending_value(window, cx)); let picker = self.color_picker.read(cx); let open = picker.is_open(); let selected = picker.value(); let displayed = picker .displayed_color() .unwrap_or(super::example_rgb(0x171717).into()); let hex = picker.hex_input().read(cx).value(); let focus_handle = picker.focus_handle(cx); let hex_input = picker.hex_input().clone(); let state = self.color_picker.clone(); let trigger_state = state.clone(); let trigger = div() .id("color-trigger") .w_full() .h_7() .px_2() .flex() .items_center() .gap_2() .border_1() .border_color(super::example_rgb(0x171717)) .bg(super::example_rgb(0xffffff)) .on_click(move |_, _, cx| { trigger_state.update(cx, |state, cx| state.toggle_open(cx)); }) .child( div() .size(px(14.)) .bg(displayed) .border_1() .border_color(super::example_rgb(0x171717)), ) .child(hex) .child(div().flex_1()) .child(super::chevron(open)); let swatches = div().flex().gap_1().children( [0xdc2626u32, 0xd97706, 0x16a34a, 0x2563eb, 0x7c3aed] .into_iter() .enumerate() .map(|(index, value)| { let color: Hsla = super::example_rgb(value).into(); let hover_state = state.clone(); let click_state = state.clone(); ColorSwatch::new(("swatch", index), color) .selected(selected == Some(color)) .size(px(24.)) .bg(color) .border_1() .border_color(if selected == Some(color) { super::example_rgb(0x171717) } else { super::example_rgb(0xffffff) }) // Hovering previews without committing; leaving restores // the committed color. .on_hover(move |color, entered, window, cx| { hover_state.update(cx, |state, cx| { if entered { state.preview_color(color, window, cx); } else { state.clear_preview(window, cx); } }); }) .on_click(move |color, _, window, cx| { click_state .update(cx, |state, cx| state.select_color(color, window, cx)); }) }), ); let content = div() .w(px(220.)) .mt_1() .p_2() .flex() .flex_col() .gap_2() .border_1() .border_color(super::example_rgb(0x171717)) .bg(super::example_rgb(0xffffff)) .child(swatches) .child( InputBase::new("color-hex-input") .w_full() .h_7() .px_2() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .styles(|styles| { styles.focused(|style| style.border_color(super::example_rgb(0x171717))) }) .on_mouse_down(MouseButton::Left, move |_, window, cx| { hex_input.update(cx, |input, cx| input.focus(window, cx)); }) .child(picker.hex_input().clone()), ); let open_state = state.clone(); let root = ColorPicker::new("example-color-picker") .open(open) .track_focus(&focus_handle) .accessibility_label("Brand color") .on_open_change(move |open, _, cx| { open_state.update(cx, |state, cx| state.set_open(open, cx)); }) .w(px(220.)) .text_xs() .child(trigger); Popup::new("example-color-picker-popup", root).when(open, |this| this.content(content)) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Provide a textual color value and keyboard controls; never communicate selection by color alone. The root exposes the trigger's expanded state, and each swatch exposes its hex value as its accessible name plus its selected state, so a palette never depends on color alone. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Textarea Source: /versions/v0.7.1/base/primitives/textarea License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/textarea and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `Textarea` is for ordinary multi-line text. Its interface stays focused on text entry: rows, wrapping, auto-grow, value updates, insertion, replacement, and cursor position. Code-editor concepts are intentionally kept on [`Editor`](/versions/v0.7.1/base/primitives/editor). ## Import ```rust use gpui_kit::base::input::{InputEvent, Textarea, TextareaState}; ``` ## Fixed rows ```rust let notes = cx.new(|cx| { TextareaState::new(window, cx) .rows(5) .placeholder("Notes") .default_value("First line\nSecond line") }); Textarea::new(¬es) ``` ## Auto-grow The textarea grows between the supplied minimum and maximum row counts. Once it reaches the maximum, its content scrolls. ```rust let message = cx.new(|cx| { TextareaState::new(window, cx) .auto_grow(2, 8) .placeholder("Write a message") }); Textarea::new(&message) ``` ## Editing the value ```rust notes.update(cx, |state, cx| { state.insert("Appended text", window, cx); }); let cursor = notes.read(cx).cursor_position(cx); let value = notes.read(cx).value(); ``` Use `soft_wrap(false)` when visual wrapping is undesirable. Set `submit_on_enter(true)` only when Enter should submit instead of inserting a line break. `TextareaState` emits the same `InputEvent` variants as `InputState`. ## Presentation The control is unstyled. Your design system supplies the frame, height, colors, padding, and `InputEditorStyle`. For a styled control, see the [`gpui-component` Textarea](/versions/v0.7.1/component/textarea). ## Runnable example ```bash cargo run -p gpui-base-examples -- textarea ``` ## Atomic inline tokens Use tokens for mentions or references that users select and delete as a whole. Insert one through the TextareaState you already use for this control: ```rust use gpui_kit::base::input::InlineToken; notes.update(cx, |state, cx| { state.replace_with_token( InlineToken::new("person-1", "@alice").with_label("Alice"), window, cx, ).expect("valid reference"); }); ``` Tokens display as unstyled labels. Use the `token` slot to supply your own single-row element and `on_token_click` to open a reference. Use `on_token_hover` to report pointer entry and exit (`InlineTokenHoverEvent::is_hovered`) without selecting or editing, for tooltips or previews; disabled tokens never report hover entry, but disabling a hovered token sends its exit. Copy and `value()` return the real text, such as `@alice`. Save drafts with `content()` and restore them with `set_value(content)` to keep their references. See [Input's token examples](/versions/v0.7.1/component/input#atomic-inline-tokens) for custom rendering, draft restoration and range units. Import the data types from `gpui_kit::base::input`. In JavaScript, use `TextareaState.new()` from `gpui-base`; it provides the same token methods. --- # Switch Source: /versions/v0.7.1/base/primitives/switch License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/switch and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A controlled on/off control with separately styleable track and thumb. Like every `gpui-base` primitive, Switch supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- switch ``` ## Import ```rust use gpui_kit::base::{Switch, SwitchThumb, SwitchTrack}; ``` ## Anatomy and API The example composes `Switch`, `SwitchThumb`, `SwitchTrack`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/switch.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/switch.rs). Native and browser previews compile this same file. ## State and events Pass the controlled boolean to `checked`; `on_change` emits the requested next value. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use super::*; impl BaseShowcase { pub(in super::super) fn switch(&self, cx: &mut Context) -> impl IntoElement { let checked = self.switch_checked; let entity = cx.entity().downgrade(); div() .w_64() .text_xs() .flex() .items_center() .justify_between() .child( div().child("Automatic updates").child( div() .mt_1() .text_xs() .text_color(super::example_rgb(0x737373)) .child("Install stable releases automatically."), ), ) .child( Switch::new("example-switch") .checked(checked) .on_change(move |next, _, _, cx| { _ = entity.update(cx, |this, cx| { this.switch_checked = next; cx.notify(); }); }) .child( SwitchTrack::new("example-switch-track") .checked(checked) .w(px(36.)) .h(px(20.)) .p(px(2.)) .bg(if checked { super::example_rgb(0x171717) } else { super::example_rgb(0xd4d4d4) }) .child( SwitchThumb::new(checked) .size_4() .bg(super::example_rgb(0xffffff)) .ml(if checked { px(16.) } else { px(0.) }), ), ), ) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Label the setting, expose checked state, and keep the accessible name stable. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Table Source: /versions/v0.7.1/base/primitives/table License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/table and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. Semantic table primitives for composing headers, bodies, rows, and cells. Like every `gpui-base` primitive, Table supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- table ``` ## Import ```rust use gpui_kit::base::{Table, TableBody, TableCell, TableHead, TableHeader, TableRow}; ``` ## Anatomy and API The example composes `Table`, `TableBody`, `TableCell`, `TableHead`, `TableHeader`, `TableRow`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/table.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/table.rs). Native and browser previews compile this same file. ## State and events Rows and cells are stateless composition; sorting, selection, and mutations remain in the parent. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use super::*; impl BaseShowcase { pub(in super::super) fn table(&self) -> impl IntoElement { Table::new("example-table") .w_72() .text_xs() .border_1() .border_color(super::example_rgb(0xe5e7eb)) .overflow_hidden() .child( TableHeader::new("header").child( TableRow::new("header-row", 1) .flex() .bg(super::example_rgb(0xf5f5f5)) .child( TableHead::new("name-head", 1) .w(px(124.)) .px_2() .py_1() .child("Component"), ) .child( TableHead::new("status-head", 2) .w(px(84.)) .px_2() .py_1() .child("Status"), ) .child( TableHead::new("version-head", 3) .w(px(92.)) .px_2() .py_1() .child("Version"), ), ), ) .child( TableBody::new("body").children( [ ("gpui-base", "Stable", "0.4.1"), ("gpui-component", "Active", "0.4.1"), ("story-web", "Preview", "0.2.8"), ("gpui-web", "Beta", "0.1.0"), ] .into_iter() .enumerate() .map(|(ix, (name, status, version))| { TableRow::new(("body-row", ix), ix) .flex() .border_t_1() .border_color(super::example_rgb(0xe5e7eb)) .child( TableCell::new("name", 1) .w(px(124.)) .px_2() .py_1() .child(name), ) .child( TableCell::new(("status", ix), 2) .w(px(84.)) .px_2() .py_1() .child( div() .px_1() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .child(status), ), ) .child( TableCell::new(("version", ix), 3) .w(px(92.)) .px_2() .py_1() .text_color(super::example_rgb(0x737373)) .child(version), ) }), ), ) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Use headers, preserve reading order, and separately expose sort and selection controls. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Checkbox Source: /versions/v0.7.1/base/primitives/checkbox License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/checkbox and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A controlled tri-state check control with a separately styled indicator. Like every `gpui-base` primitive, Checkbox supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- checkbox ``` ## Import ```rust use gpui_kit::base::{Checkbox, CheckboxIndicator}; ``` ## Anatomy and API The example composes `Checkbox`, `CheckboxIndicator`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/checkbox.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/checkbox.rs). Native and browser previews compile this same file. ## State and events Pass the controlled value to `checked`; `on_change` emits `CheckboxState`, including indeterminate. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use super::*; use gpui::{Image, ImageFormat, img}; use std::sync::Arc; const CHECK_SVG: &[u8] = br#""#; impl BaseShowcase { pub(in super::super) fn checkbox(&self, cx: &mut Context) -> impl IntoElement { let checked = self.checkbox_checked; let entity = cx.entity().downgrade(); Checkbox::new("example-checkbox") .checked(checked) .flex() .items_center() .gap_2() .on_change(move |state, _, _, cx| { _ = entity.update(cx, |this, cx| { this.checkbox_checked = state == CheckboxState::Checked; cx.notify(); }); }) .child( CheckboxIndicator::new() .checked(checked) .flex() .items_center() .justify_center() .size_4() .border_1() .border_color(super::example_rgb(0x171717)) .when(checked, |this| { this.bg(super::example_rgb(0x171717)).child( img(Arc::new(Image::from_bytes( ImageFormat::Svg, CHECK_SVG.to_vec(), ))) .size(px(12.)), ) }), ) .child(div().text_xs().child("Enable product updates")) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Keep label and control associated and expose checked, unchecked, indeterminate, and disabled states. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Toggle Source: /versions/v0.7.1/base/primitives/toggle License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/toggle and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A controlled two-state pressable for persistent choices such as formatting. Like every `gpui-base` primitive, Toggle supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- toggle ``` ## Import ```rust use gpui_kit::base::{Toggle}; ``` ## Anatomy and API The example composes `Toggle`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/toggle.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/toggle.rs). Native and browser previews compile this same file. ## State and events Pass the controlled pressed value; `on_change` emits the requested next value. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use super::*; impl BaseShowcase { pub(in super::super) fn toggle(&self, cx: &mut Context) -> impl IntoElement { let pressed = self.toggle_pressed; let entity = cx.entity().downgrade(); Toggle::new("example-toggle") .pressed(pressed) .on_change(move |next, _, _, cx| { _ = entity.update(cx, |this, cx| { this.toggle_pressed = next; cx.notify(); }); }) .size_7() .text_xs() .flex() .items_center() .justify_center() .border_1() .border_color(super::example_rgb(0x171717)) .when(pressed, |this| { this.bg(super::example_rgb(0x171717)) .text_color(super::example_rgb(0xffffff)) }) .font_weight(gpui::FontWeight::BOLD) .accessibility_label("Bold") .child("B") } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Expose pressed state and keep a stable accessible name. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Scrollbar Source: /versions/v0.7.1/base/primitives/scrollbar License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/scrollbar and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `Scrollbar` is a custom-painted scrollbar connected to a GPUI scroll handle. It supports vertical, horizontal, and two-axis viewports; track clicks; thumb dragging; configurable visibility modes; typed paint styles; reduced motion; and reversible visibility and width transitions. `gpui-base` owns the interaction and transition lifecycle. Your application or design-system layer owns colors, geometry, timing, and entrance choreography. ## Run the example The native showcase and WASM preview use the same implementation: ```bash cargo run -p gpui-base-examples -- scrollbar ``` The source is available in [`components/scrollbar.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/scrollbar.rs). ## Imports ```rust use std::time::Duration; use gpui_kit::{div, px, rgb, ScrollHandle, Styled as _}; use gpui_kit::base::{ Scrollbar, ScrollbarAxis, ScrollbarEntrance, ScrollbarMode, ScrollbarMotion, ScrollbarStyles, ScrollbarTheme, Theme, }; ``` ## Basic usage Keep the `ScrollHandle` on persistent view state. Attach it to the scrollable content with `track_scroll`, then overlay a `Scrollbar` in the same relative container. ```rust pub struct ActivityList { scroll_handle: ScrollHandle, } impl ActivityList { pub fn new() -> Self { Self { scroll_handle: ScrollHandle::new(), } } fn render_list(&self) -> impl gpui_kit::IntoElement { div() .relative() .size_full() .overflow_scroll() .track_scroll(&self.scroll_handle) .child(div().children((1..=100).map(|row| { div().h_8().px_2().child(format!("Activity {row}")) }))) .child(Scrollbar::new(&self.scroll_handle)) } } ``` `Scrollbar::new` enables both axes. Use an axis-specific constructor when the container scrolls in only one direction: ```rust Scrollbar::vertical(&scroll_handle); Scrollbar::horizontal(&scroll_handle); Scrollbar::new(&scroll_handle).axis(ScrollbarAxis::Vertical); ``` The scrollbar is an absolute overlay. Its layout and hitboxes stay fixed while the painted track and thumb animate, so entrance motion does not move content or change the interaction geometry. ## Visibility modes Set a mode on one scrollbar, or omit `.mode(...)` to use the global `ScrollbarTheme` mode. ```rust Scrollbar::vertical(&scroll_handle).mode(ScrollbarMode::Scrolling); Scrollbar::vertical(&scroll_handle).mode(ScrollbarMode::Hover); Scrollbar::vertical(&scroll_handle).mode(ScrollbarMode::Always); ``` | Mode | Behavior | | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Scrolling` | Appears after scrolling or dragging. A visible scrollbar stays visible while hovered; leaving starts a fresh idle hold. Hover cannot reveal a fully hidden scrollbar. | | `Hover` | Appears when the pointer enters the scrollbar track. | | `Always` | Remains visible and skips visibility transitions. | All modes use a 6 px resting thumb by default. Track hover keeps that width. Thumb hover and active dragging target the 8 px active width. Width changes use the configured `expand` duration. Hidden track and thumb clicks are ignored. In `Scrolling` mode, a hidden thumb also does not retain a latent hover state that could expand it on the next scroll. ## Configure the global theme `ScrollbarTheme` uses private fields with consuming builders and readers. Set it during application initialization or when your design-system theme changes. ```rust fn install_scrollbar_theme(cx: &mut gpui_kit::App) { let styles = ScrollbarStyles::default() .track(|style| { style .width(px(16.)) .bg(rgb(0x000000).alpha(0.08)) }) .track_hover(|style| { style.bg(rgb(0x000000).alpha(0.12)) }) .track_active(|style| { style.bg(rgb(0x000000).alpha(0.16)) }) .thumb(|style| { style .width(px(6.)) .inset(px(4.)) .radius(px(3.)) .min_length(px(48.)) .bg(rgb(0x737373)) }) .thumb_hover(|style| { style.width(px(8.)).bg(rgb(0x525252)) }) .thumb_active(|style| { style.width(px(8.)).bg(rgb(0x404040)) }); let motion = ScrollbarMotion::default() .with_idle(Duration::from_secs(2)) .with_enter(Duration::from_millis(300)) .with_exit(Duration::from_millis(500)) .with_expand(Duration::from_millis(300)) .with_entrance(ScrollbarEntrance::Fade) .with_thumb_hover_entrance(ScrollbarEntrance::SlideAndFade); Theme::global_mut(cx).scrollbar = ScrollbarTheme::new() .with_mode(ScrollbarMode::Scrolling) .with_motion(motion) .with_styles(styles); } ``` The same values can be inspected without exposing the theme's fields: ```rust let scrollbar = &Theme::global(cx).scrollbar; let mode = scrollbar.mode(); let motion = scrollbar.motion(); let styles = scrollbar.styles(); ``` ## Motion behavior Base ships without product motion. `ScrollbarMotion::default()` uses a 2-second behavioral idle hold, but its `enter`, `exit`, and `expand` durations are zero. An application that does not install motion therefore gets immediate visibility and width changes. The example theme above produces this choreography: | Trigger | Entrance | | ------------------------------------- | ---------------------------------------------------------------- | | Scroll in `Scrolling` or `Hover` mode | `entrance`: fade in place | | Track hover in `Hover` mode | `entrance`: fade in place | | Thumb hover in `Hover` mode | `thumb_hover_entrance`: slide from the nearest edge while fading | | `Always` mode | Immediate; visibility motion is skipped | For `SlideAndFade`, a vertical scrollbar enters from the right and a horizontal scrollbar enters from the bottom. Opacity uses linear entrance progress; position uses cubic ease-out. Exit opacity and position use cubic ease-in. An interrupted transition samples its current opacity and position before changing direction. A zero duration adopts the target immediately, including when a transition is already running. GPUI's reduced-motion preference also sets visibility and width durations to zero. You do not need a separate reduced-motion theme. ## Per-instance styles Use `.styles(...)` to override the global styles for one scrollbar. Instance styles take precedence over theme defaults. ```rust Scrollbar::vertical(&scroll_handle).styles(|styles| { styles .track(|style| style.width(px(14.)).bg(rgb(0xf5f5f5))) .track_hover(|style| style.bg(rgb(0xe5e5e5))) .thumb(|style| { style .width(px(6.)) .inset(px(3.)) .radius(px(3.)) .min_length(px(40.)) .bg(rgb(0x737373)) }) .thumb_hover(|style| style.width(px(8.)).bg(rgb(0x525252))) .thumb_active(|style| style.width(px(8.)).bg(rgb(0x404040))) }) ``` `ScrollbarTrackStyle` supports `bg`, `border_color`, and `width`. `ScrollbarThumbStyle` supports `bg`, `width`, `inset`, `radius`, and `min_length`. ## Custom viewport geometry The viewport normally comes from `ScrollbarHandle::viewport_bounds`. Two overrides support composite or custom-painted controls: ```rust Scrollbar::vertical(&scroll_handle) .viewport_bounds(editor_content_bounds); Scrollbar::vertical(&scroll_handle) .viewport_from_layout(); ``` Use `viewport_bounds` when your painted viewport differs from the handle's layout bounds. Use `viewport_from_layout` when a positioned overlay container already represents the exact viewport, such as a table body below a fixed header. Override the content size only when the handle cannot report the complete scrollable extent: ```rust Scrollbar::vertical(&scroll_handle) .scroll_size(gpui_kit::size(px(800.), px(4_000.))); ``` ## Custom scroll handles `ScrollHandle`, `UniformListScrollHandle`, and `ListState` implement `ScrollbarHandle`. Custom scroll containers can implement the same trait: ```rust use gpui_kit::{Bounds, Pixels, Point, Size}; use gpui_kit::base::ScrollbarHandle; impl ScrollbarHandle for MyScrollState { fn viewport_bounds(&self) -> Bounds { self.viewport_bounds() } fn offset(&self) -> Point { self.offset() } fn set_offset(&self, offset: Point) { self.set_offset(offset); } fn content_size(&self) -> Size { self.content_size() } fn start_drag(&self) { self.set_scrollbar_dragging(true); } fn end_drag(&self) { self.set_scrollbar_dragging(false); } } ``` `start_drag` and `end_drag` are optional. Use them when the scroll container needs to suspend snapping, selection, or another behavior during thumb drag. Only the actively dragged axis receives `end_drag` on mouse-up. ## Stable identity `Scrollbar::new`, `vertical`, and `horizontal` derive an element ID from their call site. Set an explicit stable ID when the same call site produces multiple independent scrollbars: ```rust Scrollbar::vertical(&scroll_handle).id(("activity-list", panel_id)); ``` A stable identity preserves retained visibility and width animation state across renders. ## Complete showcase source The runnable example is embedded directly from the shared Rust source: ```rust use super::*; impl BaseShowcase { pub(in super::super) fn scrollbar(&self) -> impl IntoElement { div() .id("example-scroll-region") .relative() .w_72() .h_48() .text_xs() .border_1() .border_color(super::example_rgb(0x171717)) .overflow_scroll() .track_scroll(&self.example_scroll) .child(div().children((1..=20).map(|row| { div() .h_7() .px_2() .flex() .items_center() .border_b_1() .border_color(super::example_rgb(0xe5e7eb)) .justify_between() .child(format!("Activity {row}")) .child(if row % 3 == 0 { "Completed" } else { "Pending" }) }))) .child(Scrollbar::new(&self.example_scroll).mode(ScrollbarMode::Always)) } } ``` ## Accessibility and interaction checklist - Keep wheel, trackpad, and keyboard scrolling available on the underlying viewport. - Preserve the default full-track interaction hitbox even when the painted thumb is narrow. - Give the thumb adequate contrast in normal, hover, and active states. - Do not move layout or hitboxes to implement entrance animation. - Test `Scrolling`, `Hover`, and `Always` with reduced motion enabled. - Test vertical, horizontal, and two-axis overflow independently. --- # Toast Source: /versions/v0.7.1/base/primitives/toast License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/toast and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A managed, animated stack of temporary status messages. Like every `gpui-base` primitive, Toast supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- toast ``` ## Import ```rust use gpui_kit::base::{Toast, ToastManager, ToastOptions, ToastStack}; ``` ## Anatomy and API The example composes `Toast`, `ToastManager`, `ToastOptions`, `ToastStack`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/toast.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/toast.rs). Native and browser previews compile this same file. ## State and events Push messages through toast state; transition status retains an item during entry and exit. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use super::*; impl BaseShowcase { pub(in super::super) fn toast(&self, cx: &mut Context) -> impl IntoElement { let visible = self.toast_visible; let entity = cx.entity().downgrade(); div() .w_72() .h(px(158.)) .text_xs() .relative() .flex() .items_center() .justify_center() .child( Button::new("show-toast") .h_7() .px_2() .flex() .items_center() .justify_center() .border_1() .border_color(super::example_rgb(0x171717)) .bg(super::example_rgb(0xffffff)) .child("Save changes") .on_click({ let show_entity = entity.clone(); move |_, _, cx| { _ = show_entity.update(cx, |this, cx| { this.toast_visible = true; cx.notify(); }); } }), ) .when(visible, |this| { this.child( Toast::new("example-toast") .transition_status(ToastTransitionStatus::Present) .absolute() .right_0() .bottom_0() .w_64() .p_2() .border_1() .border_color(super::example_rgb(0x171717)) .bg(super::example_rgb(0xffffff)) .child( div() .flex() .justify_between() .child( div() .font_weight(gpui::FontWeight::SEMIBOLD) .child("Changes saved"), ) .child( Button::new("dismiss-toast") .size_6() .flex() .items_center() .justify_center() .child("×") .on_click({ let entity = entity.clone(); move |_, _, cx| { _ = entity.update(cx, |this, cx| { this.toast_visible = false; cx.notify(); }); } }), ), ) .child( div() .mt_1() .text_color(super::example_rgb(0x737373)) .child("Your preferences are now up to date."), ), ) }) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Choose live-region priority carefully and avoid essential actions only in expiring content. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Link Source: /versions/v0.7.1/base/primitives/link License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/link and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. An accessible link-like control with application-defined styling. Like every `gpui-base` primitive, Link supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- link ``` ## Import ```rust use gpui_kit::base::{Link}; ``` ## Anatomy and API The example composes `Link`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/link.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/link.rs). Native and browser previews compile this same file. ## State and events The link emits activation while the application defines URL or in-app navigation. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use gpui::{IntoElement, ParentElement as _, Styled as _, div}; use gpui_base::Link; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn link(&self) -> impl IntoElement { div() .w_56() .flex() .flex_col() .gap_2() .text_xs() .child("Navigation is application-owned") .child( Link::new("example-link") .href("/base/primitives/link") .open_with(|href, _, _, cx| cx.open_url(href)) .h_7() .px_3() .py_0() .flex() .items_center() .border_1() .border_color(super::example_rgb(0x171717)) .child("Open Link documentation →"), ) .child( Link::new("disabled-link") .href("/disabled") .disabled(true) .h_7() .px_3() .py_0() .flex() .items_center() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .text_color(super::example_rgb(0x737373)) .child("Disabled destination"), ) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Use links for navigation, meaningful text, and a visible focus style. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Tree Source: /versions/v0.7.1/base/primitives/tree License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/tree and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A virtualized hierarchical list with explicit expansion and selection state. Like every `gpui-base` primitive, Tree supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- tree ``` ## Import ```rust use gpui_kit::base::{Tree, TreeItem, TreeState}; ``` ## Anatomy and API The example composes `Tree`, `TreeItem`, `TreeState`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/tree.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/tree.rs). Native and browser previews compile this same file. ## State and events `TreeState` owns items, expansion, and selection; tree actions update that entity. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use super::*; use gpui::{Image, ImageFormat, StyleRefinement, img}; use std::sync::Arc; const CHEVRON_RIGHT_SVG: &[u8] = br##""##; const CHEVRON_DOWN_SVG: &[u8] = br##""##; impl BaseShowcase { pub(in super::super) fn tree(&self) -> impl IntoElement { Tree::new(&self.tree) .w_64() .h_48() .list_style(StyleRefinement::default().flex_grow_1().size_full()) .relative() .text_sm() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .py_1() .item(|_, entry, state, _, _| { let depth = entry.depth(); let icon = entry.is_folder().then(|| { let bytes = if entry.is_expanded() { CHEVRON_DOWN_SVG } else { CHEVRON_RIGHT_SVG }; img(Arc::new(Image::from_bytes( ImageFormat::Svg, bytes.to_vec(), ))) .size_3() .flex_none() }); div() .h_8() .mx_1() .px_2() .flex() .items_center() .gap_1() .when(state.is_selected(), |this| { this.bg(super::example_rgb(0xf0f0f0)) }) .when(depth > 0, |this| { this.child(div().flex_none().w(px(depth as f32 * 12.))) }) .child( div() .size_3() .flex_none() .flex() .items_center() .justify_center() .children(icon), ) .child(entry.item().label.clone()) .into_any_element() }) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Expose hierarchy, level, expansion, and selection; preserve keyboard movement and visible focus. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Popover Source: /versions/v0.7.1/base/primitives/popover License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/popover and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. An anchored floating surface with controlled or internally managed open state. Like every `gpui-base` primitive, Popover supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- popover ``` ## Import ```rust use gpui_kit::base::{Popover}; ``` ## Anatomy and API The example composes `Popover`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/popover.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/popover.rs). Native and browser previews compile this same file. ## State and events Use `.anchor(Anchor::TopCenter).offset(px(8.))` to open below the trigger, centered, with an eight-pixel gap. The Base offset defaults to zero. `Top*` anchors open below, `Bottom*` above, `LeftCenter` to the right, and `RightCenter` to the left. The anchor names the popup's own point. Window-edge clamping does not flip the popup or change its anchor. `on_position` observes resolved popup and trigger bounds before content prepaint for custom presentation. Base does not draw an arrow; styled Component Popover provides `.arrow(true)` directly (default `false`), aligned to its anchor. Open state can be parent-controlled; activation, outside click, and Escape request lifecycle changes. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use gpui::{InteractiveElement as _, IntoElement, ParentElement as _, Styled as _, div, relative}; use gpui_base::{Button, Popover}; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn popover(&self) -> impl IntoElement { Popover::new("example-popover") .trigger( Button::new("popover-trigger") .h_7() .line_height(relative(1.)) .px_3() .flex() .items_center() .justify_center() .bg(gpui::black()) .text_color(gpui::white()) .child("Open Popover"), ) .content(|_, _, cx| { let state = cx.entity().downgrade(); div() .id("popover-content") .w_64() .p_2() .flex() .flex_col() .gap_2() .text_xs() .bg(super::example_rgb(0xffffff)) .border_1() .border_color(super::example_rgb(0xd4d4d4)) .child("Workspace access") .child( div() .text_xs() .text_color(super::example_rgb(0x737373)) .child("Anyone with the link can view."), ) .child( div().mt_1().flex().justify_end().child( Button::new("popover-done") .h_7() .line_height(relative(1.)) .px_3() .flex() .items_center() .justify_center() .bg(gpui::black()) .text_color(gpui::white()) .on_click(move |_, window, cx| { _ = state.update(cx, |state, cx| state.dismiss(window, cx)); }) .child("Done"), ), ) }) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Support Escape/outside dismissal and return focus; move focus only when its content requires it. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Radio Source: /versions/v0.7.1/base/primitives/radio License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/primitives/radio and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. A controlled single-choice item with selectable and disabled semantics. Like every `gpui-base` primitive, Radio supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system. ## Example The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above. ```bash cargo run -p gpui-base-examples -- radio ``` ## Import ```rust use gpui_kit::base::{Radio}; ``` ## Anatomy and API The example composes `Radio`. GPUI's standard styling and event traits provide presentation; these base types provide the interaction structure. The authoritative module is [`components/radio.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/radio.rs). Native and browser previews compile this same file. ## State and events Pass a controlled checked value; `on_change` reports selection and the parent clears peers. Keep controlled state on the parent render type or in a GPUI entity. Update it in callbacks and call `cx.notify()`; do not recreate persistent entities during every render. ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: ```rust use gpui::{ Context, IntoElement, ParentElement as _, Styled as _, div, prelude::FluentBuilder as _, px, }; use gpui_base::Radio; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn radio(&self, cx: &mut Context) -> impl IntoElement { let checked = self.radio_selected == 0; let entity = cx.entity().downgrade(); Radio::new("example-radio") .text_xs() .checked(checked) .on_change(move |next, _, _, cx| { _ = entity.update(cx, |this, cx| { if next { this.radio_selected = 0; } cx.notify(); }); }) .flex() .items_start() .gap_2() .child( div() .mt(px(2.)) .flex() .items_center() .justify_center() .size(px(14.)) .border_1() .border_color(super::example_rgb(0x171717)) .when(checked, |this| { this.child(div().size(px(6.)).bg(super::example_rgb(0x171717))) }), ) .child( div().child("Standard").child( div() .text_xs() .text_color(super::example_rgb(0x737373)) .child("3–5 business days"), ), ) } } ``` The command above supplies application initialization, window creation, and shared `BaseShowcase` state. ## Accessibility Give each option a label and place mutually exclusive options in a named group. ## Notes Use stable element IDs where accepted. Verify focus, hover, active, selected, disabled, reduced-motion, and high-contrast appearances in the consuming design system. --- # Text Selection Source: /versions/v0.7.1/base/text-selection License: GPUI Kit-authorized prose and original illustrations are also CC BY 4.0; credit GPUI Kit, link https://gpui-kit.com/versions/v0.7.1/base/text-selection and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples use Apache-2.0. `gpui-base` provides window-level text selection for ordinary GPUI participants. It coordinates pointer gestures, Shift-click extension, selection across multiple text elements, copying, scrolling, scopes, and multi-window lifetime without prescribing how text is laid out or highlighted. Use it when you render text with `StyledText`, `TextLayout`, a virtualized document, or another custom GPUI `Element`. ## Get started To add text selection to a custom GPUI participant, connect its layout and paint lifecycle to the window selection state as shown below. A selectable window has three roles: 1. One `TextSelectionLayer` element owns the selection state and window pointer handlers. 2. Each independently selectable text participant owns a stable `TextSelectionHandle`. 3. During rendering, the participant registers current geometry and projects the resulting snapshot onto laid-out `TextSelectionRun`s. Pointer gestures flow through the TextSelectionLayer element into window state. A participant registers a TextSelectionHandle and geometry, receives a snapshot, projects text runs into byte ranges, paints highlights, and contributes copied text. ### Key parts | API | Lifetime | Purpose | | --------------------------- | ------------------------------- | ----------------------------------------------------------------------------- | | `TextSelectionLayer` | Once per window | Installs window-level pointer handling and selection state. | | `TextSelection` | Static API | Queries and controls the window selection. | | `TextSelectionHandle` | Once per selectable participant | Identifies the participant and stores its callbacks and projected selection. | | `TextSelectionRegistration` | Recreated each rendered frame | Reports the current hitbox, bounds, scroll offset, scope, and document order. | | `TextSelectionRun` | Recreated during paint | Describes laid-out text for projection to a UTF-8 byte range. | | `TextSelectionProjection` | Returned by `update_runs` | Pairs each submitted run with its selected byte range. | | `TextSelectionSnapshot` | Produced when selection changes | Describes the participant's endpoints and coverage. | | `TextSelectionEvent` | Emitted to subscribers | Reports selection changes, clearing, and auto-scroll requests. | | `TextSelectionContentKey` | Stable content identity | Identifies virtualized content at a selection endpoint. | The complete flow is: 1. Retain one `TextSelectionLayer` element at the window root. 2. Create one `TextSelectionHandle` for each independently selectable participant. 3. During prepaint, call `TextSelectionHandle::register` with a `TextSelectionRegistration`. 4. During paint, pass laid-out `TextSelectionRun`s to `TextSelectionHandle::update_runs`. 5. Paint each returned byte range behind its glyphs. 6. Read or clear the window selection through `TextSelection`. The installed layer also provides familiar multi-click behavior: double-click selects a word using the same boundary rules as `Input`, while triple-click and later clicks select the newline-delimited logical line. The window state belongs to the retained `TextSelectionLayer` element. Handles and callbacks never receive or own that internal state. ## How it works `gpui-base` owns gesture coordination and range projection. The application remains responsible for layout and painting across the participant seam: GPUI Base owns gestures, window selection state, snapshots, and range projection. The application owns the selection handle, geometry, text runs, painting, and copying. ## Install the window element Add one `TextSelectionLayer` as the first child of the window root: ```rust use gpui_kit::prelude::*; use gpui_kit::{Context, Render, Window}; use gpui_kit::base::TextSelectionLayer; impl Render for AppView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { div() .size_full() .child(TextSelectionLayer) .child(self.content.clone()) } } ``` `TextSelectionLayer` is a zero-sized element. Keep it first and mount only one per window. Calling `TextSelection::activate_scope` before the first prepaint stores the scope until the layer binds its window state. ## Create a stable handle Create one handle for the semantic lifetime of the participant. Do not create a new handle every frame. ```rust use gpui_kit::{Context, Subscription, Window}; use gpui_kit::base::TextSelectionHandle; struct DocumentView { selection: TextSelectionHandle, _selection_refresh: Subscription, } impl DocumentView { fn new(window: &Window, cx: &mut Context) -> Self { let selection = TextSelectionHandle::new("", cx); let selection_refresh = selection.refresh_window_on_change(window, cx); Self { selection, _selection_refresh: selection_refresh, } } } ``` `refresh_window_on_change` redraws only the owning window when this handle's selection changes. Retain the returned subscription for as long as the participant is rendered, or explicitly call `.detach()` when the subscription should live for the rest of the participant entity's lifetime. Use `subscribe` instead when the participant needs events or more targeted invalidation. The `fallback_copy_text` passed to `TextSelectionHandle::new` is used until the participant projects laid-out runs or supplies custom copy behavior. Use `set_fallback_copy_text` to replace it. ## Register geometry during prepaint Call `TextSelectionHandle::register(registration, window, cx)` once per rendered frame, after the handle's bounds and hitbox are known: ```rust use gpui_kit::{Bounds, Hitbox, Pixels, Window}; use gpui_kit::base::TextSelectionRegistration; fn register_selection( handle: &TextSelectionHandle, hitbox: Hitbox, bounds: Bounds, window: &mut Window, cx: &mut gpui_kit::App, ) { handle.register( TextSelectionRegistration::new(hitbox, bounds) .with_document_order(0) .with_text_bounds(vec![bounds]), window, cx, ); } ``` - `bounds` is the participant's content viewport in window coordinates. - `text_bounds` contains the visible glyph-bearing areas. Blank-only drags do not start a text selection. - `document_order` provides stable ordering between participants for cross-participant selection and copy. Do not derive semantic order from a `HashMap` or accidental paint order. - `with_scroll_offset` maps window points into scrolled content coordinates. - `with_scope` assigns an explicit opaque scope. A surrounding `.text_selection_scope(scope)` builder overrides it while that subtree renders. Handles not registered in the current frame stop participating automatically. ## Project selection onto text runs In paint, call `TextSelectionHandle::update_runs` with laid-out runs containing the exact text used to create each `TextLayout`. It returns a `TextSelectionProjection` containing UTF-8-safe byte ranges: ```rust use gpui_kit::{Bounds, Pixels, SharedString, TextLayout}; use gpui_kit::base::TextSelectionRun; fn selected_range( handle: &TextSelectionHandle, text: SharedString, layout: TextLayout, bounds: Bounds, cx: &mut gpui_kit::App, ) -> Option> { handle .update_runs( &[TextSelectionRun::new(text, layout, bounds) .with_document_order(0)], cx, ) .ranges() .iter() .next() .and_then(|range| range.clone()) } ``` Paint the returned range behind the glyphs, then paint the text normally. Wrapped selections need three kinds of highlight geometry: the remainder of the first line, full-width middle lines, and the prefix of the last line. For multiple runs, give each run a stable `document_order`. Input order is preserved in `projection.ranges()` so each range can be paired with its original layout; document order is used when composing copied text. The [shared Text Selection showcase](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/text_selection.rs) is the complete runnable example used by both the native command and the live Rust/WASM preview above: ```bash cargo run -p gpui-base-examples -- text-selection ``` ## Complete Rust example ```rust use gpui::{Context, IntoElement, ParentElement as _, Styled as _, Window}; #[cfg(test)] use gpui_base::ElementExt as _; use gpui_base::{SelectableText, TextSelection}; use super::*; const PRODUCT_PARAGRAPH: &str = "Selection should feel like a natural part of reading a product brief. Start in this paragraph, continue into the next renderer, and GPUI preserves the document order while every frame supplies fresh geometry for the same stable selection handle."; const IMPLEMENTATION_PARAGRAPH: &str = "This second paragraph is deliberately long enough to wrap in the showcase. Drag across the boundary to see one continuous highlight, then use the platform copy shortcut to confirm that the copied result follows the visible reading order rather than renderer ownership."; const INTERNATIONAL_PARAGRAPH: &str = "International text should remain predictable when a line mixes café, déjà vu, Kraków, naïve, and résumé. Resize the window or drag across several wrapped lines; UTF-8 byte ranges still map back to the correct glyphs without splitting a character."; impl BaseShowcase { pub(in super::super) fn text_selection( &mut self, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { self.text_selection_active = TextSelection::has_selection(window, cx); self.text_selection_text = TextSelection::selected_text(window, cx); let active = self.text_selection_active; let selected_text = if active { self.text_selection_text.clone() } else { "Drag across any paragraphs to select text.".to_owned() }; let entity = cx.entity().downgrade(); let footer = div() .id("text-selection-footer") .h(px(150.)) .flex_none() .flex() .flex_col() .gap_2() .p_3() .bg(super::example_rgb(0xf5f5f5)) .border_1() .border_color(super::example_rgb(0xe5e5e5)) .child( div() .font_weight(gpui::FontWeight::SEMIBOLD) .child(if active { "Selection active" } else { "No selection" }), ) .child( div() .id("text-selection-preview") .flex_1() .min_h_0() .overflow_y_scroll() .text_color(super::example_rgb(0x525252)) .child(selected_text), ) .child( Button::new("clear-text-selection") .h_7() .px_2() .flex() .items_center() .justify_center() .self_start() .border_1() .border_color(super::example_rgb(0x171717)) .child("Clear selection") .on_click(move |_, window, cx| { TextSelection::clear(window, cx); _ = entity.update(cx, |this, cx| { this.text_selection_active = false; this.text_selection_text.clear(); cx.notify(); }); }), ); #[cfg(test)] let footer = { let bounds = self.text_selection_footer_bounds.clone(); footer.on_prepaint(move |value, _, _| *bounds.borrow_mut() = Some(value)) }; div() .id("text-selection-example") .w(px(620.)) .max_w_full() .h(px(520.)) .max_h_full() .flex() .flex_col() .gap_3() .child( div() .id("text-selection-scroll") .flex_1() .min_h_0() .overflow_y_scroll() .track_scroll(&self.text_selection_scroll) .flex() .flex_col() .gap_3() .p_4() .child( div() .text_lg() .font_weight(gpui::FontWeight::SEMIBOLD) .child( SelectableText::with_handle( "selection-heading", self.text_selection_handles[0].clone(), "Text selection across renderers", ) .document_order(0), ), ) .child( div() .text_color(super::example_rgb(0x525252)) .line_height(px(22.)) .child( SelectableText::with_handle( "selection-product", self.text_selection_handles[1].clone(), PRODUCT_PARAGRAPH, ) .document_order(1), ), ) .child( div() .text_color(super::example_rgb(0x525252)) .line_height(px(22.)) .child( SelectableText::with_handle( "selection-implementation", self.text_selection_handles[2].clone(), IMPLEMENTATION_PARAGRAPH, ) .document_order(2), ), ) .child( div() .text_color(super::example_rgb(0x525252)) .line_height(px(22.)) .child( SelectableText::with_handle( "selection-international", self.text_selection_handles[3].clone(), INTERNATIONAL_PARAGRAPH, ) .document_order(3), ), ), ) .child(footer) } } #[cfg(test)] mod tests { use gpui::{TestAppContext, point, px}; use crate::showcase::BaseShowcase; #[gpui::test] fn text_selection_footer_stays_fixed_when_document_scrolls(cx: &mut TestAppContext) { let (view, window) = cx.add_window_view(|window, cx| BaseShowcase::new("text-selection", window, cx)); window.update(|window, cx| window.draw(cx).clear(cx)); let (footer_bounds, scroll) = view.read_with(window, |view, _| { ( view.text_selection_footer_bounds .borrow() .expect("footer should be painted"), view.text_selection_scroll.clone(), ) }); scroll.set_offset(point(px(0.), px(-80.))); view.update(window, |_, cx| cx.notify()); window.update(|window, cx| window.draw(cx).clear(cx)); let scrolled_footer_bounds = view.read_with(window, |view, _| { view.text_selection_footer_bounds .borrow() .expect("footer should be painted after scrolling") }); assert_eq!(scrolled_footer_bounds, footer_bounds); } } ``` ## Query and control the window selection Use `TextSelection` associated functions to read or mutate the window selection. No extension trait import is required: ```rust use gpui_kit::base::TextSelection; let has_selection = TextSelection::has_selection(window, cx); let text = TextSelection::selected_text(window, cx); TextSelection::end(window, cx); // End a drag, preserving its range. TextSelection::clear(window, cx); // Clear window and participant-local ranges. ``` `selected_text` invokes participant copy callbacks only after the window and handle state leases have been released, so a callback may safely read or update selection state. ### Touch selection A long press on a participant selects the word under the finger, and lifting the finger keeps that selection as a *touch selection*: one that carries a grab handle at each end and an edit menu. Base owns the gesture and the drag; a presentation layer draws the handles and the menu from `TouchSelectionSnapshot`, which holds the caret line box at each end in window coordinates. ```rust use gpui_kit::base::{SelectionEdge, TextSelection}; // Re-render whoever draws the handles when the touch selection changes. let subscription = TextSelection::observe_touch_selection(window, cx, |cx| { /* notify */ }); if let Some(snapshot) = TextSelection::touch_selection(window, cx) { let start = snapshot.start(); // the caret box before the first selected character let end = snapshot.end(); // the caret box after the last one let menu = snapshot.is_menu_open(); } // Drag one end from the finger's position; the other end stays. TextSelection::begin_edge_drag(SelectionEdge::End, finger, window, cx); TextSelection::update_edge_drag(finger, window, cx); TextSelection::end_edge_drag(window, cx); TextSelection::select_all(window, cx); // the participant that was pressed TextSelection::close_edit_menu(window, cx); // after the menu's own action ran ``` A participant paints its own handles, where it is in the paint order, so that whatever covers the text covers them too: call `TextSelectionHandle::prepaint_touch_handles` in prepaint (it inserts the hitboxes the finger takes) and `TextSelectionHandle::paint_touch_handles` at the end of paint, after `register`, with the selection color. `TextView` does both. Participants report where their selection ends were painted through `TextSelectionRegistration::with_selection_edges`. Whatever draws the menu must call `TextSelection::register_touch_ui(bounds, window, cx)` with its bounds as it paints, every frame; a press inside a registered surface is then left to that surface instead of clearing the selection it belongs to. `Root` in GPUI Component draws the menu. ## Advanced participant adapters Plain text usually needs only `refresh_window_on_change` and `update_runs`. Rich or virtualized participants can configure additional behavior directly on the handle: | Method | Use | | -------------------------- | ------------------------------------------------------------------------------------- | | `refresh_window_on_change` | Redraw only the owning window when this handle's selection changes. | | `subscribe` | Receive `TextSelectionEvent` values for selection changes, clearing, and auto-scroll. | | `copy_with` | Export source text or include virtualized content that is not currently painted. | | `set_fallback_copy_text` | Replace the participant's fallback copy text. | | `resolve_content_key_with` | Attach a stable `TextSelectionContentKey` to an endpoint. | | `focus_with` | Focus the participant when a drag begins inside it. | | `clear_with` | Synchronously clear participant-local state when the window selection clears. | | `set_local_selection` | Report participant-local selection such as select-all. | Callbacks are invoked outside selection-state leases. They may update the participant or query `TextSelection` without causing a reentrant entity borrow. When `subscribe` receives `TextSelectionEvent::AutoScroll(Some(delta))`, feed that delta into the participant's scrolling loop; `None` stops it. Positive deltas move toward the bottom. The shared showcase demonstrates this with content taller than its viewport. For a virtualized document, inspect `TextSelectionEvent::SelectionChanged` and use `TextSelectionSnapshot::coverage()`, `window_points()`, and each endpoint's `content_point()` and `content_key()`. Coverage distinguishes a bounded participant from one selected from its start, to its end, or in full, allowing `copy_with` to include unpainted content. ## Isolate modal content with scopes Only handles in the active `TextSelectionScopeId` participate. Set the active window scope, then mark the corresponding rendered subtree: ```rust use gpui_kit::base::{ElementExt as _, TextSelection, TextSelectionScopeId}; let dialog_scope = TextSelectionScopeId::new(); TextSelection::activate_scope(dialog_scope, window, cx); let dialog = dialog_content.text_selection_scope(dialog_scope); ``` Scope stacks are isolated per window and are cleaned up even if a scoped subtree panics while rendering. Changing the active scope clears the previous selection atomically. ## Integration checklist - Retain one `TextSelectionLayer` element as the first child of each custom window root. - Keep each `TextSelectionHandle` stable across renders. - Register current geometry every rendered frame. - Use explicit document order and window-local scopes. - Pass the exact UTF-8 text used by each `TextLayout`. - Paint highlights before glyphs. - Keep parser, source export, and virtual-document knowledge in the participant. --- # Task Source: /versions/v0.7.1/zh-CN/docs/task 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/task 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 在 GPUI 中,[`Task`](https://docs.rs/gpui-pre/0.3.8/gpui/struct.Task.html) 是 GPUI 执行器所调度工作的 handle。它最重要的性质是**所有权**:handle 被 drop 时,尚未完成的工作会取消。只有保存、等待,或者明确 detach 这个 handle,任务才会持续运行。因此,Task 的生命周期属于 View 的状态设计,而不只是 Rust `Future` trait 的细节。 **启动任务的 API** 决定工作在哪里运行;返回的 `Task` 控制它的生命周期。前台任务可以通过异步 [Context](./context) 重新进入 GPUI,更新 [Entity]。后台任务在 UI 线程之外运行,只能返回自有数据,不能直接修改 Entity 状态。 | 启动方式 | 运行位置 | 异步 callback 收到 | 适用场景 | | --- | --- | --- | --- | | 在 `Context` 中调用 `cx.spawn(...)` | 前台线程 | `WeakEntity`、`&mut AsyncApp` | 等待 I/O、定时器,然后更新 Entity | | `cx.spawn_in(window, ...)` | 前台线程 | `WeakEntity`、`&mut AsyncWindowContext` | 完成时还需要同一个 [Window](./window) 的工作 | | 在 `App` 中调用 `cx.spawn(...)` | 前台线程 | `&mut AsyncApp` | 没有当前 Entity 的应用级工作 | | `cx.background_spawn(...)` | 后台执行器 | 不提供 GPUI Context | 使用自有 `Send` 数据进行耗时解析或计算 | ## 运行仓库中的示例 按[安装指南](/versions/v0.7.1/zh-CN/docs/installation)安装平台依赖后,在仓库根目录运行: ```sh cargo run -p example-stream-markdown ``` 窗口中有 **Replay** 按钮和 **Fade in streamed text** 开关。点击 Replay,文字先清空,再分片出现。在流结束前再次点击,会开始新一轮播放;界面只显示带有当前 replay ID 的分片。关闭窗口会 drop View 持有的任务 handle,但已经进入同步循环的生产者可能要等循环跑完才停止。完整源码见 [`examples/stream-markdown/src/main.rs`](https://github.com/longbridge/gpui-kit/blob/main/examples/stream-markdown/src/main.rs);package 和依赖见 [`examples/stream-markdown/Cargo.toml`](https://github.com/longbridge/gpui-kit/blob/main/examples/stream-markdown/Cargo.toml)。这是可直接运行的 workspace 示例;只依赖 `gpui-kit` 的应用骨架见[入门指南](/versions/v0.7.1/zh-CN/docs/getting-started)。 可沿源码追踪一次点击:`main` 创建窗口;`Example::new` 创建 channel 并保存前台接收任务的 `Task`;按钮调用 `Example::replay`,增加 `replay_id`、清空文字、替换后台生产任务的 `Task`;接收任务检查 ID,随后更新 `TextViewState`。此例用 worker 模拟数据到来。实际网络流应异步等待数据读取,并等待有界 channel 的发送,以免 UI 较慢时数据无限排队。 ## 动手做:启动、取消和失败的任务 流式示例说明了 Task 所有权,但没有失败操作。下面的小应用把三种结果都显示在界面上。在现有仓库中,将代码保存为 `examples/hello_world/src/bin/task_lab.rs`(如有需要,先创建 `bin` 目录),然后从仓库根目录执行 `cargo run -p hello_world --bin task_lab`。它复用已有的 `hello_world` package,无需新增依赖或 workspace member。 ```rust use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::*; use std::time::Duration; struct TaskLab { status: String, request_id: u64, task: Option>, } impl TaskLab { fn start(&mut self, fail: bool, cx: &mut Context) { self.request_id = self.request_id.wrapping_add(1); let request_id = self.request_id; self.task = None; // Drop the previous handle before starting again. self.status = format!("Loading request {request_id}..."); cx.notify(); self.task = Some(cx.spawn(async move |this, cx| { let result: Result = cx .background_spawn(async move { // Stand in for expensive work; this blocks a worker, not the UI thread. std::thread::sleep(Duration::from_millis(800)); if fail { Err("Simulated failure".into()) } else { Ok(format!("Completed request {request_id}")) } }) .await; _ = this.update(cx, |view, cx| { if view.request_id != request_id { return; // A newer start or cancel owns the displayed state. } view.status = match result { Ok(value) => value, Err(error) => format!("Failed: {error}"), }; cx.notify(); }); })); } fn cancel(&mut self, cx: &mut Context) { self.request_id = self.request_id.wrapping_add(1); self.task = None; // Dropping the handle prevents future polls. self.status = "Cancelled".into(); cx.notify(); } } impl Render for TaskLab { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .flex() .flex_col() .size_full() .items_center() .justify_center() .gap_2() .child(self.status.clone()) .child( Button::new("start") .primary() .label("Start") .on_click(cx.listener(|view, _, _, cx| view.start(false, cx))), ) .child( Button::new("fail") .label("Fail") .on_click(cx.listener(|view, _, _, cx| view.start(true, cx))), ) .child( Button::new("cancel") .label("Cancel") .on_click(cx.listener(|view, _, _, cx| view.cancel(cx))), ) } } fn main() { application().with_assets(assets::Assets).run(|cx| { init(cx); open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| TaskLab { status: "Idle".into(), request_id: 0, task: None, }) }) .expect("Failed to open window"); }); } ``` 点击 **Start**,观察 **Loading** 约 800 毫秒后变成 **Completed**;等待期间窗口仍应能响应点击。点击 **Fail**,会稳定地看到 **Failed**。点击 **Start**,在完成前点击 **Cancel**,**Cancelled** 应保持不变。快速依次点击 **Start** 和 **Fail**,只有最后一次请求能更新标签。若被取消的 worker 已经进入 `sleep`,它可能先把这次阻塞调用执行完;已丢弃的 `Task` 和请求 ID 会阻止结果改动 View。练习中的 sleep 只在后台 worker 中执行。真实 I/O 应使用异步 API;若长时间 CPU 工作需要及时停止,应拆成有界步骤或使用其他取消机制。 练习结束后,只删除 `examples/hello_world/src/bin/task_lab.rs`,保留 `src/bin` 中的其他文件。在未自行增加其他二进制目标的检出中,这会让 package 只剩原来的 `hello_world` 目标,其他指南中的 `cargo run -p hello_world` 就能再次运行。若该 package 中还有你自己的二进制目标,运行原示例时请指定 `--bin hello_world`。 View 持有前台 `Task`;它等待后台的 `Task>`,再通过 `cx.spawn` 提供的弱 Entity 更新 View。每次状态变化后,`cx.notify()` 让界面显示新状态。完成后的 handle 留在字段中,直到下一次启动或取消;丢弃已完成的任务没有影响。`Result` 的错误分支会把错误显示在界面上,不会默默丢弃。 ## 任务如何在线程之间切换 在桌面应用中,GPUI 的**前台执行器**在主线程/UI 线程 poll `cx.spawn` 和 `cx.spawn_in` 创建的 future。多个前台任务可以*并发*:一个任务等待未完成的操作时,poll 返回 `Pending`,UI 线程便可处理输入、渲染或 poll 其他任务。它们不会在多个 UI 线程上并行运行。如果 `await` 的值已经 ready,任务可能在同一次 poll 中继续执行;前台任务中的耗时同步函数仍会占住 UI 线程,直到函数返回。 **后台执行器**把实现 `Send` 的工作交给平台后台调度队列或 worker pool。在桌面平台上,worker 可以与 UI 线程以及其他后台任务并行执行,具体取决于可用 worker。GPUI **不会为每个 `Task` 新建一个 OS 线程**。worker 数量和调度方式随平台而异;测试执行器也可能在没有并行 OS 线程的情况下模拟调度。后台 future 在其生命周期内可能由不同 worker poll,不能依赖固定的 worker 线程。
GPUI 前台与后台任务流程 两列分别表示主线程和后台执行器。事件启动前台任务,任务派发实现 Send 的工作并在等待期间让出 UI 线程。结果就绪后,前台任务在 UI 线程恢复,更新 Entity 并请求稍后渲染。 Main / UI thread Background executor 1 · User eventcx.spawn(...) 2 · Foreground pollcx.background_spawn(work) Send future queuedPlatform workers / dispatch queue 3 · Await background TaskIf Pending, UI can handle input/render Worker polls / computesMay run in parallel with UI work 4 · Resume on UI threadWeakEntity::update · cx.notify() Send result readyWake the foreground Task 窄屏下的 GPUI 任务流程 同一流程改为纵向排列:UI 线程启动并 poll 任务;后台 worker 计算自有的 Send 数据;UI 线程恢复后更新 Entity 并请求渲染。 Main / UI thread 1 · User eventcx.spawn(...) 2 · Foreground pollbackground_spawn(work) 3 · await TaskPending → UI free Background executor Queue Send futurePlatform workers / dispatch queue Worker poll / compute Send result → wake UI Main / UI thread 4 · Resume and update EntityWeakEntity::update · cx.notify()
图示后台结果尚未就绪时的典型流程。worker 返回自有数据;只有前台更新会修改 GPUI 状态。配色随站点深浅主题切换。
`cx.spawn` 的前台 future 不要求实现 `Send`,因此可持有只能在主线程使用的 GPUI handle,但仍要求 `'static`:应把自有输入移入 future,而不是借用 `self`。`background_spawn` 则要求 future 和输出都实现 `Send + 'static`。将自有数据交给 worker,让它返回可跨线程的结果;`Entity`、`Window` 和 `Context` 的更新留在前台。在前台任务中 `await` 后台 `Task`,结果就绪后会把**后续执行**排回前台执行器。`cx.notify()` 随后将 View 标记为待渲染,并不会同步绘制一帧。 异步 GPUI context 用于在 `await` 后重新进入 GPUI,并不表示能让 `Context` 或 `Window` 的可变借用跨越等待点。启动前复制或移走自有输入;结果到来后用 `WeakEntity::update` 做简短的同步状态更新,需要同一个窗口时用 `update_in`。不要在 `background_spawn` 的 future 内修改 UI 状态。 ## 从 owner 启动任务 在具名方法、事件处理器或生命周期钩子中启动任务。不要在 [`render`](./render) 中无条件启动,否则每次 render 都可能再启动一份。启动前先取出输入值,避免 `self` 或 `cx` 的借用跨过 `await`。 ```rust struct SearchView { query: String, results: Vec, _search_task: Option>, } impl SearchView { fn search(&mut self, cx: &mut Context) { let query = self.query.clone(); self._search_task = Some(cx.spawn(async move |this, cx| { let results = search_index(query).await; _ = this.update(cx, |view, cx| { view.results = results; cx.notify(); }); })); } } ``` callback 收到名为 `this` 的 `WeakEntity`。它不会让 View 一直存活。`await` 结束后,`this.update` 在前台线程重新访问 Entity;若 View 已释放,则返回错误。可以用 `?`、`if let` 处理;若 View 消失是正常情况,也可以有意写成 `_ =`。修改 View 状态后调用 `cx.notify()`,依赖它的 UI 才会重新 render。 给 `_search_task` 赋予新 `Task` 会 drop 旧 handle,取消上一次搜索。这个字段使用 `Option`,因为 View 在用户发起搜索前没有任务。如果构造 View 时就启动任务,可以直接保存 `Task<()>`。 上面的 `search_index` 和 `SearchResult` 代表应用自己的代码,片段用来说明所有权,并非完整应用。若要直接编译运行,请使用上面的仓库示例。 取消操作遵循异步执行的协作机制。drop Task 会阻止后续 poll,但不能撤回已发生的外部副作用,也不能从中途停止正在执行的阻塞函数。如果旧请求的结果仍可能在新请求后到达,还要在应用结果前检查请求 ID 或修订号。 ## 保存、等待或 detach 创建任务时就决定其生命周期: - **保存 handle**:工作应随所属 Entity/View 或窗口级 owner 一同结束时使用。替换 `Option>` 适合搜索、刷新、debounce 和持续流。 - **等待 handle**:后续步骤依赖任务结果时,从另一个任务中 `await`。等待期间,外层任务拥有这个 handle。 - **调用 `.detach()`**:一次性工作应脱离当前 owner、独立完成时使用。它消耗 handle,让任务继续运行到结束;owner 此后无法通过 drop 字段取消任务。如果 View 可能先关闭,callback 应使用弱 Entity,并处理更新失败。 单独写一句 `cx.spawn(...);` 会在语句结束时 drop 返回的 handle,任务可能尚未执行就取消。如果 detach 的任务返回 `Result`,而任务本身没有报告错误,这个结果也会被丢弃。`Task::detach()` 与 `Subscription::detach()` 不同:前者让工作独立继续运行,直到它完成;后者让 callback 持续订阅,直到源 Entity 被释放。对用户可见的操作,应在 View 中保存 loading 和 failure 状态,并在完成时更新。 若在 render 路径或重复执行的 callback 中不断 spawn 并 detach,多个独立任务可能同时持续运行;有限任务正常完成,因此 `.detach()` 本身不等于泄漏。持续或长期工作应由 owner 保存 `Task`,需要停止时替换或 drop handle。若 owner 保存 Task,而任务 future 又强持有同一个 Entity,就会形成保留环。希望 owner 释放时取消任务,应使用 `Context::spawn` 已提供的 `WeakEntity`,或捕获 `cx.weak_entity()`。详见 [Entity 的循环引用](./entity#使用-weakentity-表示反向引用和-callback)。 ## 在 `await` 后重新进入 GPUI 从 `Context` 调用 `spawn` 会得到 `AsyncApp`,它可以访问应用,却没有当前的 `&mut Window`。如果完成时需要改变 Focus、显示提示,或使用原来的 Window,就调用 `spawn_in`: ```rust fn submit(&mut self, window: &mut Window, cx: &mut Context) { let draft = self.draft.clone(); self._submit_task = Some(cx.spawn_in(window, async move |this, cx| { let message = send_message(draft).await; _ = this.update_in(cx, |view, window, cx| { view.messages.push(message); view.input_focus.focus(window, cx); cx.notify(); }); })); } ``` `update_in` 在一次同步更新中重新提供 `&mut Self`、`&mut Window` 和 `&mut Context`。此时 Window 或 Entity 可能已经不存在,需要处理返回结果。不需要 Window 时使用 `update`。从应用级 `App::spawn` 启动时,callback 只收到 `AsyncApp`;`await` 后调用 `cx.update(|cx| { ... })`,完成简短的应用级修改。 ## 将耗时工作移出 UI 线程 前台任务等待非阻塞 I/O 且结果尚未就绪时不会占住 UI 线程,但一次 poll 中的大量 CPU 计算仍会占用它。将自有且实现 `Send` 的输入移入 `background_spawn`,从前台任务 `await` 它的 `Task`,再回到 UI 线程应用结果: ```rust struct DocumentView { source: String, // Mutable edit buffer. revision: u64, parsed: Option, _parse_task: Option>, } impl DocumentView { fn parse(&mut self, cx: &mut Context) { self.revision = self.revision.wrapping_add(1); let revision = self.revision; let source = self.source.clone(); self._parse_task = Some(cx.spawn(async move |this, cx| { let parsed = cx.background_spawn(async move { parse_document(source) }).await; _ = this.update(cx, |view, cx| { if view.revision != revision { return; // An older result must not overwrite newer content. } view.parsed = Some(parsed); cx.notify(); }); })); } } ``` worker 接收 `String`,返回实现 `Send` 的 `ParsedDocument`;它拿不到 `App`、`Window` 或 `Context`。离开 Entity 更新之前,只 clone 工作所需的输入。替换 `_parse_task` 会取消上一个外层任务及它正在等待的后台任务,但不能中途打断一次 poll 中正在执行的同步解析。修订号检查还能阻止前台更新前已经过期的结果覆盖新状态。 ## 处理完成、失败和取消 用户能看到的操作,应在所属 View 中保存明确的 idle、loading、loaded、failed 等状态。启动任务前设置 loading 并 notify。让 worker 返回自有且实现 `Send` 的 `Result`。`await` 后先拒绝过期请求 ID,再分别处理 `Ok`(保存数据)与 `Err`(保存可读错误);清除 loading,并在这次完整状态更新后 notify 一次。若界面允许,刷新时可保留此前可用的数据。仅记录日志或在 `.detach()` 后丢弃错误,会使用户一直看到加载状态。 `WeakEntity` 或原窗口已释放,是正常的取消路径:`update` 或 `update_in` 失败表示界面已不存在。drop 任务 handle 可阻止后续 poll,但不能撤回 I/O,也不能中止 worker 正在执行的同步代码。若操作有外部副作用,还需另行决定它是否必须完成、如何报告结果。替换任务后,仍要用请求 ID 防止排队中的消息或迟到结果污染状态。 不要写 `while !ready {}` 或反复立即 poll 来等待别的任务;这样的循环会占住 UI 线程,甚至让等待的工作无法继续。应 `await` Task、定时器、I/O future 或 channel 接收。周期性工作要在迭代之间等待定时器,并由 owner 保存 handle。大量 CPU 工作放到后台执行器;若要求及时取消很长的计算,可把它拆成有界步骤。前台任务不能用阻塞 sleep 或阻塞 I/O。 ## 检查与排查任务 运行上面的示例,检查可观察行为:Replay 使文字流式出现;再次 Replay 清空文字且不显示旧片段。关闭窗口会 drop View 及其任务 handle,但不能据此断定正在运行的生产者会立即停止。要区分 UI 卡死与 worker 较慢,检查前台 future 是否走到了尚未就绪的 `await`;首次返回 `Pending` 前的代码仍在 UI 线程执行。若没有任何内容,检查 `Task` handle 是否被保存、接收端是否还活着,以及更新失败是否得到处理。若出现旧文字,检查 UI 更新处的请求 ID。若 loading 一直不结束,检查成功与失败两条路径,并确认等待的操作能完成。 应用测试中,纯解析与请求顺序可用普通 Rust 测试;owner 生命周期和过期结果检查可用 GPUI context 测试。UI 交互测试应触发操作,并从渲染状态观察 loading、success 和 failure。使用测试执行器推进异步任务,避免 sleep 和忙等。仓库示例可用 `cargo check -p example-stream-markdown` 检查构建,并用上面的命令手动体验。 ## GPUI Kit 中的流式处理实例 GPUI Kit 的[流式 Markdown 示例](https://github.com/longbridge/gpui-kit/blob/main/examples/stream-markdown/src/main.rs)使用两个由 View 持有的任务和一个 channel。后台生产者生成文本片段;前台接收者负责更新 Entity,检查 replay ID,再将有效片段送进 `TextViewState`。View 保存两个 `Task<()>` handle;关闭 View 会 drop 这些 handle,再次 replay 会替换生产者 handle。生产者的 async 块没有 `await`,因此一旦其同步循环进入 poll,drop handle 不能中途打断该次 poll。replay ID 会过滤旧生产者的片段,包括替换前已经排入队列的片段。 ```rust // From Example::new; keep the returned Task in _task. let _task = cx.spawn(async move |weak_self, cx| { while let Ok((replay_id, chunk)) = rx.recv().await { _ = weak_self.update(cx, |this, cx| { if replay_id != this.replay_id { return; } this.markdown_state.update(cx, |state, cx| { state.push_str(&chunk, cx); }); this.scroll_handle.scroll_to_bottom(); }); } }); // From Example::replay; replacing the field drops the old handle. self._update_task = cx.background_executor().spawn(async move { let chars: Vec = EXAMPLE.chars().collect(); while current < chars.len() { let chunk_size = (5 + rand::random::() % 15).min(chars.len() - current); let chunk: String = chars[current..current + chunk_size].iter().collect(); _ = tx.try_send((replay_id, chunk)); current += chunk_size; std::thread::sleep(std::time::Duration::from_millis(50)); } }); ``` 摘录展示所有权和更新位置;初始化与渲染见链接中的完整源码。示例在后台生产任务中用 `std::thread::sleep` 模拟节奏,使用**无界 channel** 的 `try_send` 仅供演示。无界 channel 不提供背压;生产速度超过 UI 处理速度时,队列可能不断增长。替换任务后,生产者仍可能继续发送,直到同步循环返回;replay ID 防止旧片段进入 UI。真实流应异步等待,使用有界 channel 提供背压,处理发送和更新失败,并在 View 消失时结束接收任务。`WeakEntity` 保护 View 生命周期,channel 连接两个执行器。Entity 的所有权和更新方式见 [Entity](./entity)。 [Entity]: ./entity.md --- # Mobile Source: /versions/v0.7.1/zh-CN/docs/mobile 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/mobile 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 # 移动端 **当前定位** GPUI Kit 的首要目标仍然是桌面环境。引入移动端支持,是为了让部分组件能够在 iOS 与 Android 应用中复用,例如在原生界面里用 TextView 原生渲染富文本内容。GPUI Kit 目前没有计划像 Flutter 那样把移动端作为主要目标,也不打算成为完整的移动应用框架。 移动端支持基于 [gpui-mobile](https://github.com/itsbalamurali/gpui-mobile),由 [itsbalamurali](https://github.com/itsbalamurali) 创建并与社区共同开发。原始移动平台的成果归功于该项目的作者和贡献者。移动平台负责 [Window](./window)、触摸输入、[TextSystem](./text-system) 和 GPU 渲染表面,GPUI 与 GPUI Kit 继续管理 Rust 视图树和组件。 GPUI Kit 目前使用 `gpui-pre-mobile`,这是在[兼容性 fork](https://github.com/longbridge/gpui-mobile) 中维护的临时兼容包。它基于原项目进行打包适配,用于配合 `gpui-pre` 发布 crate,并持续跟进最新的 GPUI 版本、保持集成兼容。待社区 `gpui-mobile` 完成接入、GPUI 也发布 crate 后,我们计划将本文及相关依赖更新为社区的 `gpui-mobile`。 目前该集成仍处于实验阶段。Swift 托管的 iOS 示例已在 iOS 模拟器中构建并运行。在本文之外,GPUI Kit 已在 iOS 与 Android 上完成了有限范围的验证:一个 AI Chat 区域在两个平台上都通过了功能与性能测试,其中使用了 TextView、Button、Menu、Popover、Scrollbar、Input、Textarea 与文本选择,相关修复已合入 GPUI Kit。该场景完整覆盖了 TextView;其他组件与完整应用布局尚未在移动端验证。兼容性 fork 中的 Android Activity 示例使用不同的宿主路径,本文不涉及。下文的 iOS 模拟器路径是本文说明的目标,不能据此推断移动平台均已获得支持。 在 iOS 与 Android 上,原生 UI 与 GPUI 都可以共处同一个界面。用户期望具有平台原生行为的部分,例如 NavigationBar 和底部输入框,由原生 UI 实现;GPUI 作为其中的一个 View 渲染在两者之间。双方各自负责自己的布局与输入,宿主像摆放其他原生 View 一样摆放 GPUI View。 ## 运行 iOS 示例 从兼容 fork 中的 [Swift 容器示例](https://github.com/longbridge/gpui-mobile/tree/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example) 开始。它使用 `Message`、`Bubble`、`TextView`、`Input`、思考摘要和复制操作组成聊天界面。回复来自本地示例数据,没有接入 AI 服务。 在 Apple Silicon Mac 上安装 Xcode、iOS 模拟器运行时、Rust 和 XcodeGen: ```sh brew install xcodegen rustup target add aarch64-apple-ios-sim git clone https://github.com/longbridge/gpui-mobile.git cd gpui-mobile git checkout 0b882efdac7f524e0bb0b1d4c886b2aa752f9f20 cd example ./build.sh ios --simulator ``` 脚本会构建 Rust 静态库、生成 Xcode 工程,并在模拟器中安装和启动应用。添加 `--no-run` 可以只构建,添加 `--release` 可构建 release 版本。此固定提交的脚本按名称选择运行 iOS 18.6 的 `iPhone 16 Pro` 作为构建目标;如果本机没有对应运行时或设备,请把脚本中的 Xcode destination 改为本机已安装的目标。示例的最低部署版本为 iOS 16;这项配置不代表所有支持的系统版本都经过测试。 还要单独核对**安装与启动**使用的模拟器。[固定版本的构建脚本](https://github.com/longbridge/gpui-mobile/blob/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example/build.sh)在 `build_ios` 中设置 Xcode destination,但随后 `_ios_run_simulator` 会从 `xcrun simctl list devices available` 中选择第一个可用的 iPhone。安装了多个模拟器时,只修改构建目标可能导致应用在另一台模拟器上启动。运行 `xcrun simctl list devices available`,对照列表中的首个 iPhone 与构建目标;若两者不同,请把脚本中的 `sim_id` 选择改为目标模拟器的 UUID,再运行 `./build.sh ios --simulator`。 真机开发还需要安装 `aarch64-apple-ios` Rust target,并在 `example/ios/project.yml` 中设置自己的开发团队和签名信息。修改后重新生成工程。模拟器运行结果不能代替真机性能测试或发布验证。 ## 依赖配置 `gpui-pre-mobile` 是 Cargo 包名,Rust 库名为 `gpui_mobile`。评估阶段使用 Git 依赖;清单中的 `0.1.0` 版本号不代表已经发布到 crates.io。 ```toml [lib] crate-type = ["staticlib", "rlib"] [dependencies] gpui-mobile = { package = "gpui-pre-mobile", git = "https://github.com/longbridge/gpui-mobile", rev = "0b882efdac7f524e0bb0b1d4c886b2aa752f9f20" } gpui = { package = "gpui-pre", version = "=0.3.4", default-features = false } gpui-kit = { git = "https://github.com/longbridge/gpui-kit", rev = "7d9efcd2069f9eaa6eb3ba6345aac4aa7d87c9f7", default-features = false, features = ["component"] } ``` 这些提交固定了示例的依赖基线。Kit 提交包含移动平台条件编译支持,但尚未包含移动端 tooltip 禁用逻辑。当前 GPUI Kit 工作树使用 `gpui-pre 0.3.8`,而固定的移动平台及渲染器使用 `0.3.4`。Cargo 可能同时选出两个版本,导致 GPUI 类型不兼容;**只把 Kit 依赖替换为本地路径并不能完成升级**。需要先将移动平台及渲染器更新到与 Kit 相同的 GPUI 版本,并验证这一组合,之后才可使用如下路径依赖: ```toml gpui-kit = { path = "../gpui-kit/crates/kit", default-features = false, features = ["component"] } ``` 路径相对于应用的 Cargo 清单,请按实际目录调整。GPUI 核心、渲染器、平台和 Kit 必须使用相容的同一版本。 与桌面端[快速开始](/versions/v0.7.1/zh-CN/docs/getting-started)不同,移动端不使用 `gpui_kit::application()` 或 `gpui_kit::platform`。这些桌面平台导出在 iOS 和 Android 上被排除。移动宿主负责初始化 GPUI、调用 `gpui_kit::init(cx)`,并在应用内容外挂载一个 `component::Root`。 ## 嵌入 UIKit 视图 UIKit 管理原生窗口、导航、安全区域和键盘布局。示例中的 `GPUITextView` 是一个 Swift `UIView` 包装器,内部托管 GPUI 平台的子 `UIViewController`。虽然名字叫 `GPUITextView`,它承载的是完整的 Rust 聊天视图,而不只是一个 `TextView` 元素。 集成时请一起参考以下文件: | 文件 | 职责 | | --- | --- | | [App.swift](https://github.com/longbridge/gpui-mobile/blob/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example/ios/App.swift) | 原生窗口、视图包装、子控制器容纳、布局与帧调度 | | [Embedding.h](https://github.com/longbridge/gpui-mobile/blob/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example/ios/Embedding.h) | Swift 调用 Rust 所需的桥接声明 | | [src/lib.rs](https://github.com/longbridge/gpui-mobile/blob/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example/src/lib.rs) | 应用回调、Kit 初始化与 Rust 根视图 | | [project.yml](https://github.com/longbridge/gpui-mobile/blob/0b882efdac7f524e0bb0b1d4c886b2aa752f9f20/example/ios/project.yml) | Rust 构建步骤、静态库链接、系统框架与桥接头文件 | 启动顺序如下: 1. 在创建 GPUI 应用前调用 `gpui_ios_set_embedded()`,避免平台再创建一个原生窗口。 2. 调用示例定义的 `gpui_ios_register_app()`。它通过 `gpui_mobile::ios::ffi::set_app_callback` 注册 Rust 回调,在回调中初始化 Kit 并打开 GPUI 根视图。 3. 调用 `gpui_ios_run_demo()` 启动嵌入式应用,然后通过 `gpui_ios_get_window()` 和 `gpui_ios_view_controller()` 获取窗口与子控制器。 4. 按 UIKit 的容纳规则调用 `addChild`、添加子视图,再调用 `didMove(toParent:)`。 `gpui_ios_register_app()` 属于示例,不是平台库提供的函数。请修改它的回调来创建自己的 Rust 视图。`run_demo` 是当前桥接入口的名称,实际执行的是已注册的应用回调。 将示例的 `GPUITextView` 包装器加入项目后,原生控制器可以像布局其他视图一样设置约束: ```swift let content = GPUITextView(frame: .zero) content.translatesAutoresizingMaskIntoConstraints = false view.addSubview(content) content.attach(to: self) NSLayoutConstraint.activate([ content.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), content.leadingAnchor.constraint(equalTo: view.leadingAnchor), content.trailingAnchor.constraint(equalTo: view.trailingAnchor), content.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor), ]) ``` 这段代码依赖示例包装器,`GPUITextView` 并不是 SDK 提供的 UIKit 类。移植时应保留它的子控制器容纳和布局逻辑,以及配套声明和构建配置,而不只是复制约束。 ### 生命周期、尺寸与帧调度 平台持有 `Application::run_embedded` 返回的 `ApplicationHandle`,确保其生命周期覆盖回调和渲染视图。目前桥接支持一个随应用存活的 GPUI 视图,尚未提供独立销毁、多实例或集合视图单元格复用接口。 在 `layoutSubviews` 中,仅当非零边界尺寸发生变化时更新子控制器的 frame,调用 `gpui_ios_layout_view`,再请求渲染一帧。示例将这些操作放在禁用隐式动画的 Core Animation 事务内,使布局变化时 Metal 表面与 GPUI 视口保持同步。 宿主在界面可见时使用 `CADisplayLink` 驱动 `gpui_ios_request_frame`,在控制器消失时停止 display link。同时按 `App.swift` 转发应用激活与失活事件。UIKit 和桥接调用均应在主线程执行。 ### 输入、字体与资源 UIKit 决定嵌入视图的安全区域与键盘避让。GPUI 平台把原生触摸和文字输入转换为 Rust 窗口事件,Kit 则负责视图内部的组件交互。请在模拟器中检查焦点、选区、编辑、粘贴、滚动和屏幕键盘变化;构建成功不能证明所有输入法均正常工作。键盘出现时,`window.visual_viewport_bounds()` 可能变化,而布局视口保持不变。不要因同一次键盘变化同时手动缩小 UIKit 容器和 Rust 内容。 示例的嵌入式回调会初始化 Kit 并切换主题,但不会安装应用字体或图标 `AssetSource`。桌面端使用的字体在 iOS 上可能不存在,中文、其他 CJK 文字或 emoji 的字形覆盖也可能不同。如果应用依赖指定字体,请打包字体文件,在打开 GPUI 窗口前通过 `cx.text_system().add_fonts(...)` 注册,再在主题中选择对应字体。需要在目标设备上检查实际排版和缺失字形;参见[字体](/versions/v0.7.1/zh-CN/docs/fonts)及 [TextSystem](/versions/v0.7.1/zh-CN/docs/text-system)。 依赖示例只开启 Kit 的 `component` feature。如果应用使用命名的 Kit 图标,还需要开启 `assets` feature,并在创建第一个窗口前为移动 GPUI 应用注册合适的 `AssetSource`。原生嵌入资源与运行时文件路径对应不同的部署方式:外部图片文件必须打包到应用并使用正确路径;远程图片则需要 HTTP 客户端。不能假设桌面端的文件路径在 iOS 沙盒中也存在。参见[图标与资源](/versions/v0.7.1/zh-CN/docs/assets)。 ## 平台判断 `gpui_kit::is_mobile()` 是带 `#[inline]` 的 `const fn`,在 iOS 和 Android 目标上返回 `true`。它判断编译目标,不判断窗口宽度或是否连接鼠标。 ```rust if gpui_kit::is_mobile() { // Use interactions designed for touch input. } ``` ## 移动界面设计 可以与桌面端共享组件行为和内容,但应针对触摸操作与窄屏调整界面: - 由原生容器处理导航、安全区域和键盘避让。不要在 Rust 内容中重复添加标题栏或安全区域内边距。 - 一段对话只由一个容器负责纵向滚动。位于该容器内的 `TextView` 使用 `.w_full().min_w_0().scrollable(false)`,使文字与图片适应可用宽度。 - 输入为空时保持紧凑。如果不需要多行输入,就使用单行输入框,并确保键盘不会遮挡发送操作。 - iOS 和 Android 上点击 HoverCard 的触发元素切换开关,点击外部关闭;移动手指不会打开卡片。 - 在 `Input` 或 `Textarea` 中长按或双击可选中单词,并显示触摸拖动手柄与编辑菜单。对于可选择的只读 `TextView` 内容,请使用长按;触摸双击在这里不会选中单词。这些是 Kit 的交互路径,仍需通过移动平台的事件转换进行实机验证。 - 让操作可以通过触摸发现。复制按钮与回复正文对齐,复制成功后短暂显示对勾,不依赖悬停提示解释操作。 - 使用短段落和有意义的标题。代码、表格和图片应服务于对话,不必在每条回复中罗列所有 Markdown 格式。 - 一致使用 Kit 的主题颜色、字号和间距。在真实设备宽度下检查长回复、宽代码、图片加载和中文等不同文字。 GPUI Base 在 iOS 和 Android 上禁用其 tooltip overlay。这只覆盖通过该 overlay 显示的 Kit 提示,不影响直接调用 GPUI `.tooltip()` 的代码。上述固定依赖基线尚不包含这一修改。移动视图中不要添加 GPUI 悬停提示。 ## 验证与当前限制 集成到应用后,应检查启动和后台恢复、键盘显示与隐藏、视口尺寸变化、文本选择与复制、滚动及触摸反馈。除了 Rust 编译检查,也应观察实际渲染界面。 在做出性能结论前,使用实体设备、Release 构建和 Xcode Instruments 测量。模拟器适合验证布局与交互,但它的结果不是设备帧耗时。 Android 使用独立的 Activity 与渲染表面生命周期。仓库包含 Android 示例。GPUI 可以作为 Android `View` 嵌入原生布局(见上文),但本文只说明 iOS 的接入步骤。除上文已验证的 Chat 场景外,Android 上的 Kit 兼容性尚未确立。采用 Android 宿主路径前需要单独评估。 ## 排查问题 | 现象 | 检查项 | | --- | --- | | Xcode 找不到模拟器目标,或应用在另一台模拟器上启动 | 固定版本的 `build.sh` 默认面向 iOS 18.6 的 iPhone 16 Pro 构建。安装该运行时,或对照 `xcodebuild -showdestinations` 修改脚本中的 Xcode destination。随后运行 `xcrun simctl list devices available`:`_ios_run_simulator` 会独立选择第一个可用的 iPhone 安装应用。若它不是构建目标,请把 `sim_id` 选择改为目标模拟器的 UUID。 | | 真机构建签名或安装失败 | 替换 `example/ios/project.yml` 中的示例开发团队,重新生成 Xcode 工程,并确认 Xcode 能识别设备。脚本默认面向真机;使用本文路径时要显式传入 `--simulator`。 | | Rust 出现两个 GPUI 版本,或 `App`、`Window` 类型不匹配 | 检查解析出的 `gpui-pre` 包。固定的移动 fork 使用 `0.3.4`,当前工作树使用 `0.3.8`;使用本地 Kit 路径前须统一整套移动平台与 Kit 依赖。 | | 应用启动后 GPUI 区域空白或画面停滞 | 检查 Rust 回调是否打开窗口、子控制器是否已加入容器、`layoutSubviews` 是否传递非零尺寸,以及界面可见时是否持续请求帧。查看 Xcode 控制台;示例将 Rust 日志与 panic 输出到 `NSLog`。 | | 文字、图标或图片缺失 | 核对字体及字形覆盖、已注册的 `AssetSource` 与图标键名,以及图片的打包路径或 HTTP 客户端。桌面端的字体与资源配置不会自动进入移动宿主。 | --- # Element Source: /versions/v0.7.1/zh-CN/docs/element 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/element 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 **Element** 是 GPUI 为当前一帧构建的 Element 树节点。Element 负责布局、准备命中测试,并把像素[绘制](./paint)到 Window。下一帧开始前,GPUI 会释放整棵 Element 树及其中注册的帧级 callback,再根据应用的最新状态重新构建。 大多数应用代码只需要组合 GPUI 和 GPUI Kit 提供的 Element: ```rust div() .flex() .items_center() .gap_2() .child(Icon::new(IconName::Search)) .child("Search") ``` 这段代码构建了一棵 Element 树,并没有实现 GPUI 底层的 `Element` trait。 ## `Element`、`IntoElement` 与 `AnyElement` 它们承担不同的职责: | 类型 | 作用 | | --- | --- | | `Element` | 实现底层的布局与绘制生命周期。 | | `IntoElement` | 把值转换为具体的 `Element`,因此字符串、组件、Entity 等值都可以传给 `.child(...)`。 | | `AnyElement` | 擦除具体 Element 类型,适合异构集合、不同类型的条件分支、slot 和需要存储的子元素。 | 如果所有分支的类型相同,保留具体类型即可。只有边界需要容纳不同 Element 类型时才做类型擦除: ```rust fn status_icon(online: bool) -> AnyElement { if online { Icon::new(IconName::CircleCheck).into_any_element() } else { div().child("Offline").into_any_element() } } ``` GPUI Kit 会在可选 slot、表格单元格以及返回不同 UI 类型的函数中这样使用 `AnyElement`。如果调用方不需要类型擦除,通常用 `IntoElement` 作为 API 边界。 ## 三个阶段 GPUI 按顺序调用 `Element` 的三个 trait 方法。第一与第二次调用之间,布局引擎还会求出最终位置:
Render::render 构建当前帧的 Element 树
  1. 01request_layout

    注册 Style 和子布局;返回 LayoutId 与 RequestLayoutState。

  2. 02prepaint

    使用最终 bounds 准备几何和 hitbox;返回 PrepaintState。

  3. 03paint

    绘制已准备好的内容,并注册当前帧的输入 listener。

Taffy 在 request_layout 与 prepaint 之间计算 Bounds<Pixels>。
下一帧前,Element 树和帧级 listener 会被释放。`RequestLayoutState` 与 `PrepaintState` 只在这一轮调用中向后传递,并非长期缓存。 ### 如何判断代码放在哪个阶段 先看这段代码需要什么信息,以及它要产生什么结果: | 工作 | 所属阶段 | 原因 | | --- | --- | --- | | 决定宽高、padding、子元素布局;当内容尺寸影响布局时测量内容 | `request_layout` | 布局引擎必须先拿到这些输入,才能求出 bounds。测量闭包可以使用提供的可用宽度,但此时仍不知道 Element 的最终位置。 | | 根据最终 bounds 计算文字行、Path、裁剪区域或 hitbox;决定虚拟列表中哪些子元素可见 | `prepaint` | 这是首次拿到最终像素矩形的阶段。把绘制或输入处理还要用到的结果存入 `PrepaintState`。 | | 提交 quad、字形、Path 或图片;注册当前帧的指针、滚轮或键盘 listener | `paint` | 此时 Scene 和派发树已经可以接收绘制命令与 listener,应复用准备好的几何结果。 | 例如,一个可点击的图表先请求图表及子元素的布局。尺寸确定后,再把数据点映射到像素坐标,并为可交互区域插入 hitbox。最后根据这些坐标画出 Path,并注册指针 listener。如果标签宽度会**影响图表自身的尺寸**,就在 `request_layout` 测量;如果只需确定标签在**已定尺寸的图表内部**放在哪里,就在 `prepaint` 准备。 可以用一个问题判断边界:**这项计算会改变 Element 请求的尺寸吗?**会,就放在 layout 或其测量闭包;只依赖最终 bounds,就放在 `prepaint`;只改变像素或本帧 handler、不改变几何,就放在 `paint`。长期模型数据和订阅不属于这三个阶段,应由 [Entity](./entity) 持有。 ### `request_layout` 通过 `window.request_layout` 注册 Element 的 [Style](./style) 和子布局节点。GPUI 使用 Taffy 布局引擎,在所有布局请求完成后计算尺寸和位置。 这个阶段返回 `LayoutId`,以及后续阶段需要的 `RequestLayoutState`。此时不要假设最终 `Bounds` 已经确定。 ### `prepaint` 此时 GPUI 会传入布局完成后的 `Bounds`。在这里进行文字 shaping、几何计算、插入 hitbox、对子元素执行 prepaint,并准备 `paint` 所需的数据。 这些数据通过 `PrepaintState` 返回。命中测试应在这里准备,因为 GPUI 需要在绘制前建立当前帧的空间信息和派发信息。 ### `paint` 使用前面准备好的状态绘制 quad、文字、路径或图片;自定义 Element 需要输入处理时,也在这个阶段注册帧级 handler。这里应复用前面算好的几何信息,不要再次进行布局计算。 `RequestLayoutState` 会传给 `prepaint` 和 `paint`;`PrepaintState` 会传给 `paint`。长期存在的应用状态应放在 [Entity](./entity) 中;少量需要跨帧保存的 Element 状态可以通过 [ElementId](./element_id) 建立关联。 ### 从一个完整的底层 Element 入手 以下不可见的输入区域占满父节点。它刻意只插入 hitbox,不注册事件:hitbox 是输入命中的几何信息,本身并不是 click callback。 ```rust use gpui_kit::*; struct EventSurface; impl IntoElement for EventSurface { type Element = Self; fn into_element(self) -> Self::Element { self } } impl Element for EventSurface { type RequestLayoutState = (); type PrepaintState = Hitbox; fn id(&self) -> Option { None } fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { None } fn request_layout( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, window: &mut Window, cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { let mut style = Style::default(); style.size.width = relative(1.).into(); style.size.height = relative(1.).into(); (window.request_layout(style, None, cx), ()) } fn prepaint( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, bounds: Bounds, _: &mut Self::RequestLayoutState, window: &mut Window, _: &mut App, ) -> Self::PrepaintState { window.insert_hitbox(bounds, HitboxBehavior::Normal) } fn paint( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, _: Bounds, _: &mut Self::RequestLayoutState, _: &mut Self::PrepaintState, _: &mut Window, _: &mut App, ) {} } ``` `request_layout` 先将 `Style` 提交给 Taffy;有子元素时,先取得子元素的 `LayoutId`,再传给 `window.request_layout`。需要按内容测量大小时,可使用 `window.request_measured_layout`。`prepaint` 拿到最终 bounds,插入 hitbox,并把返回的 `Hitbox` 交给 `paint` 使用。`paint` 再注册当前帧的输入 listener 或提交绘制命令。[GPUI Kit 的 CarouselScrollMask](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/carousel/scroll_mask.rs) 正是从这种结构出发,利用 `Hitbox::should_handle_scroll` 判断滚动手势是否被前面的区域挡住。 `HitboxBehavior::Normal` 参与命中但不遮挡后方 hitbox;`BlockMouse` 遮挡后方鼠标及滚动处理,`BlockMouseExceptScroll` 保留后方滚动。绘制、裁剪和命中是三件事:画出形状不会自动建立 hitbox,插入 hitbox 也不会自动画出形状。 ### 让输入区域可见,并响应按下事件 第一个例子只是 trait 骨架:把它作为子元素挂载后,仍然看不到任何像素。要运行接下来的完整示例,请用下面的代码替换现有 [`examples/hello_world/src/main.rs`](https://github.com/longbridge/gpui-kit/blob/main/examples/hello_world/src/main.rs) 的内容。父元素给输入区域明确的 `240 × 96` 像素空间;区域请求的相对宽高才有了可参照的尺寸。整个练习沿用仓库现有示例,无须增加依赖。 ```rust use gpui_kit::component::ActiveTheme as _; use gpui_kit::*; struct SurfaceDemo { presses: usize, } impl Render for SurfaceDemo { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .flex() .flex_col() .gap_2() .child( div() .w(px(240.)) .h(px(96.)) .child(EventSurface { owner: cx.entity().clone(), color: cx.theme().primary, }), ) .child(format!("Pointer presses: {}", self.presses)) } } struct EventSurface { owner: Entity, color: Hsla, } impl IntoElement for EventSurface { type Element = Self; fn into_element(self) -> Self::Element { self } } impl Element for EventSurface { type RequestLayoutState = (); type PrepaintState = Hitbox; fn id(&self) -> Option { None } fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { None } fn request_layout( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, window: &mut Window, cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { let mut style = Style::default(); style.size.width = relative(1.).into(); style.size.height = relative(1.).into(); (window.request_layout(style, None, cx), ()) } fn prepaint( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, bounds: Bounds, _: &mut Self::RequestLayoutState, window: &mut Window, _: &mut App, ) -> Self::PrepaintState { window.insert_hitbox(bounds, HitboxBehavior::Normal) } fn paint( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, bounds: Bounds, _: &mut Self::RequestLayoutState, hitbox: &mut Self::PrepaintState, window: &mut Window, _: &mut App, ) { window.paint_quad(fill(bounds, self.color)); let hitbox = hitbox.clone(); let owner = self.owner.clone(); window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { if phase.bubble() && event.button == MouseButton::Left && hitbox.is_hovered_at(event.position, window) { owner.update(cx, |view, cx| { view.presses += 1; cx.notify(); }); } }); } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| SurfaceDemo { presses: 0 }) }) .expect("failed to open window"); }); } ``` 在仓库根目录运行 `cargo run -p hello_world`。窗口会显示 `240 × 96` 的主题色矩形和 `Pointer presses: 0`;每次在矩形内按下鼠标左键,计数依次变成 `1`、`2`,按在矩形外则不会变化。这只是展示指针输入的教学示例;它没有键盘激活、Focus、光标样式或无障碍角色。应用中的常规操作控件应优先使用现成的 [Button](../component/button),只有确实需要控制底层生命周期时才采用这种写法。 可以按四步理解这个例子: 1. **挂载并确定尺寸。** 每次 View 渲染,`Render::render` 都创建一个新的 `EventSurface`。父元素给出确定的宽高,子元素在两个方向都请求 `relative(1.)`。如果父元素没有可用尺寸,这个百分比可能解析成零尺寸或意外的区域。 2. **准备输入几何。** `prepaint` 收到布局计算后的 `Bounds`,插入与矩形一致的 hitbox。`PrepaintState = Hitbox` 将它传给 `paint`;它属于当前帧的命中测试数据。 3. **绘制并注册 listener。** View 读取 `cx.theme().primary`,把这个 `Hsla` 传给 Element;`paint_quad(fill(bounds, self.color))` 再使用当前主题的颜色画出同一矩形。`on_mouse_event` 按绘制注册顺序把当前帧的 listener 加入窗口鼠标监听列表,并不会将其挂在当前 dispatch node 上;`&MouseDownEvent` 决定事件类型,冒泡阶段、鼠标左键和 hitbox 判断共同排除无关输入。闭包捕获克隆后的 handle,因为它需要在这次 `paint` 返回后继续存在。 4. **修改长期状态。** `Entity` 在 Element 树重建后仍然存在。`owner.update` 修改 `presses`,`cx.notify()` 使 View 再次渲染。下一次 `render` 会生成新的 `EventSurface` 与计数文字;两个阶段关联状态都不是应用计数器。 学习时可以在同一个文件中逐项改动,每次都重新运行 `cargo run -p hello_world`: 1. 注释掉 `window.paint_quad(...)`。矩形变得不可见,但原区域内的按下事件仍会增加计数。输入由 hitbox 而非像素决定。 2. 恢复绘制。要移除 hitbox,需**同时**把 `PrepaintState` 改为 `()`,并从 `paint` 中移除 listener。此时矩形可见,但不会响应输入。绘制本身不提供命中区域或回调。 3. 恢复完整示例,把父节点的 `.w(px(240.))` 改成 `.w(px(120.))`。可见区域和可点击区域会一起变窄,因为两者都使用布局求出的 bounds。 | 观察结果 | 排查位置 | | --- | --- | | 看不到彩色区域 | 父节点是否给出确定尺寸、`request_layout` 是否返回布局 ID,以及 `paint_quad` 是否还在。 | | 区域可见,但按下无效 | `prepaint` 是否插入相同 bounds 的 hitbox;`paint` 是否注册 `on_mouse_event`;listener 是否检查冒泡阶段和鼠标左键。还需检查前方 hitbox 是否使用 `BlockMouse` 或 `BlockMouseExceptScroll`;普通 `Normal` hitbox 本身不会阻断后方 hitbox。 | | 事件已触发,但文字不更新 | listener 是否更新 `Entity`,并在 update 闭包内调用 `cx.notify()`。 | 前两项改动是观察现象的实验,不是另两种完整实现;继续阅读前请恢复完整代码。 这个例子不需要 `ElementId`:计数由 Entity 持有,而 hitbox 和 listener 每帧重建。只有底层 Element 自身需要 keyed state 或无障碍 identity 时才加入稳定 ID。带子元素的区域还要把子布局 handle 放入 `RequestLayoutState`,在父元素 bounds 确定后 prepaint 子元素,再按顺序绘制;真实实现可参考 [TextView](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/text/text_view.rs)。可复用的输入原语还必须明确 Focus、键盘与无障碍契约;单靠鼠标 listener 不会自动具备这些能力。 ### 在自定义 Element 中组合一个子元素 上面的指针区域没有子布局需要传递。接下来的完整示例增加**一个**子元素。把以下代码放入现有 [`examples/hello_world/src/main.rs`](https://github.com/longbridge/gpui-kit/blob/main/examples/hello_world/src/main.rs),替换上一个练习的代码,然后在仓库根目录运行 `cargo run -p hello_world`。它继续使用原来的 package 和依赖。 ```rust use gpui_kit::component::ActiveTheme as _; use gpui_kit::*; struct ChildDemo; impl Render for ChildDemo { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div().flex().flex_col().items_start().gap_2().child( ChildFrame { child: Some(div() .w(px(160.)) .h(px(32.)) .bg(cx.theme().primary) .text_color(cx.theme().primary_foreground) .child("Child: 160 x 32") .into_any_element()), background: cx.theme().secondary, accent: cx.theme().primary, }, ) } } struct ChildFrame { child: Option, background: Hsla, accent: Hsla, } impl IntoElement for ChildFrame { type Element = Self; fn into_element(self) -> Self::Element { self } } impl Element for ChildFrame { type RequestLayoutState = AnyElement; type PrepaintState = Bounds; fn id(&self) -> Option { None } fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { None } fn request_layout( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, window: &mut Window, cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { let mut child = self.child.take().expect("layout requested once per frame"); let child_layout = child.request_layout(window, cx); let mut style = Style::default(); style.padding = Edges::all(px(12.).into()); let layout = window.request_layout(style, [child_layout], cx); (layout, child) } fn prepaint( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, bounds: Bounds, child: &mut Self::RequestLayoutState, window: &mut Window, cx: &mut App, ) -> Self::PrepaintState { child.prepaint(window, cx); Bounds { origin: bounds.origin, size: size(px(4.), bounds.size.height), } } fn paint( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, bounds: Bounds, child: &mut Self::RequestLayoutState, accent_bounds: &mut Self::PrepaintState, window: &mut Window, cx: &mut App, ) { window.paint_quad(fill(bounds, self.background)); window.paint_quad(fill(*accent_bounds, self.accent)); child.paint(window, cx); } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| ChildDemo) }) .expect("failed to open window"); }); } ``` 窗口中会出现标有 `Child: 160 x 32` 的彩色子元素,四周留出 12 像素内边距,外框左侧还有一条窄色条。外框没有固定高度:Taffy 测量 32 像素高的子元素后,加上上下内边距。在这个不受约束的例子中,外框高度为 56 像素。把子元素的 `.h(px(32.))` 改为 `.h(px(64.))`,重新运行,外框就会增高到 88 像素。如果祖先节点限制高度,结果也会受该限制影响。 沿着所有权与阶段的顺序阅读代码: 1. `Render::render` 创建含有一个 `AnyElement` 的 `ChildFrame`。`Option` 允许 `request_layout` 把子元素移入返回的 `RequestLayoutState`;该 Element 在一帧内只请求一次布局。下一次渲染会创建新的 `ChildFrame`。 2. 子元素先请求自己的 `LayoutId`。父元素再把它传给 `window.request_layout(style, [child_layout], cx)`,从而在布局树中连接这两个节点。若传入空的子节点列表,父元素测量和定位时就不会考虑这个彩色子元素。 3. Taffy 完成布局后,`prepaint` 调用 `child.prepaint(window, cx)`,让子元素获得求出的最终位置,并准备自己的 hitbox 或几何。父元素同时根据自身 bounds 计算一条 4 像素宽的色条,把该矩形作为 `PrepaintState` 返回。 4. `paint` 先画外框背景和色条,再调用 `child.paint(window, cx)`,让子元素显示在背景上方。`AnyElement` 保存其内部阶段状态,但父元素仍须依序调用它的各阶段。 外框不处理输入,因此没有自己的 hitbox。内置的子 `div` 负责绘制自己的背景和文字。任何一次绘制调用都无法补回缺失的子布局;布局、prepaint 与 paint 必须都包含它。 | 观察结果 | 排查位置 | | --- | --- | | 看到了外框,却没有子元素 | 是否把子布局 ID 传给 `window.request_layout`,并依次调用了子元素的 `prepaint` 和 `paint`。若外框背景盖住子元素,还要检查绘制顺序。 | | 子元素没有处于内边距以内 | 父元素的 style 是否包含 `Edges::all(px(12.).into())`,并使用返回的子布局 ID。不要在 `paint` 中手动偏移子元素;位置由 Taffy 计算。 | | 外框高度不随子元素改变 | 检查外框或祖先节点是否固定高度或裁剪内容,以及是否将子布局 ID 连接到父布局。 | 这个例子把子元素保存在 `RequestLayoutState` 中,因为后续两个阶段还要使用它;`PrepaintState` 只保存计算好的色条矩形。两者都不是持久化应用状态;这类数据应交给 [Entity](./entity)。 ### `GlobalElementId` 与跨帧状态 `Element::id()` 返回局部 `ElementId`。GPUI 将它与上层 keyed 元素的 ID 组合成 `GlobalElementId`,传入三个阶段。底层 Element 可以用它和 `window.with_element_state` 保留少量跨帧状态;CarouselScrollMask 就这样保存连续滚动信息。ID 在最近的 keyed ancestor 下必须唯一;可重排的项目应使用领域 ID,不能用列表下标。没有 ID 时三个阶段收到 `None`。应用数据和订阅依然应由 Entity 持有。 ### TextSystem 与低阶文字绘制 [`TextSystem`](./text-system) 负责字体查询、字形 shaping、度量和缓存。可通过 `cx.text_system()` 获取,Window 也有针对窗口的文字系统。文字宽度受到字体、字号、shaping 和可用宽度影响,因此复杂文本 Element 可能在布局阶段使用测量闭包,在 `prepaint` 算行、选区及 hitbox,在 `paint` 绘制准备好的文字。三个阶段要使用相同字体参数,否则光标和选区会错位。普通文字应直接使用现有文字 Element;只有选区、内联对象或编辑器等需求才需要下沉。[GPUI Base TextView](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/text/text_view.rs) 和 [GPUI Kit 输入框底层 Element](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/input/base/element.rs) 分别展示这条管线在富文本和编辑器中的应用。 ## 什么时候实现 `Element` 只有现有 Element 无法表达需求时才实现 `Element`,例如: - 需要进行文字 shaping,并绘制选区和光标的代码编辑器或文本输入框; - 具有自定义几何图形的图表或 canvas; - 自定义布局算法; - 需要直接控制布局、hitbox 与绘制的高性能底层原语。 GPUI Kit 的输入框使用自定义 `Element`,因为它需要对文字进行 shaping,注册输入 handler,并绘制选区和光标。GPUI 的 `Svg`、`Img`、列表和 canvas 也使用同一套生命周期。大部分应用 UI 则通过组合已有 Element 完成,只在不同分支返回不同类型时转换成 `AnyElement`。 编写可复用 UI 组件时,先使用 [`RenderOnce`](./render-once)。编写由 Entity 持有状态的 UI 时,使用 [`Render`](./render)。只有需要直接控制渲染管线时,才下沉到 `Element`。 ### GPUI Kit 源码中的选择 | 案例 | 为什么仅靠组合不够 | Element 负责什么 | | --- | --- | --- | | [Input](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/input/base/element.rs) | 光标、选区、折行和指针到文字位置的映射必须使用同一份文字度量结果。 | 文字布局、hitbox、输入处理和绘制顺序。 | | [TextView](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/text/text_view.rs) | 富文本和可选择范围的测量、裁剪都依赖最终 bounds。 | 文字布局状态、hitbox、选区表面与准备好的绘制数据。 | | [CarouselScrollMask](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/carousel/scroll_mask.rs) | 手势区域必须独立占据视口,不能跟着被滚动的内容移动。 | 占满视口的布局、hitbox、鼠标与滚轮派发;它本身不画任何东西。 | | [Plot 折线](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/plot/shape/line.rs) | 数据点需要直接构建并三角化 Path,但无须自定义布局和输入。 | 在上层图表中绘制 Path;折线本身不必实现完整 `Element`。 | 这些案例说明 `Element` 的价值在于控制某个阶段边界,并不等于“有自定义绘图就要实现 Element”。Carousel 表明 Element 可以什么也不画;Plot 则表明自定义绘图可以由 canvas 或上层 Element 完成,无须让每条曲线都是 Element。 ### 性能取决于各阶段的职责 **让高效路径成为 API 的自然用法。**GPUI 将 Element 分成布局、预绘制和绘制阶段,让作者在尺寸明确时测量、一次准备几何,并在绘制时复用。GPUI Kit 也按这个原则设计组件:可能在大量元素中重复的成本,应有明确的所有者和失效规则。目标是让普通组件用法在设计上避免重复工作,而不是要求每个调用者事后自行补上缓存。 每个需要绘制的帧都可能重建树并再次执行这些方法。`request_layout` 应集中处理 Style 与布局节点,除非固有尺寸的测量确实需要,否则不要在这里 shaping 文字或三角化路径。`prepaint` 计算一次几何,借 `PrepaintState` 交给 `paint`;不要在 paint 重算。对未变化的 Path,GPUI Kit Plot 用 keyed window state 和 shape key 保留三角化结果。订阅和长期应用数据属于 Entity,不属于帧级 associated state。最后,昂贵绘制之前要裁剪与剔除:TextView 和 Input 根据最终 bounds 计算可见内容,而不是盲目绘制整份文档。 ## GPUI 如何驱动 Element 底层调用约束由 `Drawable` wrapper 保证。它按 `Start → RequestLayout → LayoutComputed → Prepaint → Painted` 迁移,乱序调用就是错误。这解释了为什么 GPUI 会向后传递两类 associated state,而不是让 Element 在 paint 时重新发现全部数据;也解释了为什么不能在 `request_layout` 中调用绘制方法:Window 此时还没有进入绘制阶段。 ### 布局树、派发树与 Scene 是不同的结构 `request_layout` 向布局引擎提交节点并返回 `LayoutId`。Taffy 求出最终布局后,`prepaint` 得到像素 bounds。GPUI 在 prepaint 时给 Element 建立 dispatch node;hitbox 则单独记录,供命中测试使用。`paint` 会激活对应的 dispatch node,让在这条路径上注册的 Action 与键盘 listener 派发。`window.on_mouse_event` 注册的鼠标 listener 则进入当前帧的窗口列表:捕获阶段按注册顺序调用,冒泡阶段反向调用;handler 需检查相关 hitbox。绘制命令进入 Scene。调用 `paint_path` 只会改变 Scene,不会自动创建派发节点或 hitbox;反过来,Carousel 的 mask 建立输入区域和 listener,却不绘制可见图形。 这种分离让自定义 Element 只承担真正需要的工作。装饰图形可能只需 canvas 和 Scene 命令;可聚焦控件还需协调派发、hitbox、Focus 和无障碍。虚拟列表要决定测量和绘制哪些子元素,而不只是画一个巨大的矩形。 ### 身份是一条路径,而不是当前帧值的指针 `Element::id()` 返回 `ElementId` 时,GPUI 把它追加到当前 keyed ancestor 路径,生成传入三个阶段的 `GlobalElementId`。Rust Element 值在后续帧会重建,它的地址并非稳定身份。`window.with_element_state(global_id, ...)` 可以在连续绘制的帧之间传递少量有类型的状态;`window.use_keyed_state(key, cx, init)` 则在当前 Element 命名空间的 key 下创建 Entity,并观察它,使其变化可通知所属 View。这些机制都要求 key 稳定;项目可能移动时不能用列表下标。 ### 无障碍也依赖最终几何 无障碍功能启用时,同时具有 ID 和 `a11y_role()` 的 Element 可以建立 AccessKit 节点。GPUI 在 prepaint 根据最终布局设置节点 bounds,调用 `write_a11y_info`,并可以加入 synthetic children。单纯绘制像素不会提供角色、名称、值或操作。GPUI Kit 标准交互组件已提供相应语义;自定义底层控件需要自行定义这份契约。具体 API 见 [Accessibility](./accessibility)。 ## Identity 与交互 下面几个概念处在不同边界: - [`ElementId`](./element_id) 在所在的 keyed scope 内标识一个 Element。GPUI 使用它的全局形式把跨帧状态和 retained work 连接起来。 - 对 `InteractiveElement` 调用 `.id(...)` 会返回 `Stateful`。这个 wrapper 让需要稳定 Element identity 的 API 可以使用对应状态。 - `InteractiveElement` 提供 GPUI 的标准交互机制,包括 hitbox、鼠标 listener、Focus 跟踪、Key Context 与 Action handler。自定义 `Element` 不会自动获得这些能力。 `InteractiveElement` 要求实现者暴露 `Interactivity`,提供 `on_mouse_down`、`on_key_down`、`track_focus`、`key_context`、`on_action` 及 hover 样式等方法。调用 `.id(...)` 后获得 `Stateful`,它实现 `StatefulInteractiveElement`,提供依赖稳定身份的 role、无障碍名称、tooltip 和 click 等 API。`IntoElement` 只负责把值转成 Element,不会自动获得上述交互能力。普通组件通常使用 `#[derive(IntoElement)]` 与 `RenderOnce`;自行实现底层 `Element` 时,`IntoElement` 通常直接返回 `self`。 获得这些 API 有两条路径。组合普通交互区域时,直接使用已有元素: ```rust div().id("result-row") .aria_label("Search result") .on_click(|_, _, _| {}) ``` `.id(...)` 将 Rust 类型从 `Div` 变成 `Stateful
`,依赖身份的方法才随之可用。重复行应按业务对象生成各自的 ID,而不是共用这里的字面量。`on_click` 只注册回调,不会自动更新 Entity 状态。 若 GPUI Kit 组件本身也要公开同一套 fluent 方法,应把交互状态委托给内部基础元素。[Radio 的实现](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/radio.rs)采用如下模式(省略无关字段): ```rust impl InteractiveElement for Radio { fn interactivity(&mut self) -> &mut Interactivity { self.base.interactivity() } } impl StatefulInteractiveElement for Radio {} ``` `StatefulInteractiveElement` 是标记 trait:默认方法写入返回的 `Interactivity`;这个空实现本身并不会创建 ID 或 hitbox。`Radio::new(id)` 把稳定 ID 传给 Base 基础元素,由它渲染真正的交互区域。如果组件无法保证稳定身份和底层交互元素,就只应公开实际支持的方法。前面 `EventSurface` 那样的原始自定义 `Element` 不会自动获得这些 trait;要么明确提供并驱动 `Interactivity`,要么组合已有交互元素。 ## `canvas`:用两个回调进入绘制阶段 GPUI 的 `canvas(prepaint, paint)` 本身就是一个 `Element`。它根据自己的 `Styled` 属性请求布局,再用最终 bounds 调用一次 `FnOnce` prepaint callback。这个 callback 返回任意临时值 `T`,GPUI 随后把它传给 `FnOnce` paint callback。这是进入 Element 生命周期的轻量方式,不是 HTML canvas,也不是跨帧保留的绘图表面。 ```rust canvas( move |bounds, _, _| { let mut line = PathBuilder::stroke(px(1.)); line.move_to(bounds.origin); line.line_to(point(bounds.right(), bounds.top())); line.build().ok() }, move |_, path, window, _| { if let Some(path) = path { window.paint_path(path, color); } }, ) .w_full() .h(px(1.)) ``` 这里的 prepaint 结果是 `Option>`,只在当前帧存在。[GPUI Kit 虚线 Separator](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/separator.rs) 在 canvas 的最终 bounds 中绘制路径;[环形 Progress](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/progress/progress_circle.rs) 则把 prepaint 计算的半径交给 paint。在 `Render` 或 `RenderOnce` 中仅需添加一处绘图 callback 时使用它。`PathBuilder`、SVG Path 写法和路径缓存的例子见 [Paint](./paint)。`canvas` 自身没有子元素、hitbox、Focus 跟踪或稳定 Element ID;如果这些职责必须协同,应实现 `Element`,或在 canvas 外组合标准交互 Element。 如果 `div()` 等现有 Interactive Element 已能满足需要,直接组合它。自定义底层原语需要标准交互时,可以像 GPUI 内置 Element 一样,嵌入或委托给 GPUI 的 `Interactivity`。如果自行实现 hitbox 与输入事件注册,也要自行正确处理派发、裁剪、cursor 行为和无障碍信息。 `Element::id()` 返回 `ElementId` 不只是给像素加一个标签,它会建立跨帧的稳定 identity。ID 在最近的 keyed ancestor 中必须唯一;只有 Element 或附加行为确实需要 identity 时才添加 ID。 [Element]: https://docs.rs/gpui-pre/0.3.8/gpui/trait.Element.html [IntoElement]: https://docs.rs/gpui-pre/0.3.8/gpui/trait.IntoElement.html [AnyElement]: https://docs.rs/gpui-pre/0.3.8/gpui/struct.AnyElement.html --- # I18N Source: /versions/v0.7.1/zh-CN/docs/i18n 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/i18n 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 # 国际化 GPUI Component 的组件文案位于 `crates/component/locales/ui.yml`,其中有 `en`、`zh-CN`、`zh-HK`、`zh-TW`、`it` 等语言的条目;不同 key 的覆盖程度不一定相同。应用负责自己的文案、语言选择策略和数据格式化,也可以通过 [rust-i18n](https://github.com/longbridge/rust-i18n) 新增或覆盖组件翻译,无需复制整个内置文件。 此功能需要 `rust-i18n` 4.2 或更高版本。 ## 添加依赖 在持有 locale 文件的应用 crate 中添加 `rust-i18n`: ```toml [dependencies] gpui-kit = "0.7.1" rust-i18n = "4.2" ``` ## 创建应用 locale 文件 在应用 crate 中创建 `locales/ui.yml`,并将组件翻译放在 `gpui_component` namespace 下: ```yaml _version: 2 app: save: en: Save fr: Enregistrer gpui_component: Calendar: week.0: fr: Di month.January: fr: Janvier DatePicker: placeholder: fr: Sélectionner une date ``` 应用自己的 key 放在 `gpui_component` 之外。组件翻译的 namespace 必须是 `gpui_component`,它对应 Rust crate 名称 `gpui-component`,其中连字符转换为下划线。请从 [GPUI Component 内置 locale 文件](https://github.com/longbridge/gpui-kit/blob/main/crates/component/locales/ui.yml)逐字核对 key;例如 `month.January` 是 `Calendar` 下的一个完整 key。 ## 注册扩展 首先在应用的 crate root 初始化 locales: ```rust rust_i18n::i18n!("locales", fallback = "en"); ``` 然后在初始化 GPUI Component 之前注册扩展: ```rust app.run(move |cx| { use gpui_kit::component as gpui_component; rust_i18n::extend!(gpui_component); gpui_kit::init(cx); // Open a window and initialize the rest of the application. }); ``` 应用启动期间只需调用一次 `extend!`。[`crates/story/src/lib.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/story/src/lib.rs)使用了同样的别名和调用顺序。 ## 查找优先级 应用提供的翻译优先于组件的内置翻译: ```text Application locales (locales/ui.yml) │ │ key not found ▼ GPUI Component built-in locales ``` 这种 deep merge 行为意味着: - 新语言(例如 `fr`)只需提供应用需要的 key。 - locale 和 key 都相同时,应用提供的翻译会覆盖内置值。 - 应用未提供的 key 会继续使用组件内置值。 - GPUI Component 后续新增的翻译无需复制,也会自动可用。 例如,只定义 `gpui_component.Calendar.month.January.en` 会修改一月份的英文标签,其他英文日历标签仍然来自 GPUI Component。 ### 命名空间只对组件内部的查找生效 `extend!` 改变的是 GPUI Component 查找**自身** key 的方式,它不会让应用自己的 `t!` 调用能访问到内置翻译: ```rust // Inside GPUI Component: check the application first, then built-in locales. t!("Calendar.month.February") // -> "February" // In application code: only the application's locale files are searched. t!("gpui_component.Calendar.month.February") // -> returns the key if undefined ``` 组件文案应当通过渲染组件来呈现,不要自己去查这些 key。 应用文案可在应用 crate 中调用 `rust_i18n::t!("app.save")`。将整句放在同一个翻译 key 中,让每种语言自行决定语序和标点。组件占位符的翻译不会顺带翻译周围的应用标题、标签或菜单项。 ## 切换语言 `gpui-component` 已经再导出 locale 相关方法,应用无需直接依赖 `rust-i18n` 即可切换语言: ```rust use gpui_kit::component::{locale, set_locale}; set_locale("fr"); let current = locale(); ``` 组件随后会按照上述优先级,使用当前 locale 查找翻译文本。 当前 locale 是 GPUI 变更跟踪之外的全局状态,单独修改它不会触发重绘。如果语言选择器由某个 view 处理,切换后通知这个 view: ```rust use gpui_kit::component::set_locale; set_locale("fr"); cx.notify(); ``` `cx.notify()` 如何安排 View 更新,见 [Context](./context)。 如果应用级 action 在 `&mut App` 中切换语言,则刷新展示翻译内容的窗口: ```rust use gpui_kit::{App, component::set_locale}; fn select_locale(language: &str, cx: &mut App) { set_locale(language); cx.refresh_windows(); } ``` 其他已缓存的界面内容还需显式重建。例如 story 应用的 [`SelectLocale` 处理函数](https://github.com/longbridge/gpui-kit/blob/main/crates/story/src/app_menus.rs)切换语言后会重建原生菜单和窗口内菜单,使选中状态更新;这些状态并非实时读取翻译。Story 的菜单标签目前也是固定字符串,因此这个选择器展示的是组件翻译和选中状态,并不表示整个画廊都已翻译。 ## 不同文字系统的文本与布局 翻译改变字符串,文本系统随后解析字体、塑形字形并测量结果。中文等 CJK 文本或中英混排需要字体包含相应字形。字体族可加载,也可能缺少特定字形;回退字体还可能改变换行和控件高度。详见[字体](./fonts)和[文本系统](./text-system)。在每个目标平台上,以长译文、窄按钮、输入框占位符和应用缩放检查界面。自定义文本的宽度应以塑形后的布局测量,不能按字符数估算。 GPUI Kit 的这个 locale API 没有提供应用级 RTL 布局切换。选择阿拉伯语或希伯来语 locale 可以选出对应文案,但不会自动镜像导航、图标方向、对齐、焦点顺序,以及指针和键盘行为。应按目标语言逐个检查页面,并在目标平台测试双向文本和编辑操作。文字塑形与整个界面的镜像是两项不同的检查。 ## 在应用中格式化数据 这里的 `rust-i18n` 用于选择文案;`set_locale` 不会替应用数据格式化日期、数字、货币、单位或复数。根据应用需求选择支持 locale 的格式化工具,将完成的文本传给 view。将变量放进完整的翻译消息,使译者能够调整位置。不要把分别翻译的片段拼成句子,也不要假定 `format!("{value}")` 会采用当地的数字分隔符。日期选择器可以翻译自己的月份名称,而应用其他地方显示的日期仍需遵循应用的格式化策略。 ## 验证集成 仓库中的 story crate 提供可运行的扩展和法语覆盖。从仓库根目录运行 `cargo run -p gpui-component-story`,在 **Language** 菜单中选择 **Français**,再打开日历或日期选择器 story;月份名称和日期占位符会使用 [`crates/story/locales/ui.yml`](https://github.com/longbridge/gpui-kit/blob/main/crates/story/locales/ui.yml) 中的 `fr` 值。切回英语并检查相同控件;菜单选中标记应跟随选择。还可运行 `cargo test -p gpui-component-story --features test-support extends_component_translations_with_story_locales`,验证 story 的翻译叠加测试。 ## 常见问题 | 现象 | 检查项 | | --- | --- | | 组件仍显示旧语言 | 调用 `set_locale` 后通知所属 view 或刷新窗口;重建缓存的菜单或标签。 | | 覆盖没有生效 | key 应放在 `gpui_component` 下,拼写和 locale 代码须与内置文件一致,并在 `gpui_kit::init` 前注册 `extend!`。 | | 应用中的 `t!` 返回 key | 在应用 locale 文件中定义该 key;组件内置文案供组件查找,不供应用直接查找。 | | CJK 出现方框或布局变化 | 检查目标平台的字体字形覆盖和回退;见[字体](./fonts)。 | | RTL 文案已出现但界面仍不顺手 | 逐项检查布局和交互;切换 locale 不会镜像整个界面。 | --- # ElementId Source: /versions/v0.7.1/zh-CN/docs/element_id 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/element_id 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `ElementId` 是 GPUI 渲染树中元素的**局部 key**。GPUI 将它与带 key 的祖先元素 ID 组合,形成 `GlobalElementId`。凭借这条路径,[View 再次 render](./render) 时,GPUI 能把交互和元素状态关联到同一个逻辑元素。当元素有 role 时,这条路径也让它在[无障碍树](./accessibility)中保持节点身份。 ID 不是 [Entity] 的 handle,也不能像 HTML DOM ID 一样用来查找元素。共享的应用状态用 `Entity`;元素树中的身份用 `ElementId`,例如组件内部按 key 保存的状态、组件提供的焦点或滚动行为,以及自定义 [Element] 保留的状态。 ## 赋予元素 ID 在 `div()` 等交互元素上调用 `.id(...)`,会得到一个 `Stateful
`: ```rust let save = div() .id("save-button") .on_click(|_, window, cx| { // Handle the click. }) .child("Save"); ``` `Stateful` wrapper 提供 GPUI 的有状态交互方法,并携带元素 ID。自定义 `Element` 可以在 `id()` 方法中返回 `Some(id)`,而不经过这个 wrapper。普通、没有 key 的 `div()` 仍可以作为布局父节点,只是不会在 ID 路径中增加一段。 字符串、整数,以及名称加整数的元组,都是常见的 `ElementId` 输入: ```rust use gpui_kit::component::button::Button; div().id("search") div().id(("message", message.id)) Button::new(("delete-project", project.id)).label("Delete") ``` key 应取自对象的身份,而非显示给用户的文字。翻译后的标签、当前选中项、每次新生成的随机值,都可能在对象仍然相同的时候改变。 ## GlobalElementId 与祖先路径 GPUI 从元素路径上的 ID 组成 `GlobalElementId`。只有带 key 的祖先才会增加路径段: ```text div().id("workspace") ├── div().id("inbox") │ └── div().id(("row", 42)) → ["workspace", "inbox", ("row", 42)] └── div().id("archive") └── div().id(("row", 42)) → ["workspace", "archive", ("row", 42)] ``` 两个 row 可以复用局部 ID,因为它们的带 key 祖先路径不同。图中只展示手写的 ID:由 Entity 支撑的 View 还会将其 `EntityId` 加入路径,[`RenderOnce`](./render-once) 组件则会加入类型名命名空间。这条路径属于 [Window](./window) 的渲染树;`GlobalElementId` 是 GPUI 的内部路径,无须在调用处自行构造,也不是整个进程通用的字符串。若自定义绘制 API 需要为自身的 key 取得路径,可用 `window.with_global_id(key, |global_id, window| { … })` 在回调期间创建。 实际的唯一性规则是:**在同一个最近的带 key 祖先之下,各条带 key 的后代分支需要不同的 ID**。没有 key 的容器不会开辟新命名空间: ```rust div().id("workspace") .child(div().child(div().id("item"))) .child(div().child(div().id("item"))) // Duplicate keyed path: IDs collide. ``` 可以给两条分支赋予各自稳定的 ID,或让两个 item 使用不同 ID。路径重复会使保留状态和交互关联到错误的逻辑元素。 ## 变化列表中的稳定 key 列表项可能插入、删除、过滤或重新排序时,每一行的 ID 应来自稳定的业务标识: ```rust div().id("messages").children(messages.iter().map(|message| { div() .id(("message", message.id)) .child(message.preview.clone()) })) ``` `("message", message.id)` 将 row 的用途与其他使用相同数字 ID 的控件区分开。调整顺序会改变绘制位置,但每条消息仍保持原来的 key 路径。相反,`.id(index)` 将状态绑定到**位置**:插入一行后,原来第一行的焦点、滚动、动画或其他带 key 状态,可能被另一个消息复用。只有位置本身就是身份且不会移动时,索引才合适。 如果重复的 row 中还有多个控件,先给 row 设置 key,再给子控件设置 `"edit"`、`"delete"` 等不同的局部 key。这样移动 row 时,它下面整棵带 key 的子树都会跟着移动。 ### 完整示例:消息行及其控件 下面使用实际的 `div().id(...)` 和 `Button::new(id)` API。`Message::id` 是稳定的数据库 ID;`preview` 是可能变化的显示内容。每一行都为自己的控件提供命名空间: ```rust use gpui_kit::*; use gpui_kit::component::button::Button; struct Message { id: u64, preview: SharedString, } fn message_list(messages: &[Message]) -> impl IntoElement { div().id("messages").children(messages.iter().map(|message| { div() .id(("message", message.id)) .child(message.preview.clone()) .child(Button::new("archive").label("Archive")) })) } ``` 消息 `42` 的按钮路径中,手写的部分是 `"messages" → ("message", 42) → "archive"`(GPUI 还可能加入 View 和组件的命名空间)。每行的按钮都能叫 `"archive"`,因为行 ID 不同。列表从 `[42, 7]` 重排为 `[7, 42]` 后,路径仍跟随对应的消息。如果消息 `42` 在某个已渲染帧中被移除,它的元素局部状态就会结束;以后重新插入会创建新状态。需要在移除期间保留的应用级选中项或草稿,应由 `Entity` 或模型持有。 行 key 不会自动区分**同一行内**的兄弟控件:该行下若有两个 `Button::new("archive")`,仍会发生冲突。应给它们不同的局部 ID。消息预览或本地化标签变化时,也应继续使用相同的业务 ID;把文字当成 key 会重置 UI 身份。 ### 动手练习:重排、隐藏与恢复行 在已有的 `examples/hello_world` 包中,把 `src/main.rs` 替换为下面的完整示例,运行 `cargo run -p hello_world`。先点两次 **Add to 42**,再点 **Swap rows**。记录 42 移到记录 7 下方后仍显示 `2`。点 **Hide 42**,确认该行已从画面消失,再点 **Show 42**。计数从 `0` 开始,因为这份 keyed state 曾缺席于一个已渲染帧。若计数必须在隐藏期间保留,应由应用拥有的 Entity 保存。 ```rust use gpui_kit::base::StyledExt; use gpui_kit::component::button::Button; use gpui_kit::*; struct Example { reversed: bool, show_42: bool, } impl Render for Example { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let order = if self.reversed { [7_u64, 42] } else { [42, 7] }; let rows = order .into_iter() .filter(|id| *id != 42 || self.show_42) .map(|id| { // This call happens while the parent View renders, before the row div is drawn. // Give the state its own domain-derived key; the row ID below keys its subtree. let count = window.use_keyed_state(("row-count", id), cx, |_, _| 0_u32); let value = *count.read(cx); div() .id(("row", id)) .h_flex() .gap_2() .child(format!("Record {id}: {value}")) .child( Button::new("increment") .label(format!("Add to {id}")) .on_click(move |_, _, cx| { count.update(cx, |value, cx| { *value += 1; cx.notify(); }); }), ) }) .collect::>(); div() .v_flex() .gap_2() .p_4() .child(Button::new("swap").label("Swap rows").on_click(cx.listener( |this, _, _, cx| { this.reversed = !this.reversed; cx.notify(); }, ))) .child( Button::new("toggle-42") .label(if self.show_42 { "Hide 42" } else { "Show 42" }) .on_click(cx.listener(|this, _, _, cx| { this.show_42 = !this.show_42; cx.notify(); })), ) .child(div().id("rows").v_flex().gap_2().children(rows)) } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| Example { reversed: false, show_42: true, }) }) .expect("failed to open window"); }); } ``` 行的 `("row", id)` 路径使控件在顺序变化后仍属于原记录。两个行里的 `Button::new("increment")` 可以复用局部名称,因为它们的行路径不同。计数器另用 `("row-count", id)` 作 key:它在父 View 构建行时取得,此时行的元素 ID 还没有进入路径。两个 key 都必须使用稳定的记录 ID。只有 GPUI 渲染过缺少该行的帧,隐藏才会使旧状态失效;如果在两次渲染之间连续触发隐藏和显示,旧状态仍可能保留。 ## ID 如何保留状态 GPUI 会在 render 时重新构建元素。`div()` 或 `RenderOnce` 组件返回的 Rust 值是临时的;赋予 ID 不会让它变成持久的 `Entity`。ID 的作用是让 GPUI 在连续帧之间重新关联元素局部状态。 `window.use_keyed_state(key, cx, init)` 使用当前带 key 的祖先路径加上 `key`。它返回 `Entity`;只要连续渲染帧都使用这个 key,状态就会保留。`Window` 管理这份 keyed state;`cx` 提供应用访问(不同类型的说明见 [Context](./context))。没有旧状态时才调用 `init`。GPUI 还会观察这份状态 Entity;状态变化时通知当前 View: ```rust let focus_handle = window .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle()) .read(cx) .clone(); ``` GPUI Kit 的 `Button` 用这种方式保存焦点 handle。它的公开 ID 让每次重新构建的按钮都能找到相同的状态 key。焦点行为仍由 focus handle 承担;元素 ID 决定这个 handle 的状态归属何处。只有路径在连续帧中被访问,窗口才会保留其 keyed state;[缓存 View](./view-cache) 重用子树时会重放这些访问。单独保存的强 `Entity` 句柄可以让该 Entity 在窗口状态条目消失后继续存活,但路径重新出现时仍会执行 `init`。 自定义 `Element` 的 `id()` 返回 ID 时,GPUI 会在 `request_layout`、`prepaint` 和 `paint` 中传入 `Option<&GlobalElementId>`。绘制期间,`window.with_element_state(global_id, ...)` 可以读取上一帧状态,并返回需要保存到下一帧的值: ```rust let state = window.with_element_state( id.expect("this element always has an ID"), |previous: Option, _window| { let state = previous.unwrap_or_default(); (state.clone(), state) }, ); ``` GPUI Kit 的 `ScrollBounce` 元素就采用这个模式,在 `prepaint` 中保留运动状态。`with_element_state` 是供元素作者在绘制阶段使用的 API;普通 View 应优先使用 Entity 状态或组件文档提供的 API。GPUI 按全局路径**以及状态类型**存储元素状态;元素不再参与后续渲染帧时,该状态会被释放。 `window.use_state(cx, init)` 使用调用位置作为局部 key,只要求**完整路径**唯一:如果每行已有稳定的带 key 祖先,同一个调用位置用于多行也是安全的。如果多次调用共享同一带 key 祖先路径,应改用带稳定条目 key 的 `use_keyed_state`,或为每个条目建立带 key 的命名空间。 ## 身份变化也会改变状态 - 改变元素 ID 或带 key 祖先的 ID,会生成新路径,并重置相应的元素状态。 - 移除元素会终止其连续帧状态的生命周期;以后重新创建时会再次初始化。 - ID 本身不会保留 View 的 `Entity`;其生命周期由强 Entity owner 决定。 - 带 key 的状态属于 Window 的渲染上下文。不要依靠 `GlobalElementId` 在不同窗口之间转移状态。 ## 对其他行为的影响 组件可能把 ID 用作焦点、滚动、测量或动画状态的一个输入。例如,Kit 的 `Button` 通过 `window.use_keyed_state(self.id.clone(), ...)` 取得 focus handle,而 `ScrollBounce` 用 `window.with_element_state(...)` 保留运动状态。因此,两个同时存在的控件复用同一路径可能混淆行为;路径变化可能使行为重新开始。`FocusHandle` 或 `ScrollHandle` 仍分别拥有对应的行为,改变 `ElementId` 本身也不会重置保存在别处的所有 handle。 稳定路径也关系到缓存 View。只有 Entity 和元素路径仍匹配,缓存的 Entity View 才能复用工作;命中缓存时,GPUI 会重放子树对元素状态的访问。ID 不是通用的 render 缓存:给 `div()` 加 `.id(...)` 不会让它的父级跳过 `render`(见 [View 缓存](./view-cache))。 对无障碍而言,自定义元素同时需要 ID 和 role 才能成为节点。稳定 ID 让 GPUI 在重绘后保持该节点的身份,但不会自动提供 role、标签、键盘行为或可聚焦能力。这些属性应使用组件的无障碍 API 设置(见[无障碍](./accessibility))。 ## 排查错误的 ID 路径 1. 找出焦点、滚动位置、动画或其他状态错误转移或重置的逻辑对象。记下它稳定的业务 ID,以及上方每一个带 key 的祖先。检查兄弟元素是否拥有相同的**完整路径**,或祖先 key 是否随数据重排而变化。 2. 检查重复控件之间是否只有不带 key 的容器;这样的容器无法区分路径。可以用稳定的对象 ID 替代共享的固定文字,或者给重复的父级设置 key。不要用每次 render 新生成的随机值解决冲突,那只会把状态混用变成状态丢失。 3. 如果状态只在条目消失后重置,确认它是否曾在某个已渲染帧中缺席。如果是,长期状态应保存在 Entity 或模型中。如果条目一直存在却重置,则分别检查祖先 ID 的变化、重新创建的 View Entity,以及缓存失效。 Kit 中有一个真实例子:[DockSkin::render_resize_handle](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/dock/dock.rs)。左、右、下三个 resize handle 可能位于同一个带 key 的祖先之下。若它们都使用 `"resize-handle"`,就会得到相同的 `GlobalElementId`;Dock 的源码指出,按住一个 handle 可能触发另一个 handle 的拖动。因此实现根据稳定的方位选择 `"resize-handle-left"`、`"resize-handle-right"` 和 `"resize-handle-bottom"`。 在 UI 集成测试里,ID 是已观测元素的查询选择器,不是第二套身份系统。导入 `gpui_kit::test::TestWindowExt`;`window.find("archive")` 要求只匹配一个已观测目标,因此重复控件需要原生路径作用域: ```rust let archive = window.within(("message", 42_u64)).find("archive"); assert!(archive.visible()); ``` 即使祖先本身没有被观测,`window.within(...)` 也能沿其带 key 的路径限定范围。`find` 或 `try_find` 报目标不唯一,意味着查询需要限定范围;不一定表示 GPUI 状态冲突,因为不同行下的控件可以正确地复用同一个局部 ID。除了检查快照,也应测试真实点击或焦点结果(见[测试](./test))。 布局、prepaint 和 paint 生命周期见 [Element](./element);需要在元素离开渲染树后继续存在的状态见 [Entity](./entity)。 [Element]: ./element.md [Entity]: ./entity.md --- # Entity Source: /versions/v0.7.1/zh-CN/docs/entity 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/entity 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 当一份状态需要由多个 View、handler 或异步任务共同使用时,把它放进 GPUI 提供的 [`Entity`][Entity]。例如,Chat 可以用 `Entity` 保存消息;任何持有其 clone 的代码都能通过 GPUI [Context](./context) 访问同一份 Chat。 使用 `cx.new` 创建 Entity,使用 `read` 读取状态,使用 `update` 修改状态。当 `Chat` 实现 [`Render`](./render) 时,`Entity` 还可以直接作为 View 渲染;不需要渲染时,它就是一个共享状态 model。 ```text Entity ├── read(cx) → &Chat ├── update(cx, …) → &mut Chat + Context └── downgrade() → WeakEntity ``` clone Entity 会复制句柄,不会复制其中的状态。GPUI 会以同步方式增加强句柄计数;它不会 clone `T` 或其内部集合。句柄 clone 比深度复制模型轻得多,但并非零成本,而且每个保留的副本都会延长 Entity 的生命周期。Entity 只能通过 GPUI Context 访问,因此 GPUI 可以统一协调状态更新、渲染、订阅和 Entity 生命周期。 ## 数据模型或持久 View `Entity` 是状态及身份的句柄;`T` **不需要**实现 `Render`。纯数据 Entity 可以保存模型或组件状态,供多个所有者读取或修改。若 `T` **实现了** `Render`,它的 `Entity` 还可以放进元素树,成为持久的 View。View 的 Entity 会继续存活,而 `render` 返回的元素会在绘制时重新构建。 ```text Entity (model; no Render) ↑ read/observe Entity(Render View) ↓ render Element tree ``` ```rust use gpui_kit::*; struct MessageStore { messages: Vec, } struct MessagePanel { store: Entity, _subscription: Subscription, } impl Render for MessagePanel { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let count = self.store.read(cx).messages.len(); div().child(format!("{count} messages")) } } // In a GPUI context outside render: let store = cx.new(|_| MessageStore { messages: vec![] }); let panel = cx.new(|cx| { let _subscription = cx.observe(&store, |_, _, cx| cx.notify()); MessagePanel { store: store.clone(), _subscription } }); store.update(cx, |store, cx| { store.messages.push("Hello".into()); cx.notify(); }); // Use `panel` as a child view; `store` is data, not an element. ``` 模型调用 `notify()` 后,保存的观察关系会通知 `MessagePanel`,后者再通知自己的 View。即使以后给该 View 加上缓存边界,这个显式连接也能保持内容更新。`RenderOnce` 是生成元素的另一条路径:它描述渲染时被消费的值类型组件,本身既不会创建也不要求 Entity。其生命周期见 [RenderOnce](./render-once)。 ### 动手练习:一个模型与一个观察它的 View 从仓库根目录开始,将 `examples/hello_world/src/main.rs` 暂时替换为下面的完整代码,然后运行 `cargo run -p hello_world`。练习复用现有示例包,无需添加依赖。如果还要保留原来的 Hello World 示例,请先保存原文件。 ```rust use gpui_kit::base::StyledExt; use gpui_kit::component::button::Button; use gpui_kit::*; struct MessageStore { count: usize, } struct MessagePanel { store: Entity, _subscription: Subscription, } impl MessagePanel { fn new(cx: &mut Context) -> Self { let store = cx.new(|_| MessageStore { count: 0 }); let _subscription = cx.observe(&store, |_, _, cx| cx.notify()); Self { store, _subscription, } } } impl Render for MessagePanel { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let count = self.store.read(cx).count; div() .v_flex() .gap_2() .size_full() .items_center() .justify_center() .child(format!("Messages: {count}")) .child( Button::new("add-message") .label("Add message") .on_click(cx.listener(|this, _, _, cx| { this.store.update(cx, |store, cx| { store.count += 1; cx.notify(); }); })), ) } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(MessagePanel::new) }) .expect("failed to open window"); }); } ``` 窗口最初显示 **Messages: 0**。每点击一次,handler 通过 `update` 修改模型 Entity;模型的 `notify()` 安排已保存的 observer 运行,observer 再对面板调用 `notify()`。下一次 render 从同一模型句柄读取计数,文字依次变成 **Messages: 1**、**Messages: 2**。模型没有实现 `Render`,也没有被放进 Element tree。面板长期持有模型句柄和 `Subscription`;若在 `render` 中重新创建它们,就会失去稳定的关系。 如果计数始终为零,检查两处通知调用及 `_subscription` 字段。删除模型的 `notify()`,observer 就收不到信号;若 `new` 结束时丢弃 subscription,观察关系会被取消。改动练习时,让 `store.update(...)` 留在点击 handler 中,让 `store.read(cx)` 留在 `render` 中,并避免在更新同一个 store 的过程中再次读取它。 ## 创建 Entity 可以在任意 GPUI context 中使用 `cx.new`: ```rs use gpui_kit::*; struct Chat { messages: Vec, } let chat: Entity = cx.new(|_cx| Chat { messages: Vec::new(), }); let same_chat = chat.clone(); assert_eq!(chat.entity_id(), same_chat.entity_id()); same_chat.update(cx, |chat, cx| { chat.messages.push("Hello".into()); cx.notify(); }); assert_eq!(chat.read(cx).messages.len(), 1); ``` 闭包会收到 [`Context`](./context),因此初始化时也可以创建子 Entity 或注册订阅。示例中的两个句柄指向同一份 `Chat`:经一个句柄修改,另一个也能读到。clone 句柄需要同步更新所有权计数,但无需复制模型及其消息。只有所有权或回调确实需要时才复制句柄,避免在高频循环里无故反复 clone。[`SharedString`](./shared-string) 也方便共享文本,但使用不同的存储机制。 当子 Entity 应该与 owner 同时存活时,owner 保存一个强引用 `Entity`: ```rs struct Workspace { chat: Entity, } impl Workspace { fn new(cx: &mut Context) -> Self { let chat = cx.new(|_cx| Chat { messages: Vec::new(), }); Self { chat } } } ``` GPUI Kit 广泛采用这种强所有权关系:父 View 持有它所渲染、协调的子 View 或 model。 ### 在功能边界间传递句柄 一个功能可以把业务数据保存在一个模型 Entity 中,再将 clone 后的 `Entity` 句柄交给面板、命令和任务。这些所有者引用的是**同一份状态**,不会各自获得一份大型消息列表或文档的副本。因此,有类型的 Entity 句柄适合在同一功能的模块间传递。clone 仍会更新 GPUI 的强句柄计数,并延长模型生命周期;反向引用或不应持有模型的工作应使用 `WeakEntity`。 文档、工作区等功能特有的状态,应由该功能自己的 Entity 持有。只有值或服务的范围确实覆盖整个应用时,才使用 [`Global`](./global),例如多个功能或窗口共同读取的偏好设置。一个类型的 Global 在应用中只有一个存储位置,不能把它当作传递逐文档 Entity 的捷径。若 Global 变化需要更新 View 输出,View 还需显式观察变化。 跨 crate 时,通过功能的公开边界暴露预期的句柄、命令或事件,不要让兄弟功能依赖其模型内部字段。只有能力具有清晰契约、且确有多个所有者时,才提取共享 crate。完整的功能与 crate 布局规则见 [Coding Guides](./coding-guides)。 ### 在 `render` 外保留组件状态 GPUI Kit 的有状态组件也遵循这一原则。输入框状态只构造一次,把 Entity 存在字段中;每次 render 再将句柄传给值类型的 `Input` 元素: ```rust use gpui_kit::*; use gpui_kit::component::input::{Input, InputState}; struct SearchPane { query: Entity, } impl SearchPane { fn new(window: &mut Window, cx: &mut Context) -> Self { let query = cx.new(|cx| InputState::new(window, cx)); Self { query } } } impl Render for SearchPane { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div().child(Input::new(&self.query)) } } ``` 如果在 `render` 里新建 `InputState`,View 重绘时就会重置草稿、选区及与焦点相关的行为。`Input` 这个值可以重建;它的状态 Entity 应继续存活。 ## 读取状态 直接同步访问状态时使用 `read`: ```rs let message_count = chat.read(cx).messages.len(); ``` 返回引用的生命周期受 `cx` 限制。需要长期使用某个值时,应复制或 clone 该值,不要保存这个引用。 当代码接收泛型 `AppContext`,或希望用闭包明确读取范围时,可以使用 `read_with`: ```rs let last_message = chat.read_with(cx, |chat, _cx| { chat.messages.last().cloned() }); ``` `read` 接收 `&App`(`Context` 可以解引用为 `App`);`read_with` 接收任意 `AppContext`,返回闭包的结果。它们都不会再复制一份 Entity。读取范围应尽量短:正在被 update 或 render 借用的 Entity,必须等本次访问结束后才能再次读取。不要把 `&T` 留到后续 update 或 `await` 之后;离开读取范围前先取出拥有所有权的值。 ## 更新状态 使用 `update` 获得可变状态和对应的 `Context`: ```rs chat.update(cx, |chat, cx| { chat.messages.push("Hello".into()); cx.notify(); }); ``` `cx.notify()` 表示这个 Entity 发生了变化,渲染过或正在观察它的 View 随后可以更新。仅修改字段不会自动产生通知;当新状态需要反映到观察者或界面上时,应调用它。 `update` 闭包可以返回结果。handler 可以先修改一个 Entity,结束对它的借用,再用返回值修改另一个,避免形成回调循环: ```rust let new_count = chat.update(cx, |chat, cx| { chat.messages.push("Hello".into()); cx.notify(); chat.messages.len() }); // Chat's update has ended; `new_count` is now a plain usize. ``` 始终使用 `update` 闭包传入的内部 `cx`。它是当前 Entity 的 `Context`。 同一个 Entity 正在 update 或 render 时,不要再次对**同一个** Entity 调用 `read` 或 `update`。GPUI 会阻止这种重入访问并 panic。在一个 update 内更新另一个 Entity 可以正常工作,但若观察者或回调又绕回第一个 Entity,同样会间接触发重入问题。应使用回调已经提供的 `&mut T`,把少量结果复制出来,或先结束第一次 update 再开始下一次访问。以所属 Entity 已被借用的状态运行的 deferred callback 同样遵循此规则。 ## 使用 WeakEntity 表示反向引用和 callback clone `Entity` 会创建另一个强句柄,并让 Entity 继续存活。若父 View 持有子 Entity,又把**强引用** `Entity` 交给子级保存,两边就会相互持有: ```text External owner → ParentView ──strong reference──→ ChildView ↑ │ └──────strong reference──────┘ ``` 外部 owner 被释放后,环中的强句柄仍然存在,所以两个 Entity 都不会释放。把父级句柄传给子级临时使用没有问题;当子级长期保存强句柄、父级又长期持有子级时才形成环。反向引用应保存为 [`WeakEntity`][WeakEntity]: ```rust use gpui_kit::*; struct ParentView { child: Option>, clicks: usize, } struct ChildView { parent: WeakEntity, } impl Render for ParentView { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div() .child(format!("Clicks: {}", self.clicks)) .children(self.child.clone()) } } impl Render for ChildView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div().child("Notify parent").on_click(cx.listener(|this, _, _, cx| { let Some(parent) = this.parent.upgrade() else { return; }; parent.update(cx, |parent, cx| { parent.clicks += 1; cx.notify(); }); })) } } // In a GPUI context outside render: let parent = cx.new(|_| ParentView { child: None, clicks: 0 }); let child = cx.new(|_| ChildView { parent: parent.downgrade() }); parent.update(cx, |parent, cx| { parent.child = Some(child); cx.notify(); }); ``` 现在父级拥有子级,但子级的反向引用不会让父级继续存活:`ParentView ──strong──→ ChildView ──weak──→ ParentView`。`upgrade()` 返回 `Option>`;父级已释放时,点击处理器直接返回。处理器在渲染之后运行,所以可以更新父级,而不会重入父级 `render` 时的借用。GPUI Kit 的嵌套弹出菜单也采用父级强持有子级、子级弱返指父级的所有权方向。 弱句柄可能比目标存活得更久。可以尝试 upgrade,或者使用它提供的可失败访问方法: ```rs let workspace = cx.weak_entity(); cx.spawn(async move |_, cx| { let conversations = load_conversations().await; workspace .update(cx, |workspace, cx| { workspace.set_conversations(conversations); cx.notify(); }) .ok(); }) .detach(); ``` `WeakEntity::upgrade` 返回 `Option>`;`read_with` 和 `update` 返回 `Result`,因为目标 Entity 可能已经释放。GPUI Kit 会在异步任务、callback、delegate 和父级引用中使用这一模式,避免这些关系意外延长 View 的生命周期。 `Context::spawn` 已经将 `WeakEntity` 作为异步闭包的第一个参数传入。若希望 owner 被释放时取消工作,就把返回的 [`Task`](./task) 存在所属 Entity 中;若工作应独立继续,就调用 `.detach()`。两种情况下,`await` 之后的弱引用更新失败都是正常的取消情形:等待期间 View 可能已关闭。 长期保存的闭包也可能闭合所有权环,但前提是其所有者链最终回到被强引用捕获的 Entity。GPUI 的 `cx.observe`、`cx.subscribe` 和 `cx.listener` 内部使用订阅者或 View 的弱句柄;仅仅保存它们返回的 `Subscription` 并不会自动形成强 Entity 环。额外用 `move` 捕获强 Entity 仍可能闭合这个环。`cx.processor` 与 `cx.listener` 不同:它会捕获当前 View 的强句柄,因此不要不加处理就把生成的闭包存回同一个 View。排查长期未释放的状态时,先检查强 Entity 环,以及生命周期脱离预期 owner 的订阅或任务;这些是常见的所有权线索,而非全部可能原因。 ## 观察变化与订阅 Event 一个 Entity 可以通过两种相关方式与另一个 Entity 协作: - `cx.observe(&entity, ...)` 在目标 Entity 调用 `cx.notify()` 时执行。只关心“状态变了”时使用。 - `cx.subscribe(&entity, ...)` 接收类型化 [Event]。需要知道变化的含义和数据时使用。 它们是两种独立的信号:`notify()` 不会发送 Event,`emit(event)` 本身也不会通知渲染者。某次状态变化如果既需要重绘,又需要语义事件,可以明确地各触发一次。观察者可以用收到的句柄读取目标 Entity,但仍须避免重入回调链中已被借用的 Entity。GPUI 通过 effect cycle 分发这些回调,此时当前 update 的借用已结束;不要假设回调会在 `update` 闭包内部执行。 应把 `observe` 或 `subscribe` 返回的 [`Subscription`](https://docs.rs/gpui-pre/0.3.8/gpui/struct.Subscription.html) 保存在发起订阅的 Entity 上,放在 `_subscription` 字段或 `_subscriptions: Vec` 字段中: ```rs enum ChatEvent { MessageSent, } impl EventEmitter for Chat {} impl Chat { fn send_message(&mut self, cx: &mut Context) { self.messages.push("Hello".into()); cx.notify(); cx.emit(ChatEvent::MessageSent); } } struct Workspace { chat: Entity, sent_count: usize, _subscriptions: Vec, } impl Workspace { fn new(cx: &mut Context) -> Self { let chat = cx.new(|_cx| Chat { messages: Vec::new(), }); let _subscriptions = vec![ cx.observe(&chat, |_workspace, _chat, cx| { cx.notify(); }), cx.subscribe(&chat, Self::on_chat_event), ]; Self { chat, sent_count: 0, _subscriptions, } } fn on_chat_event( &mut self, _chat: Entity, _event: &ChatEvent, cx: &mut Context, ) { self.sent_count += 1; cx.notify(); } } ``` 调用 `chat.update(cx, |chat, cx| chat.send_message(cx))` 会同时发出两种信号:观察者因模型变化使 `Workspace` 失效,Event 订阅者则递增 `sent_count`。GPUI Kit 的 View 使用这一模式。`Workspace` 被释放时,`_subscriptions` 也会随之释放,callback 随即取消。局部的 `let subscription = ...` 若在函数结束时被 drop,问题是**过早取消**,并非泄漏。`Subscription::detach()` 则相反:它消耗 handle 但不取消注册,订阅可一直保留到被订阅的 Entity 释放。对长寿命 Entity 反复 detach 订阅,callback 和捕获资源可能不断累积。只有确实需要这种更长的生命周期时才 detach;它与让异步工作独立继续的 `Task::detach()` 不同。callback 强捕获 Entity 只有在所有权链回到 owner 时才形成环。 `Context::observe` 和 `subscribe` 使用订阅者的弱句柄,因此注册本身不会持有该 View。把 `Subscription` 放在订阅者中,明确它的生命周期。如果上例中两个回调都为一次操作调用 `cx.notify()`,GPUI 可以合并失效请求,但仍应判断是否真的需要两种响应。 `EventEmitter`、`emit` 和类型化订阅的设计请继续阅读 [Event]。 ## 生命周期 只要还有一个强引用 `Entity`,Entity 就会继续存活。最后一个强句柄被 drop 后,`WeakEntity::upgrade()` 就会失败。GPUI 随后在 effect cycle 中运行 release callback 并释放状态;不要依赖状态的析构函数或 release callback 在 `drop(handle)` 语句处同步执行。 大部分清理工作应该直接跟随所有权关系: - 使用 `Entity` 持有子 Entity; - 使用 `WeakEntity` 表示非拥有关系; - 把 View 级 Subscription 放在同一个 View 的 `_subscription` 或 `_subscriptions` 字段中; - View 被 drop 时,一并释放订阅及 callback 捕获的资源。 如果集成代码需要在 GPUI 释放状态前访问它,[Context](./context) 还提供了 `cx.on_release(...)` 来观察当前 Entity,以及 `cx.observe_release(...)` 来观察另一个 Entity。两种回调都在 GPUI 处理释放时执行;`observe_release` 只有在订阅者仍存活时才会执行。它们返回的 Subscription 也应该只保存到 release callback 所需的生命周期结束为止。 ### 练习:共享句柄与弱引用生命周期 把这个测试放进启用了 `gpui-kit` 的 `test-support` feature 的 package(参见[测试](./test))。它不需要窗口。断言依次验证 clone 共享状态、剩余的强句柄维持生命周期,以及最后一个强句柄被 drop 后弱引用失效: ```rust use gpui_kit::{AppContext, TestAppContext}; struct Counter { value: usize, } #[gpui_kit::test] fn handles_share_state_and_control_lifetime(cx: &mut TestAppContext) { let counter = cx.new(|_| Counter { value: 0 }); let another_owner = counter.clone(); let weak = counter.downgrade(); another_owner.update(cx, |counter, cx| { counter.value += 1; cx.notify(); }); assert_eq!(counter.read(cx).value, 1); drop(counter); assert!(weak.upgrade().is_some()); drop(another_owner); assert!(weak.upgrade().is_none()); } ``` 再用上面的 `Chat`/`Workspace` 示例做第二项检查:先只调用 `notify()` 更新一次 Chat,再只调用 `emit(ChatEvent::MessageSent)` 更新一次。预测哪次会改变 `sent_count`,然后在 `#[gpui_kit::test]` 中断言。把 `_subscriptions` 保存在 `Workspace` 上,否则测试面对的是已经取消的监听器。 ## Entity 身份与 View 缓存 `Entity` 可以直接作为子 View 嵌入。它的 `EntityId` 让 View 跨帧保有稳定身份;`cx.notify()` 会使展示它的 View 失效。状态保存在 Entity 中,而普通元素树会在绘制时重新构建。因此,保留 Entity 与缓存其渲染子树是两回事。 对于父级频繁重绘、自己却经常不变的昂贵子 View,GPUI 还提供 `child.clone().cached(style)` 和等价的 `AnyView::cached(style)`。父级必须保留同一个子 Entity,`style` 也必须给出确定的外层尺寸,因为 GPUI 在布局阶段可能跳过内容的 render。未变化的缓存子级可以重放先前的子树;通知、边界尺寸或继承的绘图上下文变化则会触发重建。缓存边界与元素状态、虚拟列表的区别,见 [View Cache](./view-cache)。 [Entity]: https://docs.rs/gpui-pre/0.3.8/gpui/struct.Entity.html [WeakEntity]: https://docs.rs/gpui-pre/0.3.8/gpui/struct.WeakEntity.html [Event]: ./event.md --- # Design Guides Source: /versions/v0.7.1/zh-CN/docs/design-guides 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/design-guides 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 # 设计指南 请在选择组件或编写布局代码之前阅读本指南。它记录 GPUI Kit 在多年桌面应用开发中形成的产品判断:界面应当原生、克制、精确,并让用户无需猜测就能完成任务。 本文是一份规范性指南:**必须**表示正确性或生态约束,**应该**表示默认选择,偏离时需要有明确理由;**可以**表示可选方法。具体方法签名仍以组件 API 文档和当前源码为准。 这些规则建立在 `gpui-base` 的行为能力、GPUI Component 的主题与组件体系,以及桌面平台共同的交互习惯之上。shadcn/ui 提供了开放代码、组合能力和可靠默认值等有益方法,但不决定 GPUI 应用的外观。发生冲突时,优先服从 GPUI 生命周期和用户熟悉的桌面交互。 ## 设计主张 界面应该原生、安静、准确。由内容、层级与交互承担体验,装饰只用于支持它们。 1. **先清晰,后个性:** 在加入品牌表达前,先让主要任务与下一步行动清楚。 2. **先组合,后发明:** 从已有组件出发组成产品工作流;只有行为确实不同,才创建新 primitive。 3. **先 token,后数值:** 颜色、圆角、字体与间距必须构成系统,避免无法响应主题的孤立字面量。GPUI 的样式 API 见 [Style](./style)。 4. **桌面约定优先于网页惯例:** 保留键盘操作、窗口框架、菜单、密集数据视图、可调整区域和持久导航。 5. **状态必须可见:** hover、焦点、选中、禁用、加载、校验与破坏性状态必须清楚且一致。 6. **几何表达包含关系:** 圆角、内缩与边界应构成一致的层级,叠放时也不能破坏。 7. **反馈必须真实:** 加载、提示与动效应解释实际状态或变化,不为即时操作增加仪式感。 ## 借鉴 Shadcn Shadcn 最有价值的不是某一种边框颜色,而是一套建立系统的方法: - 拥有界面最上层代码,不与封闭抽象反复对抗; - 用小而可预测的部件组合出产品组件; - 默认样式本身就属于同一种视觉语言; - 代码和组合结构同时对人和 AI 可读; - 把行为 primitive 与带视觉观点的样式层分开。 GPUI Kit 通过 Rust 库以及 `gpui-base` / `gpui-component` 分层来实现这些原则。应用通常组合或适度封装公开组件;贡献者把真正可复用的行为放入 Base,把视觉决策留在上层。 以下网页习惯不应直接复制: | 网页习惯 | GPUI 默认方式 | | --- | --- | | 所有按钮都显示手形光标 | 按钮使用箭头,只有链接使用手形 | | 以页面跳转为主要结构 | 使用持久窗口、pane、sidebar、tab 与 menu | | 依赖浏览器兜底焦点和滚动 | 明确焦点所有者与各区域的滚动所有者 | | 移动优先的单列布局 | 有最小窗口尺寸定义的可调整桌面 shell | | 关键操作只在 hover 时出现 | 键盘和指针都可达,不依赖 hover 才存在 | | 每行放一排 hover-only 图标按钮 | 一个可见主操作,次要命令使用 `DropdownMenu` / `ContextMenu` | | 用 Link 样式文字执行应用 command | 使用 Button、outline 或 ghost;Link 只用于 URL、网页资源或 Email 地址 | | 全局采用很大的触摸密度 | 默认 medium,专业数据区域才局部紧凑 | | 通过 CSS 穿透修改后代 | 使用 typed builder、语义部件和应用组合 | ## 从用户任务开始 画界面之前先写清楚: - 用户的主要任务; - 正在查看或修改的对象; - 必须立即可见的操作; - 做决定所需的信息; - 空、加载、错误、离线、只读和无权限状态; - 完整流程的键盘路径。 窗口结构应由这些答案决定,不要从 dashboard 卡片网格或组件目录开始。优秀的桌面应用呈现用户的心智模型——文档、账户、项目、消息、设置——而不是内部服务架构。 先确定主要任务,再选择控件。视觉权重、位置与信息深度必须匹配功能在产品中的重要性。不能把核心结果缩成角落里的数字、图标或弱化操作,却让次要内容占据页面。当结果集本身就是产品价值,应使用摘要区域或卡片展示数量、代表性结果、当前状态和清晰的下一步。 每个操作都要有明确的对象、当前状态、作用范围和结果。界面尚未展示或暗示这些信息时,操作就出现得太早。不能因为后端已有能力就直接暴露入口;先设计用户如何理解这个对象和结果。 ## 视觉语言 ### 层级 使用少量、清晰的层级: - **窗口或页面标题**标识当前对象或工作区; - **章节标题**分隔有意义的区域; - **正文**承载工作内容; - **弱化文字**提供次要元数据和帮助; - **标签**标识控件和值。 先用字号、字重、间距与分隔线建立层级,再考虑颜色和容器。避免卡片嵌套卡片;大多数桌面区域只需要背景、hairline 边界和有意图的留白。 层级必须按整个功能评审,不能只看单个组件。隐藏强调色和装饰后,主要任务、当前选择、结果摘要和下一步仍应能从结构中识别。每个控件单看都合理,如果没有形成清楚的阅读顺序与决策路径,整体设计仍然失败。 注意力是有限的。一个局部区域只需要一个清晰重点。如果所有内容都有颜色、`Badge`、粗体、容器或 `Alert`,就没有内容真正重要。先用结构和距离建立优先级;只有某个区别会改变用户的注意或行动时,才使用更强的颜色或组件。 ### 颜色与主题 从 `cx.theme()` 读取颜色,并按语义角色使用: - `background` / `foreground`:主表面和正文; - `group_box`、`popover`、`sidebar` 及对应 foreground:具名表面; - `muted` / `muted_foreground`:辅助信息; - `primary`:当前决策区域的主要操作或选中强调; - `danger`、`warning`、`success`、`info`:只表达对应语义; - `border`、`input` 和 ring token:结构与交互。 不要把状态色当装饰,也不要只依赖颜色表达含义。自定义表面必须在明暗主题和自定义主题中验证;不能假设 foreground 一定是黑色或 background 一定是白色。 Badge 只用于有助快速扫描的短 state、count 或 classification,不能套在每个 label、metadata value、filter 或 section title 上。大多数 Badge 保持 neutral;只有真实表达 success、warning、danger 或 info 时才使用 semantic variant。满屏多色 Badge 通常说明 hierarchy 或 grouping 尚未设计完成。 Application UI 不应出现 raw hex、`rgb`/`rgba` 或 `hsla` color。颜色必须按语义角色从`cx.theme()` 解析。需要的 role 不存在时,在产品 theme/token 层定义,而不是在 call site 嵌入 palette value。Raw color 只属于 theme definition,或颜色本身就是数据的、经过审查的 data/raster content。 ### 圆角、间距与密度 圆角应像字号和控件尺寸一样,使用有限的主题档位(`sm`、`md`、`lg`、`xl` 及更大的表面档位),不要为每个元素发明新数值。大型容器可比内部控件使用更柔和的档位;紧凑控件与浮层则使用更小的档位。普通矩形的圆角应明显小于短边的一半。`radius_full()` 只用于有意设计的圆形与胶囊,提示框不应意外变成胶囊。 嵌套表面应呈现同心的曲线关系。用 `inner radius ≈ max(0, outer radius − inset)` 选择最接近且合适的主题档位,让转角处的边带保持均匀;重复出现的特殊组合若需要中间值,应定义具名的应用令牌。分段控件是一个完整轮廓:两端的分段顺着容器内侧圆角,中间分段保持直角,每处边界只保留一道等宽分隔线。 浅色主题的聊天输入框:内缩表面、两侧按钮及提示文字的间距一起变化;红色参考圆显示转角共用圆心。 深色主题的聊天输入框:内缩表面、两侧按钮及提示文字的间距一起变化;红色参考圆显示转角共用圆心。 圆角必须约束整个可见表面,而不只是描边。外角露出的部分应透出后方表面;子元素背景、选中填充与滚动内容也须遵循同一边界。在 GPUI 中,不要假定父级圆角会自动裁剪后代;应给内层设置相应圆角或适当裁剪,再用高对比背景检查明暗主题下的叠层。选择裁剪方式时仍须保留向外绘制的焦点环。 采用紧凑、重复的间距 scale。label 与 control 的距离应小于两个 group 的距离,两个 group 的距离应小于两个 section 的距离。优先使用组件尺寸(`xsmall`、`small`、默认 `medium`、`large`),不要单独写高度。compact 用于 toolbar 和数据密集界面,不能用来把结构不清楚的布局硬塞进更小空间。 共享语义 scale 有意保持很小:间距大致为 2、4、8、12、16、24、32 px;字体大致为 12、14、16、18、20 px。这些表示关系,不是鼓励把当前数值散落在 feature 代码中。GPUI Component 的 global `Theme` 当前投射的是固定默认 `SpacingTokens`;与 color/radius 不同,`Theme::apply_semantic_tokens` 不会保存 custom spacing scale。需要不同 scale 的应用必须自行持有完整 token snapshot,并在 application component 中一致使用。 ### 空间语法 间距表达关系。根据相邻对象的语义关系选择 token: | 关系 | 常用 token | 当前 scale | 示例 | | --- | --- | --- | --- | | 光学校正 | `xxs` | 2 px | 图标基线、紧凑分隔 | | 同一控件的部件 | `xs` | 4 px | 菜单图标与标签、标题与说明 | | 紧密相关的控件 | `sm` | 8 px | 按钮图标与文字、对话框操作 | | 同一个内容 group | `md` | 12 px | 通知各列、紧凑表单行 | | 同一 section 的不同 group | `lg` | 16 px | panel 内边距、表单组 | | 不同 section | `xl` | 24 px | 页面或 inspector 的主要区块 | | 主要区域边界 | `xxl` | 32 px | 空状态留白、页面大段落 | 这些数值描述当前默认 scale;生态默认代码使用 `cx.theme().spacing_tokens()` 或对应 GPUI scale helper。产品自有 scale 应保持关系顺序,并通过 application design-system context 传递,不能假设它会持久保存在 GPUI Component global theme 中。 处理上下左右空间时遵守以下规则: 1. **先分清内部与外部。** 组件 padding 由组件拥有,组件之间的 gap 由父容器拥有。 2. **纵向节奏表达分组。** 标题到说明的距离小于说明到下一 section 的距离;相等间距意味着相等关系。 3. **横向空间服务扫描。** 重复行中的图标、label、value、badge、尾部 action 应落在稳定列上。 4. **leading/trailing 是语义。** 即使当前 API 使用 left/right,也应按阅读方向思考,为未来 RTL 留出可能。 5. **不要重复 padding。** 已有 panel inset 内的 card 不应再无条件增加一整层 panel padding。 6. **谨慎使用光学校正。** 图标或字形允许 1–2 px 修正,但必须能解释为何偏离 scale。 现有系统中的常见组合揭示了这些关系: - 小按钮内容间距为 4 px,常规尺寸为 8 px; - Dialog header 与 footer 内部间距为 8 px; - 紧凑 list/menu 行通常采用 4 px 纵向、8–12 px 横向 padding; - Sheet header 约为 leading 16 px、trailing 12 px,为关闭控件留出空间;footer 为横向 16 px、纵向 12 px; - Notification 使用 16 px 横向 padding 和 12 px 列间距,因为图标、消息和操作属于不同 group。 这些不是所有 surface 的复制模板,而是在说明:控件内部最紧,行针对扫描优化,容器边界比内容内部花费更多空间。 ### 比例与层次 先确定内容约束,再确定比例。角色不同的 pane 不要机械地各占一半。 - Sidebar 应足够容纳稳定标签,但视觉上从属于工作区;为它定义最小、首选与最大宽度,而不只是百分比。 - Master–detail 中,collection 必须仍可扫描,detail 消耗剩余空间。约 1/3 与 2/3 可作为起点,但内容约束优先。 - Inspector 与辅助 sheet 默认不能遮住主要对象;内容增长时应可调整或关闭。 - Dialog 宽度由决策复杂度决定:短确认、中等表单;复杂工作应进入独立页面或窗口。 - 最强 elevation 只给最上层决策面;同一层内使用背景和 hairline,而不是不断加重阴影。 每个主要区域要定义三件事:任务可用的最小尺寸、舒适默认尺寸、如何消费剩余空间。如果 split 表达用户工作习惯,应持久化;恢复后必须按当前窗口重新 clamp。 ### 对齐细节 对齐是一套结构系统,不是最后的润色。每个 surface 应先建立少量 alignment spine:共享的 leading/trailing edge、文字 baseline、center line 与固定功能 lane。同一层级的元素即使使用不同 component,也应从上到下或从左到右落在同一条 spine 上。 桌面界面的对齐参考线 桌面界面的对齐参考线 纵向红色虚线贴近共享边缘或控件中心,说明内容、状态、时间与尾端操作的列对齐;横向虚线分别贴近文字基线并穿过行中心,说明文字底边和不同尺寸控件的垂直居中。右侧紧凑对照单独展示必须从结构所有者修复的一个渲染像素漂移。 - Sibling region 使用相同 content inset。同层级的 heading、toolbar、list row、empty state 与 footer 不能各自发明略有差异的起始线。 - 整个区域重复同一套 column geometry。Header、row、summary、loading state 与 inline editor 为 identity、metadata、status、number 和 action 保留相同 lane。 - 跨 row 与 section 对齐相关 control。从上到下扫描时,form label、field、description 与 validation message 应显现稳定的纵向网格。 - 水平 band 保持一致。同一 toolbar、title bar、status bar 或 row 中的项目共享 baseline 或 center line,不能逐个用 offset 微调。 - 只有真实 hierarchy、containment 或 disclosure 才引入 indentation。装饰性缩进会让 sibling 看起来像 subordinate,并破坏阅读起始线。 - Nested level 结束后必须准确回到 parent spine,不能让多层 container 的 padding 逐层漂移。 - Optional content 出现或消失时仍保持 spine。缺少 icon、badge、description 或 trailing action 不能推动其余 label;需要跨行比较时使用明确 slot 或 lane。 - 层级相同时,major region 之间也应互相对齐。Sidebar header、content title、split pane、toolbar 与 bottom bar 不必共享所有坐标,但相同层级应形成可见的连续线。 不是每一条边都必须对齐。Child 可以缩进,primary value 可以领先 supporting metadata,destructive decision 也可以获得额外间隔。但例外必须表达层级或语义,不能只是各 component padding 未协调的结果。先建立 shared spine,再明确设计例外。 精确对齐与重复 gap 是质量 invariant。两条 edge 或两段 spacing 按设计应相等时,相差一个 rendered pixel 也是 defect,不能以“肉眼大致一致”通过。必须在代表性的 window size、zoom level 与 display scale factor 下用测量工具检查 resolved bounds,直接比较 coordinate 与 distance,不能只看一张截图凭感觉验收。 这里的 rendered-pixel 容差是验证规则,不是允许用 raw pixel offset 修补代码。相等关系应来自同一个 `rem` helper、spacing token、grid definition 或 shared component inset;发生偏差时修复共同 owner。还要考虑 fractional layout 与 device rounding,确保预期 spine 在不同 zoom 下落到同一 physical pixel,而不是只在某一档看似对齐。 - 混合字号同排时按文字 baseline,而不是按 bounding box 中心对齐。 - 图标放入固定槽位,避免不同固有宽度导致 label 抖动。 - 可比较数字右对齐,文本和标识符通常左对齐(locale 另有要求除外)。 - 行尾 action 和 disclosure indicator 使用固定宽度 lane。 - 表单控件按交互 frame 对齐,而不是按下面的 help text 对齐。 - 只有两侧确实分别拥有相反边缘时才用 `justify_between`,不要用它掩盖中间结构缺失。 - Hairline 由边界所有者绘制,相邻区域不能各画一次同一条分隔线。 - 滚动条属于实际滚动的区域,并贴住面板、编辑器或窗口的尾端边缘。内容内边距可以缩进文字与行,但不能把滚动条推到界面中间;内容需要避让时,应保留明确的滚动条槽位。 ### 密度层级 Medium 是生态默认值。密度应在局部上下文整体变化,而不是只改一个控件: - **comfortable / large:** onboarding、稀疏表单、重要决策; - **standard / medium:** 大多数应用 chrome 与工作流; - **compact / small:** toolbar、menu、table 和重复的专业数据; - **extra compact / xsmall:** 极少数高密度工具,不应成为全应用默认。 当前控件体现的是有限 scale:按钮 frame 大致为 20、24、32 px;input 与数据控件在 large 时可到约 44 px;table row 大致为 26、30、32、40 px。使用组件 `Size`API,让字体、图标、padding 和 hit target 一起变化。只改外框高度通常是不完整的。 ### 缩放、基础字号与 `rem` 良好的 `rem` 系统能在界面 zoom 时保持设计层次。成功的 zoom 不只是每个对象都变大,而是在每一档 scale 下,title/body、control/icon、inner/outer spacing、primary/secondary region 之间仍保持相同关系。 GPUI Component 采用了 Tailwind CSS 中有价值的相对 scale 思想。Theme 的 base `font_size`通过 `Root` 成为 window `rem`;`text_sm()`、`gap_2()`、`p_4()`、`h_8()`、`size_4()`等 GPUI scale helper 都以它解析。Typography、spacing、control 与 icon 因而共享同一条 zoom axis。 设计时关注比例: - type step 围绕 body base size 保持相同 hierarchy; - spacing step 围绕 typography 保持相同 grouping; - control frame、icon、hit target 与 label 一起缩放; - pane minimum 与 comfortable width 要容纳缩放后的内容; - radius 与 focus treatment 相对 control frame 保持光学一致。 不能只改变文字实现 zoom。固定高度 Button 中放大 label、固定 pane minimum 中放大 document、或 virtual-list measurement 未失效时放大 row,都会破坏原有节奏并裁切内容。反过来,把包括 hairline 在内的每个 physical pixel 全部相乘,也会让界面变得沉重。 原则上 application layout 不得直接调用 `px(...)`。使用 GPUI rem-based scale helper(`p_2`、`gap_3`、`w_64`、`text_sm` 等 builder)或 semantic component Size。只有值代表 physical/raster boundary 时才使用 fixed px:one-device-pixel hairline、platform window inset、bitmap dimension、minimum hit-test tolerance,或必须匹配 external surface 的 geometry。这些必须是经过审查和记录的例外。Product spacing、typography、icon size 与普通 control geometry 保持在 relative scale 上。 不能只在默认值验证。至少使用多档 base font 检查 hierarchy、wrap、truncate、minimum window size、pane resize、focus-ring clearance、popup placement 与 virtualized row measurement。同时区分 interface zoom 与 Dock panel zoom:Dock zoom 让一个 container 保留 chrome 并填满区域,不改变 `rem` 或 application scale。 ### 表面与层级 Elevation 用于解释叠放关系,而不是表达重要程度。基础窗口保持平面;区域由背景差异和 separator 划分。Popover、menu、dialog、notification 因处于内容上方,可以逐级增强阴影。不要给每张 card 都加阴影。 同类 surface 必须采用同一种处理。GPUI Component 有意让 Popover、Select、Combobox、DatePicker 和 menu 共用 popover surface,避免它们逐渐漂移。应用新增 anchored surface 时应复用该语义处理,而不是用无关的 border/shadow 字面量近似。 ### 字体与图标 界面文字使用平台 UI 字体;代码、标识符、快捷键和对齐数字才使用等宽字体。正文必须易读,避免过度大写或字距,尤其不能把拉丁文字的 tracking 直接套给 CJK。 一个产品使用同一套图标。图标辅助 label,不能用猜谜替代陌生操作。仅图标按钮始终需要无障碍名称;是否添加视觉提示遵循下文的 tooltip 规则。填充或彩色图标用于表达状态,不用于让 toolbar 显得热闹。 ## 布局模式 ### 选择稳定的应用框架 大多数应用适合以下一种: - **单工作区:** toolbar/title bar 加一个主要视图; - **Sidebar 工作区:** 持久导航与变化的 detail; - **Master–detail:** 可调整 collection 与 detail pane; - **文档工作区:** tab 或 DockArea 管理多个长期对象; - **Utility window:** 单一任务与短而固定的操作路径。 全局导航在内容变化时应保持稳定。主要工作区用 `flex_1()` 消费剩余空间;可收缩的 overflow child 需要 `min_w_0()` / `min_h_0()`。滚动、虚拟列表、表格和 Dock 应使用 `Scrollable`、`VirtualList`、`Table`、`DockArea`,不要用多层 `div` 重做行为。 标题栏首先是窗口框架。保留其拖动区域,不要把普通的标题单击绑定到低频编辑操作。重命名应由明确的对象命令触发,并在适当时提供键盘路径。 ### 可调整的桌面窗口 桌面并不表示固定尺寸。窗口变窄时按以下优先级处理: 1. 保留主要任务; 2. 可调整区域达到有文档的最小值; 3. 折叠次要 label 或 inspector; 4. 把低频操作移入 menu; 5. 只滚动真正发生 overflow 的区域。 隐藏操作时必须提供另一条路径。不要让整个窗口滚动,而实际只有 list 或 document body 需要滚动。 GPUI flex child 即使设置 `flex_1()`,也可能因为长内容拒绝收缩。设计和代码必须约定哪些 pane 可以收缩、截断、换行或滚动。`overflow_hidden()` 也会裁掉向外绘制的 focus ring,不能为了简化 overflow 牺牲键盘焦点可见性。 ### 表单与设置 每个字段使用可见 label,help 与 validation 放在所描述字段附近。相关字段应对齐,但不要强迫长 label 进入过窄固定列。独立选择用 `Checkbox`,少量可见互斥项用`RadioGroup`,较长集合用 `Select`,立即生效的设置用 `Switch`。 操作进行中禁用重复提交,保留用户输入,并在操作附近显示结果。Dialog 只用于短而聚焦的决策;需要探索或大量字段的流程使用完整页面或 sheet。 加载表现必须对应真实的等待。设置面板内容已经可用时,切换应立即完成;只有内容确实在加载且轮廓已知时,才使用骨架图。 ## 组件与组合 采用 Shadcn 的核心思想:组件是构建材料,不是封闭设计系统。GPUI Component 提供一致默认值,应用拥有组合和产品语义。 - 变体按语义使用。主按钮(`primary`)只留给决策区域中明确的默认提交,通常也是按 Enter 执行的操作。操作唯一、使用频繁或希望用户注意,都不会使它自动成为主按钮。管理工具栏中的“添加”通常使用默认按钮;表单中默认提交的“创建”可以使用主按钮。`danger` 表示破坏性提交,`ghost` 用于低强调的工具栏操作。 - 优先使用明确的复合部件与渲染回调,不要穿透任意后代设置样式。 - 跨产品重复且带有领域语言或规则的模式,应封装为应用组件。 - 按语义角色使用标准组件。菜单、下拉菜单、弹出层、选择器与命令面板不是可以互换的容器;它们分别拥有选择、焦点、键盘操作、关闭方式和布局契约。 - 保留同类组件的几何规则。菜单行的上下左右内边距、高度、图标槽、勾选槽、分隔线、圆角和状态样式必须统一;不能用自定义弹出层模仿一个间距仅仅接近标准组件的菜单。 - 不要仅为重命名每个方法或冻结所有能力而包装一层库组件。 - 无产品样式的复用行为放入 `gpui-base`;有主题观点的表现留在 Component 或应用。 ## 交互状态 ### 让结果先于点击被理解 控件应当让结果在操作前就可以预见。优先使用熟悉的桌面控件与布局,让用户不必先学习界面。文案说明动作与对象,状态说明当前是否可用,反馈则确认同一个结果。 不要把实际会打开设置流程的按钮写成“保存”,也不要用“删除”描述仅从分组移除的操作。上下文不能说明范围时,直接写出范围。按下后立即反馈;耗时操作防止重复提交,并在被改变的对象附近显示结果。只有结果本身不可见时,才补充成功提示。 每个交互控件都要设计以下状态: | 状态 | 设计要求 | | --- | --- | | 静止 | 操作提示清楚但不嘈杂 | | 悬停 | 提供轻微指针反馈,但不能成为唯一线索 | | 按下 | 立即响应按压 | | 打开 | 附属弹出层打开期间持续显示按下或打开状态 | | 焦点可见 | 显示高对比键盘焦点环 | | 选中 | 状态持久,并与悬停明确区分 | | 禁用 | 降低强调,且不产生误导性的悬停或按下反馈 | | 加载 | 保持上下文、防止重复操作,并解释较长等待 | | 错误 | 说明发生了什么以及如何恢复 | 需要键盘访问的命令使用 GPUI 焦点系统和 [Action](./action)。遵循熟悉的平台[快捷键](./keybinding),在菜单或工具提示中展示,并在浮层打开或关闭后把焦点放到合理位置。 选中状态是信息模型的一部分,不是可选润色。标签页、分段选择、可选行、筛选项与导航入口必须持续显示选中状态。拥有下拉菜单的按钮在弹出层关闭前必须保持按下或打开外观;悬停无法说明触发按钮与弹出层之间的关系。 分段控件的选中填充与未选中表面必须共享容器轮廓。两端不应露出直角,每处边界的视觉厚度也应一致。 导航项、列表行与标签页的选中状态应由条目自身的表面表达:选中填充、更强的前景色或更重的字重。不要用左侧竖条或单侧边框作为选中标记。这是网页模板的习惯,而非桌面惯例:它会打断条目的圆角轮廓,并在本已按文字对齐的列中加入一条相互竞争的边缘。 破坏性操作要区分可逆与不可逆。可逆变更优先 undo 或临时 notification;严重且不可撤销时才使用 `AlertDialog`,确认文案必须写出具体对象和后果。 ### 指针约定 Button、Checkbox、MenuItem、Tab 等原生控件使用默认箭头;link 使用手形。文本、resize、grab、prohibited cursor 只在确实描述当前操作时使用。Cursor 只是强化 affordance,不能替代可见状态与 accessibility role。 Hover 应克制,因为键盘和 accessibility 操作没有 hover。破坏性或关键操作不能只有 hover 时才存在。行内 action 可以在 rest 时更安静,但 selection、键盘或 context menu 必须提供同一命令。 ### 优先使用桌面命令入口,而不是悬停工具栏 根据命令的频率和作用范围决定入口: - 主要或高频操作使用带文字的按钮或熟悉的工具栏控件,并保持可见; - 当前区域的次要操作放入具有可见触发按钮的 `DropdownMenu`; - 作用于指针下对象的命令放入 `ContextMenu`; - 有自然键盘形式的重要命令同时提供 `Action` 与快捷键; - 悬停时出现的图标只能是快捷入口,同一命令必须在其他位置仍可到达。 这不只是视觉偏好。GPUI Component 的 menu system 已经拥有方向键导航、confirm/cancel、disabled item、separator、submenu、shortcut 展示、focus transfer/restore 和 nested menu dismiss。自定义 hover button strip 必须重新实现这些行为,而且 keyboard-only 与许多 assistive technology workflow 根本看不到它。 用户需要明确知道“这里还有更多命令”时使用 `DropdownMenu`,例如 toolbar overflow、document action、account menu。命令只作用于当前 selection 或 pointer 下对象时使用`ContextMenu`,例如 rename、duplicate、reveal、remove。Context menu 不能是 essential command 的唯一入口;按场景同时提供 menu bar、toolbar、keyboard 或 detail view 路径。 不要为了视觉极简把所有 action 都藏入 menu。Discovery 与速度同样重要:主要 action 保持可见;danger item 有清楚 label 并与普通命令分隔;同一 command 在所有入口使用一致 verb、icon、shortcut、enabled state 与执行结果。 ### 按钮表示应用操作,链接只表示外部资源 改变应用状态、确认决策、打开工具、提交数据或执行命令时使用按钮,并根据局部层级选择表现: - 当前决策的默认提交使用主按钮(`primary`); - 普通可见操作使用默认按钮; - 需要清楚边界但强调较低时使用描边按钮(`outline`); - 工具栏或行内的熟悉低强调操作使用幽灵按钮(`ghost`); - 只有符号广为人知时才使用图标按钮,并提供无障碍名称;含义或范围不清楚时再加工具提示。 不能因为按钮是界面中唯一操作、位于右上角,或团队希望获得更多点击就使用主按钮。主按钮表达默认提交及其键盘行为。添加项目、打开工具、刷新视图等普通命令,应根据局部层级使用默认按钮、描边按钮或幽灵按钮。 带下划线的 Link 只用于外部 resource target:URL、网页、在线文档或 Email 地址。它使用手形 cursor,因为语义是离开当前 application context 并访问该 resource。不能为了让功能 command 看起来安静而套 Link 样式。Link 形态的 Delete、Save、Refresh、Add、Open menu 或应用内跳转会隐藏 control affordance,并向 accessibility 暴露错误 role。 “查看”不会让 app 内 destination 变成 Link。完整报告、分析、detail panel 或 local record 仍应通过 Button、row、card、tab 或 disclosure control 打开。当 card 已说明会打开什么时,可以使用“完整分析”这类依赖上下文的短 label;下划线只留给真正交给 browser 或 mail client 的 resource。 所有应用内导航——sidebar row、tab、breadcrumb、list item、打开本地 view、切换 workspace——必须使用对应原生 component 或 Button/Action。视觉强调通过 Button variant 或 navigation component 的 selected state 决定,不能通过伪装语义实现。 ## 反馈与浮层 选择足以承载当前决策的最小 surface: - tooltip:短解释或快捷键; - popover:不中断任务的上下文控件; - menu:紧凑 action 列表; - notification:无需用户决策的异步状态; - dialog:聚焦决策或短表单; - alert dialog:有后果操作的明确确认; - sheet:需要更多持续空间的辅助工作。 Tooltip 用于补足信息,不用于重复显而易见的操作。陌生符号、模糊的作用范围或未在别处展示的有用快捷键才需要它。消息底部反复出现的复制、下载等命令应保留无障碍名称和键盘路径;悬停标签只会打断浏览。 Alert 即使不是 modal,也会打断视觉 hierarchy。它只用于当前任务中需要立即注意的重要异常信息,不能作为普通 description、tip 或空白内容的装饰 container。不需要立即注意或 action 时,使用 inline help、muted text 或普通 section。 避免 overlay 叠 overlay。Escape 关闭最上层可关闭 surface,焦点回到 trigger 或下一个逻辑目标。 Overlay action 必须指向 overlay 实际展示的 object 或 state。例如只有独立 recent-history section 可见且存在 entry 时,才显示“清除历史”。Search result、recent item 与 favorite 是不同 collection,应明确 label 和分区,不能混成一个没有解释的 list。不适用的 action 应隐藏,或 disabled 并说明原因;不能在 footer 塞一个作用不明的 trash icon。 Footer 不是无处安放 capability 的收纳区。它可以显示适用于整个 surface 的 shortcut、status 或 action,但每项都必须回答:object 是什么、为什么现在可用、影响什么 scope、执行后哪个 visible state 会改变。 ## 动效 动效用于解释变化,不是环境装饰。出现、关闭、展开和空间连续性使用短 transition。如果 opacity 或 transform 已能表达关系,就不要动画大面积 layout。遵守 reduced motion,状态理解不能依赖动画,也不要给所有组件安装默认动画。 Motion policy 属于 styled/application 层。Base 可以拥有 transition 所需的生命周期机制和 geometry,但不决定所有产品都 fade 或 slide。独立动画值使用稳定 ID;被打断时从当前采样值平滑反向,而不是回到旧端点重新开始。 动效应遵循交互的连续性。指针或滚动位置在相关目标间移动时,预览表面保持稳定,只更新内容或位置;交互结束后再关闭。每次更新都淡出、重现,会被感知为闪烁。 ## 数据密集界面 Dense 不等于拥挤。在 table、tree、command palette、editor 与 dock 中: - header 和行主要标识保持稳定; - 可比较值对齐,必要时使用 tabular number; - 区分 focus、hover、active row 与 multi-selection; - sorting/filtering 可见且可逆; - filter/reorder 后按 domain ID 保持 selection; - virtualize 大集合,但不改变键盘语义; - 次要 column 和 inspector 渐进披露; - empty state 解释下一步。 一致字段的比较使用 table;异质内容扫描使用 list;真实层级使用 tree;只有用户需要安排长期工具或文档时才使用 dock。不要把复杂数据组件当作一种视觉样式。 ## 界面用词 文字是界面架构的一部分。一个功能中的入口、对象、命令、状态和结果应作为整体设计,不能按照实现逐项翻译。默认使用在当前上下文中仍然准确的最短表达。 ### 让上下文承担上下文 不要重复界面已经表达的信息。侧栏入口通常只需写对象或领域:使用 `Users`,而不是`User Management`;使用“快捷键”,而不是“快捷键配置管理”。如果表格内容本身都是操作,可以省略泛化的“操作”列名。对话框标题已经是“删除‘路线图’?”时,正文不必再次提问。 这是上下文经济,不是为了短而删。文字能够改变决策时必须保留:受影响范围、不可逆后果、异常前提或恢复方式。每一个额外词都应回答当前布局尚未回答的问题。 入口与对象使用名词(“用户”“外观”“订单”),命令使用动词(“保存”“复制”“导出”),状态使用形容词或短语(“离线”“已是最新版本”“待审核”)。除非能够区分真实领域概念,否则避免“管理”“模块”“页面”“功能”“操作”“系统”等包装词。 ### 分别写作,而不是翻译句形 先统一意图、层级与术语,再按每种语言的自然表达分别写作。不要保留源语言的语序、词数、客套填充或词性。中文概念直译可能是 `User Management`,自然英文却是 `Users`;忠实是保留用途,不是保留字面形式。 信息架构已经表达的词应删除。在 `Settings` 中,入口通常只写 `Account`,不用重复`Account Settings`,更不能写不自然的单数 `Account Setting`。正确英文来自控件的角色和相邻文字,而不是脱离上下文的中文短语。 为重复出现的对象、命令与状态维护一份小型产品词表。工具栏、菜单、右键菜单、对话框、快捷键搜索与文档对同一概念使用同一个词,除非上下文确实改变了含义。文案必须放回真实界面评审;只看翻译文件,往往发现不了相邻文字的重复和作用范围不一致。 技术写作不追求纯中文。已经稳定的 UI framework 术语,如果翻译后不够准确,可以保留英文;API 标识符保持原名并使用代码格式。普通叙述不能仅为显得专业而夹杂英文。术语首次出现时按需说明含义,此后在界面、文档和 API 示例中保持同一种写法。 文档中的链接若指向有固定名称的 API、组件、源码文件或指南,链接文字沿用准确的英文名称,中文页面也一样。例如写 `Font`、`Render`、`Input element`、`Plot label`,不要翻译名称,也不要额外加上多余的 `GPUI Kit` 前缀。周围的说明仍用中文。链接只覆盖被引用的名称;“源码”等说明性文字放在链接之外。 跨语言共用的技术对比表保留能力项、框架名、API 名和模型名的既有英文写法;翻译表格周围的解释,不翻译这些技术标签。普通界面用词如有通行的中文名称,仍可在对比表之外使用中文。 ### 按钮与确认对话框 按钮默认简短,通常一至两个词,并说明结果,而不是手势或控件。使用“保存”“移动”“删除”,不用“点击进行保存”“执行移动操作”“确认删除操作”。不提交并离开的操作统一使用“取消”。`OK` 只用于确认已经读到纯信息。 简短是默认值,不是机械字数限制。当额外文字能够揭示后果,或区分容易混淆的选择时,应有意使用更长但仍可扫描的文字,例如“仅从此分组删除”与“从所有位置删除”,或“不保存并重新启动”。长度必须换来决策所需的信息,不能重复标题或正文。 能够准确概括结果时,确认按钮使用最具体的短词: | 上下文 | 较弱 | 推荐 | | --- | --- | --- | | 删除对话框 | “是”“Sure”“确认删除” | “删除” | | 未保存修改 | “确认”“是” | “放弃修改” | | 纯信息确认 | “确认操作” | “知道了”或 `OK` | | 无法用一个准确动词概括的复杂承诺 | “是” | “确认” | 当对话框已完整说明复杂承诺,而不存在准确的结果动词时,“确认”是合理的后备用词;它不应替代本来清楚的命令。`Sure` 是口语回应,不是稳定的英文命令,含义也不足以进入标准词表。 确认对话框应组成一个紧凑决策: - 标题写决策或条件,例如“删除‘路线图’?”; - 正文只补充新的作用范围、后果或恢复方式; - 操作使用“取消”和结果词,例如“删除”; - 破坏性样式标记破坏性结果,但不能代替准确用词。 能够说明实际情况时,不使用“提示”“警告”“错误”“确认”等泛化标题。避免“您确定要……吗”“是否需要……”“请注意……”以及状态已经表达清楚时的“成功”等套话。礼貌来自冷静、尊重的语气,不来自重复的“请”。 ### 大小写、标点与符号 英文 UI 默认使用 sentence case:`Reset layout`,不用 `Reset Layout` 或 `RESET LAYOUT`。专有名词与约定俗成的缩写保持原样。只有原生命令菜单等确实受益于平台惯例时才使用 title case,并在同一类控件中保持一致。 全大写可以作为克制的排版强调,适合极短的分组标签、眉题、状态,以及既有缩写或代码。它通过紧凑的字形和适当字距形成接近加粗的层级,但不应用于按钮、长标题、完整句子或密集列表。同一区域不要同时用全大写、强色和粗体争夺注意。不要自动转换所有字符串;产品名、缩写与本地化内容需要保留正确大小写。 标签、按钮、菜单项、标签页、标题、占位文字与短状态末尾不加句号;完整的说明、警告和错误句子使用完整标点。日常成功或失败消息避免感叹号。中文句子使用全角标点,短控件文字同样按语义省略句末标点。 使用单个省略号字符(`…`),不用三个句点。凡按钮或菜单项会打开对话框、sheet、独立窗口,或命令完成前还需要用户输入或选择,文字末尾都加省略号,例如 `Settings…`、“导出…”。立即执行的命令不加。正在进行的任务使用不确定进度指示器,不用装饰性的点号表达。 错误说明发生了什么,并在有帮助时给出下一步恢复方式。成功反馈只在结果状态尚不可见时出现。使用“无法保存。请检查网络连接后重试。”,不要只显示技术代码或长篇道歉;文档已经明显进入保存状态时,不再弹出“保存成功”。 ## 国际化与平台适配 文案必须承受扩展、CJK 排版和不同快捷键写法。不要按一条英文 label 固定控件宽度;不要把文字放进 raster asset;不要拼接翻译片段;只有存在 tooltip 等恢复路径时才截断 label。 尊重有意义的平台差异:Command/Control、原生窗口装饰、系统 appearance、scrollbar、menu 和 notification 能力。各平台的信息架构应稳定,但不能为了表面像素一致而消除用户熟悉的平台行为。 ## AI 生成界面的规则 AI 修改 GPUI 界面前必须阅读相邻 feature、theme token 和组件文档,并先说清主要任务、state owner、component composition 和 keyboard path。不能从 React/Shadcn 示例推断 API,也不能因为方法名“听起来合理”就发明 GPUI 方法。 AI 输出只有在人能够解释 hierarchy、density、component choice 与所有例外字面量时才算完成。看起来合理的截图不是证据;键盘、焦点、动态内容、主题、resize 和失败状态都属于设计。 ## 无障碍检查表 界面完成前验证: - 所有 action 都能通过键盘到达和执行; - focus 顺序符合视觉与任务顺序; - focus 始终可见,overlay 关闭后正确恢复; - control 有无障碍名称;仅图标 control 仅在含义、范围或快捷键需要解释时使用 tooltip; - 文字与有意义边界对比度充足; - 状态不只依赖颜色; - disabled 与 read-only 可区分; - label、error、description 靠近对应 control; - 长翻译和更大字体下仍可用; - 即使紧凑布局,pointer target 仍舒适。 ## 设计评审清单 评审不是清点部件,而是判断界面是否做出了正确取舍。依次回答: 1. **任务清楚吗?** 新用户能否直接看懂界面用途、主要操作和下一步,而不必学习、猜测或试错? 2. **操作兑现承诺吗?** 文案、控件、状态、范围、反馈与结果是否始终描述同一件事? 3. **层级明确而克制吗?** 核心功能是否获得应有的空间,同时主按钮、强色、粗体、徽标和警示保持稀缺? 4. **还能做得更少吗?** 能否删除、合并或推迟某个入口、选项或状态,同时完整保留主要任务? 5. **结构准确吗?** 同层内容是否共享对齐轴,相等间距是否精确到渲染像素,滚动条是否贴住实际滚动区域的边缘? 6. **遵守组件体系吗?** 标准控件是否保留统一的几何、状态、键盘与关闭行为,外观是否来自主题与尺度令牌? 7. **所有状态与约束下都可用吗?** 检查键盘与焦点、空白、加载、失败、无权限、长翻译、缩放、最小窗口和减少动态效果。 8. **在真实窗口中验证了吗?** 使用真实组件、文案和代表性内容亲手完成任务,而不只评审一张理想截图。 继续阅读[编码指南](/versions/v0.7.1/zh-CN/docs/coding-guides),把这些设计决策落实为 GPUI 架构和代码。 --- # 安装 Source: /versions/v0.7.1/zh-CN/docs/installation 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/installation 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 先为操作系统安装原生构建工具链,再在 Rust 应用中添加 `gpui-kit`。以下步骤面向桌面开发;[WebAssembly](./webassembly) 和[移动端](./mobile)有各自的目标平台配置。 ## 平台要求
  • macOS 15 或更高版本
  • Xcode Command Line Tools,可运行 xcode-select --install 安装。之后运行 xcode-select -p;应输出所选的开发者工具目录。
  • Windows 10 或更高版本
  • Visual Studio 2022 Build Tools 或 Community,并安装 Desktop development with C++ workload,其中包含 MSVC 与 Windows SDK。Rust 工具链应选择 MSVC 目标,而非 GNU 目标。
  • 确保构建所用终端可以执行 cmake --version。

以下依赖已在 Ubuntu 24.04 验证:

sudo apt update
sudo apt install -y gcc g++ clang libfontconfig-dev libwayland-dev \
  libwebkit2gtk-4.1-dev libxkbcommon-x11-dev libx11-xcb-dev \
  libssl-dev libzstd-dev libasound2-dev vulkan-validationlayers libvulkan1

此清单与仓库的 script/install-linux.sh 一致,适用于 Ubuntu 24.04;其他发行版需要安装对应的开发包。libasound2-dev(ALSA)仅在启用 speech feature 时需要。显示窗口还需要可用的 Wayland 或 X11 图形会话及 Vulkan 驱动;单独安装 libvulkan1 并不会安装 GPU 驱动。

## Rust 和 Cargo 通过 [Rust 官方安装页面](https://rust-lang.org/tools/install/)安装 Rust 和 Cargo。按本仓库当前锁定的依赖图,请使用 Rust 1.92 或更新版本:其中的 Linux GPUI 平台依赖 `oo7 0.6.0` 明确要求至少 Rust 1.92。然后在准备用来构建应用的同一个终端中检查: ```sh rustc --version cargo --version ``` 两条命令都应输出版本号。在 Windows 上,rustup show active-toolchain 应显示 msvc 目标。如果刚安装完仍找不到 Cargo,请打开新终端重试。 在应用的 `Cargo.toml` 的 `[dependencies]` 中加入: ```toml gpui-kit = "0.7.1" ``` `0.7.1` 要求会选择兼容的 Kit 版本。Kit 的默认特性包含带样式的组件和默认图标资源,并会引入配套的 GPUI crate;按此方式构建的应用无需单独声明 GPUI。`use gpui_kit::*;` 导入 Kit 重导出的 GPUI API;各层分别可通过 `gpui_kit::component`、`gpui_kit::base`、`gpui_kit::assets` 和 `gpui_kit::platform` 访问。 ### 为什么依赖名是 `gpui-pre` 本文中的 **GPUI** 指 [Zed 的 GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui)。`gpui-pre` 是将 Zed 指定提交的 GPUI crate 以一组配套版本发布到 crates.io 时使用的 Cargo 包名,供 GPUI Kit 固定可复现的依赖;它不是另一套渲染实现。发布过程会调整包名及依赖清单以适应 crates.io,所以本文的 API 说明以当前 GPUI Kit 固定的 GPUI 版本为准。本仓库固定 `gpui-pre = 0.3.8`;应用通常只依赖 `gpui-kit`,通过 `gpui_kit::*` 使用 GPUI。`gpui-pre` 发布了更新的快照,也不代表当前 GPUI Kit 版本已支持它。 ## 验证安装 如果已检出本仓库,请在仓库根目录运行现有示例: ```sh cargo run -p hello_world ``` 第一次构建需要下载和编译依赖,可能耗时数分钟。启动后,窗口会显示 “Hello, World!” 和 “Let's Go!” 按钮;点击按钮,终端会打印 `Clicked!`。关闭窗口即可结束进程。这个步骤验证本机的构建工具链、窗口创建、绘制和输入,不能作为性能 benchmark。 如果要创建自己的项目,请接着阅读[Getting Started](./getting-started)。它用同一个依赖构建应用,并逐步解释第一个 [View](./render) 和 [Window](./window)。 ## 常见问题 | 现象 | 排查方法 | | --- | --- | | 找不到 `cargo` 或 `rustc`。 | 用 rustup 安装 Rust,打开新终端,再运行上面的版本检查命令。 | | 构建提示 Rust 版本不受支持。 | 检查 `rustc --version` 并更新当前 Rust 工具链。依赖所需的编译器版本可能高于本文的基线。 | | Windows 提示缺少 `link.exe` 或 Windows SDK。 | 检查 Visual Studio C++ workload 和 SDK,并确认使用 MSVC Rust 工具链;必要时从 Visual Studio Developer PowerShell 构建。 | | Linux 提示缺少 `pkg-config` 命令、X11、Wayland、fontconfig 或 WebKit 头文件。 | 安装上面的 Ubuntu 开发包,或对应发行版的等价包。如果缺少 `pkg-config` 命令本身,还需安装 `pkg-config` 包;根据报错定位具体工具或系统库。 | | Linux 上编译成功,但没有出现窗口。 | 确认程序运行于 Wayland 或 X11 图形会话,并且会话中有可用的 Vulkan 驱动。无图形环境的终端或只有 Vulkan loader 都不够。 | | macOS 上窗口可以打开,但没有文字。 | 如果直接依赖 GPUI,请在 `gpui-pre-platform` 上启用 `font-kit` 并重新构建。`gpui-kit` 已经启用了它。配置示例和 feature 检查命令见 [Getting Started](/versions/v0.7.1/zh-CN/docs/getting-started#macos-文字渲染font-kit)。 | | Cargo 无法解析 `gpui-pre`,或 API 与示例不一致。 | 保留 `gpui-kit` 依赖要求,并一起更新依赖。Kit 固定一组匹配的 `gpui-pre-*` 快照;不要把其中某个 GPUI 包单独覆盖为别的版本。在本仓库中使用已提交的 `Cargo.lock`。 | 窗口打开后的功能问题,可继续阅读[Getting Started](./getting-started)以及对应功能指南。 ## 提升开发模式运行性能 Rust Debug 构建下,GPUI、组件库、布局和文字渲染相关 crate 基本没有优化,因此通过 `cargo run` 启动的应用,其渲染与交互性能会明显低于 release build。下面的配置只优化这些框架依赖,应用自身代码仍保持 Debug mode,可以继续使用正常的调试流程。 这个配置**不会加快编译**。启用优化后,这些依赖的编译时间可能更长,尤其是首次构建;它改善的是开发过程中运行 GPUI 应用时的性能。Cargo 的[package profile override](https://doc.rust-lang.org/cargo/reference/profiles.html#overrides) 只在应用或 workspace 根目录的 `Cargo.toml` 中生效: ```toml [profile.dev.package] gpui-pre = { opt-level = 3 } gpui-component = { opt-level = 3 } gpui-kit = { opt-level = 3 } gpui-kit-assets = { opt-level = 3 } gpui-pre-macros = { opt-level = 3 } gpui-pre-platform = { opt-level = 3 } rustybuzz = { opt-level = 3 } taffy = { opt-level = 3 } ttf-parser = { opt-level = 3 } ``` --- # Multi Window Source: /versions/v0.7.1/zh-CN/docs/multi-window 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/multi-window 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 一个 GPUI 应用可以同时拥有多个窗口。每个窗口都有独立的 `Window` 上下文、焦点、输入派发、几何信息和 GPUI Kit `Root`;应用数据则可以由多个窗口共享。[Window](./window) 介绍单窗口 API,本指南从第二个窗口开始,说明各窗口应持有哪些状态。 ## 从空项目开始 先按[安装指南](/versions/v0.7.1/zh-CN/docs/installation)准备平台依赖,然后创建应用: ```sh cargo new gpui-multi-window cd gpui-multi-window ``` 在 `Cargo.toml` 中加入与[快速开始](/versions/v0.7.1/zh-CN/docs/getting-started)相同的单一依赖: ```toml [dependencies] gpui-kit = "0.7.1" ``` 用下面的完整示例替换 `src/main.rs`。如果已有单窗口应用,保留 `application().run(...)` 和 `init(cx)`,在启动闭包中创建一次共享 model,再调用第二次 `open_window`,为新窗口创建单独的内容 view。下面的循环会对两个名称各调用一次。 ## 基于同一份 model 打开两个窗口 打开两个窗口前只调用一次 `gpui_kit::init`。每次调用 `gpui_kit::open_window` 都会创建新窗口,并为 builder 返回的 view 包上一层独立的 Base `Root`。返回值是 `(AnyWindowHandle, Entity)`,其中 `V` 是应用传入的内容 view。builder 应返回内容 Entity,不要再包一层 `Root`。`Root` 负责该窗口的浮层、菜单、通知与焦点协调。 下面的例子让两个窗口共享一个计数器 Entity,同时为每个窗口建立自己的 `Workspace` Entity。observer 让共享数据的变化在两个窗口中显示出来;`local_clicks` 则各自独立。 ```text Application SharedCounter (one Entity) ├── First window → Root → Workspace (first Entity, first observer) └── Second window → Root → Workspace (second Entity, second observer) ``` 每个 `Workspace` 都持有同一个 Entity handle 的克隆。克隆 `Entity` handle 不会复制其中的 `T` 值。两个 observer 观察同一份 model,但各自归属于一个窗口的内容 view。 ```rust use gpui_kit::component::button::Button; use gpui_kit::*; struct SharedCounter { count: usize, } struct Workspace { shared: Entity, _shared_observer: Subscription, local_clicks: usize, name: &'static str, } impl Workspace { fn new(shared: Entity, name: &'static str, cx: &mut Context) -> Self { let _shared_observer = cx.observe(&shared, |_, _, cx| cx.notify()); Self { shared, _shared_observer, local_clicks: 0, name } } } impl Render for Workspace { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let count = self.shared.read(cx).count; div() .flex() .flex_col() .gap_2() .p_4() .child(format!("{}: shared count {count}", self.name)) .child(format!("Clicks in this window: {}", self.local_clicks)) .child( Button::new("increment") .label("Increment") .on_click(cx.listener(|this, _, _, cx| { this.local_clicks += 1; this.shared.update(cx, |shared, cx| { shared.count += 1; cx.notify(); }); cx.notify(); })), ) } } fn main() { application().with_assets(assets::Assets).run(|cx| { init(cx); let shared = cx.new(|_| SharedCounter { count: 0 }); for name in ["First window", "Second window"] { let shared = shared.clone(); open_window(WindowOptions::default(), cx, move |_, cx| { cx.new(|cx| Workspace::new(shared, name, cx)) }) .expect("open workspace window"); } }); } ``` 在新项目中运行 `cargo run`,应看到两个窗口。点击任一窗口的 **Increment**:共享计数会在两个窗口中变化,局部点击数只在点击按钮的窗口中变化。关闭其中一个窗口后,另一个窗口的计数和按钮仍可使用。`SharedCounter` 在循环外只创建一次,`Workspace::new` 则为每个窗口执行一次。 按以下顺序操作,检查各处状态: | 操作 | 第一个窗口 | 第二个窗口 | | --- | --- | --- | | 启动 | 共享 0;局部 0 | 共享 0;局部 0 | | 在第一个窗口点击 **Increment** | 共享 1;局部 1 | 共享 1;局部 0 | | 在第二个窗口点击 **Increment** | 共享 2;局部 1 | 共享 2;局部 1 | | 关闭第一个窗口,再在第二个窗口点击 | 已关闭 | 共享 3;局部 2 | 沿着代码追踪第一次点击:按钮 listener 增加第一个 `Workspace.local_clicks`;`shared.update` 修改唯一的 `SharedCounter` 并调用它的 `cx.notify()`;两个已保存的 observer 都收到通知,并各自为所在 view 调用 `cx.notify()`。随后两个 view 重新渲染,读到相同的计数。第一个 view 修改自身的局部计数后,也调用 `cx.notify()`。`render` 中的 `self.shared.read(cx)` 只提供本次渲染使用的值;model 变化后由 observer 安排后续渲染。 要让第三个独立 workspace 共享同一计数器,可在 `for name in [...]` 数组中加入 `"Third window"` 后重新运行。点击任一窗口,三个共享计数都应增加,只有被点击窗口的局部计数增加。如果把 `SharedCounter` 的创建移到循环内,则每个窗口会拥有自己的 model,观察结果也会改变。 文档或会话数据应放在 feature 持有、需要它的窗口共同引用的 Entity 中。窗口选中项、焦点 handle、浮层状态和窗口专属 task 应放在该窗口的 view 中。只有真正全应用共享的设置和服务才使用 [Global](./global),不要用一个 Global 收纳所有窗口的 UI 状态。共享 Entity 的通知只会到达观察它的 view;仅在 `render` 中读取它,不会自动订阅更新。应将返回的 `Subscription` 保存在观察它的 view 上:直接丢弃会停止更新,放在应用级 owner 则会不必要地延长其生命周期。 ## 找到正确的窗口 `gpui_kit::open_window` 返回 `AnyWindowHandle`,因为窗口实际的根 view 是 GPUI Kit 的 `Root`,而不是应用的内容 view。需要读写内容状态时保留返回的 `Entity`;需要在以后操作窗口焦点、Action 派发、激活或几何信息时保留 window handle。在 callback 内可以通过 `window.window_handle()` 取得当前窗口的 handle。启动示例中的按钮只操作各自的 view,因此可以丢弃两个返回值;文档切换器或窗口 registry 则应保留它们。 `AnyWindowHandle` 提供 `window_id()` 和 `update(...)`。`cx.windows()` 列出已打开的窗口,`cx.active_window()` 在平台支持时返回当前获得系统 Focus 的窗口。已知目标窗口时,应保留它的 handle,不要依赖遍历顺序选窗口。如果需要强类型的根 handle,可将它 downcast 为 `WindowHandle`;downcast 为 `WindowHandle` 会失败,因为 `Workspace` 位于 `Root` 内。 ```rust // target is the AnyWindowHandle returned by open_window. target.update(cx, |_, window, _cx| { window.activate_window(); window.set_window_title("Document"); })?; ``` 必须处理 `update` 的结果,因为目标窗口可能已经关闭。Focus 与 Action 派发使用所选窗口的上下文。如果更新 Entity 时还需要它所属的窗口,可用 `cx.update_window(target, |_, window, cx| { ... })`,在 callback 中更新内容 Entity。绑定某个窗口的异步任务可用 [`cx.spawn_in`](./task),并在窗口或 Entity 消失后处理 `update_in` 的失败结果。 ## 关闭与清理 `window.remove_window()` 请求移除当前窗口;何时退出整个应用由应用决定。若未保存内容需要拦截系统关闭请求,在对应窗口注册 `window.on_window_should_close(cx, |window, cx| { ... })`;返回 `false` 可取消关闭。直接调用 `remove_window()` 是应用决定移除窗口,因此应在调用前完成确认。窗口关闭前应读取或保存其局部状态:`cx.on_window_closed(...)` 执行时已无法访问该 `Window`,callback 只收到用于清理 registry 的 `WindowId`。 如果桌面应用希望关闭最后一个窗口时退出: ```rust cx.on_window_closed(|cx, _closed_id| { if cx.windows().is_empty() { cx.quit(); } }) .detach(); // Keep this app-level observer until the application exits. ``` 也可以将返回的 `Subscription` 存在应用 owner 中,随 owner 一起释放。基础示例无需关闭 observer:关闭任一窗口,另一窗口仍可使用。若应用可以从 dock 或系统托盘重新打开窗口,则应选择相应的生命周期策略。用 `WindowId` 作 key 的 registry 可以在这个 callback 中移除已关闭的 handle。窗口专属的 `Task` 和 `Subscription` 字段应随对应 view 一起释放;不要让应用级集合意外延长已关闭窗口的 view 生命周期。 关闭策略有两个不同的决策点。系统关闭请求到达 `on_window_should_close` 时,窗口仍可访问;若用户需要先处理未保存内容,应返回 `false`。窗口实际关闭后,`on_window_closed` 可以按 `WindowId` 清理 registry,或决定是否退出应用。如果按钮直接调用 `remove_window()`,应在调用之前完成确认;should-close callback 不能代替按钮自己的确认流程。 ## 恢复窗口位置 窗口关闭前,读取 `window.window_bounds()`,得到可恢复的 `WindowBounds`(`Windowed`、`Maximized` 或 `Fullscreen`)。由应用设置保存这个值,下次再传给 `WindowOptions`: ```rust let options = WindowOptions { window_bounds: Some(saved_bounds), ..Default::default() }; open_window(options, cx, |window, cx| cx.new(|cx| Workspace::new(shared, "Restored", cx)))?; ``` 这里的 `saved_bounds` 是之前窗口取得的 `WindowBounds`;如何持久化、恢复由应用负责。使用前还应检查位置是否仍落在当前连接的显示器内,因为显示器配置和缩放比可能改变。[Window 几何信息](./window#几何信息与缩放)解释全局窗口边界与窗口局部 viewport 的区别。 ## 测试窗口边界 在 [`TestAppContext` 测试](./test)中,通过 `gpui_kit::open_window` 打开两个窗口,修改共享 Entity,断言两个 view 都更新,同时窗口局部状态保持独立。对第一个窗口调用 `window.remove_window()` 后,它的 handle 更新应返回错误;第二个窗口仍应能渲染和接收输入。运行 `cargo test -p gpui-kit --features test-support --test lifecycle closing_one_window_preserves_other_window_and_owned_snapshot` 可执行仓库现有的[多窗口生命周期测试](https://github.com/longbridge/gpui-kit/blob/main/crates/kit/tests/lifecycle.rs),覆盖关闭边界。对于本示例,还应在新项目中运行 `cargo run`,目视检查两个计数和关闭行为。 ## 排查示例问题 | 现象 | 检查点 | | --- | --- | | 只打开一个窗口 | 检查循环中每次都调用 `open_window` 且调用成功;`.expect(...)` 的 panic 会指出打开失败。 | | 被点击的窗口变化,但另一个窗口的共享计数未更新 | 在每个 `Workspace` 中保存 `cx.observe` 返回的 `Subscription`,并在更新 `SharedCounter` 时调用 `cx.notify()`。只读取 Entity 不会自动注册 observer。 | | 两个窗口的局部点击数一起变化 | 每个窗口都应通过 `cx.new(...)` 创建不同的 `Workspace`;只有 `SharedCounter` 在循环外创建。 | | Action 更新了错误窗口,或关闭后更新失败 | 保存目标窗口返回的 `AnyWindowHandle`,将它用于窗口相关操作,并处理关闭后的 `update` 错误。不要依赖 `cx.windows()` 顺序。 | | 关闭一个窗口就退出整个应用 | 检查应用的最后一个窗口退出策略及 `on_window_closed` callback。本双窗口示例没有注册该 callback。 | --- # Native Extensions Source: /versions/v0.7.1/zh-CN/docs/native-extension 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/native-extension 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Native extension 是把操作系统控件或视图接入 GPUI window。本仓库有两条具体路径:[NativeMenu](https://github.com/longbridge/gpui-kit/tree/main/crates/component/src/native_menu) 使用系统菜单 API,[gpui-wry](https://github.com/longbridge/gpui-kit/tree/main/crates/webview) 嵌入原生 WebView。两者都跨越 GPUI 与系统的边界;启动另一个应用不属于这里讨论的原生控件接入。 当弹出菜单需要超出小窗口边界,或应遵循系统菜单外观时,使用 `NativeMenu`;若菜单应参与 GPUI 自身的 overlay 合成,使用 GPUI `PopupMenu`。只有屏幕确实需要浏览器引擎,并能为原生子视图留出一块矩形区域时,才使用 `gpui-wry`。[TextView HTML](/versions/v0.7.1/zh-CN/component/text-view#html) 用于显示文档内容,[`cx.open_url`](./context#在默认浏览器中打开-url) 会打开用户的默认浏览器。`gpui-wry` 仍属实验性功能。 | 平台 | NativeMenu | 本仓库的嵌入式 WebView | | --- | --- | --- | | macOS | AppKit `NSMenu`,依附于 `NSView` | Wry 原生子视图;现有例子可用 | | Windows | Win32 `TrackPopupMenuEx`,使用 `HWND` | Wry 原生子视图;现有例子需要指定 renderer 设置 | | Linux | GPUI 绘制的 `PopupMenu` 回退实现,受窗口边界裁剪 | GTK 宿主代码尚未完成,目前没有受支持的接入路径 | Linux 的菜单回退实现保持 `NativeMenu` API,但内容由 GPUI 绘制,并非系统菜单。Linux 原生子视图需要分别处理 GTK、Wayland、X11 的宿主集成与测试。当前平台要求见 [WebView](./webview)。 ## 源码与构建路径 工作区根目录的 `Cargo.toml` 把 `gpui-pre` 固定为 `=0.3.8`。把 adapter 移植到其他 GPUI 版本前,请先核对当前 checkout 的源码。相关路径如下: | 内容 | 本仓库源码 | 在仓库根目录构建或运行 | | --- | --- | --- | | 菜单公共 API 与平台选择 | `gpui-component` crate 中的 `crates/component/src/native_menu/mod.rs` | `cargo check -p gpui-component`;`cargo test -p gpui-component native_menu` 检查 builder 和图标测试 | | AppKit、Win32 菜单 adapter 与 GPUI 回退实现 | `crates/component/src/native_menu/{macos,windows,fallback}.rs`;回退 overlay 由 `crates/component/src/root.rs` 持有 | `cargo run -p gpui-component-story`,然后在目标系统中打开 **NativeMenu** story | | Wry wrapper 与自定义 element | `gpui-wry` crate 中的 `crates/webview/src/lib.rs` | `cargo check -p gpui-wry` 检查当前目标平台的 wrapper | | 子视图创建与 renderer 设置 | `examples/webview/src/main.rs`,依赖定义在 `examples/webview/Cargo.toml` | 在 macOS 或 Windows 上运行 `cargo run -p webview` | Cargo 只检查当前目标平台的 `#[cfg]` 分支;在 Linux 上构建不会验证 AppKit 或 Win32 adapter。story 和 WebView 例子需要图形桌面环境及平台原生库。Linux WebView 分支即使编译成功,也不是可用的 GPUI 宿主实现。 ## 1. 确定接入边界 对于**平台菜单集成**,应将共享的 GPUI API 与平台 adapter 分开。`NativeMenu::show(position, window, cx)` 在[共享 module](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/native_menu/mod.rs)中选择 macOS、Windows 或 Linux 回退实现。原生 adapter 创建和操作系统菜单,再通过 GPUI 返回所选 Action。 对于**嵌入式子视图**,原生内容会持续占据 GPUI layout 区域。像 [`gpui-wry::WebView`](https://github.com/longbridge/gpui-kit/blob/main/crates/webview/src/lib.rs) 一样,把它交给 `Entity` 持有。owner 负责 show/hide、Focus、bounds,并确保父窗口关闭前完成销毁。 ## 2. 取得正确的 window handle `Window::window_handle(window)` 得到 GPUI 的 `AnyWindowHandle`,供之后更新**同一个**窗口。调用系统 API 则应使用 `raw-window-handle` trait。两个方法名字相近,用途不同: ```rust use raw_window_handle::{HasWindowHandle, RawWindowHandle}; let gpui_handle = Window::window_handle(window); // Update the GPUI window later. let native_handle = HasWindowHandle::window_handle(window)?; // Platform adapter. match native_handle.as_raw() { RawWindowHandle::AppKit(handle) => { /* macOS: handle.ns_view */ } RawWindowHandle::Win32(handle) => { /* Windows: handle.hwnd */ } RawWindowHandle::Xcb(handle) => { /* Linux X11: handle.window */ } RawWindowHandle::Wayland(handle) => { /* Linux Wayland: handle.surface */ } _ => { /* Unsupported backend. */ } } ``` 这段是返回 `Result` 的 adapter 函数节选,并非完整的跨平台函数。应在对应目标的 `#[cfg]` 下匹配实际 handle 变体。`HasWindowHandle` 返回的 handle 借用有效窗口;raw 指针或 ID 并不拥有原生对象。菜单 adapter 将 `NSView` 指针或 `HWND` 复制进 foreground task,并依赖父窗口在同步 tracking 调用期间保持有效。窗口销毁后不能继续缓存或使用它们。当前固定版本的 GPUI Linux 平台在 X11 下提供 XCB window ID,在 Wayland 下提供 Wayland surface。单凭 raw surface 指针,无法得到嵌入 GTK 控件所需的 widget 层级或 compositor 集成。handle 提取可参见仓库的 [AppKit](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/native_menu/macos.rs)与 [Win32](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/native_menu/windows.rs) adapter。 ## 3. 平台菜单实例:NativeMenu adapter 通过目标平台的 Rust crate 直接调用系统 UI 框架: | Backend | 这里用到的 Rust crate 与 API | 系统对象或操作 | | --- | --- | --- | | macOS | `objc2`、`objc2-app-kit`、`objc2-foundation`;`MainThreadMarker`、`NSMenu`、`NSMenuItem`、`define_class` | 构建 AppKit 菜单项、接收 Objective-C 选择事件、从 `NSView` 弹出菜单 | | Windows | 带 Win32 UI features 的 `windows` crate;`CreatePopupMenu`、`TrackPopupMenuEx`、`DestroyMenu` | 构建 `HMENU`,依附 `HWND` 跟踪选择,释放原生资源 | | Linux | GPUI 的 X11 backend 使用 `x11rb`,Wayland backend 使用 `wayland-client`;Wry 未完成的路径使用 `gtk` 和 `WebViewBuilderExtUnix` | NativeMenu 当前在 `Root` overlay 中绘制 GPUI `PopupMenu` 回退实现;原生 widget 宿主尚未实现 | 下面是实际 adapter 中调用系统 API 的节选,省略了菜单项构建及错误处理: ```rust // macOS: objc2-app-kit, running on the AppKit main thread. let mtm = MainThreadMarker::new()?; let menu = NSMenu::new(mtm); menu.popUpMenuPositioningItem_atLocation_inView(None, point, Some(view)); // Windows:windows::Win32::UI::WindowsAndMessaging。 let menu = unsafe { CreatePopupMenu() }.ok()?; let selected = unsafe { TrackPopupMenuEx(menu, flags.0, x, y, hwnd, None) }; let _ = unsafe { DestroyMenu(menu) }; ``` macOS adapter 取得 AppKit `NSView`,以它为 `NSMenu` anchor,把 GPUI 左上角原点的逻辑坐标换算为 AppKit view 坐标,再运行系统菜单的 tracking loop。Windows adapter 取得 `HWND`,把逻辑像素换成物理 client 坐标,再换成 screen 坐标,调用 `TrackPopupMenuEx`。两者都在**没有持有 GPUI 可变借用时**运行阻塞的系统 tracking loop,随后通过 foreground task、`cx.update(...)`、保存的 `AnyWindowHandle::update(...)`,用 `Window::dispatch_action` 派发所选 `Action`。窗口已关闭或用户取消时,不派发 Action。 调用方只需提供语义化菜单项和位置;下例的 `Copy`、`Paste` 是应用定义的 GPUI Action: ```rust NativeMenu::new() .menu("Copy", Box::new(Copy)) .menu("Paste", Box::new(Paste)) .show(position, window, cx); ``` 从 `gpui_kit::component::native_menu::NativeMenu` 导入该类型。`position` 是窗口相对的逻辑像素 `Point`,例如 `MouseDownEvent::position`。builder 还支持分隔线、子菜单、禁用和勾选状态及图标;`NativeMenu::from(gpui_kit::Menu)` 可复用已有的 GPUI 菜单定义。`show` 消耗菜单并立即返回;空菜单不会显示任何内容。打开菜单前,先让目标 Action 处理器获得 Focus:选择结果会派发到窗口当前的焦点上下文。在 Linux 上,回退实现向 `Root` 获取 overlay;窗口没有这个根节点,就没有可显示的回退菜单。 [story](https://github.com/longbridge/gpui-kit/blob/main/crates/story/src/stories/native_menu_story.rs)展示 Focus 设置和 Action 处理。Linux 上,[回退实现](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/native_menu/fallback.rs)在 `Root` overlay 中构建 GPUI `PopupMenu`,仍受 GPUI 窗口边界限制。 ## 4. 嵌入式子视图实例:WebView [WebView 例子](https://github.com/longbridge/gpui-kit/blob/main/examples/webview/src/main.rs)从有效的 GPUI window 创建 Wry 原生子视图,再把 wrapper 存入 `Entity`: ```rust let webview = cx.new(|cx| { use raw_window_handle::HasWindowHandle; let handle = window.window_handle().expect("No window handle"); let native = wry::WebViewBuilder::new() .build_as_child(&handle) .expect("Failed to create WebView"); gpui_wry::WebView::new(native, window, cx) }); ``` wrapper 在普通 GPUI element tree 中渲染一个自定义 `Element`。`request_layout` 占据 layout 区域;`prepaint` 得到计算好的 `Bounds`,以逻辑坐标调用 Wry 的 `set_bounds`,并插入 GPUI hitbox。`paint` 注册点击外部时的 Focus 行为。**WebView 像素由系统子视图绘制,不由 GPUI 绘制。** GPUI 的 `ContentMask` 或 hitbox 不会改变它在系统 compositor 中的层级。owner drop 时隐藏原生视图;克隆的 `WebViewHandle` 应在父窗口销毁前释放。 当前 WebView 位于同区域 GPUI 内容之上,因此 GPUI popover、dialog、tooltip 无法可靠覆盖它。Windows 例子在 `gpui_kit::application()` 之前设置 `GPUI_DISABLE_DIRECT_COMPOSITION=true`,以支持这条子视图路径。Linux 例子中的 GTK 路径标记为未完成。用法与限制详见 [WebView](./webview)。 在 Linux 上,例子引入 `gtk` 和 Wry 的 `WebViewBuilderExtUnix`,创建 `gtk::Fixed`,再调用 `build_gtk(&fixed)`。源码本身标记宿主初始化未完成:创建 GTK widget **不会**自动将它附着到 GPUI 的 XCB 或 Wayland window。当前固定版本的 GPUI Linux backend 在 X11 使用 `x11rb` 的 `configure_window` 等调用,在 Wayland 使用 `wayland-client` 的 `WlSurface::commit`。Linux adapter 需要接入对应 backend 的 connection 与 event loop,管理 surface 生命周期,并安排输入和 compositor 顺序;只有 raw `XcbWindowHandle` 或 `WaylandWindowHandle` 无法完成这些工作。当前 `gpui-wry` 例子无法作为最后这一步的可运行模板。 ## 5. 实现其他原生控件 1. 定义一个面向 GPUI 的操作,并明确各平台的返回结果语义。说明 Linux 实现是系统原生、GPUI 绘制,还是不支持。 2. 把 AppKit、Win32 和 Linux backend 放进各自的 adapter;raw handle 与系统类型留在 adapter 内部。 3. 嵌入式视图由 `Entity` 管理生命周期;在 `prepaint` 中把 GPUI bounds 同步给系统视图,并测试缩放、Focus、输入、窗口关闭。NativeMenu 这类系统 UI 则应在不持有 GPUI 可变借用时运行 tracking loop,再把结果送回 GPUI。 4. 分别测试实际 backend。GPUI headless 测试能检查状态和 Action,但无法证明系统定位、原生 Focus 或 compositor 层级正确。 ## 调试与验证原生边界 先在目标机器运行 `cargo check -p gpui-component` 或 `cargo check -p gpui-wry`。再运行上面的 NativeMenu story 或 WebView 例子,分别检查普通与高缩放倍率下的行为。Cargo 检查成功仅说明当前目标平台通过类型检查;它不会链接或运行应用,也不能证明原生输入和层级正确。 | 现象 | 在本仓库中检查 | | --- | --- | | 菜单没有出现 | 确认菜单非空、位置使用窗口相对的逻辑像素,并核对实际 `RawWindowHandle` 变体是否匹配 adapter。Linux 还须确认窗口使用 GPUI Kit 的 `Root`,使 `WindowState::native_menu_overlay` 可用。 | | 菜单出现但选择后没有效果 | 在 `show` 之前让目标 view 获得 Focus,注册其 `on_action` 处理器,并检查是否取消了选择,或窗口是否在保存的 `AnyWindowHandle` 更新前关闭。 | | 高缩放倍率下菜单位置偏移 | 核对 AppKit 的 Y 坐标翻转,或 Win32 的逻辑到物理坐标转换及随后的 `ClientToScreen`;把窗口移到其他屏幕后再测试。 | | Windows 上 WebView 空白 | 检查例子的 DirectComposition 设置是否在 GPUI 初始化前生效,以及 WebView 是否作为有效窗口的子视图创建。 | | WebView bounds 未更新或意外拦截输入 | 检查 `prepaint` 中的 bounds 更新、可见状态、`hide` 时的焦点归还,以及原生 compositor 层级。GPUI hitbox 或 content mask 不会裁剪原生像素,也不会改变其层级。 | 添加 adapter 时,要在各受支持 backend 上测试打开、取消、选择、调整大小、缩放、隐藏和关闭父窗口。确保原生对象及所有克隆 handle 的生命周期不超过父窗口。对尚不支持的行为明确说明,不要把 Rust 构建成功当作平台支持。 ### 合成边界 当前 checkout 的 `gpui-wry` wrapper 没有提供让 GPUI overlay 覆盖其原生子视图的 API。应把 overlay 交互放在 WebView bounds 之外,或使用单独的窗口。当前行为见 [WebView](./webview);若要依赖实验性 GPUI 合成分支,须另行验证。 --- # Action Source: /versions/v0.7.1/zh-CN/docs/action 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/action 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 **Action** 表达应用可以执行的操作。快捷键、菜单项、命令面板、按钮或另一个 Action handler 都可以派发同一个有类型的值。GPUI 把它路由到 [Element](./element) 树中负责该命令的区域。[Event](./event) 则沿另一个方向工作:状态改变后,它报告已经发生的事情。 [GPUI Action](https://docs.rs/crate/gpui-pre/0.3.8/source/src/action.rs) 源码定义了本页介绍的宏、trait 和 registry。 本页说明命令定义与派发。若尚未建立键盘目标,先读 [Focus](./focus);按键写法、Context 匹配和 Keymap 设置见 [KeyBinding](./keybinding)。 ## 运行仓库中的 Action 示例 在仓库根目录直接打开 Story Gallery 的 Tree 页面: ```sh cargo run -p gpui-component-story -- Tree ``` 选中一行文件树,再按 **Enter**;终端会打印 `Renaming item: ...`。换一行再试,可以看到 handler 读取的是当前选择。如果 Enter 没反应,先点击树中的一行:这个快捷键只在 Tree story 的 Focus Path 中生效。这个示例不会真的重命名文件。 对照 [`crates/story/src/stories/tree_story.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/story/src/stories/tree_story.rs) 阅读实现: 1. `actions!(story, [Rename, OpenFile, Delete])` 定义有类型的命令。 2. `init` 把 `enter` 绑定到 `TreeStory` Context 下的 `Rename`;应用初始化时由 [`stories::init`](https://github.com/longbridge/gpui-kit/blob/main/crates/story/src/stories/mod.rs) 调用。 3. `TreeStory::render` 在容器上设置 `.key_context(CONTEXT)` 和 `.on_action(cx.listener(Self::on_action_rename))`;子级 Tree 提供活动的 Focus Path。 4. `on_action_rename` 从 `tree_state` 读取选中项。选中状态属于 Tree state;Action 表达用户请求的操作。 下文从这条路由逐步展开。菜单或按钮以后也能派发同一个 Action,无需复制 handler。 ## 在 `hello_world` 中写一个可运行的 Action Tree story 适合追踪现成应用。要亲手搭起整条路由,可暂时把 `examples/hello_world/src/main.rs` 替换为下面的完整程序。它复用现有 `hello_world` 包,不需要新增 crate 或依赖。 ```rust use gpui_kit::component::button::Button; use gpui_kit::*; actions!(counter, [Increment]); struct Counter { count: usize, focus: FocusHandle, } impl Counter { fn new(window: &mut Window, cx: &mut Context) -> Self { let focus = cx.focus_handle().tab_stop(true); focus.focus(window, cx); Self { count: 0, focus } } fn on_increment(&mut self, _: &Increment, _: &mut Window, cx: &mut Context) { self.count += 1; cx.notify(); } } impl Render for Counter { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .flex() .flex_col() .gap_2() .p_4() .track_focus(&self.focus) .key_context("Counter") .on_action(cx.listener(Self::on_increment)) .child(format!("Count: {}", self.count)) .child("Press Enter while this region has focus") .child( Button::new("increment") .label("Increment") .on_click(cx.listener(|this, _, window, cx| { this.focus.dispatch_action(&Increment, window, cx); })), ) } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); cx.bind_keys([KeyBinding::new("enter", Increment, Some("Counter"))]); gpui_kit::open_window(WindowOptions::default(), cx, |window, cx| { cx.new(|cx| Counter::new(window, cx)) }) .expect("failed to open window"); }); } ``` 在仓库根目录运行: ```sh cargo run -p hello_world --bin hello_world ``` 窗口从 `Count: 0` 开始。在计数器区域拥有 Focus 时按 **Enter**,或点击 **Increment**,每次都会让界面上的数字增加 1。计数器的 `FocusHandle` 保存在 Entity 中,并附加到渲染出的容器。`cx.bind_keys` 只在路径中包含 `Counter` 时才把 Enter 映射为 `Increment`。容器上的 `.on_action` 调用 `on_increment`,修改保留状态,再通过 `cx.notify()` 让新数字显示出来。按钮借助保存的 handle 向同一个容器派发同一个 Action;即使点击按钮改变 Focus,命令仍能到达计数器。 可以做两个小实验,逐项检查路由,然后还原源码。先删掉 `.key_context("Counter")`:Enter 不再匹配该绑定,按钮仍然有效,因为直接派发不会查询 Key Context。恢复 Context 后删掉 `.on_action(...)`:两个入口都无法改变计数,因为路径上没有 handler。如果 handler 已执行而数字未刷新,检查它是否修改了保留状态并调用 `cx.notify()`。实验结束后恢复 `examples/hello_world/src/main.rs`。 ## 一条命令,多个入口 使用 namespace 定义 unit Action。`actions!` 会生成类型,并注册稳定名称,这里的名称是 `chat::SendMessage`: ```rust use gpui_kit::*; actions!(chat, [SendMessage]); ``` 元素 handler 接收有类型的 Action、窗口和所属实体的 [Context](./context)。`cx.listener` 将方法适配成元素 callback: ```rust impl Chat { fn on_action_send_message( &mut self, _: &SendMessage, _: &mut Window, cx: &mut Context, ) { self.submit_draft(); cx.notify(); } } // In Chat::render: div() .track_focus(&self.focus_handle) .key_context("Chat") .on_action(cx.listener(Self::on_action_send_message)) .child("Chat") ``` 把快捷键绑定到 `SendMessage`,其他入口也使用同一个 Action: ```rust window.dispatch_action(Box::new(SendMessage), cx); // Button or command palette MenuItem::action("Send Message", SendMessage) // Native application menu ``` 命令逻辑只保存在一个 handler 中。直接调用 `window.dispatch_action(...)` **不需要** KeyBinding,也不会匹配 Key Context;按键要先转换为 Action,才会用到这两项。绑定方法见 [KeyBinding](./keybinding)。 ## 定义携带数据的 Action Action 可以携带数据。derive `Action` 时需要 `Clone` 和 `PartialEq`。如果还要通过有名称的 JSON Keymap 项构造它,则同时 derive `Deserialize` 和 `JsonSchema`: ```rust #[derive(Action, Clone, PartialEq, serde::Deserialize, schemars::JsonSchema)] #[action(namespace = chat)] struct InsertPrompt { text: String, } ``` Action registry 根据 namespace 和类型名,用 Action 名称及可选 JSON payload 构造有类型的值。这样,可配置的 Keymap 与命令界面可以引用同一命令。名称必须唯一;重复注册会在创建应用时 panic。 上面的 JSON 示例还要求应用依赖启用了 `derive` 功能的 `serde` 以及 `schemars`,供这两个 derive 使用。 如果 handler 能从 owner 读取所需状态,就用 unit Action,例如 Tree story 的 `Rename`。如果调用方必须指定目标或提供值,就用携带数据的 Action。Action payload 是命令输入,不是存放 owner 中动态 UI 状态的地方。 运行时命令的 payload 不应来自 JSON 时,`no_json` 保留有类型的派发,但不允许从 JSON 构造: ```rust #[derive(Action, Clone, PartialEq)] #[action(namespace = workspace, no_json)] struct OpenConversation { conversation_id: String, } ``` 给命令选择稳定的动词型名称。含义相同的各个入口应共用一个 Action 类型;状态改变由命令 owner 实现,而不是散落在不同的输入 callback 中。 ## Focus 决定路由 GPUI 快捷键派发的三个步骤 Focus 建立派发路径,Key Context 选择匹配的快捷键,Action 沿路径交给 handler。 1 · FOCUSBuild the Dispatch Path Chat → Workspacefocused Element → ancestors Start at the Element with Focus. 2 · KEY CONTEXTMatch a KeyBinding ⌘ Enter + "Chat"→ SendMessage Use key_context values on the path. 3 · ACTIONDispatch along the path Chat handlerthen parents if propagated The most specific handler runs first. [FocusHandle](./window) 标识一个键盘目标。由负责交互的实体保存 handle,并在每次渲染时将它附加到元素: ```rust struct Chat { focus_handle: FocusHandle, } impl Chat { fn new(cx: &mut Context) -> Self { Self { focus_handle: cx.focus_handle() } } } impl Focusable for Chat { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } // In Chat::render: div() .track_focus(&self.focus_handle) .key_context("Chat") .on_action(cx.listener(Self::on_action_send_message)) ``` `track_focus` 把 handle 注册在元素的 dispatch node 上。默认情况下,元素内部的 mouse down 会让此 handle 获得 Focus。内层控件需要保留自己的 Focus 时,可在它的 mouse down handler 中调用 `window.prevent_default()`,阻止祖先的默认 Focus 转移。注册 handle 不会在 render 时立即聚焦;打开视图或用户进入视图时调用 `self.focus_handle.focus(window, cx)`。 `handle.is_focused(window)` 检查当前目标是否正好获得 Focus;`handle.contains_focused(window, cx)` 也接受获得 Focus 的子元素,适合子控件活动时保持面板激活。注册的 handle 不会自动成为 Tab stop:创建它时可配置 `cx.focus_handle().tab_stop(true)`。Stateless component 可用 `window.use_keyed_state(...)` 在多次 render 间保留 handle。 GPUI 从获得 Focus 的元素经过各级祖先组成 **Dispatch Path**。路径上的 `key_context("Chat")` 让相应的上下文快捷键有资格匹配;匹配的按键生成 Action。随后 Action 沿这条路径派发。Sibling 上的 handler 无法从这条路径到达。 ### 追踪一次快捷键 假设获得 Focus 的元素位于 `Chat` 内,而 `Chat` 位于 `Workspace` 内。`KeyBinding::new("secondary-enter", SendMessage, Some("Chat"))` 只在当前 Focus Path 中有 `Chat` 时才有资格匹配。GPUI 选出匹配的 binding,再沿该路径派发 `SendMessage`。`Chat` 的 bubble handler 会先于 `Workspace` 的 handler 运行。如果路径上的元素都没有处理这个 Action,全局 `cx.on_action` handler 可以接收它。Context 负责选择 **binding**,不会自行选定 handler。绑定竞争与 predicate 写法见 [KeyBinding](./keybinding)。 点击触发命令时,`window.dispatch_action(Box::new(SendMessage), cx)` 使用调用时捕获的 Focus。确保预期路径已获得 Focus,或通过保留的 `FocusHandle` 派发。如果不需要共享命令路由,按钮 callback 也可以直接调用 owner 的方法。 ## Handler 顺序与传播 Action 派发分为两个阶段: 1. **Capture**:先调用全局 capture listener,再从根节点到目标节点调用匹配的 `.capture_action(...)` listener。 2. **Bubble**:从目标节点到根节点调用匹配的 `.on_action(...)` listener;如果继续传播,最后调用全局 `cx.on_action(...)` listener。 因此,离目标最近的 bubble handler 先处理命令。Action handler 默认会停止 bubble 传播。如果当前 handler 不处理该 Action,应该调用 `cx.propagate()`,让父级或全局 handler 继续尝试: ```rust fn on_action_close( &mut self, _: &ClosePanel, _: &mut Window, cx: &mut Context, ) { if !self.can_close() { cx.propagate(); return; } self.close(); cx.notify(); } ``` Capture listener 可以调用 `cx.stop_propagation()`,阻止派发到达目标。全局 bubble handler 默认也会停止传播,因此不处理命令的全局 fallback 应调用 `cx.propagate()`。这些方法控制 Action 派发;`window.prevent_default()` 控制 mouse Focus 转移等默认输入行为。Pointer 与 keyboard Event 的传播见 [Event](./event)。 要观察命令而不消费它,capture listener 保持传播开启即可;capture 开始时已默认开启传播。在 bubble 阶段,每个 listener 开始时都默认停止传播。只有希望父级或全局 fallback 继续接收 Action 时,才调用 `cx.propagate()`。单纯提前 `return` 不会把命令传下去。 `window.dispatch_action(Box::new(action), cx)` 记录调用时的 Focus 目标,并把派发延迟到已渲染的帧。明确要派发到某个 owner 时,`focus_handle.dispatch_action(&action, window, cx)` 从渲染该 handle 的元素开始,前提是它仍在当前帧中。`cx.dispatch_action(&action)` 派发到活跃窗口;没有活跃窗口时则派发到全局 handler。Popup 或点击改变 Focus 后,这些方法的区别尤其重要。 | 调用 | 目标 | 适用场景 | | --- | --- | --- | | `window.dispatch_action(Box::new(action), cx)` | 调用时本窗口的当前 Focus | 面向获得 Focus 的区域的菜单或按钮命令;派发会延后执行。 | | `focus_handle.dispatch_action(&action, window, cx)` | 当前帧中渲染该 handle 的元素 | 即使其他控件已获得 Focus,仍要命中指定的已渲染区域;handle 未渲染时不会派发。 | | `cx.dispatch_action(&action)` | 活跃窗口;没有活跃窗口时为全局 handler | 调用方只有 `App` context 的应用级命令。 | 这些调用都不会计算 Key Context predicate。只有**按键**通过 keymap 选取 Action 时,Key Context 才参与匹配。派发后的 Action 仍需要在选定目标的路径上找到 handler。 ## 通过共同 owner 协调并列区域 假设在 Sidebar 选中会话后,Chat 需要打开它。Sidebar 用 `OpenConversation` 描述意图;共同 owner `Workspace` 处理 Action,再更新 Chat Entity: ```rust impl Workspace { fn on_action_open_conversation( &mut self, action: &OpenConversation, window: &mut Window, cx: &mut Context, ) { self.chat.update(cx, |chat, cx| { chat.open(action.conversation_id.clone(), window, cx); }); } } // Workspace renders both regions under its own handler. h_flex() .on_action(cx.listener(Self::on_action_open_conversation)) .child(self.sidebar.clone()) .child(self.chat.clone()) // The Sidebar focus path stays active during interaction: window.dispatch_action(Box::new(OpenConversation { conversation_id }), cx); ``` 路由是 **Sidebar → Workspace**。然后 `Workspace` 通过 [Entity](./entity) API 更新 Chat。若只把 handler 挂在 Chat 上,来自 Sidebar 的 Action 无法到达它:Chat 是 sibling,位于 Sidebar 的 Dispatch Path 之外。必须无视当前 Focus、明确派发到某个已渲染区域时,可保留该区域的 `FocusHandle`,使用它的 `dispatch_action` 方法。更大的功能模块如何安排 owner,见 [Coding Guides](./coding-guides)。 GPUI Kit 的 Command palette 和 Popup Menu 也使用这种方式:选中项提供一个 boxed Action,窗口再派发它。Command palette 还在自身元素上持有 Focus handle、Key Context 与导航 Action handler。框架组件负责选择与键盘交互机制;应用 owner 负责命令的具体含义。 ## 排查丢失的命令 快捷键只有点击某个区域之后才生效时,按顺序检查路由: 1. 哪个 `FocusHandle` 获得 Focus?它是否通过 `track_focus` 附加在已渲染树上? 2. 所需的 `key_context` 是否在该元素或祖先上?绑定匹配见 [KeyBinding](./keybinding)。 3. 有类型的 `.on_action(...)` handler 是否在生成的 Dispatch Path 上? 4. 是否被更近的 handler 消费了 Action?不处理的 handler 是否忘了调用 `cx.propagate()`? 5. 如果直接派发前 Focus 已改变,是否需要用明确的 `FocusHandle` 指定目标? 按键没有反应时,先区分**没有匹配的 binding**与**找不到可到达的 handler**。尝试通过目标 `FocusHandle` 直接派发 Action:如果能到达 handler,应检查按键写法与 Context predicate;如果仍无法到达,应检查 handle 是否已渲染、Dispatch Path 和传播。handler 已执行但画面未更新时,应确认它修改了 owner 的状态,并在需要重绘时调用 `cx.notify()`。 让负责命令的区域同时持有 handle、context 和 handler。全局 handler 只用于真正适用于整个应用的操作。 ## 用 Tree story 练习 1. **跟踪路径。** 运行 `cargo run -p gpui-component-story -- Tree`,选中一行并按 Enter。在 `tree_story.rs` 中找出 binding、context 和 handler。哪个组件持有选中项?哪个 entity 持有命令 handler? 2. **更换输入。** 在自己的分支中,临时将 Tree story 的 binding 从 `"enter"` 改成 `"secondary-r"`。重新运行 Story Gallery,确认新按键仍调用同一个 `Rename` handler,而 Enter 不再调用。实验后还原源码。 3. **预测传播。** 在一个小视图的子级和父级都挂上 `Rename` handler。子级仅在没有选中项时调用 `cx.propagate()`。先预测两种情况下各有哪些 handler 运行,再用可见输出验证。handler 直接返回而不调用 `cx.propagate()`,就会消费 Action。 4. **添加目标。** 用 `#[action(namespace = story, no_json)]` 给必须携带行 ID 的操作建模。从行 callback 派发,把状态修改保留在 owner 的 handler 中。与从当前选中项读取目标的 `Rename` 比较。 --- # Animation Source: /versions/v0.7.1/zh-CN/docs/animation 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/animation 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 GPUI Kit 有三个动效层次。应按**变化中的值由谁持有**来选择,而不是只看动画的外观: | 层次 | 适用场景 | 状态与策略 | | --- | --- | --- | | GPUI `Animation`、`AnimationExt` | 元素入场、循环提示,或挂载期间播放固定步骤 | GPUI 在包装元素的 [`ElementId`](./element_id) 下保存播放进度;调用方决定时长、曲线和视觉属性。 | | [GPUI Base Motion](/versions/v0.7.1/zh-CN/base/motion) | 运动中会变化的目标、卸载前的退出、关键帧和测量式展开 | Base 以稳定 key 保存每个通道,活动期间通过 [Window](./window) 请求帧;调用方决定视觉结果。 | | GPUI Component 动效 | 外观需要跟随主题的样式化控件 | `cx.theme().motion_tokens()` 提供语义化时长、曲线、弹簧和距离;组件再与 GPUI 或 Base 组合。 | 语义状态归应用所有:对话框是否打开、当前选中哪个标签、滑块指向哪里。动画只负责呈现这些状态。无论在动画两端,还是关闭动效后,结果都必须清楚。 设计第一个动画时,先判断新事件是否可能在动画结束前改变目标。如果会,先用目标驱动的 Base `transition` 或 `spring`。如果一个已挂载元素只需从头到尾播放固定过程,则用 GPUI `with_animation`。用户连续点击时,这个区别尤其重要:播放时钟与可变目标的中断行为不同。 ## 从选中状态开始做动画 在仓库根目录运行已有的 [Motion 示例](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/motion/mod.rs): ```sh cargo run -p gpui-base-examples --bin motion ``` 示例初始打开 **Sliding time**。切换到 **Spring** 标签,交替点击 **Focus** 和 **Flow**。深色指示块会移向选中的半边;还没停下就切换选项,可以看到它转向。两个选项的文字始终可见,选中文字的颜色也会立即改变;动画帮助表现状态在空间上的连续性。 ### 把目标留在视图状态中 `MotionExample` 保存 `spring_selected: bool`。每个选项的按钮把自己的值写入状态,并通知视图: ```rust .on_click(move |_, _, cx| { _ = entity.update(cx, |this, cx| { this.spring_selected = selected; cx.notify(); }); }) ``` 这个布尔值决定选中 **Focus**(`false`)还是 **Flow**(`true`)。它是持久的状态;视图不必保存指示块当前的像素位置。再次点击已选中的选项,目标不会改变。 ### 每次 render 都采样弹簧 在 `spring_demo` 中,布尔值决定目标是 0 还是 120 像素。Base 的 `spring` 返回当前帧的值: ```rust let x = spring( "selector-indicator", if self.spring_selected { 120. } else { 0. }, Spring::new(Duration::from_millis(420)).with_damping(0.68), window, cx, ); ``` `"selector-indicator"` 是稳定的通道 ID。后续 render 因而能找到同一个弹簧;目标改变时,它保留当前位置和速度。首次 render 时,弹簧直接采用目标值;点击改变目标后才开始移动。运动期间 Base 会请求后续帧。指示块挂载期间,每次 render 都要继续采样这个通道;点击处理器中的 `cx.notify()` 用于报告状态变化,不需要 render 循环或计时器。 ### 把采样值用于指示块 轨道是宽 240 像素的相对定位父元素。指示块把采样值用作左边距: ```rust div() .relative() .w(px(240.)) .h_10() .child( div() .absolute() .left(px(x)) .w(px(119.)) .h_full(), ) ``` 源码还为轨道、指示块和选项按钮设置了样式。0 和 120 两个目标让宽 119 像素的指示块分别位于对应选项下方。示例使用固定宽度,便于理解坐标计算;产品组件应使用自己的尺寸与主题策略。 ### 跟踪一次点击直到静止 | 时刻 | 发生了什么 | 谁持有它 | | --- | --- | --- | | 点击 **Flow** | 处理器写入 `spring_selected = true` 并调用 `cx.notify()`。 | `MotionExample` 持有选中状态。 | | 下一次 render | `spring_demo` 将新目标 `120.` 传给名为 `"selector-indicator"` 的通道。 | Base 保留该通道采样到的位置与速度。 | | 移动期间 | `spring` 返回新的 `x`,并请求后续动画帧。指示块将 `x` 用于 `.left(px(x))`。 | GPUI 调度请求的帧;render 代码描述每次的结果。 | | 静止时 | `spring` 返回目标值,不再请求下一帧运动。 | 视图仍持有 `spring_selected`,下一次点击还能改变目标。 | 在 `spring_demo` 中将 `Duration::from_millis(420)` 改为 `Duration::from_millis(700)`,重新执行同一运行命令,并在指示块停下前切换 **Focus** 与 **Flow**。这会改变弹簧的响应策略,不改变点击处理器或语义选中状态。观察后恢复为 `420`。再把 **Flow** 的弹簧目标从 `120.` 改为 `60.`:指示块会停在两个标签之间,选中文字仍正确变化。恢复 `120.`;目标值是呈现用的几何位置,`spring_selected` 才回答“当前选中了哪项”。 要观察减弱动态效果,可在示例 `run` 函数中的 `gpui_base::init(cx);` 后立即加上 `cx.set_reduce_motion(true);`,然后重新运行。选中项仍会变化,弹簧则直接到达目标,不再请求动画帧。观察后移除新增的代码。若启用动画后指示块没有移动,检查目标是否变化、通道 ID 是否稳定,以及指示块挂载期间是否继续采样 `spring`。其他运动属性应使用各自的 ID;共用通道会混淆保留值。 ### 退出完成后再卸载元素 在同一示例中打开 **Presence**,点击 **Remove**。示例立即把 `present` 改为 `false`,但仍渲染提示框,直到透明度降为零。render 路径先采样 presence,再决定是否包含提示框: ```rust let sample = Presence::new("presence-notice", self.present) .transition(Transition::new(Duration::from_millis(360)).easing(Easing::EaseInOut)) .sample(window, cx); // Keep the notice mounted during its exit transition. if sample.should_render() { div().opacity(sample.progress).child("Background task") } else { div() } ``` 此处摘出生命周期决策;[完整示例](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/motion/mod.rs)还负责布局和样式。如果改用 `if self.present` 判断是否渲染,点击 **Remove** 后的第一次更新就会卸载提示框,退出动画也无从绘制。在退出期间点击 **Insert**,可看到 `Presence` 从当前采样值反向。减弱动态效果下,它会直接到达相应端点。`present` 仍是逻辑状态;当操作已不可用时,视觉上还在退出的提示框不应继续提供活动控件。 ## GPUI 元素动画 `Animation::new(duration)` 创建播放一次、线性变化的动画。`AnimationExt::with_animation(id, animation, animator)` 包装一个 `IntoElement`;回调收到元素及经过 easing 映射的进度值。GPUI 在布局阶段调用它,把返回元素的样式用于当前帧,并在结束前继续请求帧。回调可修改元素支持的属性,例如透明度或变换。包装器的播放规则可参见 [GPUI 0.3.8 动画源码](https://docs.rs/gpui-pre/0.3.8/src/gpui/elements/animation.rs.html)。这里的 `gpui-pre` 仅是 GPUI 的发布与版本同步包名,并不是额外的应用层。 ```rust use std::time::Duration; use gpui_kit::*; let entering = div() .child("Saved") .with_animation( ("saved-notice", generation), Animation::new(Duration::from_millis(180)), |element, progress| element.opacity(progress), ); ``` ### 并排体验单次重播与弹簧改目标 暂时将 `examples/hello_world/src/main.rs` 替换为以下练习,再运行 `cargo run -p hello_world`。它复用现有示例 package 和同一个 `gpui-kit` 依赖: ```rust use std::time::Duration; use gpui_kit::*; use gpui_kit::base::{Spring, spring}; use gpui_kit::component::button::*; use gpui_kit::prelude::FluentBuilder as _; struct MotionProbe { generation: usize, show_notice: bool, selected: bool, } impl Render for MotionProbe { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let offset = spring( "motion-probe-indicator", if self.selected { 120. } else { 0. }, Spring::new(Duration::from_millis(420)).with_damping(0.68), window, cx, ); div() .flex() .flex_col() .gap_4() .p_6() .when(self.show_notice, |parent| { parent.child( div().child("Saved").with_animation( ("motion-probe-notice", self.generation), Animation::new(Duration::from_millis(900)), |element, progress| element.opacity(progress), ), ) }) .child( Button::new("replay").label("Replay notice").on_click(cx.listener( |this, _, _, cx| { this.generation += 1; this.show_notice = true; cx.notify(); }, )), ) .child( Button::new("hide").label("Hide notice").on_click(cx.listener( |this, _, _, cx| { this.show_notice = false; cx.notify(); }, )), ) .child( div().relative().w(px(240.)).h_10().child( div() .absolute() .left(px(offset)) .w(px(119.)) .h_full() .bg(rgb(0x3366cc)), ), ) .child( Button::new("retarget").label("Switch target").on_click(cx.listener( |this, _, _, cx| { this.selected = !this.selected; cx.notify(); }, )), ) } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| MotionProbe { generation: 0, show_notice: true, selected: false, }) }) .expect("failed to open window"); }); } ``` **Saved** 提示先淡入一次,然后保持显示。淡入结束前点击 **Hide notice**:`show_notice = false` 会卸载包装元素,因此这次播放停止。点击 **Replay notice** 会重新挂载包装元素并再次淡入。蓝色条仍在移动时再次点击 **Switch target**:它会从当前采样位置改变方向,因为 Base 弹簧的通道 ID 稳定,变化的是 `selected` 对应的目标。弹簧的 420 ms 是响应尺度,不是固定的完成期限;它达到配置的容差后才停止。点击其中一个控件不会重新开始另一个动效。验证 ID 时,把 `("motion-probe-notice", self.generation)` 精确替换成 `"motion-probe-notice"`,保留点击处理。在提示保持挂载期间,后续点击 **Replay notice** 仍会更新 view 状态,但已完成的单次动画不会再次淡入。练习后恢复 tuple ID 和原始示例文件。 要在减弱动态效果下重做这个练习,在 `gpui_kit::init(cx);` 后立即加入 `cx.set_reduce_motion(true);`,重新运行。**Saved** 会以完全不透明的状态出现,不再淡入;弹簧直接采用选中项的目标,**Hide notice** 仍会移除提示。观察后移除临时增加的代码。 蓝色条只用于观察弹簧采样值;固定的 240 像素轨道和 120 像素目标属于这个示例的局部几何。实际应用还应在没有动效时清楚表达选中状态。要对照带文字标签和交互状态的完整选择器,运行现有 [Motion 示例](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/motion/mod.rs)并打开 **Spring** 标签页。 通过 `gpui_kit::*`(或显式导入 trait)让 `AnimationExt` 生效。ID 标识的是动画包装元素,而不是文字或视觉属性。同一位置、同一 ID 的包装元素再次渲染时会继续已有播放;每次渲染重新构造 `Animation::new(...)` **不会**重播。需要重新入场时,把应用维护的 generation 放入 ID。移除包装元素会结束它的生命周期。一次性动画结束后,只要同一包装元素仍挂载,就保持终值。 `with_easing(f)` 把归一化时间映射到动画进度。GPUI 提供 `ease_in_out`、`bounce` 等函数;自定义曲线必须返回有限数值。曲线允许超出 `0..1`,因此当样式属性的合法范围更窄时,应自行裁剪结果。`repeat()` 在本地循环;`repeat_synced()` 根据应用共享时钟循环,适合多个提示同步。`with_max_fps(rate)` 限制该动画的最高重绘频率;非正数和非有限值会被忽略。实际帧表现的测量方法见 [FPS](./fps)。`with_animations(id, animations, |element, step, progress| ...)` 播放固定步骤链,并提供当前步骤索引。 GPUI 还提供 `AnimationExt::with_spring(id, SpringAnimation, animator)`,以弹簧驱动一个元素。稳定 ID 使目标改变时仍保留位置和速度。新挂载的弹簧默认从目标值开始,除非用 `SpringAnimation::from(...)` 指定起点。弹簧只作用于一个元素时可直接使用它;若一个独立 keyed 值要驱动组合中的多个部分,Base 的 `spring` 更合适。 GPUI 弹簧的 `SpringPlayback` 控制运行、暂停、停止、完成或取消。减弱动态效果会让**运行中**的弹簧直接到达目标;暂停和停止状态仍按各自的播放状态处理。目标与播放状态应由应用状态决定,不能把包装元素当成语义状态的唯一来源。 ### 中断时会怎样 `with_animation` 按已播放时间推进,并不是追逐可变目标的 transition。在同一 ID 下改变回调捕获的终点,原播放时钟仍会继续;改 ID 则开始新播放。这两种操作都不会自动以当前画面值作为新起点。选择指示块需要在连续点击时平滑反向,应使用目标驱动的 spring 或 Base `transition`。 GPUI 的 `AnimationExt` 遵守 `App::reduce_motion()`:一次性动画显示终点,循环动画显示起点,两者都不再请求动画帧。加载状态仍需文字或其他静态提示;停住的旋转图标不足以说明当前正在加载。 为每个新动效逐项回答:哪项应用状态改变了?哪个元素或通道 ID 在多次 render 间保持稳定?首次 render 采样到什么值?结束前再次输入会怎样?何时停止请求帧?减弱动态效果后,界面还是否能被理解?这些问题通常比先调时长曲线更容易定位“动画没有执行”的原因。 ## GPUI Base Motion:keyed 值与生命周期 应用只依赖 `gpui-kit` 时,从 `gpui_kit::base` 导入这些 API: ```rust use gpui_kit::*; use gpui_kit::base::{Easing, Transition, transition}; use std::time::Duration; let opacity = transition( ("save-panel", "opacity"), if open { 1.0 } else { 0.0 }, Transition::new(Duration::from_millis(180)).easing(Easing::EaseOut), window, cx, ); div().opacity(opacity) ``` 这里的 `Transition` 是**值的时间策略**,不是带样式的元素。`transition` 返回当前采样值;`transition_with_status` 还返回 `Idle`、`Delayed`、`Running` 或 `Finished`。目标改变时,通道从当前采样值继续。直接反向会按剩余路程缩短返回时长。Base 只在延迟或运动期间请求帧。只要 owner 仍在,即使结果暂时不可见,也要在每次渲染时采样该通道,让保留的值正确收敛。 每个独立变化的值都要有自己的稳定通道 ID。按业务对象和属性设定命名空间,例如 `(project_element_id.clone(), "opacity")` 与 `(project_element_id.clone(), "height")`,其中 `project_element_id` 是稳定的 `ElementId`。两个通道共用 ID 会覆盖 retained state;每帧换 ID 则失去连续性。可重排列表要使用条目的业务 ID,而不是行号。即使描述同一个视觉元素,GPUI 包装元素 ID 和 Base 通道 ID 也服务于不同生命周期。 目标可能在收敛前再次变化时,使用 Base `spring(id, target, Spring, window, cx)`。重新设定目标时,它同时保留**位置和速度**。指针直接拖动期间使用 `Spring::with_travel(false)`,让值跟随指针;松开后恢复 travel。弹簧的 `epsilon` 使用目标值自身的单位,所以像素偏移通常需要比归一化透明度更粗的容差。 Base 还提供以下选择: | 需求 | API | 生命周期要点 | | --- | --- | --- | | 编排数值停靠点 | `Keyframes`、`Timing`、`animate_keyframes` | 相同 ID 会继续播放;需要重播时把应用维护的 generation 放入 ID。Timing 支持延迟、迭代和播放方向。 | | 连续的独立步骤 | `Sequence` | 每一步从上一步的绝对结束时刻开始;同一 ID 只播放一次,除非有意更改。活动步骤的目标变化时,会从当前采样值重新开始;sequence 不会自动反向。 | | 退出完成前继续挂载 | `Presence` | 逻辑关闭后继续采样,直到 `should_render()` 为 false 才停止渲染。退出中重新打开会从当前值反向。 | | 列表条目错峰 | `Stagger` | 根据索引和起点计算延迟;不持有列表及其 ID。 | | 展开未知高度的内容 | `MotionReveal` | 测量 child,再按调用方提供的进度裁剪可见高度;它本身不采样或推进进度。 | [Base Motion 指南](/versions/v0.7.1/zh-CN/base/motion)列出完整签名、参数校验、示例与基准测试。这里的 `Transition` 不同于旧的 `gpui_kit::base::animation::EffectTransition`:后者包装 GPUI `with_animation`,直接应用预设的淡入、滑动、宽度和高度效果。新的目标驱动动效优先使用 `base::motion` primitive,再自行应用采样值。 ## GPUI Component:语义化动效策略 样式化组件通过 `cx.theme().motion_tokens()` 共享策略。`MotionTokens` 包含 `duration_instant`、`duration_fast`、`duration_normal`、`duration_slow`;`easing_enter`、`easing_exit`、`easing_move`;`spring_control`、`spring_move`;以及 `distance_short`、`distance_medium`。默认值构成协调的尺度,并不意味着所有控件都必须动画。从当前主题读取 token,产品才能集中调整。 运行现有样式化控件:`cargo run -p gpui-component-story -- SwitchStory`。快速点击可用的开关两次:每次点击都改变 checked 状态,滑块会在运动途中改变方向。同页的禁用开关不会响应。打开 [`crates/component/src/switch.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/switch.rs),沿着 `checked` 找到滑块的目标偏移,并沿着 `cx.theme().motion_tokens().spring_move` 找到 Base `spring` 调用。再到 [`crates/component/src/theme/motion.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/theme/motion.rs),暂时将默认 `spring_move` 时长从 280 ms 改为 600 ms,重新运行 story:checked 状态相同,滑块收敛更慢。练习后恢复 280 ms。story 持有 checked 布尔值,组件将其映射成视觉目标,主题提供动效策略。 例如,[Switch](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/switch.rs) 源码 在 `(self.id.clone(), "thumb")` 通道上采样 Base spring,向选中或未选中时的滑块偏移移动,策略来自 `cx.theme().motion_tokens().spring_move`。[可调整大小手柄](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/resizable.rs) 源码 分别采样长度和透明度通道,使用 `duration_fast` 与 `easing_move`;指示块淡出时,细分隔线仍保留。[Collapsible](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/collapsible.rs) 通过 `.motion_id(id)` 启用测量式、可反向的展开。没有这个 ID 时,它直接挂载或卸载。 部分组件用 GPUI 元素包装器播放固定动画:[Spinner](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/spinner.rs) 循环旋转,[Popover](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/popover.rs) 播放入场。选择依据是组件需要持续追踪目标,还是播放固定流程;两者都可以属于样式层。 ## 减弱动态效果与帧请求 GPUI 把偏好保存在 `App` 中;`cx.reduce_motion()` 读取,`cx.set_reduce_motion(...)` 可设置。`gpui_kit::init(cx)` 会初始化 Base 的系统偏好处理。Base 在初始化时读取 macOS 和 Windows 的设置;Linux 桌面 portal 的设置到达后会继续跟踪变化;其他目标不修改该标志。应用显式设置后,Base 会尊重应用的选择。若要在初始化后重新读取 macOS 或 Windows,调用 `gpui_kit::base::apply_system_reduce_motion(cx)`。 GPUI 元素动画采用前述静态端点。Base 的有限 transition、spring、keyframes、presence 和 sequence 会立即到达相应目标或最终状态,并停止请求运动帧。`MotionReveal` 只消费进度:直接使用时,应在减弱动态效果下传入端点值,或由遵守该偏好的采样器驱动。子元素测量高度发生变化时,它仍可能请求一帧。无限循环的活动状态也必须有可理解的静态表示。自定义元素若自行持有时钟,应检查 `cx.reduce_motion()`,并且只在仍有必要运动时请求帧;不要在 render 中无条件调用 `cx.notify()`、`window.refresh()` 或 `window.request_animation_frame()`。 动效应用于解释出现、关闭、展开和空间连续性。当透明度或 transform 已足以说明关系时,避免大范围 layout 动画。过渡过程中也要协调键盘焦点、命中区域和语义状态;绘制位置改变本身不会向辅助技术播报新状态。界面变化的语义处理见[无障碍指南](./accessibility)。 --- # Auto Update Source: /versions/v0.7.1/zh-CN/docs/auto-update 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/auto-update 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 # 桌面应用自动更新 GPUI Kit 提供窗口、状态和界面组件,但没有统一的自动更新服务。应用需要决定发布渠道、更新元数据、产物验证、安装权限和重启方式;更新界面可以用 GPUI Kit 构建。先按[打包与分发](./packaging)准备可安装、可验证的产物,再添加更新入口。`gpui` 负责应用生命周期和任务调度,`gpui-kit` 整合框架能力,`gpui-base` 与 `gpui-component` 提供交互和显示组件;更新策略属于应用。使用 `gpui-shell` 扩展的应用还须确定扩展与宿主版本的兼容规则,不能只更新宿主二进制后假设旧扩展仍可工作。 自动更新通常分成两条路径: | 安装来源 | 推荐的更新归属 | 应用内动作 | | --- | --- | --- | | 用户可写目录中的便携版 | 应用自己管理经过验证的文件或完整应用包 | 检查、下载、安装,明确请求重启 | | macOS `.app` | 应用自己的完整 bundle 更新流程,或引导安装新版应用包 | 验证整个已签名 `.app`,不得只覆盖内部可执行文件 | | Windows Inno Setup 安装 `.exe` | Inno Setup 安装器 | 检查新版并启动受支持的升级流程;维护原有安装身份与卸载信息 | | Linux DEB/RPM 等系统包 | 发行版包管理器或软件中心 | 告知可升级,按包管理器规则执行;不要直接覆盖其管理的文件 | 表中的便携版并不表示所有目录都可写。系统安装目录可能需要管理员权限,应用也可能正占用将要替换的文件。更新器必须根据**实际安装来源**选择路径,而不能仅根据操作系统猜测。参见 [`self_update` 的权限与 bundle 说明](https://docs.rs/crate/self_update/1.3.0/source/README.md)。 ## 先定义发布契约 在编写更新界面前,给每个 release 明确以下字段和规则: 1. **版本与渠道**:使用可比较的版本号;明确 stable、preview 等渠道如何筛选,以及是否允许跨渠道。只在明确的恢复流程中降级。显示当前版本、目标版本和发布说明。 2. **目标平台**:每个产物标注操作系统、CPU 架构、必要时的 ABI、最低系统版本与安装格式。选择产物时做精确匹配;没有匹配项就报告“此设备暂无更新”,不要下载相近架构。 3. **获取位置**:元数据和下载地址使用 HTTPS;限制允许的主机和重定向目标,不把未经验证的元数据 URL 当作任意文件写入指令。请求设置超时、响应大小限制,并为离线、限流和损坏的元数据提供可重试错误。 4. **可信验证**:对下载的完整产物核对大小与摘要,并验证发布者身份。与产物放在同一发布位置的 `SHA256SUMS` 能发现下载损坏,但若该发布位置被控制,攻击者也可能同时替换摘要。按分发模型采用可信签名、签名元数据或平台代码签名;macOS 更新还要核对 bundle 签名及身份。验证失败必须停在替换之前。 5. **安装身份**:固定 bundle identifier、Windows 安装器身份和 Linux 包名,让新版接续旧版的升级与卸载规则。更新不能悄悄改变用户数据目录或扩展兼容性约定。 发布新版本的顺序应是先上传并验证各平台产物及校验资料,最后让更新元数据指向它们。更新服务不要向用户宣称某个目标版本可用,却没有对应架构的可下载文件。 ## 让 UI 与更新任务各司其职 把更新状态保留在拥有界面的 `Entity` 中,网络与磁盘工作放在后台任务里。完成后回到 GPUI context 更新状态并调用 `cx.notify()`;不要在 `render` 中检查网络、下载文件或修改安装目录。可参考[Task](./task)、[Entity](./entity) 和 [Context](./context)。 | 状态 | 界面应说明的内容 | 可用操作 | | --- | --- | --- | | `Idle` / `Checking` | 尚未检查,或正在检查;手动检查时显示反馈 | 检查期间避免重复提交 | | `Current` / `Available` | 当前已是最新版,或显示目标版本、渠道和发布说明 | 对可用版本提供用户可选择的更新动作 | | `Downloading` / `Verifying` / `Installing` | 下载字节数或可确定进度;校验与安装各有独立提示 | 下载可取消时明确说明边界;安装阶段不要伪装成可随意中止 | | `ReadyToRestart` | 文件已安装,当前进程仍运行旧代码 | 允许用户完成当前工作后重启 | | `Failed` | 简洁错误、旧版是否仍可启动、重试或手动下载入口 | 不自动陷入反复下载或重启循环 | 启动时可在后台做一次检查,同时保留“检查更新”入口;重复检查应受间隔或请求合并控制。一次只安装一个目标版本,用户切换渠道或窗口关闭时应避免过期任务回写新状态。下载进度不必每个字节都触发一次 `cx.notify()`,只在可见数值有意义地变化时更新。检查失败不应阻止应用启动。 按钮应有明确的可见文字与无障碍名称,进度同时提供文字或数值,不能只靠旋转图标或颜色。下载与校验完成后用静态状态提示即可;需要动效时尊重 reduced motion。更新前若有未保存内容或正在执行的任务,提示用户完成或保存后再重启。可按[Accessibility](./accessibility)验证键盘、焦点与辅助技术路径。 ## 简单路径:便携版使用 `self_update` 对**用户可写位置的单文件便携版**,可用 [`self_update` 1.3 文档](https://docs.rs/self_update/1.3.0/self_update/)中的 GitHub Releases backend 处理查询、下载及替换。下面展示应用自己的 `Cargo.toml` 依赖;归档格式和摘要支持需要显式打开对应 feature: ```toml [dependencies] self_update = { version = "1.3", default-features = false, features = ["ureq", "rustls", "github", "archive-tar", "compression-tar-gz", "archive-zip", "compression-zip-deflate", "checksums"] } ``` 下面函数只在用户选择安装后,由后台工作线程调用。替换 `example`、`hello-world-releases`、二进制名和发布产物命名;在应用启动时调用它会让更新下载和磁盘操作阻塞启动路径。`SHA256SUMS` 必须随每个 release 提供,并包含所选产物的摘要。该代码是便携版安装核心片段,尚不包含 GPUI 界面状态与平台安装器逻辑。 ```rust fn install_portable_update() -> Result, Box> { let status = self_update::backends::github::Update::configure() .repo_owner("example") .repo_name("hello-world-releases") .bin_name("hello_world") .current_version(self_update::cargo_crate_version!()) .checksum_from_asset("SHA256SUMS") .no_confirm(true) // The application UI already obtained consent. .show_output(false) .show_download_progress(false) .build()? .update()?; Ok(status.is_updated().then(|| status.version().to_owned())) } ``` `Some(version)` 表示可执行文件已替换,`None` 表示当前已是最新版。更新器会依据当前 target 匹配 release asset。发布时需在受支持的每个 target 上验证实际文件名、归档结构和内部二进制路径;不要假定 GitHub 上任意 ZIP 或 tarball 都能被正确识别。应用自己的检查 UI 可以单独调用 `is_update_available()` 获取版本,再在用户同意后调用 `update()`;检查与安装之间若发布了另一版,`latest` 可能已经变化。正式实现应固定用户选中的 release,并在替换前再次核对精确产物。[`self_update` 的检查与安装 API](https://docs.rs/crate/self_update/1.3.0/source/README.md)对此有分别的示例。 `update()` 报告已安装新版时,磁盘上的目标文件已更新,**当前进程仍执行旧代码**。在应用保存状态、关闭窗口和后台资源后,再请求重启;不要在安装回调里立即杀死进程。`self_update` 不会替应用取得管理员权限,也不会替应用设计失败恢复策略。其 `checksum_from_asset` 校验的是同一发布位置给出的摘要,不能单独证明发布者身份。 ## 按平台完成安装 ### macOS 带 `Contents/Resources`、`Info.plist` 和签名的 `.app` 必须作为一个整体更新。只覆盖 `Contents/MacOS` 里的文件会使签名失效,还可能留下旧资源。若使用 `self_update` 的 bundle 模式,release 归档应包含完整 `.app`,配置 `bundle_path_in_archive`,在替换前对暂存 bundle 验证签名与预期身份;crate 不会替应用签名或公证。首次安装和更新产物都应沿用[打包指南](./packaging)里的 Developer ID、公证及目标机器验证流程。只读或 App Translocation 位置不能原地更新时,应提示用户把应用安装到正常位置,或下载新版安装包。 ### Windows ZIP 便携版与 Inno Setup 安装 `.exe` 需要不同策略。安装版应让 Inno Setup 升级文件、开始菜单入口和卸载记录;直接替换其应用 `.exe` 可能绕开权限、身份和回滚规则。对可写的便携版,也应在 Windows 的文件占用条件下测试替换与重启,必要时退出进程后由受控辅助程序或安装器完成替换。不要假定一个运行中的进程能原地覆盖它正在使用的全部 DLL。安装失败时保留旧版可启动,并给用户手动安装路径。 ### Linux 用户自己解压到可写目录的 tarball 可以采用便携版路径。由 DEB/RPM 管理的文件则应通过对应包管理器升级,保持依赖声明、文件清单和卸载记录一致;不要让应用直接写入 `/usr` 下的包文件,也不要自动发起不明来源的提权。不同发行版、图形会话和 CPU 架构要分别验证。更新完成后由应用在合适时机重新启动,或者让用户按包管理器提示重启。 ## 失败恢复与发布验证 在发布流程中保留上一版可下载产物,并测试更新失败时的恢复路径。把“下载完成”“摘要通过”“平台身份通过”“替换完成”“新版成功启动”视为不同检查点;任何一步失败都应记录足够的诊断信息,保留可用的旧版或提供明确的重装入口。`self_update` 的具体替换行为不能替代应用级的升级事务、数据迁移和回滚设计。 最小验证矩阵应覆盖:当前版本无更新、更新可用、相同版本与降级、渠道切换、错误架构、缺少产物、损坏摘要、错误签名、离线或超时、磁盘空间不足、目录不可写、下载中关闭窗口、安装中退出、重启后版本确认,以及用户数据与扩展兼容性。每种**实际分发格式**都应从旧版安装后升级到新版,再验证启动与卸载;单独运行 `cargo build --release` 无法验证自动更新。 --- # Accessibility Source: /versions/v0.7.1/zh-CN/docs/accessibility 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/accessibility 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 GPUI 通过 [AccessKit](https://accesskit.dev/) 向平台辅助技术提供无障碍树。一个有用的控件应具有**类型**(role,说明它是什么)、**名称**(供用户辨认)、**状态**(如值、勾选、选中、展开)和**操作**(辅助技术可请求的行为)。稳定的元素 ID 用于保持跨帧身份,并不是用户听到的名称。界面还必须能用键盘操作,并清楚显示焦点;无障碍树属性本身不会实现键盘交互。 应用通常使用 `gpui_kit::component` 中的带样式组件。交互行为由无样式的 `gpui_kit::base` 层提供。两层都通过一个 `gpui-kit` 依赖使用。优先使用标准控件,再考虑自己拼装可交互的 `div`:标准控件已经协调了指针、键盘、焦点、状态与 AccessKit 语义。 需要自定义键盘目标时,先读 [Focus](./focus):`track_focus`、Tab 顺序和无障碍 role 分别解决交互的不同部分。 把无障碍看成一条贯穿应用的路径,而不是最后才附加的一段说明: | 步骤 | Profile 示例中的对象 | 需要验证什么 | | --- | --- | --- | | 保留状态 | `Entity` 持有输入草稿;`Profile::submitted` 持有已保存的值。 | 输入和 Save 确实改变目标模型,重新渲染后仍正确。 | | 渲染 | `Input`、`Button` 和状态 `div` 读取该状态。 | 可见标签和结果与模型一致。 | | 语义 | 控件通过 AccessKit 提供 role、名称、相关状态及可用操作。 | 绘制完成后的 snapshot 含有预期属性。 | | 操作 | 指针、键盘与辅助技术触达同一命令。 | 每种输入方式都能保存;焦点移动可见且顺序可预期。 | 先做好状态和渲染,再检查语义树并操作实际窗口。无头测试中的树属性断言通过,不能证明屏幕阅读器会播报什么,也不能证明焦点环可见。 ## 跟着一个完整的保存流程学习 在工作区根目录运行仓库中的 [Profile UI 测试](https://github.com/longbridge/gpui-kit/blob/main/crates/kit/tests/ui.rs): ```sh cargo test -p gpui-kit --features test-support --test ui saves_a_profile_through_the_ui -- --exact ``` 测试打开无头窗口,输入姓名,激活 **Save**,再检查应用状态与已渲染 GPUI 元素写出的无障碍属性。这样可以沿着一条实际路径,理解模型、可见控件和对应 AccessKit 节点如何连接。在自己的测试 crate 中配置环境的方法见[测试](./test)。 ### 1. 给接收输入的控件命名 视图用 `Entity` 保留跨帧文字。渲染时同时提供 GPUI 使用的稳定 ID,以及辅助技术读取的易懂名称: ```rust Input::new(&self.name) .id("name") .aria_label("Profile name") .w(px(240.)) ``` ID 与名称回答不同问题:`"name"` 让 GPUI 和测试找到这个元素;`"Profile name"` 告诉用户应该输入什么。示例还在输入框正上方绘制了 **Profile name** 可见文字。仅凭相邻位置不会自动建立无障碍关联,因此输入框仍需自己的名称。输入时 placeholder 会消失,不能很好地替代持续可见的标签。 ### 2. 更新同一份模型,再从模型渲染状态 **Save** 按钮的 listener 把当前输入值复制到视图的 `submitted` 字段,并调用 `cx.notify()`。下一次 `render` 根据 `submitted` 计算状态文字,同一个值既用于可见内容,也用于无障碍名称: ```rust let status = self.submitted.as_ref().map_or_else( || SharedString::from("Not saved"), |name| SharedString::from(format!("Saved: {name}")), ); div() .id("status") .role(Role::Status) .test_support() .aria_label(status.clone()) .child(status) ``` 这个状态 `div` 不执行命令,只描述操作结果。稳定 ID 加 `Role::Status` 使它成为有语义的节点。`.test_support()` 只在启用相应 feature 时帮助 GPUI Kit 的测试工具找到自定义节点。role 和 label 不会自行修改模型;`cx.notify()` 使新模型值进入下一帧。 ### 3. 在绘制完成后断言契约 测试在首次查询前调用 `render_frame`,再与原生元素交互,并重新读取 snapshot: ```rust window.render_frame(cx); assert_eq!(window.find("status").role(), Some(Role::Status)); assert_eq!(window.find("status").label(), Some("Not saved")); window.click("name", cx); window.input("Ada José", cx); assert_eq!(window.find("name").label(), Some("Profile name")); assert_eq!(window.find("name").value(), Some("Ada José")); window.press("backspace", cx); assert_eq!(window.find("name").value(), Some("Ada Jos")); window.click("save", cx); assert_eq!(window.find("status").label(), Some("Saved: Ada Jos")); ``` 仓库里的完整测试还检查输入焦点、状态元素的几何位置,以及 `profile.read(cx).submitted`。断言模型可以防止仅仅更新 label 的错误通过测试。这里的 `window.click` 测试的是指针激活;`backspace` 在指针聚焦输入框后删除带重音的 `é`,验证 Unicode 编辑,并未验证能否通过键盘导航到 **Save**。可以试着删除 `.aria_label("Profile name")` 后重跑测试:label 断言会显示,不能假设旁边的可见文字自动为输入框命名。 要在真实窗口检查键盘路径,把下面这个同一视图的完整版本放入已有的 `examples/hello_world/src/main.rs`,再从仓库根目录运行 `cargo run -p hello_world`。这是本地练习;结束后恢复该示例文件。上面的无头测试没有执行这条键盘路径。 ```rust use gpui_kit::{ AppContext, Context, Entity, Role, SharedString, Window, WindowOptions, component::{button::Button, input::{Input, InputState}}, div, prelude::*, px, }; struct Profile { name: Entity, submitted: Option, } impl Render for Profile { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let status = self.submitted.as_ref().map_or_else( || SharedString::from("Not saved"), |name| SharedString::from(format!("Saved: {name}")), ); div() .flex() .flex_col() .p_4() .gap_4() .child( div().flex().flex_col().gap_1().child("Profile name").child( Input::new(&self.name) .id("name") .aria_label("Profile name") .w(px(240.)), ), ) .child(Button::new("save").label("Save").on_click( cx.listener(|this, _, _, cx| { this.submitted = Some(this.name.read(cx).value()); cx.notify(); }), )) .child( div() .id("status") .role(Role::Status) .aria_label(status.clone()) .child(status), ) } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |window, cx| { cx.new(|cx| Profile { name: cx.new(|cx| InputState::new(window, cx)), submitted: None, }) }) .expect("failed to open window"); }); } ``` 窗口初始显示 **Not saved**。从表单前方开始,用 Tab 到 **Profile name**,确认焦点清晰可见;输入 `Ada José` 并按一次 Backspace,留下 `Ada Jos`。再用 Tab 到 **Save**,确认它的焦点提示,然后按 Enter;可见结果应变为 **Saved: Ada Jos**。**Save** 仍有焦点时,再按 Space 激活一次。用 Shift+Tab 返回输入框,再按 Tab 回到 **Save**,核对反向和正向导航。如果某个激活按键无效,应检查按钮的焦点与按键处理,不能用指针测试通过来替代判断。最后,用目标平台的辅助技术激活 **Save**,检查播报的 role、名称与结果;仅有 status role 不保证动态变化一定被播报。 把练习迁移到自己的表单时,也按这个顺序做:给真正的输入控件设置稳定 ID 和名称,让操作更新保留状态,从该状态渲染结果,再同时断言模型与重新绘制后的语义。为视力正常的用户保留控件旁的可见标签;无障碍名称是额外的契约。字段无效时,以可见文字说明错误,并确认使用辅助技术的人能发现错误和恢复操作。仅改变颜色无法说明原因或纠正方法。 ## 从语义控件开始 下列控件会根据自身状态提供相应语义: | 控件 | 无障碍契约 | | --- | --- | | Button、Link | Button 或 Link role、名称及激活操作。应用命令用 Button;外部目标用 Link。 | | Checkbox、Switch、Toggle、Radio | 相应 role 及勾选或切换状态。Checkbox 还能报告混合状态。 | | Input | 根据内容类型选择的输入 role、名称、非敏感值;未禁用时提供 `SetValue` 操作。遮罩和密码值不会暴露。 | | Select | ComboBox role、名称、已提交的值、展开状态及无障碍激活路径。 | | Tab、tab list | Tab 与 TabList role;Tab 报告选中状态,也可报告它在集合中的位置。 | | Slider、Progress | 数值和范围;Slider 处理无障碍 Increment、Decrement 操作。不确定进度的 Progress 不报告数值。 | | Table | Table、row、header、cell role、索引及可选的计数。应给 Table 根节点命名。 | 带文字的按钮默认用可见文字作为无障碍名称。纯图标按钮应显式命名: ```rust use gpui_kit::component::{IconName, button::Button}; Button::new("search-documents") .icon(IconName::Search) .accessibility_label("Search documents") .tooltip("Search documents") .on_click(|_, window, cx| { // Invoke the same application command used by its keyboard route. }) ``` `accessibility_label` 是辅助技术读取的控件名称;tooltip 是另一种提示,不能代替名称。名称应明确描述操作,并随操作变化而更新。不要假设自定义容器内的任意文字会自动成为其无障碍名称。可见表单标签与相邻输入框也不会仅凭布局位置产生标签关系:用 `Input::aria_label(...)` 命名真正的输入框,或使用已经实现该关系的组件。Input 可以把 placeholder 作为名称后备,但显式 label 更清楚;自动生成的遮罩 placeholder 不会被当成名称。 ## 身份与 role 元素同时拥有 [ElementId](./element_id) 和非空无障碍 role 时,GPUI 才会把它纳入无障碍树。全局身份还包含祖先的 ID。跨帧保持 ID 稳定;重复项目使用领域数据键生成 ID,避免排序后被辅助技术误认为大量节点被移除又重建。单独的 `id` 并不是 role;没有 role 的 `div` 只是布局容器,不是可播报的控件。仅有 role 也不会让控件自动获得焦点或响应操作。 语义状态消息可以同时提供两者: ```rust use gpui_kit::*; div() .id("save-status") .role(Role::Status) .test_support() .aria_label("Saved") .child("Saved") ``` 显式 label 是播报名称。启用 `test-support` 时,`.test_support()` 让后面的集成测试找到这个自定义 `div`;它不增加布局容器,在普通构建中不起作用。`Role::GenericContainer` 会从无障碍树中被过滤;有意义的节点应使用实际 role。`accessibility_id(...)` 是另外一个供平台自动化使用的作者标识:Windows 可映射为 UIA `AutomationId`,macOS 可映射为 `AXIdentifier`;Linux AT-SPI 是否支持取决于部署的 adapter。它不能代替 GPUI 的 `.id(...)`,也不能代替易懂的名称。 ## 名称、状态与关系 对带 ID 的 `div`,GPUI 的 `StatefulInteractiveElement` 提供 `.role(...)`、`.aria_label(...)`、`.aria_description(...)`、`.aria_selected(...)`、`.aria_expanded(...)`、`.aria_toggled(...)`、`.aria_value(...)`、`.aria_numeric_value(...)`,以及范围与集合属性。数值控件还可报告最小值、最大值、步长和方向;标题可报告层级;列表和表格项目可报告位置与总数。应从绘制界面的同一份模型更新这些值;持有模型的 View 在状态变化后通过 [Context](./context) 通知 GPUI。description 是名称的补充,不能代替名称。`.aria_keyshortcuts(...)` 只负责向辅助技术说明快捷键;实际按键仍须通过 GPUI keybinding 注册。 当前 GPUI `div` API 没有通用的 `.aria_disabled(...)` 构建方法。Button、Checkbox 等 Base 控件在禁用时会阻止焦点与激活,但这不保证每个节点都有原生 disabled 属性。应同时核对树中可读取的状态与实际禁用行为。同样,`.track_focus(...)` 建立焦点路径并声明无障碍 Focus 操作;只有 role 或 `.focusable()` 并不会实现有用的键盘命令。 元素树建立父子关系。复合控件可以把键盘焦点留在父节点,再给当前子节点设置 `.aria_active_descendant()`。GPUI 只有在祖先确实持有焦点时才应用该子节点标记。子节点还需要自己的 ID 与 role。这属于专门的复合控件行为;适用时优先使用内置 Select、menu 或 list。 不要根据 `aria_` 前缀推断存在网页中的 `aria-labelledby`、`aria-describedby` 或 `aria-controls` 通用构建方法:当前 GPUI `div` API 没有这些方法。Table 即使有可见 caption,也应在 Table 根节点设置 `.accessibility_label(...)`;caption 容器不会自动给 Table 命名。 ## 无障碍操作与键盘输入 AccessKit action 与由快捷键或菜单派发的 GPUI [Action](./action) 是两套概念。GPUI 将前者暴露为 `AccessibleAction`。带 ID 的 `div` 可以用 `.on_a11y_action(action, handler)` 注册一项操作;handler 接收可选的 `ActionData`、`&mut Window` 和 `&mut App`。`.on_click(...)` 已经声明无障碍 Click 激活,并把它路由给点击 handler;第二个 Click handler 可能重复执行命令。例如自定义 Slider 除了指针和键盘操作,还应支持 Increment、Decrement,并在模型改变后更新无障碍数值。GPUI Kit 的 Slider 已实现这些行为。 交互承诺应一致:可见文字、无障碍名称、快捷键、指针、键盘和辅助技术操作都指向同一命令。带 hitbox、能点击的绘制图形仍须另行实现语义与键盘操作。Dialog 或 Sheet 关闭后应把焦点还给触发控件;焦点应可见,顺序应符合任务流程。 对上面的 Profile 流程,使用可运行的窗口练习,把键盘验证与指针测试分开。只有 `submitted` 改变且 `cx.notify()` 触发重新渲染后,可见结果才会变化。复合控件还应检查其文档约定的方向键和 Escape 行为。在发布目标平台上完成这些检查;指针测试通过或声明了快捷键,都不能证明键盘路径可用。 ### 从零构建可交互的自定义控件 前面的 `EventSurface` 练习给绘制的图形增加了 Status 节点,但图形仍不能脱离指针操作。若外观能由普通元素组合,先走组合路径。把现有 `examples/hello_world/src/main.rs` 暂时替换为下面的完整示例,在仓库根目录运行 `cargo run -p hello_world`;练习结束后恢复该示例文件。 ```rust use gpui_kit::{ *, component::ThemeStyled as _, prelude::*, }; struct CounterControl { focus: FocusHandle, activations: usize, } impl CounterControl { fn new(cx: &mut Context) -> Self { Self { focus: cx.focus_handle().tab_stop(true), activations: 0, } } } impl Render for CounterControl { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let focused = self.focus.is_focused(window); let status = format!("Activations: {}", self.activations); div() .flex() .flex_col() .gap_4() .p_4() .child( div() .id("activate-counter") .role(Role::Button) .aria_label("Activate counter") .track_focus(&self.focus) .p_3() .border_1() .when(focused, |control| control.focus_ring_style(window, cx)) .on_click(cx.listener(|this, _, _, cx| { this.activations += 1; cx.notify(); })) .child("Activate counter"), ) .child( div() .id("activation-status") .role(Role::Status) .aria_label(status.clone()) .child(status), ) } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(CounterControl::new) }) .expect("failed to open window"); }); } ``` 保留的 `FocusHandle` 跨渲染周期存在。其 `.tab_stop(true)` 把控件加入 Tab 顺序,`.track_focus(...)` 将句柄绑定到当前渲染的节点。稳定 ID 与 `Role::Button` 暴露控件节点,`.aria_label(...)` 为操作命名。只有该句柄获得焦点时才绘制焦点环。唯一的 `.on_click(...)` listener 更新保留的计数并调用 `cx.notify()`;GPUI 把主指针点击、焦点节点上的 Enter/Space 激活,以及 AccessKit Click 都交给这个 listener。不要再给同一节点注册第二个 `AccessibleAction::Click`,否则命令可能执行两次。 在实际窗口逐条验收。鼠标点击后,**Activations: 0** 应变为 **Activations: 1**。从控件外用 Tab 进入,确认焦点环可见;按 Enter 后应为 **Activations: 2**,按 Space 后应为 **Activations: 3**。再用 Tab 离开、Shift+Tab 返回,确认它仍在导航顺序中。使用目标平台的无障碍检查器,找到名称为 **Activate counter** 的 Button,以及名称与可见计数一致的 Status。再用辅助技术执行 Button 的 Click 操作,确认计数恰好增加一次。无头 snapshot 可以检查 role、名称和更新后的模型,但不能证明焦点环可见、平台适配器暴露了节点,或屏幕阅读器播报了变化中的 Status。 若 Tab 跳过控件,同时检查保留句柄上的 `.tab_stop(true)` 和当前渲染节点上的 `.track_focus(...)`。若 role 或名称缺失,检查同一节点是否设置稳定 ID、role 与 label。若一次激活增加两次,寻找是否有第二个 Click 或按键 handler 重复调用同一命令。此练习展示一个类似按钮的控件;真实应用命令优先使用标准 Button,它还处理组件的其他状态与样式。 ## 自定义 `Element` 如果无法通过组合 `div()` 完成,底层 `Element` 提供明确的 hook。接着做 [Element 章的 EventSurface 练习](./element):它已经在 `examples/hello_world/src/main.rs` 中实现了 `IntoElement`、布局、prepaint 和 paint。对这个现成示例做三处修改: 1. 在 `Render for SurfaceDemo` 中,与 `owner`、`color` 一起把当前计数文字传入 `EventSurface`:`status: format!("Pointer presses: {}", self.presses),`。 2. 给 `EventSurface` 结构体增加 `status: String,` 字段。 3. 替换它的 `id()` 方法,并在 `impl Element for EventSurface` 中加入两个无障碍方法: ```rust fn id(&self) -> Option { Some("pointer-status".into()) } fn a11y_role(&self) -> Option { Some(Role::Status) } fn write_a11y_info(&self, node: &mut accesskit::Node) { node.set_label(self.status.clone()); } ``` 完整的 EventSurface 示例已使用 `use gpui_kit::*;` 导入 `ElementId`、`Role` 和 `accesskit`。再次运行 `cargo run -p hello_world`。启用辅助技术或平台无障碍检查器,在矩形位置找到名为 **Pointer presses: 0** 的 **Status** 节点。矩形内按一次鼠标左键,可见计数与重新渲染后的节点名称都应变为 **Pointer presses: 1**;稳定 ID 让它在两帧之间保持同一身份。检查器能显示树属性,只有用屏幕阅读器实际操作才能确认变化是否播报。无障碍树只在辅助技术启用后构建;没有客户端连接时看不到树,并不能单凭这一点认定方法失效。 这只是限定范围的语义练习。`EventSurface` 仍只响应指针输入,`Role::Status` 用来描述计数,并不会让矩形成为可用键盘或辅助技术激活的控件。真正的命令应优先使用标准 Button;若确需自定义,还要分别实现跟踪焦点、键盘激活、合适的控件 role 及无障碍操作。只有元素同时有 ID 和 role 时,GPUI 才调用 `write_a11y_info`。GPUI Kit 的 `window.find(...)` 测试工具观察已注册控件和 `div().test_support()`,不能指望它找到这个原始 `Element` 的 `pointer-status`。`a11y_synthetic_children(...)` 可在 prepaint 后添加 AccessKit 子节点,例如自定义编辑器的文字片段。`A11ySubtreeBuilder::synthetic_node_id(key)` 根据父节点和稳定 key 派生子节点 ID;`push_child(...)` 把节点挂在父节点下。同一父节点的合成子节点 key 必须唯一。这是高级路径:实现者还须负责命中测试、事件派发、焦点、键盘处理和无障碍操作。 ## 测试无障碍契约 UI 集成测试启用 GPUI Kit 的 `test-support` feature,并导入 `gpui_kit::test::TestWindowExt`;观察自定义 `div` 时还要导入 `TestSupportExt`。在 `window.render_frame(cx)` 后查询**已绘制**的元素。`ElementSnapshot` 提供 `role()`、`label()`、`value()`、`focused()`、`checked()`、`indeterminate()`、`selected()` 和 `expanded()`。每次交互后重新查询,因为 snapshot 只描述一个已完成的帧。上面的可运行 Profile 测试就是断言示例:它检查 role、名称、值、焦点、几何位置和保存后的模型值。 状态读取结果为 `None` 表示属性不可用,并不表示 `false`。特别是 `.disabled()` 只有在节点暴露 disabled 标志时才返回 `Some(true)`;测试禁用行为时应尝试激活,并确认结果未改变。`ElementSnapshot::value()` 读取的是字符串无障碍值,不是 Slider 的数值或绘制的文字。遮罩和密码输入有意不暴露无障碍值。当前 Input 实现只要未禁用就注册 `SetValue`,包括只读模式;handler 使用程序化替换路径。如果只读数据必须受到保护,应核实所需行为,不要假定 `readonly(true)` 能阻止这条无障碍写入路径。测试配置、帧与焦点细节见[测试](./test)。 无头 snapshot 读取 GPUI 元素生成的 AccessKit 属性;它不会检查最终的平台适配器树、屏幕阅读器实际播报或像素。应在每个目标平台用辅助技术检查播报顺序、焦点移动、编辑和操作。尤其要确认变化中的 `Role::Status` 确实被播报,不能仅凭 role 就认定会实时播报。平台 adapter 行为可能不同,[WebAssembly 支持](/versions/v0.7.1/zh-CN/docs/webassembly)也需要单独验证。再配合视觉检查:焦点对比度、文字可读性、目标大小、减少动态效果,以及不能只靠颜色传达的信息;参见 [Design Guides](./design-guides)。 ### 一轮可执行的验收 在计划发布的每个平台上按以下顺序检查。记录操作系统、辅助技术、应用构建版本和实际观察结果;测试中的 AccessKit 属性只能证明 GPUI 树的内容,不能替代各平台桥接层的验证。 1. **只用键盘**:从表单前方开始,Tab 到输入框和 Save,Shift+Tab 返回,逐站检查可见焦点。输入、编辑,再用 Enter 或 Space 激活 Save;确认可见结果和保存的模型。复合控件还要测试其约定的方向键、Escape 和 Tab 离开路径。 2. **使用辅助技术**:找到同一输入框和按钮,确认播报的 role、名称、适合披露时的输入值,以及保存后的状态。通过辅助技术的控件操作激活按钮。在实际应用中检查状态是否被播报;仅有 `Role::Status` 不能证明会播报。 3. **状态变化**:测试空值或无效输入、禁用与启用切换,以及流程中出现的浮层。确认用户能找到错误、禁用命令不能执行、浮层关闭后焦点回到合理位置。 4. **视觉呈现**:放大文字或界面,检查焦点对比度与裁切;在支持时减少动态效果;确认成功、错误、选中状态不只靠颜色区分。 ### 检查失败时从哪里入手 | 现象 | 先检查 | | --- | --- | | 绘制后的 snapshot 找不到自定义节点。 | 是否有稳定 `.id(...)` 和有意义的 `.role(...)`?是否运行了 `window.render_frame(cx)`?如果用 GPUI Kit 的测试工具查找自定义 `div`,是否添加 `.test_support()` 并启用 `test-support`? | | role 存在,但名称缺失或不正确。 | 给控件本身命名(`Input::aria_label`、`Button::accessibility_label` 或 `div().aria_label`);相邻文字和 tooltip 不保证建立名称关系。 | | 状态文字变化,但 snapshot 仍是旧值。 | 交互后重新查询;检查所有者是否更新保留状态并调用 `cx.notify()`。已保存的 `ElementSnapshot` 只描述一帧。 | | 快捷键能被播报,按下却没有反应。 | `.aria_keyshortcuts(...)` 只报告快捷键。注册 GPUI 按键绑定,并把 action handler 接到预期焦点路径;参见 [Action](./action)。 | | 指针能激活,键盘或辅助技术却无法激活。 | 检查 Tab 停靠点、跟踪焦点、key context、action handler 和无障碍操作。role 加 hitbox 本身不提供这些路径;考虑使用内置控件。 | | 无头断言通过,屏幕阅读器表现却不同。 | 在目标平台复现并检查平台适配器的结果。无头测试工具不验证播报、焦点视觉或适配器行为。 | --- # FPS Monitor Source: /versions/v0.7.1/zh-CN/docs/fps 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/fps 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `gpui-fps` 在 [Window](./window) 上叠加一个性能 HUD:一个主读数、一条滚动的帧耗时曲线,以及本进程的 CPU、GPU 与内存。它只依赖 `gpui`,任何 GPUI 应用都能用。 ```rs use gpui_fps::fps_monitor; fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { div() .relative() .size_full() .child(self.content.clone()) .when(self.show_fps, |this| this.child(fps_monitor(window, cx))) } ``` 父元素必须是 `relative()`——HUD 用绝对定位;是否显示由调用方自己决定。 ## 120 Hz 是帧预算,不是刷新承诺 “120 FPS”通常是 **120 Hz 屏幕上的目标**的简称:一个刷新周期是 `1 / 120 s`, 约 **8.33 ms**。它本身既不说明空闲应用多久请求一次画面,也不保证某个界面能在 每次预算内完成并呈现。60 Hz 屏幕的单次刷新约为 16.67 ms。可变刷新率和平台 调度还会影响实际情况;这两个数字都不是应用的实测 FPS。 GPUI 响应失效:输入或状态变化可将 View 标为 dirty,窗口随后绘制并呈现新场景; 多次变化可以合并。动画在进行时可以请求后续帧,结束后应停止请求动画帧。仅仅 因为屏幕是 120 Hz,GPUI 的渲染管线并不要求应用每秒重建 120 次元素树。 平台的帧回调仍可能运行;部分后端也可能在不重建元素的情况下呈现已有场景。 另外,HUD 自身的定时器、输入设备、平台合成器或应用代码,仍可能让看似空闲的 窗口产生活动。应在目标平台测量空闲时的 CPU/GPU 使用量,而不是从屏幕刷新率 推断出零工作量或持续完整重绘。
何时需要新的绘制过程
这是概念性的帧请求示意,不是实测 FPS,也不限制平台呈现已有场景的行为。
要声称**持续显示 120 FPS**,需要在目标硬件和窗口尺寸下,用 release 构建测量 有代表性的*整个窗口*;包含真实数据、滚动或动画,并报告预热后的帧耗时分布与 present 间隔。单个小组件的 `render` 少于 8.33 ms,只测到了单帧的一部分。 layout、prepaint、paint、提交、GPU 工作、合成器和调度都可能用掉其余预算。 平均值很低,也可能被较慢的 `P95` 帧打断,产生可见卡顿。 GPUI 的工作跨越 CPU 与 GPU:应用状态、元素构造、布局和绘制准备在 CPU 上完成; 平台渲染器与合成器在支持时使用 GPU。文字、场景复杂度、缓存行为、图形后端和 硬件共同决定某项负载的瓶颈。“CPU-bound”或“GPU-bound”应是对具体负载的 分析结果,而不是所有 GPUI 应用的固定属性。 ## Immediate、Retained 和 Hybrid 描述的是不同层次 这些词回答的是不同问题,仅用一个标签容易造成误解: | 层次 | GPUI 的行为 | 跨过程保留的内容 | | --- | --- | --- | | View 状态 | [`Entity`](./entity) 跨更新与帧持有应用状态。 | 仍有所有者时,Entity 及其订阅、Task 和子 handle。 | | 界面描述 | 需要[渲染过程](./render)时,`Render` 或 `RenderOnce` 根据当前状态构造元素,形式上接近 immediate 风格的声明。 | 元素描述属于本次过程;`RenderOnce` **不是**每显示帧只运行一次。 | | 复用与绘制 | GPUI 可复用符合条件的[缓存 View](./view-cache) 与带 key 的元素状态,并提交窗口绘制工作。 | 缓存及平台场景资源可比单次元素描述存活更久。 | “Retained”适用于 Entity 状态与部分缓存,“immediate 风格”描述当前元素的 构造方式,“hybrid”则概括了两者;[Zed 的 GPUI 概览](https://github.com/zed-industries/zed/blob/bcf6582ce3500df93a8a39366640173e6786cea6/crates/gpui/README.md#the-big-picture)也采用这个名称。这些术语都不能决定空闲时的 present 频率, 也不能证明性能数字。[Render](./render)、[Element](./element) 与 [View Cache](./view-cache) 从代码层面解释所有权与失效边界。 `gpui-fps` 应与应用使用同一套 GPUI 依赖。每个窗口最多渲染一次 `fps_monitor(window, cx)`;重复调用会复用同一个 monitor,导致 HUD 画两遍。 仓库中的可运行示例用 `cargo run --release -p fps_monitor` 启动。只有比较 Debug 构建时才用 `cargo run -p fps_monitor`:未优化的框架代码会显著改变测得的帧耗时。 开发配置参见[安装](./installation#提升开发模式运行性能)。 ## 从状态更新到画面显示 1. 状态改变后的 `cx.notify()`、动画请求或窗口刷新使窗口失效。多次失效可能合并为一次绘制。 2. GPUI 执行 `Window::draw`,为脏 view 构建元素,完成 layout、prepaint 和 paint。 profiler 记录该窗口的 `draw_start`、`draw_end` 和失效次数。`FRAME` **只取** `draw_end - draw_start`。 3. GPUI 在单独的 present 阶段把场景提交给平台。其 `present_end` 时间戳用于 `FPS` 和 `INTERVAL`。 因此,`FRAME` 为 5 ms 只说明 GPUI 的 draw 在 5 ms 内完成;它不表示请求只等待了 5 ms、平台提交耗时 5 ms,也不表示 GPU 和合成器在 5 ms 内把画面显示出来。 profiler 还提供 `dirty_to_draw_duration()` 和 `PresentTiming` 供自行埋点, 但它们与 HUD 的 `FRAME` 是不同的测量。参见 GPUI 的 [帧计时定义](https://docs.rs/gpui-pre/0.3.8/gpui/profiler/struct.FrameTiming.html)和 [sampler 源码](https://github.com/longbridge/gpui-kit/blob/main/crates/fps/src/sampler.rs)。 ## 主读数 那个大数字回答两个问题之一,`MAX` 标记说明当前是哪一个。**右键**切换,**左键**折叠成一个小标签。 | | 读的是 | 含义 | | --- | --- | --- | | `MAX FPS`(默认) | `1 / FRAME` 平均值;能获知显示器刷新率时以其为上限 | 根据取样负载估计的 draw 吞吐量,不含呈现和 GPU 完成时间 | | `FPS` | 根据近期 present 时间戳推算的速率 | 样本足够时观察到的 present 节奏 | 这是两个不同的问题。例如,即使应用没有可见变化,HUD 也每秒请求两次读数更新。 此时窗口观察到的 `FPS` 可以很低,而 `MAX FPS` 仍很高;任何一个数字都不能 单独证明空闲时持续重绘,或复杂界面的持续性能。 ### 为什么 MAX 是推导出来的,而不是数出来的 要让帧计数器读出"这个 UI 能跑多快",最直接的做法是像游戏里的帧数器那样不停地要帧。但在 这里这件事并不免费:标脏任意一个 view 排的是一次**窗口**绘制,GPUI 会重新 render 该窗口中 除 [`Entity::cached`] 边界(见 [View Cache](./view-cache))后面之外的所有 view —— 于是 HUD 每要一帧,代价就是应用的一次 layout 与 [paint](./paint) 工作,而下面那行 CPU 也会包含 HUD 自己制造的开销。 测得的 draw 耗时可用来估计当前取样窗口在 CPU 侧的余量。其倒数就是 `MAX FPS`, 但这不是对持续动画或完整呈现管线的测量。HUD 不会为了计算它而持续请求帧; 下文说明每半秒更新读数的时钟。 ### 为什么是「问平台」而不是「测出来」 3ms 的一帧会读成 333,没有任何面板能显示这个数。数 present 的时候这个上界是免费的(帧走 vsync 交给合成器),而从帧耗时推导出来的数字没有这个天花板,所以必须显式地夹。 **它推不出来。** present 之间的间隔是面板周期的整数倍,所以只能给出刷新率的**下界**,永远给不出 上界:41.7ms 在 144Hz 屏上是 6 个刷新周期,在 24Hz 屏上是 1 个,时序本身无法区分。试过的每一种 估计法都在真实窗口上读错过——取最短间隔得到 169、取最稠密的一组得到 149、隔帧绘制的窗口得到 75、 而一个自身定时器每 41.7ms 触发一次的应用得到 24。 所以改成向平台索取。GPUI 通过 `DisplayId` 把平台自己的显示器句柄透了出来,HUD 从那里接手: - **macOS** —— 用 `CGDirectDisplayID` 调 `CGDisplayCopyDisplayMode`。内置屏报告「没有固定速率」, 在 ProMotion 上这是实情,按「不夹」处理。 - **Windows** —— 用显示器的设备名调 `EnumDisplaySettingsW`。 - **Wayland** —— 另开一个连接重新枚举 outputs,再按 GPUI 从名字派生的标识与它的 display 对上; 因为对象 id 是每连接独立的,跨连接没有意义。 - **X11 及其它** —— 没有查询,也就不夹。 窗口移动到另一块显示器时会重新索取,其余时候不会。**没人能给出答案时保持不夹**,而不是按猜测夹: 夹到真值以下会把读者想看的数字藏起来。 ## 各行含义 | 行 | 测的是 | | --- | --- | | `INTERVAL` | present 之间的平均间隔,也就是平台自带 overlay 里的 frame interval,`FPS` 的倒数。它与 `MAX` 差得越大,说明窗口越空闲,而不是越慢。 | | `FRAME` | `Window::draw` 的平均耗时,按帧预算着色。觉得卡的时候看这一行。 | | `P95` | 同一批帧的慢尾,着色规则相同。 | | `DROP` | 超出预算的帧占比。 | | `INV` | 合并进同一帧的失效次数。明显大于 1 说明窗口被要求重绘的频率超过了它能画的频率。 | | `GPU` | 平台能提供本进程 GPU 计数器时显示其使用率;否则整行不显示。 | | `CPU` | 本进程,采用 `top` / 活动监视器的口径:100 表示占满一个核,占用一核半约为 150。 | | `MEM` | 归于本进程的内存:macOS 用 physical footprint,Windows 用 private usage,Linux 用常驻匿名内存;必要时回退到 RSS。各平台不是同一种计数器。 | `FRAME`、`P95`、`DROP` 按帧预算着色:平台报得出上面那个刷新率时,预算就是窗口所在面板的一次刷新; 报不出时是一个 60Hz 帧。`frame_budget()` 可以钉一个自己的预算,之后面板不再替换它。 `FRAME`、`P95`、`DROP` 和 `INV` 默认使用最近 **120 个保留的 draw 样本**, 而不是固定时长;`capacity()` 可以调整数量。`P95` 是这批帧中最接近第 95 百分位的 实际帧耗时,`DROP` 则是 draw 耗时**严格大于**预算的比例。曲线画的也是这些 draw 耗时,最新的一帧在右侧。`FPS` 和 `INTERVAL` 使用最近一秒内的 present 时间戳。 屏幕数字每 500 ms 更新一次;CPU、GPU、内存在后台取样,显示最近三秒的平均值。 Web 上不提供资源采样。 这些统计窗口可能描述不同的时刻。一次昂贵操作后,曲线可能仍保留尖峰;连续多帧缓慢时, `P95` 也可能保持偏高,而 `FPS` 已随窗口空闲下降。主读数不按预算着色,因为实际 FPS 低也可能只是应用 不再请求帧。 ## 复现并定位卡顿 1. 在仓库中运行 `cargo run --release -p fps_monitor`。待窗口与 HUD 稳定后, 记录显示器、窗口大小、构建配置,以及 `FRAME`、`P95`、`DROP`、`INV` 和资源行的基线。 示例中的 `+ load`、`− load` 会改变曲线数量;跨次比较时保持数量和窗口大小一致。 示例场景调用 `window.request_animation_frame()`,可提供持续绘制负载。 2. 加大负载,比较 `FRAME` 与显示器预算。60 Hz 的一次刷新约为 16.7 ms, 120 Hz 约为 8.3 ms。`FRAME` 低但 `P95` 或 `DROP` 升高时,寻找间歇性工作; draw 耗时低但 `INV` 升高时,检查重复更新或动画请求;只有 `INTERVAL` 增大时, 先确认窗口是否空闲、被遮挡,或在等待 present。 3. 预热后重复同一操作。构建 view、layout 或 paint 提交成本高时采集 CPU profile; `FRAME` 低但画面仍卡顿时使用平台 GPU/合成器工具。缩小负载范围,每次只改一个原因, 再在相同构建配置和条件下对比。HUD 能指出症状,但不能把耗时归因到具体 view 或 GPU 命令。 若要记录应用特定事件,GPUI 的 `profiler` feature 提供 `FrameTimingCollector::new()` 与 `collect_unseen()`。它们返回 `FrameEvent::Draw` 和 `FrameEvent::Present`;按 `window_id` 筛选,并在采样期间保留 collector。trace 是进程级的,可用 `gpui::profiler::set_trace_enabled(true)` 打开; 关闭会清空缓冲区。HUD 显示期间会管理这个开关。这些原始记录可把帧与应用事件关联, 包括首次失效时间 `dirty_at` 和 present 提交,但也不直接测 GPU 完成或像素何时出现在屏幕上。 参见 GPUI 的 [collector API](https://docs.rs/gpui-pre/0.3.8/gpui/profiler/struct.FrameTimingCollector.html)和 [GPUI Kit monitor 源码](https://github.com/longbridge/gpui-kit/blob/main/crates/fps/src/monitor.rs)。 ## 最初的几帧不计入统计 窗口最初的几帧是最贵的——着色器、字形图集、图标,所有缓存都是冷的——它们并不代表应用的运行成本。 其中一帧可能是 100ms,而预算只有 16ms;只看过八帧的 HUD 会把它算成窗口十二分之一的工作量,用琥珀色 标出来,而此时读它的人什么都还没做。 所以 sampler 会丢掉两部分:HUD 挂载之前 GPUI 记录的全部帧(要么是别人的历史,要么是冷启动), 以及挂载之后的最初几帧。刚打开的窗口,默认读数就应该是健康的。 ## HUD 自己的开销 每 500ms 一帧。它不驱动帧循环,但需要一个时钟——否则在一个已经停止绘制的窗口里没有任何东西能唤醒它, 读数会冻结在应用最后一次绘制的值上。这个时钟同时承担 CPU、GPU 与内存的采样。 这些帧不计入读数。对 GPUI 来说,时钟的 `notify` 和任何一次失效没有区别,都会换来一次整窗绘制; 如果留在读数里,就是每 500ms 一帧冷帧被当成应用的 `FRAME` 和 `MAX`。所以时钟每次触发都会先告知采样器, 采样器把回应它的那次绘制排除在外——除非应用自己也请求了这一帧,那这份工作本来就是应用要的,成本照算。 隐藏起来的 HUD 没有任何开销。连续两个 tick(一秒)没有被渲染,时钟就停下,资源探针随之停止, frame trace 也会放掉(除非别处还持有)。下一次渲染再从一个空的采样器重新开始:trace 缓冲区随开关被清空了, 中间那些帧也本来就不归谁报告。 [`Entity::cached`]: https://docs.rs/gpui-pre/0.3.8/gpui/struct.Entity.html#method.cached --- # Getting Started Source: /versions/v0.7.1/zh-CN/docs/getting-started 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/getting-started 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 本指南创建一个带 GPUI Kit 按钮的小型桌面窗口。你需要 Rust、Cargo,以及对应平台的系统库;macOS、Windows 和 Linux 的要求见[安装](/versions/v0.7.1/zh-CN/docs/installation)。学习这里的视图模型后,如需浏览器目标可继续阅读 [WebAssembly](/versions/v0.7.1/zh-CN/docs/webassembly)。 ## 创建项目 ```sh cargo new gpui-hello cd gpui-hello ``` 在生成的 `Cargo.toml` 中加入 GPUI Kit: ```toml [dependencies] gpui-kit = "0.7.1" ``` 只需这一个依赖,即可使用 GPUI、GPUI Base、带样式的 GPUI Component 和默认图标资源。应用代码通过 `use gpui_kit::*;` 使用 GPUI,通过 `gpui_kit::component` 使用组件。以后可以调整 feature 选择,详见[图标与资源](/versions/v0.7.1/zh-CN/docs/assets)。 ### macOS 文字渲染:`font-kit` **macOS 需要启用 `gpui-pre-platform` 的 `font-kit` feature 才能渲染文字。** 上面的 `gpui-kit` 依赖已经启用了它,按本指南创建应用时无需额外添加依赖或配置 feature。 如果你维护的应用直接依赖 GPUI,需要在 `gpui_platform` 依赖上启用这个 feature。 仅面向 macOS 的 GPUI 应用可使用以下依赖配置: ```toml [dependencies] gpui = { package = "gpui-pre", version = "=0.3.8" } gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit"] } ``` 保留应用原有的其他平台 features,并保持 GPUI 快照版本一致。这是直接使用 GPUI 时的配置; 本页后续 Kit 示例仍只需 `gpui-kit` 一个依赖。不要把 `features = ["font-kit"]` 加到 `gpui` 或 `gpui-kit` 上,也无需单独添加 `font-kit` crate:这个 feature 属于 `gpui-pre-platform`,会启用 `gpui-pre-macos/font-kit`。 `gpui-pre-platform` 默认不启用 `font-kit`。未启用时,macOS 后端会使用 `NoopTextSystem`: 窗口可以打开,但不会渲染文字,`all_font_names()` 也会返回空列表。修改 manifest 后, 重新运行 `cargo run`。在 macOS 上,可以用 `cargo tree -e features -i gpui-pre-macos` 查看后端的 features 由哪些依赖启用,确认其中包含 `font-kit`。 ## 添加视图 将 `src/main.rs` 替换为: ```rust use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::*; struct HelloWorld; impl Render for HelloWorld { fn render(&mut self, _: &mut Window, _: &mut Context) -> 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`,再用 [`Root`](./window) 包裹它。`Root` 管理窗口的浮层,包括对话框、抽屉和通知。闭包返回内容视图,不要再自行返回一个 `Root`。 `HelloWorld` 实现 GPUI 的 [`Render`](./render) trait。GPUI 渲染视图时,`render` 返回[元素树](./element):一个包含文字和 `Button` 的 `div`。按钮是在本次渲染中构建的值;如果控件需要持久状态,比如输入框文字,所属视图应保存对应的状态 [Entity](./entity),不要在 `render` 中重新创建。 ## 一个简短的心智模型 [Entity](./entity) 保存跨帧状态。它可以持有不参与绘制的 model;当 `T` 实现 `Render` 且被挂载时,这个 Entity 就是持久的 **View**,每次渲染都会生成新的元素树。[RenderOnce](./render-once) 组件则把输入作为一个值,描述树中可复用的一部分。调用方提供当前状态和 handler 时适合用它;它仍可使用少量带 key 的元素状态。复杂状态、订阅和任务则需要持久的 owner。 ```text Application entry → feature units (model, command, view) ├─ Entity retains state └─ Entity persistent view; View implements Render └─ Element tree rebuilt on each render └─ RenderOnce values compose reusable parts ``` 应用增长后,拥有独立流程的功能可以把 model 和 View 放在同一个 feature crate 内;只有需要真正面向整个应用的状态时,才在其中使用私有 [Global](./global)。功能之间通过小型公开接口、event 或 `Entity` handle 协作。这样可复用部分容易接入,团队成员或 AI 代理并行修改时也有清晰边界。何时拆分以及如何确定所有权和依赖方向,详见[编码指南](./coding-guides)。 ## 接下来读什么 随着应用扩展,可按顺序阅读: 1. 阅读 [Entity](/versions/v0.7.1/zh-CN/docs/entity)、[Context](/versions/v0.7.1/zh-CN/docs/context) 和 [Render](/versions/v0.7.1/zh-CN/docs/render):持有一个值,在按钮回调中修改它,并确认窗口显示新值。再读 [Window](/versions/v0.7.1/zh-CN/docs/window),了解 `Root` 如何承载视图和浮层。 2. 阅读 [Element](/versions/v0.7.1/zh-CN/docs/element) 和 [RenderOnce](/versions/v0.7.1/zh-CN/docs/render-once):区分每次重建的元素树与持久状态。跟着 [Paint](/versions/v0.7.1/zh-CN/docs/paint) 运行 Brush 练习,确认按下指针会改变绘制结果。 3. 阅读 [Focus](/versions/v0.7.1/zh-CN/docs/focus)、[Action](/versions/v0.7.1/zh-CN/docs/action) 和 [Event](/versions/v0.7.1/zh-CN/docs/event):用 Tab 到达交互目标,触发一个命令并观察状态变化。再按 [Task](/versions/v0.7.1/zh-CN/docs/task) 运行流式示例;连续点击两次 Replay,确认旧分片不会重新出现。 4. 阅读[无障碍](/versions/v0.7.1/zh-CN/docs/accessibility)和[测试](/versions/v0.7.1/zh-CN/docs/test):用键盘完成 Save 流程,检查焦点与可见结果,再运行文档中的 UI 测试,确认渲染状态和保存的模型值。在目标平台上另行检查辅助技术的实际表现。 5. 从[组件目录](/versions/v0.7.1/zh-CN/component)选择应用需要的控件;按界面需求继续阅读[图标与资源](/versions/v0.7.1/zh-CN/docs/assets)和[字体](/versions/v0.7.1/zh-CN/docs/fonts)。 如需了解持久输入状态与订阅,请看[应用示例](https://github.com/longbridge/gpui-kit/tree/main/examples/ai_recipes)中的测试用例。[编码指南](/versions/v0.7.1/zh-CN/docs/coding-guides)说明了这些示例遵循的约定。 ## 完整且经过测试的 View 以下设置界面展示如何持有 InputState 并管理订阅。代码会与[对应的 Rust 源文件](https://github.com/longbridge/gpui-kit/blob/main/examples/ai_recipes/src/settings.rs)保持同步。仓库中的 `gpui-kit-recipes` 默认可执行程序只打开一个简单的 bootstrap 视图,不会显示这个设置界面。[设置界面交互测试](https://github.com/longbridge/gpui-kit/blob/main/examples/ai_recipes/tests/settings.rs)会在 GPUI 测试窗口中挂载 `Settings`。在仓库根目录执行: ```sh cargo test -p gpui-kit-recipes --test settings ``` 测试通过时,输入后预览值先变为 `a`;在一次无关的重新绘制后继续输入,预览值变为 `ab`,变更次数依次为 1 和 2。这验证了测试窗口内的输入订阅和状态生命周期,不等同于原生界面的视觉验收。 ```rust 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, preview: SharedString, changes: usize, enabled: bool, remember: bool, delivery: Option, _subscriptions: Vec, } impl Settings { pub fn new(window: &mut Window, cx: &mut Context) -> 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 { 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) -> 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") }); }), ), ) } } ``` --- # GPUI Kit Source: /versions/v0.7.1/zh-CN/docs 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 # GPUI Kit 简介 GPUI Kit 是一个基于 GPUI 的综合性 Rust 桌面应用开发框架。 依赖名 `gpui-pre` 指 GPUI Kit 固定版本使用的 GPUI 发布快照,并非另一套渲染框架;详见[安装说明](./installation#为什么依赖名是-gpui-pre)。 GPUI Kit 的核心架构包含五个层次: - **`gpui`**:底层 UI 运行时,提供 Entity、窗口、Element、布局与渲染。 - **`gpui-kit`**:应用入口,通过一个依赖重新导出 GPUI,并整合 Base、Component 与默认资源。 - **`gpui-base`**:无样式的行为、受控状态、Focus、浮层、虚拟列表、Dock 基础设施与语义化设计 token。 - **`gpui-component`**:即 GPUI Component,完整的带样式组件库,提供 75+ 个有完整文档的组件与原语,以及主题、数据表格、Dock 布局和代码编辑器。 - **`gpui-shell`**:让 Rust 宿主可以被 JavaScript 扩展,能力逐项授予。 普通应用通过 `gpui-kit` 使用 GPUI、Base、Component 与默认资源;需要承载 JavaScript 扩展时另行依赖 `gpui-shell`。它仍是框架核心架构的一层。 使用 `gpui-component` 可以获得统一、成熟的视觉风格;基于 `gpui-base` 则可以复用可靠的行为与基础设施,同时创建并拥有自己的设计系统。本节文档介绍 GPUI Kit 的入门配置、公共设计与编码指南,以及应用开发。各层 API 请参阅 [GPUI Component](/versions/v0.7.1/component)、[GPUI Base](/versions/v0.7.1/base) 与 [GPUI Shell](/versions/v0.7.1/shell)。 先读 [Focus](./focus),理解 `FocusHandle`、Tab 顺序与键盘目标,再读 [Action](./action) 理解命令派发;[KeyBinding](./keybinding) 介绍 Action 的绑定与当前快捷键的展示。[Event](./event) 解释类型化通知以及 Action 与 Event 的关系。 理解核心渲染模型,可先阅读 [Entity](./entity) 和 [Context](./context), 再阅读 [Render](./render)、[RenderOnce](./render-once) 与 [ElementId](./element_id)。 [Style](./style) 介绍 GPUI 的 fluent 样式方法;[Element](./element) 与 [Paint](./paint) 解释更底层的绘制。 [Task](./task) 介绍 callback 返回后仍继续运行的工作。 ## 按工作顺序学习 GPUI 可以按以下阶段学习。每一阶段都可以先完成一个小任务,再继续下一阶段: 1. **打开窗口**:按[安装](./installation)和[开始使用](./getting-started)运行按钮示例,确认点击后终端输出消息。 2. **持有并更新状态**:创建 [Entity](./entity),通过 [Context](./context) 更新它,再用 [Render](./render) 显示新值。[Window](./window) 说明更新落在哪个窗口。 3. **绘制自定义控件**:跟着 [Element](./element)、[Geometry](./geometry) 和 [Paint](./paint) 使用现有 Brush 示例;需要稳定身份或复用时,再看 [ElementId](./element_id) 与 [View Cache](./view-cache)。 4. **处理输入和持续工作**:先用 [Focus](./focus) 建立键盘目标,再用 [Action](./action) 或 [Event](./event) 连接交互;使用 [Task](./task) 执行异步工作,使用 [Animation](./animation) 构建能正确结束的动效。 5. **检查应用**:用[无障碍](./accessibility)和[测试](./test)核对交互。谈论帧率前先看 [FPS Monitor](./fps);应用需要相应目标时,再看 [WebAssembly](./webassembly) 或[移动端](./mobile)。 核心页面会区分代码示例与运行结果,并连接后续概念。第一个窗口跑通后,可用 [编码指南](./coding-guides)整理所有权和架构约定。 ## 特性 - **75+ 组件与原语**:覆盖表单、导航、浮层、数据展示、编辑、反馈和布局等场景 - **生产就绪**:在实际桌面应用中持续打磨,并通过 GPUI Kit 的组件与示例不断验证;桌面主路径之外的能力以[成熟度](#成熟度)标注 - **WebAssembly**:应用与组件示例可通过 `wasm32-unknown-unknown` 在 Web 中运行 - **无障碍**:交互层内置 AccessKit role、name、state、relationship 与 action - **UI 集成测试**:在 headless window 中驱动真实鼠标、键盘、Focus、布局与无障碍行为 - **原生体验**:设计灵感来自 macOS 与 Windows 的现代桌面控件 - **高刷新率支持**:在 120 Hz 屏幕上,完整工作负载需要落在约 8.3 ms 的单帧预算内;实际流畅度取决于应用、设备与呈现链路。参阅[帧率、刷新率与渲染模式](./fps#120-hz-是帧预算不是刷新承诺) - **数据表格**:虚拟滚动、固定列、列宽调整、排序与单元格选择,可承载数十万行数据 - **虚拟列表**:只渲染可见区域,并支持不同尺寸的列表项 - **代码编辑器**:20 万行、Tree-sitter 高亮、诊断、补全和悬浮提示 - **Dock 布局**:可调整面板、可拖拽标签、嵌套分割和边缘停靠 - **丰富内容**:原生 Markdown 与 HTML、语法高亮和图表 - **设计自由**:使用完整视觉系统,或基于 `gpui-base` 构建自己的系统 - **类型化动效**:CSS 对齐的 easing、timing、keyframes、spring、presence 与测量式展开,稳定采样路径零分配 - **跨平台**:通过一份 Rust 代码交付 macOS、Windows 和 Linux ## 成熟度 GPUI Kit 的桌面组件运行在包括 Longbridge 在内的生产应用中。其他能力的实践积累还比较短,因此在页面标题下方标注成熟度。没有标注的页面即为“稳定”。 | 标注 | 含义 | | --- | --- | | **稳定** | 已用于 macOS、Windows 与 Linux 上的生产桌面应用。 | | **预览** | 可以使用且有文档。API 与边界行为在后续版本中仍可能调整。 | | **实验性** | 可以运行,但存在已知缺口。依赖它之前,请针对你的产品验证。 | | **仅用于演示** | 目前用于在浏览器中演示组件(showcase),不用于交付应用。 | | **依赖平台** | 可用性或行为因操作系统或目标平台而异,页面会列出差异。 | 标注描述的是能力本身,而不是文档的完整程度。例如 WebAssembly 运行的是与桌面相同的组件,但目前仅用于组件 showcase 演示,尚不支持在浏览器中交付应用。 ## 最小示例 按[安装说明](./installation)准备平台依赖后,运行 `cargo new gpui-hello` 创建项目,再运行 `cd gpui-hello` 进入目录。在项目的 `Cargo.toml` 中加入: ```toml [dependencies] gpui-kit = "0.7.1" ``` 将 `src/main.rs` 写成下面的完整程序,运行 `cargo run`。窗口会显示一段文字和按钮; 点击按钮后,终端输出 `Clicked!`。 ```rust use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::*; struct HelloWorld; impl Render for HelloWorld { fn render(&mut self, _: &mut Window, _: &mut Context) -> 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"); }); } ``` 下一步跟着[开始使用](./getting-started)理解初始化、`Render` 和持久状态, 再按上面的学习路线进入绘制与交互。 ## 下一步 - 阅读 [开始使用](./getting-started) - 浏览 [组件文档](../component/index) - 阅读 [GPUI Base 动画与动效](/versions/v0.7.1/base/motion) ## 社区与支持 - [GitHub 仓库](https://github.com/longbridge/gpui-kit) - [问题反馈](https://github.com/longbridge/gpui-kit/issues) - [贡献指南](https://github.com/longbridge/gpui-kit/blob/main/CONTRIBUTING.md) ## 许可证 Apache-2.0 --- # Geometry Source: /versions/v0.7.1/zh-CN/docs/geometry 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/geometry 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 # 几何与颜色 GPUI 用类型说明数字的*含义*:坐标是 `Point`,尺寸是 `Size`,矩形是 `Bounds`;`T` 指明各分量的单位。布局阶段可以使用尚需父容器尺寸才能确定的长度,绘制与命中通常使用已确定的 `Pixels`。颜色则区分便于调整色相的 `Hsla` 与直接表达通道的 `Rgba`。GPUI Kit 会重新导出 GPUI,应用示例可以从 `use gpui_kit::*;` 开始。 ## Point、Size 与 Bounds | 类型 | 字段 | 含义 | | --- | --- | --- | | `Point` | `x`、`y` | 坐标系中的位置。 | | `Size` | `width`、`height` | 不包含位置的宽高。 | | `Bounds` | `origin: Point`、`size: Size` | 与坐标轴平行的矩形。 | 构造函数 `point(x, y)`、`size(width, height)`、`bounds(origin, size)` 会根据参数推断 `T`。它们也接受普通数值类型,但 UI 几何一般使用 `Pixels`: ```rust use gpui_kit::*; let frame: Bounds = bounds( point(px(20.), px(40.)), size(px(240.), px(80.)), ); assert_eq!(frame.right(), px(260.)); assert_eq!(frame.bottom(), px(120.)); assert!(frame.contains(&point(px(20.), px(40.)))); assert!(!frame.contains(&point(px(260.), px(40.)))); let midpoint: Point = frame.center(); let local = point(px(35.), px(55.)).relative_to(&frame.origin); assert_eq!(local, point(px(15.), px(15.))); ``` `right()`、`bottom()` 把宽高加到原点上。`contains()` 包含上边和左边,不包含下边和右边,因此相邻矩形不会同时认领边界上的点。`center()`、`intersects()`、`Bounds::from_corners(...)`、`Bounds::centered_at(...)` 也可用于对齐和定位。局部坐标和窗口坐标都可能是 `Point`:类型负责检查单位,代码仍须明确坐标原点。绘制局部数据时加上 bounds 的 origin;将指针位置换算到元素内部时减去 origin。 这些值通常在布局之后出现。自定义 [`Element`](./element) 在 `prepaint` 获得 `Bounds`,可以据此建立 hitbox,并在后续[绘制](./paint)中使用同一套坐标。`Bounds` 本身不会让区域自动具备交互能力。 ## 明确坐标原点 `Point` 记录单位,却不记录坐标系。窗口内容的左上角通常是窗口坐标的原点;元素自身的左上角是另一个原点。一次计算中同时出现两种坐标时,用变量名标明: ```rust use gpui_kit::*; let element_bounds = bounds(point(px(100.), px(60.)), size(px(80.), px(40.)); let pointer_in_window = point(px(125.), px(75.)); let pointer_in_element = pointer_in_window.relative_to(&element_bounds.origin); assert_eq!(pointer_in_element, point(px(25.), px(15.))); let marker_in_element = point(px(10.), px(8.)); let marker_in_window = element_bounds.origin + marker_in_element; assert_eq!(marker_in_window, point(px(110.), px(68.))); ``` 自定义元素在 `prepaint` 和 `paint` 收到的 `bounds` 已经位于窗口坐标中。指针事件的 `position` 也采用窗口坐标,因此可直接用 `element_bounds.contains(&pointer_in_window)` 判断;只有计算元素内部的字符或拖动手柄位置时才转换为局部坐标。不要给已经基于 `bounds` 的矩形再次加上 `element_bounds.origin`。反过来,虽然局部点和窗口 hitbox 都使用 `Point`,也不能直接比较。 ## 从布局到输入与绘制 三个阶段分别处理不同问题: | 阶段 | 已有的几何信息 | 职责 | | --- | --- | --- | | `request_layout` | 样式长度和布局节点;有些长度仍依赖父容器。 | 返回 `LayoutId`,交给布局引擎求解。 | | `prepaint` | 当前帧已确定的 `Bounds`。 | 准备几何数据;需要交互区域时调用 `window.insert_hitbox(bounds, HitboxBehavior::Normal)`。 | | `paint` | 已确定的 bounds 和准备阶段的状态。 | 用 `window.paint_quad(fill(bounds, color))` 等方法绘制,并注册当前帧的输入监听器。 | 布局树、事件派发树中的 hitbox、最终绘制场景是不同结构。绘制矩形不会自动使它可点击;插入 hitbox 也不会画出矩形。`insert_hitbox` 返回的 hitbox 可作为 `PrepaintState` 传到 `paint`,监听器再用 `hitbox.is_hovered_at(event.position, window)` 判断指针。hitbox 会记录插入时生效的 content mask,因此应先确定裁剪范围,再插入子元素的 hitbox。完整的事件处理示例见[自定义 Element 教程](./element)。 ## 滚动、裁剪与计算练习 滚动视口和内容使用不同的原点。GPUI Kit 的[虚拟列表实现](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/virtual_list.rs)使用**负数**滚动偏移;它把偏移加到 item 的窗口位置,并在视口的 `ContentMask` 下对 item 执行 `prepaint`。`visible_range` 先在内容坐标中筛选 item,再由 mask 限制绘制和输入范围。筛选可见 item 本身并不会裁剪其像素。 ### 运行坐标计算练习 从仓库根目录创建 `examples/hello_world/src/bin/geometry_walkthrough.rs`(若没有 `bin` 目录,先创建)。它是现有 `hello_world` 包内的第二个 binary,不是新 crate。粘贴以下完整程序,然后运行 `cargo run -p hello_world --bin geometry_walkthrough`。 纵向视口在窗口坐标 `(100, 60)`,大小为 `80 × 40`。Item A 从内容坐标 `y = 20` 开始,高度为 `20`,滚动偏移为 `-30`。指针位置使用窗口坐标。程序将它转换为 item 局部坐标,并计算各 item 与视口的交集: ```rust use gpui_kit::*; fn main() { let viewport = bounds(point(px(100.), px(60.)), size(px(80.), px(40.))); let scroll_y = px(-30.); let item_a = bounds( viewport.origin + point(px(0.), px(20.) + scroll_y), size(px(80.), px(20.)), ); assert_eq!(item_a.origin, point(px(100.), px(50.))); let pointer_in_window = point(px(105.), px(65.)); let pointer_in_item = pointer_in_window.relative_to(&item_a.origin); assert_eq!(pointer_in_item, point(px(5.), px(15.))); let visible_a = item_a.intersect(&viewport); assert_eq!(visible_a.origin, point(px(100.), px(60.))); assert_eq!(visible_a.size, size(px(80.), px(10.))); assert!(visible_a.contains(&pointer_in_window)); let item_b = bounds( viewport.origin + point(px(0.), px(80.) + scroll_y), size(px(80.), px(20.)), ); assert_eq!(item_b.origin.y, px(110.)); assert!(!item_b.intersects(&viewport)); println!( "Item A: local pointer = ({}, {}), visible height = {}; Item B visible = {}", pointer_in_item.x.as_f32(), pointer_in_item.y.as_f32(), visible_a.size.height.as_f32(), item_b.intersects(&viewport), ); } ``` 预期输出为 `Item A: local pointer = (5, 15), visible height = 10; Item B visible = false`。若滚动偏移方向或坐标原点弄错,断言会立即失败。Item A 的顶部超出视口 10 像素,只有底部 10 像素与视口相交。Item B 位于窗口 `y = 110`,超过视口底边 `y = 100`。运行后可以删除练习文件。 `intersect()` 只计算矩形,**不会**自行裁剪绘制和输入。真正编写自定义元素时,应在子元素的 `prepaint` 和 `paint` 期间应用视口 `ContentMask`,让子元素的 hitbox 继承 mask,并让绘制像素受其限制,然后通过得到的 hitbox 判断指针。普通滚动容器会替你处理这些步骤。自定义滚动容器还应像虚拟列表那样把偏移限制在内容范围内,并让内容定位、裁剪和 hitbox 使用一致的坐标。 常见错误包括:在布局前把 `relative(0.5)` 当成 0.5 像素;把局部点与窗口 bounds 比较;定位内容时减去负数滚动偏移;或以为 `contains()` 会裁剪已绘制的子元素。遇到错位时,先写清每个中间值的原点与单位,再查看最终 bounds 和当前 content mask。 ## Edges、Side 与 Placement `Edges` 按 `top`、`right`、`bottom`、`left` 保存四个独立值。`Edges` 可表示已确定的 padding、border 或窗口四周留白。`Edges::all(value)` 给四边设置相同值;各边不同时直接填写字段: ```rust use gpui_kit::*; let padding: Edges = Edges { top: px(8.), right: px(12.), bottom: px(8.), left: px(12.), }; let uniform = Edges::all(px(4.)); ``` `use gpui_kit::*` 导入的是 GPUI 的 `Edges`。GPUI Kit 另有可序列化、可生成 JSON schema 的 `gpui_kit::base::Edges`,也通过 `gpui_kit::component::Edges` 导出。它与 GPUI 的类型字段相同,但在 Rust 中是不同类型;应按目标 API 的签名选择。 [`Placement`](https://docs.rs/gpui-base/latest/gpui_base/enum.Placement.html) 表示 trigger 的**一侧**:`Top`、`Right`、`Bottom` 或 `Left`,不是四边留白或矩形。例如,`Positioner::side` 将 `Placement::Bottom` 视为优先方向;下方空间不足时可以翻到 `Top`,随后把结果限制在 viewport 内。`ResolvedPosition::placement` 记录最终选中的方向: ```rust use gpui_kit::*; use gpui_kit::base::{Align, Placement, Positioner}; let trigger_bounds = bounds(point(px(40.), px(40.)), size(px(100.), px(32.))); let popup = Positioner::side(trigger_bounds) .placement(Placement::Bottom) .align(Align::Start) .offset(px(8.)) .child(div().child("Menu")); ``` 这里的 `trigger_bounds` 是窗口坐标中的 `Bounds`。GPUI Kit 的 `Side` 只表示 `Left` 或 `Right`,`Axis` 表示 `Horizontal` 或 `Vertical`。GPUI 的 `Anchor` 指定 `TopLeft`、`BottomCenter` 等参考点;`Corners` 保存四个角的值,常用于圆角半径。它们与 `Placement` 的用途不同。窗口坐标与缩放详见 [Window](./window)。 ## 为什么不直接用整数或浮点数? `px(12.)` 产生 `Pixels`,内部以 `f32` 保存。文字度量、动画及最终栅格化前的位置都可能有小数,整数会丢失这部分精度。裸 `f32` 还可能表示坐标、缩放倍数、透明度或父容器比例,编译器无法检查混用。像素距离之间可以加减,距离乘以标量后仍是像素距离: ```rust let inset: Pixels = px(8.); let width: Pixels = px(120.) - inset * 2.; let raw: f32 = width.as_f32(); // Convert only at API boundaries that require f32. ``` `Pixels` 表示 GPUI 的逻辑 UI 像素,不一定是显示器的物理像素。`Pixels::scale(factor)` 得到 `ScaledPixels`;`DevicePixels` 表示整数设备像素数。例如,`px(12.).scale(2.)` 是 24 个缩放后像素,但其类型仍不同于 `DevicePixels(24)`。跨越显示缩放或栅格化边界时,应区分这些单位。不过,同为 `Point` 的两个值是否使用同一原点,以及宽度是否非负,仍须由应用代码保证。 ## 布局前的 Length 与布局后的 Pixels [样式](./style)长度可能依赖上下文。GPUI 用嵌套类型表达这种差异: | 类型 | 取值 | 用途 | | --- | --- | --- | | `AbsoluteLength` | `Pixels` 或 `Rems` | 固定 UI 长度,或跟随根文字尺寸的长度。 | | `DefiniteLength` | `AbsoluteLength` 或父容器比例 | 已指定数值、但可能仍需父尺寸的长度。 | | `Length` | `DefiniteLength` 或 `Auto` | 也可由布局引擎自动决定的长度。 | `px(24.)` 产生 `Pixels`,`rems(1.5)` 产生 `Rems`,`relative(0.5)` 产生 `DefiniteLength::Fraction(0.5)`,即相关父尺寸的一半。`auto()` 产生 `Length::Auto`。`Pixels`、`Rems`、`DefiniteLength` 都可转换成 `Length`;具体样式方法接受哪种类型,以其签名为准,在链式调用中可让 Rust 推断转换: ```rust use gpui_kit::*; let panel = div() .w(relative(0.5)) .min_w(px(240.)) .h(rems(3.)); ``` 父容器的尺寸确定之前,宽度仍是相对值;rem 也需要根 rem 尺寸。`auto` 是请求布局按规则决定数值,不等于零。GPUI 将这些值传给布局引擎,再得到像素 bounds。 `Percentage` 是另一个独立的 `Percentage(f32)` 包装类型,可用 `percentage(0.25)` 构造。这个辅助函数期望 `0.0` 到 `1.0` 的比例(调试构建中会断言);GPUI 可把它转换为一整圈中相同比例的 `Radians`。它**不是** `relative(0.25)` 用来表示 25% 布局宽度的类型。布局比例使用 `relative`,圆周比例使用 `percentage`。 ## HSLA 与 RGBA GPUI 的 [`Hsla`](https://docs.rs/gpui-pre/0.3.8/gpui/struct.Hsla.html) 保存色相、饱和度、亮度及 alpha,分量是 0 到 1 的 `f32`。其 `hsla(0.6, 0.8, 0.5, 1.)` 构造函数使用色相比例,而不是角度,也会把四个输入限制在这个范围内。主题色及其交互状态默认使用 `Hsla`。[`Rgba`](https://docs.rs/gpui-pre/0.3.8/gpui/struct.Rgba.html) 保存红、绿、蓝及 alpha 通道,分量也为 0 到 1。`rgb(0x3366CC)` 读取六位 RGB 十六进制值,alpha 为 1;`rgba(0x3366CC80)` 读取 **RRGGBBAA** 顺序的八位值,其中 alpha 为 `128 / 255`(约 0.502)。需要处理 RGB 通道或读取十六进制颜色时再使用 `Rgba`。 ```rust use gpui_kit::*; let tint: Hsla = hsla(0.6, 0.8, 0.5, 1.); let translucent = tint.opacity(0.5); // Multiply the current alpha. let exact_alpha = tint.alpha(0.5); // Replace the alpha. let again: Rgba = translucent.to_rgb(); let source: Rgba = rgb(0x3366CC); let from_hex_alpha: Rgba = rgba(0x3366CC80); let red_channel: f32 = from_hex_alpha.r; ``` 想单独调整色相、饱和度或亮度时,HSLA 更方便;十六进制资源、通道数值和颜色合成用 RGBA 更直接。GPUI 可以在两者之间转换,但浮点运算可能产生舍入差异,不能保证逐字节完全往返。HSL 的亮度也不等于人眼感知亮度:不同色相即使 `l` 相同,看起来也可能一明一暗。需要按感知方式插值时,GPUI Kit 提供 `Colorize::mix_oklab`。 GPUI 的 `Hsla::blend(other)` 通过 RGBA 转换把 `other` 叠到 `self` 上。其中 `Rgba::blend` 按上层颜色的 alpha 插值 RGB,并保留接收方的 alpha;它适合不透明背景的情形,不应当作两个半透明图层的一般 alpha 合成公式。 ## 主题颜色与交互状态 GPUI Kit 用 `Hsla` 保存语义主题色,也为组件提供解析后的 token。主按钮会分别使用 `button_primary`、`button_primary_hover`、`button_primary_active` token。因此使用现成组件时,应采用主题 token,而不是就地调整亮度。主题可以显式提供每个 token,背景还可能是渐变。未提供某个 token 时,GPUI Kit 才按主题生成后备值:主色 hover 先将主色原有 alpha 乘以 0.9,再与背景混合;主色 active 在浅色模式将亮度乘以 0.9,深色模式乘以 0.8。主按钮状态 token 再回退到对应的主色状态 token。其他变体有各自的后备规则,没有统一的 hover 公式。 自定义纯色时,`Colorize` 为 `Hsla` 提供 `lighten`、`darken`、`hue`、`saturation` 和 `lightness`: ```rust use gpui_kit::*; use gpui_kit::component::Colorize; let base: Hsla = hsla(0.6, 0.8, 0.5, 1.); let darker = base.darken(0.1); // l = base.l * (1 - 0.1) let quieter = base.opacity(0.6); // a = base.a * 0.6 ``` `lighten(f)` 将亮度乘以 `1 + f`,`darken(f)` 乘以 `1 - f`,并非直接增减百分点。与 `hsla(...)` 构造函数不同,`Colorize::lighten` 不会限制计算结果,可能产生大于 1 的 `l`,用作主题色之前应检查。`Colorize::opacity` 与 GPUI 的 `Hsla::opacity` 都是乘以 alpha。控件状态优先使用语义主题 token;定义新颜色时,应检查文字对比度及明暗两种主题。 --- # WebAssembly Source: /versions/v0.7.1/zh-CN/docs/webassembly 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/webassembly 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 **当前适用范围** GPUI 和 GPUI Kit 的 WebAssembly 支持目前主要用于**在浏览器中展示、体验组件**。组件画廊与 Base 示例可以构建和运行,但本仓库尚未验证它是成熟的完整应用分发路径。浏览器兼容性、无障碍、输入、启动成本和部署方式,都需要针对具体产品逐项验证。 GPUI Kit 可以在浏览器中渲染与桌面端共用的 Rust 视图和组件。Web 目标是 `wasm32-unknown-unknown`:Rust 生成 WebAssembly 模块,`wasm-bindgen` 生成 JavaScript 绑定,网页负责加载并启动应用。浏览器提供 canvas、输入和网络环境,因此只有桌面端的 `main` 函数还不足以作为 Web 入口。 在当前工作区中,[`gpui_web` 是 Cargo 包 `gpui-pre-web` 0.3.8 的别名](https://github.com/longbridge/gpui-kit/blob/main/Cargo.toml)。[`gpui-kit` 将它作为仅用于 WASM 的依赖](https://github.com/longbridge/gpui-kit/blob/main/crates/kit/Cargo.toml),并以 `gpui_kit::web` 重新导出;[画廊 crate](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/Cargo.toml)直接依赖的是 `gpui-kit`,并没有另行直接依赖 `gpui-pre-web`。画廊的 `cdylib`、导出的 `run(...)`、Web 平台初始化和 JavaScript 加载器,共同提供桌面端 `main` 所没有的浏览器入口。 [组件画廊](https://gpui-kit.com/gallery/)是最快可运行的示例。它的 [Rust 入口](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/src/lib.rs)、[构建脚本](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/scripts/build-wasm.sh)和 [JavaScript 加载器](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/www/src/main.js)展示了从 GPUI Kit 视图到浏览器页面的完整路径。 ## 在本地运行画廊 准备好 Rust、Bun 和 `make`,然后从仓库根目录(包含工作区 `Cargo.toml` 的目录)执行: ```sh cd crates/story-web rustup target add wasm32-unknown-unknown cargo install wasm-bindgen-cli --version 0.2.121 make dev ``` 保持 `make dev` 运行,打开 **http://localhost:3000/gallery/**。成功后应看到画廊的组件列表和渲染视图;仅看到加载提示并不能说明图形初始化已经完成。首次 Rust 构建可能比后续构建慢。`make dev` 以 debug 模式构建 WASM,在 `www/src/wasm/` 生成绑定,安装 Web 依赖并启动 Vite。 按下面的顺序验收: 1. 终端显示 Vite 的本地地址,且 Rust 编译和 `wasm-bindgen` 均未报错。 2. 浏览器 Network 面板中,生成的 JavaScript 与 `.wasm` 请求均成功。使用上面的 `/gallery/` 地址,而不是 Vite 服务器根路径。 3. 加载提示消失后出现画廊。打开一个组件故事并操作按钮等控件,确认输入确实传到了 Rust 视图。加载提示消失本身只说明 JavaScript 加载器调用了 `run(...)`。 修改 Rust 视图时,可在一个终端保持 Vite 运行,另一个终端进入 `crates/story-web` 执行 `make build-wasm-dev`,再刷新页面。修改加载器或其他 `www/` 文件由 Vite 处理。Vite 不会自行重新编译 Rust crate,这两种修改不能混为一谈。 [本地工具链文件](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/rust-toolchain.toml)选用 nightly;上述 `wasm-bindgen-cli` 版本与当前检出的 [`Cargo.lock`](https://github.com/longbridge/gpui-kit/blob/main/Cargo.lock) 一致。如果锁文件更新,请让 CLI 版本与锁定的 `wasm-bindgen` crate 版本保持一致。如果已安装其他版本,可运行 `wasm-bindgen --version` 检查,再用 `cargo install -f wasm-bindgen-cli --version 0.2.121` 安装锁定的版本。[构建脚本](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/scripts/build-wasm.sh)为画廊较大的渲染树设置 8 MiB 链接栈,debug 构建也会使用该设置。 要构建生产版画廊,在同一目录运行 `make build-prod`。网页产物位于 `www/dist/`,基础路径为 `/gallery/`。请部署到这个路径;如果部署在其他路径,需要同时调整 [Vite 基础路径](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/www/vite.config.js)和 [Rust 资源端点](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/src/lib.rs)。 ## Web 入口的工作方式 画廊使用 `#[wasm_bindgen]` 导出 `run(story, dark, theme_name, theme_json)`。[加载器](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/www/src/main.js)导入生成的 JS 模块,等待默认 WASM 初始化完成,读取可选的 `?story=` 参数及宿主主题,再调用 `run(...)`。Rust 端先调用 `gpui_kit::platform::web_init()`,创建 Web `Application`、注册资源,并将启动闭包传给 `run_embedded`。图形初始化成功后,该闭包才调用 `gpui_component_story::init(cx)`(其中会调用 `gpui_kit::init(cx)`)、加载字体、应用主题,最后调用 `gpui_kit::open_window`。`run_embedded` 返回的 `ApplicationHandle` 保存在 thread-local 状态中。图形初始化是异步的,因此该函数返回时,启动闭包可能尚未运行,首帧也可能尚未绘制。改造自己的应用时,也要像[快速开始](/versions/v0.7.1/zh-CN/docs/getting-started)那样先初始化 Kit,并在页面使用视图期间保留 handle。 画廊通过 `WebPlatform::new_with_backend_and_font_fallback` 选择 Web 平台,同时接入 fetch HTTP client。`Auto` 先尝试 WebGPU,失败后再尝试 WebGL2。当前 Web 平台使用一个文档级 canvas,且只支持一个顶层窗口;不能再打开第二个顶层窗口,也不能在关闭后重新打开。对话框应通过 Kit 的 `Root` 在该窗口内渲染。可以参照[画廊入口](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/src/lib.rs)改造应用:共用视图留在 Rust 中,网页负责加载 WASM 模块并提供宿主页面。 ### 跟踪一次启动 | 阶段 | 文件或 API | 完成的工作 | | --- | --- | --- | | 编译 | `scripts/build-wasm.sh` | `cargo rustc` 将画廊的 `cdylib` 编译为 `wasm32-unknown-unknown` 目标;随后 `wasm-bindgen --target web` 将 JavaScript 绑定和可供浏览器加载的模块写入 `www/src/wasm/`。 | | 加载 | `www/index.html` 与 `www/src/main.js` | 页面先显示加载状态;加载器导入生成的绑定并等待默认初始化函数。这一步跨越 JavaScript 与 Rust 的边界。 | | 启动 | `src/lib.rs` 中的 `run(...)` | Rust 创建 Web 平台,提供资源并保留 `ApplicationHandle`;`run_embedded` 异步启动图形初始化。 | | 开窗 | `src/lib.rs` 中的启动闭包 | 图形初始化成功后才初始化 Kit、注册字体、应用主题并打开唯一的 GPUI 窗口;之后首个视图才能绘制。 | 将桌面应用移到浏览器时,可保留目标平台支持的 GPUI 视图与状态代码,但还需提供 Web 入口、页面加载器和浏览器专用资源。Rust 目标编译成功并不等于页面加载器或图形初始化已通过测试。 ## 字体与中日韩文本 GPUI Web 平台启动时的字体数据库是**空的**。画廊使用 `include_bytes!` 将四个 TTF 子集嵌入 WASM:Inter 用于界面文字,JetBrains Mono 用于代码,Noto Sans SC 包含故事中用到的汉字,IBM Plex Sans 对应 GPUI 的 `.SystemUIFont` 别名。最后这个字体必须先于首个窗口加载:初始文本测量也可能沿用窗口默认样式,如果对应字体不存在,文本系统就会报错。画廊先调用 `cx.text_system().add_fonts(...)`,再应用主题,并把界面字体与等宽字体强制设回已打包的字体;选中的主题可能指定仅桌面端存在的字体。详见[字体初始化与主题代码](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/src/lib.rs)和[字体](/versions/v0.7.1/zh-CN/docs/fonts)。 [子集脚本](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/scripts/subset-fonts.py)扫描故事源码,以及这些源码通过 `include_str!` 引入的文件(例如 README),只保留其中出现的字符。当前检出中,Noto Sans SC 子集为 **42,092 字节**,仓库内对应的原始 TTF 为 **1,213,236 字节**。这是字体文件大小,不能当作压缩后 WASM 传输量的差值。访客输入的新文字不一定包含在该子集中。 对于符合条件、打包字体缺少的 emoji,以及横排汉字、假名和现代谚文字素,`CanvasFontFallback::EmojiAndCjk` 可让浏览器用本机字体测量并绘制。GPUI 已加载的字体仍优先。回退按单个字素工作,因此字形覆盖、字距和排版特性取决于浏览器,不能代替完整的中日韩字体。默认策略仅覆盖 emoji,`Disabled` 则只使用已加载字体。策略在构造 `WebPlatform` 时确定,之后不能更改。 GPUI 也支持**启动后加载字体**。在本仓库固定的 GPUI 版本(`gpui-pre` 0.3.8)中,`TextSystem::add_fonts` 可以通过 `Cow::Owned` 接收下载的字体字节,并使字体解析及行布局缓存失效。异步请求得到并检查过有效的原始字体文件后,在应用上下文注册并刷新窗口: ```rust use std::borrow::Cow; // cx: &mut App; font_bytes: Vec, downloaded and validated by the application. cx.text_system().add_fonts(vec![Cow::Owned(font_bytes)])?; cx.refresh_windows(); ``` 使用 GPUI Web `examples/hello_web/dynamic_fonts.rs` 所示的原始 TTF 等受支持字体文件,不要把字体服务的 CSS 响应直接传给文本系统;CSS 可能引用 WOFF2 子集。该示例通过 `cx.on_missing_glyphs(...) -> Subscription` 在缺字时请求字体,保留订阅与下载 task,检查 HTTP 状态是否成功、响应体是否非空,再调用 `add_fonts` 与 `refresh_windows`。字体是否能解析由 `add_fonts` 检查;仅有 HTTP 200 不能证明响应是可用字体。缺字报告会去重,队列有长度限制;报告丢弃后不会自动安排重试。当前画廊**没有**按需下载字体,而是打包子集并使用 Canvas 回退。GPUI Web 先应用 Canvas 回退,再报告缺字;成功由 Canvas 绘制的中日韩文字**不会**触发 `on_missing_glyphs`。如果需要准确的中日韩排版,应根据内容或语言选择决定要下载哪种字体,提供加载和失败状态,并在安装后检查布局。跨域字体下载还必须符合对方服务器的 CORS 策略。 ## 更小的 GPUI Base 示例 [GPUI Base WASM 示例](https://github.com/longbridge/gpui-kit/tree/main/crates/base/examples/wasm)与原生示例共用同一套[展示视图](https://github.com/longbridge/gpui-kit/tree/main/crates/base/examples/showcase)。从仓库根目录单独运行时,如果尚未安装匹配版本的 `wasm-bindgen-cli`,请一并安装: ```sh cd crates/base/examples/wasm rustup target add wasm32-unknown-unknown --toolchain nightly cargo install wasm-bindgen-cli --version 0.2.121 make dev ``` 保持服务器运行,打开 **http://localhost:3001/examples/base/**。它使用 `gpui_platform::single_threaded_web()`,通过 `wasm-bindgen` 生成绑定,并由 Vite 提供示例。导出的 `run(component)` 选择展示视图;JavaScript 加载器从 URL 读取 `?component=...`。CLI 版本同样以工作区 `Cargo.lock` 为准。详见[构建脚本](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/wasm/scripts/build.sh)和[加载器](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/wasm/www/src/main.js)。要从有主题的 GPUI Kit 组件入手,请使用画廊;此示例主要用于了解 Base 原语。 ## 加载失败或画布空白时如何排查 先打开浏览器的 **Console 与 Network** 面板。画廊加载器会捕获导入绑定、初始化 WASM 或调用 `run` 时的异常,并用“Failed to load the application”替换加载提示。图形初始化发生在 Web 平台稍后的异步任务中;如果失败,平台会记录错误,并向页面追加“Failed to initialize browser graphics”。因此,加载提示消失并不代表首帧已经绘制。 | 现象 | 检查方式 | | --- | --- | | Rust 编译找不到 WASM 目标,或找不到 `wasm-bindgen` | 在 `crates/story-web` 下执行 `rustup target add wasm32-unknown-unknown`,让其选择的 nightly 工具链安装目标。按 `Cargo.lock` 的版本安装 CLI,再执行 `make build-wasm-dev`。必须先成功完成 Rust 构建,Vite 页面才有模块可加载。 | | JS 或 `.wasm` 请求返回 404 | 打开 `/gallery/`,而不是站点根路径。让 Vite 的 `base: '/gallery/'`、部署目录和生成文件保持一致。Rust 修改后重新执行 `make build-wasm-dev`,确认 Web 构建包含 `www/src/wasm/` 中的生成文件。 | | `wasm-bindgen` 导入或实例化失败 | 让 `wasm-bindgen-cli` 版本与锁定的 crate 一致,并重新生成绑定。以 `application/wasm` MIME 类型提供 `.wasm`;MIME 错误时生成的加载器可能退回较慢的非流式实例化。检查 Network 响应是否真的是模块。 | | 加载提示消失,但 canvas 仍为空白 | 在 `run` 返回之后继续查看 Console。`Auto` 先尝试 WebGPU,再尝试 WebGL2;若都失败,检查浏览器 GPU 支持、策略和硬件加速。Web 运行时不能打开第二个顶层窗口。 | | 启动时 panic 或文字缺失 | 先于窗口注册 IBM Plex Sans 和初始主题所用的每种字体。画廊的 `add_fonts(...).expect(...)` 与 `open_window(...).expect(...)` 会把错误变成 panic;panic hook 会将其输出到 Console。 | | 中日韩文字显示方块或字形不对 | 确认字符是否在打包子集中,再检查 Canvas 回退策略和浏览器实际字体覆盖。若需要准确整行排版,应下载合适的原始字体。 | | 图标空白 | 检查实际请求 URL、HTTP 状态及 CORS。`Assets::load` 会直接拼接 endpoint 和 `/assets/icons/...`:如果 endpoint 像画廊一样以 `/` 结尾,URL 中会出现 `//assets/`。自己的应用应使用不带末尾斜杠的 endpoint。即使本地开发,画廊固定的资源端点仍指向已发布站点。资源加载器会缓存成功下载的图标,但本身不会请求刷新窗口;如果图标仍为空白,可以触发重新渲染并查看 Console 中的资源错误。 | ## 包体大小与网络分发 `include_bytes!` 把画廊字体子集**放进 WASM 载荷**,访客需要在首帧前随模块一起下载。画廊还包含组件故事与渲染栈;图标 SVG 则另行按需请求。Vite 将 JavaScript 加载器和 WASM 打包到 `/gallery/`,但仅修改 Vite 基础路径不会改变 Rust 中的图标端点。运行 `make build-prod` 后,`ls -lh www/dist/assets/*.wasm` 可查看未压缩的模块大小。应在浏览器 Network 面板比较该模块与 JS 的**压缩传输量**,并用限速网络记录首次加载时间。本地文件大小、CDN 压缩、浏览器缓存、编译时间和 GPU 初始化分别是不同的成本。 发布画廊副本前,应从目标 `/gallery/` 路径提供 `www/dist/`,对部署后的页面重新执行上面的三步浏览器验收。确认生成的 `.wasm` 和 JS 地址指向部署主机,而图标、主题、字体请求指向预期主机。示例将资源端点固定为已发布的 GPUI Kit 网站,因此本地画廊运行成功**不能**证明另外托管的副本会提供自己的图标。分别记录首次加载与缓存后的加载结果,浏览器缓存会改变测量值。仓库的[发布流程](https://github.com/longbridge/gpui-kit/blob/main/.github/workflows/release-website.yml)构建两套 WASM 示例并将其 `dist/` 文件复制到网站对应路径,但仍需在最终主机上做浏览器验证。 例如,**如果**把类似长桥 Pro / Longbridge Pro 的完整应用及全部功能和字体覆盖一并编进单个 WASM 模块,首次下载和启动成本可能显著增加。这是需要测量的分发风险,并非该应用的实测包体,也不表示它目前在 Web 上发布。把字体或功能放到后续请求中可以降低首次传输量,但也会增加网络、缓存、CORS 和加载状态处理。画廊的字体子集与按需图标体现了这些取舍,不能直接作为完整应用的包体预算。 ## 需要规划的 Web 能力 | 方面 | 示例中的做法 | 在自己的应用中需要检查 | | --- | --- | --- | | 资源 | WASM 上的 `Assets::new(endpoint)` 通过拼接 endpoint 和 `/assets/icons/...` 按需下载 SVG。画廊的 endpoint 指向已发布站点,Vite 构建则把图标复制到 `/gallery/assets/`。 | 托管对应路径,并让 endpoint 与部署路径一致,且不要在末尾加斜杠。如果不修改 endpoint,本地画廊也会从已发布站点请求图标。文件缺失或请求失败会使图标无法显示。详见[图标与资源](/versions/v0.7.1/zh-CN/docs/assets)。 | | 字体 | 画廊在首帧前嵌入 Inter、JetBrains Mono、Noto Sans SC 子集和 IBM Plex Sans,应用主题时重新设置字体。 | 打包初始字体;对于更大的文字集合,可考虑运行时下载字体,并验证回退与布局。详见[字体](/versions/v0.7.1/zh-CN/docs/fonts)。 | | 键盘与输入法 | Web 平台通过一个很小的隐藏 HTML input 接收键盘与组合输入事件。画廊加载器在嵌入页面时管理焦点,并在纯触屏设备上禁用文字输入,避免 canvas 交互意外弹出键盘。 | 在目标浏览器和设备上检查焦点、Tab 顺序、组合输入和屏幕键盘。画廊的纯触屏策略只适合展示用途,不能当作通用文本输入方案。 | | [无障碍](/versions/v0.7.1/zh-CN/docs/accessibility) | 组件可以在 GPUI 中声明 role 和 label,而此 Web 示例最终绘制在 canvas 中。 | 在浏览器中实际验证屏幕阅读器与键盘操作。不要推断原生无障碍桥接或 GPUI 属性在 Web 上有等价语义。对于目标浏览器无法暴露的内容或操作,提供可访问的 HTML 替代界面。 | | 原生服务 | 浏览器提供 fetch 及自身的输入、渲染 API;桌面设施的可用性和权限不同。 | 将文件对话框、剪贴板、通知等功能放在按目标平台区分的能力接口后面,并测试浏览器路径。详见[编码指南](/versions/v0.7.1/zh-CN/docs/coding-guides#平台与能力边界)。 | 发布流程会[构建两套 WASM 示例](https://github.com/longbridge/gpui-kit/blob/main/.github/workflows/release-website.yml),因此这些入口也是持续维护的构建参考。WASM 构建成功只说明能够编译;交互、字形覆盖和无障碍仍需在浏览器里验证。 --- # Coding Guides Source: /versions/v0.7.1/zh-CN/docs/coding-guides 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/coding-guides 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 # 编码指南 本指南总结 GPUI Kit 中经过长期实践验证的应用架构与代码模式,面向工程师和 coding agent。请先阅读[设计指南](/versions/v0.7.1/zh-CN/docs/design-guides):代码结构的职责是保存产品意图,而不是替代产品设计。 本文是一份规范性指南:**必须**表示生命周期、正确性或生态约束;**应该**表示默认架构,偏离时需要有具体理由。精确方法签名以当前源码和 API 文档为准。 ## 让常见用法自然低成本 **设计原则:框架类型与所有权安排,应让组件的常见用法默认就低成本。** 开发者应能构建、复用 UI,而不必在每个调用处手写缓存。持久 UI 文本使用 [SharedString](./shared-string)、轻量组件值使用 [RenderOnce](./render-once),都是这种设计的例子:API 把所有权放在恰当位置,使常见路径避免重复工作。微小成本仍会随元素数和渲染次数放大;只有测到特殊热点后才添加缓存。这两种类型都不承诺零成本。具体机制见下文的[文本所有权规范](#用-sharedstring-保存持久化-ui-文本)与渲染章节。 ## 架构总览 GPUI 应用架构层次 GPUI 应用架构层次 依赖只向下。上层负责领域语义与流程协调,下层负责可复用的表现和行为。通用 component 不能依赖具体应用页面;`gpui-base` 不能依赖 GPUI Component 主题。 边界定义如下: - **app shell:** 组合窗口与 Feature Crate,不承载具体 Feature 逻辑; - **feature crate:** 在一个公开边界内组织同一业务能力的 model、service、view、command、dialog 与 workflow; - **app component:** 跨 Feature 复用且带有领域语义的模式; - **gpui-component:** 带主题的通用 UI; - **gpui-base:** 不带产品表现的可复用行为与 geometry。 ### 大型应用按业务能力组织 crate 在大型 Rust 应用中,当复杂业务能力的状态、生命周期与公开边界值得独立维护时,通常将它拆成 feature crate。把它的 model [Entity](./entity)、service、[`Render`](./render) View、command、dialog 与 workflow 放在一起。编辑 Workspace 的 dialog 属于 Workspace Feature;只有可复用的 Dialog 基础组件属于 UI library。小能力可以先留在 module 中,等 crate 边界有具体收益再拆分。 ```text crates/ ├── app/ │ └── src/main.rs # Compose windows and features ├── workspace/ │ └── src/ │ ├── lib.rs # Public feature boundary │ ├── model.rs │ ├── commands.rs │ ├── workspace_view.rs │ └── rename_dialog.rs ├── search/ │ └── src/ │ ├── lib.rs │ ├── model.rs │ ├── commands.rs │ ├── search_view.rs │ └── filters.rs ├── settings/ │ └── src/ │ ├── lib.rs │ ├── model.rs │ ├── settings_view.rs │ └── account_dialog.rs └── shared/ └── src/ ├── lib.rs └── recent_items.rs # Shared capability used by multiple features ``` 不要反过来建立全局 `models/`、`views/`、`modals/` 与 `commands/` 目录。这种方式只是按实现角色给文件分类,却会把每个 Feature 拆散到整个应用中。 App Shell 只组合 Feature Crate,尽量不承载 Feature 逻辑。每个 Feature 拥有自己的 model Entity 和渲染它们的 View。只有确实属于整个应用的 service 或 registry 才在 Feature 内使用私有 [`Global`](./global);仅供一个 Feature 使用的 model 不应自动成为 Global。同一 Feature 内的协作者传递 clone 后的 `Entity` handle,避免反复复制大型 `Vec` 或 collection。跨 Feature 边界只暴露有意公开的 Entity 类型,或小型 handle、领域 ID、command、event、interface;model 字段和实现 module 保持私有。 例如 Workspace crate 可以导出可 clone 的 handle,把可变 model 留在公开 API 之后: ```rust // Simplified workspace/src/lib.rs; move a larger model to a private model.rs. use gpui_kit::{App, Entity, SharedString}; struct WorkspaceModel { name: SharedString, } #[derive(Clone)] pub struct WorkspaceHandle { model: Entity, } impl WorkspaceHandle { pub fn rename(&self, name: SharedString, cx: &mut App) { self.model.update(cx, |model, cx| { model.name = name; cx.notify(); }); } } ``` 应用可以保存这个 handle,并把 Workspace ID 传给 Search;Search 无须拿到 Workspace 的整个 collection 或私有 View。一个简单的无环依赖关系是: ```text app ──▶ workspace ──▶ shared contracts └────▶ search ─────▶ shared contracts workspace, search ──▶ gpui-kit ``` Feature 可以依赖稳定的共享能力和 UI 基础设施,但不能反向依赖 App Shell,也不能访问另一个 Feature 的私有 module。如果一个 Feature 必须调用另一个 Feature,应让依赖显式且单向,或把真正共享的契约放在两者下层。共享 crate 要有清晰用途和真实 owner,避免循环依赖与无所不包的 `shared` crate。 这些边界让不同工程师或多个 AI agent 可以并行处理不同能力,减少文件冲突与共享状态耦合;共享契约仍需要协调。Cargo 也可能复用已缓存的依赖,在局部变更后只重编译较小的依赖子图,但增量编译速度取决于依赖图、公开 API 改动、feature 与构建配置;增加 crate 自身也有成本,不能保证每次修改都编译更快。按所有权和协作需要拆分,不要为每个页面或 helper 建 crate。 ## 初始化与 Root 所有权 在创建组件 view 前只初始化一次 GPUI Component,并让每个窗口的第一层是 `Root`: ```rust app.run(move |cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |window, cx| { cx.new(|cx| Workspace::new(window, cx)) }) .expect("failed to open window"); }); ``` `Root` 不只是容器。它协调 dialog、sheet、notification、tooltip/menu layer、modal focus restore、focus trap 与窗口级文本选择。一个窗口只能有一个 `Root`;绕过它的 UI 可能静止时正常,却会在 overlay 嵌套或快速切换 focus 时失效。 ## 理解 GPUI 阶段与上下文 GPUI 使用 retained state 与 declarative rendering。[Entity](./entity) 跨 frame 存活;`render` 返回的 [Element](./element) tree 只描述当前 frame。必须始终区分持久状态与一次 render 的输出。[Context](./context) 解释 `Context`、`App` 与 `Window` 的作用范围。 - `Context`:修改当前 entity,建立 listener,emit event,并通知 observer; - `App`:访问 global 以及读取或更新 entity,不表示当前 element 拥有这些状态; - `Window`:拥有窗口 focus、Action dispatch、input、element keyed state、measurement 与 animation-frame request; - layout、prepaint、paint 属于后续 phase,只有需要 resolved geometry 时才使用相应 hook。 不能把 `&mut Window`、`&mut App`、`&mut Context<_>` 保存到当前调用之外。应保存`Entity`、`WeakEntity`、`FocusHandle`、scroll handle 或领域 ID 等 typed handle。 ## 选择正确的组成单元 ### 值类型 UI 使用 `RenderOnce` **默认优先用 [`RenderOnce`](./render-once) 构造轻量、可复用的组件值。** caller 传入当前 props 和 handler,GPUI 消费这个值生成元素,此后该值即可丢弃。GPUI Kit 的 `Button` 和 `Checkbox` 就遵循这一模式:显示的 label、checked 或 selected 值与 callback 来自 caller 当前持有的状态。`RenderOnce` 组件仍可使用少量带 key 的交互状态,例如 focus handle;它没有独立的 View 生命周期。不要因此认为它的 render 方法在每个显示帧都必定调用:显示一帧并不一定需要重建这个组件的元素树。 ```rust #[derive(IntoElement)] struct EmptyState { title: SharedString, } impl RenderOnce for EmptyState { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { div() .v_flex() .gap_2() .items_center() .text_color(cx.theme().muted_foreground) .child(self.title) } } ``` ### 跨 frame 行为使用 `Entity` 组件需要持久的复杂状态或独立生命周期,例如 subscription、异步工作、history、子 Entity 或增量更新时,使用由 Entity 支撑的 [`Render`](./render) View。Entity 存在 owning view 中,不能在每次 `render` 里重建。 ```rust struct SearchView { query: Entity, } impl SearchView { fn new(window: &mut Window, cx: &mut Context) -> Self { let query = cx.new(|cx| InputState::new(window, cx).placeholder("Search…")); Self { query } } } ``` 不要把每个视觉 fragment 都做成 Entity;生命周期和协调有成本,只有 retained identity 确实重要时才建立 entity boundary。 ### 元素、视图与行为系统各有职责 生态中的 public unit 并非一种模板: - Button、Checkbox、Link、Tabs 等 semantic element; - Dialog、Popover、Select、Combobox 等 compound behavior root; - Input、Table、Tree、Dock、notification 等 entity-backed system; - positioning、virtualization、scroll、focus trap、motion、history、measurement 等 infrastructure。 Element 内部可以很复杂,但对 caller 仍是值。Stateful system 可以通过 render callback 让应用拥有表现,同时不重做行为。Public seam 应由行为决定,而不是 renderer 中有多少个 `div` 决定。 ## 状态所有权 ### 用 `SharedString` 保存持久化 UI 文本 **跨 frame 或 callback 闭包保存的 UI 文本必须优先使用 [`SharedString`](./shared-string)**,包括组件的 label、title 和 placeholder。把稳定文本存在所属 View 或 Entity 中,构建元素时 clone 这个 handle;不要在每次 render 时反复 clone `String`。可变编辑缓冲、格式化构建阶段使用 `String`,完成后再转换;单次调用内临时只读则使用 `&str`。 API 或 JSON 响应中若有供 UI 长期持有的不可变文本,应在反序列化边界就把响应字段定义为 `SharedString`。保留收到的 transport 值;派生或格式化后的展示文本放在单独的领域或 View model 中,不要改写响应字段,也不要反复把 `String` clone 进 UI。真正可变的缓冲区或仅供非 UI 短期解析的文本不受此规则约束。见 [API 响应示例](./shared-string#从-api-响应进入-view)。 这是所有权与性能规范,并不表示文本总是零分配。短值可以内联;较长的动态文本使用共享存储,clone 不复制文本字节。但首次构造仍可能分配,堆上文本的 clone 也有引用计数成本。具体存储方式与取舍见 [SharedString](./shared-string)。 状态应放在能够保持其正确性的最窄 owner 中: - domain state 属于 model 或 feature view; - transient view state 属于绘制它的 view; - reusable behavior state 属于为该行为设计的 component state; - 少量 element-local state 可以使用 GPUI keyed state; - 共享 application service 可以使用 GPUI global。 普通 selection/toggle 优先采用 controlled value:传入当前值,接收 requested change,由 owner 更新,再 render。Callback 表达 intent,不能建立第二份隐藏真相。 ```rust Checkbox::new("show-hidden") .checked(self.show_hidden) .label("Show hidden files") .on_click(cx.listener(|this, checked, _, cx| { this.show_hidden = *checked; cx.notify(); })) ``` 改变渲染结果后调用 `cx.notify()`;owner 需要处理语义事件时使用 `cx.emit(...)`;生命周期跟随 Entity 时使用 `cx.subscribe(...)` / `cx.observe(...)`。返回的 Subscription 必须有明确的生命周期 owner。 ### 限定所有权关系与订阅生命周期 父级用强 `Entity` handle 持有子级时,子级回指父级必须使用 `WeakEntity`;强回指会闭合所有权环。`WeakEntity::upgrade()` 返回 `Option`,其 `update` 返回 `Result`,所以父级已释放应作为正常生命周期结果处理。所有权和弱引用访问模式见 [Entity](./entity)。 把返回的 `Subscription` 保存在需要 callback 的 View 或 Entity 上,通常是 `_subscriptions: Vec`。owner 释放时 handle 随之 drop,订阅也会取消。仅存于初始化函数的局部 handle 会过早 drop;`Subscription::detach()` 则会放弃取消订阅的 handle,让 callback 持续到被订阅的 Entity 释放。默认不要 detach 随 View 存续的重复订阅;在长寿命 emitter 上反复 detach 可能使 callback 持续累积。GPUI 的 `observe` 和 `subscribe` 内部使用订阅者的弱 handle,但 callback 如果额外捕获了自己 View 的强 handle,仍可能闭合所有权环。 View 意外无法释放时,先检查强 Entity 环、捕获强 View handle 的 callback,以及生命周期超出预期的已 detach 订阅或 [Task](./task)。drop Task handle 会取消尚未完成的工作,本身不会继续保留 View。这些是优先排查点,并非所有内存泄漏原因。 读取或派生值不能触发 notify。禁止在 `render` 中无条件 notify,否则会永久重绘。形成同一 invariant 的字段应一次更新,只 notify 一次。无法获得 context 的 reusable state API 必须明确让 owner 负责 emit/notify。 ### 防止状态反馈环 文本输入、selection、filter 和 controlled popup 常有两条路径:外部 owner 设置值,以及用户请求新值。同步外部值时不能再次通过 user callback 回传。使用 origin/revision 或 coherent snapshot 比较,确保一次逻辑变化只报告一次。Callback 可能同步关闭、替换或更新当前组件时,调用路径必须可重入。 ## 稳定标识 [`ElementId`](./element_id) 是行为契约的一部分。它为元素提供稳定标识,并作为元素局部状态或组件状态的键。组件也可以将它用于自身的焦点、测量或动画标识;焦点与滚动通常仍由各自的 handle 管理。 - row、tab、tree node、重复 control 使用稳定 domain ID; - 同一 control 重复出现时,以 owning object namespace child ID; - 可插入/重排的数据不能使用翻译 label 或 mutable index 作为 ID; - `render` 中不能生成新 random ID。 ```rust Button::new(("delete-project", project.id)) .danger() .label("Delete") ``` ID 改变表示 UI identity 改变,其状态 reset 必须是有意行为。Transition channel、overlay token、scroll handle 和 persistence ID 也遵循相同规则:共享 key 会相互覆盖,每 frame 换 key 则永远无法累积状态。 ## 渲染与组合 `render` 保持 declarative:读取当前 state、派生 presentation value、组合 element。Domain operation、parsing 与复杂 mutation 放入具名 method 或 service。 ```rust impl Render for ProjectView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .v_flex() .size_full() .child(self.render_toolbar(cx)) .child(self.render_content(cx)) } } ``` 当 helper 能命名有意义区域并减少读者同时记忆的状态时,提取 `render_*`。只有该区域有可复用契约或 retained lifecycle 时才提取新 component;builder chain 很长本身不是理由。 一致使用 GPUI Component fluent trait(`Sizable`、`Disableable`、`Selectable` 和 component builder)。小的 conditional refinement 使用 `.when(...)` / `.when_some(...)`;分支代表不同 interface 时使用普通 Rust control flow。 构造 custom surface 前先使用标准 semantic component。不能为了匹配一张截图就用 generic `div` 重做 menu、select、dropdown 或 command palette。复用 component 才能保留 item geometry、focus transfer、keyboard navigation、selection、disabled state、dismissal 与 accessibility contract。如果标准 component 无法表达可重复的合理 pattern,应改进其 explicit API,而不是在每个 call site styling arbitrary descendant。 应用提供的 render callback 必须无副作用。List item renderer、menu builder、dock panel renderer 可能因 measurement 或 redraw 被多次调用,不能执行业务操作、追加数据或注册无界 subscription。 ## 行为与表现的边界 Base 的长期规则是: > Base 拥有可复用行为,以及实现行为所必需的 geometry;表现层拥有产品视觉语言。 “Headless”不表示“只有一个空 div”。Popup collision、keyboard navigation、editing、virtualization、resize arithmetic、focus trap 和 dock reconciliation 需要内部结构与状态。把它们推给 caller 不是灵活,而是复制脆弱行为。 反过来,Base 不能选择品牌色、字体、密度、最终图标、variant 或应用 composition。表现能力通过 `Styled`、typed semantic-state style、explicit part、child slot 和 item renderer 暴露。不能遍历任意后代去猜 title/description/close button,必须提供语义 part。 ## 主题与样式 从 active theme 读取 semantic value,使用 GPUI 的 [`Styled` method](./style) 布局: ```rust div() .bg(cx.theme().background) .text_color(cx.theme().foreground) .border_1() .border_color(cx.theme().border) .rounded(cx.theme().radius) ``` 规则: - 不写死 product color、radius、spacing 或 control geometry; - application code 不得引入 raw hex、`rgb`/`rgba` 或 `hsla`;颜色从 `cx.theme()`读取,缺少语义 role 时补入 product theme; - application layout 使用 GPUI rem-based helper(`p_2()`、`gap_3()`、`w_64()`、`text_sm()`),而不是直接 `px(...)`; - token 按语义而不是 palette 位置使用; - state-independent geometry 放普通 builder chain; - runtime hover/active/focus/focus-visible 使用 GPUI modifier; - checked/selected/pressed/disabled 使用组件 semantic state style; - popup ownership 使用 explicit state,使 trigger 在 dismiss 前持续渲染 open/pressed 外观; - disabled control 不应响应时,guard hover/active refinement; - variant 保持少而有意义,不为每个 call site 增加一个 variant。 - primary style 绑定决策区域真正的 default commit 与 Enter action,不能从 action 数量、频率或 toolbar 位置推导。 - Badge/Alert variant 保持 semantic 且稀缺。普通 metadata 使用 neutral;不能仅因为 variant 存在,就把每个 enum case 或 section 映射成不同颜色。 有效 precedence 为:instance style → active semantic state → disabled → GPUI runtime interaction refinement。后一层只替换自己设置的字段。 新的 application-owned presentation 优先使用 `Theme::semantic_tokens()`:它提供通用 color role、radius、spacing、typography、shadow scale,并有意避免 component-specific 名称。Legacy token 保留兼容性,但不应继续成为每个 application widget 的 extension point。 当前有一个必须明确的 ownership 边界:`Theme::spacing_tokens()` 投射固定默认 scale,`Theme::apply_semantic_tokens(...)` 不保存 custom spacing/elevation scale。应用如需自定义,必须自行持有 `SemanticThemeTokens`(或更窄的 design-system state)并提供给 component。不能把 custom spacing snapshot 写入 global theme 后,期待下次`cx.theme().semantic_tokens()` 仍返回它。 通过 `Theme::update(cx, |theme| ...)` 修改 GPUI Component 的 global theme。theme 里同一份颜色存了两次(`colors` 是纯色,`tokens` 是可带渐变的可绘制背景),Base 层还持有一份给 scrollbar 与 resize handle 的 projection;`update` 在闭包结束后把三份重新对齐并刷新所有窗口。通过 `Theme::global_mut(cx)` 修改只会改到你碰的那个字段——侧栏可能用新 colors 画文字、用旧 tokens 画背景——剩下的要自己做:从 `colors` 重新推导 `tokens`、调用 `Theme::sync_base(cx)`、刷新窗口。完整的 `Theme::change(...)` 会自行同步。 向外绘制的 focus ring 需要空间,ancestor `overflow_hidden()` 会裁掉它。优先让布局留出空间;产品确实需要大量 clipping 时,通过 theme focus-ring policy 保留 focused border,不能悄悄消除键盘焦点。 ### 基础字号控制应用缩放 `Root::render` 会调用 `window.set_rem_size(cx.theme().font_size)`。因此 theme base font 不只是 body typography,也是 application rem-based design scale 的 reference length。这有意沿用 [Tailwind CSS 的主题模型](https://tailwindcss.com/docs/theme)中有价值的部分:具名 type、spacing、size step 共享一个 relative base,而不是变成互不相关的 pixel constant。GPUI 方法名称与布局行为见 [Style](./style)。 通过更新 base font 并 refresh window 改变 zoom: ```rust Theme::update(cx, |theme| theme.font_size = px(18.)); ``` Base font 自身是 px,因为它负责锚定 scale。Descendant application UI 通常使用`text_sm()`、`gap_2()`、`px_3()`、`h_8()`、`size_4()` 等 relative helper,让 type、whitespace、control 与 icon 一起响应。Custom component 如果把 rem-based text 与 fixed-px padding/icon geometry 混合,必须记录为什么该部分不应 zoom。 Application UI 中每个直接 `px(...)` 和 raw color constructor 都应视为 review finding。只有 documented physical/platform boundary、measured runtime geometry、raster/data color 或 theme/token definition 本身可以例外。方便或“匹配截图”都不是有效理由。 从 resolved layout 得到的 cache 必须把 `window.rem_size()` 纳入 invalidation key,或者依赖随它变化的 revision。包括 wrapped row height、text shaping/layout、virtual-list measurement、popup/dialog geometry、由 text 推导的 icon size 和 custom canvas metric。Command variable-height row 是生态中的现有案例:较大 base font 会让同一 fixed width 产生不同 wrapping,因此 rem 变化时会重新 measure。 不要把 application zoom 与 Dock panel zoom 混淆。Dock zoom 是 stateful layout operation:让一个 tab group 保留 container chrome 并填满 DockArea,同时保留退出路径;它不能修改 window rem size。 ## 事件、Action 与焦点 Pointer-specific 行为使用 pointer callback;需要 key binding、menu 或多输入来源的 command 使用 GPUI [Action](./action)。Action handler 靠近拥有 command 的 view。 一个 logical desktop command 只建模一次。Toolbar Button、`DropdownMenu` item、`ContextMenu` item、menu-bar item 与 key binding 应 dispatch 同一个 Action 或调用同一 owner method,不能复制五份 mutation。条件允许时,label、icon、shortcut、enabled state 来自同一 command policy,避免不同入口互相矛盾。Menu 拥有 navigation 与 dismiss;feature owner 仍然拥有 command 是否允许以及实际执行内容。 控件选择必须符合语义。命令即使需要降低强调,也应使用 `Button` 的 `outline`、`ghost` 或图标形式,不能换成 `Link`。GPUI Kit 应用约定:`Link` 只用于交给浏览器或邮件客户端打开的 URL、网页文档和电子邮件地址;应用内目标使用相应的导航组件,命令使用 `Button` 或`Action`。这是产品设计约定,不是 `gpui_kit::base::Link` 的能力限制;后者可以通过 `open_with`把目标交给其他导航实现。 只有 nested interaction 确实必须阻止 parent 处理同一 event 时才 stop propagation。无差别阻止会破坏 menu、selection、drag 与 window command。 先绑键,再建菜单栏。`cx.set_menus` 在调用那一刻读取 keymap,把每个菜单项的快捷键固化进原生菜单;之后再注册的 binding 不会出现在菜单项旁,菜单项也不响应该按键。先 `cx.bind_keys`,再 `cx.set_menus`;keymap 之后又变了(用户 keymap 文件、切换语言重建菜单),就再调一次 `cx.set_menus`。 Focus owner 必须明确: - 拥有 keyboard interaction 的 Entity 保存 `FocusHandle`; - 在正确 focused region 注册 key context 与 Action; - overlay 打开时转移 focus,关闭后恢复; - 绘制清楚的 `focus_visible` state; - 禁止在 `render` 中无条件 request focus。 被 track 的 handle 只有自己声明了才是 Tab stop:用 `cx.focus_handle().tab_stop(true)`(或 `.tab_index(n)`)建它,元素自身的 `tab_index`/`tab_stop` 不会作用到传给 `track_focus` 的 handle 上。Stateless component 可以在 `render` 里通过 `window.use_keyed_state` 建这个 handle;keyed state 跨帧保留,Tab 顺序因此稳定——`Button` 就是这么做的。 `key_context` 与 `on_action` 应附着于同一个 focused region。注册了 Action 但没有正确 focus path,不算实现键盘交互。Composite widget 应完成整个 navigation model:方向键、适用时的 Home/End/Page、confirm、cancel 与 Tab,而不是几个孤立 shortcut。 Modal 必须 trap focus,并在关闭后恢复到仍有效的原目标。Nested overlay 从最上层关闭;快速 close/open 不能把 focus 恢复到正在关闭的中间 surface。 ## 异步任务与副作用 异步 [Task](./task) 从 event、lifecycle hook 或具名 method 启动,不能作为 `render` 的无条件副作用。不应让 task 延长已关闭 view 生命周期时 capture weak entity。完成后通过正确 GPUI context 更新,并处理 entity/window 已不存在的情况;完成 coherent update 后只 notify 一次。 重复运行的任务应将 handle 保存在 owner 上,使 owner 释放时任务取消;默认不要 detach。确实必须独立完成的一次性任务可以调用 `Task::detach()`,但之后不再持有取消 handle。错误与弱 Entity 更新失败都需要明确处理。 用 idle/loading/loaded/failed 等明确状态表示 async operation。Refresh 时尽量保留可用旧数据;防止重复破坏性提交;可恢复错误要显示在 UI,而不是只写 log。 昂贵 parsing/computation 使用 background executor,Entity mutation 回到对应 application context。结果可能晚于 request、document、view 或 selection 的变化,必须绑定 revision/ID,拒绝 stale result。 ## 布局、测量与滚动 `h_flex` 会让子元素在交叉轴上居中;`v_flex` 保持 flexbox 的默认值 `stretch`。 交叉轴居中正是一排控件想要的效果,所以图标加文字的一行什么都不用写。但一排等高的列不是这样:直接放进 `h_flex` 的列不会 填满行的高度,比行更高的列会被居中,于是它的顶部(通常是 header)被裁到窗口之外,而列附近的代码看不出原因。 子元素是列的一行,请显式写 `items_stretch()`: ```rust h_flex() .items_stretch() .size_full() .child(sidebar) .child(content) ``` 大多数 UI 使用 GPUI layout,不应自行 measurement。Measurement 是 popup、virtualization、editor、resize handle、chart 等依赖 resolved geometry 行为的深层工具。 - measurement 与 geometry 放在拥有行为的层; - 只有普通 layout 无法表达关系时才在 prepaint 观察 bounds; - prepaint 不能每 frame 修改无关 application state; - measured data 带 frame/revision 范围;font、rem、width、theme、content 改变后可能 stale; - popup flip 与 viewport clamp 等共享 geometry 必须集中实现,不能每个 overlay 各写一次。 Alignment invariant 应通过构造保证,而不是事后校正:sibling region 消费同一个 spacing token 或 shared inset,不能重复看似等价的 literal。关键重复 edge、column 与 gap 应增加 geometry assertion 或 visual regression coverage。验证不能只有默认 window;rem zoom 与 display scaling 会把 fractional coordinate 变成一个 physical pixel 的漂移,即使默认截图看起来整齐。 精度评审要测量 resolved result,但不能把测得的差值写成 raw `px(...)` 微调。应继续追踪到重复 padding、nested inset、border ownership、font metric 或 rounding,并修复结构 owner。 `h_flex()` 与 `v_flex()` 并不对称:`h_flex()` 会把 child 在 cross axis 上居中,`v_flex()` 则保持拉伸。因此 row 里的 column 拿到的是自身内容高度,而不是 row 的高度;内容比 row 高时会被居中,header 被挤出上边缘并裁掉。只要 child 拥有 header、footer 或需要按 row 高度解析的 scroll region,就给这个 column 加 `h_full()`,或给 row 加 `items_start()` / `items_stretch()`。 每个 scrollable region 只有一个 owner。Flex layout 中,可收缩 child 使用 `min_w_0()` /`min_h_0()`。Flex item 只有在自身 overflow 不是 visible 时才会放弃基于内容的 automatic minimum size,所以普通的可伸缩 child 在被明确要求之前不会围绕长内容收缩。`Scrollable` 已经为自己的 wrapper 处理了这一点,但夹在它与 flex container 之间的普通 `div` 仍需自行释放该最小值。避免意外 nested scroll;wheel input 应进入目标 axis,并在 API 不可移植时保留 platform/wasm 差异。 `Scrollable` 应附着在拥有完整面板、编辑器或窗口 viewport 的 element 上,使滚动条解析到区域边缘。内容内边距放在 scroll owner 内部,不能用带内边距的容器包住 scroll owner。滚动条悬在内容与面板边界中间,通常说明 scroll owner 错误或内边距放错了层。 ## 列表、表格与大数据集 数据可能超过小型有界集合时使用 virtualization。Row identity 与 visible position 分开,不要每次 render clone 全量数据。Stateful list/table 拥有 navigation、selection、scroll coordination、visible range;item renderer 拥有 row presentation。 分离以下状态: - source data 与 domain ID; - filtering/sorting; - selection; - viewport/scroll; - row rendering。 这样更新保持局部,也不会把 view tree 变成 data model。 Virtualization 是 behavior contract,不只是性能开关。Width、typography、rem、row content 变化时要 invalidate item measurement。即使多数 element 当前不存在,keyboard selection 与 scroll-to-item 仍必须在 model coordinate 上工作。 ## 公共 API 设计 Reusable component 应遵守: - constructor 建立合法 default; - builder 消费并返回 `Self`,使用 domain 词汇; - callback 描述 requested change;只有 pointer modifier/detail 有意义时才带 pointer event; - 需要持续演进的行为接口使用私有字段、构造方法与读取方法; - boolean reader 在同名 builder 存在时使用 `is_` / `has_`; - reader 需要 plain field name 时,non-boolean setter 使用 `with_`; - explicit compound part 优于检查 arbitrary descendant; - reusable behavior 不能强制 product-level visual choice。 需要持续演进的行为状态默认使用私有字段。配置、主题令牌、几何数据和序列化结构可以有意暴露字段,但所有包含公开字段的 `pub struct` 必须标注 `#[non_exhaustive]`,并提供构造函数、`Default` 或 builder,避免调用方依赖穷举结构体字面量。这使后续增加字段无需破坏调用方。新类型及公开 API 调整必须遵守此规则;无关的已有类型另行迁移。 内部重组时保持 public module path:通过稳定 module seam 和明确 re-export,让 folder 变化不影响 downstream import。命名优先使用平台 control 术语和项目既有词汇,不使用偶然的 web-framework 词汇。 ## 平台与能力边界 不能假设 native 与 web target 支持相同能力。Window decoration、accessibility bridge、system notification、clipboard、scroll gesture、font、timing 都可能不同。Platform-specific 代码放在窄 capability seam 后,并定义 fallback。 Platform branch 即使 presentation 不同,也必须保留 semantic contract。例如 system notification 的 retract 能力不同,application delivery state 仍要 coherent。尽可能分别测试 shared state machine 与 platform adapter。 ## 文件、代码与命名风格 - View/entity 以产品概念命名:`ProjectList`、`ProjectEditor`、`SettingsState`; - handler 以 intent 命名:`confirm_delete`、`open_project`、`on_query_changed`; - 每个 module 一个主要职责;必须读无关行为才能理解 state/lifecycle 时就应拆分; - component module、state、event 与 focused test 在共同变化时放在一起; - comment 记录 invariant 和意外 lifecycle constraint,不复述显而易见 builder; - 使用 `rustfmt` 并满足 workspace Clippy,不能用宽泛 `allow` 隐藏无关 warning。 ### 词汇是 API 的一部分 同一概念在所有 component 中使用同一个词。命名新 method 前先搜索 GPUI、`gpui-base`与 GPUI Component;生态没有既有词时,参考 macOS/Windows control 术语。本地化文档保留准确的 API 标识符;稳定的 UI framework 术语在翻译会损失精度时也可保留英文。标识符使用代码格式,必要时在首次出现处解释。普通叙述不能为了显得专业而随意混用语言。 | 概念 | 命名模式 | 示例 | | --- | --- | --- | | 值类型 control | 名词 | `Button`, `Checkbox`, `Tab` | | Retained behavior model | `State` | `InputState`, `TableState` | | Imperative shared reference | `Handle` | `DialogHandle`, scroll handle | | Semantic notification | `Event` | `TableEvent`, `SelectEvent` | | Keyboard command | 动词或 intent noun | `Confirm`, `Cancel`, `SelectNext` | | Pluggable owner | `Delegate` / `Provider` | `TableDelegate`, `CompletionProvider` | | Caller 提供的表现 | `render_` / `_renderer` | `render_item` | | Construction | `new` 或语义 constructor | `new`, `horizontal`, `vertical` | | Fluent property | 名词或形容词 | `label`, `disabled`, `selected`, `placement` | | 通用 non-boolean builder | `with_` | `with_size`, `with_mode` | | In-place mutation | `set_` | `set_items`, `set_selected_index` | | Boolean reader | `is_` / `has_` | `is_open`, `is_closable`, `has_selection` | | Plain value reader | field noun | `placement`, `selected_value` | | Callback registration | `on_` | `on_click`, `on_open_change` | | Named region renderer | `render_` | `render_toolbar`, `render_content` | 新 API 中,消费并返回 `Self` 的链式构造方法不加 `set_`;通过 `&mut self` 修改状态时使用`set_`。已经公开的名称应保持兼容,现有的 `set_position` 等链式方法属于兼容例外,不作为新 API 的命名范例。 Boolean reader 只有两种:值持有某物时用 `has_`,描述状态或许可时用 `is_`。只要动作有对应的形容词形式就用形容词:`is_closable` 而非 `can_close`,`is_zoomable` 而非 `can_zoom`,`is_copyable` 而非 `can_copy`。动作是没有形容词形式的动词短语时,改为命名它所需要的东西:用 `has_definition`,不用 `can_go_to_definition`。不再新增 `can_` reader。 Boolean builder 可叫 `disabled(bool)`,reader 叫 `is_disabled()`。含 non-boolean field 的公开接口中的非布尔字段使用 `with_item_ix(...)` 构造、`item_ix()` 读取,避免冲突。新的局部或内部零起始索引优先使用 `_ix`,现有公开名称如 `selected_index` 保持不变,不再引入 `_idx`。调用方从不构造的快照,不要为了对称而发布构造方法。 ### 让外层名字承担上下文 名字总是在某个东西内部被阅读。字段在它的类型内部被读到,参数在它的方法签名内部 被读到,所以两者都不重复外层已经说过的话:`with_item_ix(ix)`,而不是 `with_item_ix(item_ix)`。 同一个类型的字段保持相同的缩写程度。其中若有一个写全了,它就成了异类,读者会去 找那个让它与众不同的区别。又因为 builder 按 `with_` 命名,缩短字段会同时 缩短它的 builder,两者始终配对。 只在外层名字确实能消除歧义时才缩短。当短形式在生态的别处同时是*另一个量*的既有 术语时,在 doc comment 里说明你指的是哪一个,而不是把标识符加长 —— doc 是在调用处 被读到的,它能解释清楚一个更长的名字只能暗示的东西。 ### 精确区分领域词汇 - **selected** 是持久 membership/active item;**focused** 是 keyboard target;**hovered** 是 pointer presence;**confirmed** 是 activation result,不能混用。 - **open/close** 描述 overlay/disclosure state;**show/hide** 表示 transient presentation request;**expand/collapse** 描述结构。 - **disabled** 禁止交互;**readonly** 允许导航/选择但禁止编辑;**loading** 表示操作中并应防止重复提交。这个状态一律拼作 `readonly`——一个词,与 `readonly(bool)` builder 和 `is_readonly()` reader 一致——标识符、界面标签和文档中都如此,不写 `read-only` 或 `read only`。 - **index** 是当前位置;**id** 是稳定 identity;`IndexPath` 是层级位置。重排数据不能用 index 持久化或作为 key。 - **value** 是 controlled domain data;**presentation** 是 render 用 read-only snapshot;**state** 是 retained behavior。 - **placement** 是 side/anchor policy;**position** 是 resolved geometry。 - **size** 是 semantic control tier;**width/height/bounds** 是 geometry。 - **child/children** 遵循 GPUI composition;`header`、`footer`、`trigger`、`content`等 named slot 具有额外语义。 避免 `data`、`item2`、`handle_action`、`update_ui`、`process`、`manager`、`config` 等模糊 public name。只有确实协调集合或生命周期时才使用 `Manager`,例如 `ToastManager`。 ### 类型与模块命名 - Rust type/Action 使用 `UpperCamelCase`;module/function/method/field/local 使用`snake_case`;constant 使用 `SCREAMING_SNAKE_CASE`; - component module 拥有 public seam;内部 folder 可拆 state/element/geometry/platform/test,但不能把这些实现路径泄漏到 import; - 单一 component concept 使用 singular module;family 使用生态既有名称(`input`、`table`、`dock`); - 只有真正擦除 type boundary 的 wrapper 才加 `Any`,如 `AnyInputState`、`AnyElement`; - identifier 后缀 `Id`,零基 index 后缀 `ix`,collection 使用有意义复数;同一 subsystem 不混用 `idx`、`index`、`ix`; - predicate 尽量正向命名;正向的 `enabled`、`visible` 比多重否定更容易组合,但已建立的 control semantic(如 `disabled`)应保持一致。 ### 回调与事件命名 只有真正 click-level contract 才用 `on_click`。Base controlled semantic primitive 应优先`on_change(next_value, ...)`;styled compatibility component 可以为现有 API 或 pointer detail 保留 `on_click`。Model-driven change 不能伪造 `ClickEvent`。 Lifecycle hook 名称必须准确:`on_will_change` 可 veto/prepare;`on_change` 观察请求或当前值;`on_confirm` 提交选择;`on_dismiss` 关闭 transient surface。文档必须说明 callback 发生在 internal state change 之前、之后还是代替它,以及是否可同步 re-enter。 ### 文档与界面文案 Public docs 先说明 type 做什么、谁拥有 state。Example 必须使用当前可编译 API 和稳定 ID。记录 default、platform limitation、focus behavior、callback ordering,以及何时需要 notify、emit 或 theme sync。 标签、命令、确认对话框、大小写和省略号遵循[界面用词规范](/versions/v0.7.1/zh-CN/docs/design-guides#界面用词)。每个领域对象、命令和状态使用一个固定术语。翻译键描述稳定意图(`dialog.delete_project.title`),不照抄源语言句子,也不包含 screen coordinate。不要用翻译片段拼句子,也不能因为两个含义碰巧有相同英文就复用同一个 key。 本地化的是意图,不是 syntax。每个 locale 可以独立决定语序、pluralization、标点与所需上下文。String 必须放进 component 并结合真实数据评审。Test 或 lint 应发现缺失 key、英文资源中的意外 CJK、三个句点组成的省略号、未经设计的 ALL CAPS 和固定术语不一致;上下文中的重复是否必要仍需人工判断。所有字符串都要放回真实组件,在代表性内容、文本扩展和应用缩放下验证。 ## 测试策略 使用能够证明行为的最低层: 1. pure test:state transition、geometry、parsing、ordering; 2. GPUI context test:entity、event、subscription; 3. `VisualTestContext` interaction test:focus、keyboard、pointer、layout、rendered state; 4. example/application smoke test:完整 workflow。 Interactive component 测试 semantic contract,而非 implementation detail:pointer/keyboard activation、controlled value change、disabled、focus movement、event count/order、stable ID、关键 empty/failure state。Bug 可稳定复现时,修复前先加 regression test。 依赖真实 window system 的 UI 行为通过 accessibility tree 的 role、label、value、enabled、focus、selection 验证。每次改变 state 后重新读取 tree,因为 element index 只是 snapshot。Screenshot 只验证 semantic tree 无法表达的视觉事实;coordinate input 是最后手段。Automated 与 manual evidence 分开报告。 ## 性能规则 - `render` 中不能无条件 mutate 或 notify; - 不要每 frame 重建 Entity、Subscription、FocusHandle 和昂贵 data structure; - coherent state change 后只 notify 最窄 owner; - 长 collection virtualize,只 render visible range; - 不为满足 closure 而 clone 大 string/collection,capture stable handle/shared data; - 先 measure 再 cache,cache 必须有明确 invalidation owner; - animation work 有界并遵守 reduced motion。 ## 常见失败模式 避免以下模式: - 一个 Entity 保存整个应用互不相关的 state; - 长 `render` 中混入 business logic/network request; - reorderable content 使用 random/index `ElementId`; - literal color/radius 破坏 custom theme; - 已有 semantic component 时仍用 clickable `div` 重做 focus/keyboard/disabled/a11y; - duplicated local state 与 controlled model 漂移; - `render` mutation 引发 `cx.notify()` loop; - 没有 owner 的 nested scroll; - 为 one-off screen 新增 component variant; - 可逆低风险操作也弹 confirmation dialog; - test 只调用 internal method,从不执行 pointer/keyboard 行为。 ## 编码代理规则 修改前必须阅读与改动最相关的 implementation、test、re-export seam 和 component docs。必须在当前源码搜索 method signature,不能从 React、CSS 或旧 GPUI 示例类比翻译。 每项改动都应能回答: 1. behavior owner 与 presentation owner 是谁; 2. retained identity 与 state lifecycle 是什么; 3. pointer、keyboard、focus、accessibility contract 是什么; 4. layout 与 overflow owner 是谁; 5. 使用哪些 theme token,例外为何存在; 6. 哪个 test 会在行为退化时失败。 生成代码必须经过人工 review 与 test。“能编译”不是 UI quality bar;为了让生成代码看起来整洁而进行的大范围 refactor,也不能替代对仓库架构的匹配。 ## 实现检查表 提交评审前,确认: - state 与 side-effect ownership 是否明确; - `RenderOnce` / `Entity` 是否有意选择; - repeated element 是否使用稳定 domain ID; - theme token 与 component Size 是否替代孤立 visual literal; - keyboard Action、focus、disabled 与 overlay 是否共同工作; - loading、empty、error、cancellation path 是否存在; - 长数据是否使用适当 virtualized component; - public API 是否保持 dependency direction 与 encapsulation; - test 是否在适当层证明 behavior; - formatting、Clippy、targeted test 与相关 example 是否通过。 应用初始化请阅读[开始使用](/versions/v0.7.1/zh-CN/docs/getting-started),具体 API 以各组件页面为准。 --- # Window Source: /versions/v0.7.1/zh-CN/docs/window 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/window 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 GPUI 提供 [`Window`](https://docs.rs/gpui-pre/0.3.8/gpui/struct.Window.html) 作为单个平台窗口的上下文。它把渲染后的 Element 树与[平台输入](./event#鼠标与键盘输入事件)、Focus、Action 派发、绘制和窗口控制连接起来。GPUI 只会在更新或渲染这个窗口时,把它传给 View: ```rust impl Render for Chat { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let active = window.is_window_active(); div() .track_focus(&self.focus_handle) .when(!active, |this| this.opacity(0.8)) .child("Chat") } } ``` 应用状态应保存在 [Entity](./entity) 中。一个操作属于当前窗口,或者依赖窗口当前的交互状态时,才使用 `Window`。 `App` 提供应用级服务、[全局状态](./global)和 Entity 访问。[`Context`](./context) 增加当前 Entity 的操作,包括 `cx.notify()`、listener、event 和 task。`Window` 则保存**单个**窗口的 Focus、派发、输入、[元素 keyed state](./element_id)、测量与绘制。这些都是 callback 执行期间临时提供的上下文;稍后需要继续工作时,保存 `Entity`、`FocusHandle`、task、subscription 或 window handle,不要保存 `&mut Window` 或 `&mut Context<_>`。 ## 打开并持有窗口 打开窗口前调用 `gpui_kit::init(cx)`。`gpui_kit::open_window` 创建 GPUI 窗口,并在 builder 返回的 View 外包一层 Base `Root`。它同时返回 window handle 和应用内容 Entity,供应用持有其需要的部分: ```rust use gpui_kit::*; application().run(|cx| { init(cx); let (window_handle, workspace) = open_window( WindowOptions::default(), cx, |window, cx| cx.new(|cx| Workspace::new(window, cx)), ) .expect("open workspace window"); // Keep these handles in the application owner for later use. }); ``` [`WindowOptions`](https://docs.rs/gpui-pre/0.3.8/gpui/struct.WindowOptions.html) 控制初始位置和尺寸、Focus、是否显示、窗口类型、最小尺寸等平台选项。builder 只在构造期间获得 `Window`。window handle 可供稍后的代码发起更新,但窗口关闭后,基于 handle 的更新可能失败。多窗口应用要使用目标窗口的 handle 来操作其 Focus 或几何信息;只有 Entity handle 并不能确定窗口。 `open_window` 返回 `AnyWindowHandle`,因为实际的 GPUI root 是 `gpui_kit::base::Root`,不是 `Workspace`。后续拿到 `&mut App` 的 callback 可通过 handle 进入对应窗口,使用前要检查窗口是否仍然存在: ```rust if window_handle .update(cx, |_, window, _| window.activate_window()) .is_err() { // The window has closed. } ``` callback 的第一个参数是 Base `Root` view;应用内容的更新应使用 `open_window` 返回的 `workspace` Entity。window handle 选择窗口,Entity 则选择要更新的状态。 ## 动手练习:更新并关闭单个窗口 这个练习复用现有的 `hello_world` 包。将 `examples/hello_world/src/main.rs` 暂时替换为以下代码,然后在仓库根目录运行 `cargo run -p hello_world`: ```rust use gpui_kit::component::button::*; use gpui_kit::component::*; use gpui_kit::*; struct WindowPractice { renamed: bool, } impl Render for WindowPractice { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let status = if self.renamed { "Title: Second title" } else { "Title: First title" }; div() .v_flex() .gap_2() .p_4() .child(status) .child( Button::new("rename") .label("Change title") .on_click(cx.listener(|this, _, window, cx| { this.renamed = !this.renamed; let title = if this.renamed { "Second title" } else { "First title" }; window.set_window_title(title); cx.notify(); })), ) .child( Button::new("close") .label("Close window") .on_click(|_, window, _| window.remove_window()), ) } } fn main() { application().run(|cx| { gpui_kit::init(cx); open_window(WindowOptions::default(), cx, |window, cx| { window.set_window_title("First title"); cx.new(|_| WindowPractice { renamed: false }) }) .expect("open practice window"); }); } ``` 连续点击两次 **Change title**:系统窗口标题与 View 内的文字应同步在两个标题间切换。`renamed` 是 Entity 中持续保存的状态;`cx.notify()` 让更新后的文字显示出来。`window.set_window_title(...)` 直接修改当前原生窗口的标题,不依赖这次 Entity 通知。点击 **Close window** 会请求移除当前窗口。按钮 callback 中的 `&mut Window` 只在这次调用期间有效;窗口被移除后,之前保存的 window handle 再执行更新可能返回错误。 如果内容文字变了但标题没变,检查桌面环境是否显示原生窗口标题,以及 `set_window_title` 是否在按钮 callback 中执行。如果两者都没变,确认 `init(cx)` 先于 `open_window` 执行、按钮有 `on_click` listener,且修改 `renamed` 后调用了 `cx.notify()`。练习结束后恢复原来的 `main.rs`。多个窗口之间的持有关系继续阅读 [Multi Window](./multi-window)。 ## Window 负责什么 常见的窗口级操作包括: | 需求 | API | | --- | --- | | 读取尺寸和状态 | `bounds`、`viewport_size`、`scale_factor`、`is_window_active` | | 管理 Focus | `focused`、`focus`、`blur`、`focus_next`、`focus_prev` | | 从代码下达命令 | `dispatch_action` | | 重绘或安排下一帧 callback | `refresh`、`request_animation_frame`、`on_next_frame` | | 控制原生窗口 | `set_window_title`、`activate_window`、`remove_window` | | 稍后继续工作 | `defer`、`spawn` | `Window` 内部还承载布局、文本、hit testing、输入和绘制状态。大多数 View 不需要直接操作这些系统;Element 和 GPUI 会在渲染期间使用它们。 ## 几何信息与缩放 `window.bounds()` 返回系统窗口在**全局**坐标空间的矩形,可能横跨多个显示器。`window.viewport_size()` 返回可绘制内容区域在窗口局部坐标中的尺寸,单位是逻辑 `Pixels`。让局部 overlay 保持在内容区域内时,应使用 viewport 尺寸;保存窗口位置时,应使用包含可恢复窗口状态的 `window.window_bounds()`。在支持的平台上,`window.inner_window_bounds()` 会排除系统 inset。 `window.scale_factor()` 把逻辑像素换算为物理显示像素:系数 `2.0` 表示每个轴向上一个逻辑像素占两个设备像素。窗口移到另一块屏幕时,它可能变化。GPUI 布局尺寸不需要乘这个系数;只在原生平台集成等真正需要设备像素的边界使用。移动设备键盘出现时,`visual_viewport_bounds()` 可能缩小或移动,而 `viewport_size()` 仍表示布局区域。 这些值的坐标原点不同:`bounds().origin` 属于全局显示坐标,而指针事件和 `visual_viewport_bounds()` 使用窗口局部的逻辑坐标。不要直接拿指针位置与保存的全局窗口原点比较。若 overlay 必须避开系统 inset 或软键盘,可使用 `window.fully_visible_bounds()` 获得保守的窗口局部矩形;平台未报告的遮挡区域不在其计算范围内。 [Dialog 实现](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/dialog/dialog.rs) 使用 `window.viewport_size()` 和窗口边框 padding,让弹层保持在可用内容内。[原生菜单集成](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/native_menu/windows.rs) 在平台坐标边界读取 `scale_factor()`。标准组件已经持有 overlay 或原生控件时,应让它们完成这些计算。 ## Focus 与 Action 派发 Focus 属于某个具体的 Window。`window.focus(...)` 选择一个 `FocusHandle`,`window.focused(cx)` 返回当前 handle。还需通过 `.track_focus(&handle)` 将它附着到渲染后的 Element,才能在 Dispatch Path 中找到对应节点;仅创建 handle 并不会形成键盘目标。被跟踪的 handle 也不会自动进入 Tab 顺序,创建时需通过 `cx.focus_handle().tab_stop(true)` 显式加入。键盘输入随后从获得 Focus 的 Element 建立 Dispatch Path,用它匹配 [KeyBinding](./keybinding) 并派发 Action。 添加快捷键前,可跟着 [Focus 教程](./focus)建立并验证键盘目标、Tab 顺序和浮层关闭后的焦点恢复。 ```rust fn focus_composer(&mut self, window: &mut Window, cx: &mut Context) { window.focus(&self.composer_focus, cx); } fn on_action_open_conversation( &mut self, action: &OpenConversation, window: &mut Window, cx: &mut Context, ) { self.open(action.id, cx); window.focus(&self.composer_focus, cx); } ``` 当按钮、命令面板、原生菜单或其他代码要下达与 KeyBinding 相同的命令时,使用 `window.dispatch_action(action.boxed_clone(), cx)`。GPUI 会记录当前 Focus,并把真正的派发推迟到当前 effect cycle 结束以后。 ```rust Button::new("open-conversation") .label("Open") .on_click(|_, window, cx| { window.dispatch_action(Box::new(OpenConversation { id }), cx); }) ``` 这个 Action 仍沿当前 Focus 对应的 Dispatch Path 传递。它的 `on_action_*` handler 应位于这条路径上,通常挂在当前 region 或共同 owner 上。完整的路由机制见 [Action](./action)。 ## 在当前更新结束后执行 当一个操作必须等当前正在更新的 Entity 被释放以后才能执行时,使用 `window.defer`。关闭 overlay 后调整 Focus,或继续修改同一棵 UI 树的其他部分,通常都需要这样处理。 ```rust fn dismiss(&mut self, window: &mut Window, cx: &mut Context) { let composer = self.composer.clone(); window.defer(cx, move |window, cx| { let focus_handle = composer.read(cx).focus_handle(cx); window.focus(&focus_handle, cx); }); } ``` 在 Entity 内部,`cx.defer_in(window, ...)` 通常更方便,因为 GPUI 会再次把这个 Entity 传给 callback: ```rust cx.defer_in(window, |this, window, cx| { this.rebuild_results(window, cx); }); ``` callback 已经获得 `&mut Self`,不要在里面通过 handle 对同一个 Entity 调用 `update`,否则会再次更新一个正在更新中的 Entity。可以用 `defer` 和 `defer_in` 安排不能在当前 callback 中安全完成的 Focus 变化和 UI 树修改。 只有操作明确属于下一次帧 callback 时才使用 `window.on_next_frame(...)`,例如推进一帧动画。`defer` 表示“当前 effect cycle 结束后”,两者不是同一个时机。 ## 重绘与帧生命周期 修改 Entity 后调用 `cx.notify()`,会把该 Entity 标记为需要渲染。`window.refresh()` 则把**整个窗口**标记为 dirty,供下一次绘制使用;窗口级状态变化无法通过 Entity 通知覆盖时可用它,例如平台或 overlay 状态变化。两者都不应该无条件写在 render 路径中。 `window.on_next_frame(callback)` 在下一次平台帧 tick 中运行 callback,时机在该 tick 可能进行的绘制之前。它会产生帧需求,但本身不把窗口标记为 dirty。`window.request_animation_frame()` 会记录当前正在渲染的 View,在下一次 tick 通知它。当前锁定的 `gpui-pre` 0.3.8 实现会立即调用 `current_view()`,因此只应在 GPUI 有当前 View 的渲染路径中使用;在该路径之外,使用 `on_next_frame`,并显式通知 Entity 或调用 `window.refresh()`。只有运动还需要下一个采样时才调用。[GPUI 动画与 Base Motion](./animation) 已为各自动画处理帧请求和减弱动态效果。 帧 callback 若修改了窗口外部状态,应在 callback 中调用 `window.refresh()`,让变化进入绘制。若修改 Entity 状态,则在更新该 Entity 的 callback 中调用 `cx.notify()`。仅有帧 tick 不会重绘没有被标记为 dirty 的窗口。 渲染会根据保留的 Entity 状态生成新 Element 树,随后 GPUI 计算布局、在 prepaint 阶段确定输入几何,最后绘制场景。`Window` 含有各阶段的方法,但应用 View 通常只需在 `render` 中描述元素;只有需要已解析边界的自定义 Element 才使用后续阶段的 hook。在 `render` 中无条件调用 `cx.notify()`、`window.refresh()` 或 `window.request_animation_frame()`,即使 UI 空闲也会持续消耗资源。参见 [Render](./render) 与 [Element](./element)。 ## 需要 Window 的异步任务 当 [Task](./task) 属于当前 Entity,并且完成后需要同时访问 Entity 与 Window 时,使用 `cx.spawn_in(window, ...)`: ```rust struct Chat { load_task: Option>, } fn load_conversation(&mut self, window: &mut Window, cx: &mut Context) { self.load_task = Some(cx.spawn_in(window, async move |this, cx| { let Ok(messages) = fetch_messages().await else { return }; this.update_in(cx, |this, _window, cx| { this.messages = messages; cx.notify(); }) .ok(); })); } ``` `cx.spawn_in` 提供 `WeakEntity` 与 `AsyncWindowContext`。如果 Entity 或 Window 已经消失,`update_in` 会返回错误;应传播或处理错误,不能假定它们仍然存在。 任务需要 Window、但不属于某个 Entity 时,使用 `window.spawn(cx, ...)`;不需要 Window 时使用 `cx.spawn(...)`;CPU 密集型工作使用 `cx.background_spawn(...)`。`Task` 被 drop 时任务会取消,因此当任务生命周期应跟随 View 时,把它存进 View;只有任务确实应该独立继续时才调用 `.detach()`。 ## 订阅中访问 Window Event callback 需要 `&mut Window` 时使用 `cx.subscribe_in`。例如,child 完成操作后让 owner 恢复 Focus: ```rust struct Workspace { chat: Entity, _subscriptions: Vec, } impl Workspace { fn new(chat: Entity, window: &mut Window, cx: &mut Context) -> Self { let _subscriptions = vec![ cx.subscribe_in(&chat, window, |_this, chat, event, window, cx| { if let ChatEvent::ConversationOpened = event { window.focus(&chat.read(cx).focus_handle(cx), cx); } }), ]; Self { chat, _subscriptions } } } ``` 返回的 `Subscription` 应保存在订阅它的 View 上。只存在局部变量会立刻 drop,订阅也会取消;把它存在生命周期更长的全局 owner 上,则可能在 View 消失后仍然保留 callback 与捕获的资源,造成内存泄漏。订阅所有权和多个订阅者见 [Event](./event)。 ## Window 生命周期 不要保存 `&mut Window`;它是 GPUI 临时提供的上下文。需要稍后执行时,使用 `defer`、`spawn_in`,或者取得 `window.window_handle()` 后通过 GPUI 更新。handle 不会让已经关闭的窗口继续存活,因此通过 handle 执行的更新可能失败,代码应正确处理这一情况。 `window.remove_window()` 会在当前更新中请求移除窗口。需要决定平台关闭请求是否可以继续时,注册 `window.on_window_should_close(cx, callback)`;返回 `false` 可取消关闭,未保存内容的确认流程由应用负责。需要观察窗口已经关闭的事件时,使用 `cx.on_window_closed(callback)`,并将返回的 `Subscription` 保存在应用 owner 中。其 callback 依次接收 `&mut App` 与 `WindowId`,此时已无法访问关闭的 `Window`;需要的窗口状态应在关闭前读取。 在仍可访问窗口时注册关闭拦截,通常放在 `open_window` 的 builder 中。它处理平台发出的关闭请求;`remove_window()` 则是程序主动移除。若最后一个窗口关闭时应用也应退出,可在关闭完成的 callback 中检查 `cx.windows().is_empty()`,再调用 `cx.quit()`。[FPS monitor 示例](https://github.com/longbridge/gpui-kit/blob/main/examples/fps_monitor/src/main.rs) 展示了这种单窗口退出流程,也展示了 View 如何请求动画帧。在本仓库执行 `cargo run -p fps_monitor` 即可运行;这个示例使用 GPUI Kit,但未启用可选的 Component 层。 如果窗口动作似乎没有效果,先检查 handle 是否仍指向打开的窗口,以及当前获得 Focus 的 Element 的 Dispatch Path 是否包含 Action handler。如果 Entity 状态变化未显示,检查更新时是否调用了 `cx.notify()`;如果窗口级变化未显示,则调用 `window.refresh()`。如果帧 callback 已执行却没有绘制,请记住 `on_next_frame` 产生帧需求但不会标记窗口为 dirty。如果 Event callback 不再执行,检查其 `Subscription` 是否被保存。 可以用下面几条规则判断所有权: - 持久 UI 状态属于 Entity; - 窗口级工作只在 callback 执行期间获得 `&mut Window`; - 把 `Task` 和 `Subscription` 存在 View 上,让后台工作和订阅跟随 owner 生命周期; - Focus 与 Action 派发始终使用当前这个 Window 的状态。 应用有多个窗口时,继续阅读 [Multi Window](./multi-window)。[Native Extensions](./native-extension) 介绍窗口边界上的系统 API;[WebView](./webview) 说明原生子视图集成及其限制。需要由操作系统投递通知时,参见 [SystemNotification](./system-notification)。 --- # 图片 Source: /versions/v0.7.1/zh-CN/docs/image 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/image 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 GPUI 用两个元素绘制图片。`img()` 绘制全彩图片:照片、截图、头像或多色 SVG。`svg()` 把 SVG 绘制成单色图形,用文字颜色填充,图标就是这样跟随主题变色的。两者都从 `gpui_kit` 重新导出。本页说明这两个元素如何查找、解码、布局和缓存图片,以及如何避免重复下载远程图片。现成的界面写法见 [Image](/versions/v0.7.1/zh-CN/component/image) 组件页;把文件打包进程序见[图标与资源](/versions/v0.7.1/zh-CN/docs/assets)。 ## 图片来源 `img(source)` 接受任何能转换为 `ImageSource` 的值,转换结果决定从哪里读取字节: | 参数 | `ImageSource` | 字节来源 | | --- | --- | --- | | `"https://example.com/a.png"` 或任何能解析为 URL 的字符串;`SharedUri` | `Resource(Resource::Uri)` | `App` 上安装的 `HttpClient` | | `"images/a.png"` 或其他字符串 | `Resource(Resource::Embedded)` | 已注册的 `AssetSource`,按这个键精确查找 | | `&Path`、`PathBuf`、`Arc` | `Resource(Resource::Path)` | 文件系统 | | `Arc` | `Image` | 已持有的编码字节及其 `ImageFormat` | | `Arc` | `Render` | 已解码的帧,直接绘制 | | `Fn(&mut Window, &mut App) -> Option, ImageCacheError>>` | `Custom` | 自己的加载器 | 字符串只要能解析为 URL 就按 URL 处理,否则就是资源键。`"images/a.png"` 这样看起来像相对路径的字符串,不会从工作目录读取。磁盘上的文件请传 `Path`,这样既不会被当成 URL,也不会被当成资源键。 在原生平台上,默认的 `HttpClient` 会让所有请求失败,所以应用要先用 `cx.set_http_client(...)` 或 `Application::with_http_client(...)` 安装客户端,URL 图片才能加载。在 Web 上,`gpui_kit::application()` 会安装基于浏览器 Fetch API 的客户端。 `Custom` 加载器在每一帧的布局和绘制阶段都会运行。图片还在加载时返回 `None`,结果要自己保存,例如用 `window.use_asset::(...)`,否则每一帧都会重新开始加载。 ## 加载、解码与失败 加载是异步的。加载期间,`img()` 按自身样式布局,但不绘制任何内容。加载完成后,GPUI 会重绘绘制这张图片的视图。 ```rust img("https://example.com/cover.png") .id("cover") .w(px(320.)) .h(px(180.)) .with_loading(|| div().child("Loading image...").into_any_element()) .with_fallback(|| div().child("Image unavailable").into_any_element()) ``` - `with_loading` 只在加载开始 200 ms 后仍未完成时才替换图片,所以加载很快时不会闪出占位内容。GPUI 在元素状态里记录这个时间,因此只有带 `.id(...)` 的图片才会显示占位内容。 - 加载失败时,`with_fallback` 会替换图片。失败的情况包括:资源键或文件不存在、网络错误、响应不是 `2xx`、字节无法解码。 - 两个回调都不会重试。要重试,在视图状态里记录重试,再换一个来源重新绘制图片,或者先删掉缓存的失败结果(见[解码后图片的缓存](#解码后图片的缓存))。 GPUI 根据字节内容判断格式,而不是根据文件扩展名。PNG、JPEG、WebP、GIF、BMP、TIFF、ICO 以及 `Img::extensions()` 列出的其他格式,都通过 `image` crate 解码。不属于已知位图格式的字节会按 SVG 解析,并按其固有尺寸的两倍光栅化一次。所以用 `img()` 显示的 SVG 在正常缩放下是清晰的,但放大到远超自身尺寸时会变模糊。 GIF 和 WebP 动图只有在图片带 `.id(...)` 时才会播放,因为当前帧保存在元素状态里。窗口处于活动状态时动图才会播放,系统要求减少动态效果时则停止。 ## 尺寸与适配 布局决定图片的边界,`object_fit` 决定图片在边界内如何绘制。 某个维度为 `auto` 时,GPUI 会在图片解码后补上它。如果另一个维度是绝对长度,就按图片比例算出这个维度;否则使用图片自身的尺寸。除非另外设置,图片比例也会成为元素的 `aspect_ratio`。解码完成前没有尺寸可以测量,所以没有设置尺寸的图片在加载完成时会推动周围的布局。可以用明确的尺寸,或者宽度加 `aspect_ratio(...)`,预先占好位置: ```rust img("images/banner.webp") .w_full() .aspect_ratio(16. / 9.) .object_fit(ObjectFit::Cover) ``` | `ObjectFit` | 效果 | | --- | --- | | `Contain`(默认) | 显示完整图片,保持宽高比;可能留有空白。 | | `Cover` | 填满边界,保持宽高比;边缘可能被裁掉。 | | `Fill` | 拉伸到边界大小;可能变形。 | | `ScaleDown` | 与 `Contain` 相同,但不会放大图片。 | | `None` | 按图片自身尺寸居中显示。 | 在 `img()` 上设置 `.rounded(...)` 会让绘制出的图片本身带圆角。`.grayscale(true)` 以灰度绘制。边框、阴影和背景都是普通的元素样式。 ## svg() `svg()` 把 SVG 绘制成单色图形。GPUI 按元素尺寸光栅化 SVG 的透明度通道,再用一种颜色填充,所以 SVG 自带的颜色会被丢弃。用以下三个方法之一指定来源: | 方法 | 字节来源 | | --- | --- | | `.path("icons/check.svg")` | 已注册的 `AssetSource`,按键查找 | | `.external_path("/path/to/check.svg")` | 文件系统,异步读取并按路径缓存 | | `.data(bytes)` | 传入的字节,按字节的哈希缓存 | ```rust svg() .path("icons/check.svg") .size(px(16.)) .text_color(cx.theme().foreground) ``` - **在元素本身上设置颜色。** 只有元素自己设置了文字颜色,`svg()` 才会绘制。从父元素继承的颜色不算,所以没有 `.text_color(...)` 的 `svg()` 什么也不画。 - **设置尺寸。** 布局阶段不会读取 SVG,所以 `svg()` 没有固有尺寸,不设尺寸就会缩成零。 - **变换只影响绘制。** `.with_transformation(Transformation::rotate(percentage(0.25)))` 以及 `scale`、`translate` 只移动绘制结果,布局和点击区域保持不变。 在 GPUI Kit 中,图标优先使用 [Icon](/versions/v0.7.1/zh-CN/component/icon) 组件:它按组件尺寸体系确定大小,并从主题取颜色。 ## 选择 img() 还是 svg() | | `img()` | `svg()` | | --- | --- | --- | | 颜色 | 保留原有颜色 | 单色,来自 `.text_color(...)` | | 格式 | 位图格式和 SVG | 只支持 SVG | | 尺寸 | 使用图片的固有尺寸 | 必须设置 | | 来源 | URL、资源键、路径、字节、已解码的帧、自定义加载器 | 资源键、路径、字节 | | 加载与失败 | `with_loading`、`with_fallback` | 准备好之前或失败时不绘制 | | 适用于 | 照片、头像、Logo、插画 | 跟随主题或状态变色的图标和符号 | `.text_color(...)` 不能给 `img()` 改色,`svg()` 也无法保留多色 Logo 的颜色。 ## 解码后图片的缓存 解码后的图片会被保留,再次绘制同一来源时不必重新加载。由哪个缓存保留,决定了图片何时被释放。 - **默认缓存。** `Resource` 来源(URL、资源键或路径)的 `img()` 使用 App 的资源缓存,按来源作为键。一个条目供所有窗口和视图共用,直到用 `ImageSource::remove_asset(cx)` 删除为止。加载失败的结果也会被缓存,所以重试同一来源前要先删除这个条目。`Arc` 也以同样的方式缓存。 - **限定范围的缓存。** `image_cache(provider)` 包裹子元素,其中的 `img()` 改用这个缓存。`image_cache(retain_all("preview"))` 在元素状态里保存一个 `RetainAllImageCache`:它保留加载过的所有图片,元素不再绘制时一并释放。`img(...).image_cache(&cache)` 为单张图片指定缓存。 - **自定义缓存。** 实现 `ImageCache` 来决定保留哪些图片。`ImageCacheItem::new(resource, cx)` 通过 GPUI 的图片加载器开始加载,`item.use_image(window)` 返回结果,并在加载完成时重绘当前视图。淘汰图片时,用 `cx.drop_image(image, Some(window))` 释放它的 GPU 纹理。 下面的缓存保留最近绘制过的图片,释放其余的。容量必须大于同时显示在屏幕上的图片数量,否则可见的图片会被淘汰,每一帧都要重新加载。 ```rust use std::{collections::VecDeque, sync::Arc}; use gpui_kit::*; /// Keeps the `capacity` most recently drawn images and releases the rest. pub struct RecentImageCache { capacity: usize, items: VecDeque<(Resource, ImageCacheItem)>, } impl RecentImageCache { pub fn new(capacity: usize, cx: &mut App) -> Entity { let cache = cx.new(|_| Self { capacity, items: VecDeque::new(), }); // Release the GPU textures when the cache itself is dropped. cx.observe_release(&cache, |cache, cx| { for (_, item) in cache.items.drain(..) { if let Some(Ok(image)) = item.get() { cx.drop_image(image, None); } } }) .detach(); cache } } impl ImageCache for RecentImageCache { fn load( &mut self, resource: &Resource, window: &mut Window, cx: &mut App, ) -> Option, ImageCacheError>> { let item = match self.items.iter().position(|(source, _)| source == resource) { Some(ix) => self.items.remove(ix).expect("index is in bounds"), None => (resource.clone(), ImageCacheItem::new(resource, cx)), }; self.items.push_front(item); while self.items.len() > self.capacity { if let Some((_, evicted)) = self.items.pop_back() && let Some(Ok(image)) = evicted.get() { cx.drop_image(image, Some(window)); } } self.items[0].1.use_image(window) } } ``` 创建一次,把 `Entity` 保存在视图里,再用它包裹需要使用这个缓存的图片: ```rust image_cache(self.images.clone()) .flex() .gap_2() .children(self.urls.iter().map(|url| img(url.clone()).size(px(96.)))) ``` 这些缓存都在内存中保存解码后的图片。它们仍然通过 App 的 `HttpClient` 加载 URL,也都不会记住服务器对响应的缓存要求,这正是下一节要解决的问题。 ## 远程图片的 HTTP 缓存 前面几节讲的是 `img()` 在内存中保存什么。本节讲网络层:在原生平台上如何跨视图、跨启动复用响应。在 Web 上,浏览器的 HTTP 缓存已经生效。 ### GPUI 图片缓存的局限 GPUI 的图片缓存位于 App 的 `HttpClient` 之上。它在内存中按来源保存解码后的图片,足以应付运行中视图里的重复来源,却无法减少网络请求: - 进程退出后缓存随之消失,每次启动都要重新下载所有图片。 - 它不理会 `Cache-Control`、`ETag` 和 `Last-Modified`,既不能保留服务器允许复用的响应,也不能向服务器确认旧副本是否仍然有效。 - 它的寿命取决于持有它的缓存。使用独立 `.image_cache(...)` 的图片,或按视图分别缓存的加载器,每创建一个新视图都会重新请求图片。 ### 在 HTTP 层缓存 要跨视图、跨启动复用响应,可以在启动时安装一个遵循 HTTP 缓存规则的 `HttpClient`。此后所有远程图片都会受益,包括不由应用自己编写的代码加载的图片。实现时遵循以下规则: - **只缓存不带请求体的 GET。** 其他请求原样转发。 - **按共享缓存处理。** 整个应用共用一个客户端:应用自己的视图、扩展和文档视图都经过它。`no-store` 或 `private` 响应,以及对带 `Authorization` 请求的响应,除非服务器明确允许共享缓存保存,否则都不能存储。如果缓存下层会附加 Cookie 或令牌,缓存看不到这些凭据,因此应在缓存之上添加凭据,或者不缓存这些主机。 - **用重新验证代替重新下载。** 新鲜的响应直接返回,不访问网络。过期后发送 `If-None-Match` 或 `If-Modified-Since`;收到 `304 Not Modified` 时,用更新后的响应头返回已存储的响应体。 - **遵守调用方的重定向策略。** `img()` 会跟随重定向,但自行授权每一跳的加载器会请求 `RedirectPolicy::NoFollow`,并且必须拿到 `3xx` 响应。每种策略使用各自的缓存,跟随重定向得到的结果就永远不会回应这类调用方。 - **限制内存和磁盘用量。** 限制缓存大小,并清理旧条目。 - **先授权,再请求。** 同一个 URL,缓存会回应任何请求它的调用方。判断调用方能否访问某个 URL 的检查,例如 gpui-shell 脚本的网络授权,必须在 `send` 之前完成。这样缓存不会扩大调用方的访问范围,只是省去重复一次已经获准的请求。 ### 示例 [http-cache-reqwest](https://crates.io/crates/http-cache-reqwest) 以 [reqwest-middleware](https://crates.io/crates/reqwest-middleware) 中间件的形式实现了这些规则:新鲜度判断、基于 `ETag` 和 `Last-Modified` 的重新验证、共享缓存的存储限制,并自带磁盘存储。应用只需把它适配到 GPUI 的 `HttpClient`。添加以下依赖: ```toml [dependencies] anyhow = "1" futures = "0.3" http-cache-reqwest = "0.15" reqwest = { version = "0.12", features = ["stream"] } reqwest-middleware = "0.4" tokio = { version = "1", features = ["rt-multi-thread"] } ``` ```rust use std::{path::Path, sync::LazyLock}; use futures::{AsyncReadExt as _, FutureExt as _, TryStreamExt as _, future::BoxFuture}; use gpui_kit::http_client::{ AsyncBody, HttpClient, RedirectPolicy, Request, Response, Url, http::HeaderValue, }; use http_cache_reqwest::{CACacheManager, Cache, CacheMode, HttpCache, HttpCacheOptions}; use reqwest::redirect; use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; /// reqwest needs a tokio runtime; GPUI's executors are not one. static RUNTIME: LazyLock = LazyLock::new(|| { tokio::runtime::Builder::new_multi_thread() .worker_threads(1) .enable_all() .build() .expect("failed to start the HTTP runtime") }); /// A reqwest client with an HTTP cache on disk. pub struct CachedHttpClient { follow: ClientWithMiddleware, no_follow: ClientWithMiddleware, } impl CachedHttpClient { pub fn new(cache_dir: &Path) -> anyhow::Result { let client = |policy, dir| -> anyhow::Result<_> { let client = reqwest::Client::builder().redirect(policy).build()?; Ok(ClientBuilder::new(client) .with(Cache(HttpCache { mode: CacheMode::Default, manager: CACacheManager { path: cache_dir.join(dir), }, options: HttpCacheOptions::default(), })) .build()) }; // A caller that disables redirects must receive the 3xx itself. It // gets a client that never follows them, with a cache of its own. Ok(Self { follow: client(redirect::Policy::default(), "follow")?, no_follow: client(redirect::Policy::none(), "no-follow")?, }) } } impl HttpClient for CachedHttpClient { fn user_agent(&self) -> Option<&HeaderValue> { None } fn proxy(&self) -> Option<&Url> { None } fn send( &self, req: Request, ) -> BoxFuture<'static, anyhow::Result>> { let (head, mut body) = req.into_parts(); let client = match head.extensions.get::() { Some(RedirectPolicy::NoFollow) => self.no_follow.clone(), _ => self.follow.clone(), }; async move { let mut bytes = Vec::new(); body.read_to_end(&mut bytes).await?; let request = client .request(head.method, head.uri.to_string()) .headers(head.headers) .body(bytes); let response = RUNTIME.spawn(request.send()).await??; let mut builder = Response::builder() .status(response.status()) .version(response.version()); *builder.headers_mut().unwrap() = response.headers().clone(); let body = response .bytes_stream() .map_err(std::io::Error::other) .into_async_read(); Ok(builder.body(AsyncBody::from_reader(body))?) } .boxed() } } ``` GPUI 自带的 reqwest 客户端会按每个请求的 `RedirectPolicy` 处理跳转,而 `reqwest-middleware` 客户端的跳转策略在构建时就固定了。所以示例构建了两个客户端:一个跟随跳转,供 `img()` 使用;另一个从不跟随,供请求 `RedirectPolicy::NoFollow` 的调用方使用。两者的缓存目录也要分开,因为跟随跳转的客户端前面的缓存,会把最终响应存在原始 URL 下,再拿它回应另一类调用方。`RedirectPolicy::FollowLimit` 使用 reqwest 默认的 10 次上限。 ### 安装客户端 在启动时安装一次,要在任何窗口加载远程图片之前。缓存目录请使用平台的缓存目录,例如用 [dirs](https://crates.io/crates/dirs) crate 获取,不要像这里一样放在临时目录: ```rust use std::sync::Arc; gpui_kit::application().run(|cx| { gpui_kit::init(cx); let cache_dir = std::env::temp_dir().join("my-app/http-cache"); let client = CachedHttpClient::new(&cache_dir).expect("failed to create the HTTP client"); cx.set_http_client(Arc::new(client)); // Open windows here. }); ``` ### 扩展示例 - **大小。** 磁盘存储没有大小上限。可以在启动时删除旧条目,或在目录超过上限时清空它。 - **代理与 User-Agent。** 在 `reqwest::Client::builder()` 上配置,其他代码需要读取时,再从 `proxy()` 和 `user_agent()` 返回。 - **请求体。** 示例在发送前把请求体读入内存,这对图片和小请求没有问题;上传大文件时,改用 `reqwest::Body::wrap_stream` 包装数据流。 ## 常见问题 | 现象 | 检查 | | --- | --- | | 内嵌图片显示为空白 | 已注册的 `AssetSource` 必须包含完全相同的键。`img("images/a.png")` 是资源查找,不是读取文件。 | | 原生平台上所有 URL 图片都显示失败内容 | 安装一个 `HttpClient`;默认客户端会让所有请求失败。 | | `with_loading` 从不出现,或 GIF 不播放 | 给 `img()` 加上 `.id(...)`。 | | `svg()` 看不见 | 在 `svg()` 元素本身上设置 `.text_color(...)` 和尺寸。 | | 多色 SVG 变成了单色 | 用 `img()` 绘制;`svg()` 总是单色。 | | 图片加载完成时布局跳动 | 用尺寸,或宽度加 `aspect_ratio(...)` 预先占位。 | | 修好的图片仍显示之前的失败内容 | 失败结果被缓存了;重新绘制前调用 `ImageSource::remove_asset(cx)`。 | | 每个新视图或每次重启都重新下载图片 | 在 HTTP 层缓存,见[远程图片的 HTTP 缓存](#远程图片的-http-缓存)。 | --- # SystemNotification Source: /versions/v0.7.1/zh-CN/docs/system-notification 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/system-notification 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `SystemNotification` 把消息送到**操作系统通知中心**。它是应用级平台服务,不是在 [Window](./window) 中绘制的 Element。GPUI Kit 的 [Notification 组件](../component/notification) 默认显示应用内 toast;使用 `.system()` 可发送到系统,使用 `.in_app_and_system()` 可同时显示在两处。 | 需求 | 使用方式 | | --- | --- | | 用户正在当前窗口操作时给予即时反馈 | `window.push_notification(Notification::info(...), cx)` | | 后台任务完成,用户可能已切换到其他应用 | `window.push_notification(Notification::info(...).system(), cx)` | | 同一状态既显示在窗口中,也发送到通知中心 | `window.push_notification(Notification::info(...).in_app_and_system(), cx)` | | 自行设置 GPUI payload、tag 或系统通知的操作按钮 | `cx.show_system_notification(SystemNotification { ... })` | 用户正在查看结果时,使用应用内 toast;用户可能已离开应用时,使用系统通知。重要状态仍应保留在应用内:操作系统可能禁用、抑制或延迟通知。需要用户处理的错误,应在返回应用时显示应用内错误信息或对话框,不能只依赖一条短暂的系统通知。 ## 先运行现有示例 在本仓库运行通知示例,然后点击 **System only** 或 **In-app and system**: ```sh cargo run -p gpui-component-story -- NotificationStory ``` 按钮代码在 `crates/story/src/stories/notification_story.rs`;`crates/story/src/main.rs` 在启动时设置应用标识。这样可以直接验证现有 GPUI Kit 路径,无需新建 crate。macOS 上,`cargo run` 启动的程序不是打包的 `.app`,系统通知会被跳过;验证真实投递时应运行打包后的应用。**In-app and system** 的应用内部分仍可显示。 在 **System notification** 区域按以下顺序操作: | 操作 | 预期现象 | | --- | --- | | 点击 **System only**。 | 示例窗口内没有 toast。在支持系统通知且已获授权的桌面环境中,系统会收到标题为 **Build finished**、正文为 **Delivered straight to the notification center.** 的通知。 | | 点击 **In-app and system**。 | 示例窗口内出现不会自动消失的 toast,因为此按钮设置了 `autohide(false)`。系统通知的标题仍为 **Build finished**,正文为 **Shown as a toast and in the notification center.**。 | | 切换到其他应用,再点击系统通知。 | GPUI Kit 请求激活应用及原示例窗口。运行示例的终端打印 `[notification] system notification clicked`;若有对应的应用内 toast,它会关闭。实际窗口激活及通知显示还受操作系统策略影响。 | 两个按钮使用同一个 `SystemNotificationKind` ID。应用内相同 ID 的通知会替换原 toast;系统内相同 tag 的新通知只有在平台支持时才会替换旧通知。这组操作验证的是**组件路径**,并未测试底层 `SystemNotification` 的系统操作按钮。在 macOS 上,`cargo run` 只能检验应用内部分;要检验系统投递与点击激活,应使用已获授权的打包应用。如果桌面禁用了弹出横幅,也应先查看系统通知中心,再判断通知是否未送达。 ## 初始化并设置应用标识 启动时调用一次 `gpui_kit::init(cx)`,并在打开窗口或发送通知前设置稳定的标识符与显示名称。未打包的 Windows 应用需要应用标识;Linux 可使用其显示名称。GPUI 测试平台也要求设置标识,因此通知测试中也应设置。 ```rust gpui_kit::application().run(|cx| { gpui_kit::init(cx); cx.set_app_identity("com.example.exporter", "Exporter"); // Open the application window here. }); ``` | 平台 | 投递条件与行为 | | --- | --- | | macOS | 从真实的 `.app` 包运行,并放在系统信任的位置,例如 `/Applications`。直接 `cargo run` 不能投递。首次发送可能请求通知权限;拒绝授权会抑制投递。 | | Windows | 尽早设置应用标识,未打包的应用尤其需要。通知是否显示还取决于系统通知设置。 | | Linux | 需要正常工作的会话 D-Bus 与 XDG 通知守护进程。无法发送时,当前适配器会记录警告;它既不按 tag 替换,也不支持撤回。 | GPUI 的 `show_system_notification` 返回 `()`,不返回投递结果。因此调用成功仅表示请求已交给平台适配器。没有看到系统通知,并不代表应用的后台任务失败。 把权限视为产品流程的一部分:任务结果应保存在应用状态中,并在用户返回时可再次找到。系统通知只是引导用户查看状态,不能充当状态本身。权限被拒、应用通知被静音、免打扰模式、缺少通知守护进程或原窗口关闭,都不应抹掉已完成的导出结果及其错误。 ## 使用底层 GPUI API 发送 本项目固定使用 `gpui-pre 0.3.8`。其 [`App::show_system_notification`](https://docs.rs/gpui-pre/0.3.8/gpui/struct.App.html#method.show_system_notification) 接收包含 `tag`、`title`、`body` 和 `actions` 的 `SystemNotification`: ```rust use gpui_kit::SystemNotification; // In code with an &mut App named cx: cx.show_system_notification(SystemNotification { tag: "export/report-42".into(), title: "Export complete".into(), body: "report.pdf is ready".into(), actions: Vec::new(), }); ``` 为对应的任务或条目选择 tag。再次发送同一 tag 时,**支持替换的平台**会更新先前的通知。`cx.dismiss_system_notification("export/report-42")` 请求撤回待投递或已投递的通知,同样只提供尽力而为的保证。当前 Linux 适配器不实现按 tag 替换或撤回,因此重复或过期的通知可能保留到守护进程使其自然过期。 如需系统通知操作按钮,在 `actions` 中放入 `SystemNotificationAction { id, label }`。点击按钮后,`id` 通过 `SystemNotificationResponse::action_id` 返回;点击通知主体时,该字段为 `None`。部分平台可能显示通知却不显示按钮。只注册一次 `cx.on_system_notification_response(|response, cx| { ... })`,依据 `response.tag` 和 `response.action_id` 分发底层响应。只有用户激活已投递的通知时才会调用 handler;发送失败或普通关闭不会触发。底层 GPUI 通知不会自动关联原窗口,也不会自动调用应用业务回调;应用需要自己处理路由,并确认目标窗口仍存在。 底层响应处理器应将 tag 对应到持久的任务标识,在收到响应时重新查询任务,并分别处理通知主体点击(`None`)和每个操作按钮 ID。响应到达时,任务或窗口可能已经不存在。不要把这个 handler 当作后台任务的完成回调。 ## 使用 GPUI Kit 的窗口集成 需要在点击后返回原窗口时,通过该窗口的 `WindowExt` 推送 `Notification`。用 `gpui_kit::open_window` 创建窗口,或用 `Root::new` 包裹视图,以挂载通知 overlay。 ```rust use gpui_kit::component::{notification::Notification, WindowExt}; struct ExportNotice; window.push_notification( Notification::success("report.pdf is ready") .title("Export complete") .id::() .system() .on_click(|_, window, cx| { // Open the completed export task in its owning window. }), cx, ); ``` 组件把 ID 映射成带命名空间的系统 tag,并记住发送通知的窗口。不同任务需要不同身份时,可用 `.id1::(task_key)`。组件将标题和消息作为系统通知文字;只有消息时,消息会成为系统标题。既没有标题也没有消息的纯自定义内容通知不会发送到系统。自定义内容和组件的 `.action(...)` 按钮只属于应用内 toast;组件发送的系统通知不包含操作按钮。 收到可识别的点击后,GPUI Kit 请求撤回通知、激活应用,再激活原窗口、关闭对应的应用内 toast(如果存在),并调用 `on_click`。回调需要原窗口仍然存活。若窗口已关闭,应用仍可能被激活,但不会调用窗口回调。应用重启后,或组件有限的路由记录被清理后,点击也可能只激活应用而不调用 `on_click`;重要任务状态应存放在通知回调之外。组件对一条响应只处理一次,并忽略其命名空间以外的底层 tag。 `.system()` 不创建应用内 toast,因此不会调用 `on_close`。`.in_app_and_system()` 同时创建两者;toast 自动超时**不会**撤回系统副本。显式调用 `window.remove_notification::(cx)` 或 `window.clear_notifications(cx)` 会请求撤回该窗口拥有的系统通知,包括仅发送到系统的通知。若另一个窗口随后用同一组件 ID 发过通知,第一个窗口的移除操作不会撤回较新的通知。操作系统是否真正撤回仍取决于平台。 ## 保持单一响应 handler GPUI 只保留一个 `App::on_system_notification_response` handler;再次注册会替换之前的 handler。启用默认组件 feature 时,`gpui_kit::init(cx)` 会安装 GPUI Kit 的 handler。如果组件系统通知需要处理点击,就不要覆盖它。底层 API 仍可发送通知,但该 handler 会忽略底层通知的 tag,因此应用收不到它们的操作按钮响应。GPUI Kit 没有公开的接口将底层 handler 串接到组件 handler;应用应选择一种响应所有权路径。 ## 测试与排查 `TestAppContext` 提供 `shown_system_notifications()`、`delivered_system_notifications()`、`dismissed_system_notifications()` 和 `simulate_system_notification_response(...)`。测试中先设置应用标识,再检查发送的标题、正文和 tag,模拟点击,并断言预期的窗口回调或底层 handler 结果。测试平台会模拟替换和撤回,但**不能**证明真实操作系统的守护进程、授权请求或窗口激活正常;每个目标系统都需要实际检查。 没有看到通知时,检查应用标识、macOS 的打包与权限状态、Windows 通知设置,或 Linux 会话中的通知守护进程。点击没有反应时,检查通知是由组件还是底层 GPUI 发送、是否有其他 handler 替换了 GPUI Kit 的 handler,以及原窗口是否仍存在。toast 样式和生命周期见 [Notification 组件](../component/notification);平台边界设计见 [Native Extensions](./native-extension)。 | 现象 | 下一步检查 | | --- | --- | | **In-app and system** 有应用内 toast,却没有系统通知。 | 窗口集成已经生效;检查操作系统权限、免打扰模式及上面的平台投递条件。在 macOS 上直接执行 `cargo run` 时,这是预期结果。 | | **System only** 看起来没有反应。 | 此模式本来就没有应用内 toast。检查通知中心及平台投递条件;API 不提供投递结果回调。 | | 点击系统通知后只激活了应用,没有进入目标视图。 | 检查原窗口是否仍打开、组件的内存路由记录是否仍存在。用户返回后应从应用状态恢复目标视图。 | | 新通知发送或显式移除后,旧通知仍存在。 | 检查平台是否支持按 tag 替换与撤回;当前 Linux 适配器两者都不支持。 | | 底层通知的操作按钮没有触发应用行为。 | 检查唯一的应用级响应 handler。`gpui_kit::init(cx)` 安装的 handler 只路由 GPUI Kit 组件 tag,会忽略底层 tag。 | --- # RenderOnce Source: /versions/v0.7.1/zh-CN/docs/render-once 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/render-once 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 核心区别是所有权。**`RenderOnce::render(self, ...)` 消费组件值**:父级通常在自己 render 时构造一份新的、轻量的界面描述。**[`Render::render(&mut self, ...)`](./render) 借用保留的 View**,这个 View 存放在 [Entity](./entity) 中。用 `RenderOnce` 表达一次 render 所需的声明式输入;当 View 自身需要跨 render 保存状态与生命周期时,用 `Render`。`Entity` 也可以只保存没有实现 `Render` 的 model 或数据;类型实现 `Render` 后,该 Entity 才是可渲染的 View。 ```rust // RenderOnce fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement; // Render fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement; ``` 这也是为什么多数可复用的 GPUI Kit 组件优先采用 `RenderOnce`:调用方提供属性和 handler,组件返回现成的语义元素,无须给每个按钮、列表行或 Badge 创建 Entity。持有集合、订阅、异步任务或协同选中状态的文件树、聊天列表、图表工作区等功能 View,通常需要保留的 `Entity` 和 `Render`。复杂 View 内部仍可以构造许多 `RenderOnce` 子组件。 ```rust use gpui_kit::*; use gpui_kit::prelude::*; #[derive(IntoElement)] struct MessageRow { author: SharedString, body: SharedString, action: Option, } impl MessageRow { fn new(author: impl Into, body: impl Into) -> Self { Self { author: author.into(), body: body.into(), action: None, } } fn action(mut self, action: impl IntoElement) -> Self { self.action = Some(action.into_any_element()); self } } impl RenderOnce for MessageRow { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { let MessageRow { author, body, action } = self; div() .flex() .gap_2() .child(div().font_semibold().child(author)) .child(body) .when_some(action, |row, action| row.child(action)) } } ``` `#[derive(IntoElement)]` 会生成转换实现,让这个值可以直接加入 GPUI 的 [Element](./element) tree: ```rust div().child(MessageRow::new("You", "Explain RenderOnce")) ``` 这个 derive 不会立即 render 组件。它生成 `IntoElement` 实现,其 Element 类型为 `ViewElement`;GPUI 处理外层元素树时才消费并 render 这个值。单独实现 `RenderOnce` 会让值实现 `View`,但要把它直接传给 `.child(...)`,还需要 `IntoElement`;这里由 derive 提供。derive **不会**为你的类型实现 `Styled`、`ParentElement`、焦点或无障碍语义。只有实际构造这些元素能力,或另行实现相应 trait,它们才会存在。 ## 动手练习:重建组件值,保留计数 在依赖 `gpui-kit` 的应用中,用下面的完整代码替换 `src/main.rs`,然后运行 `cargo run`。`CounterLabel` 是一个 `RenderOnce` 值;`CounterView` 是持有计数的、保留在 `Entity` 中的 View。 ```rust use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::*; #[derive(IntoElement)] struct CounterLabel { count: u32, } impl RenderOnce for CounterLabel { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { div().child(format!("Count: {}", self.count)) } } struct CounterView { count: u32, } impl Render for CounterView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .flex() .flex_col() .size_full() .items_center() .justify_center() .gap_2() .child(CounterLabel { count: self.count }) .child( Button::new("increment") .primary() .label("Add one") .on_click(cx.listener(|this, _, _, cx| { this.count += 1; cx.notify(); })), ) } } fn main() { application().with_assets(assets::Assets).run(|cx| { init(cx); open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| CounterView { count: 0 }) }) .expect("Failed to open window"); }); } ``` 窗口初始显示 `Count: 0`。连续点击两次 **Add one**,应依次看到 `Count: 1` 和 `Count: 2`。每次调用 `CounterView::render` 都会用当前计数构造新的 `CounterLabel`,旧值已被消费;计数能继续增长,是因为同一个 `CounterView` Entity 持有它。点击回调修改 owner 后,`cx.notify()` 请求再次渲染。组件名并不表示每个显示帧恰好 render 一次;具体时机见 [Render](./render)。 如果文本一直为零,检查监听器是否写入 `this.count` 并调用 `cx.notify()`。如果每次都回到一,检查 `CounterView { count: 0 }` 是否只在窗口创建闭包中构造,而没有放在 `render` 中。如果 `.child(CounterLabel { ... })` 编译失败,检查 `#[derive(IntoElement)]` 和 `use gpui_kit::*;` 是否都在作用域内。本页后续片段分别展示不同变化;复制运行时以这个完整示例为起点。 ## 从构造到调用 `MessageRow` 的 builder 接口很小:`new` 提供必需的文本,`action` 是可选的 **slot**。`AnyElement` 能保存调用方传入的不同具体元素。这个 slot 总是放在正文之后;重复调用 `.action(...)` 会替换前一个元素。仓库中的 [`Empty` 组件](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/empty.rs) 也使用有名称的可选 slot,并按固定顺序渲染。如果组件要接收多个任意子元素,可保存 `Vec` 并实现 `ParentElement`;单个具名 slot 不需要该 trait。 调用方可以把现成的语义控件放进 slot,由持久的 View 保存行为。下面的 `Editor` 持有 `opened`;点击回调修改字段,并通知 GPUI 重新渲染: ```rust // In Editor::render, where cx: &mut Context is available. MessageRow::new("You", "Explain RenderOnce").action( Button::new("open-message") .label("Open") .on_click(cx.listener(|this, _, _, cx| { this.opened = true; cx.notify(); })), ) ``` 使用 `use gpui_kit::component::button::Button;` 导入按钮,并为 `Editor` 添加 `opened: bool` 字段。渲染多条消息时,每个按钮应使用由该消息的领域 ID 派生的稳定 ID(例如当 `message.id` 的类型可作为 `ElementId` 的一部分时,使用 `("open-message", message.id)`)。显示文字不能作为身份。`MessageRow` 本身没有 Entity、listener context 或回调所有权;它只消费调用方已经构造的 action。若回调本身属于自定义组件的接口,也可以在私有字段中保存持有所有权的 `'static` handler,并在 `render` 中转交给语义控件。 ## 持有的值与 render 生命周期 [`Render`](./render) Guide 详述保留的 View。`RenderOnce` 要求 `Self: 'static`,值式组件的实际签名是: ```rust fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement; ``` 因为 `self` 的所有权属于 `render`,所以可以直接把字段移动到 Element tree 和 `'static` handler 中。组件值只使用一次;父级下一次 render 时再构造新值。Builder 方法可以在构造期间修改这个值;最终属性描述的是一次 render,而不是持久的可变模型。 多个字段需要移动到不同位置时,可以先解构,让所有权关系更清楚。上方完整的 `MessageRow::render` 就这样处理文本与 action slot。 slot 的转换发生在 builder 中,早于 `render`;`.when_some(...)` 只在它存在时添加子元素。`SharedString` 持有可复用文本,`AnyElement` 持有被类型擦除的子元素,因此两者都不会借用短生命周期的局部变量。 这并不表示界面显示一帧后就会消失,也不表示每次屏幕刷新都要构造新值。“Once” 指同一个组件实例只使用一次:父级每次执行 `render` 才构造另一个实例。普通窗口重绘可能让未变化的子 View 再次 render,而显式的[缓存 View](./view-cache) 可跳过未变化的子树。GPUI 在这些 render 之间保留 Entity 和 keyed state,因此不能简单等同于每次屏幕刷新都重建整个应用的传统 immediate-mode。不要把 `&mut Window` 或 `&mut App` 引用保留到本次调用结束后。需要身份的重复行应从领域数据生成稳定 ID;见 [ElementId](./element_id)。 ## 状态应放在组件值之外 `RenderOnce` **并不表示“完全没有状态”**。值可以携带本次 render 的 label、disabled、checked 等属性。GPUI 可通过稳定的 [ElementId](./element_id) 保留微小的交互状态,`RenderOnce` 组件也可以持有外部 `Entity` handle。关键边界是:变化的应用状态必须由这个被消费的值以外的对象持久拥有。它可以是实现 `Render` 的 View、只存 model 的 Entity,或其他应用状态 owner;再把当前值、回调或 handle 传给组件。 GPUI Kit 带样式的 `Button` 和 `Checkbox` 都实现 `RenderOnce`。Button 接收 label 与点击 handler。Checkbox 接收受控的 `checked` 布尔值,并通过 `on_click` 报告请求的下一个值;owner 写入该值并调用 `cx.notify()`。Base 层提供焦点、键盘和无障碍行为。父级可以轻量地重新构造它们,同时让真正的状态只有一个 owner。 ```rust use gpui_kit::component::checkbox::Checkbox; // In the owner's Render::render, where self.show_hidden and cx exist: Checkbox::new("show-hidden") .checked(self.show_hidden) .label("Show hidden files") .on_click(cx.listener(|this, checked, _, cx| { this.show_hidden = *checked; cx.notify(); })) ``` ```rust use gpui_kit::component::button::{Button, ButtonVariants}; struct Editor { saved: bool, } impl Render for Editor { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .child(if self.saved { "Saved" } else { "Unsaved" }) .child( Button::new("save") .label("Save") .primary() .on_click(cx.listener(|this, _, _, cx| { this.saved = true; cx.notify(); })), ) } } ``` 这段代码沿用第一个示例的 `use gpui_kit::*;`。`Editor` 挂载在 `Entity` 中;`Button::new` 建立稳定的元素 ID。按钮的名称、焦点、键盘激活和无障碍 role 由组件层与 Base 层提供。Element handler 要求 `'static`,因此子组件若捕获回调或 `Entity`,必须使用持有的值。如果调用方还需要某个 handle,应先 clone,再把副本移入 handler。 文字编辑展示了边界的另一侧。`Input::new(&state)` 返回 `RenderOnce` 可视组件,但 `state` 是所属 View 保留的 `Entity`。`InputState` 实现 `Render`,跨父级 render 保留文字、选区、焦点、编辑历史和输入行为。重新构造 `Input::new(&state)` 不会重新创建编辑器状态: ```rust use gpui_kit::component::input::{Input, InputState}; struct SearchView { query: Entity, } impl Render for SearchView { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { Input::new(&self.query).id("search-query") } } ``` 构造 `SearchView` 时创建一次 `query`,例如 `cx.new(|cx| InputState::new(window, cx))`;不要在 `render` 中重新创建。持有大量集合数据的 View 也遵循相同的所有权规则:记录、过滤条件、选中项 ID 和订阅保留在 owner 中,再从它们构造值式 row 与控件。 捕获 `Entity` 会让该 Entity 在生成的 handler 存续期间保持存活。子组件操作其 owner 时,这通常符合预期。如果 handler 不应该延长目标的生命周期,应使用 `WeakEntity`,并处理 `weak.update(...)` 已经无法访问目标的情况。 `RenderOnce::render` 得到 `&mut App`,而不是 `&mut Context`。因此 `RenderOnce` 组件没有自己的 Entity [Context](./context):不能为自己使用 `cx.listener`、保留自己的 Subscription 或 Task,也不能通过 `cx.notify()` 安排自己重新 render。应传入 handler、派发 [Action](./action),或更新真正持有状态的 `Entity`。Keyed element state 可以保留局部交互细节,但不能代替持久应用数据的 owner。 ## 常见编译错误与检查方法 | 现象 | 检查方法 | | --- | --- | | `.child(...)` 报告 `MessageRow` 未实现 `IntoElement` | 除了 `impl RenderOnce`,还要添加 `#[derive(IntoElement)]`,并导入 `use gpui_kit::*;`。 | | 找不到 `when_some` 或其他链式样式方法 | 导入 `gpui_kit::prelude::*;` 以获得 `FluentBuilder`,并确认方法属于返回的 `div()`,而非自定义组件类型。 | | slot 或 handler 中借用的局部值“存活时间不够长” | 移入持有数据的 `SharedString`、`AnyElement` 或克隆后的 `Entity` handle。GPUI handler 必须是 `'static`。 | | 在 `RenderOnce::render` 中找不到 `cx.listener` 或 `cx.notify()` | 这里拿到的是 `&mut App`,不是 Entity 的 `Context`。如上例,在 owner View 的 `Render::render` 中创建 listener,再传入组件。 | | 直接对 `MessageRow` 调用 `.child(...)` 失败 | derive 提供的是 `IntoElement`,不是 `ParentElement`。使用 `.action(...)` 等具名 builder,或实现 `ParentElement` 并显式保存子元素。 | 检查复制的示例时,先把类型、builder 和 `RenderOnce` 实现放在同一模块,加入上面的两条 import,并在持久 View 中将它用作子元素,然后在应用工作区运行 `cargo check -p your-app`。文中的几段 `Editor` 代码分别说明不同用法;若要合并编译,需加上文中提到的字段与导入。当前仓库的 API 可对照 [`Empty`](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/empty.rs)、[`Checkbox`](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/checkbox.rs) 与 [`Button`](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/button/button.rs)。 ## Builder 风格的组件 持有的私有字段很适合 Builder API。GPUI Kit 组件也使用这种模式。Builder 接收并返回 `Self`,调用方可在保持组件有效的同时继续配置它: ```rust use gpui_kit::component::ActiveTheme; #[derive(IntoElement)] struct StatusBadge { label: SharedString, muted: bool, } impl StatusBadge { fn new(label: impl Into) -> Self { Self { label: label.into(), muted: false, } } fn muted(mut self, muted: bool) -> Self { self.muted = muted; self } } impl RenderOnce for StatusBadge { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { div() .rounded_full() .px_2() .text_color(cx.theme().muted_foreground) .when(!self.muted, |this| this.text_color(cx.theme().foreground)) .child(self.label) } } ``` 这段代码也使用 `use gpui_kit::*;`。返回的 `div()` 实现了 [`Styled`](./style) 和 `ParentElement`,所以能调用 `.rounded_full()`、`.text_color()` 和 `.child()`。如果调用方需要**直接对 `StatusBadge` 调用**这些方法,就要为该类型实现 `Styled` 和/或 `ParentElement`,存储样式或子元素,再在 `render` 中应用。`IntoElement` derive 不会转发返回元素的 Fluent trait。GPUI Kit 的 `Button` 明确实现了这两个 trait。小范围调整可用 `.when(...)`、`.when_some(...)`;结构差异明显时用普通 Rust 分支。 ## 选择合适的层级 | 使用 | 适用情况 | | --- | --- | | [`RenderOnce`](./render-once) + `IntoElement` | 可复用组件消费本次 render 的属性与 handler;也可使用 keyed state 或外部 Entity,渲染时得到 `&mut Window` 和 `&mut App`。 | | `Entity` 上的 [`Render`](./render) | 保留的 View 拥有变化的数据、集合、Subscription、Task 或生命周期;渲染时得到 `&mut Context`,可修改并通知它。 | | [`Element`](./element) | 内置元素无法表达所需的 layout、prepaint、paint、hit testing 等底层阶段。 | 常见的组合方式是:`Render` View 持有状态,由它创建 `RenderOnce` 组件来描述可复用 UI,而这些组件返回内置 Element。这让多数 GPUI Kit 组件拥有精简、明确的 API,同时把复杂状态放在少数有实际意义的 owner 中。只有标准 Element API 无法表达所需渲染行为时,才需要直接实现 `Element`。 如果一个组件开始积累可变状态、Subscription 或后台 Task,应把这些生命周期移入 `Entity`,并为它实现 `Render`。把它们放在每次 render 都会被消费的值里,会破坏 `RenderOnce` 简单明确的所有权模型。 --- # KeyBinding Source: /versions/v0.7.1/zh-CN/docs/keybinding 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/keybinding 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 **KeyBinding** 把一个或多个按键映射为有类型的 [Action](./action)。GPUI Kit 使用 GPUI 的 keymap:在应用上注册 binding,再把匹配的 Key Context 与 Action handler 放在当前 Focus 对应 [Element](./element) 的 Dispatch Path 上。先用 [Focus](./focus) 建立并跟踪键盘目标,再读 Action 的命令派发;本页重点说明 binding 的写法与匹配规则。 ## 绑定一条命令 `KeyBinding::new(keys, action, context)` 接收按键字符串、Action 值和可选的 context predicate。在初始化时调用 `cx.bind_keys(...)`。`None` 使 binding 在整个应用中有匹配资格;`Some("Editor")` 要求当前 Focus 路径上有 `Editor` context。 ```rust use gpui_kit::*; actions!(editor, [SaveDocument, MoveSelectionUp]); const EDITOR_CONTEXT: &str = "Editor"; fn init_keys(cx: &mut App) { cx.bind_keys([ KeyBinding::new("secondary-s", SaveDocument, Some(EDITOR_CONTEXT)), KeyBinding::new("up", MoveSelectionUp, Some(EDITOR_CONTEXT)), ]); } ``` 打开窗口之前,先调用一次 `gpui_kit::init(cx)`,再调用 `init_keys(cx)`。GPUI Kit 会在初始化时注册组件 binding,应用随后可按明确顺序追加自己的 binding。 拥有这段交互的 [Entity](./entity) 应保留 [FocusHandle](./window),并在其 [Render](./render) 实现中一起注册 handle、context 和 handler: ```rust impl Render for Editor { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .track_focus(&self.focus_handle) .key_context(EDITOR_CONTEXT) .on_action(cx.listener(Self::on_action_save_document)) .on_action(cx.listener(Self::on_action_move_selection_up)) .child("Editor") } } ``` Handler 使用 GPUI 的常规签名,例如 `on_action_save_document`。这里的 `cx` 是 owner 的 [Context](./context)。注册 binding 不会自动让元素获得 Focus;进入区域时主动设置 Focus,或让鼠标交互聚焦其 tracked handle。Focus 所属权、派发与传播详见 [Action](./action)。 ## 按键字符串的写法 一次按键由 key name 和可选 modifier 组成,modifier 之间用连字符分隔。多个按键用空格分隔,构成连续输入的 **chord**: | Binding | 含义 | | --- | --- | | `secondary-s` | 主快捷键修饰键加 S:macOS 是 Command,Linux 和 Windows 是 Control。 | | `ctrl-enter`、`alt-f4`、`shift-tab` | 指定具体修饰键。 | | `cmd-shift-p` | 平台修饰键加 Shift 和 P。`cmd`、`super`、`win` 都指平台修饰键;在 Windows 上它是 Windows 键,并非 Control。 | | `up`、`escape`、`space`、`backspace` | 具名按键。 | | `cmd-k left` | 连续按两次:平台修饰键加 K,然后 Left。 | GPUI 还识别 `fn`、`ctrl`、`alt`、`shift`、`cmd`、`super`、`win` 与 `secondary`。常见的跨平台 Command/Control 快捷键应使用 `secondary`;所有平台都必须使用 Control 时才写 `ctrl`。大写 ASCII 字母如 `A` 表示 Shift+A,显式写 `shift-a` 更容易阅读。如果按键字符串或 context predicate 无法解析,`KeyBinding::new` 会 **panic**;静态定义应便于检查,用户输入则要先验证。 操作系统或窗口管理器可能先于 GPUI 占用快捷键。应在每个支持的平台测试预定组合,尤其是 `cmd`/`super`/`win` 组合以及 `alt-f4` 等系统快捷键。Binding 能成功解析,并不代表按键事件一定会到达应用。 多个 chord 可以共用首键。只要更长的 binding 仍有可能匹配,GPUI 会暂存前缀;后续按键会完成 chord,或使前缀被重新派发。不要在可编辑区域中随意把常用文本输入键设为 chord 前缀。 ## 声明 Key Context 元素的 `.key_context(...)` 声明当前节点上的事实。`KeyBinding::new` 的第三个参数是用于测试 Focus 路径上 context 的 **predicate**。二者语法相关,但用途不同: ```rust // Context on an element: an identifier and key/value state. div().key_context("Editor mode=normal") // Predicate passed as the third argument to KeyBinding::new. Some("Editor") Some("Editor && mode == normal") Some("Editor && !Modal") Some("Workspace > Editor") ``` `Editor mode=normal` 在同一个节点声明两项事实;`Editor && mode == normal` 用来测试这些事实。Predicate 支持标识符、`==`、`!=`、`!`、`&&`、`||`、括号,以及表示祖先到后代 context 的 `>`。例如,`Workspace > Editor` 要求 `Editor` context 位于 `Workspace` context 下方。混用运算符时用括号明确分组。`!Modal` 可以排除包含 `Modal` 的路径,适用于对话框获得 Focus 时不应执行的 workspace 快捷键。 只有位于**当前 Focus** 对应 Dispatch Path 上的 context 才会参与匹配。Sibling 的 `Editor` context 不会激活编辑器 binding。即使 binding 匹配,如果 handler 挂在另一条分支上,它仍收不到 Action。把 context 和 handler 放在获得 Focus 的区域,或真正拥有这条命令的祖先上。 ## 理解优先级 多个 binding 匹配相同按键时,GPUI 首先按 context 在 Focus 路径上的深度排序。内层 `Editor` 的 binding 优先于祖先 `Workspace` 的 binding。同一深度下,**后注册**的 binding 排在前面。`None` context 被视为匹配最深的 context,所以它不会自动成为低优先级 fallback:后注册的 `None` binding 可能排在相同按键的 `Editor` binding 前面。希望 workspace 快捷键让位于内层控件时,应显式指定 workspace context。 GPUI 可以依次尝试多个匹配的 binding。它沿 Focus 路径派发每个候选 Action,直到某个 handler 消费它;Action handler 默认停止传播。Handler 决定不处理时,可调用 `cx.propagate()` 继续派发。存在竞争 binding 时,注册顺序、context 深度和 handler 是否可达都会影响结果。 切换 Focus 位置,可看到本例中哪个 `escape` binding 排在前面。层次图标出当前 Focus 路径;结果假定路径上的 handler 会消费首个 Action。
Focus path
Window
Workspace Escape → ClearWorkspaceSelection
Editor Escape → CloseEditorSearch
Modal No Escape binding
Matching Escape bindings · highest first
1 · WorkspaceClearWorkspaceSelection
1 · EditorCloseEditorSearch
2 · WorkspaceClearWorkspaceSelection

No matching Escape binding on this Focus path.

```rust cx.bind_keys([ KeyBinding::new("escape", ClearWorkspaceSelection, Some("Workspace")), KeyBinding::new("escape", CloseEditorSearch, Some("Editor")), ]); ``` Focus 位于 Workspace 内的 Editor 时,`CloseEditorSearch` 更具体;Focus 位于 Workspace 的其他区域时,只有 `ClearWorkspaceSelection` 匹配。GPUI Kit 的 Tree 与 TimeField 等组件也用自己的 context 将方向键行为限制在组件内部。 ## 从 Action 反查快捷键 Action 也是**反向查找**快捷键的依据:向 window 查询当前由哪个 binding 触发该 Action。GPUI Kit 的 [Kbd 组件](../component/kbd)可显示查询结果。渲染按钮、菜单或命令面板时,应传入命令目标的 focus handle;因为此时可能是其他控件或 overlay 拥有 Focus。 ```rust use gpui_kit::component::kbd::Kbd; let binding = window.highest_precedence_binding_for_action_in( &SaveDocument, &self.editor_focus, ); // GPUI Kit shortcut hint for the same Action and target. let hint = Kbd::binding_for_action_in( &SaveDocument, &self.editor_focus, window, ); div().children(hint) ``` 第一个调用返回 `Option`;第二个返回可直接放在标签旁渲染的 `Option`。它们都会考虑目标区域的 context、较高优先级 binding 的遮蔽,以及当前 keymap(包括用户覆盖设置)。`None` 表示在可解析的目标路径上没有该 Action 可显示的 binding。这些查询基于**上一帧已经渲染的内容**,刚在当前帧首次绘制的 focus handle 可能暂时查不到结果。查到 binding 并不代表命令在当前运行状态下可以执行,也不证明目标路径上有 Action handler;owner 仍需决定是否允许执行。`window.is_action_available_in(&SaveDocument, &self.editor_focus)` 可以检查该路径上是否有元素级 Action handler。 查询 window 当前 context 时,还可以使用 `window.highest_precedence_binding_for_action(&action)` 和 `window.bindings_for_action(&action)`;后者返回全部可见 binding。已知单个 context 时,可用 `window.highest_precedence_binding_for_action_in_context`。GPUI Kit 也提供 `Kbd::binding_for_action(&action, Some("Editor"), window)` 查询简单 context;传 `None` 则查询 window 当前 context。这里的 `Some(...)` 使用 **Key Context 声明语法**,例如 `Editor mode=normal`,而不是 `Editor && mode == normal` 这样的 predicate 语法。无效的 context 字符串会悄悄回退到 window 查询,因此动态输入应先验证。存在嵌套 context 或菜单改变了 Focus 时,使用具体 focus handle 更可靠。GPUI Kit 的 `Kbd::global_binding_for_action(&action, window)` 可针对空 Key Context 做最后的 fallback 查询,它不会重建嵌套 Focus 路径。 `Kbd::binding_for_action_in` 目前**只显示 chord 的第一个按键**。需要显示完整序列时,应格式化返回的 `KeyBinding` 中每个 keystroke: ```rust use gpui_kit::AsKeystroke; use gpui_kit::component::kbd::Kbd; let shortcut = binding.map(|binding| { binding.keystrokes().iter() .map(|stroke| Kbd::format(stroke.as_keystroke())) .collect::>() .join(" ") }); ``` `Kbd::format` 会选用对应平台的修饰键符号与按键名称:`secondary-s` 在 macOS 上显示为 `⌘S`,在 Linux 和 Windows 上显示为 `Ctrl+S`。`AsKeystroke` 让每个 binding stroke 可作为 `Keystroke` 使用。保留 `shortcut` 的可选性:没有绑定的 Action 不应显示虚构的快捷键。 ## 让菜单和快捷键显示保持同步 快捷键、按钮、命令面板与菜单项应使用同一个 Action。菜单可以保存 `MenuItem::action("Save Document", SaveDocument)`;按钮可以调用 `window.dispatch_action(Box::new(SaveDocument), cx)`。当前 Focus 路径上的 owner 随后运行同一个 handler。 先注册 binding,再调用 `cx.set_menus(...)`。Native menu 在创建时读取当时的 keymap,并保留显示的快捷键。应用以后更改 binding,需要再次调用 `cx.set_menus(...)` 重建菜单,让标签和原生快捷键反映新 keymap。窗口内的菜单和 tooltip 应按上文从 Action 查询当前 binding,不要硬编码 `⌘A` 或 `Ctrl+A`:平台、目标 context 和用户 keymap 都可能改变实际显示的快捷键。GPUI Kit 的 Popup Menu 会根据命令目标或触发位置的 Focus 查询快捷键,并通过 `Kbd` 显示;简单 context 的 tooltip 可以使用 `Tooltip::action`,让它在渲染时查询。 ## 用户 Keymap 与具名 Action GPUI 的 `actions!` 宏会注册具有稳定名称的 unit Action,例如 `editor::SaveDocument`。带配置数据的 Action 应 derive `Action` 及所需的反序列化和 schema trait;`#[action(no_json)]` 表示不能从 JSON 构造的 Action。`cx.all_action_names()` 列出已注册名称,`cx.build_action(name, data)` 则用名称与可选 JSON 数据构造 Action。 GPUI 提供 Action registry 和 keymap 机制,但**用户 keymap 文件格式**、验证、加载与重载策略由应用自己负责。Loader 可以用 `cx.build_action(...)` 解析 Action 名称与数据,用 `KeyBindingContextPredicate::parse(...)` 解析 context,再通过可返回错误的 `KeyBinding::load(...)` 创建 binding。对解析错误或未知 Action 应向用户报告,而不是把不可信字符串传给解析失败会 panic 的 `KeyBinding::new(...)`。`cx.bind_keys(...)` 会追加 binding;`cx.clear_key_bindings()` 则清除整个应用的 keymap,包括组件默认 binding,因此重载时需要按所需顺序重新注册全部 binding。keymap 改变后还要重建 native menu。 ## 排查快捷键问题 1. **按键**:检查当前平台实际使用的修饰键。`secondary-s` 在 macOS 上是 Command+S,在其他平台是 Control+S;Windows 上的 `cmd-s` 不是 Control+S。 2. **Focus**:检查哪个 `FocusHandle` 获得 Focus,以及已渲染元素是否用它调用 `track_focus`。只有点击后才生效,通常说明 Focus 路径有问题。 3. **Context**:检查 predicate 是否匹配路径上的 `key_context`。声明 context 用 `mode=normal`,测试它用 `mode == normal`。 4. **竞争**:检查更深层 context、同深度后注册的 binding 和 chord 前缀。`None` binding 可能排在较浅的 scoped binding 前面。 5. **Handler**:检查匹配的 Action 在当前 Focus 路径上是否有 `on_action` handler,以及更具体的 handler 是否提前消费了它。 6. **菜单与重载**:菜单仍显示旧快捷键时,在安装新 keymap 后再次调用 `cx.set_menus(...)`。组件快捷键在重载后消失时,确认 `clear_key_bindings()` 之后重新运行了所有组件初始化。 ## 用仓库示例验证 [Tree 实现](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/tree.rs)在 `Tree` context 下注册方向键 binding,并在渲染的根元素上放置 `.key_context(CONTEXT)`、`.track_focus(&focus_handle)` 和 `on_action` handler。[Combobox 实现](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/combobox.rs)另外绑定 Enter、Escape 和用于第二种确认方式的 `secondary-enter`。[Popover story](https://github.com/longbridge/gpui-kit/blob/main/crates/story/src/stories/popover_story.rs)展示了 macOS Command 与其他平台 Control 的显式 binding,以及获得 Focus 的 Action owner。 验证应用 binding 时,让目标 focus handle 获得 Focus,按下快捷键,确认预期 Action handler 运行。然后聚焦到声明的 context 之外的 sibling:有 context 限制的快捷键此时不应运行。最后打开 overlay 或文本输入框再试,检查 Focus 切换和按键冲突。另外,针对命令目标的 focus handle 调用 `highest_precedence_binding_for_action_in` 检查显示的快捷键;标签正确并不能证明 handler 可达。 Focus 到 Action handler 的完整路由过程,继续阅读 [Action](./action)。 --- # Comparison Source: /versions/v0.7.1/zh-CN/docs/comparison 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/comparison 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 本指南比较**跨平台桌面 UI 框架**及其有文档支持的生态能力。绿色表示可用,黄色表示仍需应用补足或验证,红色表示未提供。圆点表示功能覆盖,不代表性能。选型时还要核对版本、后端、平台与许可。 图例: Yes · Partial · No。纳入比较的生态扩展见下文。 | Capability | GPUI Kit | Iced | egui | Qt 6 | Slint | | --- | --- | --- | --- | --- | --- | | UI model | Declarative passes + retained state | Declarative view + retained state | Immediate | Retained scene | Reactive tree | | UI authoring | Rust | Rust | Rust | QML / C++ / Python | `.slint` + Rust / C++ / JavaScript / Python | | Visual tooling | Code + component gallery | Code | Code | Qt Quick Designer | Live Preview / SlintPad | | Component count | [75+](/versions/v0.7.1/zh-CN/component) | [35](https://docs.rs/iced/0.14.0/iced/widget/#structs) | [16](https://docs.rs/egui/0.36.2/egui/widgets/#structs) | [52](https://doc.qt.io/qt-6/qml-qtquick-controls-control.html) | [24](https://docs.slint.dev/latest/docs/slint/reference/std-widgets/overview/) | | Rendering stack | GPUI | wgpu | eframe: glow / wgpu | Qt RHI / scene graph | FemtoVG / Skia / software | | Documentation | Complete core + component docs; API + gallery | Book + API | API + demos | Guides + API + designer | Guides + API + preview | | Default UI style | Theme-driven components | Styled widgets | egui visuals | Qt Quick styles | Slint widget styles | | Desktop OS | | | | | | | Multiple windows | | | | | | | Shortcuts | | | | | | | Themes | | | | | | | Bundled theme presets | 38 (36 variants + Light/Dark) | 22 built-in variants | Light / Dark | Qt Quick styles | Slint widget styles | | Code editor | | | | | | | CJK font support | System / bundled fonts | Font-dependent | Custom font required | System fallback | Font-dependent | | Text model | Rope | COSMIC Text | TextBuffer | QTextDocument | TextEdit string | | Syntax highlighting | | | | | | | Markdown | | | | | | | Markdown with inline HTML | | | | | | | HTML rendering | | | | | | | Rich text | | | | | | | Text selection | | | | | | | Advanced data table | | | | | | | Large-table virtualization | Rows + columns | No | Rows (`egui_extras`) | Rows + columns (`TableView`) | Rows | | Resizable table columns | | | | | | | Virtual list | | | | | | | Charts | | | | | | | Docking | | | | | | | Animation | | | | | | | Accessibility | | | | | | | I18N | | | | | | | UI testing | | | | | | | Mobile | | | | | | | WebAssembly | | | | | | | License | Apache-2.0 | MIT | MIT / Apache-2.0 | Commercial / LGPLv3 / GPLv3 by module | GPLv3 / commercial / royalty-free | | Minimal Binary Size | ~12 MB | ~11 MB | ~5 MB | ~20 MB | ~21 MB | | WebView | | | | | | **Component count。** GPUI Kit 有 75+ 个已记录的 component 和 primitive。其余数字分别来自 Iced 0.14 的 widget 模块(35)、egui 0.36 的 widgets 模块(16,含 `TextEdit`)、Qt Quick `Control` 派生类型(52)以及 Slint 的标准组件目录(24)。各目录纳入的辅助类型和扩展范围不同,这些数字表示目录规模,不能直接用于排名。 **Documentation。** GPUI Kit 的[核心指南](/versions/v0.7.1/zh-CN/docs)、[组件文档](/versions/v0.7.1/zh-CN/component)、[API reference](https://docs.rs/gpui-kit/latest/gpui_kit/)和[交互式 gallery](/gallery/)覆盖当前公开能力;实验性功能也说明了现阶段的限制。 **Binary size 数据口径。** 前四个值保留原 `main` 分支的 Hello World Release 估计;原表为 Qt 数据附上了[这篇体积研究](https://www.qt.io/blog/reducing-binary-size-of-qt-applications-part-3-more-platforms)。Slint 的 ~21 MB 来自 Slint 1.18.1 Hello World 在 Linux x86-64 上执行 `cargo build --release`,strip 后为 20,768,216 字节(19.81 MiB)。各值的构建条件不同,是近似参考值,并非已验证的最小体积或同口径跑分。 **帧率。** 本表不对帧率排名:没有相同负载、硬件和呈现测量来支持跨框架 FPS 数字。120 Hz 目标意味着相关管线每帧约有 8.3 ms 预算;它不表示空闲窗口持续重绘,也不保证任何复杂界面都能持续每秒显示 120 帧。GPUI 的 draw 和 present 指标见 [FPS Monitor](./fps#120-hz-是帧预算不是刷新承诺)。 ## 选型速览 上表记录能力覆盖;下表说明这些能力在选型时的利弊。这些判断基于所引用的 API 与文档,不是性能跑分。 | 需求 | GPUI Kit 的优势 | GPUI Kit 的成本或限制 | 对比对象 | | --- | --- | --- | --- | | 代码编辑器与高密度数据界面 | [Editor](/versions/v0.7.1/zh-CN/component/editor)、[DataTable](/versions/v0.7.1/zh-CN/component/data-table)、[VirtualList](/versions/v0.7.1/zh-CN/component/virtual-list) 和 [Dock](/versions/v0.7.1/zh-CN/component/dock) 属于同一 Rust 组件体系。 | 应用仍负责数据排序,也需要在目标平台验证 [Accessibility](/versions/v0.7.1/zh-CN/docs/accessibility)。 | Qt 的 [Model/View](https://doc.qt.io/qt-6/modelview.html) 和 [QTextDocument](https://doc.qt.io/qt-6/qtextdocument.html) 提供覆盖更广的桌面体系。 | | 可选择的 Markdown 与正文 HTML | [TextView](/versions/v0.7.1/zh-CN/component/text-view) 在同一 UI 树中支持两者。 | HTML 支持不包括通用 CSS 布局和脚本。 | Qt [QTextDocument](https://doc.qt.io/qt-6/richtext-html-subset.html) 支持更广的富文本子集;需要浏览器行为时应评估 WebView。 | | 同时面向桌面、移动端与 Web | 可以用同一组件 API 探索 [WebAssembly 展示](/versions/v0.7.1/zh-CN/docs/webassembly)和实验性的 [iOS 工作](/versions/v0.7.1/zh-CN/docs/mobile)。 | Mobile 与 Web 集成仍需针对目标应用验证。 | Qt 的[平台列表](https://doc.qt.io/qt-6/supported-platforms.html),以及 Slint 的 [mobile](https://docs.slint.dev/latest/docs/slint/guide/platforms/mobile/general/) 和 [web](https://docs.slint.dev/latest/docs/slint/guide/platforms/web/) 指南覆盖更广目标。 | | 可视化 UI 编辑 | Rust 代码和[组件画廊](/versions/v0.7.1/zh-CN/component)让界面与应用逻辑保持接近。 | GPUI Kit 没有内置可视化设计器。 | [Qt Quick Designer](https://doc.qt.io/qtcreator/creator-using-qt-quick-designer.html) 提供可视化编辑;Slint 提供 [Live Preview](https://github.com/slint-ui/slint#tooling)。 | > AI 可以直接修改 GPUI Kit 的 Rust UI 代码,无需独立的 Visual UI editor。 ## 如何理解这些判断 **渲染模式。** GPUI Kit 在 [render](./render) 过程中按当前输入构造元素描述,并保留 [Entity](./entity)、带 ID 的元素状态及可选的 [View Cache](./view-cache)。表格中的说法同时描述这些层次,并不意味着每次屏幕刷新都重建整个 UI;[渲染模式详解](./fps#immediateretained-和-hybrid-描述的是不同层次)将它们分开说明。Iced 将自身描述为 [state/message/update/view 架构](https://book.iced.rs/architecture.html)。egui 明确使用 [immediate mode](https://docs.rs/egui/latest/egui/#understanding-immediate-mode)。Qt Quick [在帧间保留场景图](https://doc.qt.io/qt-6/qtquick-visualcanvas-scenegraph.html),Slint 使用[响应式属性绑定](https://docs.slint.dev/latest/docs/slint/guide/language/concepts/reactivity/)。 **桌面基础能力。** 五种方案都能构建多窗口桌面应用、处理键盘快捷键并定制主题;实现方式不同。例如 Iced 有[窗口打开 API](https://docs.rs/iced/latest/iced/window/fn.open.html),eframe 使用[原生 viewport](https://docs.rs/eframe/latest/eframe/trait.App.html),Slint 提供[按键绑定](https://docs.slint.dev/latest/docs/slint/reference/keyboard-input/overview/)和[标准组件样式](https://docs.slint.dev/latest/docs/slint/reference/std-widgets/style/)。输入法、剪贴板和拖放还应按目标平台实际测试。 **Rendering stack、theme 与 CJK。** `Rendering stack` 行记录框架或可选图形后端,不是 GPU 跑分:egui 的 eframe 可选 [glow 或 wgpu](https://docs.rs/eframe/latest/eframe/enum.Renderer.html),Qt Quick 使用 [Qt RHI 上的 scene graph](https://doc.qt.io/qt-6/qtquick-visualcanvas-scenegraph.html),Slint 列出 [FemtoVG、Skia 与 software renderer](https://github.com/slint-ui/slint#runtime)。GPUI Kit 的 38 种 theme 是本仓库 `themes/*.json` 中 36 个变体加上 Default Light/Dark;Iced 0.14 有 [22 个预置变体及 Custom](https://docs.rs/iced/0.14.0/iced/theme/enum.Theme.html)。原表把 egui 的 CJK 写成“差”过于笼统:egui [需要额外安装支持亚洲文字的 font](https://docs.rs/egui/latest/egui/#installing-additional-fonts),Qt 则有[按文字体系查找 fallback font](https://doc.qt.io/qt-6/qfontdatabase.html) 的机制。最终文字覆盖仍取决于打包或系统安装的字体。 **Table column resizing。** GPUI Kit、[egui_extras](https://docs.rs/egui_extras/latest/egui_extras/struct.TableBuilder.html#method.resizable)、[Qt](https://doc.qt.io/qt-6/qheaderview.html) 和 [Slint](https://github.com/slint-ui/slint/blob/master/internal/compiler/widgets/fluent/tableview.slint) 都支持;[Iced](https://docs.rs/iced/latest/iced/widget/table/struct.Column.html) 没有内置拖动列宽。 **Large-table virtualization。** 这一行保留了原表对可见行与可见列的区分:GPUI Kit `DataTable` 的[可见范围](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/table/state.rs)同时记录行和列;[egui_extras `TableBody::rows`](https://docs.rs/egui_extras/latest/egui_extras/struct.TableBody.html#method.rows)只渲染可见行;Qt Quick [`TableView`](https://doc.qt.io/qt-6/qml-qtquick-tableview.html)会复用移出视口的行列 delegate。Iced 的[表格 API](https://docs.rs/iced/0.14.0/iced/widget/table/fn.table.html)没有承诺内置虚拟化。Slint [`StandardTableView` 的实现](https://github.com/slint-ui/slint/blob/master/internal/compiler/widgets/fluent/tableview.slint)将行 repeater 放在 `ListView` 中,因此[只实例化可见行](https://docs.slint.dev/latest/docs/slint/reference/std-widgets/views/listview/);列和单元格 repeater 没有采用相同的视口虚拟化。 **编辑器与高亮。** GPUI Kit 的 [Editor](/versions/v0.7.1/zh-CN/component/editor) 用 [Rope](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/input/base/state.rs#L343) 保存文本,并提供折叠、诊断、补全和悬浮提示等代码编辑能力。启用对应语法的 [grammar feature](/versions/v0.7.1/zh-CN/component/editor) 后,语法高亮使用 [Tree-sitter](https://tree-sitter.github.io/tree-sitter/);大型文本的解析可以放到后台,编辑时可复用上次的解析树。文本模型与高亮引擎是两个不同维度:使用 Rope 不等于自动获得语法高亮。Iced 的 [TextEditor](https://docs.rs/iced/latest/iced/widget/text_editor/struct.TextEditor.html) 有需要启用 feature 的高亮;egui 的 [TextEdit](https://docs.rs/egui/latest/egui/widgets/text_edit/struct.TextEdit.html) 可传入自定义 layouter,也可用 [egui_extras 高亮](https://docs.rs/egui_extras/latest/egui_extras/syntax_highlighting/);Qt 提供 [QSyntaxHighlighter](https://doc.qt.io/qt-6/qsyntaxhighlighter.html)。这一行比较 API 是否可用,不代表同样文档规模下的性能对比。 **文本模型。** 表格列出每种编辑器公开的文本存储或编辑抽象:GPUI Kit 将文本保存在 Rope 中;Iced 使用 [COSMIC Text](https://docs.iced.rs/src/iced_graphics/text/editor.rs.html),egui 接收 [TextBuffer](https://docs.rs/egui/latest/egui/widgets/text_edit/trait.TextBuffer.html),Qt 暴露 [QTextDocument](https://doc.qt.io/qt-6/qtextdocument.html)。这些是 API 形态,不是速度排名。 **格式化文本。** GPUI Kit 的 [TextView](/versions/v0.7.1/zh-CN/component/text-view) 能显示并选择 Markdown 和 HTML 文本;Markdown 解析器也会把内嵌 HTML 节点交给 HTML 解析器。Iced 的 [Markdown 组件](https://docs.rs/iced/latest/iced/widget/markdown/)没有提供 HTML item;egui 需借助扩展库显示 Markdown;Qt 的 [QTextEdit](https://doc.qt.io/qt-6/qtextedit.html)仅支持部分内嵌 HTML。此行的“不支持”只表示文档化的内置 Markdown 路径不处理内嵌 HTML,应用仍可另接渲染器。 **HTML 渲染。** [TextView::html](/versions/v0.7.1/zh-CN/component/text-view#html) 能展示博客或新闻正文常见的标题、段落、链接、图片、列表与表格。它不实现通用 CSS 布局,也不运行脚本,因此标为部分支持;这不是 WebView。Qt 的 [QTextDocument](https://doc.qt.io/qt-6/richtext-html-subset.html)支持更广的 HTML 4 与 CSS 子集,同样不具备浏览器行为。此行比较的是有文档支持的原生 HTML 正文渲染,不把另接 WebView 算入;在比较范围内,Iced、egui 和 Slint 没有同类原生文档渲染器。 **表格与列表。** “高级数据表格”指面向大数据集,具备虚拟化渲染、排序、选择和列管理的组件。GPUI Kit 的 [DataTable](/versions/v0.7.1/zh-CN/component/data-table) 提供这些交互,底层数据排序由 delegate 实现。Qt 的 [QTableView](https://doc.qt.io/qt-6/qtableview.html)及 Model/View 体系也覆盖这类需求。[egui_extras TableBuilder](https://docs.rs/egui_extras/latest/egui_extras/struct.TableBuilder.html)能虚拟化行并调整列宽,但更多数据行为由应用实现。Slint 的 [StandardTableView](https://docs.slint.dev/latest/docs/slint/reference/std-widgets/views/standardtableview/)有排序回调和选择能力;Iced 的[表格](https://docs.rs/iced/latest/iced/widget/table/fn.table.html)提供较基础的起点。单独看列表,GPUI Kit [VirtualList](/versions/v0.7.1/zh-CN/component/virtual-list)、egui [ScrollArea::show_rows](https://docs.rs/egui/latest/egui/containers/scroll_area/struct.ScrollArea.html#method.show_rows)、Qt [ListView](https://doc.qt.io/qt-6/qml-qtquick-listview.html)和 Slint [ListView](https://docs.slint.dev/latest/docs/slint/reference/std-widgets/views/listview/)按需创建可见项;Iced 提供[滚动与可见性原语](https://docs.rs/iced/latest/iced/widget/struct.Sensor.html),虚拟化由应用实现。 **高密度桌面界面。** GPUI Kit 内置 [VirtualList](/versions/v0.7.1/zh-CN/component/virtual-list)、[DataTable](/versions/v0.7.1/zh-CN/component/data-table)、[图表](/versions/v0.7.1/zh-CN/component/chart)、[TextView](/versions/v0.7.1/zh-CN/component/text-view)和 [Dock](/versions/v0.7.1/zh-CN/component/dock)。Iced 有[富文本](https://docs.rs/iced/latest/iced/widget/fn.rich_text.html)、[表格](https://docs.rs/iced/latest/iced/widget/table/fn.table.html)、需启用 feature 的 [Markdown 组件](https://docs.rs/iced/latest/iced/widget/markdown/)、[Canvas](https://docs.rs/iced/latest/iced/widget/struct.Canvas.html)和[窗格网格](https://docs.rs/iced/latest/iced/widget/pane_grid/);虚拟化表格或图表还需要应用实现。egui 生态提供用于 Markdown 的第三方 [egui_commonmark](https://docs.rs/egui_commonmark/latest/egui_commonmark/)、用于停靠布局的 [egui_dock](https://docs.rs/egui_dock/latest/egui_dock/) 以及 [egui_extras 表格](https://docs.rs/egui_extras/latest/egui_extras/struct.TableBuilder.html)与 [egui_plot](https://docs.rs/egui_plot/latest/egui_plot/)。Qt 提供 [Model/View 表格](https://doc.qt.io/qt-6/modelview.html)、[Dock Widget](https://doc.qt.io/qt-6/qdockwidget.html)、[QTextDocument](https://doc.qt.io/qt-6/qtextdocument.html)和单独许可的 [Qt Graphs](https://doc.qt.io/qt-6/qtgraphs-index.html) 模块。Slint 有 [StandardTableView](https://docs.slint.dev/latest/docs/slint/reference/std-widgets/views/standardtableview/)及[只实例化可见项目的 ListView](https://docs.slint.dev/latest/docs/slint/reference/std-widgets/views/listview/);其 [Path](https://docs.slint.dev/latest/docs/slint/reference/elements/path/) 可用于自绘,但不是现成图表。 **产品就绪程度。** GPUI Kit 的[无障碍](/versions/v0.7.1/zh-CN/docs/accessibility)、[测试](/versions/v0.7.1/zh-CN/docs/test)、[国际化](/versions/v0.7.1/zh-CN/docs/i18n)和[动画](/versions/v0.7.1/zh-CN/docs/animation)指南说明了当前实现与限制。其组件包含翻译,但应用文案与语言策略仍由应用负责。Iced 的[无障碍集成仍列为未完成工作](https://github.com/iced-rs/iced/issues/552),同时提供[测试工具](https://docs.rs/iced_test/latest/iced_test/)。egui 文档介绍 [AccessKit 与 egui_kittest](https://github.com/emilk/egui/blob/main/docs/accessibility.md)。Qt 有较完整的[无障碍](https://doc.qt.io/qt-6/accessible.html)、[国际化](https://doc.qt.io/qt-6/internationalization.html)和[Qt Test](https://doc.qt.io/qt-6/qtest-overview.html)设施。Slint 提供[无障碍属性](https://docs.slint.dev/latest/docs/slint/reference/common/#accessibility-properties)和[翻译机制](https://docs.slint.dev/latest/docs/slint/guide/development/translations/);其[测试后端](https://docs.slint.dev/latest/docs/rust/i_slint_backend_testing/)仍标为初步 API,且属于内部 crate。无障碍一栏的“Yes”仍需要在每个目标平台使用辅助技术验证。 **平台覆盖。** GPUI Kit 有[实验性 iOS 集成](/versions/v0.7.1/zh-CN/docs/mobile)和可运行的 [WebAssembly 展示示例](/versions/v0.7.1/zh-CN/docs/webassembly);本仓库尚未将后者验证为完整应用的成熟分发路径。Iced 有 [Web 示例](https://github.com/iced-rs/iced/blob/master/examples/README.md#tour),[原生移动端支持仍在讨论](https://github.com/iced-rs/iced/issues/302)。[eframe](https://github.com/emilk/egui/blob/main/README.md#official-integrations)可在 Web 与原生平台运行 egui,但 Android/iOS 集成仍应针对具体应用验证。Qt 文档列出[支持平台](https://doc.qt.io/qt-6/supported-platforms.html)和 [WebAssembly 限制](https://doc.qt.io/qt-6/wasm.html)。Slint 有[移动端](https://docs.slint.dev/latest/docs/slint/guide/platforms/mobile/general/)与 [Web](https://docs.slint.dev/latest/docs/slint/guide/platforms/web/)文档;其 Web 输出使用 canvas,不支持浏览器屏幕阅读器。不能默认把这些基于 canvas 的 Wasm 界面当成 HTML 应用。 **WebView。** GPUI Kit 有实验性的 [Wry 集成](/versions/v0.7.1/zh-CN/docs/webview)。原生 WebView 会盖住同一区域的 GPUI 元素;需要叠层时,宜放在独立窗口或弹出层。目前文档只列 macOS 和 Windows,Linux 示例仍未完成。Qt 提供官方 [WebEngine](https://doc.qt.io/qt-6/qwebengineview.html)与 [WebView](https://doc.qt.io/qt-6/qtwebview-index.html) 模块,但 Qt WebView 与 QML 元素的重叠也有限制。此行统计适用于所比较版本、由框架维护的集成:Iced 虽有第三方 [iced_webview](https://docs.rs/iced_webview/latest/iced_webview/) crate,但它依赖 Iced 0.13,本表未验证其适配当前 Iced 0.14;egui 没有同类受维护的原生集成,Slint 的 [WebView 需求](https://github.com/slint-ui/slint/issues/3930)仍未关闭。红点不排除应用自行桥接。 **许可与比较范围。** GPUI Kit 采用 Apache-2.0;Iced 为 [MIT](https://github.com/iced-rs/iced/blob/master/LICENSE);egui 为 [MIT 或 Apache-2.0](https://github.com/emilk/egui/blob/main/LICENSE-MIT);Qt [按模块采用商业、LGPLv3 或 GPLv3 条款](https://doc.qt.io/qt-6/licensing.html),其中 Qt Graphs 为 GPLv3 或商业许可;Slint 采用 [GPLv3 与商业或免版税条款](https://slint.dev/pricing)。SwiftUI、WinUI 等单平台原生栈的平台覆盖范围不同。Electron 与 Tauri 使用 WebView/JavaScript UI 架构,应单独评估,不在此原生 UI 矩阵中打分。包体积和帧率受构建 feature、渲染器、字体、打包方式及负载影响;应测量准备发布的产品 release 构建。 --- # TextSystem Source: /versions/v0.7.1/zh-CN/docs/text-system 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/text-system 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 本文以仓库固定的 `gpui-pre` 0.3.8 API 为准。`gpui-pre` 是 GPUI 快照的发布和版本对齐机制,并非另一套文本引擎。先使用文本元素;只有自定义元素需要 GPUI 平时替你管理的字形几何时,才直接调用 `TextSystem`。 GPUI 的 `TextSystem` 负责解析 [Font](./fonts) 并提供 font metrics。每个 [Window](./window) 都有一个 `WindowTextSystem`,在共享文本系统上增加行布局缓存。普通文本元素和 GPUI Kit 控件会替你使用这些服务。编写自定义文本几何、图表标签、编辑器,或需要直接使用字形位置的元素时,才从 `window.text_system()` 入手。 文本渲染是一条连续的流程: 1. 将 `Font` 解析为 `FontId`,请求的字体不可用时尝试回退。 2. 把 UTF-8 文本与带样式的 `TextRun` 塑形为带位置的字形 run 和行度量。 3. 根据可用宽度换行、测量。 4. 用同一布局进行命中测试,并在窗口中绘制字形。 文本塑形不可省略,因为字符串宽度通常不是各字符独立宽度的简单相加。文字系统、连字、字距调整、字体回退和 emoji 都可能改变字形数量、位置与 advance。自定义标签要测量**实际塑形后的文本**,不要用字符数乘以平均宽度。 ## 跟做:从文本到像素 使用已有的 `hello_world` 示例做一个短练习。将 `examples/hello_world/src/main.rs` 替换为下面的代码,在仓库根目录运行 `cargo run -p hello_world`。界面仍用普通文本元素显示标签;同一个窗口的文本系统计算这一行塑形后的 advance,并把结果显示出来,便于修改样本观察变化。在实际应用中,如果文本固有尺寸决定布局,应把测量放在布局阶段;不要只为了打印数字就在每次 `render` 时重复昂贵测量。 ```rust use gpui_kit::*; struct TextLab; impl Render for TextLab { fn render(&mut self, window: &mut Window, _: &mut Context) -> impl IntoElement { let label: SharedString = "Office fi café 👋".into(); let font_size = px(18.); let run = TextRun { len: label.len(), // UTF-8 bytes, not character count. font: window.text_style().font(), color: window.text_style().color, ..Default::default() }; let line = window .text_system() .shape_line(label.clone(), font_size, &[run], None); div() .flex() .flex_col() .gap_2() .p_4() .text_size(font_size) .child(label) .child(format!("Shaped advance: {:.1} logical px", line.width().as_f32())) } } fn main() { application().with_assets(assets::Assets).run(|cx| { init(cx); open_window(WindowOptions::default(), cx, |_, cx| cx.new(|_| TextLab)) .expect("Failed to open window"); }); } ``` 窗口应显示单行样本文本,第二行以 `Shaped advance:` 开头。数字表示**第一行塑形后的逻辑像素宽度**,不是帧耗时,也不是外层 `div` 的宽度;具体数值取决于平台及实际解析到的字体。先把样本改为 `"iiii"`,再改为 `"WWWW"`,重新运行后应看到不同的 advance。再试 `"café 👋"`:`label.len()` 的字节数会大于屏幕上看到的字符数。不要给这个单行 API 传入 `\n`;多行接口见后文。 ### 自己绘制已塑形的文字 第一个练习在 `render` 中测量,方便把结果打印出来。要观察底层绘制路径,请再次替换同一个 `examples/hello_world/src/main.rs`,在仓库根目录运行 `cargo run -p hello_world`。`canvas` 提供 prepaint 和 paint 两个回调,无须另写一套 `Element` 实现: ```rust use gpui_kit::*; struct CanvasTextLab; impl Render for CanvasTextLab { fn render(&mut self, window: &mut Window, _: &mut Context) -> impl IntoElement { let sample: SharedString = "Office fi café 👋".into(); let canvas_text = sample.clone(); let font = window.text_style().font(); let color = window.text_style().color; let font_size = px(18.); let line_height = px(24.); div() .flex() .flex_col() .gap_2() .p_4() .text_size(font_size) .child("Normal text element:") .child(sample) .child("Shaped and painted in a canvas:") .child( canvas( move |_, window, _| { let run = TextRun { len: canvas_text.len(), font, color, ..Default::default() }; window .text_system() .shape_line(canvas_text, font_size, &[run], None) }, move |bounds, line, window, cx| { let advance = line.width(); line.paint(bounds.origin, line_height, TextAlign::Left, None, window, cx) .expect("Failed to paint shaped text"); let marker = Bounds { origin: point(bounds.origin.x + advance, bounds.origin.y), size: size(px(1.), line_height), }; window.paint_quad(fill(marker, color)); }, ) .w(px(320.)) .h(line_height), ) } } fn main() { application().with_assets(assets::Assets).run(|cx| { init(cx); open_window(WindowOptions::default(), cx, |_, cx| cx.new(|_| CanvasTextLab)) .expect("Failed to open window"); }); } ``` canvas 在普通文本元素下方绘制相同样本。细竖线位于这一行的 **advance** 处,也就是塑形后的笔位位置,不一定是最后一个字形可见像素的边缘。`canvas` 在布局阶段申请固定的 `320 × 24` 空间;边界确定后,prepaint 回调塑形并返回 `ShapedLine`,paint 回调使用**同一结果**调用 `width()` 和 `paint(...)`。修改代码中的样本并重新运行,观察竖线移动。特别长的样本可能超出固定宽度;本练习没有实现换行或裁剪。 如果普通文字能显示而 canvas 的文字消失,请依次检查 canvas 高度、`TextRun::len` 的字节数,以及 `line.paint` 的返回值。canvas 绘制的这行文字没有文本节点的无障碍 identity、选区、hitbox 或键盘行为。界面文案应使用普通文本元素。若文字宽度决定自定义元素**申请的尺寸**,应在测量布局回调中塑形,而不是使用这个固定尺寸的 canvas;[Element](./element) 解释了这条布局边界。 实际视图可按需求递进:普通标签 → 只有固有布局需要时才计算塑形宽度 → 只有自定义绘制需要时才在元素 paint 阶段复用塑形字形。拥有自己布局或子元素的完整生命周期见 [Element](./element)。标签、按钮和普通段落不需要直接调用 `TextSystem`。 ## 优先使用文本元素 普通界面文案应使用文本 child,让 GPUI 负责布局与绘制: ```rust use gpui_kit::*; div() .font_family(".SystemUIFont") .text_size(px(14.)) .child(text!("Recent activity")) ``` `text!(...)` 创建 `Text` 元素,并用宏调用的源码位置生成 ID。GPUI 可借此向无障碍树提供标签,并在 ID 稳定时报告内容更新。同一个调用位置生成多个标签时,可用 `text!(id = "activity-label", value)` 显式指定 ID;同时显示的元素各自需要不同 ID。普通 `.child("Recent activity")` 也会排版和绘制文字,但没有文本节点 ID。两种写法都不会在 `render` 中直接返回测量宽度:GPUI 在元素的测量布局回调中塑形,在 prepaint 中记录边界,再在 paint 中绘制。 GPUI Kit 的 [TextView](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/text/compat.rs) 能渲染带样式 run、选择与可选滚动的 Markdown 或 HTML;解析、布局和选择行为由 Base 承担。富文档可用 `TextView::markdown("article", source)`。[Input](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/input/base/element.rs) 在 [`prepaint`](./element#prepaint) 中塑形可见行,此时已知最终宽度与输入几何;光标和指针映射使用同一份塑形布局。普通标签不需要自行实现这些工作。 ## 字体解析与度量 `Font` 包含字体族,以及 weight、style、features 和可选的 fallback。`font("Family")` 构造字体;`Font::default()` 请求 `.SystemUIFont`。`cx.text_system().resolve_font(&font)` 返回 `FontId`;请求的字体族无法加载时,会尝试 GPUI 的回退栈。若所有回退都失败,则会 panic。`all_font_names()` 列出可用字体族,包括由 `add_fonts(...)` 注册的字体。应在第一帧之前注册打包字体,让第一次布局使用预期度量;之后再添加字体会使已缓存的字体解析和行布局失效,而已经开始的布局可能仍使用旧字体集合。 对已解析字体及 `Pixels` 字号,GPUI 提供 `ascent`、`descent`、`cap_height`、`x_height`、`bounding_box`、`advance` 和 `typographic_bounds`。这些 API 回答的问题不同:`advance(font_id, size, ch)` 给出该字体中单个字符对应字形的笔位移动;`typographic_bounds(font_id, size, ch)` 给出该字形的排版边界;ascent 与 descent 描述垂直度量。前两者不会对字符串塑形,也不会应用字体回退。`WindowTextSystem::layout_width(font_id, size, ch)` 只塑形一个字符,适合测量空格或特定单元格,不适合推算整句。 最终显示取决于已安装字体和平台回退。桌面上的 GPUI 会从操作系统字体集合中解析,请求的字体族可能落到另一种可用字体。Web 构建不能假定可访问桌面系统字体,必须注册所需字体。如果换行或对齐很重要,应在目标平台检查拉丁文、中日韩文字、emoji 与混合文字的代表样本。 ## 塑形单行文本 `TextRun::len` 以 **UTF-8 字节**计数,所有 run 合起来应覆盖要绘制的文本;对 `shape_text` 也包括换行符字节。只在合法 UTF-8 边界拆分 run。每个 run 指定其字节范围内的字体、颜色、背景、下划线和删除线。文本参数是 [SharedString](./shared-string)。下面的例子沿用 [Plot label](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/plot/label.rs) 的做法: ```rust use gpui_kit::*; fn shape_label(text: SharedString, color: Hsla, window: &mut Window) -> ShapedLine { let run = TextRun { len: text.len(), font: window.text_style().font(), color, background_color: None, underline: None, strikethrough: None, }; window.text_system().shape_line(text, px(14.), &[run], None) } // In a render, layout measurement, or paint method with a `window`: let width = shape_label("Recent activity".into(), color, window).width(); ``` 这个 helper 可以复制到已有 GPUI view 或元素中使用,其中 `window` 和 `color` 已由外层提供;它不是独立的 `main`。上面的 [canvas 练习](#自己绘制已塑形的文字)给出了完整的塑形到绘制应用。生产实现可参考 [`crates/component/src/plot/label.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/plot/label.rs):`measure_text_width` 使用同一塑形方式,`PlotLabel::paint` 调用 `ShapedLine::paint(origin, line_height, align, align_width, window, cx)`。 `shape_line(text, font_size, runs, force_width)` 返回 `ShapedLine`:`width()` 是塑形后的 advance,结果还包含原文、带位置的字形、字体 ID、ascent、descent 和装饰 run。除非自定义布局有意指定宽度,否则 `force_width` 传 `None`。`shape_line` 只处理**一行**,不要传入包含 `\n` 的文本。只需要几何信息时,`layout_line(&str, size, runs, force_width)` 返回 `Arc`;如果还要绘制,直接选用 `shape_line`。 advance 是画完一行后笔位移动的距离,不一定等于有色像素的外接矩形。字形可能伸出 advance,`font_size` 也不能单独决定一行的高度。垂直定位要结合该行的 ascent、descent 和选定的行高。需要裁剪框或命中区域时,应按实际放置的行和交互区域计算,不要把 `width()` 当作字形墨迹边界。 `LineLayout::x_for_index(byte_index)`、`index_for_x(x)` 与 `closest_index_for_x(x)` 可用于光标和命中测试。索引是原始文本中的 UTF-8 字节位置,不是 Unicode 字符数,也不是视觉列数。`x_for_index` 返回行内局部 x;`index_for_x` 在行宽处及其右侧返回 `None`,`closest_index_for_x` 则选一个最接近的插入边界。画光标时,把绘制原点加到局部 x 上;处理指针时,先减去同一个原点再查询布局。坐标使用 GPUI 的[逻辑像素](./geometry)。选择边界应保持在合法文本边界上;字形或连字不一定对应单个字符。测量、光标定位与绘制应使用同一塑形结果、字号、行高和对齐方式,才能一致。 ## 多行换行 文本包含换行符或需要软换行时,使用 `shape_text(text, font_size, runs, wrap_width, line_clamp)`。它返回 `Result>`。`wrap_width: Some(width)` 指定可用宽度。每个 `WrappedLine` 对应两个显式 `\n` 之间的一行源文本,内部可包含多个视觉行。`WrappedLine::size(line_height)` 包含这些视觉行的高度;`position_for_index(index, line_height)` 与 `closest_index_for_position(local_point, line_height)` 可在局部几何和**该源行内**字节偏移之间转换。要得到全文偏移,还要加上源行起始字节位置,并计算跳过的换行符字节。`line_clamp` 限制软换行边界,但 `shape_text` 仍会为每个显式换行分隔的源行返回一个 `WrappedLine`,不能保证结果总共不超过 N 行。宽度或字体变化会改变换行边界,因此应重新计算布局。 普通段落应交给 GPUI 文本元素或 GPUI Kit `TextView`。只有现有元素无法满足字形级定位、绘制或命中测试需求时,自定义元素才应直接塑形。 ## 对齐 GPUI 渲染阶段 GPUI 的 [Render](./render) 流程分开布局、prepaint 和 paint;自定义文本工作应放在对应阶段: | 阶段 | 文本工作 | 原因 | | --- | --- | --- | | `request_layout` | 声明样式和布局节点;固有文本尺寸或换行决定申请尺寸时,使用测量布局回调。 | 回调能拿到宽度约束,但此时还没有最终 `Bounds`。 | | `prepaint` | 用已解析边界放置准备好的行、计算光标几何并建立 hitbox;若自定义元素此时才知道内容宽度,就在这里塑形。 | 输入几何必须对应当前帧。 | | `paint` | 在准备好的原点绘制 `ShapedLine` 或 `WrappedLine`。 | 重用测量阶段或 prepaint 准备的塑形结果,使像素与命中测试一致。 | 普通 GPUI 文本元素在测量布局回调中塑形,在 prepaint 中记录边界,在 paint 中绘制准备好的 `WrappedLine`。[Input element](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/input/base/element.rs) 则在 prepaint 中塑形可见编辑行,因为它要结合已解析的视口和滚动几何。`ShapedLine::paint` 提交一行;若 run 有背景,要单独调用 `paint_background(...)`,并处理两者的 `Result`。`WrappedLine::paint` 的可选对齐宽度参数是 `Bounds`,而 `ShapedLine::paint` 用 `Option`。绘制文字本身不会建立 hitbox、键盘焦点或无障碍名称;可交互的自定义文本元素也要实现这些契约。自定义绘制详见 [Paint](./paint)。 以图表标签为例,先问标签宽度是否会改变图表申请的尺寸。如果会,在测量布局回调中塑形并把结果带到后续阶段;prepaint 根据最终边界放置它,paint 绘制同一结果。如果图表矩形尺寸已固定,标签只是需要在其中定位,就在 prepaint 塑形并定位。两种情况都要让指针映射与绘制使用相同的行和原点。上面的练习在 `render` 中测量仅为观察数值,不能替代这些元素阶段。 ## 排查文本不一致 | 症状 | 先检查 | 后续处理 | | --- | --- | --- | | 第一次布局解析字体时 panic | 初始化后查看 `cx.text_system().all_font_names()`。Web 上检查字体字节是否在打开窗口前注册。 | 选用可用字体族,或注册目标字体;详见[字体排错](./fonts#排查字体问题)。 | | 字体外观意外,或不同机器的宽度不同 | 对照请求的字体族、可用字体列表和字体文件内部的 family。字体族存在不代表覆盖所有字形。 | 在目标平台核对拉丁文、中日韩文字和 emoji;需要稳定结果时打包所需字重和字体。 | | 测量宽度与绘制标签不一致 | 对比两条路径的字体、features、字号、原文和 `force_width`。父元素的 `.text_size(...)` 不会自动修改 `shape_line` 显式传入的字号。 | 用一致的输入塑形,并复用结果完成定位和绘制。 | | 光标或点击落在错误字符处 | 索引是否为 UTF-8 字节偏移?是否恰好一次加上或减去行的原点? | 对同一份行布局使用 `x_for_index` 和 `closest_index_for_x`,索引保持合法边界。 | | 最后一个字形被切掉或行高不够 | 是否把 `width()` 当作字形墨迹边界,或把 `font_size` 当成完整行高? | 为字形伸出部分留空间;分配和裁剪时结合行度量与选定行高。 | | 字体加载后段落换行改变 | 检查注册是否发生在首次布局之后,以及窗口是否刷新。 | 尽量在首帧前注册;之后才调用 `add_fonts` 时,调用 `cx.refresh_windows()` 并重新测量。 | ## 缓存与性能边界 `WindowTextSystem` 在当前帧和上一帧的缓存中保存行布局,并通过 `TextSystem` 共享字体解析及度量。共享系统还缓存字形光栅边界;绘制使用的字形参数包含字体、字号、缩放系数和子像素位置。缓存键包含文本、已解析的字体 run、字号与宽度约束。相同输入可复用布局;改变文本、字体、features、字号、强制宽度或换行宽度,可能需要新布局。绘制颜色和背景属于装饰 run,与字形位置分开;只改变它们未必需要重新塑形。帧缓存不保证布局会被无限期保留。若预先知道会用到哪些字体,可在后台 executor 上调用 `TextSystem::prewarm_fonts(&fonts)` 准备平台字体缓存;普通塑形仍会按需填补缺失项。应复用窗口文本系统,也不要保存跨帧的 `&mut Window` 引用。 大型虚拟化编辑器或文档只应塑形可见内容,避免每次重绘都测量所有行。GPUI Kit 的 [TextView](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/text/text_view.rs) 可虚拟化可滚动的块,[Input](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/input/base/element.rs) 则塑形可见行。如果性能分析显示长行文本反复被物化,`try_layout_line_by_hash` 可在不构造连续字符串的情况下探测缓存,而 `shape_line_by_hash` 只在缓存未命中时构造文本。使用后者时,返回的 `ShapedLine.text` 始终是空占位;若还需要原文,应单独保存。调用方必须保证相同 hash 对应完全相同的文本,并将其 UTF-8 字节长度传作 `text_len`。在实际测得这项开销之前,优先使用普通 API。 --- # Context Source: /versions/v0.7.1/zh-CN/docs/context 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/context 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 GPUI callback 中经常出现 `window: &mut Window, cx: &mut Context`。这些参数由 GPUI 在调用期间提供,让代码访问当前窗口、当前 Entity 与整个应用,同时确保可变访问不会超出这次调用。 先区分各自的作用范围: | 类型 | 作用范围 | 常见能力 | | --- | --- | --- | | `Window` | 当前系统窗口 | Focus、输入、窗口尺寸、绘制、Action 派发 | | `Context` | 当前正在更新的 `Entity` | `self` 对应的 Entity、`notify`、订阅、Entity task | | `App` | 整个应用 | Global、创建 Entity、打开窗口、应用级 Action 与 task | | `AsyncApp` | 跨越 `await` 的前台任务句柄 | 通过一次简短的 update 重新进入 `App` 或 Entity | | `AsyncWindowContext` | 指向某个窗口的异步句柄 | 重新进入 Entity 及其 Window | 可以按生命周期理解这张表:`application().run` 首先提供 `App`;打开窗口后才有 `Window`;创建 Entity 后,GPUI 在构造或更新该 Entity 时提供它的 `Context`。前台异步任务得到的是异步句柄,而不是跨越 `await` 的同步 context 借用。 | 代码运行位置 | GPUI 提供的参数 | 适合处理的事 | | --- | --- | --- | | `application().run`、应用级 callback | `&mut App` | 初始化 kit、设置 Global、创建 Entity、打开窗口 | | 窗口构造闭包、Element 或组件 callback | `&mut Window`、`&mut App` | 操作该窗口及应用状态;需要所属 View 时使用 `cx.listener` | | `Render` 或 Entity update | `&mut Self`、`&mut Context`;`Render` 和 `update_in` 另有 `&mut Window` | 读取或修改当前 View;可见状态变化时调用 `notify` | | `App::spawn` / `Context::spawn` task | `&mut AsyncApp`;Entity task 还有 `WeakEntity` | `await` 后短暂重新访问应用或 Entity | | `Context::spawn_in` task | `&mut AsyncWindowContext` 和 `WeakEntity` | 重新进入还需要原窗口的操作 | `Context` 会解引用为 `App`,所以拿到 `cx: &mut Context` 时已经可以调用 App API,不需要再传一个 `&mut App`。它还知道当前是哪一个 Entity;普通的 `App` 不知道。`Window` 必须单独传入,因为同一个 Entity 可能显示在不同窗口中,而纯数据更新也可能不属于任何窗口。Window 还管理由 [ElementId](./element_id) 标识的窗口内状态。异步 context 是句柄,不是可以长期持有的 `&mut App` 或 `&mut Window` 引用。 下文使用的 Entity 专用方法定义在 [GPUI `Context`](https://docs.rs/crate/gpui-pre/0.3.8/source/src/app/context.rs) 源码中。 GPUI Kit 应用只依赖 `gpui-kit`,通过 `use gpui_kit::*;` 导入 GPUI API。创建基于组件的 View 之前调用 `gpui_kit::init(cx)`。应用级 [Global](./global) 属于 `App`;组件或功能 View 的持久状态放在 [Entity] 中。 ## 在默认浏览器中打开 URL 使用 `cx.open_url(...)` 将 URL 交给平台默认浏览器。它是 `App` API,因此 `cx` 为 `Context` 时也可以调用;Button callback 收到的 `&mut App` 同样可以调用: ```rust use gpui_kit::component::button::Button; Button::new("open-docs") .label("Open docs") .on_click(|_, _, cx| cx.open_url("https://gpui-kit.com/docs")) ``` 这会打开外部浏览器。若浏览器内容必须显示在 GPUI window 内,参见 [WebView](./webview)。`open_url` 不返回完成结果。GPUI Kit 的 `Link` 设置 `href` 后,点击时也会调用 `cx.open_url(...)`。 ## `window, cx` 与只有 `cx` View 的状态属于 Entity,窗口交互属于 Window。一个方法既要修改 View 状态,又要操作这个 View 所在的窗口时,就同时接收两者。按照 GPUI 风格,它们放在参数列表最后,并保持 `window, cx` 的顺序: ```rust fn focus_input(&mut self, window: &mut Window, cx: &mut Context) { self.composer_open = true; self.input_focus.focus(window, cx); cx.notify(); } ``` Action、Event 或鼠标 callback 可能在前面带有 `action`、`event` 等参数,但运行时参数仍放在最后: ```rust fn on_action_send_message( &mut self, action: &SendMessage, window: &mut Window, cx: &mut Context, ) { // ... } ``` 如果方法只处理数据,不读取 Focus、输入、窗口尺寸或其他窗口状态,就只保留最后一个 `cx` 参数: ```rust fn clear_messages(&mut self, cx: &mut Context) { self.messages.clear(); cx.notify(); } ``` 当前没有 Entity,只执行应用级工作时,callback 会直接接收 `&mut App`。例如应用初始化、注册全局状态或打开第一个窗口。不要为了统一签名加入未使用的 Window;函数参数应该直接反映逻辑真正依赖的范围。 异步代码使用对应的 `AsyncApp` 或 `AsyncWindowContext`,在 `await` 以后重新进入 GPUI。Window 的具体能力见 [Window](./window)。 ## 需要访问 View 的 callback GPUI Kit `Button` 的点击 handler 接收 `(&ClickEvent, &mut Window, &mut App)`,不会直接收到 View 的 `&mut Self`。在 View 的[渲染](./render)代码中用 `cx.listener` 创建 handler;GPUI 随后会更新该 View,并把它的 `Context` 交给内层闭包。组件还提供键盘与[无障碍](./accessibility)行为: 要运行下面的完整示例,将代码放入仓库现有 `hello_world` package 的 `examples/hello_world/src/main.rs`,替换该文件原有内容。在仓库根目录运行 `cargo run -p hello_world --bin hello_world`。 ```rust use gpui_kit::*; use gpui_kit::assets::Assets; use gpui_kit::component::button::Button; struct Counter { count: usize, } impl Render for Counter { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { div().child( Button::new("increment") .label(format!("Count: {}", self.count)) .on_click(cx.listener(|this, _event, _window, cx| { this.count += 1; cx.notify(); })), ) } } fn main() { application() .with_assets(Assets) .run(|cx| { init(cx); open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| Counter { count: 0 }) }) .expect("failed to open window"); }); } ``` 窗口最初显示 `Count: 0`。点击按钮一次后,标签应变成 `Count: 1`;继续点击会逐次加一。这可以检查 listener 修改的是同一个 `Counter` Entity,且 `cx.notify()` 让新值显示出来。练习结束后,如需保留原示例,请恢复原来的 `main.rs`。 外层 callback 的类型由按钮决定。内层闭包收到 `&mut Counter` 和 `&mut Context`。`cx.listener` 使用 View 的弱句柄,因此保存的 handler 不会让已关闭的 View 继续存活。计数属于 Entity 状态,`cx.notify()` 告诉 GPUI 渲染新值。不要在 `render` 中反复创建 View;拥有它的 `Entity` 在窗口打开时创建。 ## 创建、读取和更新 `cx.new` 创建 [Entity],构造闭包会拿到新 Entity 自己的 `Context`。强 `Entity` 句柄会让它保持存活。用句柄的 `read` 同步借用数据,或用 `update` 获得 `&mut T` 和该 Entity 自己的 context: ```rs struct Draft { text: String, } fn edit_draft(cx: &mut App) -> Entity { let draft = cx.new(|_| Draft { text: String::new() }); let was_empty = draft.read(cx).text.is_empty(); if was_empty { draft.update(cx, |draft, cx| { draft.text.push_str("Hello"); cx.notify(); }); } draft } ``` `read` 返回的引用不能超过 `App` 借用的生命周期。先结束读取,再用 `update` 申请可变访问;之后要用的数据应少量复制或 clone。强句柄的 `update` 直接返回闭包的结果。`WeakEntity` 可能已释放,因此其 `update` 和 `update_in` 返回 `Result`。通知变化或调用 Entity 专用方法时,使用 update 闭包内层传入的 `cx`。 `App` 和 `Context` 都提供 GPUI 的 `AppContext` API,因此都可以调用 `cx.new`。从 `Context` 创建子 Entity 时,构造闭包收到的是**新建子 Entity** 的 `Context`,并非父 Entity 的 context。若子 Entity 应跨越多次渲染存活,owner 应保存返回的强 `Entity` 句柄。 `cx.notify()` 表示当前 Entity 已改变。它会安排依赖它的 View 重新 render,并触发 `observe` callback;只修改字段不会产生这些效果。 读取数据不需要 `notify`。不要在 `render` 中无条件调用它,否则可能持续安排重新渲染。一次操作需要修改多个相关字段时,先完成修改,再通知一次。 不要让 `read` 返回的引用跨越 `await`,应先 clone 任务需要的少量数据。不要把 `&mut App`、`&mut Window` 或 `&mut Context` 保存在 View 或 task 中;它们只在当前 GPUI 调用期间有效。 Entity 已经处于 `render` 或 `update` 时,不要再次通过 handle 访问同一个 Entity;GPUI 会阻止重入并 panic。直接使用已经传入的 `self` 和内层 `cx`。在 `cx.listener` 内也是如此:`this` 已经是当前 View。若某项工作必须等当前 callback 完成后才能更新这个 Entity,就推迟执行,不要立即通过它的句柄重入。 其他对象需要当前 Entity 的强 handle 时使用 `cx.entity()`。长期 callback 不应该阻止 View 释放时,使用 `cx.weak_entity()` 或 `downgrade()`。 ## 在当前更新结束后继续 `App::defer` 在当前 effect cycle 结束后运行应用级闭包。`window.defer(cx, ...)` 在稍后提供同一个 Window。对于 Entity,`cx.defer_in(window, ...)` 会等当前更新释放借用后,重新提供同一个 View、Window 和 `Context`: ```rust fn finish_edit(&mut self, window: &mut Window, cx: &mut Context) { cx.defer_in(window, |this, window, cx| { this.finish_edit_after_update(window, cx); }); } ``` 延迟闭包也已经收到 `this: &mut Self`,不要在其中再次对同一个 Entity 调用 `update`。当 UI 树变化后需要恢复 Focus 等操作必须等当前更新结束时,再使用 defer。[`window.on_next_frame(...)`](https://docs.rs/crate/gpui-pre/0.3.8/source/src/window.rs) 则为下一次平台 frame request 安排 callback,并唤醒 frame source。callback 在该次请求可能发生的绘制之前运行;仅注册它不会把窗口标为 dirty,也不会触发 render。如果 callback 改变了可见的 Entity 状态,应在 callback 内调用 `cx.notify()`。需要主动请求下一帧重绘时,使用 `window.request_animation_frame()`。窗口关闭或 View 释放后,延迟工作可能不会执行,因此不要把它当成持久任务队列。 ## 异步任务 `cx.spawn` 启动前台任务。从 `Context` 调用时,它会提供当前 Entity 的 `WeakEntity` 和 `AsyncApp`: ```rs self._load_task = cx.spawn(async move |this, cx| { let messages = fetch_messages().await?; this.update(cx, |chat, cx| { chat.messages = messages; cx.notify(); })?; anyhow::Ok(()) }); ``` 弱 handle 不会让 View 一直存活。如果 View 已释放,它的 `update` 会返回错误,因此需要处理或传播结果。 如果从 `App` 启动 `spawn`,就没有当前 Entity handle。闭包只会收到 `AsyncApp`;经过 `await` 后,可以用 `cx.update(|cx| ...)` 执行一次简短的应用级修改。`AsyncApp` 也提供基于闭包的 Global 访问,例如 `read_global` 和 `update_global`。它不会让异步任务持有跨越 `await` 的 `&mut App`。 任务结束时还需要同一个 Window,就使用 `spawn_in`。它提供 `AsyncWindowContext`,`update_in` 可以恢复 Window 和 Entity 访问: ```rs cx.spawn_in(window, async move |this, cx| { let message = send_to_server().await?; this.update_in(cx, |chat, window, cx| { chat.messages.push(message); chat.input_focus.focus(window, cx); cx.notify(); })?; anyhow::Ok(()) }) .detach(); ``` 异步工作结束后要更新 View 及其 Window 时,使用 `spawn_in` → `update_in`。CPU 密集工作使用 `background_spawn`;它不能直接更新 GPUI 状态,需要先把结果带回前台任务。 等待期间,`WeakEntity` 指向的 View 可能已释放,Window 也可能已关闭,因此 `update` 和 `update_in` 返回 `Result`,应予以处理。如果旧请求可能晚于新请求完成,应在应用结果之前比较 ID 或版本号。 ## Task 生命周期 GPUI 的 [Task](./task) handle 被 drop 时,任务会取消: ```rs struct Chat { _load_task: Task>, } ``` - 属于 View 的工作保存在 View 上,释放 View 时任务也会取消。 - 只有工作需要脱离调用者继续运行时才调用 `.detach()`。 - 替换已保存的刷新或 debounce 任务会取消旧任务。 ## observe 与 subscribe 另一个 Entity 调用 `cx.notify()` 时,`observe` 会响应;Entity 发出类型化 [Event] 时,`subscribe` 会响应: ```rs struct Chat { input: Entity, _subscriptions: Vec, } // In Chat::new: let _subscriptions = vec![ cx.observe(&input, |_, _, cx| cx.notify()), cx.subscribe(&input, |chat, input, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { chat.draft = input.read(cx).value().to_string(); cx.notify(); } }), ]; ``` 两者都会返回 `Subscription`。把它保存在订阅者 View 上,订阅就会与 View 拥有相同生命周期。`Subscription` 被 drop 会立即取消 callback;如果把它保存在生命周期更长的 owner 上,View 消失后 callback 与捕获的资源仍可能被保留,并造成内存泄漏。callback 还需要 `&mut Window` 时,使用 `observe_in` 或 `subscribe_in`。 发送应用事件时,先为发送事件的 Entity 类型实现 `EventEmitter`,再从它的 `Context` 调用 `cx.emit(event)`: ```rust struct SaveRequested; struct Draft; impl EventEmitter for Draft {} impl Draft { fn request_save(&mut self, cx: &mut Context) { cx.emit(SaveRequested); } } ``` 父 Entity 可以用 `cx.subscribe(&draft, ...)` 处理 `SaveRequested`;订阅指定事件类型,因此同一 Entity 可以发送多种事件。`observe` 由 `cx.notify()` 触发,`subscribe` 由 `cx.emit(...)` 触发。发送事件**不会**通知 observer 或重绘发送者;若发送者的可见状态也改变,仍要调用 `cx.notify()`。反过来,`notify` 也不会发送事件。观察应用级的值应使用 `observe_global` 并保存其 `Subscription`;参见 [Global]。 ## 常见错误 - Task 被 drop,导致任务过早停止。保存它,或者明确调用 `.detach()`。 - Subscription 只存在于局部变量,导致 observer 不响应。 - Task 或 callback 捕获了强 Entity handle,导致 View 无法释放。改用弱 handle。 - 在 `render` 或 `update` 内重入同一个 Entity,导致 GPUI 报告 Entity 已被借用。 - callback 只收到普通 `App`,却需要修改 View;应使用 `cx.listener`,不要手动寻找 View 再重入。 - UI 树变化后还需要操作当前 Entity;应使用 `cx.defer_in`,不要立即更新正在被借用的 Entity。 - 通过 `spawn` 启动的异步代码无法访问 Window。改用 `spawn_in` 和 `update_in`。 - 让 borrow 跨越了 `await`。先取出自有数据,之后通过 `update` 或 `update_in` 重新访问状态。 - 状态改变后没有调用 `cx.notify()`,导致界面没有更新。 无论 Context 的具体类型是什么,GPUI 通常都把参数命名为 `cx`,并把 Window 参数命名为 `window`。 [Entity]: ./entity.md [Event]: ./event.md [Global]: ./global.md --- # Focus Source: /versions/v0.7.1/zh-CN/docs/focus 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/focus 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 **Focus(焦点)**决定一个 [Window](./window) 中谁接收键盘输入。GPUI 沿当前获得 Focus 的元素及其祖先构成的路径路由 [Action 和按键绑定](./action)。鼠标按下可以移动 Focus,但画出控件或给它 `ElementId` 并不会自动建立焦点目标。本章以 `gpui-kit` 使用的 `gpui-pre` 0.3.8 API 为准;`gpui-pre` 是 GPUI 快照的发布与版本同步名称,不代表另一套渲染引擎。 ## 先运行现有示例 在仓库根目录运行: ```sh cargo run -p focus_trap ``` 源码是 [`examples/focus_trap/src/main.rs`](https://github.com/longbridge/gpui-kit/blob/main/examples/focus_trap/src/main.rs)。点击外侧按钮后按 Tab,Focus 按窗口中的普通顺序移动。点击任一编号区域里的按钮,再按 Tab 或 Shift+Tab,Focus 会在该区域的按钮间循环。示例在每个容器上调用 `.focus_trap(id, &handle)`;按钮是拥有各自焦点句柄的 GPUI Kit `Button`。它演示的是**进入区域之后的约束**,不是完整弹窗生命周期。 新应用应像示例一样,先调用 `gpui_kit::init(cx)`,再创建窗口。`gpui_kit::open_window` 提供 Base `Root`;普通 Tab、Shift+Tab 导航和 Kit 的焦点约束处理都依赖它。应用初始化步骤见 [Getting Started](./getting-started)。 ## 区分四个操作 | 操作 | 实际作用 | 不会自动完成的事 | | --- | --- | --- | | `cx.focus_handle()` | 创建供 owner 长期保存的稳定焦点目标。 | 不会注册已渲染元素,也不会移动 Focus。默认 `tab_stop` 为 `false`。 | | `.track_focus(&handle)` | 把句柄登记到已渲染的交互元素,建立 Focus 路由与聚焦样式所需的节点。 | 不会移动 Focus,也不会自动让句柄进入 Tab 顺序。 | | `handle.focus(window, cx)` 或 `window.focus(&handle, cx)` | 将窗口的当前 Focus 移到这个句柄。 | 不会创建已渲染节点或键盘 handler。 | | `cx.focus_handle().tab_stop(true)` | 使已登记的句柄可被 Tab 导航访问。 | 不会立即聚焦它。 | 句柄应归长期存在的 owner 所有,通常是 [`Entity`](./entity)。`Element` 每帧重新构建,应在每次 [`Render::render`](./render) 时重新附上**同一个**句柄。若每次渲染都新建句柄,焦点身份和 Tab 行为会在用户操作时变化。无状态 Kit 组件可用 `window.use_keyed_state(...)` 保留句柄;Kit 的 `Button` 就采用该方式。`ElementId` 可以作为保留状态的键,但它本身不是 Focus 目标。 ### 最小可聚焦 View 以下 View 可以放进 [Getting Started](./getting-started) 展示的窗口 builder。它创建一个 Tab stop,并显示自己是否拥有精确 Focus: ```rust use gpui_kit::*; struct FocusPanel { focus_handle: FocusHandle, } impl FocusPanel { fn new(cx: &mut Context) -> Self { Self { focus_handle: cx.focus_handle().tab_stop(true), } } } impl Focusable for FocusPanel { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } impl Render for FocusPanel { fn render(&mut self, window: &mut Window, _: &mut Context) -> impl IntoElement { let label = if self.focus_handle.is_focused(window) { "Focused" } else { "Press Tab or click here" }; div() .track_focus(&self.focus_handle) .p_4() .child(label) } } ``` 在窗口 builder 中用 `cx.new(FocusPanel::new)` 创建它。`Focusable` 让持有 Entity 的代码取得句柄,**不会**自动调用 `.track_focus(...)`。一个 View 即使实现了 `Focusable`,若渲染树没有登记对应元素,键盘路由仍无法抵达它。需要从回调主动移动 Focus 时,调用 `self.focus_handle.focus(window, cx)`;应在打开或进入区域时调用,不要每次 `render` 都无条件调用,否则可能夺走子控件或其他控件的 Focus。 ## 鼠标、Tab 和键盘命令 对调用了 `.track_focus(&handle)` 的元素,GPUI 默认在鼠标按下且命中它时聚焦该句柄。若嵌套子元素也登记了句柄,子元素可先获得 Focus,并阻止祖先默认转移;自定义鼠标处理若要自行控制 Focus,可调用 `window.prevent_default()`。只修改应用状态的鼠标回调,并不能替代键盘交互。 Tab 与 Shift+Tab 访问最近一次渲染帧登记的 **Tab stop**。默认 `FocusHandle` 不在其中。希望用户通过 Tab 访问时,在构造句柄时设置 `.tab_stop(true)`。GPUI 还提供 `FocusHandle::tab_index(n)` 以指定顺序;普通表单优先采用渲染顺序。向 `.track_focus(...)` 传入句柄时,Tab 配置要设在**句柄**上;元素另有的 `.tab_index(...)` 或 `.tab_stop(...)` 不会修改该句柄的设置。 Focus 到达已登记元素后,其 dispatch path 决定哪些 `key_context`、`on_action` 和按键绑定可用。例如,Editor 或其子元素拥有 Focus,且 Save handler 位于这条路径上,Editor 的 Save 绑定才可到达;兄弟元素的 handler 不在路径上。完整命令示例见 [Action](./action) 与 [KeyBinding](./keybinding)。自定义控件还需要可见的 Focus 样式,以及符合预期的键盘激活或导航行为;`track_focus` 只提供路由目标。语义和测试限制见 [Accessibility](./accessibility)。 ## 精确 Focus 与区域内 Focus 只有句柄本身聚焦才算活动状态时,使用 `handle.is_focused(window)`。句柄或其已登记子元素聚焦都应算活动状态时,使用 `handle.contains_focused(window, cx)`;例如,面板中的文本输入框获得 Focus,面板仍保持活动外观。包含关系来自**最近一次渲染的 dispatch tree**;两个句柄必须附在预期的父子元素上。仅有 Entity 的所有权关系无法建立它。 ```rust let panel_itself = self.focus_handle.is_focused(window); let panel_or_child = self.focus_handle.contains_focused(window, cx); ``` `window.focused(cx)` 返回当前窗口焦点目标的 `Option`,适合诊断,也可在打开弹层前保存之前的目标。返回的句柄之后可能仍在,但对应元素已不再渲染;恢复之前要检查 UI 生命周期。 ## 订阅焦点变化,而不是在 `render` 中反复注册 `Context` 提供 `on_focus`、`on_blur`、`on_focus_in` 和 `on_focus_out`。在构造 owner 时注册,并将返回的 [`Subscription`](./event) 保存在 owner 中。丢弃订阅即取消注册。前两个只关注指定句柄本身;`in`/`out` 也考虑已登记的子元素。 ```rust struct SearchPanel { focus_handle: FocusHandle, _subscriptions: Vec, active: bool, } impl SearchPanel { fn new(window: &mut Window, cx: &mut Context) -> Self { let focus_handle = cx.focus_handle(); let entered = cx.on_focus_in(&focus_handle, window, |this, _, cx| { this.active = true; cx.notify(); }); let left = cx.on_focus_out(&focus_handle, window, |this, _, _, cx| { this.active = false; cx.notify(); }); Self { focus_handle, _subscriptions: vec![entered, left], active: false, } } } ``` 在面板渲染根元素上调用 `.track_focus(&self.focus_handle)`。只有当其他状态需要随 Focus 变化时才需要订阅;简单的聚焦样式通常可以在渲染时读取 `is_focused`。若每次 `render` 都注册 listener,会累积重复回调。 ## 在弹层中约束并恢复 Focus GPUI Kit 为交互容器提供 `.focus_trap(id, &container_handle)`。Base `Root` 处理 Tab 或 Shift+Tab 时,先检查当前 Focus 是否处在该容器内;若普通导航会离开,就寻找容器内的其他 Tab stop。`id` 要稳定,句柄要跨渲染保留,容器内要有可用的子 Tab stop。 ```rust div() .child(first_button) .child(second_button) .focus_trap("settings-dialog", &self.dialog_focus_handle) ``` **单独设置 trap 不会打开模态窗口、自动进入第一个控件、阻止鼠标聚焦外部控件,也不会在关闭后恢复 Focus。**完整模态流程应包含: 1. 用户打开弹层时保存 `window.focused(cx)`,由弹层 owner 保存此前的句柄。 2. 渲染弹层,在内部可用控件出现时把 Focus 移到它。明确选择初始目标,不要期待 trap 自动完成这一步。 3. 关闭时移除弹层,只在先前目标仍是合适的已渲染目标时恢复它。若目标已消失,选择当前存在的后备目标,例如打开弹层的按钮。协调更新和恢复时机,避免关闭中的弹层再次夺回 Focus。 4. 验证 Tab 的两个方向、Escape 或显式关闭、鼠标点击、禁用控件、嵌套弹层,以及原先目标已消失的情况。 GPUI Kit 的 `Dialog` 和 `Sheet` 自带其模态焦点行为;普通模态界面优先使用它们。手动 trap 适用于你明确负责进入、关闭和恢复生命周期的自定义容器。容器上的 `on_focus_out` listener 可用于观察 Focus 离开,但不能代替完整模态流程。 ## 验证与排错 先运行 `focus_trap` 示例,观察鼠标进入以及两个方向的 Tab 行为。应用测试应渲染真实 View,点击目标控件,发送 Tab 或命令按键,并断言 owner 最终状态。[Testing](./test) 介绍 Kit 的 UI 集成测试辅助方法;焦点范围需要明确的已登记句柄,检查结果才可靠。 | 症状 | 检查 | | --- | --- | | Tab 跳过自定义 View | 保留的句柄设置了 `.tab_stop(true)`,当前帧的元素调用了 `.track_focus(&handle)`。 | | 点击子控件却聚焦父容器 | 子控件有自己的已登记句柄;检查调用 `prevent_default` 的鼠标处理。 | | 快捷键只在点击后有效 | 预期句柄已聚焦、`key_context` 匹配、handler 位于聚焦 dispatch path。 | | 子控件聚焦时面板显得不活动 | 使用 `contains_focused`,并确认子元素在面板的已登记元素内部。 | | Tab 离开自定义 trap | Focus 已先进入 trap;容器通过 Kit 的 Base `Root` 渲染;子控件是真正的 Tab stop。 | | 关闭弹层后 Focus 消失 | 保存先前目标,并在关闭后恢复当前仍存在的已渲染目标。 | 相关章节:[Window](./window)、[Action](./action)、[KeyBinding](./keybinding)、[Accessibility](./accessibility) 和 [Testing](./test)。 --- # Style Source: /versions/v0.7.1/zh-CN/docs/style 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/style 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 GPUI 在构建 [Element](./element) 时设置样式。`Styled` trait 提供布局、间距、颜色、边框和文字的链式方法。许多名称有意对应 Tailwind CSS utility:`flex items-center gap-2 px-3` 在 Rust 中写成 `.flex().items_center().gap_2().px_3()`。可以借助这套词汇阅读和编写 GPUI 布局,但传入的是带类型的 Rust 值,而非 CSS class。 ```rust use gpui_kit::*; use gpui_kit::component::ActiveTheme as _; div() .flex() .items_center() .gap_2() .px_3() .py_2() .bg(cx.theme().background) .text_color(cx.theme().foreground) .child("Search results") ``` 链上的每次调用都会取得元素并返回元素。render 可以根据当前状态构建新树;需要长期保存的应用状态应放在 [`Entity`](./entity) 或带 key 的元素状态中。样式链描述本帧的外观,并非样式表或长期保存的组件实例。逐帧构建组件的方式见 [RenderOnce](./render-once)。 ## 从第一个布局开始 先找出负责可用空间的区域,再决定哪个子元素宽度固定、哪个可以伸展。用下面的完整程序替换 `examples/hello_world/src/main.rs`,然后在仓库根目录运行 `cargo run -p hello_world`: ```rust use gpui_kit::*; use gpui_kit::component::{h_flex, v_flex, ActiveTheme as _}; use gpui_kit::prelude::FluentBuilder as _; struct StyleExample; impl Render for StyleExample { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let compact = window.viewport_size().width < px(600.); let layout = if compact { v_flex() } else { h_flex() }; let documents = (1..=60).map(|number| { div().p_2().child(format!("Document {number:02}")) }); layout .items_stretch() .size_full() .bg(cx.theme().background) .text_color(cx.theme().foreground) .child( v_flex() .when(!compact, |nav| nav.w_64().flex_shrink_0()) .p_3() .gap_2() .child("Navigation") .child("Overview · Documents"), ) .child( v_flex() .flex_1() .min_w_0() .min_h_0() .child(h_flex().p_3().child("Documents")) .child( v_flex() .id("document-list") .flex_1() .min_h_0() .overflow_y_scroll() .child(v_flex().p_3().gap_2().children(documents)), ), ) } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| StyleExample) }) .expect("failed to open window"); }); } ``` 窗口较宽时,导航区域以固定的 `w_64()` 宽度位于左侧,文档面板占用剩余空间。把内容区域缩小到 600 逻辑像素以下:同一个导航区域会移到文档面板上方,而文档列表仍可独立滚动。滚到 `Document 60`,再向两个方向调整窗口宽度;列表滚动时,标题栏应保持在原位。这里的阈值是在视图 render 时求值的 Rust 条件,不是 Tailwind 响应式前缀。这个小练习中的导航只是示意文本;真实应用应提供可操作的导航控件。 `h_flex()` 建立横向布局,并默认让子元素沿交叉轴居中。`.items_stretch()` 覆盖这个默认值,让两个面板占满整行高度。`v_flex()` 建立纵向布局,子元素默认沿宽度方向拉伸。宽窗口中的导航面板保持固定宽度,文档面板占用剩余宽度。滚动区域占据标题栏下方的剩余高度。窗口大小为最外层的 `.size_full()` 提供确定高度,`min_h_0()` 则允许内部弹性区域收缩成滚动视口。 ### 确定尺寸该由谁负责 | 需求 | 设置位置 | 原因 | | --- | --- | --- | | 同级元素之间的距离 | 在父元素上设置 `.gap_3()` | gap 分隔子元素,不会在容器外边缘增加内边距。 | | 面板内部的留白 | 在面板上设置 `.p_3()` | padding 把内容向内推,并参与面板的布局尺寸。 | | 固定侧栏与弹性内容 | 侧栏 `.w_64().flex_shrink_0()`;内容 `.flex_1().min_w_0()` | 侧栏保持宽度,内容可收缩到小于文字自然宽度。 | | 标题栏下方的滚动内容 | 有确定高度的纵向容器;滚动子元素 `.flex_1().min_h_0()` | 子元素可缩进剩余高度,从而形成真正的滚动视口。 | | 父元素宽度的一半 | 在子元素上设置 `.w(relative(0.5))` | 布局时,比例按相关父尺寸解析。 | `w_full()` 和 `h_full()` 表示填满*可用*尺寸。百分比高度仍要求上层有确定高度。最小值和最大值限制最终尺寸;它们不会让没有高度约束的滚动区域自动形成视口。横向布局中的单行长标题可在标题容器上组合 `.flex_1().min_w_0().truncate()`。`.truncate()` 只改变文字溢出方式,无法让不肯收缩的同级元素腾出宽度。 ### 选择裁剪、滚动或定位 `.overflow_hidden()` 裁剪内容,但不会让内容滚动。在有状态的元素上,`.overflow_y_scroll()` 可在高度受限后启用纵向滚动。GPUI Kit 的 `.overflow_y_scrollbar()` 会加入可见滚动条,并以原元素作为滚动区域;它来自 `ScrollableElement` 扩展,而非 `Styled` 方法。每个滚动区域应有一个明确的负责元素。如果滚动条要贴着面板边缘,内容 padding 应放在滚动区域内部。滚动归属与测量方式见[编码指南](./coding-guides)。 普通 Flex 子元素占用布局空间。要让角标覆盖内容而不占一行或一列,可在容器上设置 `.relative()`,在角标上设置 `.absolute().top_0().right_0()`。偏移 setter 用于定位绝对子元素;它们不会自动将普通 Flex 子元素变成绝对定位。后面的同级元素通常绘制在前面的同级元素之上;通用 `Styled` 没有 `z_index(...)` 方法。 ### 主题与尺寸尺度 应用界面的颜色和圆角应从 `cx.theme()` 读取语义值。GPUI Kit 组件已应用正常的主题外观;实例样式可用于局部布局或有意的细化。具名间距和尺寸方法使用 rem 尺度:`_1` 为 `0.25rem`、`_2` 为 `0.5rem`、`_3` 为 `0.75rem`、`_4` 为 `1rem`。在 GPUI Kit 的 `Root` 中,当前主题的基础字号决定窗口的 rem 大小,因此字号或缩放变化也会改变基于 rem 的几何尺寸。命名尺度不合适时用 `.gap(rems(0.625))` 等带类型的 setter;只有确实需要像素尺寸时才使用 `px(...)`。长度类型见[几何](./geometry),主题与 rem 设置见[字体](./fonts)。 ### 排查布局结果 | 现象 | 检查方向 | | --- | --- | | 窗口缩窄后导航没有移到文档上方 | 把可绘制内容区域缩到 600 逻辑像素以下。条件在 `render` 中读取 `window.viewport_size().width`;仅改变显示器缩放倍率不会跨过这个逻辑像素阈值。 | | 无法滚到 `Document 60` | 把指针移到文档列表上并在该区域滚动。让有高度约束的列表区域拥有 `.id("document-list").overflow_y_scroll()`,并在该区域及外层纵向容器上保留 `.flex_1().min_h_0()`。 | | 滚动时标题栏也跟着移动 | 确认标题栏与列表视口是同级元素,不要把标题栏放进滚动元素内部。 | | 面板标题在顶部消失 | `h_flex()` 默认让子元素居中;让整行子元素拉伸,或让该面板占满高度。 | | 标题溢出而没有截断 | 在弹性子元素上用 `.min_w_0()` 解除最小宽度约束,并限制文字宽度。 | | 列表越过窗口而没有滚动 | 让祖先拥有确定高度,用 `.min_h_0()` 允许弹性子元素收缩,并在预期的视口上设置滚动。 | | 滚动条缩在面板边缘以内 | 检查哪个元素负责滚动,以及 padding 是否包在滚动元素外面。 | | 主题缩放后布局变化 | 重新检查基于 rem 的尺寸,以及仍沿用旧 rem 大小的测量缓存。 | ## 常用 `Styled` 方法 Tailwind 名称中的连字符在 GPUI 方法中写成下划线。有对应样式概念时,第一列链接到相应的 Tailwind CSS 官方参考页,并在新窗口打开。表中列的是 GPUI 方法名;可在实现 `Styled` 的值上调用,例如 `div().gap_2()`。这些表格覆盖 `Styled` 中各类独立操作,以及宏生成的通用 setter;大量数字变体按下文所述的方法族归纳,不逐个占用数千行。链接说明对应的样式概念,不表示 GPUI 与浏览器的行为完全相同。 ### 显示与可见性 | 方法 | 说明 | | --- | --- | | block | 使用块布局。 | | flex | 使用 Flexbox 布局。 | | grid | 使用 Grid 布局。 | | hidden | 从布局和绘制中移除元素。 | | invisible | 保留布局空间,但不绘制元素。 | | visible | 保留布局,并恢复绘制。 | ### Flexbox 与 Grid | 方法 | 说明 | | --- | --- | | flex_row | 沿横向排列 Flex 子项。 | | flex_col | 沿纵向排列 Flex 子项。 | | flex_wrap | 允许 Flex 子项换行。 | | flex_nowrap | 让 Flex 子项保持在同一行。 | | items_start | 让子项沿交叉轴向起点对齐。 | | items_center | 让子项沿交叉轴居中。 | | items_stretch | 让子项沿交叉轴拉伸。 | | self_center | 让当前子项沿父容器的交叉轴居中。 | | self_stretch | 让当前子项沿父容器的交叉轴拉伸。 | | justify_center | 让子项沿主轴居中。 | | justify_between | 把主轴剩余空间分配到子项之间。 | | content_between | 沿交叉轴分配换行后的各行。 | | flex_basis | 设置带类型的主轴初始尺寸。 | | flex_1 | 以零 Flex basis 伸长或收缩。 | | flex_auto | 以自动 basis 为起点伸长或收缩。 | | flex_grow_1 | 允许 Flex 子项伸长。 | | flex_shrink_0 | 阻止 Flex 子项收缩。 | | grid_cols | 设置指定列数的 Grid 模板。 | | grid_rows | 设置指定行数的 Grid 模板。 | | col_span | 跨越指定数量的 Grid 列。 | | row_span | 跨越指定数量的 Grid 行。 | | flex_row_reverse | 反向排列横向子项。 | | flex_col_reverse | 反向排列纵向子项。 | | flex_wrap_reverse | 反向排列换行后的 Flex 行。 | | items_end | 让子项沿交叉轴向终点对齐。 | | items_baseline | 按文字基线对齐子项。 | | self_start | 让当前子项沿交叉轴向起点对齐。 | | self_end | 让当前子项沿交叉轴向终点对齐。 | | self_flex_start | 让当前子项向 Flex 起点对齐。 | | self_flex_end | 让当前子项向 Flex 终点对齐。 | | self_baseline | 按文字基线对齐当前子项。 | | justify_start | 让子项向主轴起点聚集。 | | justify_end | 让子项向主轴终点聚集。 | | justify_around | 在子项周围分配空间。 | | justify_evenly | 沿主轴均匀分配间距。 | | content_normal | 使用默认的交叉轴行排列。 | | content_start | 让换行后的各行向交叉轴起点聚集。 | | content_center | 让换行后的各行沿交叉轴居中。 | | content_end | 让换行后的各行向交叉轴终点聚集。 | | content_around | 在换行后的各行周围分配空间。 | | content_evenly | 在换行后的各行之间均匀分配空间。 | | content_stretch | 沿交叉轴拉伸换行后的各行。 | | flex_initial | 使用自动 basis,可收缩但不伸长。 | | flex_none | 同时禁止 Flex 伸长与收缩。 | | flex_grow | 设置数字形式的 Flex 伸长系数。 | | flex_grow_0 | 禁止 Flex 伸长。 | | flex_shrink | 设置数字形式的 Flex 收缩系数。 | | flex_shrink_1 | 允许 Flex 收缩。 | | grid_cols_min_content | 以 min-content 为最小值创建列。 | | grid_cols_max_content | 以 max-content 为约束创建列。 | | grid_rows_min_content | 以 min-content 为最小值创建行。 | | grid_rows_max_content | 以 max-content 为约束创建行。 | | col_start | 设置 Grid 列起始线。 | | col_start_auto | 自动选择列起始位置。 | | col_end | 设置 Grid 列结束线。 | | col_end_auto | 自动选择列结束位置。 | | col_span_full | 跨越完整的 Grid 列范围。 | | row_start | 设置 Grid 行起始线。 | | row_start_auto | 自动选择行起始位置。 | | row_end | 设置 Grid 行结束线。 | | row_end_auto | 自动选择行结束位置。 | | row_span_full | 跨越完整的 Grid 行范围。 | ### 间距与尺寸 | 方法 | 说明 | | --- | --- | | gap_2 | 行间隙和列间隙均设为 `0.5rem`。 | | gap | 在双轴设置带类型的间隙。 | | gap_x_2 | 列间隙设为 `0.5rem`。 | | gap_y_2 | 行间隙设为 `0.5rem`。 | | p_4 | 四边内边距设为 `1rem`。 | | p | 四边设置带类型的内边距。 | | px_3 | 水平内边距设为 `0.75rem`。 | | py_2 | 垂直内边距设为 `0.5rem`。 | | mt_4 | 上外边距设为 `1rem`。 | | m | 四边设置带类型的外边距。 | | m_auto | 四边外边距设为自动。 | | w | 设置带类型的宽度。 | | w_full | 填满可用宽度。 | | h | 设置带类型的高度。 | | h_full | 填满可用高度。 | | min_w | 设置带类型的最小宽度。 | | min_w_0 | 允许宽度收缩到零。 | | max_w | 设置带类型的最大宽度。 | | size | 同时设置带类型的宽度与高度。 | | size_4 | 宽度和高度均设为 `1rem`。 | | aspect_square | 保持 1:1 的宽高比。 | | mt | 设置带类型的上外边距。 | | mb | 设置带类型的下外边距。 | | mx | 设置带类型的水平外边距。 | | my | 设置带类型的垂直外边距。 | | ml | 设置带类型的左外边距。 | | mr | 设置带类型的右外边距。 | | px | 设置带类型的水平内边距。 | | py | 设置带类型的垂直内边距。 | | pt | 设置带类型的上内边距。 | | pb | 设置带类型的下内边距。 | | pl | 设置带类型的左内边距。 | | pr | 设置带类型的右内边距。 | | gap_x | 设置带类型的列间隙。 | | gap_y | 设置带类型的行间隙。 | | min_h | 设置带类型的最小高度。 | | max_h | 设置带类型的最大高度。 | | aspect_ratio | 设置数字形式的宽高比。 | ### 定位与溢出 | 方法 | 说明 | | --- | --- | | relative | 保持正常布局位置,并作为定位祖先。 | | absolute | 用 inset 偏移,相对祖先定位。 | | inset | 四边设置带类型的偏移。 | | inset_0 | 上、右、下、左偏移均设为零。 | | top | 设置带类型的上方偏移。 | | top_0 | 上方偏移设为零。 | | overflow_hidden | 在双轴裁剪溢出的内容。 | | overflow_x_hidden | 只裁剪水平溢出。 | | overflow_y_hidden | 只裁剪垂直溢出。 | | bottom | 设置带类型的下方偏移。 | | left | 设置带类型的左方偏移。 | | right | 设置带类型的右方偏移。 | | scrollbar_width | 在滚动布局中预留带类型的滚动条宽度。 | ### 颜色与边框 | 方法 | 说明 | | --- | --- | | bg | 设置带类型的背景填充。 | | text_color | 设置带类型的文字颜色。 | | border_1 | 四边设置一像素边框。 | | border_t_1 | 上边设置一像素边框。 | | border_color | 设置带类型的边框颜色。 | | border_dashed | 绘制虚线边框。 | | rounded | 设置带类型的圆角半径。 | | rounded_lg | 使用命名的大圆角半径。 | | rounded_full | 让圆角半径尽可能适应该元素的尺寸。 | | border | 四边设置带类型的边框宽度。 | | border_t | 设置带类型的上边框宽度。 | | border_b | 设置带类型的下边框宽度。 | | border_l | 设置带类型的左边框宽度。 | | border_r | 设置带类型的右边框宽度。 | | border_x | 设置带类型的左右边框宽度。 | | border_y | 设置带类型的上下边框宽度。 | | rounded_t | 设置带类型的上侧圆角。 | | rounded_b | 设置带类型的下侧圆角。 | | rounded_l | 设置带类型的左侧圆角。 | | rounded_r | 设置带类型的右侧圆角。 | | rounded_tl | 设置带类型的左上圆角。 | | rounded_tr | 设置带类型的右上圆角。 | | rounded_bl | 设置带类型的左下圆角。 | | rounded_br | 设置带类型的右下圆角。 | ### 排版 | 方法 | 说明 | | --- | --- | | font_family | 按名称设置字体族。 | | font_weight | 设置带类型的字重。 | | italic | 使用斜体文字。 | | text_size | 设置带类型的字号。 | | text_xs | 使用特小字号。 | | text_sm | 使用小字号。 | | text_lg | 使用大字号。 | | text_left | 左对齐文字。 | | text_center | 让一行中的文字居中。 | | text_right | 右对齐文字。 | | line_height | 设置带类型的行高。 | | whitespace_normal | 允许文字正常换行。 | | whitespace_nowrap | 阻止文字换行。 | | text_ellipsis | 在溢出文字末尾添加省略号。 | | truncate | 裁剪单行文字并添加省略号。 | | line_clamp | 将文字限制为指定行数。 | | text_base | 使用基础字号。 | | text_xl | 使用特大字号。 | | text_2xl | 使用 2× 大字号。 | | text_3xl | 使用 3× 大字号。 | | text_align | 设置带类型的文字对齐方式。 | | not_italic | 使用非斜体文字。 | | underline | 添加文字下划线。 | | line_through | 添加文字删除线。 | | text_decoration_none | 移除文字装饰线。 | | text_decoration_color | 设置文字装饰线颜色。 | | text_decoration_solid | 使用实线文字装饰。 | | text_decoration_wavy | 使用波浪线文字装饰。 | | text_decoration_0 | 把文字装饰线宽度设为零。 | | text_decoration_1 | 把文字装饰线宽度设为一像素。 | | text_decoration_2 | 把文字装饰线宽度设为两像素。 | | text_decoration_4 | 把文字装饰线宽度设为四像素。 | | text_decoration_8 | 把文字装饰线宽度设为八像素。 | | text_overflow | 设置带类型的文字溢出行为。 | | font_features | 设置 OpenType 字体特性。 | ### 效果与光标 | 方法 | 说明 | | --- | --- | | shadow_none | 移除盒阴影。 | | shadow_sm | 使用命名的小盒阴影。 | | shadow_md | 使用命名的中等盒阴影。 | | opacity | 用浮点值设置不透明度。 | | cursor_pointer | 悬停时使用指向手形光标。 | | cursor_text | 悬停时使用文本插入光标。 | | shadow | 设置带类型的盒阴影列表。 | | shadow_2xs | 使用命名的 2× 特小盒阴影。 | | shadow_xs | 使用命名的特小盒阴影。 | | shadow_lg | 使用命名的大盒阴影。 | | shadow_xl | 使用命名的特大盒阴影。 | | shadow_2xl | 使用命名的 2× 特大盒阴影。 | | cursor | 设置带类型的鼠标光标。 | | cursor_default | 使用默认光标。 | | cursor_move | 使用移动光标。 | | cursor_not_allowed | 使用禁止操作光标。 | | cursor_context_menu | 使用上下文菜单光标。 | | cursor_crosshair | 使用十字光标。 | | cursor_vertical_text | 使用竖排文字光标。 | | cursor_alias | 使用别名光标。 | | cursor_copy | 使用复制光标。 | | cursor_no_drop | 使用禁止拖放光标。 | | cursor_grab | 使用抓取光标。 | | cursor_grabbing | 使用正在抓取的光标。 | | cursor_ew_resize | 使用水平调整尺寸光标。 | | cursor_ns_resize | 使用垂直调整尺寸光标。 | | cursor_nesw_resize | 使用东北至西南方向调整尺寸光标。 | | cursor_nwse_resize | 使用西北至东南方向调整尺寸光标。 | | cursor_col_resize | 使用列宽调整光标。 | | cursor_row_resize | 使用行高调整光标。 | | cursor_n_resize | 使用向上调整尺寸光标。 | | cursor_e_resize | 使用向右调整尺寸光标。 | | cursor_s_resize | 使用向下调整尺寸光标。 | | cursor_w_resize | 使用向左调整尺寸光标。 | ### GPUI 特有方法 这些 API 没有直接对应的 Tailwind utility。方法名不加链接,以免暗示不存在的对应关系。 | 方法 | 说明 | | --- | --- | | `font` | 用带类型的 GPUI `Font` 替换文字字体。 | | `min_size` | 同时设置带类型的最小宽度与高度。 | | `max_size` | 同时设置带类型的最大宽度与高度。 | | `text_bg` | 设置文字片段而非元素盒的背景颜色。 | | `text_ellipsis_start` | 在开头截断,保留文字末尾。 | | `text_ellipsis_middle` | 在中间截断,保留文字两端。 | | `debug` | 在 debug 构建中绘制调试轮廓。 | | `debug_below` | 在 debug 构建中为当前元素及符合条件的后代绘制调试轮廓。 | | `style` | 返回元素使用的可变 `StyleRefinement`;这是 trait 的底层访问方法。 | | `text_style` | 返回元素样式中的可变文字 refinement。 | | `grid_location_mut` | 访问 `StyleRefinement` 中可变的 Grid 位置。 | 宏还为尺寸(`w`、`h`、`size`、`min_size`、`min_w`、`min_h`、`max_size`、`max_w`、`max_h`)、间隙(`gap`、`gap_x`、`gap_y`)、外边距(`m`、`mt`、`mb`、`mx`、`my`、`ml`、`mr`)、内边距(`p`、`pt`、`pb`、`px`、`py`、`pl`、`pr`)和偏移(`inset`、`top`、`bottom`、`left`、`right`)生成方法。例如 `w_64`、`px_3` 和 `top_0` 都是真实存在的方法。共用的数字后缀包括 `0`、`0p5`、`1`、`1p5`、`2`、`2p5`、`3`、`3p5`、`4` 到 `12` 的每个整数,以及 `16`、`20`、`24`、`32`、`40`、`48`、`56`、`64`、`72`、`80`、`96`、`112`、`128`。此外还有 `_px`、`_full` 和 `_1_2` 等分数后缀;只有接受 `auto` 的方法族才生成 `_auto`。非 `auto` 值还生成 `mt_neg_2` 这样的 `_neg_` 形式。边框各侧(`border`、`border_t`、`border_b`、`border_l`、`border_r`、`border_x`、`border_y`)有 `0` 到 `12`,以及 `16`、`20`、`24`、`32` 的像素宽度后缀。各侧和各角的圆角有 `none`、`xs`、`sm`、`md`、`lg`、`xl`、`2xl`、`3xl`、`full` 这些后缀。只有在生成的属性符合布局需要时才使用它们。 间距便捷方法使用基于 rem 的尺度:`_1` 为 `0.25rem`,`_2` 为 `0.5rem`,`_3` 为 `0.75rem`,`_4` 为 `1rem`。命名方法没有覆盖所需数值时,可使用 `.gap(rems(0.625))`、`.w(px(240.))` 或 `.w(relative(0.5))` 这类带类型的 setter。`relative(0.5)` 表示可用相对尺寸的一半;`px(...)` 表示像素。命名尺度和方法范围以 GPUI 的实现为准,不要假定每个 Tailwind class 都有对应方法。 ## 样式调用改动了什么 每个实现 `Styled` 的元素都提供 `fn style(&mut self) -> &mut StyleRefinement`。例如,`.px_3()` 写入相应的可选 padding 字段,`.bg(...)` 写入 background 字段。合并样式时,未设置的字段不会覆盖已有值。解析后的 `Style` 同时包含布局数据与外观数据。 ```text Styled calls → StyleRefinement → resolved Style ├─ layout fields → Taffy → bounds └─ color, text, shadow, cursor → GPUI paint and interaction ``` 在元素的 `request_layout` 阶段,GPUI 将 display、size、padding、gap、Flex 对齐、position 和 Grid 位置等布局字段连同子元素的布局 ID 交给 Taffy。Taffy 计算几何尺寸与位置。GPUI 随后在 `prepaint` 和 [Paint](./paint) 阶段使用所得 bounds 绘制并进行命中测试。Taffy 不实现 GPUI 的文字 shaping、hover listener、[Action](./action) 或绘制。 `StyleRefinement` 本身也实现了 `Styled`,因此状态样式闭包可以使用相同的 utility 方法。`.hover(|style| style.bg(...))` 这样的交互变体属于 `InteractiveElement`,需要交互型元素。条件构建方法有所不同:`.when(...)` 在构建本帧时决定是否追加链式步骤。 ## Fluent 组合与 trait 边界 链式 API 由多个 trait 共同提供。`Styled` 提供样式方法,`ParentElement` 提供 `.child(...)`。`InteractiveElement` 提供 `.id(...)`,返回支持 `.overflow_y_scroll()` 等依赖身份的方法的 `Stateful
`;它还提供 `.hover(...)` 等状态样式细化。每个 `IntoElement` 都实现 `FluentBuilder`;只实现 `IntoElement` 不会获得样式、子元素或交互能力。缺少某个方法时,检查接收者实现了哪个 trait,以及之前的调用是否改变了其类型。 | `FluentBuilder` 方法 | 作用 | | --- | --- | | `map` | 转换当前值,返回类型也可以改变。 | | `when` | 布尔条件为真时追加构建步骤。 | | `when_else` | 在两个返回相同 builder 类型的步骤间选择。 | | `when_some` | 可选值存在时追加步骤,并把值传给闭包。 | | `when_none` | 引用的可选值为空时追加步骤。 | ```rust use gpui_kit::*; use gpui_kit::component::ActiveTheme as _; div() .id("result-row") .px_3() .py_2() .when(selected, |row| row.bg(cx.theme().selection)) .when_some(subtitle, |row, text| row.child(text)) .child(title) ``` 条件闭包取得 builder 并返回 builder。条件在本次 render 中求值,并非订阅。`.map(...)` 可以返回不同类型。跨帧变化的状态应由 `Entity` 持有。 例如,`.when(selected, ...)` 在构建本帧时判断 `selected`;`.hover(|style| ...)` 则安装指针悬停时的样式细化。滚动方法需要有状态的元素,所以应先给预期的滚动负责元素设置 `.id(...)`。不能假定任意实现 `IntoElement` 的组件都接受 `.child(...)` 或 `.bg(...)`;应检查该组件自己的 builder API,或用负责这些样式的 `div()` 包裹它。 GPUI Kit 另有 `StyledExt`,提供 `h_flex`、`v_flex` 和 `refine_style` 等不依赖主题的辅助方法;`ThemeStyled` 则提供 `popover_style(cx)` 等依赖主题的外观方法。它们是在 GPUI `Styled` 之上的扩展,不属于 Tailwind utility。自行实现元素时,应从 `style()` 返回元素的 `StyleRefinement`,并在布局与绘制阶段应用解析后的样式。只实现元素确实支持的子元素与交互 trait。 Tailwind 对应关系有明确范围:这里没有 CSS 选择器、层叠、响应式前缀,也不保证每个 Tailwind utility 都存在对应的 GPUI 方法。几何布局使用 GPUI 带类型的辅助方法,产品外观使用 GPUI Kit 主题 token,行为则使用明确的元素或实体 API。 --- # Packaging Source: /versions/v0.7.1/zh-CN/docs/packaging 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/packaging 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 # 桌面应用打包与分发 `cargo build --release` 生成可执行文件,不会自动创建应用包或安装程序。GPUI Kit 提供应用框架和组件;应用项目负责选择分发格式、确定应用标识、收集运行时资源、签名并验证安装。本文以仓库现有的 `hello_world` package 为构建示例,示例版本是 `0.7.1`。实际发布时,请用自己的应用名、版本、图标、标识及二进制路径替换示例值。 先完成[安装与本机运行](./installation),再决定要交付哪一种产物: | 平台 | 便于内部测试或手动解压 | 面向用户的安装方式 | | --- | --- | --- | | macOS | `.app`,或包含 `.app` 的归档 | 包含已签名、已公证应用的 `.dmg`;也可按自己的发布渠道选择其他格式 | | Windows | 含 `.exe` 和资源的 `.zip` | 使用 Inno Setup 制作带安装与卸载流程的 `.exe` | | Linux | 含可执行文件和资源的 `.tar.gz` | Debian/Ubuntu 使用 `.deb`;其他发行版需要各自的包格式或安装流程 | 归档与安装程序的区别在于谁创建应用入口、把文件放在哪里,以及如何升级和卸载。不要把 Windows ZIP 称为安装程序,也不要假设 DEB 能安装在所有 Linux 发行版。平台模型可参照 [Apple 的分发说明](https://developer.apple.com/documentation/xcode/packaging-mac-software-for-distribution)、[Microsoft 的打包概览](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/packaging/)和 [Debian 二进制包说明](https://www.debian.org/doc/debian-policy/ap-pkg-binarypkg.html)。 ## 先构建并列出运行时文件 在仓库根目录、目标操作系统的构建机上运行: ```sh cargo build --release --locked -p hello_world ``` `--locked` 使用已提交的 `Cargo.lock`;它不会替你处理目标平台工具链、签名或安装器。请先在构建机运行 release 二进制,确认窗口能够打开,再将**相同的二进制**放入打包暂存目录。为每个操作系统和架构分别构建、标记并测试产物;例如 macOS arm64 与 x86_64、Windows x86_64、Linux x86_64 不能仅凭改动文件名互换。 打包前核对以下文件和约定: - **应用标识**:确定稳定的包名或 bundle identifier、显示名、版本、架构与安装路径。应用升级时维持对应身份;若要让 preview 与 stable 并存,应给它们不同身份。 - **资源**:GPUI Kit 的默认原生 SVG 图标由 `Assets` 嵌入二进制;`include_bytes!` 等编译期嵌入资源也无需复制源目录。应用使用文件系统 `Path` 读取的图片、字体、配置或其他数据,必须随安装包提供,并从可靠的安装位置查找。详见[图标与资源](./assets)及[字体](./fonts)。 - **运行时依赖**:检查动态链接库与平台服务是否存在于目标机器。开发机已有的库不等于用户机器也有。Linux 特别要检查图形会话、Vulkan 驱动以及实际链接的系统库;[安装指南](./installation)里的 `-dev` 包是构建依赖,不应原样列为最终用户依赖。 - **平台功能**:通知、文件关联、URL scheme、WebView 等可能另需应用身份、权限声明或系统组件。只为应用实际使用的功能配置它们;参见[原生扩展](./native-extension)和 [WebView](./webview)。 将二进制、外部资源、许可文本和平台元数据先放入独立的暂存目录,再生成归档或安装程序。这样可以在压缩前检查内容,并避免把 `target/release` 的中间文件、调试符号或开发机路径一起发布。 如果应用使用 GPUI 的应用身份承载通知等平台功能,启动时应通过 `cx.set_app_identity("com.example.helloworld", "HelloWorld")` 设置,并与安装包的身份一起检查;参见[系统通知](./system-notification)。更换安装包标识可能生成另一份安装或数据目录,更换运行时标识也可能影响系统功能。 ## macOS macOS 的应用包有固定结构;至少需要可执行文件、`Info.plist`,通常还需要图标和应用资源。下面是示意结构,`CFBundleExecutable` 必须与 `Contents/MacOS` 中的文件名一致: ```text HelloWorld.app/ └── Contents/ ├── Info.plist ├── MacOS/ │ └── hello_world └── Resources/ ``` 下面命令在 macOS 构建机上制作最小 `.app`。示例并未包含图标;正式发布时把自己的 `.icns` 文件放入 `Contents/Resources`,并在 plist 中加上相应图标键: ```sh mkdir -p dist/HelloWorld.app/Contents/MacOS dist/HelloWorld.app/Contents/Resources cp target/release/hello_world dist/HelloWorld.app/Contents/MacOS/hello_world cat > dist/HelloWorld.app/Contents/Info.plist <<'PLIST' CFBundleIdentifiercom.example.helloworld CFBundleNameHelloWorld CFBundleDisplayNameHelloWorld CFBundleExecutablehello_world CFBundlePackageTypeAPPL CFBundleShortVersionString0.7.1 CFBundleVersion1 PLIST plutil -lint dist/HelloWorld.app/Contents/Info.plist open dist/HelloWorld.app ``` `Info.plist` 中的标识、名称及版本要与应用一致。需要文件类型、URL scheme、隐私授权或最低系统版本时,也在对应配置中声明。可手工组装,也可选择适合自己应用的 bundler;GPUI Kit 本身没有要求使用某一个 bundler。[Apple 的 bundle 布局说明](https://developer.apple.com/documentation/bundleresources/placing-content-in-a-bundle)解释了 `MacOS` 与 `Resources` 的用途。 在本机打开暂存的 `.app` 验证图标、资源和功能。若通过网站直接向普通用户分发,使用自己的 **Developer ID Application** 身份签名,并按 Apple 的流程公证。应用内若有额外可执行文件、动态库或 framework,先分别签名嵌套代码,再签名外层 `.app`。将以下示例身份替换成自己的有效签名身份: ```sh codesign --force --timestamp --options runtime \ --sign "Developer ID Application: Example (ABCDE12345)" dist/HelloWorld.app codesign --verify --deep --strict --verbose=2 dist/HelloWorld.app ``` 临时 ad-hoc 签名只能用于相应的本地测试,不能替代 Developer ID 签名和公证。[Apple 的代码签名说明](https://developer.apple.com/documentation/xcode/creating-distribution-signed-code-for-the-mac/)给出了顺序和选项。 将验证过的 `.app` 放进 DMG 暂存目录,按需添加指向 `/Applications` 的别名,再创建磁盘映像。一个不含自定义 Finder 布局的最小命令是: ```sh mkdir -p dist/dmg-root cp -R dist/HelloWorld.app dist/dmg-root/HelloWorld.app hdiutil create -volname HelloWorld -srcfolder dist/dmg-root \ -format UDZO -ov dist/HelloWorld.dmg ``` 通过网站直接分发时,先签名 DMG,再提交公证;公证完成后检查结果并把票据附加到 DMG。以下命令要求你已在本机安全地配置好 `notarytool` 的 keychain profile,并已换成自己的签名身份: ```sh codesign --force --timestamp \ --sign "Developer ID Application: Example (ABCDE12345)" dist/HelloWorld.dmg xcrun notarytool submit dist/HelloWorld.dmg \ --keychain-profile HelloWorld --wait xcrun stapler staple dist/HelloWorld.dmg xcrun stapler validate dist/HelloWorld.dmg ``` `notarytool` 返回提交结果后,应确认状态为 `Accepted`;失败时查看日志并修复签名或包内容。最后在干净的 macOS 机器上挂载 DMG、拖入 Applications、首次启动,并检查 Gatekeeper。详见 [Apple 的公证工作流](https://developer.apple.com/documentation/security/customizing-the-notarization-workflow)。 ## Windows 仅分发单个 `hello_world.exe` 适合最小示例;真实应用若从相对路径读取资源,应在 ZIP 和安装器中保持相同的文件布局。Windows 上可先暂存文件,制作便于验证的 ZIP: ```powershell cargo build --release --locked -p hello_world New-Item -ItemType Directory -Force dist\windows | Out-Null Copy-Item target\release\hello_world.exe dist\windows\hello_world.exe Compress-Archive -Path dist\windows\* ` -DestinationPath dist\hello-world-windows-x64.zip -Force ``` ZIP 不会自动创建开始菜单项、卸载记录或升级规则。需要安装程序时,使用 Inno Setup。以下是可用于**这个单文件示例**的最小脚本;把它保存为仓库根目录的 `packaging.iss`,在 Windows 构建机使用 Inno Setup 编译。多文件应用应把资源一起放进 `[Files]`,并测试资源路径。 ```ini [Setup] AppId=Example.HelloWorld AppName=HelloWorld AppVersion=0.7.1 DefaultDirName={autopf}\HelloWorld DefaultGroupName=HelloWorld OutputDir=dist OutputBaseFilename=HelloWorld-Setup ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible [Files] Source: "dist\windows\hello_world.exe"; DestDir: "{app}"; Flags: ignoreversion [Icons] Name: "{group}\HelloWorld"; Filename: "{app}\hello_world.exe" [Run] Filename: "{app}\hello_world.exe"; Description: "Launch HelloWorld"; Flags: nowait postinstall skipifsilent ``` ```powershell ISCC.exe packaging.iss ``` 发布真实产品时,用产品唯一且长期稳定的 `AppId`,补充发布者、图标、许可证、必要的运行时依赖和数字签名。应在制作 ZIP 或安装器前先签名应用 `.exe`,再签名最终安装器和卸载器;Inno Setup 会把卸载器嵌入安装 `.exe`,因此事后仅签名安装器不等于签名卸载器。按 [Inno Setup 的签名卸载器流程](https://jrsoftware.org/ishelp/topic_setup_signeduninstaller.htm)配置,具体签名方式取决于证书提供方。上面的脚本只生成用于本地验证的未签名安装器。若应用会自行更新,检查安装器与更新器是否使用同一应用身份和文件位置;脚本字段见 [Inno Setup 官方帮助](https://jrsoftware.org/ishelp/topic_setupsection.htm)。 ## Linux tar.gz 适合明确说明“解压后运行”或配套安装脚本的分发;桌面菜单、图标、权限及升级规则需要由安装脚本处理。DEB 则由包管理器安装到系统路径。为下面的普通用户安装示例,先准备自己的 256 × 256 PNG 图标 `packaging/hello-world-gpui.png`,再创建归档: ```sh mkdir -p dist/portable/HelloWorld/bin \ dist/portable/HelloWorld/share/icons/hicolor/256x256/apps cp target/release/hello_world dist/portable/HelloWorld/bin/hello_world install -m 0644 packaging/hello-world-gpui.png \ dist/portable/HelloWorld/share/icons/hicolor/256x256/apps/hello-world-gpui.png tar -czf dist/hello-world-linux-x64.tar.gz -C dist/portable HelloWorld tar -tzf dist/hello-world-linux-x64.tar.gz ``` 该归档包含示例二进制与应用提供的图标;如果应用还依赖外部资源,应先复制到 `dist/portable/HelloWorld` 中,再创建归档。解压到其他目录运行,以排除对源码目录与当前工作目录的偶然依赖。 **普通用户目录安装。** 如果希望 tar.gz 也能在无需 sudo 的情况下进入应用菜单,可随发布产物提供 `install.sh`:先从可信发布渠道取得压缩包和对应的 SHA-256 校验值,再校验完整压缩包,检查归档路径及必需文件,最后把程序、`.desktop` 入口和图标安装到当前用户目录。下面的脚本需要 Python 3,不需要 root 权限。它使用 XDG 用户数据目录的默认位置 `~/.local/share`,把入口放在 `~/.local/share/applications`,图标放在 `~/.local/share/icons/hicolor/256x256/apps`,程序放在 `~/.local/bin`;这些默认位置见 [XDG Base Directory 规范](https://specifications.freedesktop.org/basedir/latest/)。若用户另行设置 `XDG_DATA_HOME` 或自定义安装前缀,应把脚本中的 `data_dir` 改为配置的绝对路径,并确保桌面环境能够发现该目录。 发布用归档应包含 `HelloWorld/bin/hello_world` 和应用提供的 `HelloWorld/share/icons/hicolor/256x256/apps/hello-world-gpui.png`。现有仓库的 `hello_world` 仅提供二进制;请先给自己的应用准备图标。校验值应来自可信的发布清单或独立发布渠道;从刚下载的同一份压缩包现场计算一个值再传给安装脚本,不能验证它是否是发布者预期的文件。 将下面内容保存为 `install.sh`。它接受本地 tar.gz 和发布方给出的**确切 SHA-256 值**作为参数,只提取所需的普通文件;遇到绝对路径、`..`、链接或重复条目会在安装前拒绝归档。示例安装三个文件,不包含应用额外资源;实际应用若要加载外部文件,需要扩展归档布局和安装步骤。 ```sh #!/bin/sh set -eu [ "$#" -eq 2 ] || { echo 'Usage: sh install.sh ARCHIVE EXPECTED_SHA256' >&2; exit 2; } python3 - "$1" "$2" <<'PY' from pathlib import Path, PurePosixPath import hashlib import io import os import re import shutil import sys import tarfile import tempfile archive = Path(sys.argv[1]) expected = sys.argv[2].lower() if not re.fullmatch(r"[0-9a-f]{64}", expected): raise SystemExit("Expected a 64-character SHA-256 digest") digest = hashlib.sha256() verified = tempfile.TemporaryFile() with archive.open("rb") as archive_stream: for chunk in iter(lambda: archive_stream.read(1024 * 1024), b""): digest.update(chunk) verified.write(chunk) if digest.hexdigest() != expected: raise SystemExit("Checksum mismatch; nothing installed") verified.seek(0) binary_name = "HelloWorld/bin/hello_world" icon_name = "HelloWorld/share/icons/hicolor/256x256/apps/hello-world-gpui.png" with tarfile.open(fileobj=verified, mode="r:gz") as bundle: members = {} for member in bundle: path = PurePosixPath(member.name) if (not path.parts or path.is_absolute() or ".." in path.parts or path.parts[0] != "HelloWorld" or not (member.isfile() or member.isdir()) or str(path) in members): raise SystemExit("Unsafe or duplicate archive entry") members[str(path)] = member if not all(name in members and members[name].isfile() for name in (binary_name, icon_name)): raise SystemExit("Archive is missing its executable or icon") bin_dir = Path.home() / ".local/bin" data_dir = Path.home() / ".local/share" app_dir = data_dir / "applications" icon_dir = data_dir / "icons/hicolor/256x256/apps" for directory in (bin_dir, app_dir, icon_dir): directory.mkdir(parents=True, exist_ok=True) binary = bin_dir / "hello_world" icon = icon_dir / "hello-world-gpui.png" desktop = app_dir / "hello-world-gpui.desktop" # Desktop Entry quoting has two escape layers; this handles a home path with spaces. exec_path = str(binary).replace("\\", "\\\\\\\\") for character in ('"', '`', '$'): exec_path = exec_path.replace(character, "\\\\" + character) exec_path = exec_path.replace("%", "%%") desktop_content = ( "[Desktop Entry]\nType=Application\nName=HelloWorld\n" + f'Exec="{exec_path}"\n' + "Icon=hello-world-gpui\nTerminal=false\nCategories=Utility;\n" ) staged_files = [] def stage_file(destination, input_stream, mode): with tempfile.NamedTemporaryFile( dir=destination.parent, prefix=".hello-world-install-", delete=False ) as staged: staged_path = Path(staged.name) staged_files.append(staged_path) shutil.copyfileobj(input_stream, staged) staged_path.chmod(mode) return staged_path try: with bundle.extractfile(members[binary_name]) as entry_stream: staged_binary = stage_file(binary, entry_stream, 0o755) with bundle.extractfile(members[icon_name]) as entry_stream: staged_icon = stage_file(icon, entry_stream, 0o644) staged_desktop = stage_file( desktop, io.BytesIO(desktop_content.encode("utf-8")), 0o644 ) for staged, destination in ( (staged_binary, binary), (staged_icon, icon), (staged_desktop, desktop), ): os.replace(staged, destination) finally: for staged in staged_files: staged.unlink(missing_ok=True) verified.close() print("Installed HelloWorld for the current user") PY ``` 在发布流水线中为最终 tar.gz 生成 SHA-256 校验值并随产物发布;安装时从可信发布清单取得对应值。第二个参数须替换为实际的 64 位十六进制值: ```sh sh install.sh dist/hello-world-linux-x64.tar.gz SHA256_FROM_TRUSTED_RELEASE ``` 脚本生成的桌面入口使用安装后二进制的绝对路径,因此不依赖图形会话的 `PATH` 是否包含 `~/.local/bin`。路径中有空格等特殊字符时,应遵循 [Desktop Entry 规范](https://specifications.freedesktop.org/desktop-entry/latest-single/)的 `Exec` 引号与转义规则。安装后从应用菜单启动,确认图标和入口都已出现;部分桌面环境还需要重新登录或刷新菜单缓存。升级时先退出应用,再对新版本重复执行脚本;脚本先在各目标目录暂存三个文件,再分别重命名替换。每个文件的替换都是原子操作,但三个文件合起来并不是一次事务。这个最小示例可使用下列命令卸载三个安装文件,用户配置与数据默认保留: ```sh rm ~/.local/bin/hello_world \ ~/.local/share/applications/hello-world-gpui.desktop \ ~/.local/share/icons/hicolor/256x256/apps/hello-world-gpui.png ``` 若改用 DEB 等系统包管理器安装,不要让两套安装方式同时管理同一条入口或同一份文件。 **Debian/Ubuntu 系统包。** DEB 可按以下结构暂存: ```text dist/deb-root/ ├── DEBIAN/ │ └── control └── usr/ ├── lib/hello-world-gpui/ │ └── hello_world └── share/ ├── applications/hello-world-gpui.desktop └── icons/hicolor/256x256/apps/hello-world-gpui.png ``` 先在 Linux 构建机建立暂存目录并复制示例二进制;图标由应用自行提供: ```sh mkdir -p dist/deb-root/DEBIAN \ dist/deb-root/usr/lib/hello-world-gpui \ dist/deb-root/usr/share/applications \ dist/deb-root/usr/share/icons/hicolor/256x256/apps install -m 0755 target/release/hello_world \ dist/deb-root/usr/lib/hello-world-gpui/hello_world ``` 不要让 DEB 写入 `/usr/local`;该目录留给本机管理员。`.desktop` 文件中的 `Exec` 应指向已安装的命令,`Icon` 应与已安装的图标名匹配。示例控制文件内容如下;发布前依据实际链接库填写 `Depends`,不要复制开发机的构建包列表: ```text Package: hello-world-gpui Version: 0.7.1 Section: utils Priority: optional Architecture: amd64 Maintainer: Example Description: HelloWorld desktop application ``` ```ini [Desktop Entry] Type=Application Name=HelloWorld Exec=/usr/lib/hello-world-gpui/hello_world Icon=hello-world-gpui Categories=Utility; Terminal=false ``` 把两个文本分别保存到上面的 `control` 与 `.desktop` 路径,复制二进制和图标,设置可执行权限,然后在 Linux 构建机创建并查看包: ```sh dpkg-deb --root-owner-group --build dist/deb-root dist/hello-world-gpui_0.7.1_amd64.deb dpkg-deb --info dist/hello-world-gpui_0.7.1_amd64.deb dpkg-deb --contents dist/hello-world-gpui_0.7.1_amd64.deb ``` 用 `readelf -d` 或 `objdump -p` 查看自己的二进制直接依赖的共享库,再用 Debian 的 [`dpkg-shlibdeps`](https://manpages.debian.org/unstable/dpkg-dev/dpkg-shlibdeps.1.en.html) 和目标发行版包信息推导运行时 `Depends`;如果运行时才加载库,也要检查对应路径。桌面入口遵循 [Desktop Entry 规范](https://specifications.freedesktop.org/desktop-entry/latest-single/),图标放置及系统目录遵循 [Debian Policy](https://www.debian.org/doc/debian-policy/ch-opersys.html)。若还要发布 RPM,应另行制作并测试 RPM,不要直接改 DEB 的扩展名。 `--root-owner-group` 让无 root 权限的暂存构建将包内文件归属设为 root,避免把构建用户的 UID/GID 写进 DEB;参见 [`dpkg-deb` 手册](https://manpages.debian.org/bullseye/dpkg/dpkg-deb.1.en.html)。在干净的 Debian/Ubuntu 测试机使用 `sudo apt install ./dist/hello-world-gpui_0.7.1_amd64.deb` 安装,从桌面菜单启动,再使用 `sudo apt remove hello-world-gpui` 检查卸载。 ## 发布前验证 在与开发机分开的干净虚拟机或设备上,分别验证安装包和归档: 1. **检查内容**:核对版本、架构、入口文件、资源、许可文本、签名和依赖;生成 SHA-256 校验值并随产物发布。不要只依据文件存在或构建成功判断可运行。 2. **安装并启动**:从最终用户会采用的路径安装;检查窗口、文字与图标、键盘输入、系统菜单,以及应用实际使用的通知、WebView、URL scheme 或文件打开功能。 3. **升级与卸载**:用上一版升级到当前版,再卸载;确认应用身份保持稳定、用户数据处理符合产品设计,并且快捷方式、桌面入口与安装文件正确更新或清理。 4. **架构与兼容性**:每个目标架构、受支持的最低系统版本和所需图形环境至少测一次。Linux 应覆盖所声明的发行版和图形会话;Windows 应覆盖安装权限模式;macOS 应验证首次启动和公证状态。 这份指南展示发布流程和最小示例。安装器配置、签名凭据、依赖清单及平台集成仍应由具体应用维护;如需自动化,把打包与验证放在应用自己的发布流水线中,避免把仅用于编译的检查当成安装包测试。 安装包验证完成后,可继续阅读[自动更新](./auto-update),为不同安装来源设计版本检查与升级流程。 --- # Paint Source: /versions/v0.7.1/zh-CN/docs/paint 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/paint 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 GPUI 在布局与 `prepaint` 之后执行 `paint`。底层 [`Element`](./element) 拿到最终的 `Bounds`,才能确定绘制坐标。`paint` 向当前帧提交绘制命令,不负责确定布局或输入命中区域。矩形和边框使用 `window.paint_quad`,普通图文优先使用现有 Element,自由曲线和不规则形状使用 `window.paint_path`。只需绘制少量自定义图形时,可以使用 `canvas(prepaint, paint)`,无需实现完整的 `Element` trait。 ## 从一个三角形开始 这个练习不需要新建 crate 或添加依赖。在本地检出中,将现有 `examples/hello_world/src/main.rs` 的内容暂时替换为下面的代码,再从仓库根目录运行 `cargo run -p hello_world`。窗口左上附近应出现一个蓝色三角形。如果不想保留练习代码,完成后恢复该示例文件。 ```rust use gpui_kit::*; struct PaintedTriangle; impl Render for PaintedTriangle { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div().size_full().child( canvas( |bounds, _, _| bounds, |_, bounds, window, _| { let mut path = PathBuilder::fill(); let x = bounds.origin.x; let y = bounds.origin.y; path.move_to(point(x + px(24.), y + px(24.))); path.line_to(point(x + px(144.), y + px(24.))); path.line_to(point(x + px(84.), y + px(128.))); path.close(); if let Ok(path) = path.build() { window.paint_path(path, rgb(0x3b82f6)); } }, ) .size_full(), ) } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| PaintedTriangle) }) .expect("failed to open window"); }); } ``` 父 `div` 和子 `canvas` 都设置了 `.size_full()`,布局因此会给回调一个可用的矩形。第一个回调在 `prepaint` 阶段执行,并把最终 bounds 作为状态 `T` 返回;第二个回调在 `paint` 阶段收到这份值,用窗口坐标构建一条填充路径,再把 `Path` 交给 `paint_path`。`close()` 会把最后一个点与起点连接起来。`if let Ok` 处理三角化失败的情况,避免直接 panic。 两个 `canvas` 回调都是单次 Element 流程中的 `FnOnce` 回调。如果绘制需要准备好的数据,就从第一个回调返回拥有所有权的值;不要把 `Window` 或 `App` 的引用保存到下一帧。`canvas` 本身是当前帧的 Element;若绘图数据需要跨 render 保留,应放入 Entity 或 keyed window state。 试着把第三个点的 `px(84.)` 改成 `px(120.)` 后重新运行:变化的只有三角形的几何形状。再把 `PathBuilder::fill()` 改成 `PathBuilder::stroke(px(4.))`,可以画出轮廓。GPUI 渲染新的一帧时会重新创建元素树;这里的 Path 也会在 paint 时重新构建。小型装饰图形这样写很直接;大型或频繁重绘的路径应在测量成本后考虑缓存。 这里最重要的是坐标交接: ```text layout: canvas bounds = origin (x, y) + size (width, height) prepaint: pass final bounds to paint paint: local point (24, 24) + origin (x, y) -> window point -> PathBuilder ``` bounds 建立坐标系,但不会自动把路径裁剪在 canvas 内。图形可能越过 canvas 边界;如需限制溢出,应给容器设置合适的裁剪样式,或使用 content mask。 ## 让绘制响应点击 将 `examples/hello_world/src/main.rs` 替换为下面的完整示例,再运行 `cargo run -p hello_world`。每次左键点击都会在指针位置添加一个蓝色小三角形。后续点击不会抹掉已有图形,因为位置保存在 `ClickPainter` Entity 中,而不是只存在于当前帧的 `canvas` 值里。 ```rust use gpui_kit::base::ElementExt; use gpui_kit::*; struct ClickPainter { marks: Vec>, canvas_bounds: Option>, } impl ClickPainter { fn add_mark( &mut self, event: &MouseDownEvent, _: &mut Window, cx: &mut Context, ) { if let Some(bounds) = self.canvas_bounds { self.marks.push(point( event.position.x - bounds.origin.x, event.position.y - bounds.origin.y, )); cx.notify(); } } } impl Render for ClickPainter { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let marks = self.marks.clone(); let painter = cx.entity().clone(); div() .size_full() .relative() .on_mouse_down(MouseButton::Left, cx.listener(Self::add_mark)) .on_prepaint(move |bounds, _, cx| { painter.update(cx, |this, _| { this.canvas_bounds = Some(bounds); }); }) .child( canvas( move |bounds, _, _| (bounds.origin, marks), |_, (origin, marks), window, _| { for mark in marks { let center = point(origin.x + mark.x, origin.y + mark.y); let mut path = PathBuilder::fill(); path.move_to(point(center.x, center.y - px(16.))); path.line_to(point(center.x + px(16.), center.y + px(12.))); path.line_to(point(center.x - px(16.), center.y + px(12.))); path.close(); if let Ok(path) = path.build() { window.paint_path(path, rgb(0x3b82f6)); } } }, ) .absolute() .size_full(), ) } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| ClickPainter { marks: Vec::new(), canvas_bounds: None, }) }) .expect("failed to open window"); }); } ``` 父 `div` 接收鼠标事件。它的 `on_prepaint` 钩子保存最终 bounds,供下次输入使用;保存 bounds 本身不请求新一帧。`add_mark` 减去该 bounds 的 origin,以 canvas 局部坐标保存位置,然后用 `cx.notify()` 请求新一帧。下一次 render 时,canvas 收到标记的快照;paint 回调加上**当前** origin,为每个标记构建路径。试着点击两次并调整窗口大小:两个标记应保持相对 canvas 的位置。如果点击没有结果,依次检查父容器尺寸是否非零、`canvas_bounds` 是否已保存,以及 handler 修改 `marks` 后是否调用 `cx.notify()`。 ## 跟随真实绘图应用 在仓库根目录运行现有的 [Brush 示例](https://github.com/longbridge/gpui-kit/blob/main/examples/brush/src/main.rs): ```sh cargo run -p example-brush ``` 在 **Drawing Canvas** 内拖动鼠标画一笔,再试试 **Size**、**Opacity**、颜色块、**Show Grid** 和 **Clear Canvas**。下面沿着示例中一笔画的输入、布局和绘制流程逐步阅读。 先记住一帧的顺序:鼠标 handler 修改 `BrushStory` 中保留的状态并调用 `cx.notify()`;`render` 创建新元素树;布局确定尺寸和位置;`prepaint` 收到 bounds;`paint` 提交本帧的路径。保存的笔画点跨帧存在,而此示例中的 `Path` 对象会在绘制时重新构建。 ### 1. 为 canvas 分配空间并接收输入 `render_canvas` 把绘图 canvas 放在占满容器的 `div` 中。外层可伸缩的 **Drawing Canvas** 区域提供空间;`div` 处理鼠标事件,子 canvas 占满同一区域: ```rust let base_div = div() .id("canvas") .size_full() .bg(theme.background) .cursor_crosshair() .relative() .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down)) .on_mouse_move(cx.listener(Self::handle_mouse_move)) .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up)) .on_prepaint(move |bounds, _window, cx| { state_entity.update(cx, |state, _| { state.canvas_bounds = Some(bounds); }) }); ``` 示例把 `canvas(...).absolute().size_full()` 作为这个 `div` 的子元素。canvas 需要自身样式或父布局分配尺寸,否则可能没有可绘制区域。父元素的 `on_prepaint` 保存最终的 `Bounds`,供鼠标 handler 使用。bounds 和鼠标位置都是**窗口坐标**,因此 canvas 的 origin 通常不是 `(0, 0)`。 `on_prepaint` 保存 bounds 时没有调用 `cx.notify()`:它只记录后续输入所需的几何信息,不会在每帧绘制过程中再次请求渲染。 ### 2. 以 canvas 局部坐标保存点 按下鼠标时,`BrushStory::handle_mouse_down` 开始一条 `Stroke`。它先减去保存的 origin,再存储指针位置;鼠标移动时也使用同样的转换: ```rust let local_pos = if let Some(bounds) = self.canvas_bounds { Point::new( event.position.x - bounds.origin.x, event.position.y - bounds.origin.y, ) } else { event.position }; ``` `BrushStory` 在 `strokes` 中保留完成的笔画,在 `current_stroke` 中保留正在绘制的笔画。handler 修改笔画时调用 `cx.notify()`,让 GPUI 渲染新的一帧。只有鼠标按下产生的一个点还不能形成可见线条:`build_stroke_path` 至少需要两个点。松开鼠标后,两个点及以上的笔画会加入已完成列表。 ### 3. 把最终 bounds 传给绘制阶段 在 `render_canvas` 中,canvas 的第一个回调接收最终 bounds,连同笔画、当前笔画、网格开关和主题一起返回。第二个回调取得这些值并绘制。下面展示数据交接;[源码](https://github.com/longbridge/gpui-kit/blob/main/examples/brush/src/main.rs)还绘制了可选网格和正在画的笔画: ```rust canvas( move |bounds, _window, _cx| { ( strokes_for_prepaint, current_stroke_for_prepaint, show_grid_for_prepaint, theme_for_prepaint, bounds, ) }, move |_bounds, (strokes, current_stroke, show_grid, theme, prepaint_bounds), window, _cx| { for stroke in strokes.iter() { if let Some(path) = BrushStory::build_stroke_path(stroke, &prepaint_bounds) { window.paint_path(path, stroke.color); } } // The example also paints the grid and current_stroke here. }, ) .absolute() .size_full() ``` `prepaint` 发生在布局完成之后,此时 bounds 已确定。`paint_path` 提交当前帧的绘制命令;它本身不会修改状态,也不会安排下一帧。 ### 4. 用窗口坐标构建 Path `build_stroke_path` 先把保存的 canvas 局部坐标转换回窗口坐标,再对描边路径进行三角化: ```rust let mut builder = PathBuilder::stroke(px(stroke.size)); let first_point = Point::new( bounds.origin.x + stroke.points[0].x, bounds.origin.y + stroke.points[0].y, ); builder.move_to(first_point); for point in stroke.points.iter().skip(1) { let abs_point = Point::new(bounds.origin.x + point.x, bounds.origin.y + point.y); builder.line_to(abs_point); } builder.build().ok() ``` 试着调大 **Size** 再画一条线:`stroke.size` 决定新笔画的宽度,之前的笔画保留各自保存的宽度。**Opacity** 和颜色同样在一笔开始时确定。切换 **Show Grid**,可以看到同一个 paint 回调绘制的另一组路径;点击 **Clear Canvas** 则清空保留的笔画并请求新的一帧。如果布局移动或调整 canvas 尺寸,每次绘制都会使用当前的 bounds origin 放置这些点。 如果笔画没有出现,可以按下面的顺序排查: | 现象 | 检查项 | | --- | --- | | 连网格都看不到 | 确认父容器和 canvas 的布局尺寸不为零;Path 不会自己占据布局空间。 | | 单击后没有留下笔迹 | 此示例不会把一个点画成线;拖动到采样到第二个点。 | | 移动 canvas 后笔迹偏移 | 输入时减去 canvas origin,绘制时加上**当前** origin。 | | 保存的点已经变化,画面却没有更新 | 确认状态持有者在有效变化后调用了 `cx.notify()`。 | | 部分几何无声消失 | 检查 `PathBuilder::build()` 的结果;此示例把错误转成 `None`。 | 示例把构建失败视为“不绘制”;当几何来自用户数据时,应保留或报告错误。输入 handler 挂在外层 `div` 上;Path 本身不提供 hitbox 或无障碍行为。 ## 构建 Path `PathBuilder` 描述矢量路径,`build()` 成功后会把它三角化成 `Path`。封闭区域选 `fill()`,线条选 `stroke(width)`。路径点使用窗口像素坐标;若数据以 Element 左上角为原点,先加上最终 bounds 的 origin。 ```rust use gpui_kit::*; fn paint_triangle(bounds: Bounds, color: Hsla, window: &mut Window) { let mut builder = PathBuilder::fill(); builder.move_to(bounds.origin); builder.line_to(point(bounds.right(), bounds.top())); builder.line_to(point(bounds.left() + bounds.size.width / 2., bounds.bottom())); builder.close(); if let Ok(path) = builder.build() { window.paint_path(path, color); } } ``` `move_to`、`line_to`、`curve_to`(二次贝塞尔)、`cubic_bezier_to`、`arc_to`、`add_polygon` 和 `close` 用于描述路径段;`translate`、`scale`、`rotate` 可在三角化前变换路径。`PathBuilder::stroke(px(1.)).dash_array(&[px(4.), px(2.)])` 可以得到虚线。`build()` 返回 `Result`,任意几何输入不保证总能成功。路径几何没有改变时,可复用已构建的 `Path`,并用不同颜色绘制。 ### 从 SVG Path 迁移 `PathBuilder` 最初是为 K 线图的绘制需求引入 GPUI 的。它内部使用 Lyon 的 SVG path builder,因此路径段词汇与 SVG 接近。熟悉 [SVG 路径命令](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/d)的人,可以直接迁移路径段的概念: | SVG Path | GPUI builder | 含义 | | --- | --- | --- | | `M x y` | `move_to(point)` | 开始子路径。 | | `L x y` | `line_to(point)` | 直线。 | | `Q cx cy x y` | `curve_to(end, control)` | 二次贝塞尔曲线。 | | `C c1x c1y c2x c2y x y` | `cubic_bezier_to(end, control_a, control_b)` | 三次贝塞尔曲线。 | | `A rx ry rotation large sweep x y` | `arc_to(radii, rotation, large_arc, sweep, end)` | 椭圆弧。 | | `Z` | `close()` | 闭合子路径。 | 几何概念一致,但 Rust 参数顺序并非把 SVG 字符串逐字搬过来:`curve_to` 与 `cubic_bezier_to` **先传终点,再传控制点**。当前 API 的 `x_rotation` 参数类型是 `Pixels`,其数值按角度解释。坐标用 `Point` 表示;`build()` 先三角化,再由 `paint_path` 提交绘制。因此迁移 SVG 绘图算法的学习成本很低,同时要遵循 GPUI 的坐标类型和错误处理规则。 ### 用两种 Path 写法绘制 GPUI Kit 标志 GPUI Kit 标志可以直观说明 SVG Path 与 `PathBuilder` 的对应关系。它由两条独立的闭合路径组成:外形使用主题前景色,内部笔画使用主题的蓝色强调色。切换 GPUI 与 SVG 源码,再与下方的渲染结果对照。示例采用局部 32 × 32 坐标系;GPUI 代码会将 bounds 的 origin 加到每个点上。`foreground` 和 `accent_color` 由调用方提供。
let p = |x: f32, y: f32| point(bounds.left() + px(x), bounds.top() + px(y));

let mut outer = PathBuilder::fill();
outer.move_to(p(4., 4.));
outer.line_to(p(28., 4.));
outer.line_to(p(28., 9.));
outer.line_to(p(10., 9.));
outer.line_to(p(10., 23.));
outer.line_to(p(28., 23.));
outer.line_to(p(28., 28.));
outer.line_to(p(4., 28.));
outer.close();
if let Ok(path) = outer.build() {
    window.paint_path(path, foreground);
}

let mut accent = PathBuilder::fill();
accent.move_to(p(16., 13.));
accent.line_to(p(28., 13.));
accent.line_to(p(28., 23.));
accent.line_to(p(23., 23.));
accent.line_to(p(23., 18.));
accent.line_to(p(16., 18.));
accent.close();
if let Ok(path) = accent.build() {
    window.paint_path(path, accent_color);
}
<svg viewBox="0 0 32 32">
  <path fill="currentColor" d="M4 4 L28 4 L28 9 L10 9 L10 23 L28 23 L28 28 L4 28 Z" />
  <path fill="#3B82F6" d="M16 13 L28 13 L28 23 L23 23 L23 18 L16 18 Z" />
</svg>
用两条彩色路径绘制的 GPUI Kit 标志
前景色与主题蓝色强调色分别绘制两条填充路径。
GPUI 的 `PathBuilder` 接受完整点坐标,没有 SVG 的 `H`、`V` 缩写,也不能直接解析 SVG 的 `d` 字符串。SVG 标签页把每个 `L` 坐标写全,以便逐行对照。如果已有 SVG 文件,而且不需要 `Path`,可以用 `svg().path("icons/logo.svg")` 渲染该资源。要把任意 SVG path 数据变成 GPUI `Path`,则需要另外的解析器,把解析出的线段逐个送入 `PathBuilder`。 ## 哪个阶段完成什么 | 工作 | 阶段 | 原因 | | --- | --- | --- | | 声明尺寸与子节点布局 | `request_layout` | Taffy 此时需要 Style,但最终 bounds 尚不存在。 | | 准备命中测试与绘制共用的几何、插入 `Hitbox` | `prepaint` | 几何坐标和当前帧的输入区域已经确定。 | | 按需构建仅用于绘制的几何、调用 `paint_path`、`paint_quad` 或绘制子节点 | `paint` | 这时可以确定绘制顺序;Brush 在这里构建 Path。 | [GPUI Kit Plot 的折线](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/plot/shape/line.rs) 从数据点构建描边路径;[输入框的底层 Element](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/input/base/element.rs) 用 Path 绘制选区与文本范围装饰,而闪烁光标用 quad 绘制。两者都使用 `PathBuilder`,但输入框还必须协调文字度量和命中测试。 可以用 `window.with_content_mask` 限制绘制区域。绘制、输入 hitbox 与[无障碍语义](./accessibility)彼此独立:画出图形不会自动使它可点击,也不会自动让辅助技术读到它。交互图表还需建立命中区域或指针坐标映射;有语义的数据还需提供无障碍表示。路径三角化有成本,点和尺寸没变时不要重复构建;如果路径存的是窗口绝对坐标,窗口内位置变化时必须更新缓存。简单矩形则优先使用 `paint_quad` 或带样式的 `div()`。 ## GPUI Kit 的三种状态归属 相同的绘制原语可对应不同的状态归属。缓存放在哪里,取决于数据属于谁、会保留多久,以及变化频率。 ### 模型驱动的仪表:Entity 保留几何 由 [Entity](./entity) 驱动的仪表可以分别保存背景弧线、数值弧线和指针三个 `Option>`。数值变化时只清除后两者;若 Path 使用窗口绝对坐标,origin 变化时清除全部。`canvas` 的 prepaint callback 根据 bounds 构建缺失路径,paint callback 用当前主题色绘制。这样几何失效与颜色选择彼此独立。已有模型订阅的 View 适合持有这些缓存,但不要在每次 prepaint 都无条件调用 `cx.notify()`,否则可能每帧额外安排一次 render。 ### Plot:每帧重建的值使用 keyed window state 缓存依赖跨帧稳定的 [ElementId](./element_id)。[`Line`](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/plot/shape/line.rs) 是 render 时创建的值;如果缓存存在它身上,下一帧就会丢失。[`PathCaches::for_paint`](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/plot/path_cache.rs) 在当前 Plot 的 Element ID 下使用 `window.use_keyed_state`。`Line::paint_cached` 把投影后的点、描边宽度和曲线样式组成 shape key;`PathCache::get` 仅在 key 改变时重新三角化。路径以零原点构建,再克隆缓存顶点并平移到当前帧的 origin,所以滚动不会迫使曲线重新三角化,但平移本身仍有成本。数据点则用成本较低的 quad 在新 origin 绘制。这个模式要求 Element ID 稳定;同一个 series 在各帧使用同一个 slot 能提高缓存命中率。若系列按下标重排且 shape key 不同,会造成原本可避免的缓存失效。 ### Input:文本编辑器统筹整个 Element 管线 GPUI Kit 的[输入框底层 Element](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/input/base/element.rs) 不只绘制选区 Path。它测量文字、计算折行与视口几何,在 prepaint 中插入 hitbox,再根据准备好的布局绘制选区 Path、光标 quad 和文字。输入处理与 Focus 也依赖同一份几何结果。复杂编辑器需要自己实现 `Element`,因为文字布局、命中、输入路由和绘制必须共享同一份快照;选区和文本范围装饰的 Path 只是管线的一部分。 选 API 时可据此递进:已有 Entity 只需画一处装饰,用 `canvas`;每帧重建的值需要跨帧缓存,用稳定 ID 对应的 window state;文字、布局、命中和输入必须直接协同时,实现 `Element`。Trait 的三个阶段见 [Element](./element),输入传播见 [Event](./event)。 --- # Testing Source: /versions/v0.7.1/zh-CN/docs/test 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/test 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 # 测试 本指南统一介绍 GPUI Kit 应用和 GPUI 的测试方式。根据要验证的行为选择测试层级: - 纯数据转换、校验和状态转换使用普通 Rust `#[test]`。 - [Entity](./entity)、[Action](./action)、[订阅](./event)和异步 [Task](./task) 使用 `#[gpui_kit::test]` 与 `TestAppContext`,按需创建窗口。 - UI 集成测试渲染真实应用视图,通过 `gpui_kit::test` 派发[事件](./event),再检查控件状态、布局和业务结果。 - 像素检查使用独立的离屏渲染器;原生窗口和平台集成保留相应测试。 类型和 `#[gpui_kit::test]` 均由 Kit 根模块提供,应用无需再添加 GPUI 依赖。测试模块应显式导入用到的类型:`use gpui_kit::*;` 也会引入 GPUI 的 `test` 宏,可能遮蔽 Rust 原生的 `#[test]`。下方完整示例使用显式导入。 ## 什么是 UI 集成测试? **UI 集成测试**在无头窗口中渲染真实组件或应用视图,模拟点击、键盘输入和滚动, 验证组件状态、焦点、布局及业务回调。例如,给 Checkbox 增加 UI 集成测试, 可以验证点击是否修改了宿主持有的值,以及禁用时是否拒绝同样的交互。 `#[gpui_kit::test]` 负责运行测试并提供 GPUI 上下文; `gpui_kit::test` 提供操作和检查界面的工具: ```rust use gpui_kit::{TestAppContext, Window}; use gpui_kit::test::TestWindowExt; ``` 当行为涉及组件之间的协作,例如输入内容、保存对话框、检查父视图中的结果, 就适合使用 UI 集成测试。测试通过 [`ElementId`](./element_id) 定位控件,派发真实 GPUI 事件, 再用普通 Rust 断言检查结果。 本指南介绍进程内的行为与布局自动化。元素快照不会检查像素,也不会启动打包后的应用。像素验证使用下文单独介绍的 GPUI 离屏渲染器。如果需要验证原生窗口、平台集成或视觉效果,应另外保留相应测试。 ## 配置测试项目 UI 测试直接集成在 `gpui-kit` 中,通过 `test-support` feature 启用。下面示例使用包含这些辅助方法的 Kit 源码检出目录,不需要额外测试 crate、GPUI fork 或 Cargo 补丁。 先按照[安装说明](/versions/v0.7.1/zh-CN/docs/installation)准备平台依赖。无头测试仍然需要编译 GPUI 的原生依赖。可以在源码目录旁创建独立测试项目: ```text workspace/ gpui-kit/ ui-tests/ Cargo.toml tests/ui.rs tests/common/mod.rs ``` 在 `ui-tests/Cargo.toml` 中写入: ```toml [package] name = "ui-tests" version = "0.1.0" edition = "2024" publish = false [dev-dependencies] gpui-kit = { path = "../gpui-kit/crates/kit", features = ["test-support"] } ``` 已有应用可以在自己的 package 中添加这个开发依赖。普通 `gpui-kit` 依赖必须解析到相同来源和版本,测试时 feature 才能合并。将 `test-support` 放在开发依赖中,让普通应用构建不启用观察功能。直接使用组件 crate 的应用也可以启用 `gpui-component/test-support`。 ## 一个完整测试 下面直接引用仓库中会参与编译的 `tests/ui.rs`。它初始化组件库,并像真实应用一样将输入状态保存在视图上。其中 `mod common;` 会加载配套的 `tests/common/mod.rs` fixture。这个辅助文件以明确的 640 × 480 窗口尺寸调用公开的 `gpui_kit::open_window`,由该函数用 `gpui_kit::base::Root` 包装视图,并返回窗口句柄与视图 entity。它只是测试准备代码,不是额外的库依赖。 测试会输入 Unicode 姓名,通过 Backspace 编辑,点击 Save,检查状态节点的 AccessKit 角色、名称与布局,最后验证保存的业务值。测试并不能验证屏幕阅读器是否实际播报。以下代码直接引用仓库集成测试的源码,会实际编译运行。 ```rust mod common; use gpui_kit::test::{TestSupportExt, TestWindowExt}; use gpui_kit::{ AppContext, Context, Entity, Role, SharedString, TestAppContext, Window, component::{ button::Button, input::{Input, InputState}, }, div, prelude::*, px, size, }; struct Profile { name: Entity, submitted: Option, } impl Render for Profile { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let status = self.submitted.as_ref().map_or_else( || SharedString::from("Not saved"), |name| SharedString::from(format!("Saved: {name}")), ); div() .size_full() .flex() .flex_col() .p_4() .gap_4() .child( div().flex().flex_col().gap_1().child("Profile name").child( Input::new(&self.name) .id("name") .aria_label("Profile name") .w(px(240.)), ), ) .child( Button::new("save") .label("Save") .on_click(cx.listener(|this, _, _, cx| { this.submitted = Some(this.name.read(cx).value()); cx.notify(); })), ) .child( div() .id("status") .role(gpui_kit::Role::Status) .test_support() .aria_label(status.clone()) .child(status), ) } } #[gpui_kit::test] fn saves_a_profile_through_the_ui(cx: &mut TestAppContext) { cx.update(gpui_kit::init); let (handle, profile) = common::open_window(cx, Some(size(px(640.), px(480.))), |window, cx| { let view = cx.new(|cx| Profile { name: cx.new(|cx| InputState::new(window, cx)), submitted: None, }); view }); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); assert_eq!(window.find("status").role(), Some(Role::Status)); assert_eq!(window.find("status").label(), Some("Not saved")); window.click("name", cx); window.input("Ada José", cx); let name = window.find("name"); assert_eq!(name.label(), Some("Profile name")); assert_eq!(name.focused(), Some(true)); assert_eq!(name.value(), Some("Ada José")); assert!(name.bounds().size.width > px(0.)); // Named keys share the same Window API and refresh the resulting frame. window.press("backspace", cx); assert_eq!(window.find("name").value(), Some("Ada Jos")); window.click("save", cx); let status = window.find("status"); assert!(status.visible()); assert_eq!(status.label(), Some("Saved: Ada Jos")); assert!(status.bounds().top() >= window.find("save").bounds().bottom()); }) .unwrap(); // Verify the application result as well as the native properties. cx.update(|cx| { assert_eq!(profile.read(cx).submitted.as_deref(), Some("Ada Jos")); }); } ``` 独立项目必须复制**两个**文件;只复制 `ui.rs` 会在 `mod common;` 处失败。在上述布局的 `ui-tests/` 目录运行: ```sh mkdir -p tests/common cp ../gpui-kit/crates/kit/tests/ui.rs tests/ui.rs cp ../gpui-kit/crates/kit/tests/common/mod.rs tests/common/mod.rs cargo generate-lockfile cargo test --test ui --locked ``` 将 `Cargo.lock` 一起提交。在自己的应用中,应从 library crate 导入生产视图及其构造函数,替换示例中的 `Profile`,继续沿用窗口设置与交互写法。另写一份测试视图容易与应用实现脱节。在 GPUI Kit 源码目录中,可以直接运行同一个测试: ```sh cargo test -p gpui-kit --features test-support --test ui --locked ``` ## 选择稳定的测试目标 启用 `test-support` 后,以下控件在已有原生元素上注册,不增加布局容器: | 控件 | 除几何与可见性以外报告的状态 | | --- | --- | | Button | 无障碍名称、焦点作用域 | | Input | 非敏感无障碍值、名称、焦点作用域 | | Checkbox | 勾选、半选、名称、焦点作用域 | | Switch / Toggle | 勾选、名称、焦点作用域 | | Radio | 勾选、选中、名称、焦点作用域 | | Tab | 选中、名称 | | Command | 原生选项 selected 状态、面板焦点作用域和行边界 | | Combobox | 原生 expanded 状态与焦点作用域;选择结果通过事件及状态验证 | | Select | 无障碍值(包含标题前缀)、展开、焦点作用域 | | ListItem / SidebarMenuItem | 几何;其他状态仅在原生无障碍属性提供时可读 | | Accordion | 触发器展开状态;标题与面板边界 | | Tree | 原生树与节点角色、名称、选中与展开状态;根节点焦点作用域 | | Table / DataTable | 原生表格部件;DataTable 行选中状态与根焦点作用域 | | DatePicker / Calendar | 日期选择器显示的日期值、展开与焦点作用域;日历项名称与边界 | | Slider | 轨道与滑块边界;`ElementSnapshot::value()` 不读取数值型无障碍属性 | | Stepper | 步骤与触发器边界;通过应用内容验证导航结果 | | Dialog / Sheet | 宿主焦点作用域与内容表面边界;子控件保留各自属性 | | Menu | 菜单项名称与选中状态、菜单焦点作用域、子菜单边界 | | Notification | Alert 角色与边界;关闭按钮沿用 Button 观察 | | Dock | 区域、分组与内容边界及焦点作用域;Tab 保留原生选中状态 | 优先使用构造函数 ID。Input 和 Select 支持 `.id("name")`,默认 ID 包含状态 entity ID。 TabBar 内的 Tab 使用下标 ID。Select 已有的 `"input"` 子元素是触发区域: `window.within("language").click("input", cx)`。 原生 div 只注册观察,不再填写另一份测试状态: ```rust use gpui_kit::TestSupportExt as _; let target = div().id("details").test_support().child(content); ``` `TestSupportExt` 始终可用。关闭 `test-support` 时,`.test_support()` 直接返回原生元素, 保留其准确类型;启用后保留身份、布局、事件与无障碍接口,不增加布局容器。 重复调用只保留一个注册项。先调用 `.test_support()`,再调用 `.track_focus(&handle)`, 让包装器观察实际焦点绑定;Kit 控件在内部完成这件事。`focused()` 检查焦点作用域内 是否存在键盘焦点,也包括 Input 外框中的编辑器。如果 GPUI 声明元素可聚焦,但没有 观察到绑定,`focused()` 会报错并给出修复提示,不再静默返回 `None`。这能发现 `.track_focus(&handle).test_support()` 的顺序错误;隐式 `.focusable()` 句柄也无法读取, 应改用显式句柄。没有观察到绑定、也没有声明焦点 action 时返回 `None`。 这个诊断是尽力而为的,依赖原生 accessibility 的 `Action::Focus`。 自定义元素如果没有声明此 action,遗漏绑定时仍可能返回 `None`,因此 `None` 不能证明元素无法获得焦点。检测到遗漏绑定时,Debug 输出 `focused: `,格式化本身不会 panic。 快照直接读取 `role`、`aria_toggled`、`aria_selected`、`aria_expanded`、 `aria_label` 和 `aria_value`。没有 `TestProps` 或手填的备用值。Input 在测试中启用 已有的无障碍值生成路径,沿用相同的遮蔽与敏感内容限制。Select 的 `value()` 是包含 标题前缀的无障碍值,不是选中项 ID。 `label()` 表示无障碍名称,不是屏幕文字;`value()` 表示无障碍值,不是像素。 组件仍然可能把这些属性写错。不要仅为让视觉断言通过而添加 `aria_label` 或 `aria_value`。完整示例中的 Status 角色和名称服务于生产环境的无障碍播报。 框架不自动发现任意子元素文字,也不提供用模型字符串冒充绘制文本的 `text()`。 `disabled()` 仅在原生节点暴露禁用标志时返回 `Some(true)`,否则返回 `None`。 当前 GPUI 的 div 接口不能据此提供确定的启用状态。验证禁用行为时,应尝试交互并 检查应用结果没有变化;不能把 `None` 当作启用。 这个接口无法可靠地正面断言 enabled 属性。验证按钮接受操作时,应执行操作并断言 它应该产生的结果,例如: ```rust window.click("save", cx); assert_eq!(window.find("status").label(), Some("Saved: Ada")); ``` 这里应使用应用真实的预期结果。`assert_ne!(button.disabled(), Some(true))` 或 `button.disabled().is_none()` 都不能证明按钮可以正常响应。 ID 只需在 GPUI 身份作用域内唯一。窗口级查询遇到重复 ID 会报歧义;可以直接使用已有父级作用域,无须添加测试容器: ```rust window.within("toolbar").click("save", cx); window.within("dialog").click("save", cx); let save = window.within("dialog").within("footer").find("save"); assert!(save.visible()); ``` 父级本身不必被观察:它的 ID 已经包含在被观察子元素的 GPUI 路径中。 `within` 要求当前已绘制路径唯一。列表使用 `("row", record_id)` 等复合 ID,可保持重排后的记录身份。 ## 操作与断言 导入 `gpui_kit::test::TestWindowExt` 后使用以下方法: | API | 行为 | | --- | --- | | `window.find(id)` | 严格返回最近完成帧的 `ElementSnapshot`;缺失时 panic,列出注册路径与排查提示。 | | `window.try_find(id)` | 缺失时返回 `None`,歧义仍会 panic。 | | `window.click(id, cx)` | 在中心发送原生鼠标移动、按下与释放。 | | `window.click_at(id, offset, cx)` | 相对于目标左上角的像素偏移点击,适合部分裁剪。 | | `window.right_click(id, cx)` / `double_click(id, cx)` | 原生右键或两次点击序列。 | | `window.hover(id, cx)` | 移动指针,不按键。 | | `window.scroll(id, delta, cx)` | 原生滚轮事件,`ScrollDelta` 保留 GPUI 的方向与单位。 | | `window.drag_to(from_id, to_id, cx)` | 定位两个目标,在其中心之间通过真实命中测试拖拽。 | | `window.drag(from, to, cx)` | 窗口坐标之间的左键拖拽,经过真实拖拽创建与放置命中测试。 | | `window.press("backspace", cx)` | 使用 GPUI 按键解析器,为特殊键或快捷键发送原生 key-down/key-up 事件。 | | `window.input(text, cx)` | 向当前焦点逐字符输入,不自动聚焦或替换整个值。 | 作用域支持 `find`、`try_find`、嵌套 `within`、`click`、`click_at`、`right_click`、 `double_click`、`hover`、`scroll`、`drag_to`、`press` 和 `input`。 `drag_to` 的两个 ID 都在当前作用域中解析。跨作用域拖拽或指定偏移时,可查询目标后 将窗口坐标传给 `window.drag`。 ```rust let mut dialog = window.within("dialog"); dialog.click("name", cx); dialog.input("Ada", cx); dialog.press("backspace", cx); dialog.hover("help", cx); ``` 作用域内的键盘操作不会移动焦点,必须先有一个已观察的焦点绑定位于该作用域中, 否则派发前就报错。`input` 在每个字符前检查,所以处理器把焦点移到作用域外时, 剩余文字不会输入到其他控件。需要窗口级快捷键时,使用 `window.press`。 自定义输入控件需要在实际承载焦点的元素上调用 `.id("editor").test_support().track_focus(&focus_handle)`,使用控件真正的焦点句柄。 未观察的输入控件,或没有绑定焦点句柄的外层容器,即使实际焦点位于作用域内, 也无法通过检查。窗口级 `input` 和 `press` 向当前焦点派发,但不提供作用域保证。 作用域输入与窗口输入共用同一循环:开始时刷新一次,随后每个字符刷新一次, 每次作用域检查都读取已完成的帧。 `ElementSnapshot` 是某次完成绘制的独立、不可变记录。它提供 `role()`、`path()`、`bounds()`、 `visible()`、`focused()`、`disabled()`、`label()`、`value()`、`checked()`、 `indeterminate()`、`selected()` 和 `expanded()`。焦点、禁用、勾选、半选、选中、展开状态返回 `Option`: `None` 表示无法取得,不等于 false。名称与值也可能无法取得。交互后重新查询: ```rust let before = window.find("agree"); window.click("agree", cx); assert_eq!(before.checked(), Some(false)); // Previous frame. assert_eq!(window.find("agree").checked(), Some(true)); // New frame. ``` 同时断言界面状态与业务结果。验证保存的模型或发出的事件也是集成测试的一部分, 但不能取代相关控件可见状态的验证。包括 Enter 在内的命令按键不应通过 IME 回调注入换行文字。 文本输入不模拟完整的系统 IME 组合输入; 密码输入框不报告值,需要时通过应用状态验证结果。 ## 查询前完成一帧 第一次查询、外部直接修改状态或焦点、调整尺寸后,调用 `window.render_frame(cx)`。 交互方法会在同步派发过程中刷新,包括 `press`。但外层 window update 尚未返回时, 它们不能完成需要释放该借用的延迟回调。 ```rust cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window.click("name", cx); window.input("Ada", cx); window.press("backspace", cx); assert_eq!(window.find("name").value(), Some("Ad")); }).unwrap(); ``` 使用 `TestAppContext::update_window`。带类型的 `WindowHandle::update` 已经借用根 entity, 不能在同一个回调中安全地重绘它。 异步工作或 Select 的延迟提交,应在 async `#[gpui_kit::test]` 中、window update **外部**等待: ```rust use gpui_kit::test::TestAppContextExt; use std::time::Duration; cx.wait_for(handle.into(), Duration::from_millis(200), |window, _| { window.try_find("result").is_some_and(|snapshot| snapshot.visible()) }).await; ``` `wait_for` 按 GPUI 测试执行器时钟每 10ms 刷新并检查条件,超时报错列出注册路径。 它是有界的条件等待,不模拟操作系统事件循环或网络服务;外部依赖需要受控响应。 执行器停驻本身不代表定时器或延迟工作已经完成。 快照永不原地更新。缓存视图保留绘制事实,直到被失效并重绘。卸载目标在释放其 element state 的帧完成后消失;虚拟列表行则在滚动后实际绘制时进入查询结果。 GPUI `dispatch_action` 会排队执行。继续修改 action 将读取的值之前,应先完成派发, 例如离开 `update_window` 后运行 `cx.run_until_parked()`;结果或计时器完成使用 `wait_for`。 旧的非同步 GPUI `Animation` 使用真实时钟 `Instant`,推进测试时钟不会让动画结束。 Sheet/Notification 几何测试会等待真实入场时长,再断言最终边界。 Base motion 则可以响应公开的 `cx.set_reduce_motion(true)` 偏好,用于测试展开后的最终几何。 ## 覆盖范围与失败排查 仓库通过真实输入、原生属性和布局边界验证以下组件流程。这些是具体的回归契约, 不代表已穷举每个组件的全部配置和组合。 | 测试文件 | 验证行为 | | --- | --- | | `test_macro.rs` | 普通 `#[test]` 与同步/异步 `#[gpui_kit::test]` 共存;独立、仅依赖 Kit 的 recipes 包复用相同契约 | | `input.rs` 与 `input/` | Input、Textarea、Editor 的编辑、剪贴板、选区、历史记录、只读切换、Unicode、多行视口、搜索替换、补全确认,以及重绘后的状态保留 | | `input_focus.rs` | 带静态前后缀的输入框反复 Tab/Shift-Tab 遍历、前后缀按钮的焦点与激活,以及点击 Textarea/Editor 正文后的焦点和编辑 | | `search.rs` | Command 禁用项跳过、循环导航、中文关键词、空结果、Action 与原始索引回调、两阶段 Escape;Combobox 搜索、单选/多选、清除、空结果恢复、禁用行为及关闭时仅一次 Confirm | | `disclosure.rs` | Accordion 互斥展开、折叠与实际面板几何;Stepper 内容导航;禁用展开与步骤操作;Slider 轨道点击、滑块拖动与禁用行为 | | `collections.rs` | Tree 点击展开、键盘展开/折叠与选择;DataTable 行选择、键盘虚拟滚动与滚轮滚动 | | `date_picker.rs` | 打开、精确预设日期与日历日期选择、月份切换、清除、Escape 与禁用行为 | | `overlays.rs` | Dialog 校验 → 作用域 Input → 保存 → Notification;悬停显示关闭按钮;自动关闭计时;Dialog/Sheet Escape 与焦点恢复;表面边界 | | `menu.rs` | 禁用菜单项、键盘确认、Escape、焦点恢复、子菜单悬停及嵌套菜单项激活 | | `dock.rs` | Tab 选择与重排、跨分组拖放、放大和恢复分割布局 | 已有表单、Select、HoverCard、虚拟列表、指针、生命周期和隔离测试继续保留。 纯展示组件通过几何或像素断言验证,不虚构交互状态。自定义部件观察已有原生元素; 不支持的属性保持不可用,不提供手填测试值的覆盖入口。 [Input 回归测试范例](https://github.com/longbridge/gpui-kit/tree/main/crates/kit/tests/input) 展示了如何把手工编辑步骤变成可重复的 UI 测试。在仓库根目录运行编辑和焦点两个目标, 或选择单个流程: ```sh script/test-input # 完整回归入口:Base、Component 和 Kit(使用 Bash) cargo test -p gpui-kit --features test-support --test input --test input_focus --locked cargo test -p gpui-kit --features test-support --test input --locked -- history::paste_is_atomic_and_separate_from_surrounding_typing --exact cargo test -p gpui-kit --features test-support --test input_focus --locked -- reverse_tab_cycles_three_inputs_with_passive_addons --exact ``` 在合并运行命令后追加 `-- --list`,可列出用例而不执行。 这些命令用于复现,不代表已有通过记录。报告运行结果时,请注明代码版本、平台、命令和实际结果。 示例流程包括输入 → 粘贴 → 输入 → Undo/Redo、Textarea 的 Enter 提交与 Shift-Enter 换行, 以及 Editor 补全 → 接受 → Undo。每个流程读取新的快照,必要时结合公开状态或应用事件验证。 补全使用提供固定响应的 provider,不连接实际语言服务器。 独立的 `input_focus` 目标在 window update 结束后验证焦点回调。 测试还覆盖公开输入法接口的预编辑、UTF-16 范围、提交/取消与 Undo 边界,以及多光标、折叠、异步 provider 的取消和失败。 普通 Input 改动以操作覆盖表和各平台 CI 为验收依据,并为实际改动补充回归用例,减少重复手工检查整套编辑操作。 这些用例不证明完整的系统 IME、辅助功能动作、系统剪贴板或像素显示正确; 这些边界需要相应的平台验证。 通过 `WindowExt` 打开 Dialog、Sheet 或 Notification 的视图,需要窗口的根视图是 `Root`。 `Root` 始终将这三类浮层渲染在应用内容之上,缓存视图也一样,无需手动挂载。 重复控件使用 `within`。Sheet 的 `"sheet"` 宿主作用域包含 `"sheet-content"` 内容表面; Dialog 的 `"dialog"` 作用域包含以层下标标识的表面。子菜单也包含 `"popup-menu"`, 打开子菜单时应保留已解析的父作用域,或在 `"submenu"` 下查询。 不要假定打开另一层之后,原先唯一的 ID 仍然唯一。 目标缺失或不可见时点击会 panic。禁用控件仍接收原生事件,由控件自己决定是否响应。 可见性结合几何、视口与内容裁剪、目标计算样式,不判断像素遮挡;覆盖层仍会拦截点击。 `click_at(id, point(px(10.), px(10.)), cx)` 可以选择裁剪后可见的部分,不会绕过命中测试。 观察依赖 feature,因此被测制品与生产制品并非逐字节相同。透明包装器不增加布局盒子, 但可见性检查会额外计算一次样式;style/drag 谓词不能依赖调用次数。 GPUI 没有公开未观察祖先的继承绘制透明度,因此无法推断该情况。 实现没有使用 GPUI fork 或 Cargo patch 绕过这些限制。 失败时按具体情况检查注册路径、观察配置、完成帧、键盘焦点、裁剪与覆盖层、异步完成条件。 | 现象 | 优先检查 | | --- | --- | | 找不到 `mod common` | 连同 `tests/ui.rs` 复制 `tests/common/mod.rs`,或改用应用自己的窗口设置代码。 | | `find` 列不出匹配路径 | 检查 `test-support`、控件 ID 或 `.test_support()`,以及首次查询前的 `render_frame`。 | | 查询结果不唯一 | 用 `within` 定位已有父级,再查询子控件 ID。 | | `focused()` 提示遗漏绑定,或作用域 `input` 报错 | 在 `.track_focus(&handle)` 前观察元素,输入前点击正确的输入框。 | | 断言仍读到旧状态 | 完成帧后重新查询快照;若工作已排队,离开 `update_window` 并使用 `wait_for`。 | | 目标可见却收不到点击 | 检查裁剪和浮层顺序;指针辅助方法使用原生命中测试。 | ## 独立验证绘制结果 值或勾选标志正确,不代表控件正确绘制。GPUI 提供 `HeadlessAppContext::with_platform`、`Window::render_to_image` 和 `HeadlessAppContext::capture_screenshot`,可以生成真实离屏图片。当前锁定版本的 平台 crate 仅在 macOS 提供 Metal 离屏渲染器。在支持 Metal 的 Mac 上执行: ```sh cargo test -p gpui-kit --features test-support --test rendering --locked ``` 该目标设置了 `test = false`,默认 Cargo 命令不会选择它。macOS CI job 已增加必须 通过的独立步骤,显式执行 `--test rendering`;Linux 和 Windows 只运行交互与布局 测试。这使用 Cargo 的 [显式目标选择](https://doc.rust-lang.org/cargo/commands/cargo-test.html#target-selection)。 目标还使用 `harness = false`,因为 AppKit 必须在主线程初始化;普通 Rust 测试即使指定 `--test-threads=1` 仍运行在工作线程。其他平台明确报告跳过像素验证; macOS 缺少渲染能力时测试失败,不用假图片替代。 测试向真实 Kit 控件注入两种故障:`checked()` 仍为 true,但勾号资源丢失; `value()` 仍正确,但输入文字变透明。故障图片必须与正常控件不同,重复绘制正常 Checkbox 的图片必须一致。另一个原生事件测试断开 Checkbox 的状态更新处理器, 验证点击不会凭空产生已勾选结果。 这些测试验证能否发现特定错误,不是完整的基准图片回归测试。应用的视觉回归应在 固定字体、尺寸、主题、焦点和动画状态下,将图片与已审查的预期结果比较。状态与 图片断言能发现不同的故障;两者都不能证明打包应用或完整 IME 行为正确。 可执行示例见 [`crates/kit/tests/rendering.rs`](https://github.com/longbridge/gpui-kit/blob/testing/crates/kit/tests/rendering.rs)。 ## 接入 CI Kit 仓库在 macOS、Linux 和 Windows 矩阵中运行交互与布局测试。macOS job 还运行两个 Metal 像素测试,失败会使 job 失败。以下是用于 Kit 检出目录的最小 macOS workflow: ```yaml name: UI tests on: [push, pull_request] jobs: test: runs-on: macos-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - run: ./script/bootstrap - run: cargo test -p gpui-kit --features test-support --locked - run: cargo test -p gpui-kit --features test-support --test rendering --locked ``` 应用仓库需要安装自身的平台依赖,并改为在测试 package 中运行 `cargo test --test ui --locked`。将锁定版本的 Kit 源码放到 manifest 声明的路径,再按照普通原生构建的环境配置增加 Linux 和 Windows job。 仓库测试还覆盖只读与禁用输入、焦点变化、缓存视图、挂载与卸载、多窗口隔离、原生命中测试,以及列表从 1,000 个元素缩减后的清理。大列表用例验证正确性,不是渲染性能基准。 --- # WebView Source: /versions/v0.7.1/zh-CN/docs/webview 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/webview 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 [`gpui-wry`](https://github.com/longbridge/gpui-kit/tree/main/crates/webview) 是 GPUI Kit 基于 [Wry](https://github.com/tauri-apps/wry) 的**实验性**集成。需要浏览器行为时可以使用它;[TextView HTML](/versions/v0.7.1/zh-CN/component/text-view#html) 用于渲染文档内容,并不是浏览器。要在默认外部浏览器中打开 URL,使用 [`cx.open_url`](./context)。当前集成支持 macOS 和 Windows。仓库示例中的 Linux 路径尚未完成。 ## 运行示例 在仓库根目录运行: ```sh cargo run -p webview ``` [完整示例](https://github.com/longbridge/gpui-kit/blob/main/examples/webview/src/main.rs)是可运行的起点:它在 `open_window` 回调中创建原生子视图,把它包装为 `Entity`,再放在地址输入框下方渲染。在输入框按 Enter 会调用 `load_url`;示例还包含返回上一页的处理函数。从仓库根目录运行上面的命令。其他应用请参照[示例的依赖配置](https://github.com/longbridge/gpui-kit/blob/main/examples/webview/Cargo.toml):这条集成路径直接依赖 `gpui-kit`、`gpui-wry`、`wry`(package 名为 `lb-wry`)和 `raw-window-handle`。 ```rust use gpui_kit::*; use gpui_wry::WebView; let webview = cx.new(|cx| { use raw_window_handle::HasWindowHandle; let handle = window.window_handle().expect("No window handle"); let native = wry::WebViewBuilder::new() .build_as_child(&handle) .expect("Failed to create WebView"); WebView::new(native, window, cx) }); webview.update(cx, |view, _| view.load_url("https://gpui-kit.com")); // In the owning View's render method: div().flex_1().child(webview.clone()) ``` 这段代码展示 macOS 和 Windows 的子视图路径。要等 GPUI 提供有效的 `Window` 后才创建原生视图;所属 View 保存 `Entity`,让它在多次 render 之间持续存在。完整示例先调用 `gpui_kit::init(cx)` 初始化组件,再通过 `gpui_kit::open_window(...)` 创建窗口。完整的应用启动代码请以链接的源码为准;此片段不是独立的 `main` 函数。 ## 视图归属与布局 `WebView::new` 起初把原生 bounds 设为空矩形。渲染 Entity 会安装 GPUI layout element;在 `prepaint` 阶段,该 element 把算出的 bounds 以**逻辑坐标**传给 Wry,并插入 GPUI hitbox。容器必须有实际尺寸。示例使用 `div().flex_1().h(px(400.)).child(self.webview.clone())`。浏览器像素由操作系统绘制,所以 GPUI clipping、hitbox 和 content mask 都不能让 GPUI 内容盖过这个子视图。 每个原生视图保存一个 `Entity`,不要在每次 `render` 时重新构建 Wry 视图。包装层的 `visible()` 和 `bounds()` 分别返回保存的可见状态及上次 layout 的 bounds;`show()` 和 `hide()` 改变原生可见性。如果页面或标签不再渲染该 Entity,应在需要消失时明确调用 `hide()`,恢复时调用 `show()`。隐藏期间 `prepaint` 不更新 bounds。 在 GPUI UI context 中通过 `webview.update(cx, |view, _| ...)` 调用包装层方法。`load_url(&str)` 请求导航,但丢弃 Wry 的 `Result`,也不表示加载完成。`back()` 执行 JavaScript `history.back()`,其 `Result` 只反映脚本提交,不表示历史导航成功。需要返回值时,通过 `view.raw()` 使用 Wry 的 `reload()`、`url()` 或 `evaluate_script()` 等方法。短期回调需要持有 Wry 对象时可使用 `view.handle().raw()`,但这只适用于 UI 线程;不要仅为了避免更新 Entity 而长期保留 handle。 ## 加载、导航与页面消息 页面策略与回调应在 `build_as_child` **之前**配置到 `wry::WebViewBuilder` 上。它们属于 Wry,不是 `gpui-wry` 事件。仓库固定的 `lb-wry` 版本提供: | 需求 | Wry API | 含义 | | --- | --- | --- | | 初始内容 | `with_url(...)` 或 `with_html(...)` | 构建前选择内容;仓库示例则在创建后调用包装层的 `load_url`。 | | 加载状态 | `with_on_page_load_handler(|event, url| ...)` | 观察 `Started` 和 `Finished`;`Finished` 不表示加载成功,也不提供 HTTP 状态。这与 `load_url` 是否接受请求是两件事。 | | 导航策略 | `with_navigation_handler(|url| -> bool { ... })` | 返回 `true` 允许导航,返回 `false` 取消。初始 URL 之外还要考虑链接和重定向。 | | 新窗口请求 | `with_new_window_req_handler(...)` | 决定如何处理 `window.open`;其回调有平台相关的线程约束。 | | Rust 向页面发送 | 在原始 Wry 视图上调用 `evaluate_script(...)` | 提交 JavaScript 并返回 Wry `Result`;需要序列化返回值时使用 `evaluate_script_with_callback`。 | | 页面向 Rust 发送 | `with_ipc_handler(|request| ...)` | 接收 `window.ipc.postMessage(...)` 发送的字符串。执行操作前解析、验证消息。 | 例如,在构建 builder 时可附加一个只允许固定 URL 的导航策略: ```rust let builder = wry::WebViewBuilder::new() .with_navigation_handler(|url| url == "https://gpui-kit.com/") .with_on_page_load_handler(|event, url| { // Send a brief status message to the owning GPUI View if the UI must update. // Do not capture &mut Window, &mut App, or &mut Context here. let phase = match event { wry::PageLoadEvent::Started => "started", wry::PageLoadEvent::Finished => "finished", }; eprintln!("{phase}: {url}"); }); ``` 此精确匹配仅用于展示回调形式;实际白名单应解析 URL,并明确校验 scheme 与 host。回调中的工作应保持简短。通过安全的调度或 channel 把状态和 IPC 消息送到应用持有的状态,再在 GPUI context 中更新 Entity;界面变化时调用 `cx.notify()`。Wry 回调不是 GPUI Entity 事件;Windows 的新窗口回调还会在另一线程运行。应处理回调到达时页面已导航或所属窗口已关闭的情况。 ## 安全与失败边界 把页面内容视为不可信输入,包括来自自己控制的 URL 的内容。IPC 是应用能力入口:仅接受预期的消息类型与长度;平台提供发送方 origin 时应检查;执行文件、网络或账户操作前还要检查应用层授权。Wry 在 Linux/Android 上对 iframe 的 IPC request URL 使用主 frame URL,因此不能仅凭它确认 iframe 身份。初始化脚本会在新页面执行;固定版本的 Wry 文档指出,即使请求只注入主 frame,Windows 仍会注入子 frame。不要把秘密放进脚本或页面全局变量。 展示远程内容前,应决定下载、外部链接和 `window.open` 的处理策略。Wry 默认的下载开始回调允许下载;如果这不符合应用要求,应配置下载策略。在所属 GPUI View 中维护用户可见的加载和错误状态:`gpui-wry::WebView::load_url` 忽略即时错误,而成功提交请求也可能最终加载失败。分别使用原始 Wry 返回值与页面加载回调作为信号;单靠 `PageLoadEvent::Finished` 或 `load_url` 都不能证明内容成功加载。应为应用能够检测到的失败提供重试入口。 ## Focus 与生命周期 WebView 是**原生子视图**,不是 GPUI 绘制的 Element。它占据 GPUI layout 节点的 bounds,并接收浏览器原生输入。`WebView` 实现了 `Focusable`,包装层跟踪一个 `FocusHandle`。调用 `hide()` 时会先把 Focus 交回父视图;点击其 bounds 之外也会请求父视图取得 Focus。界面同时使用 GPUI input 和浏览器 input 时,应验证实际键盘与 Focus 行为。 WebView 及其 handle 应在父窗口销毁前结束生命周期。销毁所属 Entity 会隐藏子视图,但克隆的 `WebViewHandle` 或帧内持有的克隆可能推迟原生视图销毁。销毁父窗口前应释放这些 handle。状态归属参见 [Entity](./entity),窗口 handle 参见 [Window](./window)。 ## 调试与验证 示例在 debug 构建或启用其 `inspector` feature 时请求启用 Wry devtools,但不会自动打开。在 macOS release 构建中,Wry 还要求启用它自己的 `devtools` feature,这项请求才会生效。Wry 的 `open_devtools()` 仅在 debug 或该依赖 feature 下可用。通过页面检查工具区分 JavaScript/网络问题与 GPUI layout 问题。遇到空白子视图,先确认 GPUI 容器的 bounds 非零、`visible()` 为 true、原生视图来自当前有效窗口。在 Windows 上还应核对下文示例的 DirectComposition 设置。导航与 IPC 需在真实原生窗口中验证;GPUI 的 headless UI 测试不能证明原生浏览器像素、系统 Focus 或 compositor 层级。 在每个受支持的操作系统上做应用级 smoke test:初次加载、链接与重定向、返回与刷新、新窗口和下载策略、加载失败/离线、IPC 输入验证、窗口尺寸变化、显示/隐藏、键盘 Focus 转移,以及关闭父窗口。纯策略解析可以单独测试。仓库示例展示加载与布局,但没有完整的导航、bridge 或错误状态测试套件。 ## 当前限制 | 范围 | 当前行为 | | --- | --- | | 平台 | macOS 和 Windows 实验性支持。Linux 示例的 GTK hosting 路径尚未完成,不能视为已支持。 | | Overlay 层级 | 原生 WebView 位于 GPUI surface 上方,会遮住同一矩形范围内的 GPUI 内容,包括 popover、dialog、menu 和 tooltip。GPUI overlay 无法可靠地显示在它上方。 | | Windows renderer | 仓库示例在启动 GPUI 前设置 `GPUI_DISABLE_DIRECT_COMPOSITION=true`,以便当前子视图方案正常渲染。这是此示例的要求,不是 GPUI 的通用设置建议。 | 需要显示 overlay 时,可以将 WebView 放在单独窗口,或安排布局使 overlay 不跨过 WebView 的 bounds。当前实现不支持把普通 GPUI overlay 作为可依赖的交互显示在 WebView 上方。 ## 尚未合并的 Composition 尝试 以下 PR 探索 overlay composition。**它们都不属于上文所述的当前 `gpui-wry` 行为。**应用采用实验分支前,应重新核对 PR 状态和实现: - [GPUI Kit #2626](https://github.com/longbridge/gpui-kit/pull/2626) 尝试将 GPUI overlay 绘制在原生 WebView 上方。当前分支依赖 [Zed/GPUI #61945](https://github.com/zed-industries/zed/pull/61945),后者为 deferred GPUI overlay 提供可选的分层 scene。GPUI Kit 这项 PR 验证了 macOS 路径;Windows composition 和 Linux hosting 在该 PR 中仍属于后续工作。 - [Zed/GPUI #62379](https://github.com/zed-industries/zed/pull/62379) 提出另一套范围更广、可选启用的 `CompositionTree`,用于编排 GPUI 与原生 surface,并带有 macOS 和 Windows 示例。它是 #61945 的替代方案,**不是** GPUI Kit #2626 的依赖。Linux composition 不在这项 PR 的范围内。 这些实验在合并前仍可能变化。它们说明了探索方向,并未消除当前的平台、overlay 和 Focus 限制。 --- # Event Source: /versions/v0.7.1/zh-CN/docs/event 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/event 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 GPUI 提供 **Event**,用于在 [Entity](./entity) 之间发送类型明确的通知。Event 报告已经发生的事实;与 [**Action**](./action) 不同,它不经过 Focus、Key Context、KeyBinding 或 Dispatch Path。GPUI 也把原始鼠标和键盘输入称为 event;它们遵循另一套规则,下文分别说明。 ## Action 进入,Event 返回 Action 可以触发状态变化,但 Event 的传递从状态变化之后开始: ```text Chat state changes → emit(MessageSent) → Subscriber receives event → Workspace updates ``` Chat 发出一个 MessageSent Event,分别送达 Workspace、Activity Log 与 Telemetry 三个独立订阅者 Chat 发出一个 MessageSent Event,分别送达 Workspace、Activity Log 与 Telemetry 三个独立订阅者 - [**Action**](./action) 把意图向内传递:“发送这条消息”; - **Event** 把结果向外报告:“这条消息已经发送”。 Command owner 处理 Action 并改变状态,再发出 Event,让 owner 或 service 响应结果,而不必依赖命令来自快捷键、按钮还是菜单。Focus 与命令派发见 [Action](./action),快捷键匹配见 [KeyBinding](./keybinding)。 ## 从零完成一次 Event 传递 下面的小程序有两个 Entity。`Chat` 保存发送数量并发出带类型的事实;`Workspace` 持有 `Chat`,订阅这个具体的 Entity,并显示最新数量。点击按钮后,`Chat` 更新状态,订阅回调再更新 `Workspace`。 沿用[入门指南](./getting-started)中已经依赖 `gpui-kit` 的项目,把以下完整程序写入该项目的 `src/main.rs`,然后在项目目录运行 `cargo run`。窗口开始显示 **Messages sent: 0**;每次点击 **Send**,数量应增加一。 ```rust use gpui_kit::*; use gpui_kit::assets::Assets; use gpui_kit::component::button::Button; #[derive(Clone, Debug)] enum ChatEvent { MessageSent { total: usize }, } struct Chat { sent: usize, } impl EventEmitter for Chat {} impl Chat { fn send(&mut self, cx: &mut Context) { self.sent += 1; cx.emit(ChatEvent::MessageSent { total: self.sent }); } } struct Workspace { chat: Entity, shown_total: usize, _subscriptions: Vec, } impl Workspace { fn new(cx: &mut Context) -> Self { let chat = cx.new(|_| Chat { sent: 0 }); let subscription = cx.subscribe(&chat, |workspace, _chat, event, cx| { match event { ChatEvent::MessageSent { total } => workspace.shown_total = *total, } cx.notify(); // The count shown by Workspace changed. }); Self { chat, shown_total: 0, _subscriptions: vec![subscription], } } } impl Render for Workspace { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .p_4() .child(format!("Messages sent: {}", self.shown_total)) .child(Button::new("send").label("Send").on_click(cx.listener( |workspace, _, _, cx| { workspace.chat.update(cx, |chat, cx| chat.send(cx)); }, ))) } } fn main() { gpui_kit::application().with_assets(Assets).run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(Workspace::new) }) .expect("failed to open window"); }); } ``` 启动代码先调用 `gpui_kit::init`,再由 `open_window` 创建窗口。按钮回调更新 `Chat`,状态变化和 `cx.emit` 都发生在 `Chat` 内;订阅者读取 Event 的内容,并在修改了 `Workspace` 显示的数量后调用 `cx.notify()`。 `cx.emit(...)` 将 Event 放进 effect 队列;当前 Entity 更新可以先结束,订阅回调并不是 `Chat::send` 内部的一次直接函数调用。操作成功后再 emit。如果发出 Event 的 Entity 自己也显示了变化后的状态,还要在那个 Entity 中调用 `cx.notify()`;`emit` 不会请求重绘。上例的 `Chat` 是不渲染 UI 的状态 Entity。Event 名称应表达事实,例如 `MessageSent`、`Saved`、`Dismissed`;`SendMessage` 则表示命令。 ### 把 Action 接到这个 Event 上面的完整示例从按钮 callback 开始。要验证 [Action](./action) → 状态 → Event → 订阅的整条路径,请在**同一个** `src/main.rs` 中作四处改动。原有的 `Chat`、`ChatEvent`、`Chat::send` 和订阅回调保持不变。 1. 在 import 后加入 `actions!(chat, [SendMessage]);`。在 `Workspace` 的 `chat` 字段旁增加 `focus: FocusHandle,`。 2. 让 `Workspace::new` 在 `cx` 之前接收 `window: &mut Window`。在原有的 `let chat = ...` 前创建并聚焦句柄,再把 `focus,` 放入返回的 `Self`: ```rust let focus = cx.focus_handle().tab_stop(true); focus.focus(window, cx); ``` 3. 在 `impl Workspace` 中增加方法: ```rust fn on_send_message(&mut self, _: &SendMessage, _: &mut Window, cx: &mut Context) { self.chat.update(cx, |chat, cx| chat.send(cx)); } ``` 在 `render` 的**最外层** `div()` 上、添加子元素之前接上三个调用,然后只用下面的 callback 替换按钮原有的 `.on_click(...)`: ```rust .track_focus(&self.focus) .key_context("Chat") .on_action(cx.listener(Self::on_send_message)) .on_click(cx.listener(|workspace, _, window, cx| { workspace.focus.dispatch_action(&SendMessage, window, cx); })) ``` 4. 在 `main` 中调用 `gpui_kit::init(cx)` 后绑定 Enter,并让 `open_window` 闭包把窗口传入构造函数: ```rust cx.bind_keys([KeyBinding::new("enter", SendMessage, Some("Chat"))]); gpui_kit::open_window(WindowOptions::default(), cx, |window, cx| { cx.new(|cx| Workspace::new(window, cx)) }) .expect("failed to open window"); ``` 再次运行 `cargo run`。当 Workspace 区域拥有 Focus 时按 **Enter**,再点击 **Send**。两种输入都会把同一个 `SendMessage` Action 派发给 `Workspace::on_send_message`;该 handler 更新 `Chat`,`Chat::send` 增加 `sent` 并发出 `MessageSent`,保留的订阅再更新 `Workspace::shown_total`。可见计数应随每次输入增加一次。作为核对,可暂时删去 `Chat::send` 中的 `cx.emit(...)`:内部 `sent` 仍增加,但由于 `Workspace` 订阅的是 Event,可见计数不再变化。继续下一练习前恢复 `emit`。若 Enter 无效,检查焦点句柄是否挂在渲染出的容器上,以及 `Chat` Key Context 是否存在;若按钮无效,检查 Action handler 与派发路径。 ## Subscription 的生命周期与归属 `cx.subscribe(&chat, callback)` 返回一个 `Subscription`。应像上例一样由订阅方保存它。丢弃该句柄会断开回调,因此只存在于 `new` 内的局部句柄会在函数返回时悄悄停止接收 Event。`Workspace` 被释放时,它保存的句柄也会释放。克隆 `Entity` 只是保留事件源的引用,不会保留订阅关系。View 专用的订阅应随 View 保存,而不是放进生命周期更长的全局状态。 回调参数依次是 `&mut Workspace`、发出通知的 `Entity`、`&ChatEvent`、`&mut Context`。Event 内容只在回调期间借用;需要之后使用的数据应复制或克隆。订阅绑定到一个事件源 Entity 和一种 Event 类型,不会自动接收其他 `Chat` 或应用中所有 Event。多个 owner 可以分别订阅同一个事件源。要提前停止某项订阅,从 owner 的集合中移除并丢弃其句柄。 如果回调还需要 `&mut Window`,使用 `cx.subscribe_in(&chat, window, callback)`。它的回调有五个参数:owner、`&Entity`、`&ChatEvent`、window 和 context;返回的 `Subscription` 同样需要保存。只需要知道 Entity 有变化,不需要类型化 Event 内容时,使用 `cx.observe(&chat, ...)`。Event 表达主动发出的业务事实;`cx.notify()` 表示 Entity 需要更新,不会产生 `ChatEvent`。 订阅没有响应时,检查发出 Event 的是否为同一个 Entity、`EventEmitter` 实现的类型是否与发出的类型一致、句柄是否还在,以及成功修改状态后是否执行了 `cx.emit`。如果回调执行了但界面仍是旧值,检查哪个 Entity 负责渲染该值,并在它的 context 上调用 `cx.notify()`。 ### 动手比较 `observe` 与 Event 订阅 如果做过上面的 Action 延伸练习,先恢复原始的完整 `Chat`/`Workspace` 示例,再运行它:每次点击,界面上的计数增加一。然后在这个程序中做三处改动: 1. 删除 `ChatEvent` 枚举和 `impl EventEmitter for Chat {}`,将 `Chat::send` 改成下面的方法。这个版本更新状态并调用 `notify`,不发出 Event。 ```rust fn send(&mut self, cx: &mut Context) { self.sent += 1; cx.notify(); } ``` 2. 在 `Workspace::new` 中,用下面的 observer 替换 `cx.subscribe(...)` 代码块。返回的句柄继续保存在现有的 `_subscriptions` 字段中。 ```rust let subscription = cx.observe(&chat, |workspace, chat, cx| { workspace.shown_total = chat.read(cx).sent; cx.notify(); }); ``` 3. 再运行程序并点击 **Send**,计数仍然增加。observer 收到发生变化的 `Entity`,再读取当前状态;它没有 `ChatEvent` 或 Event payload。暂时删掉 `Chat::send` 中的 `cx.notify()` 并重新运行:虽然 `Chat.sent` 变化了,但 observer 不会执行,显示的计数保持零。最后恢复 `cx.notify()`。 只需知道某个 Entity 有变化,且可以从中读取当前状态时,适合使用 `observe`;生产者要主动报告一种带类型的事实时,适合使用 `subscribe`。两者都返回需要由 owner 保存的 `Subscription`。单独调用 `cx.emit` 不会通知 `observe` callback;单独调用 `cx.notify()` 也不会发出类型化 Event。 **INFO — Event 不跟随 Focus 路由** Event 只发送给 source Entity 的订阅者。移动 Focus 或改变 Key Context 不会改变接收者。不要把 Event 当成绕过 Action routing 的全局命令总线。 ## 什么时候用 Action,什么时候用 Event | 问题 | 使用 | 例子 | | --- | --- | --- | | 这是用户或调用者希望执行的指令吗? | **Action** | 保存、删除、打开搜索 | | 它需要绑定快捷键或出现在菜单里吗? | **Action** | 复制、切换侧边栏、重命名 | | 这是状态或生命周期变化后报告的事实吗? | **Event** | ValueChanged、Saved、Dismissed | | Owner 是否要独立于 UI tree 观察 child? | **Event** | 输入变化、选择行、提交对话框 | | 它只是鼠标手势且没有其他命令入口吗? | callback | hover、拖动距离、指针位置 | 当一条命令产生了应用其他部分需要观察的事实时,两者一起使用:先处理 Action,提交状态变化,再发出 Event。 ## 鼠标与键盘输入事件 `MouseDownEvent`、`MouseUpEvent`、`MouseMoveEvent`、`ScrollWheelEvent`、`KeyDownEvent` 和 `KeyUpEvent` 表示原始输入,与上文由 `EventEmitter` 发出的业务通知不同。普通 `div()` 可以用 `.on_mouse_down(MouseButton::Left, ...)`、`.on_key_down(...)` 注册处理函数;其 `InteractiveElement` 实现负责标准命中区域和派发机制。需要快捷键或菜单入口的命令使用 [Action](./action);需要位置、按钮、修饰键或手势位移时使用原始输入事件。原始输入 callback 不会自动创建 Entity Event,除非 owner 主动调用 `cx.emit(...)`。 例如,[GPUI Kit 的 TimeField](https://github.com/longbridge/gpui-kit/blob/main/crates/base/src/time_field.rs) 把方向键绑定为自己 Key Context 中的 **Action**,用 `KeyDownEvent` 处理数字输入,并且只有时间值发生变化时才发出 `TimeFieldEvent::Change`。Owner 可以订阅这个 Event,无须知道变化来自键盘还是其他控件。匹配的 KeyBinding 可能先消费按键,使原始 `on_key_down` handler 收不到它;命令应交给 Action,而不是再写一套重复的原始按键 handler。数字输入 handler 的结构如下: ```rust fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { let stroke = &event.keystroke; if stroke.modifiers.modified() || stroke.key.chars().count() != 1 { return; } let Some(digit) = stroke.key.chars().next().and_then(|c| c.to_digit(10)) else { return; // Let the UI handle other keys. }; window.prevent_default(); cx.stop_propagation(); if self.editor.input_digit(digit) { cx.emit(TimeFieldEvent::Change(self.editor.time)); } cx.notify(); } ``` ### Capture 与 bubble 原始输入有两个派发阶段。**键盘** listener 沿 Focus 对应的路径运行:capture 从根节点走向 focused node,bubble 从 focused node 返回根节点。**鼠标** listener 按绘制顺序注册,而不是沿祖先路径运行:capture 从后向前,bubble 从前向后。派发器会按这个顺序调用匹配类型的鼠标 listener;底层 listener 必须自行检查 hitbox,确认输入是否落在自身区域。普通 `.on_mouse_down(...)` 和 `.on_key_down(...)` callback 在 bubble 阶段运行;`.capture_any_mouse_down(...)` 是元素级的 capture 接口。 ### 动手观察父子区域的鼠标处理顺序 回到上面的完整 Event 版本示例。在 `Workspace` 中加入 `parent_hits: usize`、`child_hits: usize` 两个字段,并在 `Workspace::new` 中把它们都初始化为 `0`。然后将 `Render` 实现替换为下面的代码: ```rust impl Render for Workspace { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .p_4() .child(format!("Messages sent: {}", self.shown_total)) .child(Button::new("send").label("Send").on_click(cx.listener( |workspace, _, _, cx| { workspace.chat.update(cx, |chat, cx| chat.send(cx)); }, ))) .child(format!( "Parent: {} | Child: {}", self.parent_hits, self.child_hits )) .child( div() .p_4() .bg(rgb(0xd0d7de)) .on_mouse_down(MouseButton::Left, cx.listener(|workspace, _, _, cx| { workspace.parent_hits += 1; cx.notify(); })) .child("Parent surface") .child( div() .p_4() .bg(rgb(0x8ecae6)) .on_mouse_down(MouseButton::Left, cx.listener( |workspace, _, _, cx| { workspace.child_hits += 1; cx.notify(); // Uncomment to stop the parent handler: // cx.stop_propagation(); }, )) .child("Child surface"), ), ) } } ``` 点击蓝色的 **Child surface** 一次,两个计数都会增加。child listener 在绘制阶段较晚注册,所以在鼠标 bubble 阶段先运行;parent 的 hitbox 同样包含该位置,因而随后运行。点击子区域外的灰色部分,只增加 parent 计数。取消 child 回调中 `cx.stop_propagation()` 那一行的注释并重新运行,再点击 child 时只有 child 计数增加;点击灰色部分仍然只增加 parent 计数。点击 **Send** 只改变消息计数,不影响这两个命中计数。 这里的两个区域为了练习而嵌套,但 GPUI 根据窗口当前帧的 listener 顺序及各自的 hitbox 派发鼠标事件,**不会**像 DOM 一样沿祖先链路由。即使两个重叠区域在元素树上没有父子关系,后绘制的 surface 仍可能是第一个 bubble listener。只有子区域确实需要独占这次输入时,才调用 `stop_propagation()` 阻止后续 listener。 编写自定义 [`Element`](./element#三个阶段) 时,可在 `paint` 中用 `window.on_mouse_event` 注册 listener,再检查 `DispatchPhase`: ```rust window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { if phase.capture() && hitbox.is_hovered(window) { if event.button == MouseButton::Left { cx.stop_propagation(); } } }); ``` 这里的 `Hitbox` 应在 `prepaint` 插入。listener 在 `paint` 注册,下次渲染时会重新建立。GPUI Kit 的 [Carousel scroll mask](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/carousel/scroll_mask.rs) 使用 capture 处理指针与滚轮手势:在 carousel 的轴上消费移动,另一方向则交给外层滚动容器。`Hitbox::is_hovered` 用于普通指针命中,`should_handle_scroll` 还会考虑滚动遮挡。普通控件优先使用元素提供的 fluent handler;需要自己的 hitbox 或阶段处理时,再使用 `window.on_mouse_event`。 ### `stop_propagation` 与 `prevent_default` | 调用 | 作用 | 典型场景 | | --- | --- | --- | | `cx.stop_propagation()` | 停止**当前派发**中后续 listener:鼠标 bubble 阶段不再交给后方 surface,键盘 bubble 阶段不再交给祖先;在 capture 阶段调用时,还会阻止剩余的 capture listener 与 bubble 阶段。 | 子控件已经消费拖动或按键。 | | `window.prevent_default()` | 标记当前输入事件的默认行为已被阻止。GPUI 用它控制 mouse down 时父元素获得 Focus 等内置行为。 | 子控件处理鼠标按下,但不想改变父元素 Focus。 | 两者互不替代:停止传播不会自动取消默认 Focus 行为;取消默认行为也不会自动停止其他 handler。GPUI 在每次输入派发开始时重置这两个标志。`prevent_default` 只控制检查该标志的 GPUI 内置行为,不代表普遍的浏览器式或操作系统事件取消。确定当前控件确实需要处理该输入后再调用它们。Action 刚好采用另一种默认策略:Action handler 默认停止向上冒泡,需要上层继续尝试时才调用 `cx.propagate()`。输入 callback 接收 `&mut Window`,在其中调用 `window.prevent_default()`。 排查 handler 没有生效时,依次查看事件阶段、Focus path、content mask、hitbox 行为和 z 顺序。注册成功的 listener 仍可能因为前方元素遮挡,或事件走了另一条 Focus path 而收不到输入。 --- # Icons & Assets Source: /versions/v0.7.1/zh-CN/docs/assets 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/assets 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 GPUI Kit 通过 `gpui_kit::assets` 和 `gpui_kit::component` 提供 [IconName] 与 [Icon]。`gpui-kit` 默认 feature 会启用这两层,但应用仍需注册 `AssetSource` 才能按路径加载图标。底层的 [gpui-kit-assets] crate 将 SVG 资源与组件代码分开,因此你可以: - 直接使用默认内置图标资源 - 完全不引入图标资源 - 自己维护一套 SVG 资源 **NOTE — 依赖图标 crate 不等于嵌入全部图标** **补全图标目录不会让现有应用自动嵌入全部图标。** `Assets` 保留原来的 101 个组件图标,应用仍通过自己的 `AssetSource` 提供额外图标,不需要重新声明 组件自带的图标。只有显式注册 `AllAssets`,原生程序才会嵌入全部 1,830 个 SVG。 仅依赖 crate 或使用共享 `IconName` 不会引用全部 SVG 内容。 | 原生资源配置 | 嵌入的 SVG 总量 | 相对默认 `Assets` 的二进制增量 | | --- | ---: | ---: | | 默认组件图标(101 个) | 44.28 KiB | 0 B(基线) | | 默认 + 2 个应用图标(103 个) | 45.04 KiB | +15.19 KiB | | 默认 + 10 个应用图标(111 个) | 48.09 KiB | +19.19 KiB | | 显式使用 `AllAssets`(1,830 个) | 731.45 KiB | +1.02 MiB | **本例中,额外使用 10 个应用图标增加约 19 KiB,并不会带入整个图标库。** 这 10 个 SVG 合计 3,903 字节,二进制实际增加 19,648 字节,包含额外资源源的查找、 列表合并代码、元数据和对齐开销。这不是固定的单图标成本,也不是整个应用的大小。 测量环境:Lucide 1.43.0、Linux x86_64、Rust 1.98.0、`--release` 并移除符号。 各组使用相同的 `IconName` 查找和运行时资源路径。额外资源源回退到 `Assets`, 并合并、排序、去重两个资源源的列表。10 个额外图标为 `Accessibility`、 `AlarmClock`、`Archive`、`Award`、`Backpack`、`Bike`、`Bird`、`Camera`、 `Coffee` 和 `Compass`;两图标组使用前两个。实际结果取决于 SVG 复杂度、工具链 和资源源的实现方式。 二进制大小不等于内存占用。按需资源借用静态字节,不复制或创建缓存;实际渲染仍有 解析、栅格化和渲染缓存的开销。运行时共享名称查找可能保留名称映射表,Cargo 下载包 和构建产物也仍包含完整目录。[WebAssembly](./webassembly) 的 `Assets::new(endpoint)` 和 `AllAssets::new(endpoint)` 沿用按需下载的 CDN 加载器,不嵌入完整资源包。 ## 共享名称与兼容性 `gpui_kit::assets::IconName` 提供不依赖 Component 的完整共享目录。 `gpui_kit::component::IconName` 保留为原来的兼容枚举:现有导入、穷尽匹配和 `.view(cx)` 调用均无需改动,也无需新增 trait 导入。`Icon::new(...)` 同时接受 两种类型;旧名称可以通过 `.into()` 转为共享名称。 对于新的共享枚举,需要组件 [Entity](./entity) 时使用 `Icon::new(name).view(cx)`,也可导入 `gpui_kit::component::IconNameExt` 后使用 `name.view(cx)`。 `IconName::ALL` 列出完整的 1,830 个名称,`IconName::Accessibility.path()` 返回 `icons/accessibility.svg`。默认资源源只包含原来的 101 个组件图标;额外图标请使用 下文的自定义资源源,或者显式注册 `AllAssets` 使用完整资源包。 ## 从默认资源源开始 `gpui_kit::assets::Assets` 是默认资源源,包含 [`default-icons.txt`](https://github.com/longbridge/gpui-kit/blob/main/crates/assets/default-icons.txt) 中列出的原有 101 个组件图标。 在 `Cargo.toml` 中添加总入口 crate;它的默认 feature 包含 `component` 与 `assets`: ```toml [dependencies] gpui-kit = "0.7.1" ``` 对于原生桌面应用,应在打开窗口前注册资源源。下面是可作为 `src/main.rs` 使用的完整示例: ```rust use gpui_kit::*; use gpui_kit::assets::Assets; use gpui_kit::component::{Icon, IconName}; struct Example; impl Render for Example { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div().child(Icon::new(IconName::Inbox)) } } fn main() { gpui_kit::application().with_assets(Assets).run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| Example) }) .expect("Failed to open window"); }); } ``` `with_assets` 为整个应用安装一个资源源。`gpui_kit::init(cx)` 在创建窗口前初始化组件层。`IconName::Inbox` 对应 `icons/inbox.svg`,GPUI 将这个精确路径交给资源源。仅添加 crate 依赖不会注册资源源。完整应用初始化流程见[快速开始](/versions/v0.7.1/zh-CN/docs/getting-started)。 ## 用 `icon_assets!` 选择更多目录图标 应用已经依赖 `gpui-kit` 时,直接使用 `gpui_kit::assets::icon_assets!`;默认启用的 `assets` feature 已经提供宏和共享目录。**只为使用该宏,无须再添加一项 `gpui-kit-assets` 依赖。** 如果某个 crate 有意独立使用资产层,而不依赖门面 crate,可以直接依赖 `gpui-kit-assets`,调用 `gpui_kit_assets::icon_assets!`。 按应用实际绘制的图标选择资源源: | 需求 | 传给 `with_assets` 的资源源 | 原生程序嵌入的图标数据 | | --- | --- | --- | | 只用默认组件图标 | `Assets` | 101 个默认 SVG | | 默认图标加少量目录图标 | 组合 `Assets` 与 `icon_assets!` | 默认图标加选中的图标 | | 使用全部目录图标 | `AllAssets` | 全部 1,830 个 SVG | | 不使用 Component,只用少量目录图标 | 宏生成的资源源 | 仅选中的图标 | 宏参数必须是 **`IconName` 枚举成员的标识符**,不是字符串、路径,也不是自备 SVG 的文件名。例如,`Accessibility` 选中 `icons/accessibility.svg`。宏生成的 `ExtraIcons` 是实现了 `AssetSource` 的单元结构体:`load` 对选中路径返回借用的嵌入字节,对其他路径返回 `Ok(None)`;`list(prefix)` 列出匹配前缀的已选路径。选取发生在编译期;枚举成员拼写错误或不存在会编译失败。需要在其他模块使用时,宏也接受可见性修饰符,例如 `icon_assets!(pub ExtraIcons, [Accessibility]);`。 下面是完整的 `src/main.rs`,选取两个额外的 Lucide 图标,同时保留默认组件图标。示例适用于原生桌面应用;使用上文的 `gpui-kit` 依赖即可,无须拷贝 SVG 或新增 crate。 ```rust use gpui_kit::*; use gpui_kit::assets::{Assets as ComponentAssets, icon_assets}; use gpui_kit::component::Icon; use std::borrow::Cow; icon_assets!(ExtraIcons, [Accessibility, AlarmClock]); struct AppAssets; impl AssetSource for AppAssets { fn load(&self, path: &str) -> Result>> { if let Some(bytes) = ExtraIcons.load(path)? { return Ok(Some(bytes)); } ComponentAssets.load(path) } fn list(&self, path: &str) -> Result> { let mut paths = ComponentAssets.list(path)?; paths.extend(ExtraIcons.list(path)?); paths.sort(); paths.dedup(); Ok(paths) } } struct Example; impl Render for Example { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div() .child(Icon::new(gpui_kit::assets::IconName::Accessibility)) .child(Icon::new(gpui_kit::assets::IconName::AlarmClock)) } } fn main() { gpui_kit::application().with_assets(AppAssets).run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| Example) }) .expect("Failed to open window"); }); } ``` `with_assets` 只能注册一个资源源,因此 `AppAssets` 同时负责两个查找。必须先查询 `ExtraIcons`:原生平台的 `ComponentAssets.load` 对未知路径会报错,阻止后续回退。`list` 合并、排序并去重两个来源,使列出的资源键与 `load` 能提供的键一致。只有应用不需要默认组件图标时,才适合单独注册宏生成的资源源。 从手写的 Lucide 图标资源查找迁移时,可用一条 `icon_assets!(ExtraIcons, [...]);` 声明替换复制的 SVG 字节或文件名匹配分支。其他应用文件继续留在独立资源源中,查询该来源及 `ExtraIcons` 后再回退到默认 `Assets`,并合并三个来源的 `list` 结果。确认没有其他代码按路径读取旧 SVG 文件后,再删除旧副本。`icons/brand-mark.svg` 等自备文件不属于目录,仍需下文的自定义资源源。`IconName::ALL` 只列出名称,并不表示已注册资源源可以加载每个名称。 快速检查时,调用 `AppAssets.load("icons/accessibility.svg")` 和 `AppAssets.load("icons/inbox.svg")`;原生平台上两者都应返回 `Some`。`AppAssets.list("icons/")` 也应包含这两个路径。额外图标空白时,检查绘制的 `IconName` 是否列在宏中,以及注册的是 `AppAssets` 而非 `ComponentAssets`。在 WebAssembly 上,宏生成的资源源仍可嵌入选中图标,但内置 `Assets` 需要带 endpoint 构造,并作为组合资源源的字段保存。 ## 自定义资源 `icon_assets!` 只能选择 GPUI Kit 已命名目录中的图标。应用自己的标志、插画、照片或其他图片,应由应用的 `AssetSource` 提供。仓库中的 [assets] 目录提供目录 SVG;把新文件放入应用**不会**生成新的 `IconName` 枚举成员。`icons/brand-mark.svg` 这样的资源键相对于嵌入目录,不是 URL,也不依赖程序运行时的工作目录。 下面的示例嵌入一个自有 SVG 和一个图片目录。在应用的 `Cargo.toml` 旁建立这些文件: ```text Cargo.toml src/main.rs assets/icons/brand-mark.svg assets/images/cover.png ``` 除 `gpui-kit` 外,添加 [rust-embed]: ```toml [dependencies] gpui-kit = "0.7.1" rust-embed = { version = "8.7", features = ["include-exclude"] } ``` 下面是完整的原生平台 `src/main.rs`:优先提供应用文件,再回退到 GPUI Kit 的默认组件图标。`images/**/*` 会嵌入该目录下的**每个文件**,所以目录中只应放要发布的资源;也可以改用更窄的 `#[include]` 规则。示例在打开窗口前注册一个组合资源源。 ```rs use gpui_kit::*; use gpui_kit::assets::Assets as ComponentAssets; use gpui_kit::component::Icon; use rust_embed::RustEmbed; use std::borrow::Cow; #[derive(RustEmbed)] #[folder = "./assets"] #[include = "icons/**/*.svg"] #[include = "images/**/*"] struct AppFiles; struct AppAssets; impl AssetSource for AppAssets { fn load(&self, path: &str) -> Result>> { if path.is_empty() { return Ok(None); } if let Some(file) = AppFiles::get(path) { return Ok(Some(file.data)); } ComponentAssets.load(path) } fn list(&self, path: &str) -> Result> { let mut paths = ComponentAssets.list(path)?; paths.extend(AppFiles::iter().filter_map(|p| p.starts_with(path).then(|| p.into()))); paths.sort(); paths.dedup(); Ok(paths) } } struct Example; impl Render for Example { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div() .flex() .flex_col() .gap_3() .child(Icon::default().path("icons/brand-mark.svg")) .child(img("images/cover.png").w(px(240.)).h(px(160.)).object_fit(ObjectFit::Cover)) } } fn main() { gpui_kit::application().with_assets(AppAssets).run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| Example) }) .expect("Failed to open window"); }); } ``` `AssetSource::load(path)` 根据精确资源键提供字节,`list(prefix)` 报告匹配的键。此例中,自有文件发生键冲突时优先;其他路径交给 `ComponentAssets`。原生平台上的内置资源源遇到未知非空路径会报错。如果应用也使用 `icon_assets!`,应在这个最终回退之前查询宏生成的资源源,并按上文方式将其路径合并到 `list`。在 WASM 上,需以 endpoint 构造 `Assets::new(endpoint)`,并将其值保存在组合资源源中,而不能使用原生平台的单元结构体。 ## 使用资源路径 使用注册资源源提供的路径。`Icon::path` 用于 SVG 图标;`IconName` 把已知名称映射到这些路径。 ```rust use gpui_kit::component::{Icon, IconName}; let built_in = Icon::new(IconName::Inbox); let custom = Icon::default().path("icons/brand-mark.svg"); let extra = Icon::new(gpui_kit::assets::IconName::Accessibility); ``` `extra` 需要上面的选取图标资源源或 `AllAssets`;`custom` 需要包含 `icons/brand-mark.svg` 的资源源。需要随文字颜色变化的**单色 SVG**,使用 `Icon::path` 或 `svg().path(...)`:GPUI 将其绘制为可着色的透明度蒙版。需要时指定尺寸和 `text_color`。照片、其他位图,以及要保留原始颜色的**多色 SVG**,使用 `img()`: ```rust use gpui_kit::{ObjectFit, StyledImage, img, px, svg}; let tinted_mark = svg().path("icons/brand-mark.svg").size(px(24.)); let cover = img("images/cover.png") .w(px(240.)) .h(px(160.)) .object_fit(ObjectFit::Cover); let color_artwork = img("images/illustration.svg").size(px(160.)); ``` `img()` 支持常见的 PNG、JPEG、WebP、GIF、SVG,以及 GPUI 的 `Img::extensions()` 列出的其他格式。它通过字节识别位图格式,SVG 则走另一条解码路径;因此文件内容也必须有效,不能只改扩展名。默认的 `ObjectFit::Contain` 会在边界内完整显示图片;`Cover` 会铺满并可能裁剪,`Fill` 可能拉伸变形。`object_fit` 作用于 `img()`,不作用于单色 `svg()` 元素。`img()` 通过 GPUI 的图片缓存异步加载并解码;嵌入资源的字节会复制进加载器,嵌入本身不会省掉解码或运行时图片内存。动画 GIF 和 WebP 可以包含多个帧。 对于 `"images/cover.png"` 这样的字符串,GPUI 会用该键调用已注册的 `AssetSource`。`img(std::path::Path::new("/absolute/file.png"))` 读取文件系统路径,URL 字符串使用 HTTP 加载器;它们的部署方式和错误处理不同。各种来源如何加载、布局和缓存,见[图片](/versions/v0.7.1/zh-CN/docs/image)。 ## 单独嵌入 SVG 图标 自定义图标可以通过 `Icon::data` 直接传入 SVG 字节,无须维护资源路径注册表: ```rust use gpui_kit::component::{Icon, button::Button}; Button::new("search") .icon(Icon::default().data(include_bytes!("search.svg"))) .label("Search") ``` 这样可以省去该图标的资源查找。内置 `IconName` 和组件中使用的其他路径图标仍需要资源源。 数据所有权、来源替换、加载图标与自定义图标类型的说明见 [SVG 字节](/versions/v0.7.1/zh-CN/component/icon#svg-字节)。 ## 排查资源缺失 | 现象 | 检查项 | | --- | --- | | 默认组件图标空白 | 确认打开窗口前调用了 `.with_assets(Assets)`,或注册了包含默认图标的组合资源源;核对路径以 `icons/` 开头、以 `.svg` 结尾。 | | 共享目录中的图标空白 | 确认名称属于已注册资源源。`Assets` 只有 101 个默认图标;其他名称需要选取图标资源源或 `AllAssets`。 | | 自定义图标空白 | 将 `Icon::path` 中的键与 `#[folder]` 下的相对路径逐字比较,并检查 `#[include]` 和大小写。可用 `list("icons/")` 查看资源源提供的键。 | | 图片空白 | 确认传入的是资源键、文件系统 `Path` 还是 URL;检查资源源是否包含位图文件,以及字节是否为受支持的图片格式。 | 在原生平台,内置 `Assets` 和 `AllAssets` 遇到缺失路径会返回错误;空路径返回 `Ok(None)`。组合资源源应先查询自定义资源,再回退到内置资源。`Icon::data` 可免去该 SVG 的路径查找,但无效 SVG 字节仍可能无法绘制。 ## 打包与 WebAssembly 原生平台的 `Assets` 只嵌入默认 SVG,`AllAssets` 则嵌入完整目录。`icon_assets!` 嵌入选中的 SVG 字节;`rust-embed` 嵌入 include 规则匹配的文件。源文件路径在构建时解析,打包后的原生应用运行时不需要源项目的 `assets/` 目录。传给 `img()` 的文件系统 `Path` 不同:目标文件必须在应用运行的位置存在。上面的大小测量仅供参考,请测量自己应用的 release 二进制。 在 WebAssembly 上,`Assets::new(endpoint)` 和 `AllAssets::new(endpoint)` 使用同一个按需 HTTP 资源源。对于 `icons/*.svg`,它请求 `endpoint + "/assets/" + path`,缓存成功响应,并在请求期间返回临时加载错误。请托管精确路径,并使用不带尾部斜线的 endpoint。遇到图标空白时检查浏览器的 Network 和 Console 中的状态码、CORS 和 URL;下载完成后加载器本身不会主动要求重绘。此资源源的 `list()` 返回空列表。上面的原生 `rust-embed` 示例是另一种选择,也可以用于在 WASM 中显式嵌入应用文件。部署细节见 [WebAssembly](/versions/v0.7.1/zh-CN/docs/webassembly)。 ## 参考资源 - [Lucide Icons](https://lucide.dev/) - GPUI Kit 的图标目录主要基于 Lucide 开源图标库。 [rust-embed]: https://docs.rs/rust-embed/latest/rust_embed/ [IconName]: https://docs.rs/gpui-kit-assets/0.7.1/gpui_kit_assets/enum.IconName.html [Icon]: https://docs.rs/gpui-component/latest/gpui_component/struct.Icon.html [assets]: https://github.com/longbridge/gpui-kit/tree/main/crates/assets/assets/icons [gpui-kit-assets]: https://docs.rs/crate/gpui-kit-assets/0.7.1 --- # Render Source: /versions/v0.7.1/zh-CN/docs/render 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/render 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `Render` 连接持久存在的 [`Entity`](./entity) 与它当前描述的界面。GPUI 需要某个 View 的[元素树](./element)时,会调用 `T::render`。Entity 保留数据和 identity;返回的元素则描述本次渲染的布局、外观与 handler。面板、页面、编辑器等需要拥有可变状态、订阅或子 Entity 的 View,适合实现 `Render`。 ## 运行一个有状态 View 完成[入门教程](./getting-started)后,用下面的完整代码替换该项目的 `src/main.rs`。它沿用同一个 `gpui-kit` 依赖,无需新增示例 crate。 ```rust use gpui_kit::*; use gpui_kit::component::button::Button; #[derive(Default)] struct Chat { messages: Vec, } impl Chat { fn add(&mut self, cx: &mut Context) { self.messages .push(format!("Message {}", self.messages.len() + 1).into()); cx.notify(); } fn clear(&mut self, cx: &mut Context) { if !self.messages.is_empty() { self.messages.clear(); cx.notify(); } } } impl Render for Chat { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .flex() .flex_col() .size_full() .gap_2() .p_4() .child(format!("Messages: {}", self.messages.len())) .child( Button::new("add-message") .label("Add message") .on_click(cx.listener(|this, _, _, cx| this.add(cx))), ) .child( Button::new("clear-messages") .label("Clear") .on_click(cx.listener(|this, _, _, cx| this.clear(cx))), ) .children( self.messages .iter() .cloned() .map(|message| div().child(message)), ) } } fn main() { application() .with_assets(assets::Assets) .run(|cx| { init(cx); open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| Chat::default()) }) .expect("Failed to open window"); }); } ``` 运行 `cargo run`。窗口最初显示 **Messages: 0** 和两个按钮。点击 **Add message** 后,计数变为 1,并出现 **Message 1**;继续点击会添加更多行。点击 **Clear** 会清空行,计数回到 0。若列表已经为空,再点 **Clear** 不会改变状态。 `open_window` 保留 `cx.new` 返回的 `Entity`;`Chat::default()` 只在创建该 Entity 时运行。`Render::render` 接收 View 的可变引用、`Window` 和该 View 的 [Context](./context),返回 `impl IntoElement`;trait 要求 `Self: 'static + Sized`。返回类型无需写出具体、往往深度嵌套的 Element 类型。这里的 `div()` 是 GPUI Element,`Button` 是 GPUI Kit 组件。点击 closure 在渲染时只是被*注册*,发生输入后才通过 `cx.listener` 取得 `Chat` 的可变引用。构建元素树时,按钮并不会修改 `Chat`。 沿一次点击追踪所有权与渲染过程: | 步骤 | 发生的事 | | --- | --- | | 1. 输入 | 按钮调用先前注册的回调。`cx.listener` 进入已有的 `Chat` Entity 并调用 `add`。 | | 2. 状态 | `add` 向 `Chat::messages` 添加消息,然后通过该 Entity 的 `cx.notify()` 发出变化通知。 | | 3. 窗口处理 | GPUI 使当前展示 `Chat` 的窗口失效;之后处理受影响的 View 时,`render` 描述新的计数和消息行。 | | 4. 元素处理 | GPUI 按需计算布局、执行 prepaint 和 paint,随后才能呈现变化。 | 这是一次更新的路径,不代表固定的 `render` 调用次数或显示帧数。如果文字一直停在 **Messages: 0**,检查回调是否修改了已挂载的 `Chat` Entity,并调用它的 `Context::notify()`。如果点击一次却重复发起请求或写日志,检查这类工作是否误放进了 `render`,而不是点击 handler。 ## View 持有状态,元素树描述本次界面 使用 `cx.new` 创建 Entity。父 View 可以保留同一个 handle,并在每次渲染时将它放入树中: ```rust struct ConversationPage { chat: Entity, } impl ConversationPage { fn new(cx: &mut Context) -> Self { Self { chat: cx.new(|_| Chat::default()), } } } impl Render for ConversationPage { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div().size_full().child(self.chat.clone()) } } ``` `ConversationPage` 在多次渲染之间持有同一个 `Chat` Entity,不会因为页面重绘就重新创建消息列表。克隆 `Entity` 只复制 handle,不复制消息。文件列表页面也可以采用同样的结构:以子 Entity 持有文件列表 View,由它自己管理选择、加载状态和订阅。`Entity` 可以作为 child,因为 GPUI 会把它转换为 View Element。Entity ID 为这个 View 提供响应式边界及独立的 Element ID 空间。创建、共享及生命周期规则见 [Entity](./entity)。 ## Render 还是 RenderOnce? `Entity` 首先是状态容器,`T` 不必实现 `Render`。`Entity` 可以只保存数据、接受更新并通知观察者,完全不出现在元素树中。当 `T: Render` 时,同一个容器也能作为持久 View 加入元素树,并以 Entity ID 作为身份。GPUI 的 `View` 机制也接受 `RenderOnce` 值,但这些值没有自己的 Entity ID。`Render` 是 Entity 承载 UI 的路径,并非所有 Entity 的必备条件。 | Trait | 接收者与所有者 | 适合承担的工作 | | --- | --- | --- | | [`Render`](./render) | 持久 `Entity` View 上的 `render(&mut self, ..., &mut Context)` | 跨渲染过程持有变化或复杂的状态、订阅、焦点、Task 与子 Entity。 | | [`RenderOnce`](./render-once) | 父级构造的值由 `render(self, ..., &mut App)` 消费 | 根据当前输入和回调描述轻量组件;父级下次渲染时提供新的值。 | 上面的 `Chat` View 持有会变化的消息集合,因此实现 `Render`。GPUI Kit 的 `SelectState` 是另一种实现 `Render` 的状态所有者。带样式的 `Button` 则实现 `RenderOnce`:父级提供当前 props,并处理点击结果。`RenderOnce` 值仍然可以交互;GPUI 也可能在其内部保留少量 keyed element state,只是这个值没有独立的 Entity 生命周期。两者区别在于**持久状态由谁拥有**,不是承诺一个 trait 每显示帧只运行一次,或另一个 trait 每帧都会运行。 返回元素树不等于立即绘制。GPUI 会把值转换成元素,计算布局,执行 `prepaint`,最后[绘制](./paint)。View 的 `render` 可能发生在布局或 prepaint 过程中;有效的子树也可能直接复用。因此不能假设每帧恰好调用一次 `render`,也不能假设父 View 每次渲染都会调用所有子 View 的 `render`。输出是供 GPUI Element 管线处理的描述,不是持久保存的像素列表。各阶段见 [Element](./element)。 关于渲染过程、屏幕刷新与已呈现帧的区别,以及“120 FPS”和 GPUI hybrid 模型的含义,参阅 [FPS Monitor](./fps#120-hz-是帧预算不是刷新承诺)。 ## 有意义的状态变化后调用 notify Entity update 提供可变访问,但仅修改字段并不会报告界面变化。当某个 Entity 的渲染结果或观察者应该更新时,调用*该 Entity* 的 `Context::notify()`: ```rust chat.update(cx, |chat, cx| { chat.messages.push("Hello".into()); cx.notify(); // This is Context, not the caller's Context. }); ``` `notify` 使正在展示该 Entity 的窗口失效,并把通知排给观察者。GPUI 会安排后续渲染,不会在 `notify` 这一行同步调用 `render`。同一个 View 若展示在多个活动窗口,可能让各窗口都失效。GPUI 会将变化的 View 及其已渲染的祖先标记为 dirty,随后可能复用符合条件的缓存子树。不要依赖固定的 `render` 调用次数。对于自身不渲染、但有人观察的模型 Entity,通知也有意义。 [Event](./event) 与通知承担不同职责:`cx.emit(event)` 向订阅者传递带类型的事实;`cx.notify()` 报告 Entity 已变化。只发送 Event 不代表读取该 Entity 的所有 View 都会重绘;通知也不携带 Event 的数据。若状态变化和事件事实都需要对外可见,可分别使用两者。 一个 View 读取另一个 Entity 的状态时,要明确谁拥有数据、谁需要失效。若另一个 Entity 作为子 View 渲染,其自身 `notify` 可以使该子 View 失效。若父 View 把别的 Entity 的值复制到自己的输出中,应观察源 Entity,并在这些值变化时调用父 View 的 `cx.notify()`。不要假设保留状态的子 View 只能靠父 View 重渲染才能变化。 ### 沿示例追踪一次更新 1. `ConversationPage::new` 创建一个 `Chat` Entity 并保留它的 handle。页面每次渲染,都把同一个 handle 放进元素树。 2. 调用方可以用 `chat.update(cx, |chat, cx| { chat.messages.push("Hello".into()); cx.notify(); });` 添加消息。内层 `cx` 属于 `Chat`;这次更新无需另行通知 `ConversationPage`。 3. 在之后的窗口渲染过程中,GPUI 按需构建受影响的元素树,并执行布局、prepaint 和 paint,新消息随之出现。点击 **Clear** 后,先前注册的 listener 清空 `Chat` 的消息,再通知同一个 Entity。 4. 再点一次 **Clear** 不会改变集合,所以 `clear` 不调用 notify。这两步都不保证固定的 `render` 调用次数:窗口刷新和缓存是否可用也会影响调用次数。 在运行的 View 中检查所有权边界:通过保留的 `Chat` handle 添加消息,确认消息出现。改变与 `Chat` 无关的父级状态,确认子级仍保留消息;如果列表重置,检查是否在父级的 `render` 中重新创建了 `Chat` Entity。随后点击 **Clear**,确认消息消失。 ## Render 构建值,handler 执行工作 读取状态、选择子元素、应用[样式](./style)与注册 handler,都是正常的渲染工作。GPUI 重新构建元素树的原因未必是用户操作。如果在 `render` 中无条件发起请求、注册订阅、发送 Event 或修改应用状态,每次重建都可能重复执行。在 `render`、`prepaint`、`paint` 或 canvas callback 中无条件调用 `cx.notify()`,还可能让窗口持续失效。 把生命周期工作放在 Entity 初始化、订阅或明确的输入 handler 中。`Subscription` 应由订阅的 Entity 持有。异步工作完成后,通过 update 修改状态;状态确有变化时,从该 update 调用 notify。上面 Chat 示例中的 `cx.listener` 会把 GPUI Kit `Button` 日后发出的点击回调转为当前 `Render` 所有者的更新。handler 也可以捕获 `Entity` handle,以更新另一位所有者;不要在 Entity 正在渲染或更新时重新进入同一 Entity。 GPUI Kit 的输入与选择状态等状态所有者实现 `Render`;`Button` 等控件用 `RenderOnce` 把当前 props 转成元素。需要直接控制布局、hitbox 或绘制时才实现自定义 `Element`。这种组合让持久 View 负责行为,生命周期较短的值描述当前界面。 ## 常见问题 | 现象 | 检查方向 | | --- | --- | | 状态已改变,画面却没更新 | 是否更新了正确的 Entity,并通过它的 Context 调用 notify? | | 空闲时仍不断执行工作 | `render` 或绘制回调是否每次都启动工作或调用 notify? | | 父 View 变化后子状态重置 | 是否在 `render` 中每次新建子 Entity,而没有由所有者保留 handle? | | 点击 handler 无法借用 `self` | 用 `cx.listener` 注册拥有自身数据的回调,或捕获 Entity handle 供日后 update。 | | 重复列表项的局部 UI 状态错位 | 使用 keyed state 的重复元素应采用基于条目 identity 的稳定 ID,不要直接用数组下标。见 [ElementId](./element_id)。 | 本文使用的 Context API 见 [Context](./context),输入回调及事件详见 [Event](./event)。 --- # SharedString Source: /versions/v0.7.1/zh-CN/docs/shared-string 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/shared-string 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 微小成本会累积。UI 中一次普通的组件调用所带的成本,会随组件数量和重绘次数反复出现。GPUI 和 GPUI Kit 因此把它当作 API 设计约束:长期持有的文本应易于拥有和传递,不让每位调用方都完整复制其内容。`SharedString` 就是为组件作者提供这一默认路径的具体选择。 `SharedString` 是 GPUI 用于保存文本的自有、不可变类型。GPUI Kit 用它存储 label、placeholder、title 等内容;组件或元素在 builder 调用结束后仍需持有这些文本。对于跨多次 [render](./render)、组件边界或 task 闭包持有的 **UI 文本**,默认使用它。临时读取可以借用 [`&str`](https://doc.rust-lang.org/std/primitive.str.html)。可以通过 `use gpui_kit::*;` 或 `use gpui_kit::SharedString;` 导入。 ## 微小成本会累积 选择框架类型时,微小的复制与分配是一项需要长期主动控制的预算,不应把负担留给每一个调用位置。若标签 API 要求调用方每次都提供新建的自有缓冲区,大量普通组件组合就会反复完整复制文本。以 `SharedString` 接收长期持有的文本,组件可以拥有自己的值,调用方也能复用已经保存的值。常见路径无需给每个标签手写缓存。 这种默认设计避免的是反复**完整复制文本**,并非消除全部工作:clone 内联文本会复制少量字节,clone 共享堆文本会更新引用计数,`format!(...).into()` 每次运行仍会构造新文本。其他成本有各自的工具,例如用[虚拟列表](../base/virtual-list)跳过屏幕外行,用[视图缓存](./view-cache)复用未变化的子树。 ## 为何 UI 文本优先用它而不是 `String` 设想工作区 View 将标题依次传给标题栏、标签页组件,最后交给标签。每层若要在调用方返回后继续持有标题,就得遵循 Rust 的所有权规则:移动值并放弃上游那份、借用并受源数据生命周期约束,或 clone。Rust 的 [`String`](https://doc.rust-lang.org/std/string/struct.String.html) 拥有可变缓冲区;在每个边界 clone 非空 `String`,就会创建独立缓冲区并复制标题字节。若每层改用 `format!` 重新构造标题,则会反复格式化和分配。 `SharedString` 以容易 clone 的形式表示这段不可变文本。工作区可以保留一份值,每层再取得自有的 clone;对于堆上长文本,这些 clone 共享字节,无需在每次透传时完整复制。这就是 GPUI 与 GPUI Kit 在许多文本属性中使用它、并在组件边界接收 `impl Into` 的原因。代价是不可变:改动文本就要构建新值。只要内容不变,多个副本才得以共享;这并不意味着绝对零开销。
三个 String 所有者与三个 SharedString 所有者的内存关系 对于存于堆上的长文本,三个 String 所有者各自指向完整文本缓冲区。三个 SharedString 所有者分别持有小型句柄,指向一份带引用计数的共享文本。虚线动效只表示所有权连接,不表示耗时。 String::clone() Each owned clone copies the full text into its own allocation. View A · handle View B · handle View C · handle Text buffer A · full text Text buffer B · full text Text buffer C · full text 3 text buffers SharedString::clone() Each owner keeps a handle; the long text stays in one allocation. View A · handle View B · handle View C · handle One shared text bufferreference count: 3
这是三个所有者持有长文本的概念图,不是按字节精确缩放的性能基准。SharedString 每位所有者仍持有句柄,clone 也会更新引用计数;短内联文本则会复制少量字节。
## 文本如何存储 GPUI 目前使用 `SmolStr` 作为 `SharedString` 的底层实现。存储形式取决于构造方式和文本的 **UTF-8 字节数**: | 输入 | 当前存储形式 | clone 时发生什么 | | --- | --- | --- | | `SharedString::new_static("Ready")` | 引用静态字面量 | 复制小型 handle,不分配堆内存 | | `SharedString::from("Ready")` | 将这段短文本复制到值内部 | 复制内联字节,不分配堆内存 | | 较长的动态文本 | 共享的堆内存 | clone 带引用计数的 handle,不复制文本字节 | 当前的内联容量是 23 字节。某些由换行符后接空格组成的文本也会使用无需分配的特殊静态表示。这些属于实现细节,不是长度限制,也不表示每个 `SharedString` 都不会分配内存。尤其是,`SharedString::from("a long literal ...")` 走普通转换路径;如果明确需要字面量的静态存储,应使用 `new_static`。构造和 clone 都不会对相同内容做字符串驻留或自动去重。 对于这些当前的存储形式,`SharedString::clone()` 的工作量不随文本长度增长:静态值复制 handle,短内联值复制少量字节,堆上值复制 handle 并更新引用计数。它不会复制堆上长文本的字节。`String::clone()` 则会分配独立缓冲区并复制文本字节。重新构造长 `SharedString` 仍可能分配;格式化、转换、引用计数更新,以及最终释放共享副本,都需要时间。 ## 所有权与读取 即使输入来自临时 `String` 或借用的 `&str`,`SharedString` 得到的仍是可独立持有的值。转换非静态借用时,会按需要复制内容;结果不会借用调用方的数据。因此可以把它存入 [Entity](./entity),或移入 callback,而无需维持输入的生命周期。 ```rust use gpui_kit::SharedString; let title: SharedString = "Quarterly report for the product team".into(); let header_title = title.clone(); // Keep a copy for one UI owner. let tab_title = title.clone(); // Keep a copy for another owner. let borrowed: &str = title.as_str(); // Borrow read-only without taking ownership. assert_eq!(borrowed, "Quarterly report for the product team"); assert_eq!(header_title, tab_title); ``` 这里的 `.into()` 把字面量转换为自有的 `SharedString`;字面量若应使用静态存储,则改用 `SharedString::new_static(...)`。这里走的是普通转换路径,标题也超过当前内联容量,因此 `header_title` 与 `tab_title` 在内容不变时共享其堆上文本;它们的 clone 仍要更新引用计数。可以通过 `as_str()`、`AsRef` 或解引用取得 `&str`,无需创建另一位所有者;这个借用不能超过原 `SharedString` 的生命周期。 `SharedString` 本身不能原地修改。如果确实需要可变的构造或编辑缓冲区,可在局部使用 `String`,完成后转换一次。将自有的长 `String` 转成 `SharedString` 时仍可能复制到共享存储中,不要假设会复用原缓冲区。 ## 在所有权边界转换 根据下一步由谁持有文本选择操作: | 来源与下一步用途 | 写法 | 所有权结果 | | --- | --- | --- | | UI 长期持有的固定字面量 | `SharedString::new_static("Ready")` | 拥有使用静态文本存储的值 | | UI 长期持有的临时 `&str` | `SharedString::from(text)` | 拥有独立于借用来源的值 | | UI 长期持有的已完成 `String` | `let label: SharedString = text.into();` | 将 `String` 移入转换;仍可能复制或分配 | | 已有 `SharedString`,两位所有者都需要 | `let label = title.clone();` | 双方各自拥有值;堆上长文本继续共享 | | 已有 `SharedString`,仅接收方需要 | `Label::new(title)` | 移动该值,不额外 clone | | 已有 `SharedString`,被调用方只读取 | `read_text(title.as_str())` | 在本次调用中借用 `&str` | builder 接收 `impl Into` 时也可传入 `&title`,因为 GPUI 实现了从 `&SharedString` 转换并 clone。传 `title.as_str()` 则会由借用文本构造**新的** `SharedString`;希望共享现有值时,使用 `title.clone()`(或 `&title`)。如果某个 API 特别要求 `String`,`title.to_string()` 会生成独立的可变字符串;只在该 API 边界这样做。 ```rust use gpui_kit::SharedString; use gpui_kit::component::label::Label; let title = SharedString::new_static("Downloads"); let heading = Label::new(title.clone()); // Keep title for another owner. let tab = Label::new(title); // Final use: move the value directly. ``` `heading` 与 `tab` 都各自拥有文本。移动后不能继续使用 `title`;只有别的所有者还需要它时才先 clone。 ## 从 API 响应进入 View 对于 API 响应快照中的不可变文本字段,默认设计为 `SharedString`。用 Serde 将 JSON `title` 直接反序列化为这种类型,再把响应带入应用状态。若 UI 需要格式化后的标题,应在响应到达时另行生成展示值,而不是修改传输层响应: ```rust use gpui_kit::SharedString; use serde::Deserialize; #[derive(Deserialize)] struct UserResponse { title: SharedString, } struct Workspace { response: UserResponse, display_title: SharedString, } impl From for Workspace { fn from(response: UserResponse) -> Self { let display_title = format!("Profile: {}", response.title).into(); Self { response, display_title } } } ``` 把 `response` 移入 `Workspace`,其原始 `title` 不需要 clone。这里另行计算 `display_title` 一次;之后嵌套的 View 和组件需要自有文本时,可以 clone 任一值。若文本较长并存于堆上,这些后续 clone 会共享该值的文本分配,而不复制字节;短文本则存于内联空间。最初的 JSON 解析仍有成本:当前 `Deserialize` 实现先读取一个 `String`,再由它构造 `SharedString`,期间可能复制或分配。`Serialize` 输出普通字符串;共享是内存中的属性,不是 JSON 值的一部分。`format!` 也会为展示标题构造新值,因此应在数据变化时计算,而不是每次 render 时重新格式化。只有响应文本确实需要编辑时才使用可变文本缓冲区;普通只读响应字段无需从 `String` 开始。 ## 在 GPUI Kit 中使用 GPUI Kit 的组件 builder 经常接收 `impl Into`,调用方可以传入字面量或已有的 `SharedString`。例如,[`Button::label`](../component/button) 与 [`Label::new`](../component/label) 都采用这种形式: ```rust use gpui_kit::*; use gpui_kit::component::{button::Button, label::Label}; let title = SharedString::new_static("Downloads"); let heading = Label::new(title.clone()); let button = Button::new("open-downloads").label(title); ``` 对于存放在 View 中、会在多次 render 时使用的文本,在 View 中保存一个 `SharedString`,再 clone 给每次创建的元素。`Render::render` 会同时提供该 View 的 [Context](./context) 和 `Window`: ```rust use gpui_kit::*; use gpui_kit::component::label::Label; struct Header { title: SharedString, } impl Render for Header { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { Label::new(self.title.clone()) } } ``` clone 后,新元素有自己的 handle,View 也保留原值。对于长标题,这不会在每次 render 时复制全部字符。反过来,如果每次 render 都重新执行 `format!(...).into()`,仍会反复格式化和分配。将稳定文本保存在拥有它的状态中,只在内容变化时替换。 ## 按文本的生命周期选择 对于 UI 元素、View、callback 或多个 owner 会长期持有的文本,优先使用 `SharedString`。需要自有值的固定字面量用 `SharedString::new_static`,明确采用静态存储。组件 builder 接收 `impl Into` 时,可以直接传这些值,不必先构造新的 `String`。 如果 API 只在当前调用中读取文本,传 `&str` 即可;已有 `SharedString` 也可用 `.as_str()` 借出。这样不会再创建文本所有者。 普通 UI 文本尽量不要引入 `String`。确实需要**可变构造或编辑**文本时,例如组装草稿,可以使用它,并在所有权边界将完成的内容转换一次: ```rust let mut draft = String::from("Quarterly report"); draft.push_str(" for the product team"); let title: SharedString = draft.into(); ``` 这个 `.into()` 消费 `String` 并构造 `SharedString`;它可能复制或分配,不保证复用原缓冲区。GPUI Kit 的 [`InputState`](../component/input) 说明编辑缓冲区和对外值不必同型:它把可编辑文本存入 `Rope`,而 `value()` 每次调用都会物化一个 `SharedString` 快照。不要为了读取未变化的字段而反复调用 `value()`。日常 UI 代码往往不需要 `String`,但真正的文本构造和编辑工作仍可使用它。 **源码快照(2026-09-24)** 在 GPUI Kit 的 `crates/{kit,base,component,assets}/src` 正式库源码中,类型包含 `String` 的**具名 struct 存储字段有 34 个**,类型包含 `SharedString` 的有 **375 个**。这些 `String` 字段包括可编辑文本、搜索状态和主题 schema 数据等合理例外。这支持长期持有的 UI 文本默认使用 `SharedString`,并不表示整个仓库只有 34 处 `String` 用法。 复核时,扫描上述四个 `src` 目录中 `*.rs` 的具名 struct 主体,跳过仅用于测试的文件及 `#[cfg(test)]` 之后的内容,再统计字段类型中出现独立 Rust token `String` 与 `SharedString` 的数量。这是轻量源码扫描,不是 Rust 语义解析:它不含局部变量、函数签名和 enum 字段,包含源码中同时存在的原生与 WebAssembly `cfg` 变体,也不能推断运行时分配次数。 ## 常见编译错误与意外行为 | 现象 | 原因与修正 | | --- | --- | | `Label::new(title)` 之后出现“use of moved value” | builder 会消费参数。View 或其他元素仍需该值时传 `title.clone()`;最后一次使用才移动它。 | | “borrowed data escapes”,或 callback 要求 `'static` | callback 不能长期保存从 View 借出的 `title.as_str()`。把 `SharedString` clone 进 `move` 闭包,需要时在闭包内部调用 `.as_str()`。 | | 方法要求 `&str`,却传了 `SharedString` | 传 `title.as_str()`(适用解引用强制转换时也可用 `&title`)。这只是借用,不复制文本。 | | 找不到 `push_str` 等修改方法 | `SharedString` 不可变。用 `String` 构造或编辑,完成后转换为 `SharedString`。 | | `.into()` 无法推断目标类型 | 显式指定 `let title: SharedString = source.into();`,或调用 `SharedString::from(source)`。 | callback 与元素遵循相同的所有权规则:闭包必须拥有在 `render` 返回后仍要使用的值。例如,在 `on_click(move |_, _, _| { /* use title_for_click here */ })` 前执行 `let title_for_click = self.title.clone();`,就能给 handler 一份独立的值。构建 callback 时 clone 一次,不要在每次 render 时把 `self.title.as_str()` 重新转换成新值。 ## 与 `Cow` 的关系 Rust 的 [`Cow<'a, str>`](https://doc.rust-lang.org/std/borrow/enum.Cow.html) 可以借用现有文本,避免当下复制,但其 `Borrowed` 形式受源数据生命周期约束。`Owned` 形式持有 `String`;clone 非空的 owned 值会复制文本字节。对 borrowed 值调用 `to_mut()`,则会先复制成自有的 `String`,再供修改: ```rust use std::borrow::Cow; let mut text: Cow<'_, str> = Cow::Borrowed("Ready"); text.to_mut().push('!'); // The value is now owned and editable. ``` `SharedString` 是独立持有的不可变值,具有静态、内联或共享堆存储形式。clone 堆上长文本会共享字节;修改内容需要构建新值。它不是 `Cow` 的别名,也没有 `Cow` 那种修改时才复制的行为。 --- # View Cache Source: /versions/v0.7.1/zh-CN/docs/view-cache 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/view-cache 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 GPUI 把应用状态保存在 [Entity](./entity) 中,但窗口每次绘制通常会重新构建[元素树](./element)。保留 `Entity` 并不等于保留上次的元素树。窗口重绘时,普通子 View 即使自身状态没变,也可能再次执行 `Render::render`。缓存 View 则会复用上一帧选定的记录,其中包括输入处理器。 当前 GPUI 没有需要构造的公开 `ViewCache` 类型。真正的 View 缓存 API 是 **`Entity::cached(style)`**(或 **`AnyView::cached(style)`**),它为一个由 Entity 支撑的子树建立缓存边界。GPUI Kit 还使用范围更窄的缓存,它们节省的是不同的成本。 | 机制 | 复用或省去的工作 | 生命周期与所有者 | | --- | --- | --- | | `Entity::cached(style)` | 未变化 View 的 render、子元素 layout/prepaint 与 paint 工作 | GPUI 的窗口缓存,按 Entity View 及元素路径索引 | | [`Window::use_keyed_state`](./window) | 重建后的元素所需的少量状态或计算结果 | 稳定 [`ElementId`](./element_id) 下的窗口元素状态 | | 模型持有的缓存 | 派生值、测量结果或绘图资源 | 所属 `Entity`;由应用定义失效条件 | | `VirtualList` | 构建屏幕外的行 | 只处理可见范围;滚动位置由单独的 handle 保留 | 这些机制可以组合。例如缓存的面板里可以包含虚拟列表,可见的图表行也可以复用已三角化的路径。虚拟列表**不会**缓存所有行的 View;`use_keyed_state` 也**不会**跳过 View 的 `render`。 可以分别问三个问题:**什么触发重绘?** `cx.notify()` 报告 Entity 的可见输出已变化,`window.refresh()` 请求刷新整窗。**重建元素后什么会保留?** 应用持有的 Entity,以及按元素 key 保存的状态,都可以跨帧存活。**新一帧能省去什么工作?** 只有干净的缓存 View 能重放已记录的子树;keyed state 或路径缓存只是为仍需执行的工作提供可复用数据。缓存的 scene 是 GPUI 上一帧的绘制记录,并非应用持有的图片或可单独寻址的纹理。 ## 缓存由 Entity 支撑的子树 下面的例子直接使用现有 `hello_world` 包。将 `examples/hello_world/src/main.rs` 替换为以下代码,再执行 `cargo run -p hello_world`。两个按钮让缓存边界无需性能分析器也能观察:终端会在两个 View 执行 `render` 时打印记录。 ```rust use gpui_kit::base::StyledExt; use gpui_kit::component::button::Button; use gpui_kit::*; struct Workspace { panel: Entity, parent_clicks: usize, } struct ResultsPanel { panel_clicks: usize, } impl Render for ResultsPanel { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { println!("panel render: {}", self.panel_clicks); div() .v_flex() .gap_2() .size_full() .child(format!("Panel clicks: {}", self.panel_clicks)) .child(Button::new("panel-click").label("Update panel").on_click( cx.listener(|this, _, _, cx| { this.panel_clicks += 1; cx.notify(); }), )) } } impl Render for Workspace { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { println!("workspace render: {}", self.parent_clicks); div() .v_flex() .gap_2() .size_full() .child(format!("Workspace clicks: {}", self.parent_clicks)) .child(Button::new("workspace-click").label("Update workspace").on_click( cx.listener(|this, _, _, cx| { this.parent_clicks += 1; cx.notify(); }), )) .child( div().w_full().h(px(180.)).child( self.panel .clone() .cached(StyleRefinement::default().size_full()), ), ) } } fn main() { gpui_kit::application().run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { let panel = cx.new(|_| ResultsPanel { panel_clicks: 0 }); cx.new(|_| Workspace { panel, parent_clicks: 0, }) }) .expect("failed to open window"); }); } ``` 首帧之后连续点击 **Update workspace**。`Workspace` 会重新 render,而 `ResultsPanel` 可以重放,不再打印 `panel render`。缓存区域里的 **Update panel** 仍然可以点击:它改变子 Entity,调用 `cx.notify()`,随后打印新计数的 `panel render`。不同平台和输入过程产生的窗口帧数可能不同;比较两种 render 记录即可,不要假设每次点击恰好绘制一帧。 例子在构造 `Workspace` 时用 `cx.new(...)` 创建一次 `panel`,并将它保存在字段中(详见 [Context](./context))。如果在 `render` 里每次创建新 Entity,它就会获得新身份,状态和热缓存都会丢失。`style` 是缓存 View 的**外层布局约定**。GPUI 要先布局这个盒子,才能决定是否复用内容;它无法向尚未渲染的子树询问固有尺寸。例子中的固定高度父盒子提供确定尺寸,缓存子级填满它。需要由内容决定尺寸时,直接用 `.child(self.panel.clone())` 嵌入,不使用缓存。 父级保存类型擦除的面板时,可用 `AnyView::cached(style)`,GPUI Kit 的 Dock 就采用这种形式。[`RenderOnce`](./render-once) 值和任意 `ViewElement` 不能使用这个公开 API:它们没有 Entity 的通知机制来使冻结的子树失效。 缓存属于**窗口的帧**,不属于 `Entity` 本身。同一个 Entity 出现在两个窗口时,两边各有自己的帧记录。GPUI 使用 View 由 Entity 派生的元素 ID 及其 keyed 祖先路径定位记录,因此把 View 移到另一个路径,不会带走旧路径的缓存子树。保留 Entity 能保留其状态;要复用帧记录,还需要稳定的位置和未失效的缓存条件。[GPUI Kit 的 Dock 面板](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/dock/tab_panel.rs) 在有尺寸约束的标签页区域中用 `cached(StyleRefinement::default().absolute().size_full())` 嵌入 `AnyView`。 ## GPUI 何时复用? 命中缓存时,GPUI 不会调用该子 View 的 `render`,而是将上一帧子树的 prepaint 和 paint 记录重放到当前帧。其中包括命中区域、事件派发节点、焦点状态、鼠标监听器、输入处理器和绘制场景,因此缓存区域仍可交互。原来的元素值并不会作为一棵永久元素树保存。重用子树中的事件处理器仍会在输入发生时执行;省去的是重新构建和绘制它们的工作。 命中要求 Entity 保持干净,同时缓存 View 的**边界、内容裁剪区域及继承的文本样式**与记录的帧一致。强制刷新时 GPUI 也会绕过复用;检查器选取元素时可以禁用缓存。条件不满足,就会重新 render、布局子树,并记录新缓存。 | 变化 | 这个缓存边界的行为 | | --- | --- | | 兄弟元素或父 View 重绘,而此子 View 及其盒子没变 | 父级仍构建自己的树;缓存子级可被重放。 | | 子 View 改变并调用 `cx.notify()` | GPUI 标记该 View 为脏,重建并更新缓存。 | | 后代 View 改变并发出通知 | GPUI 将其祖先 View 路径标脏,使缓存边界重新构建。 | | 缓存盒子改尺寸、裁剪区域改变,或继承的文本样式改变 | 此缓存条目不命中,GPUI 重建子树。 | | 子 View 被移除,或其身份、路径改变 | 旧位置的缓存子树不能用于新位置。 | 在事件处理器或[任务](./task)中修改状态;影响可见输出时,对相应 Entity 调用 `cx.notify()`。如果子 View 的输出依赖 [Global](./global),应观察该 Global 并通知子 View;仅修改 Global 不会把读取它的缓存 View 标脏。如果依赖另一个 Entity,则用观察或其他明确的失效路径。不要以为仅仅重新 render 父级,就一定会刷新仍然干净的缓存子级。`window.refresh()` 用于有意进行整窗刷新,不是通常的状态更新方式。 观察关系应指向**输出依赖该数据的 View**。例如,面板在 `render` 中读取模型 Entity 时,仅更新模型不能告知 GPUI 面板 View 已变脏。应让面板观察模型,并在展示值改变时对面板调用 `cx.notify()`。在边界内部读取主题、语言设置或其他 Global 时也要遵守此规则。命中缓存时,读取数据和创建闭包的 `render` 都会跳过,因此父级捕获了新值也不足以刷新它。 缓存有明确范围:它能跳过子级边界**内部**的工作,但窗口仍然要绘制一帧,父级也仍会按需运行。第一帧和每次未命中都要付出正常的 render/layout/paint 成本。适合在测量表明子树昂贵、周围内容变化时它却经常保持不变的地方使用。 ## 元素状态是另一种缓存 GPUI 在后续 render 中会重新创建值类型的元素。如果元素需要让少量状态跨连续帧存活,`Window::use_keyed_state` 会按当前元素路径加指定 key 保存 `Entity`。它还会观察该状态 Entity;状态变化时,当前 View 会收到通知。只要这个路径在连续帧中被访问,状态就会保留;缓存子树重放其元素状态访问的帧也算在内。路径消失且没有其他强引用时,状态会释放。`Window::use_state` 使用调用位置作为 key,只有这个位置能唯一标识状态时才合适。重复或可重排行的 `ElementId` 应从稳定的领域 ID 生成:改 ID 会重置状态;不相关的兄弟元素共用 ID 则可能碰撞。 GPUI Kit 的 [`Plot` 路径缓存](../component/plot) 是具体例子。Plot 及各条 `Line` 都作为值重新构建,因此存在 `Line` 上的路径会随该值消失。`PathCaches::for_paint("lines", window, cx)` 把缓存放在 Plot 的元素 ID 下的窗口键控状态中。`ShapeKey` 涵盖投影后的点和影响几何形状的描边设置;`PathCache::get` 只在 key 改变时重新三角化。路径以零原点构建,绘制时平移到当前原点,所以图表移动仍能复用几何形状。只变颜色时,可以在绘制时应用新颜色,而无需重建未变化的路径几何。 这个缓存节省的是**路径构建**,不是 Plot View 的 `render`,也不是当前帧提交绘图命令的工作。它的 slot 按位置编号:series 可以重排时,应将稳定的 series 身份映射到 slot,或确保 shape key 能安全地使变化的 slot 失效。源码分析见 [Paint](./paint#plot每帧重建的值使用-keyed-window-state)。 key 必须包含改变缓存**几何形状**的所有输入:投影后的点、尺寸、描边宽度、曲线样式,以及构建路径时使用的其他三角化设置。如果颜色只在 paint 时选取,则不必纳入几何 key。漏掉几何输入可能绘制过期路径;纳入绝对原点则会让滚动失去复用机会。GPUI Kit 的 [`PathCache::get` 和 `ShapeKey`](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/plot/path_cache.rs) 明确表达了这种依赖。`PathCaches` 由窗口 keyed element state 持有;如果 Plot 路径在一帧中消失,该窗口持有的缓存也会结束,即使后来有同 ID 的 Plot 再出现。 ## 虚拟化通过少做工作提升性能 长集合即使放在缓存 View 中,第一帧和缓存失效时仍要处理所有行。GPUI Kit 的 [`VirtualList`](../base/virtual-list) 接收逐项尺寸,针对可见范围(外加少量预绘范围)调用渲染闭包;还可能为测量交叉轴尺寸而渲染一个代表项。该帧的大部分屏幕外行元素根本不会创建。它的 `VirtualListScrollHandle` 由所有者单独保留,使滚动位置跨元素重建存活。 ```rust use std::rc::Rc; use gpui_kit::*; use gpui_kit::base::{v_virtual_list, VirtualListScrollHandle}; struct Row { id: u64, name: SharedString, } struct ResultsList { rows: Vec, sizes: Rc>>, scroll: VirtualListScrollHandle, } impl Render for ResultsList { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_virtual_list(cx.entity(), "results", self.sizes.clone(), |this, range, _, _| { range .map(|ix| { div() .id(("result", this.rows[ix].id)) .child(this.rows[ix].name.clone()) }) .collect::>() }) .track_scroll(&self.scroll) .size_full() } } ``` 闭包应读取已经准备好的数据;不要在里面排序、加载数据,或给每一行都创建长期存活的 Entity。重复的可交互行应从行数据获得稳定 ID。虚拟化与 View 缓存分别解决两个问题:创建**多少**元素,以及是否重放**未变化的子树**。 ## 选择最小的有效边界 先使用普通 Entity 和声明式 render。如果性能分析表明,某个稳定面板只是因为旁边的 UI 变化而反复构建,就在面板周围加 `cached(style)`,并给它可靠的布局盒子。如果每帧可见的绘图操作仍然昂贵,用明确的 key 和失效规则缓存派生几何。若成本随集合长度增长,则使用虚拟列表。不要用一个大缓存去掩盖本该移出 `render` 的计算,或本该由 Entity 保留的数据。 关于 View 何时重新构建元素树,见 [Render](./render)。 ## 保留缓存之前验证行为 在针对性测试或性能分析器中统计子 View 的 `render` 调用次数,并在首次绘制后比较以下情况: 1. 在缓存盒子不变时重绘无关的兄弟元素。子 View 不应再次 render,但内部控件仍应响应输入。 2. 修改子 View 展示的状态,调用其 `cx.notify()`,确认子 View 重新 render 且新内容显示出来。 3. 通过实际的观察关系修改子 View 外部的依赖,例如模型或 Global,确认子 View 重建。如果父级变了、子级却没变,缺少依赖通知就是正确性问题。 4. 改变边界的尺寸或位置、裁剪区域或继承文本样式,确认发生预期的缓存未命中。移动盒子会改变其 bounds;滚动中的 Plot 即使 **路径几何**缓存仍然有效,View scene 也可能需要重绘。 GPUI Kit 的[缓存文本选区测试](https://github.com/longbridge/gpui-kit/blob/main/crates/component/src/text/window_selection.rs)用 render 计数器和重放帧中的选区检查验证行为。性能应在 release/profile 构建中测量:增加缓存边界也有管理成本,每帧都变化的子树未必受益。若输出过期,先检查 Entity 通知和外部依赖;若意外未命中,则检查 Entity 身份、keyed 祖先路径、bounds、content mask 和继承文本样式。 --- # Fonts Source: /versions/v0.7.1/zh-CN/docs/fonts 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/fonts 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 本文说明应用应提供哪些字体。GPUI 如何解析、塑形、测量并绘制字形,见 [TextSystem](./text-system)。 ## 从这里开始 桌面应用可以先使用主题默认字体。如果需要指定外观,就选目标机器都已安装的字体族,或把字体文件打包进应用。通过 `Theme::update` 设置 UI 与等宽字体;只调整某个元素时用 `.font_family(...)`。在各目标平台上用拉丁文、CJK、emoji 和混排内容检查结果。仅有字体族名称,并不代表它包含这些字形。 WebAssembly 应用要在创建首个窗口或测量文字之前注册字体文件。浏览器已安装的字体列表不是 GPUI Web 的字体集合。下文“WebAssembly:选择字体来源”说明浏览器构建所需的额外选择。 ## 默认字体 每个应用都从主题自带的一套 UI 字体和等宽字体开始: | 用途 | 字体 | 字号 | | --- | --- | --- | | UI 文本 | `.SystemUIFont` | 16px | | 代码/等宽 | macOS:`Menlo`,Windows:`Consolas`,Linux:`DejaVu Sans Mono` | 13px | 编辑器使用 `mono_font_family` 和 `mono_font_size` 绘制代码,详见 [Editor](/versions/v0.7.1/zh-CN/component/editor)。 应用主题时会对照系统已安装的字体检查这两个默认值:等宽默认字体缺失时换成已安装的备选;当 `.SystemUIFont` 解析到的是 GPUI 回退栈里的某个字体而不是系统字体本身(Linux 桌面通常没有 GPUI 映射到的那个字体),主题会直接记下该字体名,让文本查找一直命中缓存。你自己设置的字体保持不变。 ## 系统字体 桌面应用可以直接按名称使用**操作系统已安装的任意字体**,无需打包、无需配置。GPUI 会实时向系统字库解析(macOS 用 CoreText,Windows 用 DirectWrite,Linux 用 fontconfig)。 ```rust div().font_family("Segoe UI") Editor::new(&editor).font_family("JetBrains Mono") ``` 各平台常见字体举例: - macOS:`SF Pro`、`Helvetica`、`Arial`、`Times New Roman`、`Menlo`、`Monaco` - Windows:`Segoe UI`、`Arial`、`Consolas`、`Courier New` - Linux:`Noto Sans`、`DejaVu Sans`、`Liberation Sans`、`DejaVu Sans Mono` 请求的字体族无法加载时,`TextSystem::resolve_font` 会尝试 GPUI 的默认回退栈;如果全都无法加载,排版时会 panic。字体族能加载但缺少某个字形,是另一种情况:字形回退可能用其他字体绘制该字符。应在每个目标平台上同时确认字体族名称和所需字形。`Font::fallbacks` 用于主字体加载成功之后的缺字回退,不能使不存在的主字体变得可用。 在初始化完成、打包字体注册之后,可以查看 GPUI 当前能找到的字体族: ```rust let families = cx.text_system().all_font_names(); println!("Available font families: {families:?}"); ``` 列表包含通过 `add_fonts` 注册的字体族,但不能证明某个字重或每个字符都得到支持。 ## 通过 Theme 修改字体 通过 `Theme::update` 设置应用级字体,它会同步到底层并刷新窗口: ```rust Theme::update(cx, |theme| { theme.font_family = "Inter".into(); theme.mono_font_family = "JetBrains Mono".into(); theme.font_size = px(18.); }); ``` `font_size` 同时是应用缩放控制——`Root` 会调用 `window.set_rem_size(cx.theme().font_size)`,因此基于 [`rem` 的间距](./geometry)会跟随缩放。详见[编码指南](/versions/v0.7.1/zh-CN/docs/coding-guides)。 ## 元素级覆盖 实现了 `Styled` 的元素可以在不改动主题的情况下覆盖字体: ```rust div() .font_family("JetBrains Mono") .text_size(px(15.)) .font_weight(FontWeight::BOLD) ``` 这些就是普通的 [`Styled`](https://docs.rs/gpui-pre/0.3.8/gpui/trait.Styled.html) 方法,与样式链的其余部分组合使用。 ## 打包自定义字体 用户系统中没有的字体必须打包,并在**首帧之前**注册到文本系统。把字体文件放进应用,在启动时调用 `add_fonts`,顺序要早于打开窗口或构造会测量文字的对象: ```rust use std::borrow::Cow; cx.text_system() .add_fonts(vec![Cow::Borrowed( include_bytes!("../fonts/MyFont-Regular.ttf").as_slice(), )]) .expect("Failed to load fonts"); ``` 之后照常用 family 名称引用: ```rust Theme::update(cx, |theme| theme.font_family = "MyFont".into()); ``` 这里要写字体文件**内部**的 family 名称,它可能与文件名不同。若要稳定显示常规、粗体和斜体,应注册所需的各个字体文件;单个常规字体文件不保证所有样式。打包字体也能让桌面应用不依赖用户是否已安装该字体族。分发时要保留相应的字体许可。 [GPUI Kit Web 画廊](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/src/lib.rs)就这样打包 `Inter Variable`、`JetBrains Mono`、`Noto Sans SC` 子集和 `IBM Plex Sans`。Rust 的 [`include_bytes!`](https://doc.rust-lang.org/std/macro.include_bytes.html) 会把这些字体字节放入 WebAssembly 下载包。画廊所用的 CJK 子集约 42 KB,源字体约 1.2 MB:已知界面文案可以制作子集来控制初始体积,但用户任意输入的文字需要另行安排字体来源。 ### 跟做:在 `hello_world` 中注册打包字体 仓库已有 `crates/story-web/fonts/Inter-Regular.ttf`,它在文件内部的 family 名称是 `Inter Variable`。将 `examples/hello_world/src/main.rs` 替换为下面的完整示例,然后在仓库根目录运行 `cargo run -p hello_world`。下方 `include_bytes!` 的路径相对于这个 `main.rs` 文件。自己的应用应把许可允许分发的字体放在自己的资源目录,并相应调整路径。 ```rust use std::borrow::Cow; use gpui_kit::component::theme::Theme; use gpui_kit::*; struct FontLab; impl Render for FontLab { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div() .flex() .flex_col() .gap_2() .p_4() .child("Theme font: Inter Variable") .child( div() .font_family(".SystemUIFont") .child("System UI font for comparison"), ) } } fn main() { application().run(|cx| { init(cx); cx.text_system() .add_fonts(vec![Cow::Borrowed( include_bytes!("../../../crates/story-web/fonts/Inter-Regular.ttf").as_slice(), )]) .expect("Failed to load bundled font"); let families = cx.text_system().all_font_names(); assert!(families.iter().any(|family| family == "Inter Variable")); println!("Registered font: Inter Variable"); Theme::update(cx, |theme| theme.font_family = "Inter Variable".into()); open_window(WindowOptions::default(), cx, |_, cx| cx.new(|_| FontLab)) .expect("Failed to open window"); }); } ``` 终端应输出 `Registered font: Inter Variable`,窗口应显示两行标签。第一行继承主题中刚注册的字体;第二行明确请求系统 UI 字体。两者外观差异取决于平台。此练习验证注册与主题选择,并不能证明字体覆盖所有字形;使用用户输入前,还要换成代表性文本并在目标平台检查。字体注册放在 `Theme::update` 与 `open_window` 之前,让首次布局使用新的字体。 ## 更换字体会改变布局 不同字体的字形宽度、上升部、下降部和字符覆盖范围各不相同。即使字号相同,回退字体或新加载的 CJK 字体也可能改变换行、控件高度、光标位置和对齐。主题更改会刷新窗口;`add_fonts` 会使字体解析和已缓存的行布局失效。如果窗口已经显示,注册之后还要调用 `cx.refresh_windows()`。在新一帧重新检查文字,尤其是窄控件和多文字体系混排的段落。自行测量时,应使用 [TextSystem](./text-system) 塑形后的行,而不是按字符数估算宽度。 ## 主题 JSON 配置 字体与字号也可以来自主题文件: ```json { "font.family": "Inter", "font.size": 16, "mono_font.family": "JetBrains Mono", "mono_font.size": 13 } ``` 在桌面应用的启动回调中,先执行 `init(cx)`,再选择主题名称并监听存有主题文件的目录。下面是需要放入该回调的片段:`cx` 来自启动回调,`"My Theme"` 必须与 `./themes` 内某个主题文件中的名称一致。 ```rust use std::path::PathBuf; use gpui_kit::component::theme::{Theme, ThemeRegistry}; use gpui_kit::SharedString; let theme_name: SharedString = "My Theme".into(); ThemeRegistry::watch_dir(PathBuf::from("./themes"), cx, move |cx| { if let Some(theme) = ThemeRegistry::global(cx).themes().get(&theme_name).cloned() { Theme::update(cx, |current| current.apply_config(&theme)); } }) .expect("Failed to watch theme directory"); ``` 完整配置说明参见 [Theme](/versions/v0.7.1/zh-CN/component/theme)。 ## WebAssembly:选择字体来源 浏览器构建及字体初始化流程见 [WebAssembly 指南](./webassembly)。 GPUI 的 Web 文本系统**不会把浏览器已安装字体枚举、加载为主要字体集合**。必须在创建窗口或测量第一段文字前,注册所有需要稳定塑形的字体族,包括初始文本样式使用的字体。在这个 Web 平台上,`.SystemUIFont` 映射到 `IBM Plex Sans`;如果主题生效前可能使用这个别名,也要注册该字体。先注册字体,**之后**再应用或切换主题,并让主题的 `font_family` 与 `mono_font_family` 指向已加载的字体。主题文件若指定 Web 中不可用的桌面字体,字体解析可能失败。[画廊初始化代码](https://github.com/longbridge/gpui-kit/blob/main/crates/story-web/src/lib.rs)依次初始化 GPUI Kit、注册字体字节、应用主题,最后打开窗口;之后切换主题也会重新指定已加载的字体族。 常用的来源有三种: | 选择 | 初始下载 | 覆盖与取舍 | | --- | --- | --- | | 通过 `include_bytes!` 打包完整字体 | WebAssembly 包体较大 | 可离线使用,对字体覆盖范围内的用户输入也有稳定效果。 | | 只打包已知界面文字的子集 | 包体较小 | 其他 CJK 字符和新增输入需要其他来源。 | | 需要时再请求字体文件 | 初始包体较小,稍后增加网络请求 | 运行时注册,并刷新窗口以重新塑形文字;需要处理加载和失败状态。 | 下载后的字体字节仍交给**同一个** `TextSystem::add_fonts` API,但使用 owned 数据。HTTP 下载可以通过应用的 `cx.http_client()` 或其他客户端完成;注册步骤如下: ```rust use std::borrow::Cow; use gpui_kit::*; fn install_downloaded_font(cx: &mut App, bytes: Vec) -> Result<()> { cx.text_system().add_fonts(vec![Cow::Owned(bytes)])?; cx.refresh_windows(); Ok(()) } ``` `add_fonts` 会使字体解析和行布局缓存失效,但已经显示的窗口仍需 `refresh_windows()` 才能显示重新塑形后的文字。注册前应确认 HTTP 响应成功且内容非空。应提供文本系统支持的实际字体文件,例如原始 TTF;字体服务的 CSS 地址可能返回样式表或 WOFF2 子集,而不是此文本系统接受的字节。外部请求仍受浏览器跨源规则约束。只下载当前内容需要的字体,并对并发请求去重。 `App::on_missing_glyphs(callback) -> Subscription` 可以在塑形后报告仍无法解析的字素簇。保存 subscription,检查 `MissingGlyph::grapheme()` 与 `font_class()`,即可按文字系统请求一次相应字体。新注册会替换之前的 callback;报告有去重和队列上限,因此它适合作为加载提示,不保证是完整缺字清单。如果 Canvas fallback 已能绘制某个 CJK 字素,它**不会**触发缺字报告。需要准确 CJK 排版时,应根据用户选择的语言或已知内容覆盖范围主动加载,不能只依赖缺字事件。 ## 排查字体问题 | 现象 | 检查方法 | | --- | --- | | 首次排版时程序 panic | 打开窗口之前查看 `all_font_names()`。确认主字体族和可用的默认字体族已注册;Web 端尤其要检查 `.SystemUIFont` 的映射字体。 | | 文字显示成意外的字体 | 对照请求的字体族、`all_font_names()` 和文件内部的 family 名称,并检查切换主题后是否覆盖了设置。 | | CJK 出现方框或字体混杂 | 确认字体包含这些**具体字符**。子集可能覆盖界面标签却漏掉用户输入;此时要加载覆盖更广的字体或相应文字体系的字体。 | | 加载字体后换行发生变化 | 用新字体的度量重新检查布局;如果窗口已显示,`add_fonts` 后刷新窗口。 | | Web 端缺字回调没有触发 | 检查 Canvas 回退是否已绘制该字素。需要统一排版时,根据内容或语言选择主动加载字体。 | ## 浏览器 Canvas 回退 已加载字体无法绘制的文字,有时仍可由浏览器提供。Web 平台可以借助访问者本机字体,通过 Canvas 2D 绘制符合条件的 emoji,因此不必为它们打包字体。构造平台时选择回退策略,之后不能更改: | `CanvasFontFallback` | 由浏览器绘制的内容 | | --- | --- | | `Emoji`(默认) | emoji,包括肤色、旗帜、键帽和 ZWJ 序列 | | `EmojiAndCjk` | emoji,以及符合条件的横排汉字、假名、现代谚文和相关标点 | | `Disabled` | 不回退,只使用打包字体 | `gpui_kit::application()` 和 `gpui_kit::platform::single_threaded_web()` 沿用默认策略。要放宽范围,就自己构造平台: ```rust use gpui_kit::web::{CanvasFontFallback, WebBackendPreference, WebPlatform}; let platform = Rc::new(WebPlatform::new_with_backend_and_font_fallback( false, WebBackendPreference::Auto, CanvasFontFallback::EmojiAndCjk, )); let http_client = Arc::new(platform.fetch_http_client()); let app = Application::with_platform(platform).with_http_client(http_client); ``` 已加载字体只要有对应字形和所需呈现形式,就仍然优先使用已加载字体。Canvas 回退只处理符合条件的**单个完整字素簇**;它不是通用系统字体 API,也不保证浏览器一定有相应字形。CJK 回退逐个独立绘制符合条件的横排字素,因此优先保证可读性,不保证精确的字距、塑形或字体特性;外观取决于访问者机器上的字体。需要准确度量、换行或视觉一致性时,应加载真正的 CJK 字体。画廊选择 `EmojiAndCjk`,因为打包的 CJK 子集覆盖已知文案,而访问者还可能在输入框中键入其他字符。 --- # Global Source: /versions/v0.7.1/zh-CN/docs/global 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/docs/global 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `Global` 是标记 trait,让 GPUI 按具体 Rust 类型在每个 [`App`](./context) 中保存一份值。多个功能和窗口共同使用的设置或服务适合放在这里。`AppSettings` 与 [`Theme`](../component/theme) 使用不同的存储位置。`Global` 本身只有 `'static` 约束,不会让值成为 View,也不会自动建立事件流。 ```rust use gpui_kit::*; struct AppSettings { notifications_enabled: bool, } impl Global for AppSettings {} ``` 这份值属于当前 `App`,不属于某个 [`Window`](./window) 或 [`Entity`](./entity)。应在应用启动时、View 读取之前初始化。对相同类型再次调用 `set_global` 会替换旧值,不会合并字段。 ## 选择状态的所有者 | 所有者 | 适用状态 | 变化如何传递给 View | | --- | --- | --- | | `App` 中的 `Global` | 整个应用共享的一份设置或服务,包括多个窗口。 | 观察该 Global 类型并通知依赖它的 View,或使用会刷新窗口的领域 API。 | | `Entity` | 有自身生命周期和行为的文档、功能 model 或 View 状态。 | 更新 Entity;其渲染状态变化时调用 `cx.notify()`。其他所有者可以观察它或订阅其事件。 | | `Window` | 属于单个窗口的 Focus、输入派发、尺寸等状态或操作。 | 根据需要使用 Window 自身的失效或刷新 API。 | 值的*作用范围*并不决定哪些界面会重绘。在 `render` 中读取 Global 不会自动建立依赖。反过来,即使 View 不负责修改某个 Global,也可以观察它。如果多个窗口中的 View 都渲染这项设置,每个 View 都需要保留自己的观察者。 可以用两个编辑器窗口来判断归属:通知偏好对两个窗口都生效,适合放在唯一的 `AppSettings` Global 中;各窗口选中的标签和点击次数属于各自的 View Entity;同时在两个窗口打开的文档可以由一个共享的 `Entity` 保存,再让两个 View 观察它。这分别对应应用、窗口 View 和文档的生命周期。仅为方便访问而把值搬进 Global,不会自动获得正确的生命周期或响应式渲染。共享 model 的练习见[多窗口](./multi-window)。 ## 读取与修改全局值 | API | 结果 | 用途 | | --- | --- | --- | | `cx.has_global::()` | `bool` | 检查是否已安装该类型。 | | `cx.global::()` | `&T` | 读取必需的值;不存在时 panic。 | | `cx.try_global::()` | `Option<&T>` | 读取可选值。 | | `cx.set_global(value)` | `()` | 安装或替换值,并通知全局观察者。 | | `cx.global_mut::()` | `&mut T` | 修改已安装的值,并通知全局观察者。 | | `cx.update_global::(\|value, cx\| …)` | 闭包结果 | 修改已安装的值,同时使用 GPUI context;修改结束时通知观察者。 | | `cx.default_global::()` | `&mut T` | 读取或安装 `T::default()`,并获得可变引用;要求 `T: Default`。 | | `cx.update_default_global::(\|value, cx\| …)` | 闭包结果 | 修改值;若尚未安装,先安装默认值。 | | `cx.remove_global::()` | `T` | 移除已安装的值,并通知全局观察者。 | `global_mut`、`update_global` 和 `remove_global` 都要求值已经存在。读取不会通知观察者。可变及默认值 API 即使没有真正改动值,也会安排一次通知。若重复工作有成本,应在调用可变 API **之前**用 `global::()` 检查;进入 `update_global` 后再比较,已无法避免这次通知。`remove_global` 也会通知:移除后读取该位置的观察者应使用 `try_global`,或以其他方式处理值不存在的情况。`global`、`global_mut` 和 `default_global` 返回的引用只在当前 GPUI 调用期间有效;之后需要数据时应复制或 clone。 `update_global` 暂时取出全局值,让闭包同时获得 `&mut T` 和 `&mut cx`。直接使用传给闭包的 `value`,不要在闭包内部再次读取或更新同一份全局值。 ```rust cx.update_global::(|settings, _cx| { settings.notifications_enabled = false; }); ``` [`Context`](./context) 可以调用这些应用级 API,因为它会解引用到 `App`。应用初始化等不属于某个 Entity 的代码直接接收 `&mut App`。 ## App 范围与 Window 范围 当所有窗口都应看到同一个值时,使用 Global,例如应用偏好、主题或共享服务的句柄。Focus、输入派发、窗口尺寸等行为应交给 [Window](./window)。Global 在整个应用中只有一份;把各窗口独立的选择状态放入同一个 Global,会增加所有权与清理的难度。 业务功能的状态应保存在由该功能的 crate 或 View 拥有的 Entity 中。`Global` 只适合真正由整个应用共享的服务、设置与协调状态;不能因为跨模块传值不便,就把庞大的业务数据集合搬进应用级存储。功能之间需要协作时,若所有权边界允许,可以传递轻量的 Entity 句柄;否则使用明确的接口、command 或 event。[编码指南](./coding-guides) 进一步说明如何把每项功能的 model 与 workflow 留在其模块边界内。 GPUI Kit 的 `Theme` 展示了应用级所有权。调用 `gpui_kit::init(cx)` 后,组件通过 `cx.theme()` 读取当前主题。GPUI Kit 还需要同步供底层使用的主题数据,并在主题变化后刷新窗口。切换模式使用 `Theme::change(...)`,编辑主题使用 `Theme::update(cx, |theme| { … })`。直接通过 `Theme::global_mut(cx)` 编辑字段,不会完成这些同步,也不会刷新所有窗口。这是 GPUI Kit 主题在普通 GPUI `Global` 行为之上的专门规则。 ## 动手构建双窗口示例 修改 Global 会通知**全局观察者**。GPUI 把通知放进 effect 队列;同一类型在通知尚未处理时的重复改动会合并。回调读取的是当前值,而不是某个中间快照或逐字段变化。Global 变化不会自动对所有曾经读取它的 Entity 调用 `cx.notify()`。如果一个 View 的渲染结果依赖 Global,可以注册 `cx.observe_global::(...)`,并在回调里通知当前 View。把返回的 `Subscription` 保存在 View 中,让观察者与 View 一起存活。 使用仓库现有的 `examples/hello_world` workspace package。将下面的完整代码放入 `examples/hello_world/src/main.rs`,在仓库根目录运行 `cargo run -p hello_world --bin hello_world`;不需要增加 package 或依赖。示例先创建一份 Global,再打开两个窗口,每个窗口创建一个 `SettingsView` Entity。Global 保存共享偏好;`local_clicks` 只属于所在窗口的 Entity。两个按钮都在 `render` 之外修改状态。 共享状态的完整路径是:启动时安装值 → 创建每个 View 并保存观察者 → 在 `render` 中读取值 → 在输入回调中修改值 → 每个观察者调用所属 Entity 的 `cx.notify()` → 两个 View 重新渲染并读取当前值。设置不会被复制到 View 的另一个字段里。 ```rust use gpui_kit::*; use gpui_kit::assets::Assets; use gpui_kit::component::button::Button; struct AppSettings { notifications_enabled: bool, } impl Global for AppSettings {} struct SettingsView { _settings_observer: Subscription, local_clicks: usize, name: &'static str, } impl SettingsView { fn new(name: &'static str, cx: &mut Context) -> Self { let _settings_observer = cx.observe_global::(|_this, cx| { cx.notify(); }); Self { _settings_observer, local_clicks: 0, name, } } } impl Render for SettingsView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let enabled = cx.global::().notifications_enabled; div() .flex() .flex_col() .gap_2() .p_4() .child(format!("{}: notifications {}", self.name, if enabled { "on" } else { "off" })) .child(format!("Clicks in this window: {}", self.local_clicks)) .child( Button::new("toggle-notifications") .label("Toggle shared setting") .on_click(|_, _window, cx| { cx.update_global::(|settings, _cx| { settings.notifications_enabled = !settings.notifications_enabled; }); }), ) .child( Button::new("local-click") .label("Increment local clicks") .on_click(cx.listener(|this, _, _, cx| { this.local_clicks += 1; cx.notify(); })), ) } } fn main() { application() .with_assets(Assets) .run(|cx| { init(cx); cx.set_global(AppSettings { notifications_enabled: true, }); for name in ["First window", "Second window"] { open_window(WindowOptions::default(), cx, move |_, cx| { cx.new(|cx| SettingsView::new(name, cx)) }) .expect("failed to open window"); } }); } ``` 在任一窗口点击 **Toggle shared setting**,两个窗口都应在 `notifications on` 和 `notifications off` 之间切换。在一个窗口点击 **Increment local clicks**,只有该窗口的数字变化。关闭其中一个窗口后,另一个窗口的两个按钮仍能使用:它自己的 Entity 和应用 Global 仍在。第一个按钮通过 `update_global` 修改 Global;两个已保存的观察者分别通知所属 Entity,两个 View 都会在渲染时读到新值。第二个按钮只修改所在窗口的 Entity,并调用该 Entity 的 `cx.notify()`;它不会修改 Global。若移除观察者字段,订阅随即释放,共享标签不会再跟随之后的 Global 更新。若移除本地按钮里的 `cx.notify()`,计数在内存中已改变,但画面可能在下次其他原因触发的渲染前一直显示旧值。 当 View 使用了[缓存](./view-cache),观察者通知尤为必要:仅重绘父级仍可能重放子级的旧内容。一个 `SettingsView` 释放时,它保存的 `Subscription` 也释放,那个窗口的回调断开;另一个窗口的观察者继续工作。`observe_global` 只表示该类型的值被触及,不携带具体变化的类型化数据;消费者需要知道操作或数据时,使用 Entity 发出的 [Event](./event)。 可以做一个排错练习:暂时注释观察者里的 `cx.notify()`,运行程序并切换设置。Global 已变化,但两个标签不一定立即更新。继续前恢复调用。这样可以把“状态没有修改”和“缺少失效通知”区分开。恢复调用后若标签仍不更新,检查每个 View 是否保存了自己的 `Subscription`,以及修改是否经过 GPUI 的 Global API。 如果回调还需要窗口,使用 `cx.observe_global_in::(window, ...)`,并保存其 `Subscription`。如果没有拥有者 Entity,也可以用 `window.observe_global::(cx, ...)` 注册窗口级观察者;回调会收到 `&mut Window` 和 `&mut App`。同样需要将订阅保存在合适的 owner 中。`window.refresh()` 或 `cx.refresh_windows()` 可显式请求渲染;上面的 View 已在观察者中调用 `cx.notify()`,因此不需要它们。 ## 常见错误 - 安装 `T` 之前就调用 `cx.global::()`:这会 panic。应先初始化,或者对可选值使用 `try_global`。 - 认为 `set_global` 或 `update_global` 会自动重绘所有读取者:应注册全局观察者并通知依赖它的 View,或者使用 GPUI Kit 主题更新等会明确刷新窗口的 API。 - 让 `observe_global` 返回的 `Subscription` 在构造函数结束时被 drop:观察会立即停止。 - 把窗口独立的 Focus、选择状态或文档状态放进唯一的应用级 Global:让 Window 或 Entity 拥有它;确实需要应用级协调时,再明确按窗口或文档 ID 保存。 - 通过 `global_mut` 修改 GPUI Kit 主题后,期待所有主题映射和窗口自动更新:应使用主题专用 API。 - 在 `render` 中修改 Global:GPUI 可能因为很多原因重新渲染。应在输入或其他副作用回调中修改状态,再通过观察者让 View 更新。 - 通过 Global 内的 `Arc`、锁、原子变量或其他内部可变句柄修改数据,却期待 Global 观察者运行:GPUI 只能感知通过 Global 修改 API 进行的操作。应明确通知依赖该数据的所有者;频繁变化的数据可交给可观察的 Entity。 --- # NumberInput Source: /versions/v0.7.1/zh-CN/component/number-input 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/number-input 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 NumberInput 是针对数值输入场景设计的组件,内置递增/递减按钮,并支持最小值、最大值、步进值以及千分位格式化等能力。 ## 导入 ```rust use gpui_kit::component::input::{InputState, NumberInput, NumberInputEvent, StepAction}; ``` ## 用法 ### 基础数值输入 ```rust let number_input = cx.new(|cx| InputState::new(window, cx) .placeholder("Enter number") .default_value("1") ); NumberInput::new(&number_input) ``` ### 输入限制与字符归一化 默认情况下,NumberInput 只接受合法数字:可选的开头 `+`/`-` 符号、数字和一个小数点(例如 `-1.5`),其他字符在输入和粘贴时会被拒绝。 针对中文等输入法用户,全角数字字符会自动转换为对应的半角字符: - 全角数字:`123` → `123` - 全角符号:`+` → `+`、`-` → `-` - 全角小数点与中文句号:`.`、`。` → `.` 以小数点开头的输入保持原样(例如 `.5`,解析为 `0.5`),与 Web 行为一致——删除 `1.2` 的整数部分会保留 `.2`,便于继续编辑。 如需关闭默认限制,可显式设置 mask:`state.set_mask_pattern(MaskPattern::None, window, cx)`。 如需进一步限制输入(例如仅允许正整数),可使用 `pattern`: ```rust let integer_input = cx.new(|cx| InputState::new(window, cx) .placeholder("Integer value") .pattern(Regex::new(r"^\d+$").unwrap()) ); NumberInput::new(&integer_input) ``` ### 最小值 / 最大值 / 步进值 默认情况下,NumberInput 以 `step(1.)` 在内部更新数值:`↑`/`↓` 键和 `+`/`-` 按钮按 1 步进并发出 `InputEvent::Change` 事件。可通过 `min`/`max` 限制范围,或设置自定义步进值。 如需仅发出 `NumberInputEvent::Step` 事件(由订阅方负责更新数值),可调用 `state.set_step(None, window, cx)`。 手动输入的越界值在输入过程中会被保留,失焦时自动收敛到范围内。步进遵循 Web 行为:无法朝按键方向移动数值的步进不会生效(例如数值已等于或低于 `min` 时按 `↓` 不会变化)。 ```rust let stepper_input = cx.new(|cx| InputState::new(window, cx) .default_value("50") .step(5.) .min(0.) .max(100.) ); NumberInput::new(&stepper_input) ``` ### 动态步长 使用 `step_by` 可以根据当前值和步进方向实时计算步长,例如步长随取值区间变化。步长可在边界处随方向不同,因此闭包会收到 `StepAction`;下例中以 `1.0` 为界,向下步进 `0.1`,向上步进 `0.5`。闭包还会收到一个 `Context`,可用于读取或更新其他 entity: ```rust let price_input = cx.new(|cx| InputState::new(window, cx) .step_by(|value, action, _cx| match action { StepAction::Increment => if value < 1.0 { 0.1 } else { 0.5 }, StepAction::Decrement => if value <= 1.0 { 0.1 } else { 0.5 }, }) .min(0.) ); NumberInput::new(&price_input) ``` 步进策略也可以在运行时通过 `set_step` 更新: ```rust use gpui_kit::component::input::NumberStep; state.set_step(NumberStep::Fixed(0.01), window, cx); state.set_step(NumberStep::by_value(|v, _, _cx| if v < 1. { 0.01 } else { 0.1 }), window, cx); state.set_step(None, window, cx); // 回退到 NumberInputEvent::Step 事件模式 ``` ### 数字格式化 ```rust use gpui_kit::component::input::MaskPattern; let currency_input = cx.new(|cx| InputState::new(window, cx) .placeholder("Amount") .mask_pattern(MaskPattern::Number { separator: Some(','), fraction: Some(2), }) ); NumberInput::new(¤cy_input) ``` ### 不同尺寸 ```rust NumberInput::new(&input).large() NumberInput::new(&input) NumberInput::new(&input).small() ``` ### 前缀与后缀 ```rust use gpui_kit::component::{button::{Button, ButtonVariants}, IconName}; NumberInput::new(&input) .prefix(div().child("$")) NumberInput::new(&input) .suffix( Button::new("info") .ghost() .icon(IconName::Info) .xsmall() ) ``` ### 禁用状态 ```rust NumberInput::new(&input).disabled(true) ``` ### 关闭默认外观 ```rust div() .w_full() .bg(cx.theme().secondary) .rounded(cx.theme().radius) .child(NumberInput::new(&input).appearance(false)) ``` ### 处理 NumberInput 事件 默认情况下 NumberInput 在内部更新数值。如需回退到 `NumberInputEvent::Step` 模式(由订阅方负责更新数值),可调用 `state.set_step(None, window, cx)`: ```rust let number_input = cx.new(|cx| InputState::new(window, cx)); let mut value: i64 = 0; cx.subscribe_in(&number_input, window, |view, state, event, window, cx| { match event { InputEvent::Change => { let text = state.read(cx).value(); if let Ok(new_value) = text.parse::() { view.value = new_value; } } _ => {} } }); cx.subscribe_in(&number_input, window, |view, state, event, window, cx| { match event { NumberInputEvent::Step(step_action) => { match step_action { StepAction::Increment => { view.value += 1; state.update(cx, |input, cx| { input.set_value(view.value.to_string(), window, cx); }); } StepAction::Decrement => { view.value -= 1; state.update(cx, |input, cx| { input.set_value(view.value.to_string(), window, cx); }); } } } } }); ``` ### 程序化控制 ```rust NumberInput::increment(&number_input, window, cx); NumberInput::decrement(&number_input, window, cx); ``` ## API 参考 ### NumberInput | 方法 | 说明 | | ------------------------------ | ------------------------------------------ | | `new(state)` | 使用 `InputState` 创建数值输入组件 | | `placeholder(str)` | 设置占位文案 | | `size(size)` | 设置尺寸 | | `prefix(el)` | 添加前缀元素 | | `suffix(el)` | 添加后缀元素 | | `appearance(bool)` | 开启或关闭默认样式 | | `disabled(bool)` | 设置禁用状态 | | `increment(state, window, cx)` | 以代码方式递增 | | `decrement(state, window, cx)` | 以代码方式递减 | ### NumberInputEvent | 事件 | 说明 | | ------------------ | ---------------------------------- | | `Step(StepAction)` | 点击增减按钮时触发,仅当 `step` 为 `None` 时发出(通过 `set_step(None, ...)` 选择该模式) | ### StepAction | 动作 | 说明 | | ----------- | ------------------------- | | `Increment` | 数值增加 | | `Decrement` | 数值减少 | ### InputState(数值相关方法) | 方法 | 说明 | | ----------------------------------- | ------------------------------------------------------- | | `step(impl Into)` | 设置内置递增/递减的步进值(默认为 1) | | `step_by(fn(f64, StepAction, &mut Context) -> f64)` | 步进时根据当前值和方向实时计算步长 | | `min(f64)` | 设置最小值,步进与失焦时收敛到该值 | | `max(f64)` | 设置最大值,步进与失焦时收敛到该值 | | `set_step(Option, ...)` | 构造后更新步进策略 | | `set_min(Option, ...)` | 构造后更新最小值 | | `set_max(Option, ...)` | 构造后更新最大值 | | `pattern(regex)` | 设置校验正则,例如只允许数字 | | `mask_pattern(MaskPattern::Number)` | 设置数字格式化规则 | | `value()` | 获取当前展示值 | | `unmask_value()` | 获取未格式化的真实数值 | ### MaskPattern::Number | 字段 | 类型 | 说明 | | ----------- | --------------- | -------------------------------------- | | `separator` | `Option` | 千分位分隔符 | | `fraction` | `Option` | 小数位数 | ## 键盘导航 | 按键 | 行为 | | ----------- | -------------------------- | | `↑` | 增加数值 | | `↓` | 减少数值 | | `Tab` | 切换到下一个字段 | | `Shift+Tab` | 切换到上一个字段 | | `Enter` | 提交或确认当前值 | | `Escape` | 清空输入(若启用) | ## 示例 ### 整数计数器 ```rust struct CounterView { counter_input: Entity, counter_value: i32, } impl CounterView { fn new(window: &mut Window, cx: &mut Context) -> Self { let counter_input = cx.new(|cx| InputState::new(window, cx) .placeholder("Count") .default_value("0") .pattern(Regex::new(r"^-?\d+$").unwrap()) ); let _subscription = cx.subscribe_in(&counter_input, window, Self::on_number_event); Self { counter_input, counter_value: 0, } } fn on_number_event( &mut self, state: &Entity, event: &NumberInputEvent, window: &mut Window, cx: &mut Context, ) { match event { NumberInputEvent::Step(StepAction::Increment) => { self.counter_value += 1; state.update(cx, |input, cx| { input.set_value(self.counter_value.to_string(), window, cx); }); } NumberInputEvent::Step(StepAction::Decrement) => { self.counter_value -= 1; state.update(cx, |input, cx| { input.set_value(self.counter_value.to_string(), window, cx); }); } } } } NumberInput::new(&self.counter_input) ``` ### 货币输入 ```rust struct PriceInput { price_input: Entity, price_value: f64, } impl PriceInput { fn new(window: &mut Window, cx: &mut Context) -> Self { let price_input = cx.new(|cx| InputState::new(window, cx) .placeholder("0.00") .mask_pattern(MaskPattern::Number { separator: Some(','), fraction: Some(2), }) ); Self { price_input, price_value: 0.0, } } } h_flex() .gap_2() .child(div().child("$")) .child(NumberInput::new(&self.price_input)) ``` ### 带上下限的数量选择器 ```rust struct QuantitySelector { quantity_input: Entity, } impl QuantitySelector { fn new(window: &mut Window, cx: &mut Context) -> Self { // 按 1 步进并限制在 1..=99 范围内,无需处理事件。 let quantity_input = cx.new(|cx| InputState::new(window, cx) .default_value("1") .min(1.) .max(99.) ); Self { quantity_input } } } NumberInput::new(&self.quantity_input).small() ``` ### 浮点数输入 ```rust // 按 0.1 步进,步进时保留当前值的小数位数, // 例如 0.2 -> 0.3(而不是 0.30000000000000004)。 let float_input = cx.new(|cx| InputState::new(window, cx) .placeholder("0.0") .step(0.1) ); NumberInput::new(&float_input) ``` ## 最佳实践 1. 客户端和服务端都应校验数值输入。 2. 使用 `min`/`max` 对用户可操作范围设置明确上下限。 3. 根据业务场景选择合适的 `step` 步进值。 4. 对非法输入提供清晰反馈。 5. 在整个应用中保持统一的数字格式。 6. 高频点击增减时,必要时做节流或去抖。 7. 始终为输入提供清晰的标签与描述。 --- # Input Source: /versions/v0.7.1/zh-CN/component/input 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/input 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 多个附加元素、共享外框和文本域工具栏的组合方式,见 [Input Group](/versions/v0.7.1/zh-CN/component/input-group)。 Input 是一个单行文本输入组件,支持校验、输入掩码、前后缀元素以及多种交互状态。普通多行文本请使用 [Textarea](/versions/v0.7.1/zh-CN/component/textarea),源代码编辑请使用 [Editor](/versions/v0.7.1/zh-CN/component/editor)。 ## 导入 ```rust use gpui_kit::component::input::{Input, InputState}; ``` ## 用法 ### 基础输入框 ```rust let input = cx.new(|cx| InputState::new(window, cx)); Input::new(&input) ``` ### Placeholder ```rust let input = cx.new(|cx| InputState::new(window, cx) .placeholder("Enter your name...") ); Input::new(&input) ``` ### 默认值 ```rust let input = cx.new(|cx| InputState::new(window, cx) .default_value("John Doe") ); Input::new(&input) ``` ### 可清空 ```rust Input::new(&input) .cleanable(true) ``` ### 前缀和后缀 ```rust use gpui_kit::component::{Icon, IconName}; Input::new(&input) .prefix(Icon::new(IconName::Search).small()) Input::new(&input) .suffix( Button::new("info") .ghost() .icon(IconName::Info) .xsmall() ) Input::new(&input) .prefix(Icon::new(IconName::Search).small()) .suffix(Button::new("btn").ghost().icon(IconName::Info).xsmall()) ``` ### 密码输入 ```rust let input = cx.new(|cx| InputState::new(window, cx) .masked(true) .default_value("password123") ); Input::new(&input) .content_type(InputContentType::Password) .mask_toggle() ``` 掩码状态下,输入框不会让明文进入剪贴板,也不会通过选区暴露内容:Copy 和 Cut 不执行任何操作(上下文菜单中同样置灰),按词删除会删掉光标之前的全部内容,双击 则选中整个值而不是其中一个词。Paste 和 Select All 不受影响,通过 `mask_toggle` 显示明文后,上述操作全部恢复。 ### 尺寸 ```rust Input::new(&input).large() Input::new(&input) Input::new(&input).small() ``` ### 禁用态 ```rust Input::new(&input).disabled(true) ``` ### 只读态 与 `disabled` 不同,只读输入框保持正常外观,仍然可以聚焦、选中和复制,只是拒绝用户对内容的修改。 ```rust Input::new(&input).readonly(true) ``` ### 按 ESC 清空 ```rust let input = cx.new(|cx| InputState::new(window, cx) .clean_on_escape() ); Input::new(&input) ``` ### 输入校验 ```rust let input = cx.new(|cx| InputState::new(window, cx) .validate(|s, _| s.parse::().is_ok()) ); let input = cx.new(|cx| InputState::new(window, cx) .pattern(regex::Regex::new(r"^[a-zA-Z0-9]*$").unwrap()) ); ``` ### 输入掩码 ```rust let input = cx.new(|cx| InputState::new(window, cx) .mask_pattern("(999)-999-9999") ); let input = cx.new(|cx| InputState::new(window, cx) .mask_pattern("AAA-###-AAA") ); use gpui_kit::component::input::MaskPattern; let input = cx.new(|cx| InputState::new(window, cx) .mask_pattern(MaskPattern::Number { separator: Some(','), fraction: Some(3), }) ); ``` ### 监听事件 ```rust let input = cx.new(|cx| InputState::new(window, cx)); cx.subscribe_in(&input, window, |view, state, event, window, cx| { match event { InputEvent::Change => { let text = state.read(cx).value(); println!("Input changed: {}", text); } InputEvent::PressEnter { secondary } => { println!("Enter pressed, secondary: {}", secondary); } InputEvent::Focus => println!("Input focused"), InputEvent::Blur => println!("Input blurred"), } }); ``` ### 自定义外观 ```rust Input::new(&input).appearance(false) div() .border_b_2() .px_6() .py_3() .border_color(cx.theme().border) .bg(cx.theme().secondary) .child(Input::new(&input).appearance(false)) ``` ### 上下文菜单 ```rust // 完全关闭右键菜单,自定义菜单也不会显示。 let input = cx.new(|cx| InputState::new(window, cx).context_menu(false)); // 或者用自己的菜单替换内置菜单。state 的上下文菜单需要保持开启(默认即开启)。 Input::new(&input).context_menu(|menu, window, cx| { // 可以定义自己的操作,也可以直接复用内置操作(剪切、复制、粘贴等), // 不必重新实现这些功能。 menu.menu("Custom Action", Box::new(CustomAction)) .separator() .menu("Cut", Box::new(input::Cut)) .menu("Copy", Box::new(input::Copy)) .menu("Paste", Box::new(input::Paste)) }) ``` ### 触摸选择 在触摸屏上,长按会选中手指下的单词,手指按住不放时选区跟随手指移动。抬起手指后,选区上方会出现编辑菜单,列出当前可用的命令——`剪切`、`复制`、`粘贴` 和 `全选`——并在选区两端各显示一个拖动 handle。拖动 handle 会移动对应的一端,另一端保持不动;多行输入框在手指到达边缘时会自动滚动。长按空白处或空输入框时会放置光标,菜单只提供 `粘贴` 和 `全选`。 handle 和菜单属于这次手势产生的选区。只要其他操作改变了选区——点击、输入、方向键、`Escape`——它们就会消失;手指滚动内容时菜单会暂时让开。点击已选中的文字可以重新呼出菜单。 剪切、复制和粘贴通过输入框自身的 action 执行,因此自定义快捷键或打开中的补全菜单都能以同样方式处理它们。只读输入框只提供 `复制` 和 `全选`;密码输入框的内容不会进入剪贴板。 ### 粘贴钩子 `on_paste` 会在默认文本插入之前拦截剪贴板内容,因此粘贴的图片和复制的文件可以保存在应用状态中,而不会被静默丢弃。`Input`、`Textarea` 和 `Editor` 均可使用。 ```rust use gpui_kit::ClipboardEntry; let view = cx.entity().downgrade(); Textarea::new(&self.composer).on_paste(move |item, _, cx| { let images: Vec<_> = item.entries().iter().filter_map(|entry| match entry { ClipboardEntry::Image(image) => Some(image.clone()), _ => None, }).collect(); if images.is_empty() { return false; // 回退到默认的文本插入 } view.update(cx, |this, cx| { // 将图片保存在输入框之外的应用状态中,例如 `Attachment`。 this.attachments.extend(images); cx.notify(); }).ok(); true // 已处理,输入框不再插入任何内容 }) ``` 当 handler 接管了粘贴时返回 `true`:`input::Paste` action 就此停止,输入框不插入任何内容。返回 `false` 则放行,engine 会像往常一样插入 `clipboard.text()`。复制的文件以 `ClipboardEntry::ExternalPaths` 的形式走同一个钩子。 已知限制:在 web 上 `read_from_clipboard()` 为 `None`(文本经由平台输入处理器到达);那里的图片粘贴需要异步剪贴板访问和权限,不在本次范围内。 ## 示例 ### 搜索输入框 ```rust let search = cx.new(|cx| InputState::new(window, cx) .placeholder("Search...") ); Input::new(&search) .prefix(Icon::new(IconName::Search).small()) ``` ### 金额输入 ```rust let amount = cx.new(|cx| InputState::new(window, cx) .mask_pattern(MaskPattern::Number { separator: Some(','), fraction: Some(2), }) ); div() .child(Input::new(&amount)) .child(format!("Value: {}", amount.read(cx).value())) ``` ### 多输入表单 ```rust struct FormView { name_input: Entity, email_input: Entity, } v_flex() .gap_3() .child(Input::new(&self.name_input)) .child(Input::new(&self.email_input)) ``` ## 原子行内 token 使用行内 token,可以在输入中加入需要整块选中、删除的人员提及、文件引用或命令。例如,输入框显示一个名为“Alice”的标签,而 `value()` 和复制操作返回它的真实文本 `@alice`。 ### 插入引用 创建并保留输入状态,在用户选中引用时插入 token: ```rust use gpui_kit::component::input::{InlineToken, Input, InputState}; let input = cx.new(|cx| InputState::new(window, cx)); input.update(cx, |state, cx| { state.replace_with_token( InlineToken::new("person-1", "@alice").with_label("Alice"), window, cx, ).expect("有效的引用"); }); Input::new(&input) ``` `replace_with_token` 替换当前选区,空选区则在光标处插入,不自动添加空格。要替换 `@ali` 这样的补全查询,使用 `replace_range_with_token(range, token, window, cx)`。Rust 范围是 UTF-8 字节半开区间,可以使用 `str::find` 等方法返回的字节偏移。 ID 标识被引用的资源,同一个人被提及两次时两个 token 使用同一个 ID。`text` 用于复制和提交,`with_label` 指定显示名称;省略 `with_label` 时直接显示真实文本。 用户可以将光标移到 token 两侧,单击选中整块 token,或用 Backspace/Delete 删除它。选区覆盖部分 token 时会包含整块 token。Undo/Redo 同时恢复文本和引用。粘贴得到普通文本。 ### 自定义外观与打开引用 token 默认渲染为 `InputToken`。`token` 槽位提供每个 token 的元素;需要图标时在其中返回带图标的 `InputToken`,并通过 `on_token_click` 打开引用: ```rust use gpui_kit::component::{ IconName, input::{InputToken, InlineTokenClickEvent}, }; Input::new(&input) .token(|token, _, _| { InputToken::new(token).icon(IconName::File) }) .on_token_click(|event: &InlineTokenClickEvent, _, _| { // 根据 event.token().id() 查找并打开资源。 }); ``` 也可以返回自己的单行元素。元素应保持在输入框行高内,超出可用行宽的内容会被裁切。通过 renderer 的上下文读取选中、只读和禁用状态。在事件回调中修改输入,不要在 renderer 中修改。悬停和选中样式应保持尺寸稳定。token 每次渲染时都会重新测量,因此数据到达后变宽的元素会在下一帧重新排版。 单击会先选中 token 再打开引用;拖选或 Shift 扩选不会打开引用。只读输入允许打开引用,禁用输入不允许。若要为打开选中的整块 token 提供快捷键,可以将 `ActivateToken` 绑定到自己选择的按键;辅助技术通过 token 的 click 操作触发同一个监听器。当应用能为引用给出明确名称(例如“打开文件”)时,可以通过 `context_menu` 自行添加菜单项。 ### 悬停显示提示或预览 使用 `on_token_hover` 响应指针悬停,不改变文档内容。悬停不会选中或编辑,只报告进入与离开,应用据此显示 tooltip、预览或状态详情: ```rust use gpui_kit::component::input::InlineTokenHoverEvent; Input::new(&input) .on_token_hover(|event: &InlineTokenHoverEvent, _, _| { if event.is_hovered() { // 根据 event.token().id() 查找并显示预览。 } // `false` 表示指针离开 token:关闭预览。 }); ``` 事件携带 token、字节 `range()`、测量得到的 `bounds()` 与 `is_hovered()`。禁用 token 从不上报悬停进入,与点击规则一致;只读 token 会上报。删除、替换或禁用正在悬停的 token 时仍会发送其退出事件,提示应随之关闭。悬停与选中样式应保持尺寸一致,避免行在指针下抖动。 如果 token 内含按钮,应消费按钮的 mouse-down 和 click 事件,避免同时打开引用。所有子操作(包括无障碍操作)都应遵守 `token.is_disabled()`;会修改内容的操作还应遵守 `token.is_readonly()`。 ### 保存、恢复与提交 保存草稿时,使用 `content()` 一起保留文本和引用: ```rust let draft = input.read(cx).content(); // 稍后恢复保存的草稿:`set_value` 既接受纯文本,也接受 content。 input.update(cx, |state, cx| { state.set_value(draft, window, cx); }); ``` 从应用自己的存储中恢复数据时,先用文本构造 `InputContent`,再把每个 token 附加到它的字节范围上。`with_token` 会随即按文本校验范围,因此 content 在设置之前就已经是自洽的: ```rust use gpui_kit::component::input::InputContent; let draft = InputContent::new("Ask @alice") .with_token(4..10, InlineToken::new("person-1", "@alice").with_label("Alice"))?; ``` 提交时重新读取 `content()`:`text()` 是消息文本,`tokens()` 是其中仍然存在的引用。根据 token ID 查找资源,并在发送前处理资源已不存在的情况。 `set_value` 清空撤销历史,不触发 `InputEvent::Change`。传入纯文本会移除全部 token,即使文字未变;传入 content 则恢复其中的 token(无法显示 token 的模式除外)。需要可撤销的纯文本替换时使用 `replace_all`。token 编辑会触发 `InputEvent::Change`,包括为已有文本添加引用。程序化 setter 可以修改只读或禁用的输入,因此不应对用户开放的应用命令需要自行检查这些状态。 ### 校验与适用范围 token 适用于 Input 和 Textarea,不支持 Editor、NumberInput、格式化 mask 或密码输入。ID 不能全为空白;真实文本和标签必须非空、单行且不含控制字符。范围不能重叠或切开 Unicode grapheme(例如 emoji 或带组合重音的字符);恢复草稿时,每个 token 的真实文本必须与对应范围匹配。 token 操作返回 `Result<_, InlineTokenError>`,失败时输入保持原样。如果插入返回 `CompositionActive`,应等待用户完成当前输入法组合后再插入。 ### JavaScript 在 `init()` 中创建并保留 `InputState`,渲染时将它传给 Input。JavaScript 范围使用 **UTF-16 字符串偏移**,与 `slice()`、`indexOf()` 一致: ```javascript import { Input, InputState } from "gpui-component"; // 在 init() 中: this.input = InputState(); this.input.set_value({ text: "🙂 @alice", tokens: [{ range: { start: 3, end: 9 }, token: { id: "person-1", text: "@alice", label: "Alice" }, }], }); // 在 render() 中: new Input(this.input) .on_token_click((event, cx) => { // 根据 event.token.id 查找并打开资源。 }) .on_token_hover((event, cx) => { // event.hovered 为 true 表示进入,为 false 表示离开;据此显示或关闭预览。 }); ``` 用 `replace_with_token` 或 `replace_range_with_token` 插入引用,用 `content()` 和 `set_value(content)` 保存、恢复草稿,用 `tokens()` 读取当前引用。要删除引用,将它的当前范围传给 `set_selected_range`,再调用 `replace("")`。返回的快照是独立对象,修改快照不会更新输入。应在初始化、事件或任务回调中编辑,不要在 renderer 中编辑。 token 校验异常提供 `error.code`,例如 `InvalidBoundary` 或 `CompositionActive`;参数形状无效时也会抛出异常。Textarea 的 `TextareaState()` 提供相同方法。使用 `gpui-base` 时,改用 `InputState.new()` 或 `TextareaState.new()` 构造状态。 --- # Speech Source: /versions/v0.7.1/zh-CN/component/speech 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/speech 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Speech 模块把用户说的话转成文本。`SpeechState` 管理一次语音输入会话:它从 `AudioInput` 采集音频,交给 `SpeechRecognizer` 识别,保存转写文本并发出 `SpeechEvent`。`SpeechButton` 负责开始和停止会话,`SpeechWaveform` 在采集期间显示输入音量。 文本写到哪里由应用决定。状态本身不会修改任何输入框;应用订阅它的事件,再把最终文本插入到合适的位置。 识别和采集都可以替换。不做额外配置时,状态使用 macOS 或 Windows 自带的识别器和默认麦克风。实现 `SpeechRecognizer` 可以接入云端服务或本地模型,实现 `AudioInput` 可以从其他来源提供音频。 ## 启用 feature 麦克风和系统识别器位于 `speech` feature 之后: ```toml [dependencies] gpui-kit = { version = "0.7.1", features = ["speech"] } ``` 该 feature 会引入 [cpal](https://crates.io/crates/cpal) 采集音频。在 Linux 上构建需要 ALSA 开发包(Debian 和 Ubuntu 上为 `libasound2-dev`)。 未启用该 feature 时,以及在 Web 上,这些类型依然可用,但没有默认输入,也没有系统识别器。此时状态只有在应用同时提供识别器和输入时才能工作。 ## 导入 ```rust use gpui_kit::component::speech::{ SpeechButton, SpeechEvent, SpeechState, SpeechStatus, SpeechWaveform, }; ``` ## 用法 ### 语音输入到 Input 在要填充的输入框旁边创建状态并订阅它,再把按钮和波形放到输入框的 suffix 中: ```rust use gpui_kit::component::{ WindowExt as _, h_flex, input::{Input, InputState}, notification::Notification, speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform}, }; let input = cx.new(|cx| InputState::new(window, cx)); let speech = cx.new(SpeechState::new); let subscription = cx.subscribe_in(&speech, window, { let input = input.clone(); move |_, _, event, window, cx| match event { SpeechEvent::Final(text) if !text.is_empty() => { input.update(cx, |input, cx| input.insert(text.clone(), window, cx)); } SpeechEvent::Error(error) => { window.push_notification(Notification::error(error.to_string()), cx); } _ => {} } }); ``` ```rust let capturing = self.speech.read(cx).status().is_capturing(); Input::new(&self.input).suffix( h_flex() .gap_2() .when(capturing, |this| { this.child(SpeechWaveform::new(&self.speech).w(px(48.)).xsmall()) }) .child(SpeechButton::new(&self.speech).xsmall()), ) ``` 把订阅保存在拥有该输入框的视图上,例如放进它的 `_subscriptions` 列表。 ### 事件 | 事件 | 触发时机 | | --- | --- | | `Started` | 会话已开始,正在采集音频。 | | `Partial(text)` | 用户说话期间转写文本发生变化。 | | `Final(text)` | 会话正常结束。文本可能为空。 | | `Cancelled` | 会话被取消,转写文本被丢弃。 | | `Error(error)` | 会话失败并结束。 | `Partial` 和 `Final` 携带的是本次会话到目前为止的**完整**转写文本:所有已确认的短语,加上当前的识别假设。后一个事件会取代前一个,因此把最新的 `Partial` 文本作为预览显示,只提交 `Final` 文本。在 render 中,`SpeechState::transcript()` 返回同样的文本: ```rust let speech = self.speech.read(cx); v_flex() .gap_2() .child(Input::new(&self.input)) .when(speech.status().is_active(), |this| { this.child( div() .text_sm() .text_color(cx.theme().muted_foreground) .child(speech.transcript()), ) }) ``` ### 控制会话 `SpeechButton` 会切换会话。如果要从 Action、快捷键或其他控件触发,直接调用状态的方法: ```rust speech.update(cx, |speech, cx| speech.start(cx)); // 会话进行中时不做任何事。 speech.update(cx, |speech, cx| speech.stop(cx)); // 结果到达后发出 `Final`。 speech.update(cx, |speech, cx| speech.cancel(cx)); // 立即发出 `Cancelled`。 speech.update(cx, |speech, cx| speech.toggle(cx)); // 空闲时 `start`,否则 `stop`。 ``` `status()` 表示会话所处的阶段: | `SpeechStatus` | 含义 | | --- | --- | | `Idle` | 没有进行中的会话。 | | `Connecting` | 正在采集音频,识别器仍在连接。 | | `Recording` | 正在采集并识别音频。 | | `Stopping` | 已停止采集,等待最终结果。 | 除 `Idle` 外,`is_active()` 都为 true;`is_capturing()` 只在 `Connecting` 和 `Recording` 时为 true。处于 `Stopping` 时,按钮显示 spinner 并忽略点击。如果识别器在停止超时内没有给出最终结果,会话会以当前已有的文本结束。超时默认为 3 秒: ```rust let speech = cx.new(|cx| SpeechState::new(cx).stop_timeout(Duration::from_secs(5))); ``` ### 按钮状态 `SpeechButton` 是一个 ghost 样式的图标 `Button`,带有本地化的 tooltip 和无障碍名称:空闲时显示麦克风,采集时显示按下状态的停止图标。 - 当状态在当前平台上没有识别器或没有输入(`has_recognizer()` 为 `false`)时,按钮**不渲染任何内容**,应用可以无条件放置它。使用 `.show_when_unsupported(true)` 可以改为渲染禁用的按钮。 - 当识别器报告自己不可用(`is_available(cx)` 为 `false`),例如语言未安装时,按钮会禁用,tooltip 提示不可用。 - `.disabled(true)` 用于应用自身的禁用原因,例如 readonly 的输入框。 ```rust SpeechButton::new(&speech) .small() .show_when_unsupported(true) .disabled(readonly) ``` ## 选择识别器 状态按以下顺序选择识别器: 1. 通过 `.recognizer(...)` 传入的识别器; 2. 否则使用平台的 `SystemRecognizer`,除非 `.system_fallback(false)` 关闭了回退; 3. 否则没有识别器,`has_recognizer()` 为 `false`。 ```rust use gpui_kit::component::speech::SystemRecognizer; // 无论系统语言是什么,都识别中文。 let speech = cx.new(|cx| { SpeechState::new(cx).recognizer(SystemRecognizer::new().locale("zh-CN")) }); // 使用应用自己的识别器代替系统识别器。 let speech = cx.new(|cx| SpeechState::new(cx).recognizer(CloudRecognizer::new(client))); // 不回退到系统识别器。没有自己的识别器时, // `has_recognizer()` 为 false,按钮不渲染任何内容。 let speech = cx.new(|cx| SpeechState::new(cx).system_fallback(false)); ``` `CloudRecognizer` 代表应用中实现了 `SpeechRecognizer` 的类型,参见[实现识别器](#实现识别器)。 `SystemRecognizer::new()` 识别系统当前语言;`.locale(...)` 接受 BCP 47 语言标签,例如 `en-US` 或 `zh-CN`。平台无法识别的语言会使识别器不可用。 ## 实现识别器 实现 `SpeechRecognizer` 即可接入任意语音服务。`start` 打开一个 `RecognitionSession`,并通过收到的 `SpeechSink` 报告结果: ```rust use gpui_kit::App; use gpui_kit::component::speech::{ RecognitionSession, SpeechError, SpeechRecognizer, SpeechSink, }; /// 报告已收到多少音频,用来代替真实服务。 struct DurationRecognizer; impl SpeechRecognizer for DurationRecognizer { fn start( &self, sink: SpeechSink, cx: &mut App, ) -> Result, SpeechError> { // 真实服务在这里建立连接,连接成功后再调用 `ready`。 sink.ready(cx); Ok(Box::new(DurationSession { sink, samples: 0 })) } } struct DurationSession { sink: SpeechSink, samples: usize, } impl RecognitionSession for DurationSession { fn push_audio(&mut self, samples: &[i16], cx: &mut App) { // 16 kHz 单声道,即默认的 `audio_format`。 self.samples += samples.len(); let seconds = self.samples / 16_000; self.sink.hypothesis(format!("{seconds} s of audio"), cx); } fn finish(&mut self, cx: &mut App) { let seconds = self.samples / 16_000; self.sink.phrase(format!("{seconds} s of audio"), cx); self.sink.finish(cx); } } ``` 一次会话的流程如下: 1. **`start`** 打开会话。连接可能需要一段时间,因此应立即返回,缓存这期间推送的音频,并在服务开始接收音频时调用 `sink.ready(cx)`。状态随之从 `Connecting` 变为 `Recording`。 2. **`push_audio`** 按识别器的 `audio_format()`(默认 16 kHz 单声道)传入交错的 16 位 PCM。用 `sink.hypothesis(...)` 报告正在说的短语,它会替换上一个识别假设;用 `sink.phrase(...)` 报告识别完成的短语,它会确认该短语并清空识别假设。 3. **`finish`** 表示用户已停止说话:发送剩余音频,等待最后的结果,然后调用 `sink.finish(cx)`。状态随后发出 `Final`。 4. **drop** 会话即取消会话。在 drop 时关闭连接,之后不再报告任何内容。 出错时调用 `sink.error(SpeechError::recognizer(error), cx)`,会话以 `SpeechEvent::Error` 结束。 以下几点能让识别器保持简单: - sink 可以低成本 clone,读取服务响应的任务可以持有一份。会话停止、取消或被替换后,它的调用会被忽略,迟到的响应不会混入下一次会话。 - sink 的调用在当前 update 结束后生效。可以在主线程的任何位置调用,包括在 `start` 或 `push_audio` 内部。 - 短语按原样拼接。请带上语言需要的分隔符,例如英文句子之间的前导空格;中文句子之间则不需要。 - 识别器暂时无法工作时(例如用户登录之前),重写 `is_available` 返回 `false`。它在每次 render 时都会调用,应保持轻量。 ## 提供音频 `AudioInput` 是音频的扩展点。内置的 `Microphone` 从默认输入设备采集,并转换为识别器需要的格式。如需从其他来源提供音频(例如测试中的文件),替换它即可: ```rust use std::time::Duration; use gpui_kit::{App, Subscription}; use gpui_kit::component::speech::{AudioFormat, AudioInput, AudioSink, SpeechError}; /// 每次推送 100 ms 的静音。 struct Silence; impl AudioInput for Silence { fn start( &self, format: AudioFormat, sink: AudioSink, cx: &mut App, ) -> Result { let len = format.sample_rate() as usize / 10 * format.channels() as usize; let task = cx.spawn(async move |cx| { loop { cx.background_executor().timer(Duration::from_millis(100)).await; cx.update(|cx| sink.push(vec![0; len], cx)); } }); // 采集一直持续到状态 drop 这个 subscription。 Ok(Subscription::new(move || drop(task))) } } let speech = cx.new(|cx| SpeechState::new(cx).recognizer(recognizer).input(Silence)); ``` `levels()` 返回 `SpeechWaveform` 绘制所用的近期输入音量,取值 `0.0..=1.0`,按时间从旧到新排列,每 80 ms 音频一个值,适合需要自绘音量表的应用。音量升高时立即跟上、回落时平滑下降,背景噪音记为 `0.0`。 ## 平台支持 | 平台 | `SystemRecognizer` | `Microphone` | | --- | --- | --- | | macOS | `SFSpeechRecognizer`,仅在本机识别 | Core Audio | | Windows | `Windows.Media.SpeechRecognition`,经由 Microsoft 在线服务 | WASAPI | | Linux | 无,需由应用提供识别器 | ALSA | | Web | 无 | 无,需由应用提供输入 | ### macOS 应用的 `Info.plist` 必须说明使用麦克风和语音识别的原因: ```xml NSMicrophoneUsageDescription 用于在消息中语音输入文字。 NSSpeechRecognitionUsageDescription 用于把你的语音转成文字。 ``` 缺少 `NSMicrophoneUsageDescription` 时,系统会直接拒绝麦克风访问,不会询问用户。缺少 `NSSpeechRecognitionUsageDescription` 时,系统识别器会报告不可用,而不是发起询问,因为缺少该键时发起询问会导致进程终止。在 app bundle 之外运行的二进制(例如通过 `cargo run` 启动)不包含这两个键。 识别只在本机进行,音频不会离开设备。Mac 无法离线识别的语言不可用。 ### Windows 语音输入需要安装对应语言的语音包,并在**设置 > 隐私和安全性 > 语音**中打开**联机语音识别**。音频会发送到 Microsoft 的在线语音服务。音频必须保留在本机的应用应使用 `.system_fallback(false)`,并提供自己的识别器。 Windows 识别器会自行监听默认麦克风。状态仍通过其输入采集音频,但只用于驱动波形,因此自定义 `AudioInput` 不会改变系统识别器听到的内容。 ### Linux Linux 没有系统识别器,只有在应用提供识别器时才能使用语音输入;否则 `SpeechButton` 不渲染任何内容。`Microphone` 通过 ALSA 采集,构建时需要 `libasound2-dev`。 ## API 参考 - [SpeechState]:会话本身,包括 `recognizer`、`input`、`system_fallback`、`stop_timeout`、`start`、`stop`、`cancel`、`toggle`、`status`、`has_recognizer`、`is_available`、`transcript`、`levels` - [SpeechEvent] 与 [SpeechStatus] - [SpeechButton]:`show_when_unsupported`,以及 `Sizable` 和 `Disableable` - [SpeechWaveform]:实时波形。有音频进来后从末端开始出现,以固定速度滚动(每 80 ms 一根),直到铺满自身宽度(不设宽度时为 96 px);静音时显示为圆点。`Sizable` 决定高度,`Styled` 决定宽度 - [SpeechRecognizer]、[RecognitionSession] 与 [SpeechSink]:识别的扩展点 - [AudioInput]、[AudioSink] 与 [AudioFormat]:音频的扩展点 - [SpeechError] - [SystemRecognizer] 与 [Microphone]:`speech` feature 提供的默认实现 [SpeechState]: https://docs.rs/gpui-component/latest/gpui_component/speech/struct.SpeechState.html [SpeechEvent]: https://docs.rs/gpui-component/latest/gpui_component/speech/enum.SpeechEvent.html [SpeechStatus]: https://docs.rs/gpui-component/latest/gpui_component/speech/enum.SpeechStatus.html [SpeechButton]: https://docs.rs/gpui-component/latest/gpui_component/speech/struct.SpeechButton.html [SpeechWaveform]: https://docs.rs/gpui-component/latest/gpui_component/speech/struct.SpeechWaveform.html [SpeechRecognizer]: https://docs.rs/gpui-component/latest/gpui_component/speech/trait.SpeechRecognizer.html [RecognitionSession]: https://docs.rs/gpui-component/latest/gpui_component/speech/trait.RecognitionSession.html [SpeechSink]: https://docs.rs/gpui-component/latest/gpui_component/speech/struct.SpeechSink.html [AudioInput]: https://docs.rs/gpui-component/latest/gpui_component/speech/trait.AudioInput.html [AudioSink]: https://docs.rs/gpui-component/latest/gpui_component/speech/struct.AudioSink.html [AudioFormat]: https://docs.rs/gpui-component/latest/gpui_component/speech/struct.AudioFormat.html [SpeechError]: https://docs.rs/gpui-component/latest/gpui_component/speech/enum.SpeechError.html [SystemRecognizer]: https://docs.rs/gpui-component/latest/gpui_component/speech/struct.SystemRecognizer.html [Microphone]: https://docs.rs/gpui-component/latest/gpui_component/speech/struct.Microphone.html --- # Slider Source: /versions/v0.7.1/zh-CN/component/slider 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/slider 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Slider 用于在给定范围内选择数值,支持单值和区间选择、横向和纵向布局、自定义样式以及步进控制。 ## 导入 ```rust use gpui_kit::component::slider::{Slider, SliderState, SliderEvent, SliderValue}; ``` ## 用法 ### 基础 Slider ```rust let slider_state = cx.new(|_| { SliderState::new() .min(0.0) .max(100.0) .default_value(50.0) .step(1.0) }); Slider::new(&slider_state) ``` ### 处理事件 ```rust struct MyView { slider_state: Entity, current_value: f32, } impl MyView { fn new(cx: &mut Context) -> Self { let slider_state = cx.new(|_| { SliderState::new() .min(0.0) .max(100.0) .default_value(25.0) .step(5.0) }); let subscription = cx.subscribe(&slider_state, |this, _, event: &SliderEvent, cx| { match event { SliderEvent::Change(value) => { this.current_value = value.start(); cx.notify(); } } }); Self { slider_state, current_value: 25.0, } } } impl Render for MyView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .gap_2() .child(Slider::new(&self.slider_state)) .child(format!("Value: {}", self.current_value)) } } ``` ### 区间 Slider ```rust let range_slider = cx.new(|_| { SliderState::new() .min(0.0) .max(100.0) .default_value(20.0..80.0) .step(1.0) }); Slider::new(&range_slider) ``` ### 纵向 Slider ```rust Slider::new(&slider_state) .vertical() .h(px(200.)) ``` ### 自定义步进 ```rust let integer_slider = cx.new(|_| { SliderState::new() .min(0.0) .max(10.0) .step(1.0) .default_value(5.0) }); let decimal_slider = cx.new(|_| { SliderState::new() .min(0.0) .max(1.0) .step(0.01) .default_value(0.5) }); ``` ### 最小值和最大值 ```rust let temp_slider = cx.new(|_| { SliderState::new() .min(-10.0) .max(40.0) .default_value(20.0) .step(0.5) }); let percent_slider = cx.new(|_| { SliderState::new() .min(0.0) .max(100.0) .default_value(75.0) .step(5.0) }); ``` ### 禁用状态 ```rust Slider::new(&slider_state) .disabled(true) ``` ### 自定义样式 ```rust Slider::new(&slider_state) .bg(cx.theme().success) .text_color(cx.theme().success_foreground) .rounded(px(4.)) ``` ### Scale Slider 支持两种比例模式: - `Linear` - `Logarithmic` 对数比例适合跨度很大的取值范围,可以让较小数值获得更细的控制精度。 ```rust let log_slider = cx.new(|_| { SliderState::new() .min(1.0) .max(1000.0) .default_value(10.0) .step(1.0) .scale(SliderScale::Logarithmic) }); ``` 在这种模式下: $$ v = min \times (max/min)^p $$ 其中 `p` 是滑块位置对应的百分比,范围为 0 到 1。 - 当滑块在 25% 时,值约为 `5.62` - 当滑块在 50% 时,值约为 `31.62` - 当滑块在 75% 时,值约为 `177.83` - 当滑块在 100% 时,值为 `1000.0` #### 类型转换 ```rust let single_value: SliderValue = 42.0.into(); let range_value: SliderValue = (10.0, 90.0).into(); let range_value: SliderValue = (10.0..90.0).into(); ``` ### SliderEvent | 事件 | 说明 | | --- | --- | | `Change(SliderValue)` | 滑块值变化过程中持续触发 | | `Release(SliderValue)` | 拖拽结束(松开鼠标)时触发一次 | ### 样式 Slider 实现了 `Styled` trait,支持: - 轨道和滑块背景色 - 滑块文本颜色 - 圆角 - 尺寸定制 ## 示例 ### 颜色选择器 ```rust struct ColorPicker { hue_slider: Entity, saturation_slider: Entity, lightness_slider: Entity, alpha_slider: Entity, current_color: Hsla, } impl ColorPicker { fn new(cx: &mut Context) -> Self { let hue_slider = cx.new(|_| { SliderState::new() .min(0.0) .max(1.0) .step(0.01) .default_value(0.5) }); let saturation_slider = cx.new(|_| { SliderState::new() .min(0.0) .max(1.0) .step(0.01) .default_value(1.0) }); let subscriptions = [&hue_slider, &saturation_slider /* ... */] .iter() .map(|slider| { cx.subscribe(slider, |this, _, event: &SliderEvent, cx| { match event { SliderEvent::Change(_) => { this.update_color(cx); } } }) }) .collect::>(); Self { hue_slider, saturation_slider, // ... other fields } } fn update_color(&mut self, cx: &mut Context) { let h = self.hue_slider.read(cx).value().start(); let s = self.saturation_slider.read(cx).value().start(); // ... 计算颜色 self.current_color = hsla(h, s, l, a); cx.notify(); } } ``` ### 音量控制 ```rust struct VolumeControl { volume_slider: Entity, volume: f32, } impl VolumeControl { fn new(cx: &mut Context) -> Self { let volume_slider = cx.new(|_| { SliderState::new() .min(0.0) .max(100.0) .step(1.0) .default_value(50.0) }); let subscription = cx.subscribe(&volume_slider, |this, _, event: &SliderEvent, cx| { match event { SliderEvent::Change(value) => { this.volume = value.start(); this.apply_volume_change(); cx.notify(); } } }); Self { volume_slider, volume: 50.0, } } fn apply_volume_change(&self) { println!("Volume changed to: {}%", self.volume); } } ``` ### 价格区间筛选 ```rust struct PriceFilter { price_range: Entity, min_price: f32, max_price: f32, } impl PriceFilter { fn new(cx: &mut Context) -> Self { let price_range = cx.new(|_| { SliderState::new() .min(0.0) .max(1000.0) .step(10.0) .default_value(100.0..500.0) }); let subscription = cx.subscribe(&price_range, |this, _, event: &SliderEvent, cx| { match event { SliderEvent::Change(value) => { this.min_price = value.start(); this.max_price = value.end(); this.filter_products(); cx.notify(); } } }); Self { price_range, min_price: 100.0, max_price: 500.0, } } fn filter_products(&self) { println!("Filtering products: ${} - ${}", self.min_price, self.max_price); } } ``` ## 键盘快捷键 | 按键 | 操作 | | --- | --- | | `←` / `↓` | 按步进减小数值 | | `→` / `↑` | 按步进增大数值 | | `Page Down` | 较大幅度减小数值 | | `Page Up` | 较大幅度增大数值 | | `Home` | 设置为最小值 | | `End` | 设置为最大值 | | `Tab` | 焦点移动到下一个元素 | | `Shift + Tab` | 焦点移动到上一个元素 | --- # Alert Source: /versions/v0.7.1/zh-CN/component/alert 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/alert 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Alert 是一个通用提示组件,用于展示重要消息。它支持多种变体、可选标题、自定义图标、可关闭行为以及横幅模式,适合通知、状态提示和操作反馈场景。 ## 导入 ```rust use gpui_kit::component::alert::Alert; ``` ## 用法 ### 基础 Alert ```rust Alert::new("alert-id", "This is a basic alert message.") ``` ### 带标题 ```rust Alert::new("alert-with-title", "Your changes have been saved successfully.") .title("Success!") ``` ### 不同变体 ```rust Alert::info("info-alert", "This is an informational message.") .title("Information") Alert::success("success-alert", "Your operation completed successfully.") .title("Success!") Alert::warning("warning-alert", "Please review your settings before proceeding.") .title("Warning") Alert::error("error-alert", "An error occurred while processing your request.") .title("Error") ``` ### Alert 尺寸 ```rust use gpui_kit::component::{alert::Alert, Sizable as _}; Alert::info("alert", "Message content") .xsmall() .title("XSmall Alert") Alert::info("alert", "Message content") .small() .title("Small Alert") Alert::info("alert", "Message content") .title("Medium Alert") Alert::info("alert", "Message content") .large() .title("Large Alert") ``` ### 可关闭提示 只要设置 `on_close`,Alert 就会显示关闭按钮: ```rust Alert::info("closable-alert", "This alert can be dismissed.") .title("Dismissible") .on_close(|_event, _window, _cx| { println!("Alert was closed"); }) ``` ### 横幅模式 横幅模式会占满可用宽度,并且不显示标题: ```rust Alert::info("banner-alert", "This is a banner alert that spans the full width.") .banner() Alert::success("banner-success", "Operation completed successfully!") .banner() Alert::warning("banner-warning", "System maintenance scheduled for tonight.") .banner() Alert::error("banner-error", "Service temporarily unavailable.") .banner() ``` ### 自定义图标 ```rust use gpui_kit::component::IconName; Alert::new("custom-icon", "Meeting scheduled for tomorrow at 3 PM.") .title("Calendar Reminder") .icon(IconName::Calendar) ``` ### 使用 Markdown 内容 可以配合 `TextView` 渲染 Markdown 或 HTML 内容: ```rust use gpui_kit::component::text::markdown; Alert::error( "error-with-markdown", markdown( "Please verify your billing information and try again.\n\ - Check your card details\n\ - Ensure sufficient funds\n\ - Verify billing address" ), ) .title("Payment Failed") ``` ### 条件显示 ```rust Alert::info("conditional-alert", "This alert may be hidden.") .title("Conditional") .visible(should_show_alert) ``` ## API 参考 - [Alert] ## 示例 ### 表单校验错误 ```rust Alert::error( "validation-error", "Please correct the following errors before submitting:\n\ - Email address is required\n\ - Password must be at least 8 characters\n\ - Terms of service must be accepted" ) .title("Validation Failed") ``` ### 成功提示 ```rust Alert::success("save-success", "Your profile has been updated successfully.") .title("Changes Saved") .on_close(|_, _, _| { // Auto-dismiss after showing }) ``` ### 系统状态横幅 ```rust Alert::warning( "maintenance-banner", "Scheduled maintenance will occur tonight from 2:00 AM to 4:00 AM EST. \ Some services may be temporarily unavailable." ) .banner() .large() ``` ### 交互式提示 ```rust Alert::info("update-available", "A new version of the application is available.") .title("Update Available") .icon(IconName::Download) .on_close(cx.listener(|this, _, _, cx| { this.handle_update_notification(cx); })) ``` ### 多行格式化内容 ```rust use gpui_kit::component::text::markdown; Alert::warning( "security-alert", markdown( "**Security Notice**: Unusual activity detected on your account.\n\n\ Recent activity:\n\ - Login from new device (Chrome on Windows)\n\ - Location: San Francisco, CA\n\ - Time: Today at 2:30 PM\n\n\ If this wasn't you, please [change your password](/) immediately." ) ) .title("Security Alert") .icon(IconName::Shield) ``` [Alert]: https://docs.rs/gpui-component/latest/gpui_component/alert/struct.Alert.html --- # Badge Source: /versions/v0.7.1/zh-CN/component/badge 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/badge 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Badge 是一个通用徽标组件,可在头像、图标或其他元素上显示数字、圆点或图标。适合用来表示通知数、状态或上下文提示信息。 ## 导入 ```rust use gpui_kit::component::badge::Badge; ``` ## 用法 ### 显示数字 使用 `count` 显示数字徽标。只有当数字大于 0 时才会显示;否则自动隐藏。 默认最大值是 `99`,超过后显示为 `99+`。你也可以通过 `max` 自定义上限。 ```rust Badge::new() .count(3) .child(Icon::new(IconName::Bell)) ``` ### 不同变体 - 默认:显示数字 - Dot:显示状态圆点 - Icon:显示图标 ```rust Badge::new() .count(5) .child(Avatar::new().src("https://example.com/avatar.jpg")) Badge::new() .dot() .child(Icon::new(IconName::Inbox)) Badge::new() .icon(IconName::Check) .child(Avatar::new().src("https://example.com/avatar.jpg")) ``` ### 不同尺寸 Badge 也实现了 [Sizable] trait: ```rust Badge::new() .small() .count(1) .child(Avatar::new().small()) Badge::new() .count(5) .child(Avatar::new()) Badge::new() .large() .count(10) .child(Avatar::new().large()) ``` ### 颜色 ```rust use gpui_kit::component::ActiveTheme; Badge::new() .count(3) .color(cx.theme().blue) .child(Avatar::new()) Badge::new() .icon(IconName::Star) .color(cx.theme().yellow) .child(Avatar::new()) Badge::new() .dot() .color(cx.theme().green) .child(Icon::new(IconName::Bell)) ``` ### 用在图标上 ```rust use gpui_kit::component::{Icon, IconName}; Badge::new() .count(3) .child(Icon::new(IconName::Bell).large()) Badge::new() .count(103) .child(Icon::new(IconName::Inbox).large()) Badge::new() .count(150) .max(999) .child(Icon::new(IconName::Mail)) ``` ### 用在头像上 ```rust use gpui_kit::component::avatar::Avatar; Badge::new() .count(5) .child(Avatar::new().src("https://example.com/avatar.jpg")) Badge::new() .icon(IconName::Check) .color(cx.theme().green) .child(Avatar::new().src("https://example.com/avatar.jpg")) Badge::new() .dot() .color(cx.theme().green) .child(Avatar::new().src("https://example.com/avatar.jpg")) ``` ### 复杂嵌套 ```rust Badge::new() .count(212) .large() .child( Badge::new() .icon(IconName::Check) .large() .color(cx.theme().cyan) .child(Avatar::new().large().src("https://example.com/avatar.jpg")) ) Badge::new() .count(2) .color(cx.theme().green) .large() .child( Badge::new() .icon(IconName::Star) .large() .color(cx.theme().yellow) .child(Avatar::new().large().src("https://example.com/avatar.jpg")) ) ``` ## API 参考 - [Badge] ## 示例 ### 通知提示 ```rust Badge::new() .count(12) .child(Icon::new(IconName::Mail).large()) Badge::new() .count(3) .color(cx.theme().red) .child(Icon::new(IconName::Bell).large()) Badge::new() .count(1234) .max(999) .color(cx.theme().orange) .child(Icon::new(IconName::AlertTriangle)) ``` ### 状态提示 ```rust Badge::new() .dot() .color(cx.theme().green) .child(Avatar::new().src("https://example.com/user.jpg")) Badge::new() .icon(IconName::CheckCircle) .color(cx.theme().blue) .child(Avatar::new().src("https://example.com/verified-user.jpg")) Badge::new() .icon(IconName::AlertTriangle) .color(cx.theme().yellow) .child(Avatar::new().src("https://example.com/user.jpg")) ``` ### 显示位置 ```rust // Badge 会根据变体自动选择位置: // - Dot:右上角小圆点 // - Number:右上角数字徽标 // - Icon:右下角图标徽标 ``` ### 数字格式 ```rust Badge::new().count(5) Badge::new().count(99) Badge::new().count(100) Badge::new().count(1000).max(999) Badge::new().count(0) ``` [Badge]: https://docs.rs/gpui_component/latest/gpui_component/badge/struct.Badge.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html --- # AlertDialog Source: /versions/v0.7.1/zh-CN/component/alert-dialog 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/alert-dialog 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 AlertDialog 是一个用于中断用户并请求明确响应的模态对话框组件。它构建在 [Dialog] 之上,提供更明确的默认行为和更精简的 API,适合确认、警告和危险操作提示。 ## 与 Dialog 的区别 AlertDialog 基于 Dialog 提供了以下默认值: - 默认不允许点击遮罩关闭,可通过 `overlay_closable(true)` 修改 - 默认不显示关闭按钮,可通过 `close_button(true)` 修改 - 底部按钮居中对齐,而 Dialog 默认为右对齐 - API 更聚焦在确认和提示场景 ## 导入 ```rust use gpui_kit::component::dialog::{AlertDialog, DialogAction, DialogClose}; use gpui_kit::component::WindowExt; ``` ## 用法 ### 配置应用根视图 与 Dialog 一样,你需要在应用根视图中渲染 dialog layer。具体可参考 [Dialog 文档](/versions/v0.7.1/zh-CN/component/dialog#setup-application-root-view)。 ### 基础 AlertDialog:声明式 API 通过 `trigger` 和 `content` 创建声明式 AlertDialog: ```rust use gpui_kit::component::dialog::{AlertDialog, DialogHeader, DialogTitle, DialogDescription, DialogFooter}; AlertDialog::new(cx) .trigger( Button::new("show-alert") .outline() .label("Show Alert") ) .content(|content, _, cx| { content .child( DialogHeader::new() .child(DialogTitle::new().child("Are you absolutely sure?")) .child(DialogDescription::new().child( "This action cannot be undone. \ This will permanently delete your account from our servers." )) ) .child( DialogFooter::new() .child( Button::new("cancel") .outline() .label("Cancel") .on_click(|_, window, cx| { window.close_dialog(cx); }) ) .child( Button::new("ok") .primary() .label("Continue") .on_click(|_, window, cx| { window.push_notification("Confirmed", cx); window.close_dialog(cx); }) ) ) }) ``` ### 使用 DialogAction 和 DialogClose `DialogAction` 与 `DialogClose` 是包装组件,可自动触发对应按钮行为: - `DialogClose`:触发取消操作,并调用 `on_cancel` - `DialogAction`:触发确认操作,并调用 `on_ok` 这样就不需要手动调用 `window.close_dialog(cx)`: ```rust AlertDialog::new(cx) .trigger(Button::new("show-alert").outline().label("Show Alert")) .on_ok(|_, window, cx| { window.push_notification("You confirmed!", cx); true }) .on_cancel(|_, window, cx| { window.push_notification("You cancelled!", cx); true }) .content(|content, _, cx| { content .child( DialogHeader::new() .child(DialogTitle::new().child("Confirm Action")) .child(DialogDescription::new().child("Do you want to proceed?")) ) .child( DialogFooter::new() .child( DialogClose::new().child( Button::new("cancel").outline().label("Cancel") ) ) .child( DialogAction::new().child( Button::new("ok").primary().label("Confirm") ) ) ) }) ``` 优点: - 不需要手动关闭对话框 - 自动连接 `on_ok` 和 `on_cancel` - 代码更简洁 - 支持在回调返回 `false` 时阻止关闭 ### 基础 AlertDialog:命令式 API 通过 `WindowExt::open_alert_dialog` 直接打开: ```rust window.open_alert_dialog(cx, |alert, _, _| { alert .title("Delete File") .description("Are you sure you want to delete this file? This action cannot be undone.") .show_cancel(true) .on_ok(|_, window, cx| { window.push_notification("File deleted", cx); true }) }) ``` ### 自定义按钮 直接在对话框上设置按钮文案和变体: ```rust use gpui_kit::component::button::ButtonVariant; window.open_alert_dialog(cx, |alert, _, _| { alert .title("Delete Account") .description("This will permanently delete your account and all associated data.") .confirm() .ok_text("Delete") .ok_variant(ButtonVariant::Danger) .cancel_text("Keep") .on_ok(|_, window, cx| { window.push_notification("Account deleted", cx); true }) }) ``` `button_props` 把同样的属性打包成一个值,便于逐步拼装或在各处传递。它只覆盖该值中显式设置的字段,对话框上已有的配置——`confirm` 要求的取消按钮、此前 `on_ok` 注册的回调——都会保留,调用顺序也就不再重要: ```rust use gpui_kit::component::dialog::DialogButtonProps; window.open_alert_dialog(cx, move |alert, _, _| { alert .title("Delete Account") .confirm() .button_props( DialogButtonProps::default() .ok_text("Delete") .ok_variant(ButtonVariant::Danger) ) }) ``` ### 带图标的 AlertDialog 声明式写法: ```rust use gpui_kit::component::{Icon, IconName, ActiveTheme}; AlertDialog::new(cx) .w(px(320.)) .trigger(Button::new("permission").outline().label("Request Permission")) .on_ok(|_, window, cx| { window.push_notification("Permission granted", cx); true }) .content(|content, _, cx| { content .child( DialogHeader::new() .items_center() .child( Icon::new(IconName::TriangleAlert) .size_10() .text_color(cx.theme().warning) ) .child(DialogTitle::new().child("Network Permission Required")) .child(DialogDescription::new().child( "We need your permission to access the network to provide better services." )) ) .child( DialogFooter::new() .v_flex() .child( DialogAction::new().child( Button::new("allow").w_full().primary().label("Allow") ) ) .child( DialogClose::new().child( Button::new("deny").w_full().outline().label("Don't Allow") ) ) ) }) ``` 命令式写法: ```rust window.open_alert_dialog(cx, |alert, _, cx| { alert .title("Warning") .description("This action requires confirmation.") .icon( Icon::new(IconName::AlertTriangle) .size_8() .text_color(cx.theme().warning) ) }) ``` ### 危险操作确认 ```rust AlertDialog::new(cx) .trigger( Button::new("delete-account") .outline() .danger() .label("Delete Account") ) .on_ok(|_, window, cx| { window.push_notification("Account deletion initiated", cx); true }) .content(|content, _, _| { content .child( DialogHeader::new() .child(DialogTitle::new().child("Delete Account")) .child(DialogDescription::new().child( "This will permanently delete your account \ and all associated data. This action cannot be undone." )) ) .child( DialogFooter::new() .child( DialogClose::new().child( Button::new("cancel").flex_1().outline().label("Cancel") ) ) .child( DialogAction::new().child( Button::new("delete") .flex_1() .outline() .danger() .label("Delete Forever") ) ) ) }) ``` ### 自定义宽度 ```rust AlertDialog::new(cx) .width(px(500.)) .trigger(Button::new("custom-width").label("Custom Width")) .content(|content, _, _| { // ... dialog content }) ``` ### 控制关闭行为 #### 允许点击遮罩关闭 ```rust window.open_alert_dialog(cx, |alert, _, _| { alert .title("Notice") .description("Click outside this dialog or press ESC to close it.") .overlay_closable(true) }) ``` #### 禁用 ESC 关闭 ```rust window.open_alert_dialog(cx, |alert, _, _| { alert .title("Important Notice") .description("Please read this carefully before proceeding.") .keyboard(false) }) ``` #### 显示关闭按钮 ```rust window.open_alert_dialog(cx, |alert, _, _| { alert .title("Information") .description("Some information...") .close_button(true) }) ``` ### 阻止对话框关闭 如果 `on_ok` 或 `on_cancel` 返回 `false`,对话框不会关闭: ```rust window.open_alert_dialog(cx, |alert, _, _| { alert .title("Processing") .description("A process is running. Click Continue to stop it or Cancel to keep waiting.") .confirm() .ok_text("Continue") .on_ok(|_, window, cx| { window.push_notification("Cannot close: Process still running", cx); false }) .on_cancel(|_, window, cx| { window.push_notification("Waiting...", cx); false }) }) ``` ### Dialog 关闭回调 ```rust window.open_alert_dialog(cx, |alert, _, _| { alert .title("Confirm") .description("Are you sure?") .on_close(|_, window, cx| { window.push_notification("Dialog closed", cx); }) }) ``` ## API 参考 ### AlertDialog | 方法 | 说明 | | ------------------------ | ------------------------------------------------------------- | | `new(cx)` | 创建新的 AlertDialog | | `trigger(element)` | 设置触发对话框的元素 | | `content(builder)` | 通过 builder 函数设置内容 | | `title(title)` | 设置标题,命令式 API | | `description(desc)` | 设置描述,命令式 API | | `icon(icon)` | 设置图标,命令式 API | | `confirm()` | 同时显示确认与取消按钮 | | `ok_text(text)` | 设置确认按钮文案,默认 `"OK"` | | `ok_variant(variant)` | 设置确认按钮变体,默认 `Primary` | | `cancel_text(text)` | 设置取消按钮文案,默认 `"Cancel"` | | `cancel_variant(variant)` | 设置取消按钮变体 | | `button_props(props)` | 只覆盖该值中显式设置的字段,其余保留 | | `show_cancel(bool)` | 显示或隐藏取消按钮,默认 `false` | | `width(px)` | 设置宽度,默认 `420px` | | `overlay_closable(bool)` | 是否允许点击遮罩关闭,默认 `false` | | `close_button(bool)` | 是否显示关闭按钮,默认 `false` | | `keyboard(bool)` | 是否支持 ESC 关闭,默认 `true` | | `on_ok(callback)` | 设置确认回调,返回 `true` 时关闭 | | `on_cancel(callback)` | 设置取消回调,返回 `true` 时关闭 | | `on_close(callback)` | 设置关闭后的回调 | ### DialogButtonProps 每个属性在被设置前都处于未设置状态,未设置的属性会保留对话框上已有的配置。 | 方法 | 说明 | | ------------------------- | ---------------------------------------- | | `ok_text(text)` | 设置确认按钮文案,默认 `"OK"` | | `cancel_text(text)` | 设置取消按钮文案,默认 `"Cancel"` | | `ok_variant(variant)` | 设置确认按钮变体 | | `cancel_variant(variant)` | 设置取消按钮变体 | | `show_cancel(bool)` | 显示或隐藏取消按钮 | | `on_ok(callback)` | 设置确认回调 | | `on_cancel(callback)` | 设置取消回调 | ### DialogAction 点击其子元素时自动触发 `Confirm`,调用 AlertDialog 的 `on_ok`。 ```rust DialogAction::new().child( Button::new("ok").primary().label("Confirm") ) ``` 行为: - 派发 `Confirm` - 调用 `on_ok` - 回调返回 `true` 时关闭 - 返回 `false` 时保持打开 ### DialogClose 点击其子元素时自动触发 `Cancel`,调用 AlertDialog 的 `on_cancel`。 ```rust DialogClose::new().child( Button::new("cancel").outline().label("Cancel") ) ``` 行为: - 派发 `Cancel` - 调用 `on_cancel` - 回调返回 `true` 时关闭;如果没有设置回调也会关闭 - 返回 `false` 时保持打开 ## 示例 ### 删除确认 命令式 API: ```rust Button::new("delete") .danger() .label("Delete") .on_click(|_, window, cx| { window.open_alert_dialog(cx, |alert, _, _| { alert .title("Delete File?") .description("This action cannot be undone.") .confirm() .ok_text("Delete") .ok_variant(ButtonVariant::Danger) .on_ok(|_, window, cx| { window.push_notification("File deleted", cx); true }) }); }) ``` 声明式 API: ```rust AlertDialog::new(cx) .trigger(Button::new("delete").danger().label("Delete")) .on_ok(|_, window, cx| { window.push_notification("File deleted", cx); true }) .content(|content, _, cx| { content .child( DialogHeader::new() .child(DialogTitle::new().child("Delete File?")) .child(DialogDescription::new().child("This action cannot be undone.")) ) .child( DialogFooter::new() .child( DialogClose::new().child( Button::new("cancel").outline().label("Cancel") ) ) .child( DialogAction::new().child( Button::new("delete-confirm").danger().label("Delete") ) ) ) }) ``` ### 会话超时 ```rust window.open_alert_dialog(cx, |alert, _, _| { alert .content(|content, _, _| { content .child( DialogHeader::new() .items_center() .child(DialogTitle::new().child("Session Expired")) .child(DialogDescription::new().child( "Your session has expired due to inactivity. \ Please log in again to continue." )) ) .child( DialogFooter::new() .child( Button::new("sign-in") .label("Sign in") .primary() .flex_1() .on_click(|_, window, cx| { window.push_notification("Redirecting to login...", cx); window.close_dialog(cx); }) ) ) }) }) ``` ### 有更新可用 ```rust AlertDialog::new(cx) .trigger(Button::new("update").outline().label("Update Available")) .on_cancel(|_, window, cx| { window.push_notification("Update postponed", cx); true }) .on_ok(|_, window, cx| { window.push_notification("Starting update...", cx); true }) .content(|content, _, _| { content .child( DialogHeader::new() .child(DialogTitle::new().child("Update Available")) .child(DialogDescription::new().child( "A new version (v2.0.0) is available. \ This update includes new features and bug fixes." )) ) .child( DialogFooter::new() .child( DialogClose::new().child( Button::new("later").flex_1().outline().label("Later") ) ) .child( DialogAction::new().child( Button::new("update-now").flex_1().primary().label("Update Now") ) ) ) }) ``` ## 最佳实践 1. 简单确认场景优先使用命令式 `open_alert_dialog`。 2. 复杂布局或需要和其它组件联动时使用声明式 `trigger` + `content`。 3. 优先使用 `DialogAction` 和 `DialogClose`,而不是手动关闭对话框。 4. 对危险操作使用明确的按钮样式和文案。 5. 对结果不可逆的操作提供清晰说明。 6. 只有在确有必要时才阻止对话框关闭。 7. 保持整个应用中的按钮顺序和交互一致。 ## 相关组件 - [Dialog] - [DialogHeader] - [DialogTitle] - [DialogDescription] - [DialogFooter] - [DialogAction] - [DialogClose] [AlertDialog]: https://docs.rs/gpui-component/latest/gpui_component/dialog/struct.AlertDialog.html [Dialog]: https://docs.rs/gpui-component/latest/gpui_component/dialog/struct.Dialog.html [DialogHeader]: https://docs.rs/gpui-component/latest/gpui_component/dialog/struct.DialogHeader.html [DialogTitle]: https://docs.rs/gpui-component/latest/gpui_component/dialog/struct.DialogTitle.html [DialogDescription]: https://docs.rs/gpui-component/latest/gpui_component/dialog/struct.DialogDescription.html [DialogFooter]: https://docs.rs/gpui-component/latest/gpui_component/dialog/struct.DialogFooter.html [DialogAction]: https://docs.rs/gpui-component/latest/gpui_component/dialog/struct.DialogAction.html [DialogClose]: https://docs.rs/gpui-component/latest/gpui_component/dialog/struct.DialogClose.html --- # Dock Source: /versions/v0.7.1/zh-CN/component/dock 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/dock 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Dock 用可拖动标签组、嵌套分割和可收起的左、右、底部 Dock 构建应用工作区。它是 Longbridge 在商业产品中长期使用的布局基础,而不是一个脱离实际项目的 UI Demo。 `gpui-base` 负责数据模型、布局计算和拖放行为,`gpui-component` 提供完整控件与统一视觉。需要直接用于真实应用的 Dock 时,请使用 `gpui_kit::component::dock`。 如果你需要了解与渲染器无关的架构或实现自定义渲染器,请阅读 [Dock — gpui-base](/versions/v0.7.1/zh-CN/base/dock)。 ## 创建 DockArea 通过 `DockSkin` 创建 DockArea。如果之后需要调整外观,请保留返回的 skin。 ```rust use gpui_kit::component::dock::{DockArea, DockSkin}; struct Workspace { dock_area: Entity, dock_skin: Rc, } impl Workspace { fn new(window: &mut Window, cx: &mut Context) -> Self { let (dock_area, dock_skin) = DockSkin::dock_area("main-dock", Some(1), window, cx); Self { dock_area, dock_skin } } } ``` 可选的 version 属于你的持久化布局 schema。当应用无法再恢复旧布局时再增加它。 ## 定义 Panel 带样式的 Dock Panel 通过 `BasePanel` 提供身份与持久化能力,通过 `Panel` 提供标题、标签页和工具栏表现。 ```rust use gpui_kit::component::dock::{BasePanel, Panel, PanelEvent}; struct FilesPanel { focus_handle: FocusHandle, } impl EventEmitter for FilesPanel {} impl Focusable for FilesPanel { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } impl BasePanel for FilesPanel { fn panel_name(&self) -> &'static str { "FilesPanel" } } impl Panel for FilesPanel { fn title(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { "Files" } } impl Render for FilesPanel { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div().size_full().p_3().child("Project files") } } ``` 请用 `panel_handle` 包装带样式的 Panel。这样 base Dock 通过与渲染无关的 handle 保存 Panel 时,仍会保留 `gpui-component` 的完整面板外观。 ## 描述初始布局 `DockLayout` 是纯数据:可以在 Window 存在之前组合,也可以序列化、比较或从应用状态生成。标签组与分割布局可以任意嵌套。 ```rust use gpui_kit::component::dock::{DockLayout, panel_handle}; let files = cx.new(|cx| FilesPanel { focus_handle: cx.focus_handle(), }); let editor = cx.new(|cx| EditorPanel::new(cx)); let layout = DockLayout::h_split() .child( DockLayout::tabs().panel_view(panel_handle(files), cx), Some(px(240.)), ) .child( DockLayout::tabs().panel_view(panel_handle(editor), cx), None, ); self.dock_area.update(cx, |area, cx| { area.set_center(layout, window, cx); }); ``` 用 `h_split()` 和 `v_split()` 创建横向与纵向分割,用 `tabs()` 创建标签组。分割尺寸为 `None` 时会填满剩余空间。 DockArea 还通过 `DockPlacement` 支持左、右和底部区域。运行时可以添加、移除、激活、最大化或移动 Panel;用户操作会触发 `DockEvent`,其中 `LayoutChanged` 可用于持久化。 ## 恢复与持久化 将整个工作区导出为 `DockAreaState`,通过 Serde 保存,并在下次启动时恢复。 ```rust use gpui_kit::component::dock::DockAreaState; // 保存。 let state = self.dock_area.read(cx).dump(cx); let json = serde_json::to_string_pretty(&state)?; // 恢复。 let state: DockAreaState = serde_json::from_str(&json)?; self.dock_area.update(cx, |area, cx| { area.load(state, window, cx) })?; ``` 应用初始化时需要注册每一种可恢复的 Panel。注册的工厂通过 `panel_handle` 重新创建带样式的 Panel。 ```rust register_panel(cx, "FilesPanel", |state, window, cx| { let panel = cx.new(|cx| FilesPanel::from_state(state, window, cx)); panel_handle(panel) }); ``` Dock 状态兼容旧版本保存的布局。对于已经从应用移除的 Panel 或主动调整的 schema,仍应准备合理的默认布局作为回退。 ## 调整工作区样式 `DockSkin` 将渲染决策与布局引擎分离。你可以在不改变 Dock 行为的情况下配置常用面板外观: ```rust self.dock_skin.set_panel_style(PanelStyle::default(), cx); self.dock_skin.set_toggle_button_visible(true, cx); ``` 如果需要完全不同的视觉,可以实现 `gpui-base` 的渲染器 traits;同一份布局数据和操作逻辑仍然可以复用。 ## 可运行示例 仓库内提供了包含边缘 Dock、运行时 Panel 操作、布局持久化与键盘操作的完整工作区: ```sh cargo run -p example-dock ``` 完整实现见 [`examples/dock/src/main.rs`](https://github.com/longbridge/gpui-kit/blob/main/examples/dock/src/main.rs)。 --- # Scrollable Source: /versions/v0.7.1/zh-CN/component/scrollable 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/scrollable 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Scrollable 是一个功能完整的可滚动容器组件,支持自定义滚动条、滚动位置跟踪以及虚拟化渲染。它同时支持纵向和横向滚动,并可按需定制显示行为。 ## 导入 ```rust use gpui_kit::component::{ scroll::{ScrollableElement, ScrollbarAxis, ScrollbarMode}, StyledExt as _, }; ``` ## 用法 ### 基础可滚动容器 让任意元素具备滚动能力的最简单方式,是使用 `ScrollableElement` trait 提供的 `overflow_scrollbar()`: - `overflow_scrollbar()`:按需为两个方向都添加滚动条。 - `overflow_x_scrollbar()`:按需添加横向滚动条。 - `overflow_y_scrollbar()`:按需添加纵向滚动条。 ```rust use gpui_kit::{div, Axis}; use gpui_kit::component::ScrollableElement; div() .id("scrollable-container") .size_full() .child("Your content here") .overflow_scrollbar() ``` ### 纵向滚动 ```rust v_flex() .id("scrollable-container") .overflow_y_scrollbar() .gap_2() .p_4() .child("Scrollable Content") .children((0..100).map(|i| { div() .h(px(40.)) .w_full() .bg(cx.theme().secondary) .child(format!("Item {}", i)) })) ``` ### 横向滚动 ```rust h_flex() .id("scrollable-container") .overflow_x_scrollbar() .gap_2() .p_4() .children((0..50).map(|i| { div() .min_w(px(120.)) .h(px(80.)) .bg(cx.theme().accent) .child(format!("Card {}", i)) })) ``` ### 双向滚动 ```rust div() .id("scrollable-container") .size_full() .overflow_scrollbar() .child( div() .w(px(2000.)) .h(px(2000.)) .bg(cx.theme().background) .child("Large content area") ) ``` ## 自定义滚动条 ### 手动创建滚动条 如果你需要更高的控制粒度,可以手动创建滚动条: ```rust use gpui_kit::component::scroll::{ScrollableElement}; pub struct ScrollableView { scroll_handle: ScrollHandle, } impl Render for ScrollableView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .relative() .size_full() .child( div() .id("content") .track_scroll(&self.scroll_handle) .overflow_scroll() .size_full() .child("Your scrollable content") ) .vertical_scrollbar(&self.scroll_handle) } } ``` ## 虚拟化 ### 使用 VirtualList 处理大数据集 渲染超长列表时,推荐使用 `VirtualList`: ```rust use gpui_kit::component::{VirtualList, VirtualListScrollHandle}; pub struct LargeListView { items: Vec, scroll_handle: VirtualListScrollHandle, } impl Render for LargeListView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let item_count = self.items.len(); VirtualList::new( self.scroll_handle.clone(), item_count, |ix, window, cx| { size(px(300.), px(40.)) }, |ix, bounds, selected, window, cx| { div() .size(bounds.size) .bg(if selected { cx.theme().accent } else { cx.theme().background }) .child(format!("Item {}: {}", ix, self.items[ix])) .into_any_element() }, ) } } ``` ### 滚动到指定项 ```rust impl LargeListView { fn scroll_to_item(&mut self, index: usize) { self.scroll_handle.scroll_to_item(index, ScrollStrategy::Top); } fn scroll_to_item_centered(&mut self, index: usize) { self.scroll_handle.scroll_to_item(index, ScrollStrategy::Center); } } ``` ### 可变高度项 ```rust VirtualList::new( scroll_handle, items.len(), |ix, window, cx| { let height = if items[ix].len() > 50 { px(80.) } else { px(40.) }; size(px(300.), height) }, |ix, bounds, selected, window, cx| { // Render logic }, ) ``` ## 主题定制 ### 滚动条外观 可以通过主题配置自定义滚动条样式: ```rust // In your theme JSON { "scrollbar.background": "#ffffff20", "scrollbar.thumb.background": "#00000060", "scrollbar.thumb.hover.background": "#000000" } ``` ### 滚动条显示模式 控制滚动条何时显示: ```rust use gpui_kit::component::{Theme, scroll::ScrollbarMode}; Theme::set_scrollbar_mode(ScrollbarMode::Scrolling, cx); Theme::set_scrollbar_mode(ScrollbarMode::Hover, cx); Theme::set_scrollbar_mode(ScrollbarMode::Always, cx); ``` ### 跟随系统设置 ```rust Theme::sync_scrollbar_appearance(cx); ``` ## 示例 ### 带滚动的文件浏览器 ```rust pub struct FileBrowser { files: Vec, } impl Render for FileBrowser { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .border_1() .border_color(cx.theme().border) .size_full() .child( v_flex() .gap_1() .p_2() .overflow_y_scrollbar() .children(self.files.iter().map(|file| { div() .h(px(32.)) .w_full() .px_2() .flex() .items_center() .hover(|style| style.bg(cx.theme().secondary_hover)) .child(file.clone()) })) ) } } ``` ### 自动滚动到底部的聊天列表 ```rust pub struct ChatView { messages: Vec, scroll_handle: ScrollHandle, should_auto_scroll: bool, } impl ChatView { fn add_message(&mut self, message: String) { self.messages.push(message); if self.should_auto_scroll { let max_offset = self.scroll_handle.max_offset(); self.scroll_handle.set_offset(point(px(0.), max_offset.y)); } } } ``` ### 带虚拟滚动的数据表格 ```rust pub struct DataTable { data: Vec>, scroll_handle: VirtualListScrollHandle, } impl Render for DataTable { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { VirtualList::new( self.scroll_handle.clone(), self.data.len(), |_ix, _window, _cx| size(px(800.), px(32.)), |ix, bounds, _selected, _window, cx| { h_flex() .size(bounds.size) .border_b_1() .border_color(cx.theme().border) .children(self.data[ix].iter().map(|cell| { div() .flex_1() .px_2() .flex() .items_center() .child(cell.clone()) })) .into_any_element() }, ) } } ``` --- # Dialog Source: /versions/v0.7.1/zh-CN/component/dialog 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/dialog 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Dialog 用于创建普通对话框、确认框和提示弹窗。它支持遮罩层、键盘快捷键以及多种自定义能力。 ## 导入 ```rust use gpui_kit::component::dialog::DialogButtonProps; use gpui_kit::component::WindowExt; ``` ## 用法 ### 对话框的渲染位置 窗口的 [Root](/versions/v0.7.1/zh-CN/component/root) 会自动挂载并渲染对话框层。使用 `gpui_kit::open_window` 打开窗口,或将应用视图包裹在 `Root::new` 中即可,无需在视图中手动渲染浮层。 ### 基础对话框 ```rust window.open_dialog(cx, |dialog, _, _| { dialog .title("Welcome") .child("This is a dialog dialog.") }) ``` ### 表单对话框 ```rust let input = cx.new(|cx| InputState::new(window, cx)); window.open_dialog(cx, |dialog, _, _| { dialog .title("User Information") .child( v_flex() .gap_3() .child("Please enter your details:") .child(Input::new(&input)) ) .footer(|_, _, _, _| { vec![ Button::new("ok") .primary() .label("Submit") .on_click(|_, window, cx| { window.close_dialog(cx); }), Button::new("cancel") .label("Cancel") .on_click(|_, window, cx| { window.close_dialog(cx); }), ] }) }) ``` ### 带图标的对话框 ```rust window.open_dialog(cx, |dialog, _, cx| { dialog .child( h_flex() .gap_3() .child(Icon::new(IconName::TriangleAlert) .size_6() .text_color(cx.theme().warning)) .child("This action cannot be undone.") ) }) ``` ### 可滚动内容 ```rust use gpui_kit::component::text::markdown; window.open_dialog(cx, |dialog, window, cx| { dialog .h(px(450.)) .title("Long Content") .child(markdown(long_markdown_text)) }) ``` Dialog 不会超出窗口。宽度最多为视口宽度减去两侧各 16px 的边距,高度最多为顶部偏移到底部 16px 边距之间的空间,因此标题和底部操作区始终可见,正文在内部滚动。`w`、`max_w`、`h` 和 `margin_top` 在这些限制内生效;本来就放得下的 Dialog 会保持其设定尺寸和默认位置。 ### 常用选项 ```rust window.open_dialog(cx, |dialog, _, _| { dialog .title("Custom Dialog") .overlay(true) .overlay_closable(true) .keyboard(true) .close_button(false) .child("Dialog content") }) ``` ### 操作按钮 `Dialog` 自己的按钮放在 [`footer`](#dialogfooter) 里并派发 `Confirm` / `Cancel`;`on_ok`、`on_cancel` 决定 Enter 与 Esc 的行为。需要默认按钮的确认框请用 [AlertDialog](/versions/v0.7.1/zh-CN/component/alert-dialog)。 ### 嵌套对话框 ```rust window.open_dialog(cx, |dialog, _, _| { dialog .title("First Dialog") .child("This is the first dialog") .footer(|_, _, _, _| { vec![ Button::new("open-another") .label("Open Another Dialog") .on_click(|_, window, cx| { window.open_dialog(cx, |dialog, _, _| { dialog .title("Second Dialog") .child("This is nested") }); }), ] }) }) ``` ### 自定义样式 ```rust window.open_dialog(cx, |dialog, _, cx| { dialog .rounded(cx.theme().radius_lg) .bg(cx.theme().cyan) .text_color(cx.theme().info_foreground) .title("Custom Style") .child("Styled dialog content") }) ``` ### 自定义内边距 ```rust window.open_dialog(cx, |dialog, _, _| { dialog .p_3() .title("Custom Padding") .child("Dialog with custom spacing") }) ``` ### 代码中主动关闭 ```rust window.close_dialog(cx); Button::new("submit") .primary() .label("Submit") .on_click(|_, window, cx| { window.close_dialog(cx); }) ``` ## 声明式 API 现在 Dialog 也支持声明式写法,可以通过 header、title、description、footer 等组件来组织内容。 ### 导入 ```rust use gpui_kit::component::dialog::{ Dialog, DialogHeader, DialogTitle, DialogDescription, DialogFooter, }; ``` ### 触发器模式 ```rust Dialog::new(cx) .trigger( Button::new("open-dialog") .outline() .label("Open Dialog") ) .content(|content, _, cx| { content .child( DialogHeader::new() .child(DialogTitle::new().child("Account Created")) .child(DialogDescription::new().child( "Your account has been created successfully!", )) ) .child( DialogFooter::new() .border_t_1() .border_color(cx.theme().border) .bg(cx.theme().muted) .child( Button::new("cancel") .outline() .label("Cancel") .on_click(|_, window, cx| { window.close_dialog(cx); }) ) .child( Button::new("ok") .primary() .label("Save Changes") ) ) }) ``` ### 内容构建器模式 ```rust window.open_dialog(cx, |dialog, _, _| { dialog .w(px(400.)) .content(|content, _, _| { content .child( DialogHeader::new() .child(DialogTitle::new().child("Custom Width")) .child(DialogDescription::new().child( "This dialog has a custom width of 400px.", )) ) .child(div().child( "Content area with custom width configuration." )) .child( DialogFooter::new() .justify_center() .child( Button::new("cancel") .flex_1() .outline() .label("Cancel") .on_click(|_, window, cx| { window.close_dialog(cx); }) ) .child( Button::new("done") .flex_1() .primary() .label("Done") .on_click(|_, window, cx| { window.close_dialog(cx); }) ) ) }) }) ``` ### 相关子组件 #### DialogHeader 用于容纳标题和描述区域: ```rust DialogHeader::new() .child(DialogTitle::new().child("Title")) .child(DialogDescription::new().child("Description")) ``` #### DialogTitle 用于显示主标题: ```rust DialogTitle::new() .child("Account Settings") ``` #### DialogDescription 用于显示标题下方的说明文字: ```rust DialogDescription::new() .child("Update your account settings and preferences here.") ``` #### DialogFooter 用于放置底部操作按钮和页脚内容: ```rust DialogFooter::new() .bg(cx.theme().muted) .border_t_1() .border_color(cx.theme().border) .child(Button::new("cancel").outline().label("Cancel")) .child(Button::new("save").primary().label("Save")) ``` ## API 变化 ### Dialog::new() 签名变化 `Dialog::new()` 现在不再需要 `window` 参数: ```rust // Old API (deprecated) Dialog::new(window, cx) // New API Dialog::new(cx) ``` ### content 构建方式变化 `.content()` 现在接收 builder function,而不是预先构造好的 `DialogContent`: ```rust // Old approach (still works) dialog.child(DialogHeader::new()...) // New declarative API dialog.content(|content, window, cx| { content .child(DialogHeader::new()...) .child(DialogFooter::new()...) }) ``` ## 最佳实践 1. 优先使用 `DialogHeader`、`DialogTitle`、`DialogDescription` 和 `DialogFooter` 2. 简单场景优先用 trigger 模式 3. 复杂逻辑或复杂状态更适合 `window.open_dialog` + builder 4. 尽量保持语义结构完整,标题和说明建议成对出现 5. 所有操作按钮尽量放在 `DialogFooter` 中保持一致性 6. 对内容尺寸敏感的弹窗,建议显式设置宽度 --- # Questionnaire Source: /versions/v0.7.1/zh-CN/component/questionnaire 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/questionnaire 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `Questionnaire` 引导用户完成一组有序问题。它负责当前 item、答案状态、校验、 进度和导航。外层页面、`GroupBox`、`Dialog` 或 `Sheet` 负责关闭、取消、持久化、 传输以及应用特有的条件分支。 ## 引入 ```rust use gpui_kit::component::questionnaire::{ Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireState, QuestionnaireSubmit, QuestionnaireTitle, }; ``` ## 用法 先创建一次 item 集合,并使用一个 `QuestionnaireState` entity 作为所有部件的 状态源。 ```rust use gpui_kit::component::input::InputState; use gpui_kit::component::questionnaire::{ QuestionnaireChoiceDefinition, QuestionnaireInputDefinition, QuestionnaireItemDefinition, QuestionnaireState, }; let direction_input = cx.new(|cx| { InputState::new(window, cx).placeholder("Type another answer…") }); let items = vec![ QuestionnaireItemDefinition::new("direction", "What should we prototype next?") .with_required(true) .with_description("Choose a direction or write your own.") .with_choices([ QuestionnaireChoiceDefinition::new("delegation", "Delegation") .with_description("Show how work moves to a specialist."), QuestionnaireChoiceDefinition::new("questions", "Question prompts"), QuestionnaireChoiceDefinition::new("both", "Both together"), ]) .with_input(QuestionnaireInputDefinition::new( direction_input, "Another answer", )), QuestionnaireItemDefinition::new("detail", "How much detail should it include?") .with_description("You can skip this question if you are not sure yet.") .with_choices([ QuestionnaireChoiceDefinition::new("focused", "Focused"), QuestionnaireChoiceDefinition::new("complete", "Complete flow"), ]), ]; let state = cx.new(|cx| { QuestionnaireState::new(items, cx) .expect("valid questionnaire schema") }); ``` 将集合中的每个 definition 都映射为组合部件。`QuestionnaireItem` 只渲染当前 item;如果组合中遗漏某个 item,导航到该 item 时界面会为空。 ```rust Questionnaire::new(&state) .child(QuestionnaireProgress::new(&state)) .child( QuestionnaireItem::new(&state, "direction") .child(QuestionnaireTitle::new(&state, "direction")) .child(QuestionnaireDescription::new(&state, "direction")) .child( QuestionnaireChoices::new(&state, "direction") .child(QuestionnaireChoice::new(&state, "direction", "delegation")) .child(QuestionnaireChoice::new(&state, "direction", "questions")) .child(QuestionnaireChoice::new(&state, "direction", "both")) .child(QuestionnaireInput::new(&state, "direction")), ) .child(QuestionnaireError::new(&state, "direction")), ) .child( QuestionnaireItem::new(&state, "detail") .child(QuestionnaireTitle::new(&state, "detail")) .child(QuestionnaireDescription::new(&state, "detail")) .child( QuestionnaireChoices::new(&state, "detail") .child(QuestionnaireChoice::new(&state, "detail", "focused")) .child(QuestionnaireChoice::new(&state, "detail", "complete")), ) .child(QuestionnaireError::new(&state, "detail")), ) .child( QuestionnaireActions::new(&state) .child(QuestionnairePrevious::new(&state)) .child(QuestionnaireSkip::new(&state)) .child(QuestionnaireNext::new(&state)) .child(QuestionnaireSubmit::new(&state)), ) ``` ## 组合结构 ```text Questionnaire ├── QuestionnaireProgress ├── QuestionnaireItem │ ├── QuestionnaireTitle │ ├── QuestionnaireDescription │ ├── QuestionnaireChoices │ │ ├── QuestionnaireChoice │ │ │ └── QuestionnaireChoiceDescription (custom child) │ │ └── QuestionnaireInput │ └── QuestionnaireError └── QuestionnaireActions ├── QuestionnairePrevious ├── QuestionnaireSkip ├── QuestionnaireNext └── QuestionnaireSubmit ``` 所有部件都接受普通 GPUI 样式,并可以与现有的 `Button`、`Input`、`Radio`、 `Checkbox`、`Progress`、`Stepper`、`GroupBox` 和 `Dialog` 组合。将同一个 state entity 传给每个部件。自定义部件应读取对应 state 并调用 state 方法处理用户操作, 不要创建第二份答案存储。 `QuestionnaireChoice` 默认提供 indicator、content 和 shortcut。加入 child 后, 它会替换 fallback label 与 description,同时保留选项激活、焦点、状态和可访问 行为。使用 `QuestionnaireChoiceDescription::new()` 为自定义 choice body 添加 辅助文字。下面这些 seam 只定制对应区域: ```rust use gpui_kit::{IntoElement as _, ParentElement as _, StyleRefinement, Styled as _, div}; use gpui_kit::component::{ActiveTheme as _, StyledExt as _}; use gpui_kit::component::questionnaire::{ QuestionnaireChoice, QuestionnaireChoiceDescription, }; let _styled_choice = QuestionnaireChoice::new(&state, "direction", "questions") .indicator_style(StyleRefinement::default().opacity(0.9)) .content_style(StyleRefinement::default().opacity(0.95)) .shortcut_style(StyleRefinement::default().opacity(0.8)); let _rendered_choice = QuestionnaireChoice::new(&state, "direction", "delegation") .render_indicator(|choice, _, cx| { div() .size_4() .rounded_full() .bg(if choice.is_selected() { cx.theme().primary } else { cx.theme().muted }) .into_any_element() }) .child( div() .child("Delegation") .child(QuestionnaireChoiceDescription::new().child( "Show how work moves to a specialist.", )), ); ``` `render_shortcut` 使用相同的 renderer 签名,并接收 `QuestionnaireChoiceState`;需要替换默认 `Kbd` 提示时使用它。renderer 会完整替换 对应区域,因此同一区域的 style seam 不再应用;请直接设置自定义 renderer 的样式。 状态快照提供 `is_selected`、`is_disabled`、`is_invalid` 和 `shortcut`,可用于自定义 渲染。 ## 选项 item 默认单选。激活某个选项(点击、在选项上按 Space 或 Enter,或按它的快捷键) 即回答并确认该 item:选中状态短暂停留以便看清后,问卷移动到下一个 enabled item; 最后一个 item 则直接提交。答案未通过校验时停留在原 item 并显示错误。再次选择已选中 的答案(例如返回上一题后)会立即确认。开启减弱动态效果时跳过这段停留。方向键只在 item 内移动选中项而不确认,在自由输入中打字也不会确认。`with_multiple` 则保留 所有已选项,激活选项只会切换其选中状态,由 `Next` 继续。答案 reader 按 schema 顺序返回结果,后续被禁用的 choice 会从 effective answer 中排除。 definition builder 承载初始快照:choice 可以初始选中,item、choice 和 input 都 可以初始禁用,单选 item 最多只能有一个默认选中项。 ```rust let tools_input = cx.new(|cx| InputState::new(window, cx)); let items = vec![ QuestionnaireItemDefinition::new("plan", "Which plan fits your team?") .with_required(true) .with_choices([ QuestionnaireChoiceDefinition::new("plus", "Plus").with_default_selected(true), QuestionnaireChoiceDefinition::new("pro", "Pro"), ]), QuestionnaireItemDefinition::new("tools", "Which tools do you use?") .with_multiple(true) .with_choices([ QuestionnaireChoiceDefinition::new("editor", "Editor"), QuestionnaireChoiceDefinition::new("terminal", "Terminal"), QuestionnaireChoiceDefinition::new("browser", "Browser").with_disabled(true), ]) .with_input(QuestionnaireInputDefinition::new(tools_input, "Something else")), QuestionnaireItemDefinition::new("advanced", "Advanced preferences").with_disabled(true), ]; ``` `QuestionnaireState::new` 会拒绝重复的 item name、同一 item 内重复的 choice value,以及单选 item 上的多个默认值。对未知 item 或 choice 调用 setter 返回 `QuestionnaireSchemaError`。 ## 自由输入 加入 `QuestionnaireInputDefinition`,允许用户输入固定选项之外的答案。请为输入 提供可访问名称;placeholder 不能替代 label。 只有空白的输入视为未回答。选择固定选项时会保留输入草稿,但只有自由输入成为 当前答案时才会提交它。多选 item 可以同时提交固定选项和非空自由输入。 ## 校验 必填状态校验已经内置。可以为 item 添加同步 validator,实现领域规则。validator 通过 `QuestionnaireValidationContext` 接收当前 item、当前答案和完整的 enabled 答案快照。`Next` 校验当前 item;`Submit` 校验全部 enabled item,并将焦点移到 第一个无效 item。 ```rust let item = QuestionnaireItemDefinition::new("handle", "Choose a handle") .with_required(true) .with_validator(|context| { if context .answer() .freeform() .is_some_and(|value| value.as_ref().len() >= 3) { Ok(()) } else { Err("Use at least three characters.".into()) } }); ``` 可选但未回答的 item 在显式跳过前仍然无效;`Skipped` 是明确有效的状态。disabled item 和 disabled control 不参与校验。提交失败时会选中第一个无效 item,焦点优先 移到其中已填写的 input 或已选 choice,再退回第一个 enabled control。 外部 schema 或服务器响应应使用 external error。外部错误由宿主负责,并会一直 保留到宿主清除它。 ```rust state.update(cx, |state, cx| { state .set_external_error("handle", "This handle is already taken.", cx) .expect("known questionnaire item"); }); // After the owner accepts a corrected answer or a new server response: state.update(cx, |state, cx| { state .clear_external_error("handle", cx) .expect("known questionnaire item"); }); ``` `reset` 会清除内部校验尝试和错误,但保留 owner 管理的 external error。 ## 导航与提交 `QuestionnaireState` 暴露当前 item、有序 item 状态和导航状态,可用于自定义操作 布局。 ```rust let state = state.read(cx); let progress = state.progress(); let status = state.item_state("direction").map(|item| item.status()); let navigation = state.navigation_state(); let show_skip = navigation.is_skip_visible(); ``` `QuestionnaireNavigationState` 对 `Previous`、`Next`、`Submit` 和 `is_confirmable` 给出同样的判断;`current_item` 与 `current_ix` 定位当前 item。 默认操作布局在开头显示 `Previous`,在 item 之间显示 `Next`,当前 item 可选时 显示 `Skip`,最后显示 `Submit`。隐藏的操作不会渲染,也不会进入键盘导航。 disabled item 会从导航和进度总数中排除。item 有三种状态:`Unanswered`、 `Answered` 和 `Skipped`。 ### 跳过 可选 item 可以显示 `QuestionnaireSkip`。跳过是一个明确且有效的状态,会清除该 item 的答案并允许 `Next` 继续。必填 item 不允许跳过。重新进入 item 并选择答案 后,skipped 状态会被清除。跳过最后一个 enabled item 后,会在记录跳过状态后请求 提交。 ### Event 与提交 订阅 `QuestionnaireEvent`,即可监听当前 item 变化、答案变化、完成和成功提交。 `Completed` 只在状态转入 complete 时发出;每次成功执行显式 submit 都会发出 `Submit`。 首次成功提交时,事件顺序为 `Completed`,随后是 `Submit`。 答案或 enabled 条件变化会清除 complete 状态,因此下次成功提交可以再次发出 `Completed`。 ```rust use gpui_kit::component::questionnaire::QuestionnaireEvent; cx.subscribe(&state, |_, _, event, _| match event { QuestionnaireEvent::CurrentItemChanged { current, .. } => { println!("Current item: {:?}", current); } QuestionnaireEvent::AnswerChanged(change) => { println!("Changed: {:?} ({:?})", change.item(), change.status()); } QuestionnaireEvent::Completed(submission) | QuestionnaireEvent::Submit(submission) => { println!("Answers: {:?}", submission.items()); } _ => {} }) .detach(); ``` `detach` 会让 callback 持续有效,直到订阅涉及的 entity 被销毁。如果宿主需要提前 取消监听,请改为保存返回的 `Subscription`。 提交结果按 item schema 顺序排列,并且只包含 enabled item。每个 item 包含 name、 `Unanswered`/`Answered`/`Skipped` 状态和 effective answer。它表示本地已校验的 提交请求;远程保存仍由宿主应用负责。 ## 状态控制 当页面需要控制当前 item,或需要在 state 创建后应用已保存答案时,使用静默 setter。 它们会按需更新 UI 和焦点,但不会发出用户交互事件。 ```rust use gpui_kit::component::questionnaire::QuestionnaireAnswer; state.update(cx, |state, cx| { state .set_current_item("detail", window, cx) .expect("known enabled questionnaire item"); state .set_answer( "direction", QuestionnaireAnswer::new().with_choices(["delegation"]), window, cx, ) .expect("known questionnaire item"); state .set_input_value("direction", "A controlled draft", window, cx) .expect("item has an input"); }); ``` 用户意图应使用 `choose`、`confirm_current`、`go_previous`、`go_next`、 `skip_current` 和 `submit`。点击和快捷键走的就是 `choose`,因此在单选 item 上它 也会确认;`activate_choice` 只修改答案,不会确认。这些路径会发出相应的 `QuestionnaireEvent`。宿主也 可以使用 `set_item_disabled` 和 `set_choice_disabled`;禁用当前 item 后,焦点会 移动到下一个 enabled item;没有下一个时移动到前一个。 ### 重置 Reset 会恢复初始 choices 和 input 草稿,清除显式 skip、校验尝试和完成状态,回到 初始当前 item,并将焦点移到恢复后的当前 item。 ```rust state.update(cx, |state, cx| { state.reset(window, cx); }); ``` External error 在 reset 后仍由 owner 管理。如果 reset 也应该移除服务器错误,请 使用 `clear_external_error` 显式清除。 `reset` 回到 schema 构造时的快照,因此「已保存的草稿」属于 definition:用 `InputState::default_value`、`with_default_selected` 和 `with_current_item` 建立这个基线。构造之后用 `set_answer`、`set_input_value`、`set_current_item` 写入的值只改变当前状态,不会移动 reset 的基线。 ### 条件 item Questionnaire 不包含 branching engine。宿主可以根据前一个答案推导 item 的禁用 状态,并通过 `set_item_disabled` 同步。这让条件策略留在页面中,同时由 Questionnaire 继续负责顺序、焦点、进度、校验和提交。 ```rust fn sync_advanced_item( state: &Entity, window: &mut Window, cx: &mut App, ) { let enabled = state.read(cx).answer("direction").is_some_and(|answer| { answer .choices() .iter() .any(|choice| choice.as_ref() == "delegation") }); state.update(cx, |state, cx| { let _ = state.set_item_disabled("advanced", !enabled, window, cx); }); } ``` 可以从宿主的 answer-change 处理,或改变前一个答案的 UI action 中调用这个 helper。 被禁用的条件 item 不参与进度、导航、校验、焦点、快捷键和提交。 ## 键盘快捷键 为 state 启用字母或数字快捷键。快捷键只作用于当前 item 的 enabled choices。 重复 key event、文本输入、IME 组合以及带修饰键的按键都会保持原有行为。 ```rust use gpui_kit::component::questionnaire::QuestionnaireShortcutMode; let state = cx.new(|cx| { QuestionnaireState::new(items, cx) .expect("valid questionnaire schema") .with_shortcuts(QuestionnaireShortcutMode::Letters) }); ``` Questionnaire 按原生单选交互处理 radio 的移动。其他场景下,Up/Down 会按 schema 顺序在 enabled choices 和自由输入之间移动;存在 input 时它也会包含在这个顺序中。 非空文本 input 获得焦点时保留正常文本编辑行为。只有焦点不在文本 input 或单选 radio 上时,Left/Right 才会在 item 之间移动;Right 要求当前 item 可确认。 Enter 确认已填写的答案;在未选中的选项上按 Space 或 Enter 会选择它,并确认所在的 单选 item。Command/Ctrl+Enter 确认当前 item。空答案不会隐式提交。 快捷键标签按 enabled choice 顺序分配(`A`–`Z` 或 `1`–`9`),disabled choice 不会分配标签。 ## 进度 `QuestionnaireProgress` 使用默认的 “Question 2 of 4” 样式。同一份快照也可以用来 驱动现有的指示器。 ```rust QuestionnaireProgress::new(&state); let progress = state.read(cx).progress(); let percent = if progress.total() == 0 { 0. } else { progress.current() as f32 / progress.total() as f32 * 100. }; Progress::new("questionnaire-progress").value(percent); ``` `current` 和 `total` 只统计启用的 item,宿主禁用或重新启用某一题时两者都会变化。 如果指示器为每一步固定一个标签(例如 `Stepper`),它的步骤必须从同一份启用集合 推导出来,否则标签和选中步骤会与问卷错位。 ## 尺寸与主题 `Questionnaire` 接受整份问卷的比例,该问卷的所有部件都会跟随 —— root 会把 size 记录在它的 state 上,因此组合部件不需要被逐个告知。部件自己声明的 size 优先。 ```rust use gpui_kit::component::{Sizable as _, Size}; Questionnaire::new(&state) .with_size(Size::Small) .child(QuestionnaireProgress::new(&state)) .child( QuestionnaireItem::new(&state, "direction") .child(QuestionnaireTitle::new(&state, "direction")) .child( QuestionnaireChoices::new(&state, "direction") // 跟随 root;只有需要不同比例时才在这里写 with_size。 .child(QuestionnaireChoice::new(&state, "direction", "delegation")), ), ); ``` 支持的尺寸为 `XSmall`、`Small`、`Medium`(默认)和 `Large`,也可以使用 `Size::Size(value)` 自定义比例。答案文字与同尺寸下 Checkbox、Radio 家族的 label 一致。 spacing、typography、radius、border、input、primary、muted、destructive 和 focus ring 全部取自当前主题的 semantic tokens,应用通过调整主题改变问卷的形状。 局部微调使用 `Styled` 方法或 `StyleRefinement`,实例样式在组件默认样式之后应用。 `QuestionnaireChoiceDescription` 是唯一没有自己 state 的部件 —— 它只是自定义 选项内容里的一个文本槽 —— 因此默认 `Medium`,需要其它比例时通过 `with_size` 指定。 ## Card 和 Dialog 组合 问卷负责题目流程,容器负责自己的外观与关闭/取消行为。把完整组合 —— progress、 全部 item 和 actions —— 都放进容器,这样切换到下一题时仍然可见。 ```rust use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants as _}; GroupBox::new() .outline() .title("Set up your workspace") .child(questionnaire); ``` 放进 Dialog 时,footer 里容器自己的 `Cancel` 与问卷的导航按钮并排,宿主在问卷报告 提交成功后关闭 Dialog。 ```rust use gpui_kit::component::dialog::{ Dialog, DialogClose, DialogFooter, DialogHeader, DialogTitle, }; use gpui_kit::component::{WindowExt as _, questionnaire::QuestionnaireEvent}; let dialog_state = state.clone(); cx.subscribe_in( &dialog_state, window, |_, _, event: &QuestionnaireEvent, window, cx| { if matches!(event, QuestionnaireEvent::Submit(_)) { window.close_dialog(cx); } }, ) .detach(); Dialog::new(cx) .trigger(Button::new("open-questionnaire").outline().label("Open questionnaire")) .content(move |content, _, _| { content .child(DialogHeader::new().child(DialogTitle::new().child("Workspace setup"))) .child( Questionnaire::new(&dialog_state) // …progress 和每个 item,同上面的「用法」 .child( DialogFooter::new() .child(DialogClose::new().child( Button::new("cancel-questionnaire").outline().label("Cancel"), )) .child( QuestionnaireActions::new(&dialog_state) .child(QuestionnairePrevious::new(&dialog_state)) .child(QuestionnaireNext::new(&dialog_state)) .child(QuestionnaireSubmit::new(&dialog_state)), ), ), ) }); ``` `Cancel` 始终关闭。`Submit` 只有在问卷校验通过全部启用 item 之后才关闭,同一个 event 也把校验后的 `QuestionnaireSubmission` 交给应用层传输。 ## 可访问性 `Questionnaire` 根部使用 GPUI 的 `Form` role。`QuestionnaireItem` 是带有 item label 和 description 的可访问分组。definition 中的 `accessibility_label` 与 `description` 始终是 item 和 choice 的语义来源;自定义 child 只替换可见的 fallback 内容,并保留 Questionnaire parts 提供的状态、role、焦点行为和语义。 `QuestionnaireError` 只有 item 无效时才会以 alert 形式播报。Choice 保留 radio 和 checkbox 语义,进度暴露当前值与总数,导航使用真实按钮。 非当前 item 和隐藏操作不会进入键盘导航。成功切换后,焦点移动到新的当前 item; 校验失败时,焦点优先移动到已选或已填写的答案控件,再退回第一个可用控件。 请始终为自由输入在 definition 中提供 `accessibility_label`;可见 label 或等价的 自定义组合可以补充它。GPUI accessibility layer 没有直接对应 `aria-invalid` 的 builder;Questionnaire 仍通过错误 alert、语义分组状态、焦点行为和 destructive 样式暴露无效状态。 ## 当前范围 问卷一次只呈现一道题:非当前题的部件不会渲染任何内容,因此它不适合做「一页多题」 的表单。schema 在构造时固定 —— 运行时不能插入或重排题目与选项,但可以禁用其中 任意一项 —— 校验器同步执行。持久化、传输和提交后的副作用属于外层页面,由它订阅 `QuestionnaireEvent` 处理。 ## API 参考 ### 组合部件 - [Questionnaire] - [QuestionnaireProgress] - [QuestionnaireItem] - [QuestionnaireTitle] - [QuestionnaireDescription] - [QuestionnaireChoices] - [QuestionnaireChoice] - [QuestionnaireChoiceDescription] - [QuestionnaireInput] - [QuestionnaireError] - [QuestionnaireActions] - [QuestionnairePrevious] - [QuestionnaireSkip] - [QuestionnaireNext] - [QuestionnaireSubmit] ### 状态、答案与事件 - [QuestionnaireState] - [QuestionnaireItemDefinition] - [QuestionnaireChoiceDefinition] - [QuestionnaireInputDefinition] - [QuestionnaireAnswer] - [QuestionnaireAnswers] - [QuestionnaireItemStatus] - [QuestionnaireShortcutMode] - [QuestionnaireProgressState] - [QuestionnaireItemState] - [QuestionnaireChoiceState] - [QuestionnaireNavigationState] - [QuestionnaireValidationContext] - [QuestionnaireValidator] - [QuestionnaireAnswerChange] - [QuestionnaireSubmission] - [QuestionnaireSubmissionItem] - [QuestionnaireEvent] - [QuestionnaireSchemaError] - [Sizable] [Questionnaire]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.Questionnaire.html [QuestionnaireProgress]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireProgress.html [QuestionnaireItem]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireItem.html [QuestionnaireTitle]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireTitle.html [QuestionnaireDescription]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireDescription.html [QuestionnaireChoices]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoices.html [QuestionnaireChoice]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoice.html [QuestionnaireChoiceDescription]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoiceDescription.html [QuestionnaireInput]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireInput.html [QuestionnaireError]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireError.html [QuestionnaireActions]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireActions.html [QuestionnairePrevious]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnairePrevious.html [QuestionnaireSkip]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSkip.html [QuestionnaireNext]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireNext.html [QuestionnaireSubmit]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSubmit.html [QuestionnaireState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireState.html [QuestionnaireItemDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireItemDefinition.html [QuestionnaireChoiceDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoiceDefinition.html [QuestionnaireInputDefinition]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireInputDefinition.html [QuestionnaireAnswer]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireAnswer.html [QuestionnaireAnswers]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireAnswers.html [QuestionnaireItemStatus]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireItemStatus.html [QuestionnaireShortcutMode]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireShortcutMode.html [QuestionnaireProgressState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireProgressState.html [QuestionnaireItemState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireItemState.html [QuestionnaireChoiceState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireChoiceState.html [QuestionnaireNavigationState]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireNavigationState.html [QuestionnaireValidationContext]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireValidationContext.html [QuestionnaireValidator]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/type.QuestionnaireValidator.html [QuestionnaireAnswerChange]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireAnswerChange.html [QuestionnaireSubmission]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSubmission.html [QuestionnaireSubmissionItem]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/struct.QuestionnaireSubmissionItem.html [QuestionnaireEvent]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireEvent.html [QuestionnaireSchemaError]: https://docs.rs/gpui-component/latest/gpui_component/questionnaire/enum.QuestionnaireSchemaError.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html --- # Stepper Source: /versions/v0.7.1/zh-CN/component/stepper 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/stepper 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Stepper 用于按步骤展示流程进度,适合表单向导、订单流程和安装步骤等场景。支持横向和纵向布局、自定义图标以及不同尺寸。 ## 导入 ```rust use gpui_kit::component::stepper::{Stepper, StepperItem}; ``` ## 用法 ### 基础 Stepper 使用 `selected_index` 设置当前步骤,索引从 `0` 开始,默认值也是 `0`。 ```rust Stepper::new("my-stepper") .selected_index(0) .items([ StepperItem::new().child("Step 1"), StepperItem::new().child("Step 2"), StepperItem::new().child("Step 3"), ]) .on_click(|step, _, _| { println!("Clicked step: {}", step); }) ``` ### 带图标的 Stepper ```rust use gpui_kit::component::IconName; Stepper::new("icon-stepper") .selected_index(0) .items([ StepperItem::new() .icon(IconName::Calendar) .child("Order Details"), StepperItem::new() .icon(IconName::Inbox) .child("Shipping"), StepperItem::new() .icon(IconName::Frame) .child("Preview"), StepperItem::new() .icon(IconName::Info) .child("Finish"), ]) ``` ### 纵向布局 ```rust Stepper::new("vertical-stepper") .vertical() .selected_index(2) .items_center() .items([ StepperItem::new() .pb_8() .icon(IconName::Building2) .child(v_flex().child("Step 1").child("Description for step 1.")), StepperItem::new() .pb_8() .icon(IconName::Asterisk) .child(v_flex().child("Step 2").child("Description for step 2.")), StepperItem::new() .pb_8() .icon(IconName::Folder) .child(v_flex().child("Step 3").child("Description for step 3.")), StepperItem::new() .icon(IconName::CircleCheck) .child(v_flex().child("Step 4").child("Description for step 4.")), ]) ``` ### 文本居中 ```rust Stepper::new("center-stepper") .selected_index(0) .text_center(true) .items([ StepperItem::new().child( v_flex() .items_center() .child("Step 1") .child("Desc for step 1."), ), StepperItem::new().child( v_flex() .items_center() .child("Step 2") .child("Desc for step 2."), ), StepperItem::new().child( v_flex() .items_center() .child("Step 3") .child("Desc for step 3."), ), ]) ``` ### 不同尺寸 ```rust use gpui_kit::component::{Sizable as _, Size}; Stepper::new("stepper") .xsmall() .items([...]) Stepper::new("stepper") .small() .items([...]) Stepper::new("stepper") .large() .items([...]) ``` ### 禁用状态 ```rust Stepper::new("disabled-stepper") .disabled(true) .items([ StepperItem::new().child("Step 1"), StepperItem::new().child("Step 2"), ]) ``` ## API 参考 - [Stepper] - [StepperItem] ### 尺寸 实现了 [Sizable] trait: - `xsmall()`:超小尺寸 - `small()`:小尺寸 - `medium()`:中尺寸,默认值 - `large()`:大尺寸 ## 示例 ### 多步骤表单 ```rust Stepper::new("form-stepper") .w_full() .selected_index(form_step) .items([ StepperItem::new() .icon(IconName::User) .child("Personal Info"), StepperItem::new() .icon(IconName::CreditCard) .child("Payment"), StepperItem::new() .icon(IconName::CircleCheck) .child("Confirmation"), ]) .on_click(cx.listener(|this, step, _, cx| { this.form_step = *step; cx.notify(); })) ``` ### 禁用单个步骤 ```rust Stepper::new("stepper") .selected_index(0) .items([ StepperItem::new().child("Available"), StepperItem::new().disabled(true).child("Locked"), StepperItem::new().child("Available"), ]) ``` [Stepper]: https://docs.rs/gpui-component/latest/gpui_component/stepper/struct.Stepper.html [StepperItem]: https://docs.rs/gpui-component/latest/gpui_component/stepper/struct.StepperItem.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html --- # Kbd Source: /versions/v0.7.1/zh-CN/component/kbd 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/kbd 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Kbd 用于展示键盘快捷键和组合键,并会自动根据平台采用合适的显示格式。macOS 会使用符号,Windows 和 Linux 则使用文本标签,便于文档、菜单和帮助面板保持一致的快捷键表达。 ## 导入 ```rust use gpui_kit::component::kbd::Kbd; use gpui_kit::Keystroke; ``` ## 用法 ### 基础快捷键 ```rust let kbd = Kbd::new(Keystroke::parse("cmd-shift-p").unwrap()); let kbd: Kbd = Keystroke::parse("escape").unwrap().into(); ``` ### 常见快捷键 ```rust Kbd::new(Keystroke::parse("cmd-shift-p").unwrap()) Kbd::new(Keystroke::parse("cmd-t").unwrap()) Kbd::new(Keystroke::parse("cmd--").unwrap()) Kbd::new(Keystroke::parse("cmd-+").unwrap()) Kbd::new(Keystroke::parse("escape").unwrap()) Kbd::new(Keystroke::parse("enter").unwrap()) Kbd::new(Keystroke::parse("backspace").unwrap()) ``` ### 多修饰键 ```rust Kbd::new(Keystroke::parse("cmd-ctrl-shift-a").unwrap()) Kbd::new(Keystroke::parse("cmd-alt-backspace").unwrap()) Kbd::new(Keystroke::parse("ctrl-alt-shift-a").unwrap()) ``` ### 方向键与功能键 ```rust Kbd::new(Keystroke::parse("left").unwrap()) Kbd::new(Keystroke::parse("right").unwrap()) Kbd::new(Keystroke::parse("up").unwrap()) Kbd::new(Keystroke::parse("down").unwrap()) Kbd::new(Keystroke::parse("f12").unwrap()) Kbd::new(Keystroke::parse("secondary-f12").unwrap()) Kbd::new(Keystroke::parse("pageup").unwrap()) Kbd::new(Keystroke::parse("pagedown").unwrap()) ``` ### 关闭默认外观 ```rust Kbd::new(Keystroke::parse("cmd-s").unwrap()) .appearance(false) ``` ### 从 Action 绑定读取 ```rust use gpui_kit::{Action, Window, FocusHandle}; if let Some(kbd) = Kbd::binding_for_action(&MyAction {}, None, window) { // 显示该 action 绑定的快捷键 } if let Some(kbd) = Kbd::binding_for_action(&MyAction {}, Some("Editor"), window) { // 显示特定上下文中的快捷键 } if let Some(kbd) = Kbd::binding_for_action_in(&MyAction {}, &focus_handle, window) { // 显示焦点元素上的快捷键 } ``` ## 平台差异 ### macOS - 使用符号:⌃ ⌥ ⇧ ⌘ - 修饰键之间不加分隔符 - 顺序为 Control、Option、Shift、Command - 特殊键使用 ⌫、⎋、⏎、← → ↑ ↓ 等符号 ### Windows / Linux - 使用文本标签:Ctrl、Alt、Shift、Win - 修饰键之间使用 `+` - 顺序为 Ctrl、Alt、Shift、Win - 特殊键显示为 Backspace、Esc、Enter、Left、Right、Up、Down ### 平台示例 | 输入 | macOS | Windows / Linux | | --- | --- | --- | | `cmd-a` | ⌘A | Win+A | | `ctrl-shift-a` | ⌃⇧A | Ctrl+Shift+A | | `cmd-alt-backspace` | ⌥⌘⌫ | Win+Alt+Backspace | | `escape` | ⎋ | Esc | | `enter` | ⏎ | Enter | | `left` | ← | Left | ## 示例 ### 快捷键帮助面板 ```rust use gpui_kit::{div, h_flex, v_flex}; v_flex() .gap_2() .child( h_flex() .gap_2() .items_center() .child("Open command palette:") .child(Kbd::new(Keystroke::parse("cmd-shift-p").unwrap())) ) .child( h_flex() .gap_2() .items_center() .child("Save file:") .child(Kbd::new(Keystroke::parse("cmd-s").unwrap())) ) .child( h_flex() .gap_2() .items_center() .child("Find in files:") .child(Kbd::new(Keystroke::parse("cmd-shift-f").unwrap())) ) ``` ### 带快捷键的菜单项 ```rust h_flex() .justify_between() .items_center() .child("New File") .child(Kbd::new(Keystroke::parse("cmd-n").unwrap())) ``` ### 行内说明 ```rust div() .child("Press ") .child(Kbd::new(Keystroke::parse("escape").unwrap())) .child(" to cancel or ") .child(Kbd::new(Keystroke::parse("enter").unwrap())) .child(" to confirm.") ``` ### 自定义样式 ```rust Kbd::new(Keystroke::parse("cmd-k").unwrap()) .text_color(cx.theme().accent) .border_color(cx.theme().accent) .bg(cx.theme().accent.opacity(0.1)) ``` ### 仅获取文本格式 ```rust let shortcut_text = Kbd::format(&Keystroke::parse("cmd-shift-p").unwrap()); div().child(format!("Shortcut: {}", shortcut_text)) ``` ## 样式 Kbd 默认包含以下样式: - 使用主题边框颜色绘制边框 - 使用 muted 前景色显示文字 - 使用主题背景色作为底色 - 小圆角 - 文本居中 - 超小字号 - 极小的内边距 - 最小宽度为 5 - 禁止 flex shrink,避免压缩失真 所有样式都可以通过 `Styled` trait 的方法覆盖。 --- # TextView Source: /versions/v0.7.1/zh-CN/component/text-view 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/text-view 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `TextView` 用于在 GPUI 中渲染格式化文本。它支持 Markdown、简单 HTML、文本选择、代码块操作,以及通过 Markdown 插件解析和渲染项目自定义语法。 标准实现现在位于 `gpui-base`;本模块保留兼容重导出和组件主题适配。仅使用 Base 时的设置、完整默认样式及可选语法高亮请参阅 [GPUI Base TextView](/versions/v0.7.1/zh-CN/base/text-view)。 `TextView::selectable(true)` 使用 `gpui-base` 提供的窗口级文本选择引擎。如果要让普通文本或自定义 renderer 参与同一选择,请参阅 [GPUI Base Text Selection](/versions/v0.7.1/zh-CN/base/text-selection)。 ## 导入 ```rust use gpui_kit::component::text::{markdown, TextView}; ``` ## 用法 ### Markdown 只需要渲染 Markdown 时,可以使用 `markdown` helper: ```rust use gpui_kit::component::text::markdown; markdown("# Hello\n\nThis is **Markdown**.") .selectable(true) .scrollable(true) ``` 如果需要稳定 id,也可以直接构造 `TextView`: ```rust use gpui_kit::component::text::TextView; TextView::markdown("preview", markdown_source) .selectable(true) ``` ### HTML ```rust TextView::html("html-preview", "Hello") ``` ### 放在有底色的容器里 没有显式设置 `.style()` 的 `TextView` 会跟随容器的文字颜色。如果底色与页面明暗相反,例如 `Filled` 样式的 [Bubble](/versions/v0.7.1/zh-CN/component/bubble),链接、次要文字、代码和表格背景、边框、选择色也会从这个颜色推算,并且不使用语法高亮,所以内容不需要额外设置样式: ```rust Bubble::new().child(TextView::markdown("reply", text)) ``` ### 流式文字淡入 聊天回复是分块到达的。`stream_fade(true)` 会让每一块新文字在落点处淡入,而不是直接蹦出来,观感与 Claude 展示回复的方式一致: ```rust TextView::new(&self.reply).stream_fade(true) ``` 淡入以渲染后的文字为准:`push_str`,或者 `set_text` 传入以当前文本为前缀的更长文本,新增的部分从透明渐变到正常颜色,用时 350ms、ease-out 曲线,这是从 Claude 实测得到的节奏:比模型每块 50–300ms 的到达间隔更长,于是相邻几块的淡入互相重叠,尾部呈现为一段渐变,而不是最新一块突然变实。代码块里的代码和表格单元格里的文字同样参与。流式过程中被补齐的 Markdown 标记(`**bo` 变成粗体 `bold`)只让发生变化的字形重新淡入,不会整段闪烁。替换当前内容的文本直接显示;系统开启减少动态效果时也直接显示。不开启就没有任何动画。 需要自定义时长、缓动,或者让每块按词逐个浮现时,通过 `.motion(...)` 传入 `TextViewMotion`,详见 [GPUI Base TextView](/versions/v0.7.1/zh-CN/base/text-view#保留状态与动态更新)。 ### 高亮文本范围 应用在文档里搜索,或者指向文档里的某处引用时,用 `set_range_highlights` 把这些范围标出来。搜索由应用负责:在视图显示的文本 `rendered_text()` 里找到范围,连同绘制用的颜色一起交回,当前结果用更醒目的颜色: ```rust use gpui_kit::component::{ ActiveTheme as _, text::{RangeHighlight, RangeHighlightError, TextViewState}, }; fn highlight_matches( state: &mut TextViewState, query: &str, current_match: usize, cx: &mut Context, ) -> Result<(), RangeHighlightError> { let (color, current_color) = (cx.theme().warning.opacity(0.3), cx.theme().warning); let text = state.rendered_text(); let matches = if query.is_empty() { Vec::new() } else { text.as_str().match_indices(query).collect() }; let highlights = matches.into_iter().enumerate().map(|(ix, (start, found))| { RangeHighlight::new( start..start + found.len(), if ix == current_match { current_color } else { color }, ) }); state.set_range_highlights(highlights, cx) } ``` `rendered_text()` 与纯文本复制得到的文字一致:`hello **world**` 读作 `hello world`,转义字符已经还原,标题和列表的标记不在其中。偏移量是 UTF-8 字节偏移,`str` 搜索返回的范围可以直接传入;重复出现的词组按各自的位置区分。文本在第一次读取时才会生成。 高亮绘制在文字背后、选区之下,换行、对齐、语法颜色、链接、选择和复制都保持不变。多个高亮重叠时,后面的覆盖前面的。跨越两个块的范围在两个块里分别绘制。不属于任何块的文字不会绘制:块之间的换行、表格单元格之间的空格、自定义块、HTML 块和 inline plugin 对象。只有起点在终点之后、超出范围或不在字符边界上的范围会被拒绝,同一批的整组高亮也一并拒绝。 内容变化时,高亮会跟随它所在的块,保留到这个块里文字开始变化的位置为止。流式追加的文字(无论通过 `push_str` 还是 `set_text`)不影响前面的高亮;修改某处时,修改前后的高亮都会保留。表格单元格只按位置区分,因此修改表格内部时,被修改的那一行及其后各行单元格的高亮都会失效。视图会发出通知:观察这个 state,重新在新的 `rendered_text()` 里搜索即可。请在同一次 state 更新中计算范围并调用 `set_range_highlights`,确保范围对应当前文本。属于文字本身的背景(例如 `` 和语法高亮)会覆盖在范围高亮之上(行内代码的背景在高亮之下),高亮也不会随流式文字一起淡入。HTML 视图不支持范围高亮。 如果应用保存的是 Markdown 源码里的范围,例如用户评论某段文字时 `selected_source_range()` 返回的范围,就在同一个 `rendered_text()` 上用 `range_for_source` 逐个转换: ```rust use std::ops::Range; use gpui_kit::component::{ ActiveTheme as _, text::{RangeHighlight, RangeHighlightError, TextViewState}, }; fn highlight_comments( state: &mut TextViewState, markdown: &str, comments: &[Range], cx: &mut Context, ) -> Result<(), RangeHighlightError> { let text = state.rendered_text(); // 范围对应的是 `markdown`。它解析完成之前,视图显示的仍是之前的源码; // 等视图发出通知后再转换一次。 if text.source() != markdown { return Ok(()); } let color = cx.theme().info.opacity(0.3); let highlights = comments .iter() .filter_map(|comment| text.range_for_source(comment.clone())) .map(|range| RangeHighlight::new(range, color)); state.set_range_highlights(highlights, cx) } ``` `range_for_source` 返回能包含该源码范围所渲染出的全部字符的最小范围。不渲染任何文字的源码(强调符号、标题和列表标记、代码围栏、表格竖线、链接地址)不影响结果,因此 `**bold**` 和 `bold` 转换出的范围相同。只要字符的源码有一部分落在范围内,这个字符就会被包含:`&` 的一部分对应 `&`,inline plugin 对象源码的一部分对应该对象的全部文字。把选区报告的源码范围转换回来,得到的就是原来选中的范围;只是块之间的换行不对应任何源码,落在选区两端时不会包含在内:全选转换回来的范围不含最后一个块之后的换行。 `rendered_text().source()` 是最近一次完成解析的源码。用 `push_str` 追加文字时,它是交给视图的文本的前缀;用 `set_text` 替换内容后,在新文本解析完成之前,它可能仍是之前那段不相干的文本。因此转换前要像示例那样,先确认它就是范围所对应的源码。范围为空、超出范围或不在字符边界上,或者没有渲染出任何文字时返回 `None`;HTML 视图总是返回 `None`。 ### 滚动到范围 `reveal_range` 滚动到同一份文本里的某个范围,比如用户跳到下一个结果时的当前结果: ```rust state.reveal_range(current_range, cx)?; ``` 它把范围起点所在的那一行滚动到可见区域内,长段落中间的某一行也能定位到;这一行(或整块显示的块)已经可见时,视图保持不动。空范围会显示它所在位置的那一行。`scrollable` 视图自己滚动。按内容高度排版的视图会让最近的外层 `gpui::list` 滚动,聊天记录就是这种情况,前提是承载视图的那一行已经完成布局,这一行可能不在屏幕上时,先滚动到这一行。其他滚动容器(例如设置了 `overflow_y_scroll` 的 `div`)通过 `on_reveal` 滚动,回调会收到这一行在窗口坐标中的位置: ```rust let scroll = scroll_handle.clone(); TextView::new(&state).on_reveal(move |line, _, _| { let viewport = scroll.bounds(); let mut offset = scroll.offset(); if line.bottom() > viewport.bottom() { offset.y -= line.bottom() - viewport.bottom(); } else if line.top() < viewport.top() { offset.y += viewport.top() - line.top(); } scroll.set_offset(offset); }) ``` 不覆盖任何块文字的范围(例如自定义块里的文字)会把整个块滚动到可滚动视图的可见区域内。只执行最后一次请求。它像高亮一样跟随内容变化;如果它所在的文字发生变化、视图用 `max_lines` 限制了行数,或者一秒内无法显示,请求就会被取消,因此不会在很久之后才突然滚动。表格里横向滚出的文字不会被滚动出来;整块显示且比视图更高的块从下方滚入时,显示的是它的末尾;放在应用列表里的可滚动视图只滚动自身;共用同一个 state 的多个视图共用同一个请求。 滚动请求只是尽力而为:返回 `Ok(())` 表示范围对当前文本有效、请求已被接受,并不表示视图已经滚动;请求被取消时也不会另行通知。 ## 触摸选择 在触摸屏上,长按会选中手指下的单词,手指按住不放时选区跟随手指移动。抬起手指后,选区上方会出现包含 `复制` 和 `全选` 的编辑菜单,并在选区两端各显示一个拖动 handle。拖动 handle 会移动对应的一端,另一端保持不动;`全选` 选中被按下的那个视图,其 handle 仍可继续调整结果。 handle 和菜单由 [`Root`](/versions/v0.7.1/zh-CN/component/root) 为整个窗口选区绘制,因此跨多个视图的选区也能覆盖到。点击其他位置会清除它们;手指滚动内容时菜单会暂时让开。 ## 图片 Markdown 的 `![alt](src)` 和 HTML 的 `` 都通过 GPUI 的 `img` 元素渲染, `src` 决定字节从哪里来: - `http://`、`https://` URL 使用应用的 HTTP client 拉取。 - `data:` URL 就地解码,文档可以内嵌自己的图片(`data:image/png;base64,…`, 或者百分号编码的 `data:image/svg+xml,…`)。GPUI 能解码的图片格式都可以; media type 不是图片的 `data:` URL 会交给默认加载器,像其他加载失败的图片一样报错。 - 其余的值——相对路径、`file://`、自定义 scheme——原样作为 URI 传下去。 `TextView` 不会替文档读文件系统或 asset bundle。 要解析这些来源,或者改变任意图片的加载方式,把 `TextView` 包在一个安装了 GPUI `ImageCache` 的元素里。它内部的每个 `img`(包括文档生成的)都会先向这个 cache 请求自己的 `Resource`,再回退到默认加载器: ```rust use gpui_kit::{ImageCache, ImageCacheProvider}; div() .image_cache(app_image_cache.clone()) .child(markdown("![diagram](app://diagrams/pipeline.svg)")) ``` `ImageCache::load` 拿到 `Resource::Uri` 后自行决定怎样得到 `RenderImage`, 加载策略归应用所有,文档本身仍是普通 Markdown。 ## Markdown 插件 使用 `.plugin(...)` 支持自定义 Markdown 格式。插件同时拥有解析和渲染逻辑,调用方只需要把它挂到 `TextView` 上: ```rust markdown(source) .plugin(TickerPlugin::new()) ``` Markdown 插件实现 `MarkdownPlugin`: ```rust use gpui_kit::{App, IntoElement, ParentElement as _, Window}; use gpui_kit::component::text::{ markdown_ast, MarkdownNode, MarkdownParseContext, MarkdownPlugin, }; struct TickerNode { symbol: String, } struct TickerPlugin; impl TickerPlugin { fn new() -> Self { Self } } impl MarkdownPlugin for TickerPlugin { fn is_block(&self) -> bool { true } fn name(&self) -> &str { "ticker" } fn parse( &self, node: &markdown_ast::Node, cx: &MarkdownParseContext<'_>, ) -> Option { let markdown_ast::Node::Paragraph(paragraph) = node else { return None; }; let [markdown_ast::Node::Text(text)] = paragraph.children.as_slice() else { return None; }; let symbol = text.value.strip_prefix('$')?; Some( MarkdownNode::new( "ticker", TickerNode { symbol: symbol.to_string(), }, ) .text(format!("${symbol}")) .markdown(cx.node_source(node).unwrap_or(text.value.as_str())), ) } fn render( &self, node: &MarkdownNode, _window: &mut Window, _cx: &mut App, ) -> impl IntoElement { let ticker = node.data::().expect("ticker node data"); gpui_kit::div().child(format!("${}", ticker.symbol)) } } ``` 然后挂到 Markdown `TextView`: ```rust markdown("$AAPL.US") .plugin(TickerPlugin::new()) ``` ## MarkdownNode `MarkdownNode` 是 `parse` 和 `render` 之间传递的中性数据结构。 ```rust MarkdownNode::new("ticker", TickerNode { symbol }) .text("$AAPL.US") .markdown("$AAPL.US") ``` - `name` 是稳定的节点名称,用于匹配 renderer。 - `data` 是 parser 产生的类型化数据,通过 `node.data::()` 读取。 - `text` 是纯文本表示,用于选择和未注册 renderer 时的回退渲染。 - `markdown` 是 Markdown 表示,用于将文档重新序列化为 Markdown。 ## Block plugin Block plugin 在 `is_block()` 中返回 `true`,使用 block parser 和 renderer: ```rust fn is_block(&self) -> bool { true } ``` Inline plugin 保留默认的 `is_block() == false`,`render_inline` 返回 `Option`。通过 `InlineElement::new(...)` 包裹任意 GPUI 元素,使用原生样式与事件,并按需指定基线。TextView 将整个元素作为原子对象测量和选择,支持纯文本与 Markdown 复制、文本降级和异步布局失效。契约与 `.plugin(...)` 注册示例详见[Inline plugin](/versions/v0.7.1/zh-CN/base/text-view#inline-plugin)。Component 层导出相同的 `InlineElement` 和 `InlineRenderContext` 类型。 ## YAML Frontmatter YAML frontmatter 不属于 CommonMark 或 GFM,因此默认不启用。启用 parser construct 并挂载 `FrontmatterPlugin` 后,顶层 mapping 会渲染为 `DescriptionList`: ```rust use gpui_component::text::{markdown, FrontmatterPlugin, MarkdownExtensions}; let extensions = MarkdownExtensions::default().frontmatter(); markdown("---\nname: example\ndescription: Example metadata.\n---") .markdown_extensions(extensions) .plugin(FrontmatterPlugin::new()) ``` 值以纯文本渲染。支持简单的无引号值,以及使用 `|-` 或 `>-` 的 block scalar; literal scalar 会保留内容缩进。带引号的值、行尾注释、集合、别名、其他 block header,以及包含额外缩进行的 folded scalar 会回退为 YAML code block, 保留原始内容,避免显示错误解析的值。 ## 代码块操作 可以为 Markdown 代码块渲染操作控件: ```rust markdown(source) .code_block_actions(|code_block, _window, _cx| { gpui_kit::div().child(format!("Run {}", code_block.lang().unwrap_or_default())) }) ``` --- # Plot Source: /versions/v0.7.1/zh-CN/component/plot 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/plot 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `plot` 模块提供了构建自定义图表所需的底层能力,包括比例尺、图形和辅助工具。高层 `Chart` 组件也是基于这些原语实现的,适合需要完全控制图表绘制逻辑的场景。 这些原语位于 gpui-base 的 [`gpui_kit::base::plot`](/versions/v0.7.1/zh-CN/base/plot),并在这里原样重新导出,两个路径都可以使用。GPUI Component 在其上补充了样式化的部分:`plot::tooltip` 中的 `Tooltip`、`CrossLine` 和 `Dot` 浮层,`#[derive(IntoPlot)]` 简写,以及从主题投射的 hover 动效。 ## 导入 ```rust use gpui_kit::component::plot::{ scale::{Scale, ScaleLinear, ScaleBand, ScalePoint, ScaleOrdinal}, shape::{Bar, Stack, Line, Area, Pie, Arc}, PlotAxis, AxisText }; ``` ## 比例尺 比例尺用于把抽象数据映射成可视化坐标或样式。 ### ScaleLinear ```rust let scale = ScaleLinear::new( vec![0., 100.], [0., 500.] ); scale.tick(&50.); ``` ### ScaleBand ```rust let scale = ScaleBand::new( vec!["A", "B", "C"], [0., 300.] ) .padding_inner(0.1) .padding_outer(0.1); scale.band_width(); scale.tick(&"A"); ``` ### ScalePoint ```rust let scale = ScalePoint::new( vec!["A", "B", "C"], [0., 300.] ); scale.tick(&"A"); ``` ### ScaleOrdinal ```rust let scale = ScaleOrdinal::new( vec!["A", "B", "C"], vec![color1, color2, color3] ); scale.map(&"A"); ``` ## 图形 ### Bar ```rust Bar::new() .data(data) .band_width(30.) .x(|d| x_scale.tick(&d.category)) .y0(|d| y_scale.tick(&0.).unwrap()) .y1(|d| y_scale.tick(&d.value)) .fill(|d| color_scale.map(&d.category)) .paint(&bounds, window, cx); ``` ### Line ```rust Line::new() .data(data) .x(|d| x_scale.tick(&d.date)) .y(|d| y_scale.tick(&d.value)) .stroke(cx.theme().chart_1) .stroke_width(px(2.)) .paint(&bounds, window); ``` #### 带节点的折线 ```rust Line::new() .data(data) .x(|d| x_scale.tick(&d.date)) .y(|d| y_scale.tick(&d.value)) .dot() .dot_size(px(4.)) .paint(&bounds, window); ``` ### Area ```rust Area::new() .data(data) .x(|d| x_scale.tick(&d.date)) .y0(height) .y1(|d| y_scale.tick(&d.value)) .fill(cx.theme().chart_1.opacity(0.5)) .stroke(cx.theme().chart_1) .paint(&bounds, window); ``` ### Pie 与 Arc ```rust let pie = Pie::new() .value(|d| Some(d.value)) .pad_angle(0.05); let arcs = pie.arcs(&data); let arc_shape = Arc::new() .inner_radius(0.) .outer_radius(100.); for arc_data in arcs { arc_shape.paint( &arc_data, color_scale.map(&arc_data.data.category), None, None, &bounds, window ); } ``` ### Stack ```rust let stack = Stack::new() .data(data) .keys(vec!["series1", "series2"]) .value(|d, key| match key { "series1" => Some(d.val1), "series2" => Some(d.val2), _ => None }); let series = stack.series(); ``` ## 组件 ### PlotAxis ```rust PlotAxis::new() .x(height) .x_label(labels) .stroke(cx.theme().border) .paint(&bounds, window, cx); ``` ## 示例 ### 自定义堆叠柱状图 ```rust struct StackedBarChart { data: Vec, series: Vec>, } impl StackedBarChart { pub fn new(data: Vec) -> Self { let series = Stack::new() .data(data.clone()) .keys(vec!["desktop", "mobile"]) .value(|d, key| match key { "desktop" => Some(d.desktop), "mobile" => Some(d.mobile), _ => None, }) .series(); Self { data, series } } } impl Plot for StackedBarChart { fn paint(&mut self, bounds: Bounds, window: &mut Window, cx: &mut App) { // 1. 准备比例尺 let x = ScaleBand::new( self.data.iter().map(|v| v.date.clone()), [0., width], ); let y = ScaleLinear::new(vec![0., max_value], [height, 0.]); // 2. 绘制坐标轴 // ...(坐标轴绘制逻辑) // 3. 绘制堆叠柱 let bar = Bar::new() .stack_data(&self.series) .band_width(x.band_width()) .x(move |d| x.tick(&d.data.date)) .fill(move |_| cx.theme().chart_1); bar.paint(&bounds, window, cx); } } --- # GroupBox Source: /versions/v0.7.1/zh-CN/component/group-box 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/group-box 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 GroupBox 是一个用于组织相关内容的容器组件,支持标题、边框、背景和自定义样式,适合表单分区、设置面板和语义分组场景。 ## 导入 ```rust use gpui_kit::component::group_box::{GroupBox, GroupBoxVariant, GroupBoxVariants as _}; ``` ## 用法 ### 基础 GroupBox ```rust GroupBox::new() .child("Subscriptions") .child(Checkbox::new("all").label("All")) .child(Checkbox::new("newsletter").label("Newsletter")) .child(Button::new("save").primary().label("Save")) ``` ### 不同变体 ```rust GroupBox::new() .child("Content without visual container") GroupBox::new() .fill() .title("Settings") .child("Content with background") GroupBox::new() .outline() .title("Preferences") .child("Content with border") ``` ### 带标题 ```rust GroupBox::new() .fill() .title("Account Settings") .child( h_flex() .justify_between() .child("Make profile private") .child(Switch::new("privacy").checked(false)) ) .child(Button::new("save").primary().label("Save Changes")) ``` ### 表面外的底部说明 用 `footer` 在填充背景或边框下方放置辅助内容,而不是将其放进内容区域。 它与标题左对齐,位于表面下方 8 px 处,并像描述文字一样以小号 muted 文本渲染,直接传入纯文本即可。 `content_style` 只影响主体内容。 ```rust GroupBox::new() .fill() .child("Update preferences") .footer("Changes apply to this device only.") ``` ### 自定义 ID ```rust GroupBox::new() .id("user-preferences") .outline() .title("User Preferences") .child("Preference controls...") ``` ### 自定义标题样式 ```rust use gpui_kit::{StyleRefinement, relative}; GroupBox::new() .outline() .title("Custom Title") .title_style( StyleRefinement::default() .font_semibold() .line_height(relative(1.0)) .px_3() .text_color(cx.theme().accent) ) .child("Content with custom title styling") ``` ### 自定义内容区域样式 ```rust GroupBox::new() .fill() .title("Custom Content Area") .content_style( StyleRefinement::default() .rounded_xl() .py_3() .px_4() .border_2() .border_color(cx.theme().accent) ) .child("Content with custom styling") ``` ### 复杂示例 ```rust GroupBox::new() .id("notification-settings") .outline() .bg(cx.theme().group_box) .rounded_xl() .p_5() .title("Notification Preferences") .title_style( StyleRefinement::default() .font_semibold() .line_height(relative(1.0)) .px_3() ) .content_style( StyleRefinement::default() .rounded_xl() .py_3() .px_4() .border_2() ) .child( v_flex() .gap_3() .child( h_flex() .justify_between() .child("Email notifications") .child(Switch::new("email").checked(true)) ) .child( h_flex() .justify_between() .child("Push notifications") .child(Switch::new("push").checked(false)) ) .child( h_flex() .justify_between() .child("SMS notifications") .child(Switch::new("sms").checked(false)) ) ) .child( h_flex() .justify_end() .gap_2() .child(Button::new("cancel").label("Cancel")) .child(Button::new("save").primary().label("Save Settings")) ) ``` ## 示例 ### 表单分区 ```rust GroupBox::new() .fill() .title("Personal Information") .child( v_flex() .gap_4() .child( h_flex() .gap_2() .child(Input::new("first-name").placeholder("First Name")) .child(Input::new("last-name").placeholder("Last Name")) ) .child(Input::new("email").placeholder("Email Address")) .child( h_flex() .justify_end() .child(Button::new("update").primary().label("Update Profile")) ) ) ``` ### 设置面板 ```rust GroupBox::new() .outline() .title("Display Settings") .child( v_flex() .gap_3() .child( h_flex() .justify_between() .child(Label::new("Theme")) .child( RadioGroup::horizontal("theme") .child(Radio::new("light").label("Light")) .child(Radio::new("dark").label("Dark")) .child(Radio::new("auto").label("Auto")) ) ) .child( h_flex() .justify_between() .child(Label::new("Font Size")) .child( Select::new("font-size") .option("small", "Small") .option("medium", "Medium") .option("large", "Large") ) ) ) ``` ### 邮件订阅管理 ```rust GroupBox::new() .title("Email Subscriptions") .child( v_flex() .gap_2() .child(Checkbox::new("newsletter").label("Weekly Newsletter")) .child(Checkbox::new("updates").label("Product Updates")) .child(Checkbox::new("security").label("Security Alerts")) .child(Checkbox::new("marketing").label("Marketing Communications")) ) .child( h_flex() .justify_between() .mt_4() .child(Button::new("unsubscribe-all").link().label("Unsubscribe All")) .child(Button::new("save").primary().label("Update Preferences")) ) ``` ### 无标题分组 ```rust GroupBox::new() .outline() .child( h_flex() .justify_between() .items_center() .child("Enable two-factor authentication") .child(Switch::new("2fa").checked(false)) ) ``` ## 样式 GroupBox 既支持内置变体,也支持自定义样式。 ### 主题集成 ```rust GroupBox::new() .fill() .bg(cx.theme().group_box) .title("Themed Group Box") ``` ### 自定义外观 ```rust GroupBox::new() .outline() .border_2() .border_color(cx.theme().accent) .rounded(cx.theme().radius_lg) .title("Custom Styled Group Box") .title_style( StyleRefinement::default() .text_color(cx.theme().accent) .font_bold() ) ``` ## 最佳实践 1. 对明确分组的表单项使用标题。 2. 主要内容分区可用 `fill()`,次级分区可用 `outline()`。 3. 用 GroupBox 建立清晰层级,但避免视觉过载。 4. 只把逻辑相关的内容放进同一个分组。 5. 组件会处理内部间距,但外部间距仍需要按页面布局控制。 6. GroupBox 能较好适配不同容器宽度和响应式布局。 ## 相关组件 - **Form**:可在表单中用 GroupBox 做分区 - **Dialog**:适合在对话框中组织内容 - **Accordion**:需要可折叠分组时可考虑使用 - **Card**:需要更强视觉容器感时可考虑 Card --- # Tabs Source: /versions/v0.7.1/zh-CN/component/tabs 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/tabs 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Tabs 用于把内容组织成多个独立分区,一次只显示一个标签面板。它支持多种外观、尺寸、导航控制,以及前后缀元素、滚动和菜单等交互能力。 ## 导入 ```rust use gpui_kit::component::tab::{Tab, TabBar}; ``` ## 用法 ### 基础 Tabs ```rust TabBar::new("tabs") .selected_index(0) .on_click(|selected_index, _, _| { println!("Tab {} selected", selected_index); }) .child(Tab::new().label("Account")) .child(Tab::new().label("Profile")) .child(Tab::new().label("Settings")) ``` ### 标签样式 #### 默认样式 ```rust TabBar::new("default-tabs") .selected_index(0) .child(Tab::new().label("Account")) .child(Tab::new().label("Profile")) .child(Tab::new().label("Documents")) ``` #### Underline ```rust TabBar::new("underline-tabs") .underline() .selected_index(0) .child(Tab::new().label("Account")) .child(Tab::new().label("Profile")) .child(Tab::new().label("Documents")) ``` #### Pill ```rust TabBar::new("pill-tabs") .pill() .selected_index(0) .child(Tab::new().label("Account")) .child(Tab::new().label("Profile")) .child(Tab::new().label("Documents")) ``` #### Outline ```rust TabBar::new("outline-tabs") .outline() .selected_index(0) .child(Tab::new().label("Account")) .child(Tab::new().label("Profile")) .child(Tab::new().label("Documents")) ``` #### Segmented ```rust use gpui_kit::component::IconName; TabBar::new("segmented-tabs") .segmented() .selected_index(0) .child(IconName::Bot) .child(IconName::Calendar) .child(IconName::Map) .children(vec!["Settings", "About"]) ``` ### 不同尺寸 ```rust TabBar::new("tabs").xsmall() .child(Tab::new().label("Small")) TabBar::new("tabs").small() .child(Tab::new().label("Small")) TabBar::new("tabs") .child(Tab::new().label("Medium")) TabBar::new("tabs").large() .child(Tab::new().label("Large")) ``` ### 带图标的标签 ```rust use gpui_kit::component::{Icon, IconName}; TabBar::new("icon-tabs") .child(Tab::default().icon(IconName::User).with_variant(TabVariant::Tab)) .child(Tab::default().icon(IconName::Settings).with_variant(TabVariant::Tab)) .child(Tab::default().icon(IconName::Mail).with_variant(TabVariant::Tab)) ``` ### 前缀和后缀 ```rust use gpui_kit::component::button::Button; use gpui_kit::component::{h_flex, IconName}; TabBar::new("tabs-with-controls") .prefix( h_flex() .gap_1() .child(Button::new("back").ghost().xsmall().icon(IconName::ArrowLeft)) .child(Button::new("forward").ghost().xsmall().icon(IconName::ArrowRight)) ) .suffix( h_flex() .gap_1() .child(Button::new("inbox").ghost().xsmall().icon(IconName::Inbox)) .child(Button::new("more").ghost().xsmall().icon(IconName::Ellipsis)) ) .child(Tab::new().label("Account")) .child(Tab::new().label("Profile")) .child(Tab::new().label("Settings")) ``` ### 禁用标签 ```rust TabBar::new("tabs-with-disabled") .child(Tab::new().label("Account")) .child(Tab::new().label("Profile").disabled(true)) .child(Tab::new().label("Settings")) ``` ### 动态标签 ```rust struct TabsView { active_tab: usize, tabs: Vec, } impl Render for TabsView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { TabBar::new("dynamic-tabs") .selected_index(self.active_tab) .on_click(cx.listener(|view, index, _, cx| { view.active_tab = *index; cx.notify(); })) .children( self.tabs .iter() .map(|tab_name| Tab::new().label(tab_name.clone())) ) } } ``` ### 菜单模式 当标签很多时,可以开启 `menu(true)`,在标签栏末尾显示下拉菜单按钮: ```rust TabBar::new("tabs-with-menu") .menu(true) .selected_index(0) .child(Tab::new().label("Account")) .child(Tab::new().label("Profile")) .child(Tab::new().label("Documents")) .child(Tab::new().label("Mail")) .child(Tab::new().label("Settings")) ``` ### 最大标签宽度 使用 `max_width` 限制每个标签的最大宽度。通过 `.label()` 创建的标签会自动截断超长文本并显示省略号。仅图标的标签(`.icon()`)不受此限制。前缀和后缀元素(例如关闭按钮)不会被截断——标签文本会优先让出空间。如果启用了溢出菜单,下拉项仍会显示完整标签文本。 通过 `.child()` 传入自定义内容的标签只会应用宽度限制,需要自行在应当收缩的部分调用 `truncate()`。 ```rust use gpui_kit::{div, px}; TabBar::new("tabs-with-max-width") .max_width(px(100.)) .menu(true) .selected_index(0) .child(Tab::new().label("Account Settings & Preferences")) .child(Tab::new().label("Documents & Files")) .child(Tab::new().label("Appearance & Themes")) .child( Tab::new().child( h_flex() .gap_1() .child(Icon::new(IconName::Bot)) .child(div().truncate().child("Custom Child Tab")), ), ) ``` ## API 参考 ### TabBar | 方法 | 说明 | | --- | --- | | `new(id)` | 创建新的标签栏 | | `child(tab)` | 添加单个标签 | | `children(tabs)` | 批量添加标签 | | `selected_index(index)` | 设置当前选中索引 | | `on_click(fn)` | 点击标签时触发,返回标签索引 | | `prefix(element)` | 在标签前添加元素 | | `suffix(element)` | 在标签后添加元素 | | `last_empty_space(element)` | 自定义尾部空白区域 | | `track_scroll(handle)` | 配合滚动句柄启用可滚动标签栏 | | `with_menu(bool)` | 启用下拉菜单选择 | | `max_width(width)` | 设置每个标签的最大宽度;超长文本自动截断 | ### TabBar 变体 | 方法 | 说明 | | --- | --- | | `with_variant(variant)` | 为所有子标签设置统一样式 | | `underline()` | 下划线样式 | | `pill()` | 胶囊样式 | | `outline()` | 描边样式 | | `segmented()` | 分段控制样式 | ### Tab | 方法 | 说明 | | --- | --- | | `new(label)` | 创建带标签文本的 Tab | | `empty()` | 创建空 Tab | | `icon(icon)` | 创建仅图标的 Tab | | `id(id)` | 设置自定义 ID | | `with_variant(variant)` | 设置当前 Tab 的样式 | | `prefix(element)` | 在标签内容前添加元素 | | `suffix(element)` | 在标签内容后添加元素 | | `disabled(bool)` | 设置禁用状态 | | `selected(bool)` | 设置选中状态,通常由 `TabBar` 统一管理 | | `on_click(fn)` | 为单个标签设置点击回调 | ### 样式 `TabBar` 和 `Tab` 都实现了 `Sizable` trait: - `xsmall()`:超小尺寸 - `small()`:小尺寸 - `medium()`:中尺寸,默认值 - `large()`:大尺寸 ## 说明 - `TabBar` 负责统一管理所有子标签的选中状态 - 当设置了 `TabBar.on_click` 时,单个 `Tab.on_click` 通常不会生效 - 子标签会自动继承父级 `TabBar` 的样式和尺寸 - 标签过多时可通过 `with_menu` 或滚动支持提升可用性 --- # Pagination Source: /versions/v0.7.1/zh-CN/component/pagination 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/pagination 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 [Pagination] 组件用于在多页内容之间切换,支持显示页码、上一页和下一页操作,适合表格、列表和搜索结果等需要分页浏览的场景。 ## 导入 ```rust use gpui_kit::component::pagination::Pagination; ``` ## 用法 ### 基础分页 ```rust Pagination::new("my-pagination") .current_page(5) .total_pages(10) .on_click(|page, _, cx| { println!("Navigated to page: {}", page); }) ``` ### 自定义可见页数 默认最多显示 5 个页码按钮,可以通过 `visible_pages()` 调整: ```rust Pagination::new("my-pagination") .current_page(1) .total_pages(50) .visible_pages(10) .on_click(|page, _, cx| { // 处理页码切换 }) ``` ### 紧凑模式 紧凑模式只显示上一页和下一页按钮,不显示具体页码: ```rust Pagination::new("my-pagination") .compact() .current_page(3) .total_pages(10) .on_click(|page, _, cx| { // 处理页码切换 }) ``` ### 不同尺寸 ```rust use gpui_kit::component::{Sizable as _, Size}; Pagination::new("my-pagination") .xsmall() .current_page(1) .total_pages(10) Pagination::new("my-pagination") .small() .current_page(1) .total_pages(10) Pagination::new("my-pagination") .current_page(1) .total_pages(10) Pagination::new("my-pagination") .large() .current_page(1) .total_pages(10) ``` ### 禁用状态 ```rust Pagination::new("my-pagination") .current_page(4) .total_pages(10) .disabled(true) .on_click(|_, _, _| {}) ``` ### 处理页码变化 `on_click` 会在用户点击页码、上一页或下一页时返回新的页码: ```rust Pagination::new("my-pagination") .current_page(current_page) .total_pages(total_pages) .on_click(|page, _, cx| { // 用新的页码更新状态 // 页码从 1 开始 }) ``` ## API 参考 ### 尺寸 实现了 [Sizable] trait: - `xsmall()`:超小尺寸 - `small()`:小尺寸 - `medium()`:中尺寸,默认值 - `large()`:大尺寸 - `with_size(size)`:设置自定义尺寸 ### 方法 - `current_page(page: usize)`:设置当前页,页码从 1 开始,超出范围时会自动限制到 `1..=total_pages` - `total_pages(pages: usize)`:设置总页数 - `visible_pages(max: usize)`:设置最多显示多少个页码按钮,默认 `5` - `compact()`:启用紧凑模式,仅显示前后翻页按钮 - `disabled(bool)`:设置禁用状态 - `on_click(handler)`:设置页码切换回调 ## 示例 ### 结合状态管理 ```rust let mut current_page = 1; let total_pages = 20; Pagination::new("pagination") .current_page(current_page) .total_pages(total_pages) .on_click({ let entity = entity.clone(); move |page, _, cx| { entity.update(cx, |this, cx| { this.current_page = *page; cx.notify(); }); } }) ``` ### 大数据集分页 ```rust Pagination::new("large-pagination") .current_page(25) .total_pages(100) .visible_pages(10) .on_click(|page, _, cx| { // 加载新页的数据 }) ``` [Pagination]: https://docs.rs/gpui-component/latest/gpui_component/pagination/struct.Pagination.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html --- # Avatar Source: /versions/v0.7.1/zh-CN/component/avatar 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/avatar 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Avatar 用于显示用户头像图片,并在无图片时自动回退为姓名首字母或占位图标。组件支持多种尺寸,也可以通过 AvatarGroup 组合展示团队或成员列表。 ## 导入 ```rust use gpui_kit::component::avatar::{Avatar, AvatarGroup}; ``` ## 用法 ### 基础 Avatar 通过图片地址和用户名创建头像: ```rust Avatar::new() .name("John Doe") .src("https://example.com/avatar.jpg") ``` ### 使用首字母回退 当未提供图片时,Avatar 会显示用户名首字母,并自动生成背景颜色: ```rust Avatar::new() .name("John Doe") Avatar::new() .name("Jane Smith") ``` 颜色由首字母推导,因此同一个人始终得到同一种颜色。取色自 12 个等距 OkLCH 色相组成 的色环,亮度与彩度固定,所以每个头像的视觉分量一致,明暗两种主题下文字对比度都不低于 WCAG AA。描边取同一色相;显示图片的 Avatar 仍然使用中性描边。 ### 占位头像 适用于匿名用户或没有姓名的场景: ```rust use gpui_kit::component::IconName; Avatar::new() Avatar::new() .placeholder(IconName::Building2) ``` ### 不同尺寸 ```rust Avatar::new() .name("John Doe") .xsmall() Avatar::new() .name("John Doe") .small() Avatar::new() .name("John Doe") Avatar::new() .name("John Doe") .large() Avatar::new() .name("John Doe") .with_size(px(100.)) ``` ### 自定义样式 ```rust Avatar::new() .src("https://example.com/avatar.jpg") .with_size(px(100.)) .border_3() .border_color(cx.theme().foreground) .shadow_sm() .rounded(px(20.)) ``` ## AvatarGroup [AvatarGroup] 可以以紧凑、重叠的方式显示多个头像。 ### 基础分组 ```rust AvatarGroup::new() .child(Avatar::new().src("https://example.com/user1.jpg")) .child(Avatar::new().src("https://example.com/user2.jpg")) .child(Avatar::new().src("https://example.com/user3.jpg")) .child(Avatar::new().name("John Doe")) ``` ### 限制数量 ```rust AvatarGroup::new() .limit(3) .child(Avatar::new().src("https://example.com/user1.jpg")) .child(Avatar::new().src("https://example.com/user2.jpg")) .child(Avatar::new().src("https://example.com/user3.jpg")) .child(Avatar::new().src("https://example.com/user4.jpg")) .child(Avatar::new().src("https://example.com/user5.jpg")) ``` ### 使用省略标记 当超过限制数量时,可显示 `...` 提示还有更多成员: ```rust AvatarGroup::new() .limit(3) .ellipsis() .child(Avatar::new().src("https://example.com/user1.jpg")) .child(Avatar::new().src("https://example.com/user2.jpg")) .child(Avatar::new().src("https://example.com/user3.jpg")) .child(Avatar::new().src("https://example.com/user4.jpg")) .child(Avatar::new().src("https://example.com/user5.jpg")) ``` ### 分组尺寸 [Sizable] trait 也可用于 AvatarGroup,并会作用于内部所有头像: ```rust AvatarGroup::new() .xsmall() .child(Avatar::new().name("A")) .child(Avatar::new().name("B")) .child(Avatar::new().name("C")) AvatarGroup::new() .small() .child(Avatar::new().name("A")) .child(Avatar::new().name("B")) AvatarGroup::new() .child(Avatar::new().name("A")) .child(Avatar::new().name("B")) AvatarGroup::new() .large() .child(Avatar::new().name("A")) .child(Avatar::new().name("B")) ``` ### 批量添加头像 ```rust let avatars = vec![ Avatar::new().src("https://example.com/user1.jpg"), Avatar::new().src("https://example.com/user2.jpg"), Avatar::new().name("John Doe"), ]; AvatarGroup::new() .children(avatars) .limit(5) .ellipsis() ``` ## API 参考 - [Avatar] - [AvatarGroup] ## 示例 ### 团队成员展示 ```rust use gpui_kit::component::{h_flex, v_flex}; v_flex() .gap_4() .child("Development Team") .child( AvatarGroup::new() .limit(4) .ellipsis() .child(Avatar::new().name("Alice Johnson").src("https://example.com/alice.jpg")) .child(Avatar::new().name("Bob Smith").src("https://example.com/bob.jpg")) .child(Avatar::new().name("Charlie Brown")) .child(Avatar::new().name("Diana Prince")) .child(Avatar::new().name("Eve Wilson")) ) ``` ### 用户资料头部 ```rust h_flex() .items_center() .gap_4() .child( Avatar::new() .src("https://example.com/profile.jpg") .name("John Doe") .large() .border_2() .border_color(cx.theme().primary) ) .child( v_flex() .child("John Doe") .child("Software Engineer") ) ``` ### 匿名用户 ```rust use gpui_kit::component::IconName; Avatar::new() .placeholder(IconName::UserCircle) .medium() ``` ### 自动配色 ```rust Avatar::new().name("Alice") Avatar::new().name("Bob") Avatar::new().name("Charlie") ``` [Avatar]: https://docs.rs/gpui-component/latest/gpui_component/avatar/struct.Avatar.html [AvatarGroup]: https://docs.rs/gpui-component/latest/gpui_component/avatar/struct.AvatarGroup.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html --- # StatusBar Source: /versions/v0.7.1/zh-CN/component/status-bar 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/status-bar 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 StatusBar 是一个水平栏,分为 `left`、`center`、`right` 三个区域。它通常放置在窗口或面板底部,用于显示上下文信息和快捷操作。 其设计参考了原生 UI 框架中的状态栏:Windows 的 `StatusStrip`、WPF 的 `StatusBar` 以及 macOS 的 `NSStatusBar`。 ## 引入 ```rust use gpui_kit::component::status_bar::StatusBar; ``` ## 区域 向区域传入任意 `impl IntoElement` —— 字符串、`Icon`、`Button`、自定义布局等。`left` 和 `right` 把项固定在两端;`child` / `children` 添加到中间区域,其对齐方式取决于固定了哪一端 —— 同时有 `left` 和 `right` 时居中,只有 `left` 时右对齐,否则左对齐(只有 `right`,或两者都没有时,像普通容器一样)。多次调用即可追加更多。 - **不可交互的标签**:直接传字符串 —— 它会继承状态栏的文字样式,且没有 hover。 - **可点击的按钮**:传入一个 ghost、xsmall 的 `Button` —— `Button::new(id).ghost().xsmall()` —— 保证按钮尺寸一致。可链式调用 `label`、`icon`、`tooltip`、`on_click` 等。 - **分隔线**:传入 `Separator::vertical()`。 - **其他任意内容**:直接传该元素。 ## 用法 ### 标签 ```rust StatusBar::new() .left("Ready") .child("README.md") .right("UTF-8") ``` ### 按钮 ```rust StatusBar::new() .left( Button::new("branch").ghost().xsmall() .icon(IconName::Github) .label("main") .on_click(|_, window, cx| { /* ... */ }), ) .right( Button::new("go-to-line").ghost().xsmall() .label("Ln 1, Col 1") .tooltip("Go to Line/Column") .on_click(cx.listener(|this, _, window, cx| { /* ... */ })), ) ``` ### 分割线与自定义元素 ```rust StatusBar::new() .left(Button::new("branch").ghost().xsmall().icon(IconName::Github).label("main")) .left(Separator::vertical()) .left( // 任意自定义元素都可以。 h_flex() .items_center() .gap_1() .child(Icon::new(IconName::CircleCheck).xsmall()) .child("0 problems"), ) .child(Progress::new("indexing").value(60.).w_24()) ``` ### 自定义样式 `StatusBar` 实现了 `Styled`,因此任意样式方法都会覆盖默认值。 ```rust StatusBar::new() .bg(cx.theme().secondary) .border_color(cx.theme().border) .left("Ready") ``` ## API 参考 ### StatusBar | 方法 | 说明 | | ---------------- | ------------------------------------------ | | `new()` | 创建一个空的状态栏 | | `left(child)` | 向左侧区域追加一个元素(可多次调用) | | `right(child)` | 向右侧区域追加一个元素 | | `child(c)` / `children(cs)` | 向中间区域添加元素 | 每个区域方法接受 `impl IntoElement`。`StatusBar` 同时实现了 `Styled`,样式方法(`bg`、`border_color`、`py` 等)可以覆盖默认值。 ## 注意事项 - 中间区域(通过 `child` / `children`)在同时有 `left` 和 `right` 时居中,只有 `left` 时右对齐,否则左对齐(只有 `right`,或两者都没有时,像普通容器一样)。 - 只读项请用纯字符串(或任意不可交互元素),以避免按钮的 hover 效果;只有可点击项才用 ghost、xsmall 的 `Button`。 - 颜色取自 `status_bar`(背景)和 `status_bar_border`(边框)主题变量,缺省回退到 `background` / `border`。 --- # Tag Source: /versions/v0.7.1/zh-CN/component/tag 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/tag 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Tag 是一个轻量但灵活的标签组件,适合展示分类、状态、优先级和其他元数据。它体积紧凑,适合在列表、卡片和详情页中重复使用。 ## 导入 ```rust use gpui_kit::component::tag::Tag; ``` ## 用法 ### 基础标签 ```rust Tag::primary().child("Primary") Tag::secondary().child("Secondary") Tag::danger().child("Danger") Tag::success().child("Success") Tag::warning().child("Warning") Tag::info().child("Info") ``` ### 语义变体 ```rust Tag::primary().child("Featured") Tag::secondary().child("Category") Tag::danger().child("Critical") Tag::success().child("Completed") Tag::warning().child("Pending") Tag::info().child("Information") ``` ### Outline 风格 ```rust Tag::primary().outline().child("Primary Outline") Tag::secondary().outline().child("Secondary Outline") Tag::danger().outline().child("Error Outline") Tag::success().outline().child("Success Outline") ``` ### 尺寸 ```rust Tag::primary().small().child("Small Tag") Tag::primary().child("Medium Tag") ``` ### 预设颜色 ```rust use gpui_kit::component::ColorName; Tag::color(ColorName::Blue).child("Blue Tag") Tag::color(ColorName::Green).child("Green Tag") Tag::color(ColorName::Purple).child("Purple Tag") Tag::color(ColorName::Pink).child("Pink Tag") ``` ### 自定义 HSLA 颜色 ```rust use gpui_kit::{hsla, Hsla}; let color = hsla(220.0 / 360.0, 0.8, 0.5, 1.0); let foreground = hsla(0.0, 0.0, 1.0, 1.0); let border = hsla(220.0 / 360.0, 0.8, 0.4, 1.0); Tag::custom(color, foreground, border).child("Custom Color") ``` ### 圆角 ```rust use gpui_kit::px; Tag::primary().rounded_full().child("Rounded Full") Tag::primary().rounded(px(4.0)).child("Custom Radius") Tag::primary().rounded(px(0.0)).child("Square Tag") ``` ## 常见场景 ### 状态标签 ```rust Tag::success().child("Completed") Tag::warning().child("In Progress") Tag::danger().child("Failed") Tag::info().child("Pending Review") ``` ### 分类标签 ```rust Tag::secondary().child("Technology") Tag::color(ColorName::Blue).child("Design") Tag::color(ColorName::Green).child("Development") Tag::color(ColorName::Purple).child("Marketing") ``` ### 优先级标签 ```rust Tag::danger().child("High Priority") Tag::warning().child("Medium Priority") Tag::secondary().child("Low Priority") ``` ## API 参考 ### 创建方法 | 方法 | 说明 | | --- | --- | | `primary()` | 主色标签 | | `secondary()` | 次级标签 | | `danger()` | 危险状态标签 | | `success()` | 成功状态标签 | | `warning()` | 警告状态标签 | | `info()` | 信息标签 | | `color(ColorName)` | 使用预设颜色创建标签 | | `custom(color, fg, border)` | 使用自定义 HSLA 颜色创建标签 | ### 样式方法 | 方法 | 说明 | | --- | --- | | `outline()` | 使用描边风格 | | `rounded(radius)` | 自定义圆角 | | `rounded_full()` | 完整圆角,胶囊样式 | ### 尺寸方法 | 方法 | 说明 | | --- | --- | | `small()` | 小尺寸标签 | | `with_size(size)` | 设置自定义尺寸 | ## 设计建议 - 状态类信息优先使用语义颜色,如 success、warning、danger - 分类标签可结合 `ColorName` 做稳定的颜色映射 - 空间有限时优先使用 `small()` - 纯展示标签不应默认承担交互职责 --- # Accordion Source: /versions/v0.7.1/zh-CN/component/accordion 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/accordion 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Accordion 是一个可折叠内容组件,允许用户展开和收起多个内容区块。它内部基于 collapse 功能实现,适合 FAQ、设置分组和分段内容展示。 ## 导入 ```rust use gpui_kit::component::accordion::Accordion; ``` ## 用法 ### 基础 Accordion ```rust Accordion::new("my-accordion") .item(|item| { item.title("Section 1") .child("Content for section 1") }) .item(|item| { item.title("Section 2") .child("Content for section 2") }) .item(|item| { item.title("Section 3") .child("Content for section 3") }) ``` ### 允许同时展开多个项 默认情况下,一次只能展开一个项。使用 `multiple()` 可以允许多个项同时展开: ```rust Accordion::new("my-accordion") .multiple(true) .item(|item| item.title("Section 1").child("Content 1")) .item(|item| item.title("Section 2").child("Content 2")) ``` ### 带边框 ```rust Accordion::new("my-accordion") .bordered(true) .item(|item| item.title("Section 1").child("Content 1")) ``` ### 不同尺寸 ```rust use gpui_kit::component::{Sizable as _, Size}; Accordion::new("my-accordion") .small() .item(|item| item.title("Small Section").child("Content")) Accordion::new("my-accordion") .large() .item(|item| item.title("Large Section").child("Content")) ``` ### 处理切换事件 ```rust Accordion::new("my-accordion") .on_toggle_click(|open_indices, window, cx| { println!("Open items: {:?}", open_indices); }) .item(|item| item.title("Section 1").child("Content 1")) ``` ### 禁用状态 ```rust Accordion::new("my-accordion") .disabled(true) .item(|item| item.title("Disabled Section").child("Content")) ``` ## API 参考 - [Accordion] - [AccordionItem] ### 尺寸 实现了 [Sizable] trait: - `small()`:小尺寸 - `medium()`:中尺寸,默认值 - `large()`:大尺寸 - `xsmall()`:超小尺寸 ## 示例 ### 自定义图标标题 ```rust Accordion::new("my-accordion") .item(|item| { item.title( h_flex() .gap_2() .child(Icon::new(IconName::Settings)) .child("Settings") ) .child("Settings content here") }) ``` ### 嵌套 Accordion ```rust Accordion::new("outer") .item(|item| { item.title("Parent Section") .child( Accordion::new("inner") .item(|item| item.title("Child 1").child("Content")) .item(|item| item.title("Child 2").child("Content")) ) }) ``` [Accordion]: https://docs.rs/gpui-component/latest/gpui_component/accordion/struct.Accordion.html [AccordionItem]: https://docs.rs/gpui-component/latest/gpui_component/accordion/struct.AccordionItem.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html --- # Combobox Source: /versions/v0.7.1/zh-CN/component/combobox 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/combobox 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 支持从可搜索列表中选择一个或多个值的下拉选择组件。 ## Select 与 Combobox 的区别 | 功能 | Select | Combobox | | --- | --- | --- | | 可搜索 | ✓(可选) | ✓(可选) | | 多选 | — | ✓(`.multiple(true)`) | | 自定义触发器渲染 | — | ✓ | | 自定义列表项渲染 | — | ✓ | | 底部操作插槽 | — | ✓ | 需要简单单选时用 `Select`;需要多选、完全自定义触发器或自定义列表项渲染时用 `Combobox`。 ## 导入 ```rust use gpui_kit::component::combobox::{ Combobox, ComboboxState, ComboboxEvent, ComboboxTriggerCtx, }; use gpui_kit::component::searchable_list::{ SearchableListItem, SearchableVec, SearchableGroup, }; ``` ## 用法 ### 基础单选 ```rust let state = cx.new(|cx| { ComboboxState::new( SearchableVec::new(vec!["Next.js", "SvelteKit", "Nuxt.js"]), vec![], // 无初始选中 window, cx, ) .searchable(true) }); Combobox::new(&state) .placeholder("选择框架...") .search_placeholder("搜索...") .w_full() ``` ### 多选 通过 `.multiple(true)` 开启多选模式。点击列表项会切换其选中状态,下拉菜单保持展开直到按下 Escape 或点击外部。 ```rust let state = cx.new(|cx| { ComboboxState::new( SearchableVec::new(vec!["React", "Vue", "Angular"]), vec![IndexPath::new(0)], // 预选项 window, cx, ) .multiple(true) .searchable(true) }); Combobox::new(&state).placeholder("选择框架") ``` ### 预选项 通过索引路径指定预选的列表项: ```rust let state = cx.new(|cx| { ComboboxState::new(items, vec![IndexPath::new(0)], window, cx) }); ``` ### 分组列表项 使用 `SearchableGroup` 对列表项进行分组: ```rust let grouped = SearchableVec::new(vec![ SearchableGroup::new("水果").items(vec![ FoodItem::new("苹果"), FoodItem::new("香蕉"), ]), SearchableGroup::new("蔬菜").items(vec![ FoodItem::new("胡萝卜"), FoodItem::new("菠菜"), ]), ]); let state = cx.new(|cx| { ComboboxState::new(grouped, vec![], window, cx).searchable(true) }); Combobox::new(&state) ``` ### 实现 `SearchableListItem` `String`、`SharedString` 和 `&'static str` 已内置实现了 `SearchableListItem`。自定义类型需手动实现该 trait: ```rust #[derive(Clone)] struct Country { name: SharedString, code: SharedString, } impl SearchableListItem for Country { type Value = SharedString; fn title(&self) -> SharedString { self.name.clone() } fn value(&self) -> &SharedString { &self.code } fn matches(&self, query: &str) -> bool { self.name.to_lowercase().contains(query) || self.code.to_lowercase().contains(query) } } ``` ### 禁用列表项 在列表项的 `disabled()` 方法中返回 `true` 即可将该项设为不可选: ```rust impl SearchableListItem for MyItem { // ... fn disabled(&self) -> bool { self.is_unavailable } } ``` ### 自定义勾选图标 ```rust Combobox::new(&state) .check_icon(Icon::new(IconName::CircleCheck)) ``` ### 底部操作按钮 在下拉菜单底部渲染一个固定操作项(如"新建"按钮): ```rust Combobox::new(&state) .footer(|_, cx| { Button::new("add-new") .ghost() .label("新建项目") .icon(Icon::new(IconName::Plus)) .w_full() .justify_start() .into_any_element() }) ``` ### 自定义触发器 完全覆盖触发器元素的渲染。`ComboboxTriggerCtx` 包含当前选中状态、开关标志和尺寸信息: ```rust Combobox::new(&state) .render_trigger(|ctx, _, cx| { h_flex() .w_full() .items_center() .gap_2() .when(ctx.selection.is_empty(), |this| { this.text_color(cx.theme().muted_foreground) .child("请选择...") }) .children(ctx.selection.iter().map(|(_, item)| { div() .bg(cx.theme().accent) .rounded_sm() .px_1p5() .py_0p5() .text_sm() .child(item.title()) })) .into_any_element() }) ``` ### 尺寸 ```rust Combobox::new(&state).large() Combobox::new(&state) // 默认(medium) Combobox::new(&state).small() ``` ### 可清除 ```rust Combobox::new(&state).cleanable(true) // 有选中值时显示清除按钮 ``` ### 禁用状态 ```rust Combobox::new(&state).disabled(true) ``` ### 事件监听 `Change`(每次切换时触发)和 `Confirm`(下拉菜单关闭时触发)均携带完整的选中值列表 `Vec`。 ```rust cx.subscribe_in(&state, window, |view, _, event, window, cx| { match event { ComboboxEvent::Change(values) => { // 每次切换时触发 } ComboboxEvent::Confirm(values) => { // 下拉菜单关闭时触发 } } }); ``` ### 程序化操控 值会通过当前 delegate 解析,无法找到的值会被忽略。 `set_selected_values` 会先清除搜索关键词,因此正在进行的搜索不会决定哪些值可以被选中。 index path 定位的是列表当前显示的内容,所以 `set_selected_indices`、`add_selected_index` 和 `remove_selected_index` 作用于可见行,不会改动搜索关键词。 ```rust // 按值替换整个选中集合 state.update(cx, |s, cx| { s.set_selected_values(&["React", "Angular"], window, cx); }); // 按索引路径替换整个选中集合 state.update(cx, |s, cx| { s.set_selected_indices(vec![IndexPath::new(0), IndexPath::new(2)], window, cx); }); // 增加 / 移除单个选项 state.update(cx, |s, cx| { s.add_selected_index(IndexPath::new(1), cx); s.remove_selected_index(IndexPath::new(0), cx); }); // 清空选中 state.update(cx, |s, cx| { s.clear_selection(cx); }); // 读取所有选中值(多选) let values = state.read(cx).selected_values(); // Vec // 读取第一个选中值(单选便利方法) let value = state.read(cx).selected_value(); // Option ``` ## 键盘快捷键 | 按键 | 操作 | | ---------- | -------------------------------- | | `Tab` | 聚焦触发器 | | `Enter` | 打开菜单或确认当前高亮项 | | `↑ / ↓` | 在选项间导航(未打开时自动打开) | | `Escape` | 关闭菜单 | ## 主题样式 - `background` — 触发器背景 - `input` — 触发器边框颜色 - `foreground` — 文字颜色 - `muted_foreground` — 占位符和禁用文字颜色 - `border` — 菜单边框颜色 - `radius` — 圆角 --- # Icon Source: /versions/v0.7.1/zh-CN/component/icon 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/icon 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Icon 支持通过资源路径或内存中的字节渲染 SVG 图标,并可定制尺寸、颜色与变换。内置的 Lucide 图标使用资源包;自定义 SVG 字节可以通过 `Icon::data` 直接传入。 在开始之前,建议先阅读 [Icons & Assets](/versions/v0.7.1/zh-CN/docs/assets),了解如何在 GPUI 与 GPUI Component 应用中使用 SVG。 `gpui_kit::assets::IconName` 提供不依赖 Component 的完整共享目录。 `gpui_kit::component::IconName` 保留为原来的兼容枚举:现有导入、穷尽匹配和 `.view(cx)` 调用均无需改动,也无需新增 trait 导入。`Icon::new(...)` 同时接受 两种类型;旧名称可以通过 `.into()` 转为共享名称。 对于新的共享枚举,需要组件实体时使用 `Icon::new(name).view(cx)`,也可导入 `gpui_kit::component::IconNameExt` 后使用 `name.view(cx)`。 **NOTE — 依赖图标 crate 不等于嵌入全部图标** **补全图标目录不会让现有应用自动嵌入全部图标。** `Assets` 保留原来的 101 个组件图标,应用仍通过自己的 `AssetSource` 提供额外图标,不需要重新声明 组件自带的图标。只有显式注册 `AllAssets`,原生程序才会嵌入全部 1,830 个 SVG。 仅依赖 crate 或使用共享 `IconName` 不会引用全部 SVG 内容。 | 原生资源配置 | 嵌入的 SVG 总量 | 相对默认 `Assets` 的二进制增量 | | --- | ---: | ---: | | 默认组件图标(101 个) | 44.28 KiB | 0 B(基线) | | 默认 + 2 个应用图标(103 个) | 45.04 KiB | +15.19 KiB | | 默认 + 10 个应用图标(111 个) | 48.09 KiB | +19.19 KiB | | 显式使用 `AllAssets`(1,830 个) | 731.45 KiB | +1.02 MiB | **本例中,额外使用 10 个应用图标增加约 19 KiB,并不会带入整个图标库。** 这 10 个 SVG 合计 3,903 字节,二进制实际增加 19,648 字节,包含额外资源源的查找、 列表合并代码、元数据和对齐开销。这不是固定的单图标成本,也不是整个应用的大小。 测量环境:Lucide 1.43.0、Linux x86_64、Rust 1.98.0、`--release` 并移除符号。 各组使用相同的 `IconName` 查找和运行时资源路径。额外资源源回退到 `Assets`, 并合并、排序、去重两个资源源的列表。10 个额外图标为 `Accessibility`、 `AlarmClock`、`Archive`、`Award`、`Backpack`、`Bike`、`Bird`、`Camera`、 `Coffee` 和 `Compass`;两图标组使用前两个。实际结果取决于 SVG 复杂度、工具链 和资源源的实现方式。 二进制大小不等于内存占用。按需资源借用静态字节,不复制或创建缓存;实际渲染仍有 解析、栅格化和渲染缓存的开销。运行时共享名称查找可能保留名称映射表,Cargo 下载包 和构建产物也仍包含完整目录。WASM 的 `Assets::new(endpoint)` 和 `AllAssets::new(endpoint)` 沿用按需下载的 CDN 加载器,不嵌入完整资源包。 ## 应用额外图标 照常注册默认 `Assets`。如果要使用目录中的额外图标,请按照 [选取图标教程](/versions/v0.7.1/zh-CN/docs/assets#用-icon_assets-选择更多目录图标)操作:`icon_assets!` 只为列出的 SVG 创建资源源,应用还需将它与默认资源源组合并注册。应用自己的 SVG 文件请参阅 [自定义资源](/versions/v0.7.1/zh-CN/docs/assets#自定义资源)。 ## 导入 ```rust use gpui_kit::component::{Icon, IconName}; ``` ## 用法 ### 基础图标 ```rust IconName::Heart Icon::new(IconName::Heart) ``` ### 自定义尺寸 ```rust Icon::new(IconName::Search).xsmall() Icon::new(IconName::Search).small() Icon::new(IconName::Search).medium() Icon::new(IconName::Search).large() Icon::new(IconName::Search).with_size(px(20.)) ``` ### 自定义颜色 ```rust Icon::new(IconName::Heart) .text_color(cx.theme().red) Icon::new(IconName::Star) .text_color(gpui_kit::red()) ``` ### 旋转图标 ```rust use gpui_kit::{Transformation, radians}; Icon::new(IconName::ArrowUp) .rotate(radians(std::f32::consts::FRAC_PI_2)) Icon::new(IconName::ChevronRight) .transform(Transformation::rotate(radians(std::f32::consts::PI))) ``` ### 自定义 SVG 路径 ```rust Icon::new(Icon::empty()) .path("icons/my-custom-icon.svg") ``` ### SVG 字节 通过 `data(&[u8])` 传入 SVG 字节,无须为该图标注册 `AssetSource` 路径: ```rust use gpui_kit::component::{Icon, button::Button, menu::PopupMenuItem}; let icon = Icon::default().data(include_bytes!("search.svg")); Button::new("search").icon(icon.clone()).label("Search"); PopupMenuItem::new("Search").icon(icon); ``` `data` 会将输入复制到共享存储中,因此输入无须具有 `'static` 生命周期。 克隆 `Icon` 时会共享这些字节,并保留样式和变换。直接渲染与通过 `Icon::view(cx)` 创建实体视图都会保留数据源。GPUI 渲染器可能再次复制字节, 因此此 API 不承诺渲染过程零复制。 最后一次设置的数据源生效,即使新来源为空也会替换旧来源: ```rust let bytes = include_bytes!("search.svg"); Icon::default().path("icons/old.svg").data(bytes); // Uses SVG bytes Icon::default().data(bytes).path("icons/search.svg"); // Uses the asset path ``` 字节图标与路径图标使用相同的 SVG 渲染器,保留组件尺寸、前景色与按钮加载行为。 可以通过 `loading_icon` 指定自定义加载图标: ```rust Button::new("search") .icon(Icon::default().data(include_bytes!("search.svg"))) .loading_icon(Icon::default().data(include_bytes!("loader.svg"))) .loading(true) .label("Searching") ``` `NativeMenu::menu_with_icon` 也支持字节图标,尺寸与着色继续遵循现有原生菜单规则。 应用或组件中使用的其他路径图标仍需要资源源。 ### 使用 SVG 字节的自定义图标类型 图标 crate 可以导出独立类型,并实现 `From for Icon`: ```rust use gpui_kit::component::{Icon, button::Button}; pub struct Search; impl From for Icon { fn from(_: Search) -> Self { Icon::default().data(include_bytes!("search.svg")) } } Button::new("search").icon(Search); ``` 现有 `IconNamed` 实现继续提供资源路径。使用字节的类型实现上述转换即可, 无须同时实现 `IconNamed`。二进制体积能否缩小取决于实际引用的资源和构建配置。 ## 可用图标 `IconName` 枚举内置了一组常见图标: ### 导航 - `ArrowUp`、`ArrowDown`、`ArrowLeft`、`ArrowRight` - `ChevronUp`、`ChevronDown`、`ChevronLeft`、`ChevronRight` - `ChevronsUpDown` ### 操作 - `Check`、`Close`、`Plus`、`Minus` - `Copy`、`Delete`、`Search`、`Replace` - `Maximize`、`Minimize`、`WindowRestore` ### 文件与文件夹 - `File`、`Folder`、`FolderOpen`、`FolderClosed` - `BookOpen`、`Inbox` ### UI 元素 - `Menu`、`Settings`、`Settings2`、`Ellipsis`、`EllipsisVertical` - `Eye`、`EyeOff`、`Bell`、`Info` ### 社交与外链 - `GitHub`、`Globe`、`ExternalLink` - `Heart`、`HeartOff`、`Star`、`StarOff` - `ThumbsUp`、`ThumbsDown` ### 状态与提醒 - `CircleCheck`、`CircleX`、`TriangleAlert` - `Loader`、`LoaderCircle` ### 面板与布局 - `PanelLeft`、`PanelRight`、`PanelBottom` - `PanelLeftOpen`、`PanelRightOpen`、`PanelBottomOpen` - `LayoutDashboard`、`Frame` ### 用户与身份 - `User`、`CircleUser`、`Bot` ### 其它 - `Calendar`、`Map`、`Palette`、`Inspector` - `Sun`、`Moon`、`Building2` ## 图标尺寸 | 尺寸 | 方法 | CSS Class | 像素 | | ----------- | --------------------- | ------------ | ------ | | 超小 | `.xsmall()` | `size_3()` | 12px | | 小 | `.small()` | `size_3p5()` | 14px | | 中 | `.medium()` | `size_4()` | 16px | | 大 | `.large()` | `size_6()` | 24px | | 自定义 | `.with_size(px(n))` | - | n px | ## 自定义 `IconName` 如果你需要更贴合业务的图标命名,可以自己定义 `IconName` 并实现 `IconNamed` trait。 ```rust use gpui_kit::component::IconNamed; pub enum IconName { Encounters, Monsters, Spells, } impl IconNamed for IconName { fn path(self) -> gpui_kit::SharedString { match self { IconName::Encounters => "icons/encounters.svg", IconName::Monsters => "icons/monsters.svg", IconName::Spells => "icons/spells.svg", } .into() } } Button::new("my-button").icon(IconName::Spells); Icon::new(IconName::Monsters); ``` 如果你希望在元素树中直接 `render` 自定义 `IconName`,还需要实现 `RenderOnce` 并为 `IconName` 派生 `IntoElement`: ```rust impl RenderOnce for IconName { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { Icon::empty().path(self.path()) } } div() .child(IconName::Monsters) ``` ## 示例 ### 按钮中的图标 ```rust use gpui_kit::component::button::Button; Button::new("like-btn") .icon( Icon::new(IconName::Heart) .text_color(cx.theme().red) .large() ) .label("Like") ``` ### 旋转加载图标 ```rust Icon::new(IconName::LoaderCircle) .text_color(cx.theme().muted_foreground) .medium() ``` ### 状态图标 ```rust Icon::new(IconName::CircleCheck) .text_color(cx.theme().green) Icon::new(IconName::CircleX) .text_color(cx.theme().red) Icon::new(IconName::TriangleAlert) .text_color(cx.theme().yellow) ``` ### 导航图标 ```rust Icon::new(IconName::ArrowLeft) .medium() .text_color(cx.theme().foreground) Icon::new(IconName::ChevronDown) .small() .text_color(cx.theme().muted_foreground) ``` ### 来自资源包的自定义图标 ```rust Icon::empty() .path("icons/my-brand-logo.svg") .large() .text_color(cx.theme().primary) ``` ## 说明 - 图标以 SVG 形式渲染,可使用完整的样式能力。 - 如果未显式指定尺寸,默认尺寸会跟随当前文字大小。 - 图标默认带有 `flex-shrink-0`,避免在 Flex 布局中被意外压缩。 - 所有图标路径都相对于 assets bundle 根目录。 - Lucide.dev 图标在 16px 下效果最佳,并且在其它尺寸下也有良好缩放表现。 --- # Button Source: /versions/v0.7.1/zh-CN/component/button 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/button 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 [Button] 是一个支持多种变体、尺寸和状态的按钮组件。它支持图标、加载态,也可以与 [ButtonGroup] 组合使用。 ## 导入 ```rust use gpui_kit::component::{ Sizable as _, button::{Button, ButtonGroup, ButtonVariants as _}, }; ``` ## 用法 下面标记的 recipe 是完整的、**Tested consumer recipe**。其余示例均为上下文片段;使用变体或尺寸构建器时请保留上面的导入。 ```rust use gpui_kit::IntoElement; use gpui_kit::component::{ Sizable as _, button::{Button, ButtonVariants as _}, }; pub fn primary_command() -> impl IntoElement { Button::new("save").primary().small().label("Save changes") } ``` ### 基础按钮 ```rust Button::new("my-button") .label("Click me") .on_click(|_, _, _| { println!("Button clicked!"); }) ``` ### 变体 ```rust use gpui_kit::component::button::ButtonVariants as _; // Primary button Button::new("btn-primary").primary().label("Primary") // Secondary button (default) Button::new("btn-secondary").label("Secondary") // Danger button Button::new("btn-danger").danger().label("Delete") // Warning button Button::new("btn-warning").warning().label("Warning") // Success button Button::new("btn-success").success().label("Success") // Info button Button::new("btn-info").info().label("Info") // Ghost button Button::new("btn-ghost").ghost().label("Ghost") // Link button Button::new("btn-link").link().label("Link") // Text button Button::new("btn-text").text().label("Text") ``` ### Outline 按钮 `outline` 不是独立变体,而是可以和其它变体叠加使用: ```rust use gpui_kit::component::button::ButtonVariants as _; Button::new("btn").primary().outline().label("Primary Outline") Button::new("btn").danger().outline().label("Danger Outline") ``` ### 紧凑模式 `compact` 会减少按钮内边距,使按钮更紧凑: ```rust Button::new("btn") .label("Compact") .compact() ``` ### 尺寸 Button 支持 [Sizable] trait: ```rust use gpui_kit::component::Sizable as _; Button::new("btn").xsmall().label("Extra Small") Button::new("btn").small().label("Small") Button::new("btn").label("Medium") // default Button::new("btn").large().label("Large") ``` ### 图标 `icon` 方法支持多种图标类型: - **[Icon] / [IconName]** - 静态图标 - **[Spinner]** - 加载中的旋转图标 - **[ProgressCircle]** - 环形进度图标 这些图标会自动适配按钮尺寸,也可以继续定制颜色和其他属性。 #### 基础图标 ```rust use gpui_kit::component::{Icon, IconName}; Button::new("btn") .icon(IconName::Check) .label("Confirm") Button::new("btn") .icon(Icon::new(IconName::Heart)) .label("Like") Button::new("btn") .icon(IconName::Search) ``` #### Spinner ```rust use gpui_kit::component::{ActiveTheme as _, spinner::Spinner}; Button::new("btn") .icon(Spinner::new()) .label("Loading...") Button::new("btn") .icon(Spinner::new().color(cx.theme().blue)) .label("Processing") Button::new("btn") .icon(Spinner::new().icon(IconName::LoaderCircle)) .label("Syncing") ``` #### ProgressCircle ```rust use gpui_kit::component::{ ActiveTheme as _, Sizable as _, button::ButtonVariants as _, progress::ProgressCircle, }; Button::new("btn") .icon(ProgressCircle::new("install-progress").value(45.0)) .label("Installing...") Button::new("btn") .primary() .icon( ProgressCircle::new("download-progress") .value(75.0) .color(cx.theme().primary_foreground) ) .label("Downloading") ``` ### 动态更新图标 图标可以随组件状态动态变化: ```rust struct InstallButton { progress: f32, is_installing: bool, } impl InstallButton { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let button = Button::new("install-btn") .label(if self.is_installing { "Installing..." } else { "Install" }); if self.is_installing { button.icon( ProgressCircle::new("install-progress") .value(self.progress) ) } else { button.icon(IconName::Download) } } } ``` ### 加载态 按钮进入 `loading(true)` 时,会自动处理图标切换: ```rust Button::new("btn") .icon(Spinner::new()) .label("Processing") .loading(true) Button::new("btn") .icon(IconName::Save) .label("Saving") .loading(true) ``` ### 下拉箭头 `.dropdown_caret(true)` 可以在按钮右侧增加一个下拉箭头: ```rust Button::new("btn") .label("Options") .dropdown_caret(true) ``` ### 状态 Button 常见状态包括 `disabled`、`loading` 和 `selected`: ```rust use gpui_kit::component::{Disableable as _, Selectable as _}; Button::new("btn") .label("Disabled") .disabled(true) Button::new("btn") .label("Loading") .loading(true) Button::new("btn") .label("Selected") .selected(true) ``` ## Button Group ```rust ButtonGroup::new("btn-group") .child(Button::new("btn1").label("One")) .child(Button::new("btn2").label("Two")) .child(Button::new("btn3").label("Three")) ``` ### 切换式按钮组 ```rust use gpui_kit::component::Selectable as _; ButtonGroup::new("toggle-group") .multiple(true) .child(Button::new("btn1").label("Option 1").selected(true)) .child(Button::new("btn2").label("Option 2")) .child(Button::new("btn3").label("Option 3")) .on_click(|selected_indices, _, _| { println!("Selected: {:?}", selected_indices); }) ``` ## 自定义变体 ```rust use gpui_kit::component::{ ActiveTheme as _, Colorize as _, button::{ButtonCustomVariant, ButtonVariants as _}, }; let custom = ButtonCustomVariant::new(cx) .color(cx.theme().magenta) .foreground(cx.theme().primary_foreground) .hover(cx.theme().magenta.opacity(0.1)) .active(cx.theme().magenta); Button::new("custom-btn") .custom(custom) .label("Custom Button") ``` ## 示例 ### Tooltip ```rust Button::new("btn") .label("Hover me") .tooltip("This is a helpful tooltip") ``` ### 自定义子内容 ```rust Button::new("btn") .child( h_flex() .items_center() .gap_2() .child("Custom Content") .child(IconName::ChevronDown) .child(IconName::Eye) ) ``` [Button]: https://docs.rs/gpui-component/latest/gpui_component/button/struct.Button.html [ButtonGroup]: https://docs.rs/gpui-component/latest/gpui_component/button/struct.ButtonGroup.html [ButtonCustomVariant]: https://docs.rs/gpui-component/latest/gpui_component/button/struct.ButtonCustomVariant.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html [Spinner]: https://docs.rs/gpui-component/latest/gpui_component/spinner/struct.Spinner.html [ProgressCircle]: https://docs.rs/gpui-component/latest/gpui_component/progress/struct.ProgressCircle.html [Icon]: https://docs.rs/gpui-component/latest/gpui_component/icon/struct.Icon.html [IconName]: https://docs.rs/gpui-component/latest/gpui_component/icon/enum.IconName.html --- # DataTable Source: /versions/v0.7.1/zh-CN/component/data-table 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/data-table 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 DataTable 是一个面向大数据集场景的高性能表格组件。它支持虚拟滚动、列配置、排序、筛选、行/列/单元格选择、自定义单元格渲染以及右键菜单,适合展示成千上万条数据同时保持流畅体验。 ## 核心特性 - 多种选择模式:支持行、列和单元格选择 - 虚拟滚动:适合大体量数据集 - 列管理:支持固定列、调整列宽、列移动 - 排序:内建排序能力 - 键盘导航:完整键盘交互支持 - 自定义单元格:每个单元格都可以渲染任意 GPUI 内容 - 右键菜单:支持行和单元格上下文菜单 - 无限加载:滚动到底部时按需加载更多数据 ## 导入 ```rust use gpui_kit::component::table::{ DataTable, TableState, TableDelegate, Column, ColumnSort, ColumnFixed, TableEvent }; ``` ## 用法 ### 基础表格 要创建一个 DataTable,需要实现 `TableDelegate`,提供列定义和数据渲染逻辑,再通过 `TableState` 管理状态。 ```rust use std::ops::Range; use gpui_kit::{App, Context, Window, IntoElement}; use gpui_kit::component::table::{DataTable, TableDelegate, Column, ColumnSort}; struct MyData { id: usize, name: String, age: u32, email: String, } struct MyTableDelegate { data: Vec, columns: Vec, } impl MyTableDelegate { fn new() -> Self { Self { data: vec![ MyData { id: 1, name: "John".to_string(), age: 30, email: "john@example.com".to_string() }, MyData { id: 2, name: "Jane".to_string(), age: 25, email: "jane@example.com".to_string() }, ], columns: vec![ Column::new("id", "ID").width(60.), Column::new("name", "Name").width(150.).sortable(), Column::new("age", "Age").width(80.).sortable(), Column::new("email", "Email").width(200.), ], } } } impl TableDelegate for MyTableDelegate { fn columns_count(&self, _: &App) -> usize { self.columns.len() } fn rows_count(&self, _: &App) -> usize { self.data.len() } fn column(&self, col_ix: usize, _: &App) -> Column { self.columns[col_ix].clone() } fn render_td(&mut self, row_ix: usize, col_ix: usize, _: &mut Window, _: &mut Context>) -> impl IntoElement { let row = &self.data[row_ix]; let col = &self.columns[col_ix]; match col.key.as_ref() { "id" => row.id.to_string(), "name" => row.name.clone(), "age" => row.age.to_string(), "email" => row.email.clone(), _ => "".to_string(), } } } let delegate = MyTableDelegate::new(); let state = cx.new(|cx| TableState::new(delegate, window, cx)); ``` ## 列配置 列支持丰富的配置能力: ```rust Column::new("id", "ID") Column::new("name", "Name") .sortable() .width(150.) Column::new("price", "Price") .text_right() .sortable() Column::new("actions", "Actions") .fixed(ColumnFixed::Left) .resizable(false) .movable(false) ``` 常用能力包括: - `sortable()` 开启排序 - `width()` 设置宽度 - `fixed(ColumnFixed::Left)` 固定到左侧 - `resizable(false)` 禁止调整列宽 - `movable(false)` 禁止拖动列顺序 - `text_right()` / `text_center()` 设置对齐 ## 虚拟滚动 DataTable 默认面向大数据场景设计。即使数据量达到数千或上万行,也只会渲染当前可见区域附近的内容。 ```rust impl TableDelegate for LargeDataDelegate { fn rows_count(&self, _: &App) -> usize { self.data.len() } fn render_td(&mut self, row_ix: usize, col_ix: usize, _: &mut Window, _: &mut Context>) -> impl IntoElement { let row = &self.data[row_ix]; format_cell_data(row, col_ix) } fn visible_rows_changed(&mut self, visible_range: Range, _: &mut Window, _: &mut Context>) { // 可在这里根据可见区做数据预取或缓存更新 } } ``` ## 排序 排序逻辑需要由你的 `TableDelegate` 实现: ```rust impl TableDelegate for MyTableDelegate { fn perform_sort(&mut self, col_ix: usize, sort: ColumnSort, _: &mut Window, _: &mut Context>) { let col = &self.columns[col_ix]; match col.key.as_ref() { "name" => match sort { ColumnSort::Ascending => self.data.sort_by(|a, b| a.name.cmp(&b.name)), ColumnSort::Descending => self.data.sort_by(|a, b| b.name.cmp(&a.name)), ColumnSort::Default => self.data.sort_by(|a, b| a.id.cmp(&b.id)), }, "age" => match sort { ColumnSort::Ascending => self.data.sort_by(|a, b| a.age.cmp(&b.age)), ColumnSort::Descending => self.data.sort_by(|a, b| b.age.cmp(&a.age)), ColumnSort::Default => self.data.sort_by(|a, b| a.id.cmp(&b.id)), }, _ => {} } } } ``` ## 右键菜单 你可以为行或单元格提供右键菜单: ```rust impl TableDelegate for MyTableDelegate { fn context_menu(&mut self, row_ix: usize, menu: PopupMenu, _: &mut Window, _: &mut Context>) -> PopupMenu { let row = &self.data[row_ix]; menu.menu(format!("Edit {}", row.name), Box::new(EditRowAction(row_ix))) .menu("Delete", Box::new(DeleteRowAction(row_ix))) .separator() .menu("Duplicate", Box::new(DuplicateRowAction(row_ix))) } } ``` ## 自定义单元格 DataTable 的一个重要能力是每个单元格都可以渲染复杂内容: ```rust impl TableDelegate for MyTableDelegate { fn render_td(&mut self, row_ix: usize, col_ix: usize, _: &mut Window, cx: &mut Context>) -> impl IntoElement { let row = &self.data[row_ix]; let col = &self.columns[col_ix]; match col.key.as_ref() { "status" => { let (color, text) = match row.status { Status::Active => (cx.theme().green, "Active"), Status::Inactive => (cx.theme().red, "Inactive"), Status::Pending => (cx.theme().yellow, "Pending"), }; div() .px_2() .py_1() .rounded(px(4.)) .bg(color.opacity(0.1)) .text_color(color) .child(text) } _ => row.get_field_value(col.key.as_ref()).into_any_element(), } } } ``` ## 选择模式 DataTable 支持三种主要选择模式: ```rust let state = cx.new(|cx| { TableState::new(delegate, window, cx) .row_selectable(true) .col_selectable(false) .cell_selectable(false) }); let state = cx.new(|cx| { TableState::new(delegate, window, cx) .row_selectable(false) .col_selectable(true) .cell_selectable(false) }); let state = cx.new(|cx| { TableState::new(delegate, window, cx) .row_selectable(true) .col_selectable(false) .cell_selectable(true) }); ``` ### 单元格选择 启用 `cell_selectable(true)` 后: - 点击单元格可以直接选中 - 可以用方向键在单元格之间移动 - 可以监听双击和右键事件 - 可以程序化设置当前选中单元格 ```rust // 只有在选中单元格时才有值;选中行或列时返回 `None` if let Some((row_ix, col_ix)) = state.read(cx).selected_cell() { println!("Current cell: ({}, {})", row_ix, col_ix); } state.update(cx, |state, cx| { state.set_selected_cell(5, 3, cx); }); ``` ### 读取和写入选中状态 表格同一时刻只有一个选中状态:未选中、选中一行、选中一列或选中一个单元格。`selection()` 把它作为一个 `TableSelection` 值返回,`set_selection()` 则整体写回,保存和恢复选中状态只需一读一写: ```rust use gpui_component::table::TableSelection; match state.read(cx).selection() { TableSelection::None => {} TableSelection::Row(row_ix) => println!("Row {row_ix}"), TableSelection::Column(col_ix) => println!("Column {col_ix}"), TableSelection::Cell(row_ix, col_ix) => println!("Cell ({row_ix}, {col_ix})"), } state.update(cx, |state, cx| { // 滚动和事件与 `set_selected_cell(5, 3, cx)` 完全一致 state.set_selection(TableSelection::Cell(5, 3), cx); }); ``` `selected_row()`、`selected_col()`、`selected_cell()` 三个方法各自只回答自己那一类选中。`selected_row()` 只有在行本身被选中时才返回 `Some`,选中单元格不会通过它体现;选中一行之后,`selected_cell()` 也会变回 `None`。需要知道选中单元格所在的行时,从单元格映射: ```rust let row_ix = state.read(cx).selected_cell().map(|(row_ix, _)| row_ix); ``` 键盘导航会在模式切换后记住上一次的行列位置。选中一列后按 `Down`,会从之前选中的那一行继续移动,即使列模式下 `selected_row()` 返回的是 `None`。 ## 列宽调整与列移动 ```rust let state = cx.new(|cx| { TableState::new(delegate, window, cx) .col_resizable(true) .col_movable(true) .sortable(true) .col_selectable(true) .row_selectable(true) }); ``` 可以通过事件监听这些变化: ```rust cx.subscribe_in(&state, window, |view, table, event, _, cx| { match event { TableEvent::ColumnWidthsChanged(widths) => { save_column_widths(widths); } TableEvent::MoveColumn(from_ix, to_ix) => { save_column_order(from_ix, to_ix); } _ => {} } }).detach(); ``` ## 无限加载 如果你的数据来自分页接口或流式加载,可以在 delegate 中实现按需加载: ```rust impl TableDelegate for MyTableDelegate { fn has_more(&self, _: &App) -> bool { self.has_more_data } fn load_more_threshold(&self) -> usize { 50 } fn loading(&self, _: &App) -> bool { self.loading } } ``` ## 表格样式 `DataTable` 实现了 `Sizable`:可以用 `.small()`、`.large()` 等预设尺寸调整表格密度,也可以传入自定义像素值来设置统一的表头和表体行高。 ```rust use gpui_kit::px; use gpui_kit::component::Sizable as _; DataTable::new(&state) .with_size(px(48.)) .stripe(true) .bordered(true) .scrollbar_visible(true, true) ``` ## 键盘快捷键 ### 行选择模式 - `↑/↓` 在行之间移动 - `←/→` 在列之间移动 - `Home` / `End` 跳到首尾 - `PageUp/PageDown` 按页移动 - `Escape` 清除选中 ### 单元格选择模式 - `↑/↓` 在当前列中上下移动 - `←/→` 在当前行中左右移动 - `Tab` 移动到下一个单元格 - `Shift+Tab` 移动到上一个单元格 - `Escape` 清除选中 ## API 参考 ### 核心类型 - [DataTable] - 表格组件本体 - [TableState] - 表格状态管理 - [TableDelegate] - 数据源和渲染协议 - [Column] - 列定义 - [TableEvent] - 表格事件 - [TableSelection] - 当前选中状态的单一值:`None`、`Row`、`Column` 或 `Cell` ### 常见方法 #### TableState - `new(delegate, window, cx)` - `cell_selectable(bool)` - `row_selectable(bool)` - `col_selectable(bool)` - `selection()` - 以 `TableSelection` 返回当前选中状态 - `set_selection(selection, cx)` - 以 `TableSelection` 设置选中状态,`TableSelection::None` 表示清除 - `selected_cell()` - 仅在选中单元格时有值 - `set_selected_cell(row_ix, col_ix, cx)` - `selected_row()` - 仅在行本身被选中时有值 - `selected_col()` - 仅在列本身被选中时有值 - `clear_selection(cx)` - `scroll_to_row(row_ix, cx)` - `scroll_to_col(col_ix, cx)` #### Column - `new(key, name)` - `width(pixels)` - `sortable()` - `ascending()` - `descending()` - `text_right()` - `text_center()` - `fixed(ColumnFixed)` - `resizable(bool)` - `movable(bool)` - `selectable(bool)` [DataTable]: https://docs.rs/gpui-component/latest/gpui_component/table/struct.DataTable.html [TableState]: https://docs.rs/gpui-component/latest/gpui_component/table/struct.TableState.html [TableDelegate]: https://docs.rs/gpui-component/latest/gpui_component/table/trait.TableDelegate.html [Column]: https://docs.rs/gpui-component/latest/gpui_component/table/struct.Column.html [TableEvent]: https://docs.rs/gpui-component/latest/gpui_component/table/enum.TableEvent.html [TableSelection]: https://docs.rs/gpui-component/latest/gpui_component/table/enum.TableSelection.html [ColumnSort]: https://docs.rs/gpui-component/latest/gpui_component/table/enum.ColumnSort.html [ColumnFixed]: https://docs.rs/gpui-component/latest/gpui_component/table/enum.ColumnFixed.html --- # Tooltip Source: /versions/v0.7.1/zh-CN/component/tooltip 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/tooltip 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Tooltip 用于在鼠标悬停或元素获得焦点时显示补充信息。它支持纯文本、自定义内容、快捷键信息以及多种触发方式,适合做解释说明、状态提示和操作说明。 ## 移动端行为 iOS 和 Android 上会禁用由 GPUI Base overlay 管理的 tooltip。共享组件可以保留 tooltip 配置,但移动端操作仍应提供可见或可访问的标签。直接使用 GPUI `.tooltip()` 的调用(包括下方的基础 `div()` 示例)不经过此 overlay,因此不受该策略限制。集成方式请参阅[移动端](/versions/v0.7.1/zh-CN/docs/mobile)。 ## 导入 ```rust use gpui_kit::component::tooltip::Tooltip; ``` ## 用法 ### 纯文本 Tooltip ```rust div() .child("Hover me") .id("basic-tooltip") .tooltip(|window, cx| { Tooltip::new("This is a helpful tooltip").build(window, cx) }) ``` ### 按钮 Tooltip ```rust Button::new("save-btn") .label("Save") .tooltip("Save the current document") ``` ### 携带快捷键信息 ```rust actions!(my_actions, [SaveDocument]); Button::new("save-btn") .label("Save") .tooltip_with_action( "Save the current document", &SaveDocument, Some("MyContext") ) ``` ### 自定义内容 Tooltip ```rust div() .child("Hover for rich content") .id("rich-tooltip") .tooltip(|window, cx| { Tooltip::element(|_, cx| { h_flex() .gap_x_1() .child(IconName::Info) .child( div() .child("Muted Text") .text_color(cx.theme().muted_foreground) ) .child( div() .child("Danger Text") .text_color(cx.theme().danger) ) .child(IconName::ArrowUp) }) .build(window, cx) }) ``` ### 手动指定快捷键 ```rust div() .child("Custom keybinding") .id("custom-kb") .tooltip(|window, cx| { Tooltip::new("Delete item") .key_binding(Some(Kbd::new("Delete"))) .build(window, cx) }) ``` ## API 参考 ### Tooltip | 方法 | 说明 | | --- | --- | | `new(text)` | 创建文本型 Tooltip | | `element(builder)` | 创建自定义内容 Tooltip | | `action(action, context)` | 关联 action,显示对应快捷键信息 | | `key_binding(kbd)` | 手动设置快捷键展示 | | `build(window, cx)` | 构建并返回 Tooltip 视图 | ### 内置 Tooltip 方法 很多组件内置了 Tooltip 支持,常见形式包括: | 方法 | 说明 | | --- | --- | | `tooltip(text)` | 添加简单文本提示 | | `tooltip_with_action(text, action, context)` | 添加带快捷键的提示 | | `tooltip(closure)` | 使用构建器生成自定义提示 | ## 样式 Tooltip 默认会自动应用与主题匹配的样式: - 背景:`theme.popover` - 文字:`theme.popover_foreground` - 边框:`theme.border` - 阴影:中等投影 - 圆角:约 `6px` 也可以继续通过 `Styled` trait 自定义: ```rust Tooltip::new("Custom styled tooltip") .bg(cx.theme().accent) .text_color(cx.theme().accent_foreground) .build(window, cx) ``` ## 示例 ### 工具栏提示 ```rust h_flex() .gap_1() .child( Button::new("new") .icon(IconName::Plus) .tooltip_with_action("Create new file", &NewFile, Some("Editor")) ) .child( Button::new("save") .icon(IconName::Save) .tooltip_with_action("Save file", &SaveFile, Some("Editor")) ) ``` ### 表单说明 ```rust v_flex() .gap_4() .child( Input::new("email") .placeholder("Enter your email") .tooltip("We'll never share your email address") ) .child( Input::new("password") .input_type(InputType::Password) .placeholder("Password") .tooltip("Must be at least 8 characters with special characters") ) ``` ## 最佳实践 - 提示文案应简短直接,不重复界面上已经明显存在的信息。 - Tooltip 适合做补充说明,不应用来承载关键流程信息。 - 图标按钮、缩写和危险操作尤其适合配套 Tooltip。 - 需要高频触发的 Tooltip 应尽量避免复杂内容,以减少渲染开销。 --- # DropdownButton Source: /versions/v0.7.1/zh-CN/component/dropdown_button 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/dropdown_button 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 [DropdownButton] 是一个组合型按钮组件。点击左侧主按钮时可以执行独立动作,点击右侧触发按钮时则会展开下拉菜单。 共享变体和尺寸可以设置在 DropdownButton 上。文案、图标、提示、加载状态和点击回调等动作自身的选项属于内层 [Button]。 ## 导入 ```rust use gpui_kit::component::button::{Button, DropdownButton}; ``` ## 用法 ```rust use gpui_kit::Anchor; DropdownButton::new("dropdown") .button(Button::new("btn").label("Click Me")) .dropdown_menu(|menu, _, _| { menu.menu("Option 1", Box::new(MyAction)) .menu("Option 2", Box::new(MyAction)) .separator() .menu("Option 3", Box::new(MyAction)) }) ``` ### 变体 与 [Button] 一样,DropdownButton 支持不同视觉变体: ```rust DropdownButton::new("dropdown") .primary() .button(Button::new("btn").label("Primary")) .dropdown_menu(|menu, _, _| { menu.menu("Option 1", Box::new(MyAction)) }) ``` DropdownButton 上不设置变体或尺寸时,内层按钮的值会应用到两半。 ### 内层按钮选项 ```rust DropdownButton::new("dropdown") .button( Button::new("btn") .label("Save") .compact() .loading(is_saving) .tooltip("Save the current view") .on_click(|_, _, _| println!("Saved")), ) .dropdown_menu(|menu, _, _| { menu.menu("Save as…", Box::new(MyAction)) }) ``` ### 自定义锚点 ```rust DropdownButton::new("dropdown") .button(Button::new("btn").label("Click Me")) .dropdown_menu_with_anchor(Anchor::BottomRight, |menu, _, _| { menu.menu("Option 1", Box::new(MyAction)) }) ``` [Button]: https://docs.rs/gpui-component/latest/gpui_component/button/struct.Button.html [DropdownButton]: https://docs.rs/gpui-component/latest/gpui_component/button/struct.DropdownButton.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html --- # HoverCard Source: /versions/v0.7.1/zh-CN/component/hover-card 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/hover-card 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 HoverCard 用于在鼠标悬停到触发元素时显示富内容浮层,适合做用户资料预览、链接预览和上下文信息展示。它支持打开和关闭延迟,以减少鼠标快速移动时的闪烁问题。 它和 [Popover] 很像,但触发方式是 hover 而不是 click,并且提供了更细的时间控制。 iOS 和 Android 上点击触发元素即可打开或关闭卡片,点击外部关闭,点击内容区域保持打开。移动端不使用悬停延迟;Tooltip 提示仍保持禁用。参阅[移动端](/versions/v0.7.1/zh-CN/docs/mobile)。 ## 导入 ```rust use gpui_kit::component::hover_card::HoverCard; ``` ## 用法 ### 基础 HoverCard ```rust use gpui_kit::{ParentElement as _, Styled as _}; use gpui_kit::component::{hover_card::HoverCard, v_flex}; HoverCard::new("basic") .trigger( div() .child("Hover over me") .text_color(cx.theme().primary) .cursor_pointer() .text_sm() ) .child( v_flex() .gap_2() .child( div() .child("This is a hover card") .font_semibold() .text_sm() ) .child( div() .child("You can display rich content when hovering over a trigger element.") .text_color(cx.theme().muted_foreground) .text_sm() ) ) ``` ### 用户资料预览 这是一个很常见的场景,类似 GitHub 或 Twitter 的用户悬停预览: ```rust use gpui_kit::{px, relative, Styled as _}; use gpui_kit::component::{ avatar::Avatar, hover_card::HoverCard, h_flex, v_flex, }; h_flex() .child("Hover over ") .text_sm() .child( HoverCard::new("user-profile") .trigger( div() .child("@huacnlee") .cursor_pointer() .text_color(cx.theme().link) ) .child( h_flex() .w(px(320.)) .gap_4() .items_start() .child( Avatar::new() .src("https://avatars.githubusercontent.com/u/5518?s=64") ) .child( v_flex() .gap_1() .line_height(relative(1.)) .child(div().child("Jason Lee").font_semibold()) .child( div() .child("@huacnlee") .text_color(cx.theme().muted_foreground) .text_sm() ) .child("The author of GPUI Kit.") ) ) ) .child(" to see their profile") ``` ### 自定义时间控制 你可以按需调整打开和关闭延迟: ```rust use std::time::Duration; use gpui_kit::Styled as _; use gpui_kit::component::{ button::{Button, ButtonVariants as _}, h_flex, }; h_flex() .gap_4() .child( HoverCard::new("fast-open") .open_delay(Duration::from_millis(200)) .close_delay(Duration::from_millis(100)) .trigger(Button::new("fast").label("Fast Open (200ms)").outline()) .child(div().child("This hover card opens after 200ms").text_sm()) ) .child( HoverCard::new("slow-open") .open_delay(Duration::from_secs(1)) .close_delay(Duration::from_secs_f32(0.5)) .trigger(Button::new("slow").label("Slow Open (1000ms)").outline()) .child(div().child("This hover card opens after 1000ms").text_sm()) ) ``` ### 定位 HoverCard 支持通过 [Anchor] 设置 6 种定位: - TopLeft - TopCenter - TopRight - BottomLeft - BottomCenter - BottomRight 可以把卡片想象成有一个箭头尖角(像对话气泡的小三角)。anchor 指的就是这个尖角相对于触发器落在哪个点——`TopCenter` 把它放在触发器的顶部中间,`BottomRight` 放在右下角,以此类推。卡片就从这个点挂出来。 例如 `Anchor::TopLeft` 会让卡片出现在触发器正下方,并与其左对齐: ```text [ Trigger ] ┌──────────────┐ │ Hover Card │ └──────────────┘ ``` ### 使用动态内容构建器 对于较复杂的内容,可以使用 `content` builder,在 HoverCard 打开时再生成内容: ```rust HoverCard::new("complex") .trigger(Button::new("btn").label("Hover me")) .content(|state, window, cx| { v_flex() .child("Dynamic content") .child(format!("Open: {}", state.is_open())) }) ``` ### 样式 HoverCard 继承了 `Styled` trait 的所有方法: ```rust HoverCard::new("styled") .trigger(Button::new("btn").label("Styled")) .w(px(400.)) .max_h(px(500.)) .text_sm() .gap_2() .child("Styled content") ``` 关闭默认外观并自定义样式: ```rust HoverCard::new("custom-styled") .appearance(false) .trigger(Button::new("btn").label("Custom")) .bg(cx.theme().background) .border_2() .border_color(cx.theme().primary) .rounded(px(12.)) .p_4() .child("Custom styled content") ``` ## API 参考 ### HoverCard 方法 - `new(id: impl Into)`:创建一个新的 HoverCard - `trigger(trigger: T)`:设置触发元素 - `content(content: F)`:设置内容构建器 - `open_delay(duration: Duration)`:设置显示延迟,默认 600ms - `close_delay(duration: Duration)`:设置隐藏延迟,默认 300ms - `anchor(anchor: impl Into)`:设置定位,默认 `TopCenter` - `on_open_change(callback: F)`:打开状态变化回调 - `appearance(appearance: bool)`:是否启用默认样式,默认 `true` ### HoverCardState 方法 - `is_open() -> bool`:判断当前是否打开 ## 行为细节 ### Hover 时间控制 HoverCard 的时间控制主要解决悬停交互中的抖动问题: 1. **Open Delay**:防止鼠标快速扫过时意外打开 2. **Close Delay**:允许用户从 trigger 移动到内容区域时不立刻关闭 3. **Interactive Content**:只要鼠标在 trigger 或内容区域内,浮层就保持打开 ### 已处理的边界场景 - **快速划过**:因为有打开延迟,不会误触发 - **从触发器移动到内容**:浮层不会马上关闭 - **频繁悬停**:通过基于 epoch 的定时器机制做了去抖 - **多个 HoverCard 同时存在**:每个 HoverCard 都维护独立状态,互不影响 ## 最佳实践 1. 为不同场景设置合适的延迟。 2. HoverCard 适合预览信息,不适合承载完整流程。 3. 让触发器具备清晰的可悬停视觉反馈。 4. HoverCard 不支持键盘导航;如需键盘可达性,优先考虑 Popover。 5. 尽量避免嵌套 HoverCard,以免交互混乱。 ## 与 [Popover] 的区别 | 特性 | HoverCard | Popover | | ------------------------ | ---------------- | ------------------ | | 触发方式 | 鼠标悬停 | 点击或右键 | | 键盘导航 | 不支持 | 支持 | | 点击外部关闭 | 不适用 | 支持,可配置 | | 时间延迟 | 支持 | 不支持 | | 主要用途 | 预览信息 | 操作和表单 | [Popover]: ./popover.md [Anchor]: https://docs.rs/gpui-component/latest/gpui_component/enum.Anchor.html [Avatar]: ./avatar.md --- # Components Source: /versions/v0.7.1/zh-CN/component 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 ## 基础组件 - [Accordion](accordion) - 折叠内容面板 - [Alert](alert) - 多种变体的提示消息 - [Attachment](attachment) - 文件与媒体附件表面 - [Avatar](avatar) - 用户头像与回退文本 - [Badge](badge) - 徽标与数量指示器 - [Bubble](bubble) - 支持对齐与 reaction 的聊天消息表面 - [Button](button) - 支持多种样式的按钮 - [Checkbox](checkbox) - 二元选择控件 - [Icon](icon) - 图标展示组件 - [Image](image) - 带回退能力的图片展示 - [Marker](marker) - 会话状态与分隔标记 - [Message](message) - 可组合的聊天消息结构 - [MessageScroller](message-scroller) - 支持尾部跟随的虚拟消息列表 - [TextView](text-view) - Markdown 与 HTML 文本渲染 - [Tooltip](tooltip) - 悬浮提示 ## 表单组件 - [Input](input) - 单行文本输入与类输入控件 - [Textarea](textarea) - 支持固定行数或自动增高的多行文本输入 - [Editor](editor) - 支持语法高亮、行号和折叠的源代码编辑器 - [Select](select) - 选项选择器 - [Combobox](combobox) - 可搜索的单选或多选下拉组件 - [NumberInput](number-input) - 数字输入 - [DatePicker](date-picker) - 日期选择器 - [TimeField](time-field) - 分段时间输入 - [OtpInput](otp-input) - 一次性验证码输入 - [Speech](speech) - 通过系统或自定义识别器进行语音输入 - [ColorPicker](color-picker) - 颜色选择器 - [Questionnaire](questionnaire) - 可组合的多步骤问卷与答案 - [Form](form) - 表单容器与布局 ## 布局与高级组件 - [Root](root) - 窗口级的主题、对话框与通知的根提供者 - [Theme](theme) - 定制颜色、字体与明暗外观 - [Command](command) - 用于搜索与快捷操作的命令面板 - [Dialog](dialog) - 对话框与模态窗口 - [Popover](popover) - 浮层内容 - [Resizable](resizable) - 可调整大小的面板 - [Scrollable](scrollable) - 可滚动容器 - [Sidebar](sidebar) - 侧边栏导航 - [StatusBar](status-bar) - 底部状态栏,含左/中/右三区 - [Toolbar](toolbar) - 顶部工具栏,含左/右区域与尺寸 - [Chart](chart) - 图表组件 - [Carousel](carousel) - 浏览一组相关内容 - [DataTable](data-table) - 高性能数据表格 - [Dock](dock) - 支持标签、分割与状态持久化的生产级 Dock 布局 - [Tree](tree) - 树形结构组件 - [VirtualList](virtual-list) - 大数据量虚拟列表 --- # Input Group Source: /versions/v0.7.1/zh-CN/component/input-group 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/input-group 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 使用 `InputGroup` 可以在输入框或文本域周围添加文本、图标、按钮和工具栏, 并将它们放在同一个外框内。简单的前缀或后缀可以使用 [Input](/versions/v0.7.1/zh-CN/component/input)。 下面的示例定义了可用于 GPUI Kit 应用的视图。应用初始化方式见 [快速开始](/versions/v0.7.1/zh-CN/docs/getting-started)。 ## 带清空按钮的输入框 在视图中创建一次 `InputState`,再将它传给 `InputGroupInput`。 订阅 `InputEvent::Change`,更新依赖输入内容的界面。 将返回的 `Subscription` 保存在视图中,使回调持续有效。 下面的视图会显示字符数,并提供清空输入的按钮: ```rust use gpui_kit::{ AppContext as _, ClickEvent, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _, Subscription, Window, rems, }; use gpui_kit::assets::IconName; use gpui_kit::component::{ Disableable as _, Icon, input::{ InputEvent, InputGroup, InputGroupAddon, InputGroupAddonAlignment, InputGroupButton, InputGroupInput, InputGroupText, InputState, }, }; struct SearchField { query: Entity, _change: Subscription, } impl SearchField { fn new(window: &mut Window, cx: &mut Context) -> Self { let query = cx.new(|cx| InputState::new(window, cx).placeholder("搜索…")); let change = cx.subscribe(&query, |_, _, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { cx.notify(); } }); Self { query, _change: change } } fn clear(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { self.query.update(cx, |state, cx| { state.set_value("", window, cx); state.focus(window, cx); }); cx.notify(); } } impl Render for SearchField { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let count = self.query.read(cx).value().chars().count(); InputGroup::new("search") .max_w(rems(24.)) .input(InputGroupInput::new(&self.query).aria_label("搜索")) .addon(InputGroupAddon::new("search-icon") .child(Icon::new(IconName::Search).size_4())) .addon(InputGroupAddon::new("search-actions") .align(InputGroupAddonAlignment::InlineEnd) .child(InputGroupText::new().child(format!("{count} 个字符"))) .child(InputGroupButton::new("clear").label("清空") .disabled(count == 0) .on_click(cx.listener(Self::clear)))) } } ``` 通过同一个状态读取或设置输入内容: ```rust let value = self.query.read(cx).value(); self.query.update(cx, |state, cx| { state.set_value("gpui", window, cx); }); cx.notify(); ``` `InputEvent::Change` 用于响应用户编辑。通过 `set_value` 设置内容不会触发该事件; 程序更新输入值后,如果视图中的其他内容也需要刷新,请调用 `cx.notify()`。 ## 部件与对齐 | 部件 | 用途 | | --- | --- | | `InputGroup` | 将一个输入控件与多个附加区域组合使用 | | `InputGroupInput` | 放入组合中的 [Input](/versions/v0.7.1/zh-CN/component/input),使用 `InputState` | | `InputGroupTextarea` | 放入组合中的 [Textarea](/versions/v0.7.1/zh-CN/component/textarea),使用 `TextareaState` | | `InputGroupAddon` | 放置文本、图标、按钮或自定义内容 | | `InputGroupButton` | 带有紧凑组合样式的 [Button](/versions/v0.7.1/zh-CN/component/button) | | `InputGroupText` | 显示辅助文字、前后缀或计数 | `InputGroupInput` 和 `InputGroupTextarea` 就是普通的 `Input` 和 `Textarea`,只是以组合中的名字出现, 所以这两个控件的全部 builder——`aria_label`、`content_type`、`on_paste`、`cleanable`、`mask_toggle` 以及 `Styled` 方法——在组合里都可以使用。组合会去掉控件自身的边框、背景和焦点环,改为绘制在整个外框上。 用 `.input(...)` 设置输入控件,用 `.addon(...)` 添加附加区域, 在附加区域中通过 `.child(...)` 或 `.children(...)` 放置内容。 再次调用 `.input(...)` 会替换之前的输入控件;多次调用 `.addon(...)` 会保留所有附加区域。 通过 `.align(InputGroupAddonAlignment::...)` 设置位置: | 对齐方式 | 位置 | | --- | --- | | `InlineStart`(默认) | 输入区域前侧 | | `InlineEnd` | 输入区域后侧 | | `BlockStart` | 输入区域所在行上方 | | `BlockEnd` | 输入区域所在行下方 | 四种位置可以组合使用。同侧的附加区域及其内部内容按添加顺序排列。 为各部件设置稳定且不同的 ID。点击附加区域中的文本、图标或留白会聚焦输入框。 例如,为单行输入框添加协议前缀和域名后缀: ```rust InputGroup::new("website") .input(InputGroupInput::new(&self.query).aria_label("网站")) .addon(InputGroupAddon::new("protocol") .child(InputGroupText::new().child("https://"))) .addon(InputGroupAddon::new("domain") .align(InputGroupAddonAlignment::InlineEnd) .child(InputGroupText::new().child(".com"))) ``` ## 按钮、图标与菜单 用 `.label(...)` 设置按钮文字,用 `.icon(...)` 设置图标。 纯图标按钮需要提供 `.accessibility_label(...)`,也可以通过 `.tooltip(...)` 添加提示。 ```rust InputGroupButton::new("clear-icon") .icon(IconName::X) .accessibility_label("清空搜索") .tooltip("清空搜索") .on_click(cx.listener(Self::clear)) ``` 按钮和其它控件一样通过 `Sizable` 设置尺寸:`.xsmall()` 是默认的紧凑尺寸,`.small()` 稍大; 只有图标的按钮在这两个尺寸下都是正方形。`.medium()` 和 `.large()` 保留标准按钮尺寸, 适合放在 block 附加区域里的主要操作。 按钮默认使用 ghost 样式。导入 `button::ButtonVariants` 后,可以使用 `.primary()`、`.secondary()` 或 `.danger()`。 用 `.outline()` 添加描边,`.disabled(true)` 禁用操作, `.loading(true)` 显示进度并防止重复点击。点击按钮后,焦点不会被自动移回输入框。 操作菜单可以通过 `.dropdown_menu(...)` 配置,具体用法见 [Menu](/versions/v0.7.1/zh-CN/component/menu); `.dropdown_caret(true)` 会在文字右侧绘制下拉箭头。 上下文帮助可以将 `InputGroupButton` 传给 [Popover](/versions/v0.7.1/zh-CN/component/popover) 的 `.trigger(...)`, 再把 Popover 放入附加区域。 ## 带字数统计和提交操作的 Textarea 将 `TextareaState` 传给 `InputGroupTextarea`。 `.auto_grow(min, max)` 使输入区域在指定行数范围内增高,超过最大行数后滚动显示。 固定行数使用 `.rows(n)`,固定高度使用 `InputGroupTextarea::h(...)`。 下面的完整视图会统计字符数,在内容为空或超出限制时禁用提交按钮, 并在编辑框下方显示提交的文本。提交后会清空内容,并将焦点放回文本域。 ```rust use gpui_kit::{ AppContext as _, ClickEvent, Context, Entity, IntoElement, ParentElement as _, Render, SharedString, Styled as _, Subscription, Window, rems, }; use gpui_kit::component::{ Disableable as _, button::ButtonVariants as _, v_flex, input::{ InputEvent, InputGroup, InputGroupAddon, InputGroupAddonAlignment, InputGroupButton, InputGroupText, InputGroupTextarea, TextareaState, }, }; struct MessageComposer { message: Entity, submitted: Option, _change: Subscription, } impl MessageComposer { fn new(window: &mut Window, cx: &mut Context) -> Self { let message = cx.new(|cx| { TextareaState::new(window, cx) .placeholder("输入消息…") .auto_grow(2, 6) }); let change = cx.subscribe(&message, |_, _, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { cx.notify(); } }); Self { message, submitted: None, _change: change } } fn submit(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { let value = self.message.read(cx).value(); if value.trim().is_empty() || value.chars().count() > 280 { return; } self.submitted = Some(value); self.message.update(cx, |state, cx| { state.set_value("", window, cx); state.focus(window, cx); }); cx.notify(); } } impl Render for MessageComposer { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let value = self.message.read(cx).value(); let count = value.chars().count(); v_flex().max_w(rems(28.)).gap_2() .child(InputGroup::new("message") .invalid(count > 280) .input(InputGroupTextarea::new(&self.message).aria_label("消息")) .addon(InputGroupAddon::new("message-footer") .align(InputGroupAddonAlignment::BlockEnd) .child(InputGroupText::new().child(format!("{count}/280"))) .child(InputGroupButton::new("submit").ml_auto().primary().label("提交") .disabled(value.trim().is_empty() || count > 280) .on_click(cx.listener(Self::submit))))) .children(self.submitted.as_ref().map(|text| format!("已提交:{text}"))) } } ``` 标题或上方工具栏可以使用 `BlockStart`。文本滚动时,附加区域保持原位。 更多文本设置见 [Textarea](/versions/v0.7.1/zh-CN/component/textarea)。 ## 禁用、只读与校验 | 方法 | 效果 | | --- | --- | | `.disabled(true)` | 禁用输入区域及直接添加的 `InputGroupButton` 子部件 | | `.readonly(true)` | 禁止编辑,同时允许聚焦、选择、复制和附加操作 | | `.invalid(true)` | 显示错误状态,允许继续编辑 | 输入部件设置 `.disabled(true)` 时,整个组合也会禁用。 自定义交互内容和经过包装的控件需要分别传入禁用状态。 根据校验结果设置 `.invalid(...)`,并在旁边显示错误说明。 需要拒绝特定编辑内容时,使用 [`InputState::validate`](/versions/v0.7.1/zh-CN/component/input)。 即使为整个组合设置了名称,也应为输入控件单独提供 `.aria_label(...)`。 在 `InputGroupInput` 上通过 `.content_type(...)` 设置 URL、邮箱等输入提示, 通过 `InputState::masked` 配置密码遮罩。两个输入部件都支持用 `.context_menu(...)` 自定义右键菜单。 在触屏设备上,长按文本可以选择单词,拖动选择手柄可以调整范围, 编辑菜单提供剪切、复制、粘贴和全选操作。 在 Rust 中,两个输入部件还支持 `.on_paste(...)`,可在插入文本前处理剪贴板中的图片和文件。 返回 `true` 表示已处理此次粘贴,返回 `false` 则继续默认的文本插入。 输入控件处于禁用或只读状态时,不会调用该回调。 附件处理示例及 Web 端限制见 [粘贴回调](/versions/v0.7.1/zh-CN/component/input#粘贴钩子)。 ## 尺寸与样式 组合的默认尺寸为 Medium。导入 `Sizable` 后,可以使用 `.xsmall()`、`.small()`、 `.large()` 或 `.with_size(Size::Medium)`;尺寸决定外框高度、文字大小,以及附加区域与控件共用的内边距。 颜色、圆角、焦点环和错误外环遵循当前 [Theme](/versions/v0.7.1/zh-CN/component/theme),宽度、间距等外观可通过 `Styled` 方法调整。 控件本身的 `Styled` 方法作用于它编辑的文字,附加区域、按钮和文字部件也各自用同样的方式设置样式: ```rust use gpui_kit::component::{ActiveTheme as _, Sizable as _, StyledExt as _}; InputGroup::new("styled-search") .small() .max_w(rems(24.)) .input(InputGroupInput::new(&self.query) .aria_label("搜索") .px_3() .text_base()) .addon(InputGroupAddon::new("styled-actions") .align(InputGroupAddonAlignment::InlineEnd) .child(InputGroupButton::new("styled-clear").label("清空").icon(IconName::X) .font_semibold() .on_click(cx.listener(Self::clear)))) ``` 占位提示、光标和选区颜色遵循 Theme。导入 `FocusableExt` 后,可以用 `.focus_ring(false)` 隐藏默认外环。 ## JavaScript 从 `gpui-component` 导入同名部件,在 `View.init` 中创建输入状态。 使用 `.value(...)` 和 `.on_change(...)` 控制输入值: ```javascript import { View } from "gpui-kit"; import { InputState, InputGroup, InputGroupInput, InputGroupAddon, InputGroupButton, } from "gpui-component"; export default class Search extends View { init() { this.input = InputState("搜索…"); this.query = ""; } render() { return new InputGroup("search") .input(new InputGroupInput(this.input) .aria_label("搜索").value(this.query) .on_change((value, cx) => { this.query = value; cx.notify(); })) .addon(new InputGroupAddon("actions").align("inline-end") .child(new InputGroupButton("clear").label("清空") .disabled(this.query.length === 0) .on_click((_event, cx) => { this.query = ""; cx.notify(); }))); } } ``` 程序通过 `.value(...)` 更新内容不会触发 `on_change`,设置相同的值会保留选区和撤销历史。 省略 `.value(...)` 可以让输入控件自行保存内容,需要响应编辑时使用 `on_change(value, cx)`。 `InputGroupTextarea` 接收 `TextareaState`,支持 `.rows(n)` 和 `.auto_grow(min, max)`。 两个输入部件均支持 `.placeholder(...)`。 `InputGroupInput` 还支持 `.masked(bool)` 和 `.content_type(...)`, 后者可使用 `email_address`、`url`、`new_password` 等值。 组合和按钮的尺寸都通过 `.size("small")` 设置,可用值为 `xsmall`、`small`、`medium` 和 `large`。 按钮图标使用资源路径,例如 `.icon("icons/search.svg")`。样式方法和 Rust 一样直接作用于各个部件: ```javascript new InputGroupInput(this.input).px(12).text_base(); new InputGroupButton("clear").label("清空").icon("icons/x.svg").font_semibold(); ``` 执行 `gpui-component-shell types <应用目录>` 可生成编辑器补全声明。 ## 行内引用 需要将行内引用与附件、发送按钮等组合时,将包含 token 的 Input 或 Textarea 传给 `InputGroup`,并按需自定义标签: ```rust use gpui_kit::component::{ IconName, input::{InputToken, InputGroup, Textarea}, }; InputGroup::new("composer") .input(Textarea::new(&state) .token(|token, _, _| InputToken::new(token).icon(IconName::File))) ``` JavaScript 的分组输入也提供 `token` 和 `on_token_click`。重绘时保留原输入状态,需要恢复草稿时用保存的 content 调用 `set_value`。详见[原子行内 token](/versions/v0.7.1/zh-CN/component/input#原子行内-token)。 --- # Carousel Source: /versions/v0.7.1/zh-CN/component/carousel 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/carousel 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Carousel 在可吸附的 viewport 中展示一个或多个相关 item,支持横向和纵向布局、键盘导航、指针与触控板手势、循环以及受控选中项。 ## 引入 ```rust use gpui_kit::Axis; use gpui_kit::component::carousel::{ Carousel, CarouselContent, CarouselEvent, CarouselItem, CarouselNext, CarouselPagination, CarouselPaginationItem, CarouselPrevious, CarouselState, }; ``` ## 使用 为内容创建一个 `CarouselState`,并将它传给所有 Carousel 部件。 ```rust let state = cx.new(|_| CarouselState::new(3)); Carousel::new("projects-carousel", &state) .child( CarouselContent::new(&state) .child(CarouselItem::new("project-1", 0, &state).child("项目一")) .child(CarouselItem::new("project-2", 1, &state).child("项目二")) .child(CarouselItem::new("project-3", 2, &state).child("项目三")), ) .child(CarouselPrevious::new(&state)) .child(CarouselNext::new(&state)) ``` `CarouselContent` 管理 viewport 与吸附布局,`CarouselItem` 标识一个逻辑 slide。到达对应边界时,上一项和下一项按钮会自动禁用。 state 的 item 数量应与直接 `CarouselItem` 子元素的数量一致。一个 state 及其 scroll handle 只服务一个 viewport。 ## 组合结构 Carousel 由一个内容 viewport、其中的 item 和可选控制按钮组成: ```text Carousel ├── CarouselContent │ ├── CarouselItem │ └── CarouselItem ├── CarouselPrevious └── CarouselNext ``` 可以在 `Carousel` 根节点上使用 `.w_full().max_w_96()` 约束整个 Carousel;需要单独设置 viewport 的宽度或高度时,可以直接设置 `CarouselContent` 的样式。`track_style` 仅用于间距等内部 track 调整。根节点会把常规子元素按列排布并留出 16px 间距,因此放在内容后面的 `CarouselPagination` 会自然与内容拉开;需要其他排布时直接在根节点上覆盖样式。 ## 每屏多个 item `CarouselItem` 实现了 `Styled`。设置 flex basis 可以在 viewport 中同时显示多个 item;再通过 `CarouselContent::track_style` 设置负的起始 margin,并为每个 item 设置数值相同的起始 padding,即可调整它们之间的间距。这与 shadcn/ui 采用的成对间距模型一致。 ```rust use gpui_kit::{ParentElement as _, StyleRefinement, Styled as _, relative}; let state = cx.new(|_| CarouselState::new(6)); CarouselContent::new(&state) .track_style(StyleRefinement::default().ml_neg_1()) .children((0..6).map(|index| { CarouselItem::new(("project", index), index, &state) .flex_basis(relative(1. / 3.)) .pl_1() .child(format!("项目 {}", index + 1)) })) ``` flex basis 控制的是 item 几何尺寸,与按钮等控件使用的语义 `Size` 相互独立。 横向 Carousel 默认在 content track 上使用 `.ml_neg_4()`,在 item 上使用 `.pl_4()`;纵向 Carousel 使用对应的 `.mt_neg_4()` 与 `.pt_4()`。覆盖间距时应同步修改两侧,并使用相同的 spacing scale,这样首个 item 会继续与 viewport 对齐,同时改变可见间距。 ## 方向 创建 state 时使用 `with_axis`: ```rust let state = cx.new(|_| { CarouselState::new(3).with_axis(Axis::Vertical) }); ``` 横向 Carousel 使用 Left 和 Right,纵向 Carousel 使用 Up 和 Down。 纵向 `CarouselContent` 需要设置明确的高度,让每个全高 item 都有可供吸附的 viewport。 Carousel 根节点可通过 Tab 获得焦点,因此省略可选控制按钮时仍可使用键盘导航。Home 和 End 用于选择第一项和最后一项。点击 Carousel 内部或它的控制按钮同样会让它获得焦点以便键盘导航,但不会显示焦点环;焦点环只在通过键盘聚焦时出现。 ## 循环 启用循环后,从最后一项继续向后会回到第一项: ```rust let state = cx.new(|_| CarouselState::new(5).with_looping(true)); ``` ## 受控选中项 应用可以控制 `CarouselState`。使用 `with_selected_index` 设置初始选中项,使用 `set_selected_index` 进行程序化切换。 ```rust let state = cx.new(|_| CarouselState::new(4).with_selected_index(1)); state.update(cx, |state, cx| { state.set_selected_index(3, cx); }); ``` 如果应用需要同步当前 slide,可以监听 `CarouselEvent::Change`: ```rust cx.subscribe(&state, |this, _, event: &CarouselEvent, cx| { let CarouselEvent::Change(index) = event; this.selected_index = *index; cx.notify(); }); ``` ## 事件 | 事件 | 说明 | | --- | --- | | `CarouselEvent::Change(index)` | 用户导航选中新的内容时触发。 | 键盘导航和上一项/下一项按钮使用同一套 state 状态转换,并触发相同事件。指针和触控板手势结束时,会吸附到最近的 snap 点。鼠标滚轮每格移动一项;在边界处开始的手势会交给外层容器滚动。 ## 分页指示器 分页是可选部件,不会固定一种视觉样式。使用 `CarouselPaginationItem` 组合指示器,再按需要设置每一项的样式或内容: ```rust CarouselPagination::new().children((0..3).map(|index| { CarouselPaginationItem::new(("project-page", index), index, &state) .child((index + 1).to_string()) })) ``` `CarouselPaginationItem` 与指针、键盘和上一项/下一项导航使用同一套 selection 状态转换。 ## 控件尺寸 `CarouselPrevious`、`CarouselNext` 和 `CarouselPaginationItem` 实现了 `Sizable`。需要让这些控件同步缩放时,为它们设置相同的语义尺寸: ```rust use gpui_kit::component::{Sizable as _, Size}; CarouselPrevious::new(&state).with_size(Size::Large); CarouselNext::new(&state).with_size(Size::Large); ``` 上一项和下一项控件默认使用 `Size::Medium`,分页项默认使用 `Size::XSmall`。 ## 自定义控制按钮 `CarouselPrevious` 和 `CarouselNext` 实现了 `ParentElement` 与 `Styled`。没有子元素时,它们会根据方向显示对应的箭头;添加子元素后,可以替换可见内容,同时保留自动导航和边界禁用状态。`accessibility_label` 也会同步替换控件的 tooltip。 ```rust use gpui_kit::ParentElement as _; CarouselPrevious::new(&state) .accessibility_label("上一个项目") .child("返回"); CarouselNext::new(&state) .accessibility_label("下一个项目") .child("继续"); ``` 需要完全自定义控制按钮时,可以省略对应的 Carousel 部件,并使用公开 state API 组合任意控件: ```rust use gpui_kit::ParentElement as _; use gpui_kit::component::{Disableable as _, button::Button}; let previous_state = state.clone(); let previous_disabled = !state.read(cx).has_previous(); Button::new("projects-previous") .label("返回") .disabled(previous_disabled) .on_click(move |_, _, cx| { previous_state.update(cx, |state, cx| { state.select_previous(cx); }); }) ``` ## 无障碍 Carousel 会提供带 label 的区域,每个 item 会报告自己在内容集合中的位置。当默认的“轮播”无法准确描述内容时,使用 `accessibility_label` 设置更明确的名称。 Carousel 动画会遵循应用的减少动效设置。 --- # Chart Source: /versions/v0.7.1/zh-CN/component/chart 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/chart 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Chart 是一组完整的数据可视化组件,提供 Line、Bar、Area、Pie、Radar、Candlestick 和 Sankey 图表。它们支持动画、自定义样式、主题配色和多种展示方式,适合仪表盘、统计分析和行情场景。 ## 导入 ```rust use gpui_kit::component::chart::{ LineChart, BarChart, AreaChart, PieChart, RadarChart, CandlestickChart, SankeyChart, }; ``` ## 图表类型 ### LineChart 折线图用于展示随时间变化的趋势。 #### 基础折线图 ```rust #[derive(Clone)] struct DataPoint { x: String, y: f64, } let data = vec![ DataPoint { x: "Jan".to_string(), y: 100.0 }, DataPoint { x: "Feb".to_string(), y: 150.0 }, DataPoint { x: "Mar".to_string(), y: 120.0 }, ]; LineChart::new(data) .x(|d| d.x.clone()) .y(|d| d.y) ``` #### 折线图变体 ```rust LineChart::new(data) .x(|d| d.month.clone()) .y(|d| d.value) LineChart::new(data) .x(|d| d.month.clone()) .y(|d| d.value) .linear() LineChart::new(data) .x(|d| d.month.clone()) .y(|d| d.value) .step_after() LineChart::new(data) .x(|d| d.month.clone()) .y(|d| d.value) .dot() LineChart::new(data) .x(|d| d.month.clone()) .y(|d| d.value) .stroke(cx.theme().success) ``` #### 刻度控制 ```rust LineChart::new(data) .x(|d| d.month.clone()) .y(|d| d.value) .tick_margin(1) LineChart::new(data) .x(|d| d.month.clone()) .y(|d| d.value) .tick_margin(2) ``` `LineChart` 同样支持 `y_domain` 和 `point_count`,用法见 AreaChart 下的「固定 Y 轴与未完成的序列」。 #### 坐标轴与辅助线 以下选项 `LineChart` 和 `AreaChart` 通用。`y_axis` 在 Y 轴刻度处显示标签,默认放在绘图区左侧的标签栏里,标签栏会按最宽的标签自动加宽,用 `y_axis_label_placement(AxisLabelPlacement::Inside)` 可以改为叠在绘图区内。`y_tick_count` 设置刻度数,刻度从基线到顶边均匀分布,两端都算在内;横向网格线也画在这些刻度上,每个标签显示比例尺在该高度对应的数值。默认的 5 个刻度就是图表一直以来的网格。`y_tick_format` 根据这个数值生成标签文字。 `x_tick_count` 只给这么多个 X 值标注,从第一个到最后一个均匀挑选,不再按 `tick_margin` 每隔几个标一个;设置了 `point_count` 时按轴上的全部点位挑选,数据增长时标签位置不变。`grid_columns` 增加纵向网格线,`grid_dashed(false)` 把网格改为实线,`reference_line` 在某个数值处画一条贯穿绘图区的虚线,颜色比网格深,`y_padding` 设置最大值上方和最小值下方保留的空白,默认上方 10px、下方 0。 ```rust use gpui_kit::component::plot::AxisLabelPlacement; // 分时图:标签叠在图内、实线网格、标出昨收 AreaChart::new(minutes) .x(|d| d.time.clone()) .y(|d| d.price) .y_domain(low, high) .y_axis(true) .y_axis_label_placement(AxisLabelPlacement::Inside) .y_tick_count(3) .y_tick_format(|v| format!("{v:.2}")) .x_tick_count(3) .grid_columns(4) .grid_dashed(false) .reference_line(prev_close) .y_padding(6., 6.) ``` ### BarChart 柱状图通过矩形条形对比不同类别的数据,并可通过 `alignment` 选项切换垂直或水平方向。 #### 基础柱状图 ```rust BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) ``` #### 自定义柱状图 ```rust // 自定义填充颜色 // // `fill` 闭包接收四个参数:数据项、柱子的像素边界(相对于图表原点)、 // 图表的像素边界,以及柱子的 `BarAlignment`。返回值可以是任何能转换为 // `Background` 的类型(纯色、渐变、图案等)。 BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .fill(|d, _bar_bounds, _chart_bounds, _alignment| d.color) // 显示数值标签 BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .label(|d| format!("{}", d.value)) // 自定义刻度间距 BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .tick_margin(2) // 隐藏分类轴的轴线和标签 BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .label_axis(false) ``` #### 柱状图渐变填充 如需让渐变方向跟随柱子方向,请使用 `fill_gradient`。闭包接收三个参数:数据项、图表的完整数据范围(`chart_range`),以及一个 `chart_to_bar` 辅助函数(将图表数值坐标映射为柱子局部的渐变位置,其中 `0.0` 表示柱子的基线端,`1.0` 表示尖端)。渐变方向由柱子的 `BarAlignment` 推导,使 stop-0 始终位于基线端、stop-1 位于尖端。 ```rust use gpui_kit::linear_color_stop; // 单柱渐变:每个柱子都从半透明基线渐变到完全不透明的尖端, // 与该柱子的具体数值无关。 BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .fill_gradient(|d, _chart_range, _chart_to_bar| { let c = d.color; [ linear_color_stop(c.opacity(0.3), 0.0), linear_color_stop(c, 1.0), ] }) // 跨图表渐变:每根柱子展示同一条覆盖整个图表数值范围的渐变中 // 对应自身值域的那一段。超出 `[0, 1]` 的 stop 会被裁剪到柱子内, // 颜色会在裁剪点处插值,使整体效果保持连续。 BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .fill_gradient(|d, chart_range, chart_to_bar| { let c = d.color; [ linear_color_stop(c.opacity(0.3), chart_to_bar(*chart_range.start())), linear_color_stop(c, chart_to_bar(*chart_range.end())), ] }) ``` `fill` 与 `fill_gradient` 互斥——设置其中一个会清空另一个。 #### 柱状图对齐方式 `BarAlignment` 用于控制柱子的方向以及基线所在的一侧,需从 `gpui_kit::component::plot::shape` 导入。 ```rust use gpui_kit::component::plot::shape::BarAlignment; // 默认:垂直方向 - 向上 BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .alignment(BarAlignment::Bottom) // 垂直方向 - 向下 BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .alignment(BarAlignment::Top) // 水平方向 - 向右 BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .alignment(BarAlignment::Left) // 水平方向 - 向左 BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .alignment(BarAlignment::Right) ``` #### 柱状图圆角 为柱状条形设置圆角。可传入任意可转换为 `Corners` 的值—— 使用单个 `px(..)` 表示四角统一圆角,或手动构造 `Corners` 仅对特定角进行圆角处理(例如仅对柱顶一端进行圆角)。 ```rust use gpui_kit::{px, Corners}; // 所有柱条统一 4px 圆角 BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .corner_radii(px(4.)) // 仅顶部圆角(适用于底部对齐柱状图的柱顶一端) BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .corner_radii(Corners { top_left: px(4.), top_right: px(4.), bottom_left: px(0.), bottom_right: px(0.), }) ``` #### 柱状图负值 柱条从零点而非绘图区边缘开始生长,因此负值会向零线的另一侧延伸。 分类轴线跟随零点位置,每个分类标签也会移动到自身柱条未占用的那一侧。 无需任何配置——数据中包含负值时即以此方式渲染。 ```rust // `growth` 可为负值;零线以下的柱条向下绘制 BarChart::new(data) .band(|d| d.quarter.clone()) .value(|d| d.growth) .label(|d| format!("{:+.0}%", d.growth)) ``` #### 柱状图数值轴 使用 `value_axis` 显示数值刻度标签,并通过 `value_tick_count` 设置数值轴上的刻度数。 刻度从基线到远端均匀分布,两端都算在内,网格线和刻度标签都由它决定,两者始终一致。 `tick_margin` 则是分类轴上的步长:`tick_margin(2)` 表示每隔一个分类保留一个标签。 ```rust // 纵向柱状图的数值标签位于左侧,横向柱状图位于下方 BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .value_axis(true) // 7 个刻度(默认为 5 个) BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .value_axis(true) .value_tick_count(7) ``` `value_axis_label_placement(AxisLabelPlacement::Inside)` 把标签叠在绘图区内、紧挨各自的网格线,柱子不必让出标签栏的空间;`value_tick_format` 生成标签文字。`band_count` 让分类轴按比数据更多的格位数铺开,数据较少时每根柱保持原有宽度,只占前面几格。`band_tick_count` 只给这么多个分类标注,从第一个到最后一个均匀挑选(设置了 `band_count` 时按全部格位挑选);`grid_dashed(false)` 把网格改为实线。 ```rust use gpui_kit::component::plot::AxisLabelPlacement; // 最近 20 天每天一个值,不管目前有几天数据 BarChart::new(days) .band(|d| d.date.clone()) .value(|d| d.value) .value_axis(true) .value_axis_label_placement(AxisLabelPlacement::Inside) .value_tick_count(2) .value_tick_format(|v| format!("{v:.2}")) .band_count(20) .band_tick_count(2) .grid_dashed(false) ``` #### 柱状图标签与间距 `label_color` 给每根柱的 `label` 文字单独配色,数值可以跟随柱子的颜色,不必统一用前景色。`padding_inner` 和 `padding_outer` 分别设置柱子之间、首尾两端的间距,以占一个分类宽度的比例计,默认是 0.4 和 0.2。`min_length` 让每根柱子至少画这么多像素长,数量为 0 的分档也能在基线上留一截柱桩。 ```rust // 分布图:细柱、数值跟随柱色、0 值留柱桩 BarChart::new(buckets) .band(|d| d.range.clone()) .value(|d| d.count) .fill(|d, _, _, _| d.color) .label(|d| d.count.to_string()) .label_color(|d| d.color) .padding_inner(0.6) .min_length(2.) ``` 柱桩朝柱子本该生长的方向延伸:从零线向外,负值朝负方向,零值朝正方向。带 `label` 的纵向柱状图会在最高的柱子上方留出一行文字的高度,保证它的标签不超出图表。 ### AreaChart 面积图类似折线图,但会填充曲线下方的区域。 #### 基础面积图 ```rust AreaChart::new(data) .x(|d| d.time.clone()) .y(|d| d.value) ``` #### 多系列面积图 ```rust AreaChart::new(data) .x(|d| d.date.clone()) .y(|d| d.desktop) .stroke(cx.theme().chart_1) .fill(cx.theme().chart_1.opacity(0.4)) .y(|d| d.mobile) .stroke(cx.theme().chart_2) .fill(cx.theme().chart_2.opacity(0.4)) ``` #### 样式 ```rust use gpui_kit::{linear_gradient, linear_color_stop}; AreaChart::new(data) .x(|d| d.month.clone()) .y(|d| d.value) .fill(linear_gradient( 0., linear_color_stop(cx.theme().chart_1.opacity(0.4), 1.), linear_color_stop(cx.theme().background.opacity(0.3), 0.), )) AreaChart::new(data) .x(|d| d.month.clone()) .y(|d| d.value) .linear() ``` #### 固定 Y 轴与未完成的序列 Y 轴默认从 0 开始拟合数据。`y_domain` 把它固定在给定区间,价格、资产这类离 0 很远的数值就不会被压成顶部的一条线。`point_count` 让 X 轴按比数据更多的点数排布,尚未完成的序列(比如当天的分时)只占前面一段。`LineChart` 同样支持这两个方法。 ```rust // 分时缩略图:美股一个交易日 390 个分钟点位。 AreaChart::new(minutes) .x(|d| d.time.clone()) .y(|d| d.price) .linear() .y_domain(low, high) .point_count(390) .x_axis(false) .grid(false) .interactive(false) ``` 固定区间和默认一样,在最大值上方留出 10px;序列会被裁剪在绘图区内,超出区间的值止于边缘。`min` 与 `max` 相等时什么都不画,数值全相同的序列需要先自行放宽区间。平滑曲线(natural)会在最高点和最低点附近冲过头,区间贴着数据取值时建议用 `linear`。 第 i 条数据固定落在第 i 个点位,所以数据必须从第一个点位开始连续,中间缺一条会让后面的数据都向左错一位。 ### PieChart 饼图适合展示占比关系。 #### 基础饼图 ```rust PieChart::new(data) .value(|d| d.amount as f32) .outer_radius(100.) ``` #### 环形图 ```rust PieChart::new(data) .value(|d| d.amount as f32) .outer_radius(100.) .inner_radius(60.) ``` #### 自定义 ```rust PieChart::new(data) .value(|d| d.amount as f32) .outer_radius(100.) .color(|d| d.color) PieChart::new(data) .value(|d| d.amount as f32) .outer_radius(100.) .inner_radius(60.) .pad_angle(4. / 100.) ``` ### RadarChart 雷达图以围绕中心的闭合多边形展示多维数据,适合对比多个系列在各维度上的表现。 #### 基础雷达图 ```rust RadarChart::new(data) .label(|d| d.month.clone()) .value(|d| d.desktop) ``` #### 多系列 ```rust // 每次调用 `.value()` 新增一个系列,与随后的 `.stroke()` / `.fill()` // 一一配对。颜色默认按主题图表色循环取用。 RadarChart::new(data) .label(|d| d.month.clone()) .value(|d| d.desktop) .stroke(cx.theme().chart_1) .value(|d| d.mobile) .stroke(cx.theme().chart_2) ``` #### 元素标签 `label` 既接受字符串,也接受自定义元素。返回 `element.into_any_element()` 即可在外圈周围渲染任意内容——图标、多行、按维度换色都可以。 ```rust RadarChart::new(data) .label({ let foreground = cx.theme().foreground; let muted_foreground = cx.theme().muted_foreground; move |d: &Device| { v_flex() .items_center() .child(div().text_xs().text_color(foreground).child(d.month.clone())) .child( div() .text_xs() .text_color(muted_foreground) .child(format!("{:.0}", d.desktop)), ) .into_any_element() } }) .value(|d| d.desktop) ``` 每个标签按自然尺寸测量,并沿径向朝外推开,所以即使很高也不会压到外圈上。 元素标签自带样式,因此 `.label_color()` 对它无效,也不会提供 tooltip 标题(字符串标签会)。 外圈不会为标签自动让位:默认外圈半径是图表高度的 40%,所以标签比单行文字高很多时, 需要调小 `.outer_radius()` 才能让它留在图表范围内。 #### 自定义 ```rust // 顶点圆点与自定义填充 RadarChart::new(data) .label(|d| d.month.clone()) .value(|d| d.desktop) .stroke(cx.theme().chart_2) .fill(cx.theme().chart_2.opacity(0.2)) .dot() // 固定外圈最大值与网格环数 RadarChart::new(data) .label(|d| d.month.clone()) .value(|d| d.desktop) .max_value(400.) .grid_levels(5) .outer_radius(120.) ``` ### CandlestickChart K 线图适合展示金融行情中的 OHLC 数据。 #### 基础 K 线图 ```rust #[derive(Clone)] struct StockPrice { pub date: String, pub open: f64, pub high: f64, pub low: f64, pub close: f64, } let data = vec![ StockPrice { date: "Jan".to_string(), open: 100.0, high: 110.0, low: 95.0, close: 105.0 }, StockPrice { date: "Feb".to_string(), open: 105.0, high: 115.0, low: 100.0, close: 112.0 }, StockPrice { date: "Mar".to_string(), open: 112.0, high: 120.0, low: 108.0, close: 115.0 }, ]; CandlestickChart::new(data) .x(|d| d.date.clone()) .open(|d| d.open) .high(|d| d.high) .low(|d| d.low) .close(|d| d.close) ``` #### 自定义 ```rust CandlestickChart::new(data) .x(|d| d.date.clone()) .open(|d| d.open) .high(|d| d.high) .low(|d| d.low) .close(|d| d.close) .body_width_ratio(0.4) CandlestickChart::new(data) .x(|d| d.date.clone()) .open(|d| d.open) .high(|d| d.high) .low(|d| d.low) .close(|d| d.close) .tick_margin(2) ``` 收盘高于开盘的 K 线使用主题的 `chart.bullish` 色,收盘不高于开盘的使用 `chart.bearish` 色。红涨绿跌的市场把两者对调即可: ```rust CandlestickChart::new(data) .x(|d| d.date.clone()) .open(|d| d.open) .high(|d| d.high) .low(|d| d.low) .close(|d| d.close) .bullish(cx.theme().danger) .bearish(cx.theme().success) ``` ### SankeyChart 桑基图用于展示节点之间的流量关系,适合财报资金流向、能源流动和流量分析等场景。布局算法对标 [d3-sankey](https://github.com/d3/d3-sankey)。 #### 基础桑基图 ```rust use gpui_kit::component::plot::shape::SankeyLink; #[derive(Clone)] struct FlowNode { pub name: SharedString, } let nodes = vec![ FlowNode { name: "营业收入".into() }, FlowNode { name: "毛利润".into() }, FlowNode { name: "营业成本".into() }, ]; // 连接通过节点在 `nodes` 中的索引引用节点。 let links = vec![ SankeyLink::new(0, 1, 45.0), SankeyLink::new(0, 2, 55.0), ]; SankeyChart::new(nodes, links) .node_label(|d| d.name.clone()) .value_label(|_, value| format!("{:.1}", value).into()) ``` 数值标签显示在名称标签上方,闭包会收到节点的吞吐量(进出流量的较大值)。 #### 节点对齐 ```rust use gpui_kit::component::plot::shape::SankeyAlign; // Justify(默认):没有出边的节点移到最后一列 SankeyChart::new(nodes, links).node_align(SankeyAlign::Justify) // Left:节点保持在自己的拓扑深度列 SankeyChart::new(nodes, links).node_align(SankeyAlign::Left) // 还支持:SankeyAlign::Right、SankeyAlign::Center ``` #### 样式 ```rust SankeyChart::new(nodes, links) .node_width(8.) // 节点条宽度(默认 10) .node_padding(20.) // 同列节点垂直间距(默认 16) .node_corner_radius(px(2.)) // 节点条圆角(默认 0) .node_color(|d| d.color) // 每个节点的颜色,默认循环主题图表配色 .link_opacity(0.4) // 连接带透明度(默认 0.3) .min_link_width(2.) // 连接带最小粗细(默认 1) .iterations(10) // 布局松弛迭代次数(默认 6) ``` 连接带使用从源节点颜色到目标节点颜色的水平渐变填充。 #### 自定义标签 需要完全控制标签行时使用 `labels`——每行一个 `SankeyLabel`,从上到下排列,每行可单独设置颜色和字号。设置后优先于 `node_label`/`value_label`。例如带同比涨跌幅行的财报标签: ```rust use gpui_kit::component::chart::SankeyLabel; SankeyChart::new(nodes, links).labels(move |d: &FlowNode, value| { let arrow = if d.growth >= 0. { "▲" } else { "▼" }; let growth_color = if d.growth >= 0. { green } else { red }; vec![ SankeyLabel::new(format!("{:.1}", value)), SankeyLabel::new(format!("{} {:+.2}%", arrow, d.growth)).color(growth_color), SankeyLabel::new(d.name.clone()).color(muted), ] }) ``` 行颜色默认为主题前景色,字号默认 10;摆位、对齐和边距预留仍由组件负责。首/末列标签若超出预留边距,会被截断并加省略号,而不会画到图表外;若想让长标签完整分多行显示,请自行折断或缩短。 #### 压缩数值跨度 节点高度默认与流量值成线性关系,数值跨度很大时(如 200:1)小流量几乎不可见、主流量过大。设置 `value_scale(SankeyValueScale::Sqrt)` 即可压缩跨度——组件按值的平方根来定节点高度,小流量保持可见,且无需预处理数据,标签仍显示真实值: ```rust use gpui_kit::component::plot::shape::SankeyValueScale; SankeyChart::new(nodes, links).value_scale(SankeyValueScale::Sqrt) ``` 无论用哪种缩放,每个节点都被其连接精确填满,所以子节点高度始终与父节点匹配。 ## 悬停与 Tooltip 图表默认就会对光标做命中测试,为光标所在的数据显示 tooltip,并按图表类型强调这条数据,无需额外开启: ```rust LineChart::new(data) .x(|d| d.date.clone()) .y(|d| d.value) .name("Desktop") // tooltip 行中的系列名 ``` | 图表 | 悬停时 | | --- | --- | | `LineChart`、`AreaChart` | 十字线和每个系列的圆点沿折线滑到悬停的数据点,圆点外扩出一圈光晕。 | | `BarChart` | 与柱同宽的高亮条滑到悬停的柱,其余柱淡出到它后面。 | | `PieChart` | 悬停的扇区从圆环中抬起,其余扇区淡出;tooltip 显示数值与占比,设了 `tooltip_value` 则用它。 | | `RadarChart` | 每个系列的圆点沿多边形滑到悬停的辐条。 | | `CandlestickChart` | 高亮条滑到悬停的 K 线;tooltip 列出开盘、最高、最低、收盘。 | | `SankeyChart` | 悬停节点的连接保持颜色,其余淡出;tooltip 显示节点的名称与流量,设了 `tooltip_name` / `tooltip_value` 则用它们。 | tooltip 框跟随光标,靠近边缘时翻向绘图区中心。`AreaChart` 与 `RadarChart` 每个系列各取一个 `.name()`,在对应的 `.y()` / `.value()` 之后调用。 tooltip 的一行由色块、名称、数值三部分组成。`PieChart::tooltip_name` 与 `SankeyChart::tooltip_name` 用光标所在的数据项来填这个名称——扇区名、节点名——对于每个数据项只有一个数字的图表,这正是那一行想要的。不设时,饼图回落到 `name`(整个系列共用的那一个名字),桑基图的行则完全没有名称:只剩一个色块和一个数字,中间空着。 `name` 顶替不了它。它说的是这些数字在计量什么,对每个扇区都一样,因此永远说不出 tooltip 讲的是哪一块。把 `label` 拿来做标题也不行——`label` 会同时在圆环外画引线标签,桑基图的 `node_label` 同理会把名字写在节点旁边。 `PieChart::tooltip_value` 用来替换它那一行的文本(默认写的是原始数值加占比)。只要原始数值不是该给用户看的东西就应该设它——本身已是比例的数值默认会显示成 `0.35 (35.0%)`;而用调整过的值绘制的图表(例如为了让极小扇区可见而设的下限)会把调整后的数字当作真实数据报出来: ```rust PieChart::new(holdings) .value(|d| d.ratio.max(MIN_VISIBLE)) // 按下限绘制 .tooltip_name(|d| d.name.clone()) // 不画引线也能命名 .tooltip_value(|d, _, _| pct(d.ratio)) // 按真实值显示 ``` ### Tooltip 内容 `LineChart`、`AreaChart`、`BarChart`、`RadarChart` 和 `CandlestickChart` 默认用悬停处的 X 值、分类名或维度名作为 tooltip 标题,每行的数值直接显示原始数字。`tooltip_title` 和 `tooltip_value` 根据光标下的数据替换这些文字,`tooltip_value_color` 为每行的数值着色,比如按正负显示绿色或红色。两个闭包都会收到数据和该行的数值;`AreaChart`、`RadarChart` 和 `CandlestickChart` 有多行,闭包在这两者之间还会收到该行的下标(多个系列时按添加顺序;K 线图依次为开、高、低、收): ```rust BarChart::new(flows) .band(|d| d.month.clone()) .value(|d| d.net) .tooltip_title(|d| format!("{} 2025", d.month).into()) .tooltip_value(|_, value| format!("${value:.2}").into()) .tooltip_value_color(move |_, value| if value >= 0. { gain } else { loss }) ``` 标题加若干行表达不了的版式(比如表格),用 `tooltip_content` 根据数据自行绘制浮层里的内容。图表的悬停标记(十字线、圆点、高亮带)和浮层的位置仍由图表负责,上面三个文字选项此时不再生效: ```rust AreaChart::new(data) .x(|d| d.month.clone()) .y(|d| d.last_year) .y(|d| d.revenue) .tooltip_content(|d, _, _| { v_flex() .child(d.month.clone()) .child(format!("2025: {}", d.revenue)) .child(format!("2024: {}", d.last_year)) }) ``` ### 标识 这些行为都以 `ElementId` 为键,图表默认取自己的构造位置作为 id——只写出一次的图表因此天然唯一,绝大多数图表都是这种情况。若同一处构造被渲染成多个同级图表,需要分别命名,否则它们会共用同一份悬停状态与缓存: ```rust shares.iter().enumerate().map(|(i, share)| PieChart::new(share.clone()).id(("share", i))) ``` `GlobalElementId` 是整条 id 栈,因此本身已带 id 的同级元素(例如 `List`、`uniform_list` 绘制的行)会自动把其下的图表区分开,无需额外处理。 ### 关闭交互 `interactive(false)` 把整层连同 hitbox 一起去掉,语义等同 Highcharts 的 `enableMouseTracking` 或 ECharts 的 `silent`。两种场景需要它:纯装饰的图表,以及上面盖着别的元素的图表: ```rust AreaChart::new(placeholder).interactive(false) // 骨架屏、缩略图 AreaChart::new(range).interactive(false) // 拖拽手柄下面的底图 ``` 第二种尤其要注意:普通 hitbox **不会挡住它后面的 hitbox**,盖在图表上的元素被悬停时,图表**同样**算被悬停,十字线会在它下面继续跟着跑。只能让图表让位。 关闭交互的图表仍保留自己的 id,所以依然有入场动画,也依然保留缓存。 ### 动效 强调效果使用样式层的 motion tokens(`cx.theme().motion_tokens()`)驱动,主题会把它们作为 [`PlotMotion`](/versions/v0.7.1/zh-CN/base/plot) 投射到 gpui-base:十字线、高亮条、圆点等指示器以快速弹簧跟随悬停的数据,饼图扇区以 control 弹簧抬起,整个覆盖层在光标落到数据上时淡入、离开后淡出。动效遵循操作系统的减弱动态效果偏好,开启后所有值立即到达目标。 ### 入场 图表第一次绘制时,数据会按 `easeOutQuart` 曲线(Chart.js 的默认曲线)在 1000 ms 内画出来:折线、面积、K 线和桑基图像 ECharts、Highcharts、Recharts 那样从左往右展开,柱子从零线同时长出,饼图从第一个扇区顺时针扫开,雷达图从中心向外放大。坐标轴、网格线和刻度文字从第一帧起就完整显示,tooltip 等数据画完才出现。 每个 id 只播放一次入场。之后数据变化会原地重绘,所以接实时行情的图表不会每次推送都重播。图表换成展示别的内容(比如换了股票或周期)时,传一个 key 让它重播: ```rust LineChart::new(candles).appear_key((&symbol, period)) ``` 图表一旦不再绘制就会忘掉自己的入场,所以虚拟列表里的图表每次滚回视野都会重播。把列表包进 [`PlotAppearScope`](/versions/v0.7.1/zh-CN/base/plot#appear-scope),每张图表只入场一次,滚回来时直接完整显示。作用域按列表展示的内容命名(比如一段对话、一个自选列表),切到别的内容或关闭视图后,图表会重新入场: ```rust PlotAppearScope::new(("rows", list_id), list(state, render_row).flex_1()) ``` 图表完全不需要入场时,把它关掉: ```rust LineChart::new(intraday).interactive(false).appear(false) ``` 开启减弱动态效果时跳过入场。 ### 缓存 图表还会跨帧保留较重的几何计算,因为它在屏幕上的每一帧都会重绘:折线与面积的描边、饼图扇区在投影点不变时保持已细分的路径,桑基图在数据、设置和尺寸不变时保留布局。这份缓存挂在同一个 id 上,因此共用 id 的图表会互相冲刷缓存——这是同级图表需要分别命名的另一个理由。饼图和雷达图入场时每一帧都是新的形状,所以入场结束前每帧都重新细分路径。 ### 自定义 Plot 自定义 [`Plot`] 需要手动接入——那里的 `Plot::id` 仍默认返回 `None`。这个 trait、`PlotElement` 和 hover 跟踪都来自 [gpui-base](/versions/v0.7.1/zh-CN/base/plot),因此基于 `gpui_kit::base::plot` 编写的 Plot 可以直接在这里使用。在 `Plot::id` 返回 id,在 `Plot::tooltip_state` 解析光标所在的数据,在 `Plot::tooltip` 构建覆盖层。要做入场,就保存 `Plot::appear` 每帧传入的 `PlotAppear`,用它的进度来绘制。这里返回的 `Tooltip` 会自己为悬停加动画,和内置图表一样:整个覆盖层随悬停淡入淡出;十字线和圆点按指针 spring 滑到每个悬停的数据点,光标落下的那一帧直接就位;圆点的 `halo` 随悬停淡入逐渐放大。十字线只沿它标记的那条轴滑动,所以同时跟随光标的那条线不会滞后。传入数据点本身即可,其余交给 tooltip: ```rust fn tooltip(&self, state: &TooltipState, cursor: Point, bounds: Bounds, _: &mut Window, cx: &mut App) -> Option { Some( Tooltip::new(cursor, bounds.size) .cross_line(CrossLine::new(state.cross_line).band(px(24.))) .title("Title") .row(cx.theme().chart_1, "Series", "42") .into_any_element(), ) } ``` `value_color` 为最后添加的一行设置数值颜色,比如按正负给涨跌幅着色,需紧跟在那一行之后调用。`plain_row` 添加一行不带色块的内容,用于图上没有对应系列的数字,比如合计或比率;与带色块的行放在一起时,它的标签会与这些行的标签对齐: ```rust Tooltip::new(cursor, bounds.size) .title("Apr 5") .row(desktop, "Desktop", "373") .row(mobile, "Mobile", "187") .plain_row("Total", "560") .plain_row("Change", "+12%") .value_color(gain) ``` 如果还要强调 plot 自己的图形——让悬停柱子周围的柱子变淡、让扇区弹出——就实现 `Plot::hover`。它在每帧的 `tooltip` 与 `paint` 之前运行,收到当前悬停的 [`PlotHover`];它携带 `TooltipState`,光标离开后会保留一段时间,`hover.progress()` 逐渐回到零,因此在这里采样动效并把结果存到 `self`。`hover.glide` 让一个位置按 tooltip 所用的同一个 spring 移动;把结果交给十字线,并用 `Tooltip::glide(false)` 关掉 tooltip 自己的滑动,避免重复做 spring: ```rust fn hover(&mut self, hover: Option<&PlotHover>, window: &mut Window, cx: &mut App) { self.band_center = hover.map(|hover| hover.glide(("my-plot", "band"), hover.state().cross_line.x, window, cx)); } ``` ## 数据结构示例 ```rust #[derive(Clone)] struct DailyDevice { pub date: String, pub desktop: f64, pub mobile: f64, } #[derive(Clone)] struct MonthlyDevice { pub month: String, pub desktop: f64, pub color_alpha: f32, } impl MonthlyDevice { pub fn color(&self, base_color: Hsla) -> Hsla { base_color.alpha(self.color_alpha) } } #[derive(Clone)] struct StockPrice { pub date: String, pub open: f64, pub high: f64, pub low: f64, pub close: f64, pub volume: u64, } // 桑基图连接:通过索引引用节点(来自 gpui_kit::component::plot::shape) pub struct SankeyLink { pub source: usize, pub target: usize, pub value: f64, } ``` ## 图表配置 ### 容器布局 ```rust fn chart_container( title: &str, chart: impl IntoElement, center: bool, cx: &mut Context, ) -> impl IntoElement { v_flex() .flex_1() .h_full() .border_1() .border_color(cx.theme().border) .rounded(cx.theme().radius_lg) .p_4() .child( div() .when(center, |this| this.text_center()) .font_semibold() .child(title.to_string()), ) .child( div() .when(center, |this| this.text_center()) .text_color(cx.theme().muted_foreground) .text_sm() .child("Data period label"), ) .child(div().flex_1().py_4().child(chart)) .child( div() .when(center, |this| this.text_center()) .font_semibold() .text_sm() .child("Summary statistic"), ) .child( div() .when(center, |this| this.text_center()) .text_color(cx.theme().muted_foreground) .text_sm() .child("Additional context"), ) } ``` ### 主题集成 ```rust let chart = LineChart::new(data) .x(|d| d.date.clone()) .y(|d| d.value) .stroke(cx.theme().chart_1); ``` 可用主题色为 `cx.theme().chart_1` 到 `cx.theme().chart_5`(主题文件中的 `chart.1` 到 `chart.5`)。 ## API 参考 - [LineChart] - [BarChart] - [AreaChart] - [PieChart] - [RadarChart] - [CandlestickChart] - [SankeyChart] ## 示例 ### 销售仪表盘 ```rust #[derive(Clone)] struct SalesData { month: String, revenue: f64, profit: f64, region: String, } fn sales_dashboard(data: Vec, cx: &mut Context) -> impl IntoElement { v_flex() .gap_4() .child( h_flex() .gap_4() .child( chart_container( "Monthly Revenue", LineChart::new(data.clone()) .x(|d| d.month.clone()) .y(|d| d.revenue) .stroke(cx.theme().chart_1) .dot(), false, cx, ) ) .child( chart_container( "Profit Breakdown", PieChart::new(data.clone()) .value(|d| d.profit as f32) .outer_radius(80.) .color(|d| match d.region.as_str() { "North" => cx.theme().chart_1, "South" => cx.theme().chart_2, "East" => cx.theme().chart_3, "West" => cx.theme().chart_4, _ => cx.theme().chart_5, }), true, cx, ) ) ) .child( chart_container( "Regional Performance", BarChart::new(data) .band(|d| d.region.clone()) .value(|d| d.revenue) .fill(|d, _, _, _| match d.region.as_str() { "North" => cx.theme().chart_1, "South" => cx.theme().chart_2, "East" => cx.theme().chart_3, "West" => cx.theme().chart_4, _ => cx.theme().chart_5, }) .label(|d| format!("${:.0}k", d.revenue / 1000.)), false, cx, ) ) } ``` ### 多系列时间图 ```rust #[derive(Clone)] struct DeviceUsage { date: String, desktop: f64, mobile: f64, tablet: f64, } fn device_usage_chart(data: Vec, cx: &mut Context) -> impl IntoElement { chart_container( "Device Usage Over Time", AreaChart::new(data) .x(|d| d.date.clone()) .y(|d| d.desktop) .stroke(cx.theme().chart_1) .fill(linear_gradient( 0., linear_color_stop(cx.theme().chart_1.opacity(0.4), 1.), linear_color_stop(cx.theme().background.opacity(0.3), 0.), )) .y(|d| d.mobile) .stroke(cx.theme().chart_2) .fill(linear_gradient( 0., linear_color_stop(cx.theme().chart_2.opacity(0.4), 1.), linear_color_stop(cx.theme().background.opacity(0.3), 0.), )) .y(|d| d.tablet) .stroke(cx.theme().chart_3) .fill(linear_gradient( 0., linear_color_stop(cx.theme().chart_3.opacity(0.4), 1.), linear_color_stop(cx.theme().background.opacity(0.3), 0.), )) .tick_margin(3), false, cx, ) } ``` ### 金融图表 ```rust #[derive(Clone)] struct StockData { date: String, price: f64, volume: u64, } #[derive(Clone)] struct StockOHLC { date: String, open: f64, high: f64, low: f64, close: f64, } fn stock_chart(ohlc_data: Vec, price_data: Vec, cx: &mut Context) -> impl IntoElement { v_flex() .gap_4() .child( chart_container( "Stock Price - Candlestick", CandlestickChart::new(ohlc_data.clone()) .x(|d| d.date.clone()) .open(|d| d.open) .high(|d| d.high) .low(|d| d.low) .close(|d| d.close) .tick_margin(3), false, cx, ) ) .child( chart_container( "Stock Price - Line", LineChart::new(price_data.clone()) .x(|d| d.date.clone()) .y(|d| d.price) .stroke(cx.theme().chart_1) .linear() .tick_margin(5), false, cx, ) ) .child( chart_container( "Trading Volume", BarChart::new(price_data) .band(|d| d.date.clone()) .value(|d| d.volume as f64) .fill(|d, _, _, _| { if d.volume > 1000000 { cx.theme().chart_1 } else { cx.theme().muted_foreground.opacity(0.6) } }) .tick_margin(5), false, cx, ) ) } ``` ## 自定义选项 ### 配色 ```rust LineChart::new(data) .x(|d| d.x.clone()) .y(|d| d.y) .stroke(cx.theme().chart_1) let colors = [ cx.theme().success, cx.theme().warning, cx.theme().destructive, cx.theme().info, cx.theme().chart_1, ]; BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .fill(|d, _, _, _| colors[d.category_index % colors.len()]) ``` ### 响应式容器 ```rust div() .flex_1() .min_h(px(300.)) .max_h(px(600.)) .w_full() .child( LineChart::new(data) .x(|d| d.x.clone()) .y(|d| d.y) ) ``` ### 默认样式 图表默认会自动包含: - 虚线网格,颜色取主题的 `chart.grid`(主题未设置时为半透明的 `border`) - 自动定位的 X 轴标签 - 从 0 开始的 Y 轴刻度 - 基于 `tick_margin` 的刻度稀疏控制 ## 性能建议 ### 大数据集 ```rust let sampled_data: Vec<_> = data .iter() .step_by(5) .cloned() .collect(); LineChart::new(sampled_data) .x(|d| d.date.clone()) .y(|d| d.value) .tick_margin(3) ``` ### 内存优化 ```rust LineChart::new(data) .x(|d| d.date.clone()) .y(|d| d.value) ``` ## 集成示例 ### 结合状态管理 ```rust struct ChartComponent { data: Vec, chart_type: ChartType, time_range: TimeRange, } impl ChartComponent { fn render_chart(&self, cx: &mut Context) -> impl IntoElement { match self.chart_type { ChartType::Line => LineChart::new(self.filtered_data()) .x(|d| d.date.clone()) .y(|d| d.value) .into_any_element(), ChartType::Bar => BarChart::new(self.filtered_data()) .band(|d| d.date.clone()) .value(|d| d.value) .into_any_element(), ChartType::Area => AreaChart::new(self.filtered_data()) .x(|d| d.date.clone()) .y(|d| d.value) .into_any_element(), } } fn filtered_data(&self) -> Vec { self.data .iter() .filter(|d| self.time_range.contains(&d.date)) .cloned() .collect() } } ``` ### 实时更新 ```rust struct LiveChart { data: Vec, max_points: usize, } impl LiveChart { fn add_data_point(&mut self, point: DataPoint) { self.data.push(point); if self.data.len() > self.max_points { self.data.remove(0); } } fn render(&self, cx: &mut Context) -> impl IntoElement { LineChart::new(self.data.clone()) .x(|d| d.timestamp.clone()) .y(|d| d.value) .linear() .dot() } } ``` [LineChart]: https://docs.rs/gpui-component/latest/gpui_component/chart/struct.LineChart.html [BarChart]: https://docs.rs/gpui-component/latest/gpui_component/chart/struct.BarChart.html [AreaChart]: https://docs.rs/gpui-component/latest/gpui_component/chart/struct.AreaChart.html [PieChart]: https://docs.rs/gpui-component/latest/gpui_component/chart/struct.PieChart.html [RadarChart]: https://docs.rs/gpui-component/latest/gpui_component/chart/struct.RadarChart.html [CandlestickChart]: https://docs.rs/gpui-component/latest/gpui_component/chart/struct.CandlestickChart.html --- # Editor Source: /versions/v0.7.1/zh-CN/component/editor 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/editor 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `Editor` 用于编辑源代码。单行输入请使用 [Input](/versions/v0.7.1/zh-CN/component/input),普通多行文本请使用 [Textarea](/versions/v0.7.1/zh-CN/component/textarea)。 ## 导入 ```rust use gpui_kit::component::input::{Editor, EditorState, TabSize}; ``` ## 语言编辑规则 `LanguageConfig` 描述语言规则;`.auto_close(bool)` 和 `.smart_indent(bool)` 是独立的编辑器选项。 切换语言或替换规则不会重置这两个选项。自动补全、跳过结束符和成对 Backspace 使用 `auto_closing_pairs`;Enter 使用 `brackets` 和 `indentation_rules`,因此关闭自动补全后, 仍可在已有括号内换行并缩进。 ```rust use gpui_kit::component::input::{ AutoClosingPair, BracketPair, language_config::LanguageConfig, SyntaxContext, set_language_config, }; let rules = LanguageConfig::default() .brackets([BracketPair::new("{", "}"), BracketPair::new("(", ")")]) .auto_closing_pairs([ AutoClosingPair::new("{", "}") .not_in([SyntaxContext::String, SyntaxContext::Comment]), AutoClosingPair::new("(", ")") .not_in([SyntaxContext::String, SyntaxContext::Comment]), ]); set_language_config("rust", rules, cx); let editor = cx.new(|cx| { EditorState::new(window, cx) .language("rust") .auto_close(true) .smart_indent(true) }); ``` `set_language_config` 替换当前应用中指定语言的配置,已有编辑器在下一次编辑时立即使用它, 即使配置修改和编辑发生在同一个事件处理函数内。语言别名共享配置,例如 `python`、`py`、 `pyi`,不受对应 grammar feature 是否启用影响。自定义配置在 Component 初始化后仍然保留。 精确注册的自定义 grammar 名称优先于内置别名,并保留原始大小写。 未知语言使用 `LanguageConfig::default()`。 Component 安装 `LanguageProvider`,统一提供语言名称、默认规则及每个编辑器的语法提供者。 首次编辑和切换语言后的语法选择都不依赖 render。直接使用 Base 时,可通过 `set_language_provider` 安装自己的语言服务;普通 Component 使用者只需调用 `set_language_config`。高亮的 grammar 资源可使用 `highlighter::GrammarConfig`, 原有 `highlighter::LanguageConfig` 名称保持兼容。 配对使用字符串,支持多字符定界符。`auto_closing_pairs = None` 表示使用 `brackets`; `Some(vec![])` 表示禁用全部自动配对,其 builder 设置的是 `Some`。 `auto_close_before` 指定允许自动补全的后方字符;空白和文档末尾始终允许。 `not_in` 依赖语法上下文提供者;没有提供者时 Base 按 `Code` 处理。 启用对应 Tree-sitter grammar 后,Component 会安装语法上下文提供者。 `IndentationRules::new(increase, decrease)` 接受两个已编译的 `regex::Regex`。 Enter 时分别匹配光标前、后的文本;未配置增加缩进模式时,使用结构括号判断。 这些规则不会重新格式化已有行或粘贴内容。Python 的默认规则额外识别末尾冒号, 未知语言仅使用结构括号。 这是 Monaco 风格语言配置的已支持子集,不直接加载 Monaco JSON 或 `.scm`。 包围选区、自定义 `onEnterRules` 留待后续实现。 ## 基础用法 ```rust let editor = cx.new(|cx| { EditorState::new(window, cx) .language("rust") .line_number(true) .folding(true) .tab_size(TabSize { tab_size: 4, hard_tabs: false, }) .default_value("fn main() {\n println!(\"Hello\");\n}") }); Editor::new(&editor).h(px(320.)) ``` 使用 `.language()` 指定语法高亮语言。应用需要启用对应的 Cargo feature,例如 `tree-sitter-rust` 或 `tree-sitter-markdown`;也可以使用 `tree-sitter-languages` 包含全部内置语法。 ## 编辑器选项 ```rust let editor = cx.new(|cx| { EditorState::new(window, cx) .language("json") .line_number(true) .folding(true) .show_whitespaces(true) .default_value(source) }); ``` ## 快捷键与矩形列选 以下默认快捷键在编辑器聚焦时生效。macOS 的 Option 对应 Alt 修饰键;Linux 的这些操作不使用 Super/Win。 | 操作 | macOS | Linux | Windows | | --- | --- | --- | --- | | 在上方/下方添加光标 | Cmd+Option+↑ / ↓ | Alt+Shift+↑ / ↓ | Ctrl+Alt+↑ / ↓ | | 逐字符扩展所有选区 | Shift+← / → | Shift+← / → | Shift+← / → | | 按词扩展所有选区 | Option+Shift+← / → | Ctrl+Shift+← / → | Ctrl+Shift+← / → | | 鼠标添加光标 | Option+左键点击 | Alt+左键点击 | Alt+左键点击 | | 矩形列选 | Option+Shift+左键拖动 | Alt+Shift+左键拖动 | Alt+Shift+左键拖动 | | 只保留活动光标 | Escape | Escape | Escape | Linux 额外支持与 Ghostty 一致的 Ctrl+Alt+左键拖动列选,以及 Alt+Shift+← / → 按词选择。Windows 额外支持 Alt+Shift+← / → 逐字符选择。三个平台都兼容 Alt/Option+左键拖动列选:单击添加光标,继续拖动则以鼠标按下位置为起点建立新的矩形选区。 在编辑区按住 Alt/Option 时,鼠标指针显示为 `+`。带 Alt 的选择手势优先于 Ctrl/Cmd+点击跳转定义。矩形选区按显示行生成,每行一个选区,短行会截断到已有文本边界。输入和删除同时作用于所有选区。松开鼠标结束拖动,Escape 只保留活动光标(若上下文菜单已打开,则先处理菜单的 Escape)。 使用 ↑ / ↓ 添加光标是累加操作,反向按键不会收缩矩形高度。因此这是多光标编辑与鼠标列选,并非持续的 Vim Visual Block 模式。键盘输入期间光标保持可见,空闲 300ms 后恢复闪烁。 Linux 桌面可能在编辑器收到事件之前拦截快捷键。部分桌面使用 Ctrl+Alt+↑ / ↓ 切换工作区,因此 Linux 默认不绑定这一组合。以上快捷键指键盘重映射后的逻辑修饰键。 ## 搜索 编辑器内置搜索面板。编辑器聚焦时按 `Ctrl-F`(Windows/Linux)或 `Cmd-F`(macOS)打开。`Enter` 跳到下一个匹配,`Shift+Enter` 跳到上一个,`Escape` 关闭面板。 ```rust // 以代码方式打开查找面板 editor.update(cx, |state, cx| { state.open_search(false, cx); }); // 关闭它 editor.update(cx, |state, cx| { state.close_search(cx); }); ``` `Editor` 默认启用搜索。如需禁用: ```rust editor.update(cx, |state, cx| { state.set_searchable(false, cx); }); ``` 只读编辑器仍可搜索——替换界面会自动隐藏。 ### 自定义搜索界面 搜索引擎不依赖内置面板,应用可以在编辑器的匹配、高亮、滚动和替换之上绘制自己的搜索栏。 `set_search_query` 开始一次搜索,编辑器会一直高亮匹配项,直到调用 `close_search`。 未启用 `searchable` 的编辑器不会打开内置面板,也不拦截 `Ctrl-F` / `Cmd-F`,快捷键会冒泡到上层视图, 应用可以把它绑定到自己的搜索框。 ```rust let editor = cx.new(|cx| EditorState::new(window, cx).searchable(false)); // 从应用自己的搜索框发起搜索 editor.update(cx, |state, cx| { state.set_search_query("needle", true, cx); }); // 在匹配项之间移动,每次调用都会把匹配项滚动到可见区域 editor.update(cx, |state, cx| { state.next_search_match(cx); state.previous_search_match(cx); }); // 读取匹配情况:"2/5" let matcher = &editor.read(cx).search_session().matcher; let label = matcher.label(); let count = matcher.len(); let current = matcher.current(); // 没有匹配时为 None // 替换(编辑器可编辑时) editor.update(cx, |state, cx| { state.replace_current_search_match("replacement", window, cx); state.replace_all_search_matches("replacement", window, cx); }); // 结束搜索并清除高亮 editor.update(cx, |state, cx| { state.close_search(cx); }); ``` 在拥有搜索框的视图上接管快捷键: ```rust use gpui_kit::component::input::Search; div() .on_action(cx.listener(|this: &mut Self, _: &Search, window, cx| { this.search.update(cx, |search, cx| search.focus(window, cx)); })) .child(Editor::new(&this.editor)) ``` ## 文本装饰 ```rust let decorations = editor.update(cx, |state, cx| { state.create_decorations_collection(initial_decorations, cx) }); ``` 保留返回的 `TextDecorationCollection`,以便更新或清空该调用方的文本样式。 范围会跟随文本编辑;丢弃句柄不会移除装饰。 ### 几何范围装饰 使用独立的 `RangeDecorationCollection` 绘制连续填充或一个逻辑像素宽的边框。 它们只参与绘制,不预留行内空间、不添加 widget、不拦截鼠标事件,也不改变键盘焦点。 ```rust use gpui_kit::component::input::{RangeDecoration, RangeDecorationStyle}; let review_ranges = editor.update(cx, |state, cx| { state.create_range_decorations_collection( vec![ RangeDecoration::new(0..8).with_style(RangeDecorationStyle::Fill), RangeDecoration::new(12..24), // 默认为 Frame。 ], cx, ) }); review_ranges.set(vec![RangeDecoration::new(4..16)], cx); review_ranges.append(vec![RangeDecoration::new(20..28)], cx); let tracked_ranges = review_ranges.get_ranges(cx); review_ranges.clear(cx); // 清空,但保留集合以便复用。 review_ranges.dispose(cx); // 释放集合,使该句柄及其克隆全部失效。 ``` 每个集合只管理自己的条目,不同扩展不会互相覆盖。丢弃句柄后,集合仍保存在编辑器中; 调用 `dispose` 才会永久释放它。对已释放集合或已销毁编辑器的调用不会产生效果。 单个装饰不需要提供 ID。 范围使用左闭右开的 UTF-8 **字节偏移量**,不是字符下标或行号。起止偏移量向外裁剪到有效 字符边界;空范围、反向范围和完全超出文档的范围会被丢弃。文本装饰和几何装饰共享跟踪规则: - 在两端插入文字不会扩展范围,在内部插入则会扩展。 - 替换文字时,重叠锚点裁剪到替换区域;删除整个范围会移除该装饰。 - 撤销、重做、`set_value`、`replace_all`(包括格式化)都应用相同的编辑变换。 装饰**不是**撤销历史中的快照:撤销删除不会恢复已移除的装饰,撤销替换也不会恢复原先的 内部锚点。如果业务需要这种语义,请依据自己的语义数据重新设置集合。 - 折叠只改变显示投影,不修改存储的范围。完全隐藏的范围不绘制;可见部分按视口裁剪, 并跟随软换行后的实际字形位置。 填充绘制在边框下方,两者均位于选区和字形下方。同一种样式内,后创建的集合和后加入的 条目覆盖先前的条目。不调用 `with_color` 时,边框使用编辑器前景色,填充使用其 12% 透明度 版本,因此默认颜色会跟随主题变化;显式颜色由应用自行维护。 可见范围查询使用区间索引并跳过折叠的缓冲区范围,不会逐帧扫描全部装饰。设置或追加条目 会重建对应集合的索引;编辑文本时线性更新受影响的集合,不重新排序。 Editor 展示页的 **Decorations** 标签演示了两种集合的组合使用。 ## 值与事件 ```rust let source = editor.read(cx).value(); editor.update(cx, |state, cx| { state.set_value(new_source, window, cx); }); ``` `EditorState` 会发出 `InputEvent::Change`、`Focus` 和 `Blur` 等事件。 ## 字体 Editor 默认使用主题中的等宽字体 —— `mono_font_family` 和 `mono_font_size`,行高为字号的 1.5 倍。这只是默认值:在 Editor 上设置的文本样式会覆盖它,gutter 和行高都跟随字号变化。 主题加载时会核对平台默认等宽字体(`Menlo`、`Consolas`、`DejaVu Sans Mono`)是否已安装, 缺失时换成已安装的等宽字体,再不行退到 `.SystemUIFont`;你自己指定的字体族则原样使用。 ```rust Editor::new(&editor).text_sm() Editor::new(&editor) .font_family("JetBrains Mono") .text_size(px(15.)) ``` 这些就是所有元素都有的 [`Styled`](https://docs.rs/gpui/latest/gpui/trait.Styled.html) 方法,`font_weight`、`line_height` 用法相同。 ## 外观 ```rust Editor::new(&editor) .h(px(480.)) .bordered(true) .disabled(false) .readonly(false) .aria_label("Rust 源代码") ``` 预览文件但不允许修改时使用 `readonly`。与 `disabled` 不同,只读编辑器保持正常外观,仍然可以聚焦、选中、复制和搜索,只是拒绝用户对内容的修改。`set_value` 等程序调用不受影响。 ```rust Editor::new(&editor).readonly(true) ``` Editor 聚焦时不会应用单行 Input 的焦点边框效果。gutter、当前行背景和滚动条会作为同一个编辑器表面对齐绘制。 前后缀、密码显示切换和清除按钮只属于单行 Input。Editor 的工具栏和操作按钮应组合在组件外部。 --- # Image Source: /versions/v0.7.1/zh-CN/component/image 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/image 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 GPUI 的 `img()` 绘制图片,`svg()` 绘制单色图标。GPUI Kit 从 `gpui_kit` 重新导出这两个函数。本页列出应用中最常用的写法;来源、加载、尺寸、`svg()`、缓存和 HTTP 缓存的细节见[图片](/versions/v0.7.1/zh-CN/docs/image)。 ## 从可运行示例开始 以下是完整的原生桌面 `src/main.rs`。它使用 GPUI Kit 自带的图标,不需要额外图片文件。在 `Cargo.toml` 加入 `gpui-kit = "0.7.1"`。同一份资源分别以保留原色的图片和单色 SVG 显示。 以下是完整的原生桌面 `src/main.rs`。它使用 GPUI Kit 自带的图标,不需要额外图片文件。在 `Cargo.toml` 加入 `gpui-kit = "0.7.1"`。同一份 SVG 分别以保留原色的图片和随主题着色的单色图标显示;两者区别见下文。 ```rust use gpui_kit::*; use gpui_kit::assets::Assets; struct ImageExample; impl Render for ImageExample { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div() .flex() .gap_4() .child( img("icons/inbox.svg") .id("inbox-image") .size(px(64.)) .object_fit(ObjectFit::Contain) .with_loading(|| div().child("Loading image...").into_any_element()) .with_fallback(|| div().child("Image unavailable").into_any_element()), ) .child(svg().path("icons/inbox.svg").size(px(64.))) } } fn main() { gpui_kit::application().with_assets(Assets).run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| ImageExample) }) .expect("Failed to open window"); }); } ``` `with_assets(Assets)` 注册默认组件图标;要嵌入自己的图片,请注册自己的 `AssetSource`(见[图标与资源](/versions/v0.7.1/zh-CN/docs/assets))。`"images/cover.png"` 这样的字符串是资源键,`Path` 读取文件,URL 则通过 App 的 `HttpClient` 下载;见[图片来源](/versions/v0.7.1/zh-CN/docs/image#图片来源)。 ## 常用写法 填满固定区域并裁掉边缘的缩略图: ```rust img("images/cover.png") .w(px(320.)) .h(px(180.)) .object_fit(ObjectFit::Cover) ``` 跟随父元素宽度、在图片加载前就占好高度的横幅: ```rust img("images/banner.webp") .w_full() .aspect_ratio(16. / 9.) .object_fit(ObjectFit::Cover) ``` 带加载中和加载失败状态的远程图片。必须设置 `.id(...)`,加载中的内容才会出现: ```rust img("https://example.com/avatar.png") .id("avatar") .size(px(48.)) .rounded_full() .with_loading(|| div().child("Loading image...").into_any_element()) .with_fallback(|| div().child("Image unavailable").into_any_element()) ``` 必须完整显示的 Logo 和图表,使用 `ObjectFit::Contain`(默认值)。要保留多色 SVG 的颜色,用 `img()` 绘制;跟随主题变色的图标,使用 [Icon](/versions/v0.7.1/zh-CN/component/icon)。见[尺寸与适配](/versions/v0.7.1/zh-CN/docs/image#尺寸与适配)和[选择 img() 还是 svg()](/versions/v0.7.1/zh-CN/docs/image#选择-img-还是-svg)。 ## 可选择图片的画廊 能够切换主图的缩略图是一种操作控件。使用真正的 `Button`,让键盘激活和可访问名称都有效;将选中项保存在视图的持久状态中。下面完整的 `src/main.rs` 使用 GPUI Kit 自带的三个图标;图片画廊可以换成自己注册的资源键名。依赖与前面的示例相同,仍是 `gpui-kit = "0.7.1"`。 ```rust use gpui_kit::*; use gpui_kit::assets::Assets; use gpui_kit::base::Selectable; use gpui_kit::component::button::Button; const PICTURES: [(&str, &str); 3] = [ ("icons/inbox.svg", "Inbox"), ("icons/book-open.svg", "Book"), ("icons/gallery-vertical-end.svg", "Gallery"), ]; struct Gallery { selected: usize, } impl Render for Gallery { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let (source, name) = PICTURES[self.selected]; div() .flex() .flex_col() .gap_3() .p_4() .child(format!("Selected: {name}")) .child( img(source) .id("gallery-main") .w(px(360.)) .h(px(240.)) .object_fit(ObjectFit::Contain) .with_fallback(|| div().child("Image unavailable").into_any_element()), ) .child( div().flex().gap_2().children( PICTURES.iter().enumerate().map(|(index, (source, name))| { Button::new(format!("gallery-thumbnail-{index}")) .accessibility_label(format!("Show {name}")) .selected(index == self.selected) .child( img(*source) .size(px(40.)) .object_fit(ObjectFit::Contain), ) .on_click(cx.listener(move |this, _, _, cx| { this.selected = index; cx.notify(); })) }), ), ) } } fn main() { gpui_kit::application().with_assets(Assets).run(|cx| { gpui_kit::init(cx); gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| Gallery { selected: 0 }) }) .expect("Failed to open window"); }); } ``` 被选中的按钮有明确的选中状态,可见的 `Selected: ...` 文本也报告当前选择。在实际画廊中,使用 `Show front view` 等有意义的名称,不要只用文件名。如果图片集合可能为空,先显示空状态,再读取选中项。如果图片来源会被删除或重新排序,应保存稳定的业务 ID,而不是数组索引,并在渲染时解析它。 ## 可访问性 - 传达信息的图片,要在周围界面配上可见文字或无障碍描述。文件名不是描述。装饰性图片不需要朗读。 - 点击后会执行命令的图片,要做成真正的控件,例如 `Button`,这样它才有名称、焦点和键盘操作。 - 加载中和加载失败的内容要占用与图片相同的区域,避免推动周围内容。 ## 延伸阅读 - [图片](/versions/v0.7.1/zh-CN/docs/image):`img()` 的所有来源、加载与解码、`svg()` 和常见问题。 - [解码后图片的缓存](/versions/v0.7.1/zh-CN/docs/image#解码后图片的缓存):限定范围的缓存与自定义 `ImageCache`。 - [远程图片的 HTTP 缓存](/versions/v0.7.1/zh-CN/docs/image#远程图片的-http-缓存):跨视图、跨启动复用下载结果。 --- # Empty Source: /versions/v0.7.1/zh-CN/component/empty 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/empty 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `Empty` 用于缺少内容、搜索无结果和首次使用等空状态。命名插槽负责布局与视觉层级, 应用决定何时显示空状态,并管理子组件的状态和操作。 该组件无状态,完整位于 GPUI Component 层,使用该层的主题和原生控件。 ## 导入 ```rust use gpui_kit::{ParentElement as _, Styled as _, rems}; use gpui_kit::assets::IconName; use gpui_kit::component::{ ActiveTheme as _, Icon, Sizable as _, avatar::{Avatar, AvatarGroup}, button::{Button, ButtonVariants as _}, empty::{ Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyMediaVariant, EmptyTitle, }, input::{Input, InputState}, link::Link, }; ``` 通过 `gpui_kit::component::empty` 导入该组件。GPUI 自带的 `gpui_kit::Empty` 是另一个不渲染内容的元素。 ## 基本用法 ```rust Empty::new() .header( EmptyHeader::new() .media( EmptyMedia::new() .with_variant(EmptyMediaVariant::Icon) .child(Icon::new(IconName::Folder)), ) .title(EmptyTitle::new().child("No projects yet")) .description( EmptyDescription::new() .child("Create your first project to get started."), ), ) .content( EmptyContent::new() .flex_row() .flex_wrap() .justify_center() .gap_2() .child(Button::new("create-project").label("Create project…")) .child( Button::new("import-project") .outline() .label("Import project…"), ), ) .child( Link::new("empty-help") .href("https://gpui-kit.com/docs/getting-started") .text_sm() .child("Learn more"), ) ``` 使用 Button 的常规回调连接应用操作。根部额外添加的子元素显示在 `EmptyContent` 之后,因此帮助链接可以独立于主要内容组。 ## 组成 | 部分 | 组合方式 | 职责 | | --- | --- | --- | | `Empty` | `.header(EmptyHeader)`、`.content(EmptyContent)`、`.child(...)` | 整体对齐与间距 | | `EmptyHeader` | `.media(EmptyMedia)`、`.title(EmptyTitle)`、`.description(EmptyDescription)` | 媒体与说明内容 | | `EmptyMedia` | `.with_variant(...)`、`.child(...)` | 图标、图片、头像或任意媒体 | | `EmptyTitle` | `.child(...)` | 标题文字或自定义内容 | | `EmptyDescription` | `.child(...)` | 可换行的文字或富内容说明 | | `EmptyContent` | `.child(...)` | 操作、输入框或其他控件 | 所有部分均提供 `new()` 和 `Default`,并实现 `Styled`。 除 `EmptyHeader` 外,其他部分都实现了 `ParentElement`。 命名插槽均为可选项,重复设置时替换原值:调用两次 `.header(...)` 会保留第二个 Header。 渲染顺序固定为 Header、Content,以及 Header 内的 Media、Title、Description, 与这些 setter 的调用顺序无关。根部直接添加的子元素按照自身插入顺序显示在两个 命名插槽之后,不会自动放进 Content。替换某个插槽不会影响其他插槽或根部额外内容。 ## 边框 Empty 默认背景透明,没有可见边框。通过 `Styled` 开启边框后,默认使用虚线样式。 ```rust Empty::new() .border_1() .header( EmptyHeader::new() .title(EmptyTitle::new().child("Cloud storage is empty")) .description( EmptyDescription::new() .child("Upload files to access them anywhere."), ), ) ``` 通过 `.border_color(...)` 调整边框的语义颜色。 ## 背景 直接应用语义背景,无需新增组件变体: ```rust Empty::new() .bg(cx.theme().muted.opacity(0.3)) .header( EmptyHeader::new() .title(EmptyTitle::new().child("No notifications")) .description( EmptyDescription::new() .child("New notifications will appear here."), ), ) ``` ## 头像 默认媒体变体不添加外框、背景或固定尺寸。现有 Avatar 保留自己的图片、回退内容、 尺寸和外观。 ```rust EmptyHeader::new() .media( EmptyMedia::new().child( Avatar::new() .name("Alex Morgan") .src("https://avatars.githubusercontent.com/u/5518?v=4"), ), ) .title(EmptyTitle::new().child("Alex is offline")) .description( EmptyDescription::new() .child("Leave a message for Alex to read when they're back."), ) ``` ## 头像组 多个头像使用同一个媒体插槽。头像的重叠和尺寸由 AvatarGroup 管理,Empty 不检查 或修改其子元素。 ```rust EmptyHeader::new() .media( EmptyMedia::new().child( AvatarGroup::new() .child(Avatar::new().name("Alex Morgan")) .child(Avatar::new().name("Taylor Lee")) .child(Avatar::new().name("Sam Chen")), ), ) .title(EmptyTitle::new().child("No team members")) .description( EmptyDescription::new() .child("Invite your team to collaborate on this project."), ) ``` ## 输入框与自定义内容 在所属视图中持有 `Entity`,然后将现有 Input 放入 `EmptyContent`: ```rust EmptyContent::new() .child( Input::new(&self.search) .prefix(Icon::new(IconName::Search).size_4()) .cleanable(true), ) .child( EmptyDescription::new() .child("Search by name or try a different keyword."), ) ``` 应用处理输入事件,并在结果列表与 Empty 之间切换。每个同时渲染的输入框各自持有 状态实体和焦点。Empty 不管理输入状态、校验、提交或加载策略。 ## 受限布局 同时调整根元素与各个插槽,即可构建紧凑、起始侧对齐的空状态: ```rust Empty::new() .max_w(rems(20.)) .p_4() .items_start() .text_left() .header( EmptyHeader::new() .items_start() .title(EmptyTitle::new().child("No shared files")) .description( EmptyDescription::new() .child("Add files so your team can review and edit them together."), ), ) .content( EmptyContent::new() .items_start() .child(Button::new("add-files").outline().label("Add files…")), ) ``` 根元素填满可用宽度,并可在 flex 布局中增长。Header 和 Content 使用可用宽度, 上限为 24 rem。文字自然换行;Empty 不裁切子元素,也不创建滚动区域。视口和所需的 滚动由父容器提供。自定义媒体应适应容器宽度,操作行可使用 `.flex_wrap()` 处理窄空间。 ## 默认样式 | 部分 | 默认值 | | --- | --- | | 根元素 | 居中列布局,`p_6()`、`gap_4()`、主题 `radius_tokens().xl` | | Header | `gap_2()`,子项居中,最大宽度 24 rem | | Media | 按内容确定尺寸的居中列布局,`mb_2()`,不收缩 | | Icon 媒体 | `size_8()`、muted 背景、前景色、主题 `radius_tokens().lg` | | Title | `text_sm()`,中等字重 | | Description | `text_sm()`,1.625 行高,muted 前景色 | | Content | 居中列布局,`gap_2p5()`、`text_sm()`,最大宽度 24 rem | 实例样式覆盖默认值和媒体变体样式。Icon 媒体提供一 rem 字号,未显式设置尺寸的 GPUI Component `Icon` 会继承该尺寸;显式设置的图标尺寸仍然有效。任意 SVG 或图片 子元素保留自己的尺寸。排版跟随应用字体和 rem 比例,使用 GPUI 的原生换行与字距; 组件不另行实现 CSS 的 `text-balance` 和 `tracking-tight`。 Empty 不创建焦点目标,也不会自动作为警告或实时状态播报。内部 Button 和 Input 保留正常的焦点与键盘行为。应用命令使用 Button,外部资源使用 Link。 --- # List Source: /versions/v0.7.1/zh-CN/component/list 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/list 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 List 是一个功能完整的列表组件,支持虚拟化展示、搜索、分组、头部和底部区域、选择状态以及无限滚动。它基于 delegate 模式构建,数据管理和项渲染都可以按业务场景自由扩展。 ## 导入 ```rust use gpui_kit::component::list::{List, ListState, ListDelegate, ListItem, ListEvent, ListSeparatorItem}; use gpui_kit::component::IndexPath; ``` ## 用法 ### 基础列表 创建列表前,需要先为数据实现 `ListDelegate`: ```rust struct MyListDelegate { items: Vec, selected_index: Option, } impl ListDelegate for MyListDelegate { type Item = ListItem; fn items_count(&self, _section: usize, _cx: &App) -> usize { self.items.len() } fn render_item( &mut self, ix: IndexPath, _window: &mut Window, _cx: &mut Context>, ) -> Option { self.items.get(ix.row).map(|item| { ListItem::new(ix) .child(Label::new(item.clone())) .selected(Some(ix) == self.selected_index) }) } fn set_selected_index( &mut self, ix: Option, _window: &mut Window, cx: &mut Context>, ) { self.selected_index = ix; cx.notify(); } } let delegate = MyListDelegate { items: vec!["Item 1".into(), "Item 2".into(), "Item 3".into()], selected_index: None, }; let state = cx.new(|cx| ListState::new(delegate, window, cx)); ``` 渲染列表: ```rs div().child(List::new(&state)) ``` ### 分组列表 注意:`items_count` 为 `0` 的 section 会被自动隐藏,不会渲染 header 或 footer。 ```rust impl ListDelegate for MyListDelegate { type Item = ListItem; fn sections_count(&self, _cx: &App) -> usize { 3 } fn items_count(&self, section: usize, _cx: &App) -> usize { match section { 0 => 5, 1 => 3, 2 => 7, _ => 0, } } fn render_section_header( &mut self, section: usize, _window: &mut Window, cx: &mut Context>, ) -> Option { let title = match section { 0 => "Section 1", 1 => "Section 2", 2 => "Section 3", _ => return None, }; Some( h_flex() .px_2() .py_1() .gap_2() .text_sm() .text_color(cx.theme().muted_foreground) .child(Icon::new(IconName::Folder)) .child(title) ) } } ``` ### 带图标和操作的列表项 ```rust fn render_item( &mut self, ix: IndexPath, _window: &mut Window, cx: &mut Context>, ) -> Option { self.items.get(ix.row).map(|item| { ListItem::new(ix) .child( h_flex() .items_center() .gap_2() .child(Icon::new(IconName::File)) .child(Label::new(item.title.clone())) ) .suffix(|_, _| { Button::new("action") .ghost() .small() .icon(IconName::MoreHorizontal) }) .selected(Some(ix) == self.selected_index) .on_click(cx.listener(move |this, _, window, cx| { this.delegate_mut().select_item(ix, window, cx); })) }) } ``` ### 可搜索列表 实现 `perform_search` 处理查询逻辑,并在 `ListState` 上启用 `searchable(true)`: ```rust impl ListDelegate for MyListDelegate { fn perform_search( &mut self, query: &str, _window: &mut Window, _cx: &mut Context>, ) -> Task<()> { self.filtered_items = self.all_items .iter() .filter(|item| item.to_lowercase().contains(&query.to_lowercase())) .cloned() .collect(); Task::ready(()) } } let state = cx.new(|cx| ListState::new(delegate, window, cx).searchable(true)); List::new(&state) ``` ### 加载状态 ```rust impl ListDelegate for MyListDelegate { fn loading(&self, _cx: &App) -> bool { self.is_loading } fn render_loading( &mut self, _window: &mut Window, _cx: &mut Context>, ) -> impl IntoElement { v_flex() .justify_center() .items_center() .py_4() .child(Skeleton::new().h_4().w_full()) .child(Skeleton::new().h_4().w_3_4()) } } ``` ### 无限滚动 ```rust impl ListDelegate for MyListDelegate { fn has_more(&self, _cx: &App) -> bool { self.has_more_data } fn load_more_threshold(&self) -> usize { 20 } fn load_more(&mut self, window: &mut Window, cx: &mut Context>) { if self.is_loading { return; } self.is_loading = true; cx.spawn_in(window, async move |view, window| { Timer::after(Duration::from_secs(1)).await; view.update_in(window, |view, _, cx| { view.delegate_mut().load_more_items(); view.delegate_mut().is_loading = false; cx.notify(); }); }).detach(); } } ``` ### 列表事件 ```rust let _subscription = cx.subscribe(&state, |_, _, event: &ListEvent, _| { match event { ListEvent::Select(ix) => { println!("Item selected at: {:?}", ix); } ListEvent::Confirm(ix) => { println!("Item confirmed at: {:?}", ix); } ListEvent::Cancel => { println!("Selection cancelled"); } } }); ``` ### 拖拽重排 `ListItem` 实现了 GPUI 的 `InteractiveElement` 和 `StatefulInteractiveElement` trait, 因此 `on_drag`、`on_drop`、`drag_over`、`on_hover` 等原生交互 API 均可直接使用: ```rust #[derive(Clone)] struct DragItem { ix: IndexPath, name: SharedString, } impl Render for DragItem { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { // 拖拽时跟随光标的预览元素。 div() .px_2() .py_1() .bg(cx.theme().accent) .text_color(cx.theme().accent_foreground) .rounded(cx.theme().radius) .child(self.name.clone()) } } // 在 `ListDelegate` 的 `render_item` 中: ListItem::new(ix) .child(Label::new(item.name.clone())) .on_drag(DragItem { ix, name: item.name.clone() }, |drag, _, _, cx| { cx.new(|_| drag.clone()) }) .drag_over::(|style, _, _, cx| style.bg(cx.theme().drop_target)) .on_drop(cx.listener(move |this, drag: &DragItem, _, cx| { this.delegate_mut().move_item(drag.ix, ix); cx.notify(); })) ``` ### 自定义空状态 ```rust impl ListDelegate for MyListDelegate { fn render_empty(&mut self, _window: &mut Window, cx: &mut Context>) -> impl IntoElement { v_flex() .size_full() .justify_center() .items_center() .gap_2() .child(Icon::new(IconName::Search).size_16().text_color(cx.theme().muted_foreground)) .child( Label::new("No items found") .text_color(cx.theme().muted_foreground) ) .child( Label::new("Try adjusting your search terms") .text_sm() .text_color(cx.theme().muted_foreground.opacity(0.7)) ) } } ``` ## 配置选项 ### 列表配置 ```rust List::new(&state) .max_h(px(400.)) .scrollbar_visible(false) .paddings(Edges::all(px(8.))) ``` ### 滚动控制 ```rust state.update(cx, |state, cx| { state.scroll_to_item( IndexPath::new(0).section(1), ScrollStrategy::Center, window, cx, ); }); state.update(cx, |state, cx| { state.scroll_to_selected_item(window, cx); }); state.update(cx, |state, cx| { state.set_selected_index(Some(IndexPath::new(5)), window, cx); }); ``` --- # Skeleton Source: /versions/v0.7.1/zh-CN/component/skeleton 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/skeleton 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Skeleton 会在真实内容尚未加载完成时显示带动画的占位块,为用户提供加载反馈,并尽量保持界面布局稳定。 ## 导入 ```rust use gpui_kit::component::skeleton::Skeleton; ``` ## 用法 ### 基础 Skeleton ```rust Skeleton::new() ``` ### 文本行占位 ```rust Skeleton::new() .w(px(250.)) .h_4() .rounded_md() v_flex() .gap_2() .child(Skeleton::new().w(px(250.)).h_4().rounded_md()) .child(Skeleton::new().w(px(200.)).h_4().rounded_md()) .child(Skeleton::new().w(px(180.)).h_4().rounded_md()) ``` ### 圆形占位 ```rust Skeleton::new() .size_12() .rounded_full() Skeleton::new() .w(px(64.)) .h(px(64.)) .rounded_full() ``` ### 矩形占位 ```rust Skeleton::new() .w(px(250.)) .h(px(125.)) .rounded_md() Skeleton::new() .w(px(120.)) .h(px(40.)) .rounded_md() ``` ### 不同形状 ```rust Skeleton::new().w(px(200.)).h_4().rounded_sm() Skeleton::new().size_20().rounded_md() Skeleton::new().w_full().h(px(200.)).rounded_lg() Skeleton::new().size_6().rounded_md() ``` ### Secondary 变体 ```rust Skeleton::new() .secondary() .w(px(200.)) .h_4() .rounded_md() ``` ## 动画 Skeleton 内置脉冲动画,行为如下: - 持续循环播放,周期为 2 秒 - 使用 bounce easing,并带有 ease-in-out 变化 - 透明度会在 100% 和 50% 之间往返变化 - 自动重复,以持续表达“内容正在加载” 该动画不可关闭,因为它本身就是加载状态的重要视觉提示。 ## 尺寸 Skeleton 没有预设的尺寸枚举,通常配合 gpui 的尺寸工具使用: ```rust Skeleton::new().h_3() Skeleton::new().h_4() Skeleton::new().h_5() Skeleton::new().h_6() Skeleton::new().w(px(100.)) Skeleton::new().w(px(200.)) Skeleton::new().w_full() Skeleton::new().w_1_2() Skeleton::new().size_4() Skeleton::new().size_8() Skeleton::new().size_12() Skeleton::new().size_16() ``` ## 示例 ### 资料卡片加载中 ```rust v_flex() .gap_4() .p_4() .border_1() .border_color(cx.theme().border) .rounded(cx.theme().radius_lg) .child( h_flex() .gap_3() .items_center() .child(Skeleton::new().size_12().rounded_full()) .child( v_flex() .gap_2() .child(Skeleton::new().w(px(120.)).h_4().rounded_md()) .child(Skeleton::new().w(px(100.)).h_3().rounded_md()) ) ) .child( v_flex() .gap_2() .child(Skeleton::new().w_full().h_4().rounded_md()) .child(Skeleton::new().w(px(200.)).h_4().rounded_md()) ) ``` ### 文章列表加载中 ```rust v_flex() .gap_6() .children((0..3).map(|_| { h_flex() .gap_4() .child(Skeleton::new().w(px(120.)).h(px(80.)).rounded_md()) .child( v_flex() .gap_2() .flex_1() .child(Skeleton::new().w_full().h_5().rounded_md()) .child(Skeleton::new().w(px(300.)).h_4().rounded_md()) .child(Skeleton::new().w(px(250.)).h_4().rounded_md()) .child(Skeleton::new().w(px(100.)).h_3().rounded_md()) ) })) ``` ### 表格行加载中 ```rust v_flex() .gap_2() .children((0..5).map(|_| { h_flex() .gap_4() .p_3() .border_b_1() .border_color(cx.theme().border) .child(Skeleton::new().size_8().rounded_full()) .child(Skeleton::new().w(px(150.)).h_4().rounded_md()) .child(Skeleton::new().w(px(200.)).h_4().rounded_md()) .child(Skeleton::new().w(px(80.)).h_4().rounded_md()) .child(Skeleton::new().w(px(60.)).h_4().rounded_md()) })) ``` ### 按钮加载态 ```rust h_flex() .gap_3() .child(Skeleton::new().w(px(80.)).h(px(36.)).rounded_md()) .child(Skeleton::new().w(px(70.)).h(px(36.)).rounded_md()) .child(Skeleton::new().size_9().rounded_md()) ``` ### 表单字段加载态 ```rust v_flex() .gap_4() .child( v_flex() .gap_1() .child(Skeleton::new().w(px(60.)).h_4().rounded_md()) .child(Skeleton::new().w_full().h(px(40.)).rounded_md()) ) .child( v_flex() .gap_1() .child(Skeleton::new().w(px(80.)).h_4().rounded_md()) .child(Skeleton::new().w_full().h(px(120.)).rounded_md()) ) ``` ### 条件加载 ```rust if loading { Skeleton::new().w(px(200.)).h_4().rounded_md() } else { div().child("Actual content here") } ``` ## 主题 Skeleton 默认使用主题中的 `skeleton` 颜色;如果未配置,则回退到 `secondary`。你可以在主题中这样覆盖: ```json { "skeleton.background": "#e2e8f0" } ``` `secondary(true)` 变体会对骨架颜色应用 50% 透明度,让占位效果更柔和。 --- # Label Source: /versions/v0.7.1/zh-CN/component/label 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/label 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Label 是一个灵活的文本标签组件,可用于表单标签、说明文字和通用文本展示。它支持次要文本、高亮、掩码显示以及丰富的样式定制。 ## 导入 ```rust use gpui_kit::component::label::{Label, HighlightsMatch}; ``` ## 用法 ### 基础标签 ```rust Label::new("This is a label") ``` ### 带次要文本 ```rust Label::new("Company Address") .secondary("(optional)") Label::new("Email Address") .secondary("(required)") ``` ### 文本对齐 ```rust Label::new("Text align left") Label::new("Text align center") .text_center() Label::new("Text align right") .text_right() ``` ### 文本高亮 ```rust Label::new("Hello World Hello") .highlights("Hello") Label::new("Hello World") .highlights(HighlightsMatch::Prefix("Hello".into())) Label::new("Company Name") .secondary("(optional)") .highlights("Company") ``` ### 颜色与字体样式 ```rust use gpui_kit::component::green_500; Label::new("Color Label") .text_color(green_500()) Label::new("Font Size Label") .text_size(px(20.)) .font_semibold() .line_height(rems(1.8)) ``` ### 掩码文本 ```rust Label::new("9,182,1 USD") .text_2xl() .masked(true) Label::new("500 USD") .text_xl() .masked(self.masked) ``` ### 多行文本 ```rust div().w(px(200.)).child( Label::new( "Label should support text wrap in default, \ if the text is too long, it should wrap to the next line." ) .line_height(rems(1.8)) ) ``` ### 不同尺寸 ```rust Label::new("Extra Large").text_2xl() Label::new("Large").text_xl() Label::new("Medium").text_base() Label::new("Small").text_sm() Label::new("Extra Small").text_xs() ``` ## API 参考 ### Label | 方法 | 说明 | | ------------------- | ------------------------------------------------------------- | | `new(text)` | 使用文本创建标签 | | `secondary(text)` | 添加次要文本,常用于 optional 或 required 标识 | | `masked(bool)` | 使用圆点字符隐藏文本 | | `highlights(match)` | 高亮匹配内容 | ### HighlightsMatch | 变体 | 说明 | | -------------- | ------------------------------------------------ | | `Full(text)` | 高亮所有匹配内容 | | `Prefix(text)` | 仅在文本开头匹配时高亮 | | 方法 | 说明 | | ------------- | ------------------------------- | | `as_str()` | 获取匹配字符串 | | `is_prefix()` | 判断是否为前缀匹配 | ### 样式方法(来自 Styled trait) | 方法 | 说明 | | --------------------- | --------------------------- | | `text_color(color)` | 设置文字颜色 | | `text_size(size)` | 设置字体大小 | | `text_center()` | 居中对齐 | | `text_right()` | 右对齐 | | `font_semibold()` | 半粗体 | | `font_bold()` | 粗体 | | `line_height(height)` | 设置行高 | | `text_xs()` | 超小字号 | | `text_sm()` | 小字号 | | `text_base()` | 默认字号 | | `text_lg()` | 大字号 | | `text_xl()` | 超大字号 | | `text_2xl()` | 2 倍大字号 | ## 示例 ### 表单标签 ```rust Label::new("Email Address") .secondary("*") .text_color(cx.theme().destructive) Label::new("Phone Number") .secondary("(optional)") Label::new("Password") .secondary("(minimum 8 characters)") ``` ### 搜索高亮 ```rust let search_term = "Hello"; Label::new("Hello World Hello Universe") .highlights(search_term) ``` ### 敏感信息 ```rust h_flex() .child( Label::new("$9,182.50 USD") .text_2xl() .masked(self.is_masked) ) .child( Button::new("toggle-mask") .ghost() .icon(if self.is_masked { IconName::EyeOff } else { IconName::Eye }) .on_click(|this, _, _, _| { this.is_masked = !this.is_masked; }) ) ``` ### 多语言支持 ```rust Label::new("这是一个标签") Label::new("こんにちは世界") Label::new("🌍 Hello World 🚀") ``` ### 状态提示 ```rust Label::new("✓ Verified") .text_color(cx.theme().success) Label::new("⚠ Pending Review") .text_color(cx.theme().warning) Label::new("✗ Failed") .text_color(cx.theme().destructive) ``` ### 自定义布局 ```rust h_flex() .justify_between() .child(Label::new("Total Amount")) .child(Label::new("$1,234.56").font_semibold()) v_flex() .gap_2() .child(Label::new("Name:").font_semibold()) .child(Label::new("John Doe")) .child(Label::new("Email:").font_semibold()) .child(Label::new("john@example.com")) ``` --- # Collapsible Source: /versions/v0.7.1/zh-CN/component/collapsible 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/collapsible 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Collapsible 是一个用于展开和收起内容的交互式组件。 ## 导入 ```rust use gpui_kit::component::collapsible::Collapsible; ``` ## 用法 ### 基础用法 ```rust Collapsible::new() .max_w_128() .gap_1() .open(self.open) .child( "This is a collapsible component. \ Click the header to expand or collapse the content.", ) .content( "This is the full content of the Collapsible component. \ It is only visible when the component is expanded. \n\ You can put any content you like here, including text, images, \ or other UI elements.", ) .child( h_flex().justify_center().child( Button::new("toggle1") .icon(IconName::ChevronDown) .label("Show more") .when(open, |this| { this.icon(IconName::ChevronUp).label("Show less") }) .xsmall() .link() .on_click({ cx.listener(move |this, _, _, cx| { this.open = !this.open; cx.notify(); }) }), ), ) ``` 可以通过 `open` 方法控制当前是否展开。若值为 `false`,则通过 `content` 添加的子内容会被隐藏。 ### 展开动画 使用稳定 motion ID 可选择启用支持中途反向的测量式高度展开: ```rust Collapsible::new() .motion_id("advanced-options") .open(self.open) .content(options) ``` 启用后,内容在关闭时仍保持挂载,以便测量自然高度,并可在动画途中切换时立即反向。不调用 `motion_id` 时仍使用即时挂载/卸载行为。timing、reduced motion 和性能细节见 [GPUI Base 动画与动效](/versions/v0.7.1/zh-CN/base/motion)。 [Collapsible]: https://docs.rs/gpui-component/latest/gpui_component/collapsible/struct.Collapsible.html --- # Calendar Source: /versions/v0.7.1/zh-CN/component/calendar 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/calendar 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Calendar 是一个独立的日历组件,支持单日选择、日期区间选择、多月视图、禁用日期规则以及完整的键盘导航能力。 - [CalendarState] 负责状态与选择管理 - [Calendar] 负责渲染日历界面 ## 导入 ```rust use gpui_kit::component::{ calendar::{Calendar, CalendarState, CalendarEvent, Date, Matcher}, }; ``` ## 用法 ### 基础日历 ```rust let state = cx.new(|cx| CalendarState::new(window, cx)); Calendar::new(&state) ``` ### 初始日期 ```rust use chrono::Local; let state = cx.new(|cx| { let mut state = CalendarState::new(window, cx); state.set_date(Local::now().naive_local().date(), window, cx); state }); Calendar::new(&state) ``` ### 日期区间 ```rust use chrono::{Local, Days}; let state = cx.new(|cx| { let mut state = CalendarState::new(window, cx); let now = Local::now().naive_local().date(); state.set_date( Date::Range(Some(now), now.checked_add_days(Days::new(7))), window, cx ); state }); Calendar::new(&state) ``` ### 多月显示 ```rust Calendar::new(&state) .number_of_months(2) Calendar::new(&state) .number_of_months(3) ``` ### 尺寸 ```rust Calendar::new(&state).large() Calendar::new(&state) Calendar::new(&state).small() ``` ## 日期限制 ### 禁用周末 ```rust let state = cx.new(|cx| { CalendarState::new(window, cx) .disabled_matcher(vec![0, 6]) }); ``` ### 禁用日期区间 ```rust use chrono::{Local, Days}; let now = Local::now().naive_local().date(); let state = cx.new(|cx| { CalendarState::new(window, cx) .disabled_matcher(Matcher::range( Some(now), now.checked_add_days(Days::new(7)), )) }); ``` ### 自定义禁用规则 ```rust let state = cx.new(|cx| { CalendarState::new(window, cx) .disabled_matcher(Matcher::custom(|date| { date.weekday() == chrono::Weekday::Mon })) }); ``` ## 月份与年份导航 Calendar 自带这些导航能力: - 上一月 / 下一月按钮 - 点击月份切换月视图 - 点击年份切换年视图 - 在年视图中按页浏览年份 ### 自定义年份范围 ```rust let state = cx.new(|cx| { CalendarState::new(window, cx) .year_range((2020, 2030)) }); ``` ## 监听选择事件 ```rust let state = cx.new(|cx| CalendarState::new(window, cx)); cx.subscribe(&state, |view, _, event, _| { match event { CalendarEvent::Selected(date) => { match date { Date::Single(Some(selected_date)) => { println!("Date selected: {}", selected_date); } Date::Range(Some(start), Some(end)) => { println!("Range selected: {} to {}", start, end); } _ => {} } } } }); ``` ## 示例 ### 仅工作日 ```rust use chrono::Weekday; let state = cx.new(|cx| { CalendarState::new(window, cx) .disabled_matcher(Matcher::custom(|date| { matches!(date.weekday(), Weekday::Sat | Weekday::Sun) })) }); ``` ### 假期禁用 ```rust use chrono::NaiveDate; use std::collections::HashSet; let holidays: HashSet = [ NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), NaiveDate::from_ymd_opt(2024, 7, 4).unwrap(), NaiveDate::from_ymd_opt(2024, 12, 25).unwrap(), ].into_iter().collect(); ``` ### 多月区间选择 ```rust let state = cx.new(|cx| { let mut state = CalendarState::new(window, cx); state.set_date(Date::Range(None, None), window, cx); state }); Calendar::new(&state) .number_of_months(3) ``` [Calendar]: https://docs.rs/gpui-component/latest/gpui_component/calendar/struct.Calendar.html [CalendarState]: https://docs.rs/gpui-component/latest/gpui_component/calendar/struct.CalendarState.html [RangeMatcher]: https://docs.rs/gpui-component/latest/gpui_component/calendar/struct.RangeMatcher.html --- # Sheet Source: /versions/v0.7.1/zh-CN/component/sheet 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/sheet 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Sheet 是一种从屏幕边缘滑出的面板组件,也常被用作侧栏、抽屉或临时内容面板。它适合承载导航菜单、表单、设置项和辅助信息,而不会直接占用主视图空间。 ## 导入 ```rust use gpui_kit::component::WindowExt; use gpui_kit::component::Placement; ``` ## 用法 ### 抽屉的渲染位置 窗口的 [Root](/versions/v0.7.1/zh-CN/component/root) 会自动挂载并渲染抽屉层。使用 `gpui_kit::open_window` 打开窗口,或将应用视图包裹在 `Root::new` 中即可,无需在视图中手动渲染浮层。 ### 基础 Sheet ```rust window.open_sheet(cx, |sheet, _, _| { sheet .title("Navigation") .child("Sheet content goes here") }) ``` ### 不同方向 ```rust window.open_sheet_at(Placement::Left, cx, |sheet, _, _| { sheet.title("Left Sheet") }) window.open_sheet_at(Placement::Right, cx, |sheet, _, _| { sheet.title("Right Sheet") }) ``` ### 自定义尺寸 ```rust window.open_sheet(cx, |sheet, _, _| { sheet .title("Wide Sheet") .size(px(500.)) .child("This sheet is 500px wide") }) ``` ### 表单内容 ```rust let input = cx.new(|cx| InputState::new(window, cx)); let date = cx.new(|cx| DatePickerState::new(window, cx)); window.open_sheet(cx, |sheet, _, _| { sheet .title("User Profile") .child( v_flex() .gap_4() .child("Enter your information:") .child(Input::new(&input).placeholder("Full Name")) .child(DatePicker::new(&date).placeholder("Date of Birth")) ) .footer( h_flex() .gap_3() .child(Button::new("save").primary().label("Save")) .child(Button::new("cancel").label("Cancel")) ) }) ``` ### Overlay 与关闭行为 ```rust window.open_sheet(cx, |sheet, _, _| { sheet .title("Settings") .overlay(true) .overlay_closable(true) .child("Sheet settings content") }) ``` ### 可调整大小 ```rust window.open_sheet(cx, |sheet, _, _| { sheet .title("Resizable Panel") .resizable(true) .size(px(300.)) .child("You can resize this sheet by dragging the edge") }) ``` ### 自定义位置偏移 ```rust window.open_sheet(cx, |sheet, _, _| { sheet .title("Below Title Bar") .margin_top(px(32.)) .child("This sheet appears below the title bar") }) ``` ### 自定义样式 ```rust window.open_sheet(cx, |sheet, _, cx| { sheet .title("Styled Sheet") .bg(cx.theme().accent) .text_color(cx.theme().accent_foreground) .border_color(cx.theme().primary) .child("Custom styled sheet content") }) ``` ### 主动关闭 ```rust Button::new("close") .label("Close Sheet") .on_click(|_, window, cx| { window.close_sheet(cx); }) window.close_sheet(cx); ``` ## API 参考 ### Window 扩展 - `open_sheet(cx, fn)` 默认在右侧打开 - `open_sheet_at(placement, cx, fn)` 在指定方向打开 - `close_sheet(cx)` 关闭当前 Sheet ### 常用 Builder 方法 - `title(str)` - `child(el)` - `footer(el)` - `size(px)` - `margin_top(px)` - `resizable(bool)` - `overlay(bool)` - `overlay_closable(bool)` - `on_close(fn)` ## 最佳实践 1. 左右方向更适合导航和设置面板 2. 上下方向更适合临时辅助内容 3. 尽量提供清晰标题和明显关闭路径 4. 长内容建议分组排版 5. 对于复杂内容,尽量延迟加载内部数据 --- # Select Source: /versions/v0.7.1/zh-CN/component/select 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/select 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 在 `<= 0.3.x` 中,这个组件的名字是 `Dropdown`。 现在已经改名为 `Select`,以便更准确地表达它的用途。 Select 允许用户从一组选项中选择一个值。 它支持搜索、分组、自定义渲染和多种状态,并内建键盘导航和可访问性支持。 如需自定义触发器渲染或多选功能,请参阅 [Combobox](combobox)。 ## 导入 ```rust use gpui_kit::component::select::{ Select, SelectState, SelectItem, SelectDelegate, SelectEvent, SearchableVec, SelectGroup }; ``` ## 用法 ### 基础用法 `SelectState` 的第一个类型参数表示状态中的选项集合,这些选项需要实现 [SelectItem] trait。 框架已经为 `String`、`SharedString` 和 `&'static str` 提供了默认实现。 ```rust let state = cx.new(|cx| { SelectState::new( vec!["Apple", "Orange", "Banana"], Some(IndexPath::default()), window, cx, ) }); Select::new(&state) ``` ### Placeholder ```rust let state = cx.new(|cx| { SelectState::new( vec!["Rust", "Go", "JavaScript"], None, window, cx, ) }); Select::new(&state) .placeholder("Select a language...") ``` ### 可访问性 给控件一个不随选中项变化的名称: ```rust Select::new(&state) .accessibility_label("Programming language") .placeholder("Choose a language") ``` 可访问值取自已提交选项的 `title()` 以及 `title_prefix`。自定义的 `display_title()` 仍然只用于视觉呈现。搜索不会改变这个已提交的值。未选中时,可访问值使用 placeholder。 启用状态的控件会暴露可访问的激活操作,用于打开或关闭弹层。 ### 可搜索 启用 `searchable(true)` 后,下拉菜单中会出现搜索能力: ```rust let fruits = SearchableVec::new(vec![ "Apple", "Orange", "Banana", "Grape", "Pineapple", ]); let state = cx.new(|cx| { SelectState::new(fruits, None, window, cx).searchable(true) }); Select::new(&state) .icon(IconName::Search) ``` ### 自定义 SelectItem 如果你希望选项携带更复杂的数据结构,或者希望 `selected_value` 返回自定义类型,可以自己实现 `SelectItem`。 同时也可以通过重写 `matches` 定制搜索逻辑。 ```rust #[derive(Debug, Clone)] struct Country { name: SharedString, code: SharedString, } impl SelectItem for Country { type Value = SharedString; fn title(&self) -> SharedString { self.name.clone() } fn display_title(&self) -> Option { Some(format!("{} ({})", self.name, self.code).into_any_element()) } fn value(&self) -> &Self::Value { &self.code } fn matches(&self, query: &str) -> bool { self.name.to_lowercase().contains(&query.to_lowercase()) || self.code.to_lowercase().contains(&query.to_lowercase()) } } ``` ### 分组 ```rust let mut grouped_items = SearchableVec::new(vec![]); grouped_items.push( SelectGroup::new("A") .items(vec![ Country { name: "Australia".into(), code: "AU".into() }, Country { name: "Austria".into(), code: "AT".into() }, ]) ); grouped_items.push( SelectGroup::new("B") .items(vec![ Country { name: "Brazil".into(), code: "BR".into() }, Country { name: "Belgium".into(), code: "BE".into() }, ]) ); let state = cx.new(|cx| { SelectState::new(grouped_items, None, window, cx) }); Select::new(&state) ``` ### 尺寸 ```rust Select::new(&state).large() Select::new(&state) Select::new(&state).small() ``` ### 禁用态 ```rust Select::new(&state).disabled(true) ``` ### 可清空 ```rust Select::new(&state) .cleanable(true) ``` ### 自定义外观 ```rust Select::new(&state) .w(px(320.)) .menu_width(px(400.)) .menu_max_h(rems(10.)) .appearance(false) .title_prefix("Country: ") ``` ### 空状态 ```rust let state = cx.new(|cx| { SelectState::new(Vec::::new(), None, window, cx) }); Select::new(&state) .empty( h_flex() .h_24() .justify_center() .text_color(cx.theme().muted_foreground) .child("No options available") ) ``` ### 事件 ```rust cx.subscribe_in(&state, window, |view, state, event, window, cx| { match event { SelectEvent::Confirm(value) => { if let Some(selected_value) = value { println!("Selected: {:?}", selected_value); } else { println!("Selection cleared"); } } } }); ``` ### 更新选中项和数据 ```rust state.update(cx, |state, cx| { state.set_selected_index(Some(IndexPath::default().row(2)), window, cx); }); state.update(cx, |state, cx| { state.set_selected_value(&"US".into(), window, cx); }); let current_value = state.read(cx).selected_value(); ``` 更新选项列表: ```rust state.update(cx, |state, cx| { let new_items = vec!["New Option 1".into(), "New Option 2".into()]; state.set_items(new_items, window, cx); }); ``` ## 示例 ### 语言选择器 ```rust let languages = SearchableVec::new(vec![ "Rust".into(), "TypeScript".into(), "Go".into(), "Python".into(), "JavaScript".into(), ]); let state = cx.new(|cx| { SelectState::new(languages, None, window, cx) }); Select::new(&state) .placeholder("Select language...") .title_prefix("Language: ") ``` ### 与 Input 组合 ```rust h_flex() .border_1() .border_color(cx.theme().input) .rounded(cx.theme().radius_lg) .w_full() .gap_1() .child( div().w(px(140.)).child( Select::new(&country_state) .appearance(false) .py_2() .pl_3() ) ) .child(Separator::vertical()) .child( div().flex_1().child( Input::new(&phone_input) .appearance(false) .placeholder("Phone number") .pr_3() .py_2() ) ) ``` ## 键盘快捷键 | 按键 | 行为 | | --- | --- | | `Tab` | 聚焦到 Select | | `Enter` | 打开菜单或确认当前项 | | `Up/Down` | 在选项间移动 | | `Escape` | 关闭菜单 | | `Space` | 打开菜单 | ## 主题 Select 会使用当前主题中的这些 token: - `background` - 输入区域背景 - `input` - 边框颜色 - `foreground` - 文本颜色 - `muted_foreground` - placeholder 与禁用态文字 - `accent` - 当前项背景 - `accent_foreground` - 当前项文字 - `border` - 菜单边框 - `radius` - 圆角 [SelectItem]: https://docs.rs/gpui-component/latest/gpui_component/select/trait.SelectItem.html --- # Clipboard Source: /versions/v0.7.1/zh-CN/component/clipboard 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/clipboard 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Clipboard 组件提供了一个简单的复制按钮,可将文本或其它数据复制到用户剪贴板。它默认显示复制图标,在复制成功后会切换为勾选图标。组件既支持静态值,也支持通过回调动态生成复制内容。 这个图标按钮默认使用本地化的“复制”作为无障碍名称。如果需要更明确地说明复制对象,可用 `accessibility_label` 指定名称,例如“复制 API 密钥”。tooltip 只是辅助提示,不会设置无障碍名称。 ## 导入 ```rust use gpui_kit::component::clipboard::Clipboard; ``` ## 用法 ### 基础 Clipboard ```rust Clipboard::new("my-clipboard") .value("Text to copy") .on_copied(|value, window, cx| { window.push_notification(format!("Copied: {}", value), cx) }) ``` ### 动态值 `value_fn` 允许你在用户点击复制按钮时再动态生成内容: - 适合依赖当前应用状态的值 - 也适合那些计算开销较大、不想在每次渲染时都计算的内容 ```rust let state = some_state.clone(); Clipboard::new("dynamic-clipboard") .value_fn(move |_, cx| { state.read(cx).get_current_value() }) .on_copied(|value, window, cx| { window.push_notification(format!("Copied: {}", value), cx) }) ``` ### 自定义组合内容 ```rust use gpui_kit::component::label::Label; h_flex() .gap_2() .child(Label::new("Share URL")) .child(Icon::new(IconName::Share)) .child( Clipboard::new("custom-clipboard") .value("https://example.com") ) ``` ### 用在输入框中 Clipboard 很适合作为输入框后缀: ```rust use gpui_kit::component::input::{InputState, Input}; let url_state = cx.new(|cx| InputState::new(window, cx).default_value("https://github.com")); Input::new(&url_state) .suffix( Clipboard::new("url-clipboard") .value_fn({ let state = url_state.clone(); move |_, cx| state.read(cx).value() }) .on_copied(|value, window, cx| { window.push_notification(format!("URL copied: {}", value), cx) }) ) ``` ## API 参考 - [Clipboard] ## 示例 ### 复制简单文本 ```rust Clipboard::new("simple") .value("Hello, World!") ``` ### 带反馈提示 ```rust h_flex() .gap_2() .child(Label::new("Your API Key:")) .child( Clipboard::new("feedback") .value("sk-1234567890abcdef") .accessibility_label("复制 API 密钥") .on_copied(|_, window, cx| { window.push_notification("API key copied to clipboard", cx) }) ) ``` ### 表单字段集成 ```rust use gpui_kit::component::{ input::{InputState, Input}, h_flex, label::Label }; let api_key = "sk-1234567890abcdef"; h_flex() .gap_2() .items_center() .child(Label::new("API Key:")) .child( Input::new(&input_state) .value(api_key) .readonly(true) .suffix( Clipboard::new("api-key-copy") .value(api_key) .on_copied(|_, window, cx| { window.push_notification("API key copied!", cx) }) ) ) ``` ### 复制动态内容 ```rust struct AppState { current_url: String, } let app_state = cx.new(|_| AppState { current_url: "https://example.com".to_string() }); Clipboard::new("current-url") .value_fn({ let state = app_state.clone(); move |_, cx| { SharedString::from(state.read(cx).current_url.clone()) } }) .on_copied(|url, window, cx| { window.push_notification(format!("Shared: {}", url), cx) }) ``` ## 数据类型 Clipboard 当前主要支持复制文本字符串,内部使用 GPUI 的 `ClipboardItem::new_string()`,可处理: - 纯文本 - UTF-8 编码内容 - 跨平台剪贴板写入 [Clipboard]: https://docs.rs/gpui-component/latest/gpui_component/clipboard/struct.Clipboard.html --- # Theme Source: /versions/v0.7.1/zh-CN/component/theme 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/theme 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 所有组件都支持内置主题系统。[ActiveTheme] trait 用于访问当前主题中的颜色值: ```rs use gpui_kit::component::{ActiveTheme as _}; // Access theme colors in your components cx.theme().primary cx.theme().background cx.theme().foreground ``` 因此,如果你希望组件使用当前主题的颜色,组件或视图就需要运行在带有 [App] 上下文的环境中。 ## 渐变背景 主题颜色值继续兼容既有的字符串格式: ```json { "colors": { "button.primary.background": "#4F46E5" } } ``` 支持渐变渲染的背景 token 也可以使用 CSS 风格的两段线性渐变: ```json { "colors": { "button.primary.background": "linear-gradient(135deg, #4F46E5, #06B6D4)", "button.primary.hover.background": "linear-gradient(to right, red-500 25%, blue-600 75%)" } } ``` `cx.theme().button_primary` 等顶层字段仍然是纯色 `Hsla`,保持兼容。需要完整 resolved token 时使用 `cx.theme().tokens.button_primary`;其中 `.color` 是纯色代表色,`.background` 是实际配置的 `Background`,包含渐变。 ## Theme Registry 仓库在 [themes](https://github.com/longbridge/gpui-kit/tree/main/themes) 目录下内置了 20+ 主题。 你可以通过 [ThemeRegistry] 来加载和监听这些主题文件: 从 registry 查找主题时使用 `themes` 数组中条目的 `name`,例如 `Ayu Light`。 ```rs use std::path::PathBuf; use gpui_kit::{App, SharedString}; use gpui_kit::component::{Theme, ThemeRegistry}; pub fn init(cx: &mut App) { let theme_name = SharedString::from("Ayu Light"); // Load and watch themes from ./themes directory if let Err(err) = ThemeRegistry::watch_dir(PathBuf::from("./themes"), cx, move |cx| { if let Some(theme) = ThemeRegistry::global(cx) .themes() .get(&theme_name) .cloned() { Theme::update(cx, |current| current.apply_config(&theme)); } }) { tracing::error!("Failed to watch themes directory: {}", err); } } ``` [ActiveTheme]: https://docs.rs/gpui-component/latest/gpui_component/theme/trait.ActiveTheme.html [ThemeRegistry]: https://docs.rs/gpui-component/latest/gpui_component/theme/struct.ThemeRegistry.html [App]: https://docs.rs/gpui/latest/gpui/struct.App.html --- # Progress Source: /versions/v0.7.1/zh-CN/component/progress 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/progress 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Progress 组件用于直观展示任务完成百分比。库中提供两种形式: - **[Progress](#progress)**:线性水平进度条 - **[ProgressCircle](#progresscircle)**:环形进度指示器 这两个组件都支持数值变化动画、加载中(不确定进度)状态、自定义颜色以及自动适配当前主题。 ## Progress ```rust use gpui_kit::component::progress::Progress; ``` ### 用法 ```rust Progress::new("my-progress") .value(75.0) ``` ### 不同进度值 ```rust Progress::new("progress-0").value(0.0) Progress::new("progress-25").value(25.0) Progress::new("progress-75").value(75.0) Progress::new("progress-100").value(100.0) ``` ### 加载状态 当实际进度未知时,可通过 `.loading(true)` 显示不确定进度动画。启用后会忽略 `value`。 ```rust Progress::new("loading").loading(true) Progress::new("my-progress") .loading(self.is_loading) .value(self.progress) ``` ### 尺寸 `Progress` 实现了 `Sizable` trait: ```rust Progress::new("xs").value(50.0).xsmall() Progress::new("sm").value(50.0).small() Progress::new("md").value(50.0) Progress::new("lg").value(50.0).large() ``` ### 自定义样式 组件实现了 `Styled` trait,可自定义高度、圆角、颜色和边框: ```rust Progress::new("custom") .value(32.0) .h(px(16.)) .rounded(px(2.)) .color(cx.theme().green_light) .border_2() .border_color(cx.theme().green) ``` ### 动态更新进度 ```rust struct MyView { value: f32, is_loading: bool, } impl Render for MyView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .gap_3() .child( h_flex() .gap_2() .child( Button::new("toggle-loading") .label("Loading") .selected(self.is_loading) .on_click(cx.listener(|this, _, _, cx| { this.is_loading = !this.is_loading; cx.notify(); })), ) .child(Button::new("inc").icon(IconName::Plus).on_click( cx.listener(|this, _, _, _| { this.value = (this.value + 10.).min(100.); }), )), ) .child( Progress::new("progress") .value(self.value) .loading(self.is_loading), ) } } ``` ### API 参考 | 方法 | 类型 | 说明 | |---|---|---| | `new(id)` | `ElementId` | 创建新的进度条 | | `value(v)` | `f32` | 设置进度值,范围 0 到 100,超出时会自动裁剪 | | `loading(v)` | `bool` | 开启不确定进度动画;为 `true` 时忽略 `value` | | `color(c)` | `impl Into` | 覆盖填充颜色,默认使用 `theme.progress_bar` | | `xsmall()` / `small()` / `large()` | — | 通过 `Sizable` 设置预定义高度 | | `Styled` trait methods | — | 自定义高度、圆角、边框等 | ## ProgressCircle 环形进度指示器适合用于紧凑场景、内联状态或下载上传进度。 ```rust use gpui_kit::component::progress::ProgressCircle; ``` ### 用法 ```rust ProgressCircle::new("circle").value(50.0) ``` ### 加载状态 ```rust ProgressCircle::new("loading").loading(true) ProgressCircle::new("circle") .loading(self.is_loading) .value(self.progress) ``` ### 尺寸 `ProgressCircle` 也实现了 `Sizable` trait。命名尺寸映射到固定像素值,也可以通过 `.size(px(n))` 设置自定义尺寸: ```rust ProgressCircle::new("xs").value(50.0).xsmall() ProgressCircle::new("sm").value(50.0).small() ProgressCircle::new("md").value(50.0) ProgressCircle::new("lg").value(50.0).large() ProgressCircle::new("xl").value(50.0).size_20() ``` ### 自定义颜色 ```rust ProgressCircle::new("green").value(75.0).color(cx.theme().green) ProgressCircle::new("yellow").value(40.0).color(cx.theme().yellow) ProgressCircle::new("primary").value(60.0).color(cx.theme().primary) ``` ### 内部内容 `ProgressCircle` 实现了 `ParentElement`,所以你可以在环形内部放置内容: ```rust ProgressCircle::new("circle-with-label") .value(self.value) .size_20() .child( v_flex() .size_full() .items_center() .justify_center() .gap_1() .child( div() .child(format!("{}%", self.value as i32)) .text_color(cx.theme().progress_bar), ) .child(div().child("Loading").text_xs()), ) ``` ### 和文本一起内联显示 ```rust h_flex() .gap_2() .items_center() .child( ProgressCircle::new("download") .color(cx.theme().primary) .value(self.progress) .size_4(), ) .child("Downloading...") ``` ### API 参考 | 方法 | 类型 | 说明 | |---|---|---| | `new(id)` | `ElementId` | 创建新的环形进度组件 | | `value(v)` | `f32` | 设置进度值,范围 0 到 100,超出时会自动裁剪 | | `loading(v)` | `bool` | 开启不确定进度动画;为 `true` 时忽略 `value` | | `color(c)` | `impl Into` | 覆盖弧线颜色,默认使用 `theme.progress_bar` | | `xsmall()` / `small()` / `large()` | — | 通过 `Sizable` 设置预定义尺寸 | | `size(px(n))` | `Pixels` | 设置自定义尺寸 | | `ParentElement` | — | 允许在环形内部放置内容 | ## 示例 ### 文件上传 ```rust struct FileUpload { uploaded: u64, total: u64, } impl FileUpload { fn progress(&self) -> f32 { if self.total == 0 { return 0.0; } (self.uploaded as f32 / self.total as f32) * 100.0 } fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { v_flex() .gap_2() .child( h_flex() .justify_between() .child("Uploading...") .child(format!("{:.0}%", self.progress())), ) .child(Progress::new("upload").value(self.progress())) } } ``` ### 初始化加载状态 ```rust struct AppInit { loading: bool, progress: f32, } impl Render for AppInit { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .gap_3() .child( h_flex() .gap_2() .items_center() .child( ProgressCircle::new("init-circle") .loading(self.loading) .value(self.progress) .size_4(), ) .child(if self.loading { "Initializing..." } else { "Ready" }), ) .child( Progress::new("init-bar") .loading(self.loading) .value(self.progress), ) } } ``` ### 多步骤流程 ```rust struct Install { step: usize, total: usize, step_progress: f32, } impl Install { fn overall(&self) -> f32 { if self.total == 0 { return 0.0; } (self.step as f32 + self.step_progress / 100.0) / self.total as f32 * 100.0 } fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { v_flex() .gap_2() .child( h_flex() .justify_between() .child(format!("Package {}/{}", self.step + 1, self.total)) .child(format!("{:.0}%", self.overall())), ) .child(Progress::new("overall").value(self.overall())) .child( h_flex() .gap_2() .items_center() .child(Progress::new("package").value(self.step_progress).small()) .child("Current package"), ) } } ``` --- # OtpInput Source: /versions/v0.7.1/zh-CN/component/otp-input 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/otp-input 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 OtpInput 是为一次性验证码(OTP)设计的输入组件,会以网格方式显示多个输入框,适合短信验证码、验证器 App 动态码以及 PIN 码输入场景。 ## 导入 ```rust use gpui_kit::component::input::{OtpInput, OtpState}; ``` ## 用法 ### 基础 OTP 输入 ```rust let otp_state = cx.new(|cx| OtpState::new(6, window, cx)); OtpInput::new(&otp_state) ``` ### 默认值 ```rust let otp_state = cx.new(|cx| OtpState::new(6, window, cx) .default_value("123456") ); OtpInput::new(&otp_state) ``` ### 掩码输入 ```rust let otp_state = cx.new(|cx| OtpState::new(6, window, cx) .masked(true) .default_value("123456") ); OtpInput::new(&otp_state) ``` ### 不同尺寸 ```rust OtpInput::new(&otp_state).small() OtpInput::new(&otp_state) OtpInput::new(&otp_state).large() OtpInput::new(&otp_state).with_size(px(55.)) ``` ### 分组布局 ```rust OtpInput::new(&otp_state).groups(1) OtpInput::new(&otp_state).groups(2) OtpInput::new(&otp_state).groups(3) ``` ### 禁用状态 ```rust OtpInput::new(&otp_state).disabled(true) ``` ### 不同长度的验证码 ```rust let pin_state = cx.new(|cx| OtpState::new(4, window, cx)); OtpInput::new(&pin_state).groups(1) let sms_state = cx.new(|cx| OtpState::new(6, window, cx)); OtpInput::new(&sms_state) let auth_state = cx.new(|cx| OtpState::new(8, window, cx)); OtpInput::new(&auth_state).groups(2) ``` ### 处理 OTP 事件 ```rust let otp_state = cx.new(|cx| OtpState::new(6, window, cx)); cx.subscribe(&otp_state, |this, state, event: &InputEvent, cx| { match event { InputEvent::Change => { let code = state.read(cx).value(); if code.len() == 6 { println!("Complete OTP: {}", code); this.verify_otp(&code, cx); } } InputEvent::Focus => println!("OTP input focused"), InputEvent::Blur => println!("OTP input lost focus"), _ => {} } }); ``` ### 程序化控制 ```rust otp_state.update(cx, |state, cx| { state.set_value("123456", window, cx); }); otp_state.update(cx, |state, cx| { state.set_masked(true, window, cx); }); otp_state.update(cx, |state, cx| { state.focus(window, cx); }); let current_value = otp_state.read(cx).value(); ``` ## API 参考 ### OtpState | 方法 | 说明 | | ------------------------------ | -------------------------------------------- | | `new(length, window, cx)` | 创建指定长度的 OTP 状态 | | `default_value(str)` | 设置初始值 | | `masked(bool)` | 开启掩码显示 | | `set_value(str, window, cx)` | 以代码方式设置值 | | `value()` | 获取当前值 | | `set_masked(bool, window, cx)` | 切换掩码状态 | | `focus(window, cx)` | 聚焦输入框 | | `focus_handle(cx)` | 获取焦点句柄 | ### OtpInput | 方法 | 说明 | | ---------------- | ---------------------------------------- | | `new(state)` | 使用状态实体创建 OTP 输入组件 | | `groups(n)` | 设置可视分组数量,默认值为 2 | | `disabled(bool)` | 设置禁用状态 | | `small()` | 小尺寸 | | `large()` | 大尺寸 | | `with_size(px)` | 自定义单格尺寸 | ### InputEvent | 事件 | 说明 | | -------- | ------------------------------------------------- | | `Change` | 所有数字输入完毕后触发 | | `Focus` | 输入框获得焦点 | | `Blur` | 输入框失去焦点 | ## 示例 ### 短信验证码 ```rust struct SmsVerification { otp_state: Entity, phone_number: String, is_verifying: bool, } impl SmsVerification { fn new(window: &mut Window, cx: &mut Context) -> Self { let otp_state = cx.new(|cx| OtpState::new(6, window, cx)); cx.subscribe(&otp_state, |this, state, event: &InputEvent, cx| { if let InputEvent::Change = event { let code = state.read(cx).value(); this.verify_sms_code(&code, cx); } }); Self { otp_state, phone_number: "+1234567890".to_string(), is_verifying: false, } } fn verify_sms_code(&mut self, code: &str, cx: &mut Context) { self.is_verifying = true; println!("Verifying SMS code: {}", code); cx.notify(); } } impl Render for SmsVerification { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .gap_4() .child(format!("Enter the 6-digit code sent to {}", self.phone_number)) .child(OtpInput::new(&self.otp_state)) .when(self.is_verifying, |this| { this.child("Verifying...") }) } } ``` ### 双因素认证 ```rust struct TwoFactorAuth { otp_state: Entity, is_masked: bool, } impl TwoFactorAuth { fn new(window: &mut Window, cx: &mut Context) -> Self { let otp_state = cx.new(|cx| OtpState::new(6, window, cx) .masked(true) ); Self { otp_state, is_masked: true, } } fn toggle_visibility(&mut self, window: &mut Window, cx: &mut Context) { self.is_masked = !self.is_masked; self.otp_state.update(cx, |state, cx| { state.set_masked(self.is_masked, window, cx); }); } } impl Render for TwoFactorAuth { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .gap_4() .child("Enter your authenticator code") .child(OtpInput::new(&self.otp_state)) .child( Button::new("toggle-visibility") .label(if self.is_masked { "Show" } else { "Hide" }) .on_click(cx.listener(Self::toggle_visibility)) ) } } ``` ### PIN 码输入 ```rust struct PinEntry { pin_state: Entity, attempts: usize, max_attempts: usize, } impl PinEntry { fn new(window: &mut Window, cx: &mut Context) -> Self { let pin_state = cx.new(|cx| OtpState::new(4, window, cx) .masked(true) ); cx.subscribe(&pin_state, |this, state, event: &InputEvent, cx| { if let InputEvent::Change = event { let pin = state.read(cx).value(); this.verify_pin(&pin, cx); } }); Self { pin_state, attempts: 0, max_attempts: 3, } } fn verify_pin(&mut self, pin: &str, cx: &mut Context) { self.attempts += 1; if pin == "1234" { println!("PIN verified successfully!"); } else { println!("Incorrect PIN. Attempts: {}/{}", self.attempts, self.max_attempts); self.pin_state.update(cx, |state, cx| { state.set_value("", window, cx); }); } cx.notify(); } } impl Render for PinEntry { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let is_locked = self.attempts >= self.max_attempts; v_flex() .gap_4() .child("Enter your 4-digit PIN") .child( OtpInput::new(&self.pin_state) .groups(1) .disabled(is_locked) ) .when(is_locked, |this| { this.child("Too many attempts. Please try again later.") }) .when(self.attempts > 0 && !is_locked, |this| { this.child(format!( "Incorrect PIN. {} attempts remaining.", self.max_attempts - self.attempts )) }) } } ``` ## 行为说明 ### 输入处理 - **仅数字**:只接受 `0-9`。 - **自动聚焦**:输入数字后自动跳到下一个输入框。 - **退格**:删除当前数字并回到前一个输入框。 - **长度限制**:不会超过设定长度。 - **自动完成**:所有输入框填满后触发 `Change` 事件。 ### 视觉反馈 - **焦点态**:当前输入框显示高亮边框与闪烁光标。 - **掩码**:启用后显示星号而不是数字。 - **分组**:可将输入框按组分隔,提升可读性。 - **禁用态**:禁用后显示灰化样式。 ### 键盘导航 - **方向键**:在输入框之间移动。 - **Tab**:切换到下一个可聚焦元素。 - **Shift+Tab**:切换到上一个可聚焦元素。 - **Backspace**:删除当前数字并向前移动。 - **Delete**:清空当前输入框。 ## 常见模式 ### 输入完成后自动提交 ```rust cx.subscribe(&otp_state, |this, state, event: &InputEvent, cx| { if let InputEvent::Change = event { let code = state.read(cx).value(); if code.len() == 6 { this.submit_verification_code(&code, cx); } } }); ``` ### 聚焦时清空旧值 ```rust cx.subscribe(&otp_state, |this, state, event: &InputEvent, cx| { if let InputEvent::Focus = event { state.update(cx, |state, cx| { state.set_value("", window, cx); }); } }); ``` ### 重发验证码计时器 ```rust struct OtpWithResend { otp_state: Entity, resend_timer: Option, can_resend: bool, } // Implementation would include timer logic for resend functionality ``` --- # TitleBar Source: /versions/v0.7.1/zh-CN/component/title-bar 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/title-bar 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 TitleBar 用于替换系统默认标题栏,提供可定制的窗口标题区域。它内置平台相关的窗口控制按钮,并支持插入菜单栏、状态信息和自定义操作区。组件会根据 macOS、Windows 和 Linux 自动调整行为和视觉样式。 ## 导入 ```rust use gpui_kit::component::TitleBar; ``` ## 用法 ### 基础标题栏 ```rust TitleBar::new() .child(div().child("My Application")) ``` ### 带自定义内容的标题栏 ```rust TitleBar::new() .child( div() .flex() .items_center() .gap_3() .child("App Name") .child(Badge::new().count(5)) ) .child( div() .flex() .items_center() .gap_2() .child(Button::new("settings").icon(IconName::Settings)) .child(Button::new("profile").icon(IconName::User)) ) ``` ### 带菜单栏 ```rust TitleBar::new() .child( div() .flex() .items_center() .child(AppMenuBar::new(window, cx)) ) .child( div() .flex() .items_center() .justify_end() .gap_2() .child(Button::new("github").icon(IconName::GitHub)) .child(Button::new("notifications").icon(IconName::Bell)) ) ``` ### Linux 自定义关闭行为 ```rust TitleBar::new() .on_close_window(|_, window, cx| { window.push_notification("Saving before close...", cx); window.remove_window(); }) .child(div().child("Custom Close Behavior")) ``` ### 自定义样式 ```rust TitleBar::new() .bg(cx.theme().primary) .border_color(cx.theme().primary_border) .child( div() .text_color(cx.theme().primary_foreground) .child("Styled Title Bar") ) ``` ### 窗口配置 推荐以 `TitleBar::window_options()` 作为窗口配置的基础,它会配置好标题栏所需的 全部选项,其中包括让标题栏(而不是系统)来处理拖动和双击。 ```rust use gpui_kit::WindowOptions; WindowOptions { window_bounds: Some(window_bounds), ..TitleBar::window_options() } ``` 如果自行构造 [`WindowOptions`],需要同时设置这两个字段: ```rust use gpui_kit::WindowOptions; WindowOptions { titlebar: Some(TitleBar::title_bar_options()), // macOS 上必须设置,否则系统也会处理标题栏双击, // 并且会为了判定双击而延迟投递标题栏点击。 app_owns_titlebar_drag: true, ..Default::default() } ``` ## 平台差异 ### macOS - 使用原生红黄绿窗口按钮 - traffic light 默认位置为 `(9px, 9px)` - 双击标题栏会调用 `window.titlebar_double_click()` - 左侧默认预留 `80px` - 默认表现为透明标题栏 ### Windows - 使用自定义窗口控制按钮并接入系统窗口管理 - 通过 `WindowControlArea` 处理交互 - 支持 hover 和 active 状态 - 每个控制按钮宽度固定为 `34px` - 左侧默认内边距为 `12px` ### Linux - 使用手动事件处理的自定义窗口控制按钮 - 支持通过 `on_close_window()` 覆盖关闭逻辑 - 支持双击最大化 / 还原 - 支持右键弹出窗口菜单 - 支持在标题栏区域拖动窗口 ## API 参考 ### TitleBar | 方法 | 说明 | | --- | --- | | `new()` | 创建标题栏 | | `child(element)` | 向标题栏中添加子元素 | | `on_close_window(fn)` | 自定义关闭行为,仅 Linux 有效 | | `title_bar_options()` | 获取窗口可用的默认标题栏配置 | | `window_options()` | 获取标题栏配套的默认窗口配置 | ### 窗口配置项 | 属性 | 说明 | | --- | --- | | `appears_transparent` | 标题栏透明(默认 true) | | `traffic_light_position` | macOS 红黄绿按钮位置 | | `title` | 窗口标题(使用自定义标题栏时可选) | | `app_owns_titlebar_drag` | 由标题栏自行处理拖动与双击(仅 macOS) | ### 常量 | 常量 | 值 | 说明 | | --- | --- | --- | | `TITLE_BAR_HEIGHT` | `34px` | 标准标题栏高度 | | `TITLE_BAR_LEFT_PADDING` | `80px`(macOS),`12px`(其他) | 内容区域左侧留白 | ## 说明 - 组件会自动处理平台相关的标题栏行为 - Windows 和 Linux 才会渲染自定义窗口控制按钮 - 拖拽窗口的逻辑已内置在合适区域中 - 自定义样式时应考虑各平台对标题栏的交互习惯 --- # Rating Source: /versions/v0.7.1/zh-CN/component/rating 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/rating 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Rating 是一个星级评分组件,允许用户选择评分值。它支持不同尺寸、自定义颜色、禁用状态以及点击事件处理。 ## 导入 ```rust use gpui_kit::component::rating::Rating; ``` ## 用法 ### 基础评分 ```rust Rating::new("my-rating") .value(3) .max(5) .on_click(|value, _, _| { println!("Rating changed to: {}", value); }) ``` ### 受控评分 ```rust struct MyView { rating: usize, } impl Render for MyView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { Rating::new("rating") .value(self.rating) .max(5) .on_click(cx.listener(|view, value: &usize, _, cx| { view.rating = *value; cx.notify(); })) } } ``` ### 不同尺寸 Rating 实现了 [Sizable] trait: ```rust Rating::new("rating").xsmall().value(3).max(5) Rating::new("rating").small().value(3).max(5) Rating::new("rating").value(3).max(5) Rating::new("rating").large().value(3).max(5) ``` ### 自定义颜色 默认使用主题中的 `yellow` 颜色。你也可以通过 `color` 方法覆盖: ```rust Rating::new("rating") .value(4) .max(5) .color(cx.theme().green) ``` ### 禁用状态 ```rust Rating::new("rating") .value(2) .max(5) .disabled(true) ``` ### 自定义最大值 默认最大值为 5 星,也可以设置为任意数量: ```rust Rating::new("rating") .value(7) .max(10) ``` ### 点击行为 Rating 的点击行为有两个规则: - 点击已点亮的星星,会将评分减少 1。 - 点击未点亮的星星,会将评分设置为该星星对应的值。 `on_click` 回调接收到的新值类型为 `&usize`。 ```rust Rating::new("rating") .value(3) .max(5) .on_click(|new_value, _, _| { println!("New rating: {}", new_value); }) ``` ## API 参考 - [Rating] ### 方法 - `new(id: impl Into)`:创建新的 Rating 组件。 - `with_size(size: impl Into)`:设置星星尺寸,支持 [Sizable]。 - `value(value: usize)`:设置当前评分值,范围 `0..=max`。 - `max(max: usize)`:设置最大星数,默认值为 5。 - `color(color: impl Into)`:设置激活颜色,默认使用主题黄色。 - `disabled(disabled: bool)`:禁用交互,支持 [Disableable]。 - `on_click(handler: Fn(&usize, &mut Window, &mut App))`:设置点击处理函数。 ## 示例 ### 只读展示 ```rust Rating::new("rating") .value(4) .max(5) .disabled(true) ``` ### 带状态的交互评分 ```rust struct ProductView { user_rating: usize, } impl Render for ProductView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .gap_3() .child( Rating::new("product-rating") .value(self.user_rating) .max(5) .on_click(cx.listener(|view, value: &usize, _, cx| { view.user_rating = *value; cx.notify(); })) ) .child(format!("Your rating: {}/5", self.user_rating)) } } ``` ### 自定义颜色的大尺寸评分 ```rust Rating::new("rating") .large() .value(5) .max(5) .color(cx.theme().orange) ``` [Rating]: https://docs.rs/gpui-component/latest/gpui_component/rating/struct.Rating.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html [Disableable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Disableable.html --- # TimeField Source: /versions/v0.7.1/zh-CN/component/time-field 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/time-field 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 TimeField 用于分段输入一天中的时间。时、分以及可选的秒和上午/下午各自独立,用键盘逐段编辑;输入数字时字段宽度保持不变。[DatePicker](date-picker) 也用它来编辑日期对应的时间。 ## 导入 ```rust use gpui_kit::component::time_field::{ HourCycle, TimeField, TimeFieldEvent, TimeFieldState, TimePrecision, }; ``` ## 用法 ### 基础用法 ```rust let time = cx.new(|cx| TimeFieldState::new(window, cx)); TimeField::new(&time) ``` 字段初始值为 `00:00`。用 `set_time` 设置值,这个方法不会触发事件: ```rust use chrono::NaiveTime; time.update(cx, |state, cx| { state.set_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap(), window, cx); }); ``` ### 精确到秒 ```rust let time = cx.new(|cx| { TimeFieldState::new(window, cx).precision(TimePrecision::Second) }); TimeField::new(&time) // 09:30:15 ``` ### 12 小时制 默认使用 24 小时制。`HourCycle::H12` 会把小时显示为 `12` 到 `11`,并在后面加一个上午/下午(AM/PM)段: ```rust let time = cx.new(|cx| { TimeFieldState::new(window, cx).hour_cycle(HourCycle::H12) }); TimeField::new(&time) // 09:30 PM ``` ### 尺寸、禁用与校验状态 ```rust TimeField::new(&time).small() TimeField::new(&time).large() TimeField::new(&time).disabled(true) // 显示业务层的校验结果,不会拒绝用户的编辑。 TimeField::new(&time).invalid(true) ``` ## 处理变更 用户编辑后会触发 `TimeFieldEvent::Change`,携带新的时间: ```rust cx.subscribe(&time, |this, _, event, cx| match event { TimeFieldEvent::Change(time) => { this.reminder = *time; cx.notify(); } }); ``` ## 键盘操作 | 按键 | 行为 | | --- | --- | | `Up` / `Down` | 调整当前选中的段,在段内循环,不会改动上一级 | | `Left` / `Right` | 在段之间移动 | | `Tab` / `Shift-Tab` | 在段之间移动,到头后离开字段 | | `0`–`9` | 输入当前段,填满后自动跳到下一段 | | `a` / `p` | 12 小时制下切换上午或下午 | | `Backspace` / `Delete` | 重置当前段 | --- # VirtualList Source: /versions/v0.7.1/zh-CN/component/virtual-list 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/virtual-list 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 VirtualList 是一个面向大规模数据集的高性能列表组件。它只渲染当前可见区域的项目,因此非常适合长列表、动态高度内容以及类似表格的复杂布局。 与普通均匀列表不同,VirtualList 支持每一项拥有不同尺寸。 ## 导入 ```rust use gpui_kit::component::{ v_virtual_list, h_virtual_list, VirtualListScrollHandle, scroll::{Scrollbar, ScrollbarState, ScrollbarAxis}, }; use std::rc::Rc; use gpui_kit::{px, size, ScrollStrategy, Size, Pixels}; ``` ## 用法 ### 基础纵向列表 ```rust pub struct ListViewExample { items: Vec, item_sizes: Rc>>, scroll_handle: VirtualListScrollHandle, } ``` ```rust v_virtual_list( cx.entity().clone(), "my-list", self.item_sizes.clone(), |view, visible_range, _, cx| { visible_range .map(|ix| { div() .h(px(30.)) .w_full() .bg(cx.theme().secondary) .child(format!("Item {}", ix)) }) .collect() }, ) .track_scroll(&self.scroll_handle) ``` ### 横向列表 ```rust h_virtual_list( cx.entity().clone(), "horizontal-list", item_sizes.clone(), |view, visible_range, _, cx| { visible_range .map(|ix| { div() .w(px(120.)) .h_full() .bg(cx.theme().accent) .child(format!("Card {}", ix)) }) .collect() }, ) ``` ### 可变尺寸项 ```rust let item_sizes = Rc::new( (0..1000) .map(|i| { let height = if i % 5 == 0 { px(60.) } else if i % 3 == 0 { px(45.) } else { px(30.) }; size(px(300.), height) }) .collect::>() ); ``` ## 滚动控制 ### 基础滚动 ```rust pub struct ScrollableList { scroll_handle: VirtualListScrollHandle, scroll_state: ScrollbarState, } ``` ### 编程式滚动 ```rust impl ScrollableList { fn scroll_to_item(&self, index: usize) { self.scroll_handle.scroll_to_item(index, ScrollStrategy::Top); } fn center_item(&self, index: usize) { self.scroll_handle.scroll_to_item(index, ScrollStrategy::Center); } fn scroll_to_bottom(&self) { self.scroll_handle.scroll_to_bottom(); } } ``` ### 双轴滚动 ```rust Scrollbar::both(&scroll_state, &scroll_handle) .axis(ScrollbarAxis::Both) ``` ## 性能说明 VirtualList 的核心优势在于: - 只渲染可见范围内的项 - 滚动时复用已渲染元素 - 数据量很大时内存占用仍然稳定 因此只要列表项超过几十个,尤其是达到上百上千个时,就值得优先考虑 VirtualList。 ## 示例 ### 文件浏览器 ```rust pub struct FileExplorer { files: Vec, item_sizes: Rc>>, scroll_handle: VirtualListScrollHandle, selected_index: Option, } ``` ### 聊天窗口 ```rust pub struct ChatWindow { messages: Vec, scroll_handle: VirtualListScrollHandle, auto_scroll: bool, } ``` ### 固定表头的数据网格 ```rust pub struct DataGrid { headers: Vec, data: Vec>, column_widths: Vec, scroll_handle: VirtualListScrollHandle, } ``` ## 最佳实践 1. 尽量预先计算 item 尺寸 2. 渲染函数里避免重计算 3. 列表项数量超过 50 时优先考虑 VirtualList 4. 将项状态与渲染逻辑拆开管理 5. 测试不同数据量和滚动位置下的表现 --- # DatePicker Source: /versions/v0.7.1/zh-CN/component/date-picker 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/date-picker 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 DatePicker 是一个灵活的日期选择组件,内置日历界面,支持单日期选择、日期范围选择、自定义格式、禁用日期和预设范围。 ## 导入 ```rust use gpui_kit::component::{ date_picker::{DatePicker, DatePickerState, DateRangePreset, DatePickerEvent, DateTime}, calendar::{Date, Matcher}, time_field::{HourCycle, TimePrecision}, }; ``` ## 用法 ### 基础 Date Picker ```rust let date_picker = cx.new(|cx| DatePickerState::new(window, cx)); DatePicker::new(&date_picker) ``` ### 设置初始日期 ```rust use chrono::Local; let date_picker = cx.new(|cx| { let mut picker = DatePickerState::new(window, cx); picker.set_date(Local::now().naive_local().date(), window, cx); picker }); DatePicker::new(&date_picker) ``` ### 日期范围选择 ```rust use chrono::{Local, Days}; let range_picker = cx.new(|cx| DatePickerState::range(window, cx)); DatePicker::new(&range_picker) .number_of_months(2) let range_picker = cx.new(|cx| { let now = Local::now().naive_local().date(); let mut picker = DatePickerState::new(window, cx); picker.set_date( (now, now.checked_add_days(Days::new(7)).unwrap()), window, cx, ); picker }); DatePicker::new(&range_picker) .number_of_months(2) ``` ### 日期与时间 设置 `time_precision` 后即可同时编辑具体时间。弹层会在日历下方显示时间字段,选择日期后弹层保持打开,方便接着调整时间。每次修改都会立即触发事件。再次点击已选中的日期(或直接双击某个日期)即可确认并关闭弹层,按 Enter、Escape 或点击弹层外部同样可以关闭。 ```rust use chrono::NaiveTime; let date_time_picker = cx.new(|cx| { DatePickerState::new(window, cx) .time_precision(TimePrecision::Minute) // 或 TimePrecision::Second .default_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap()) }); DatePicker::new(&date_time_picker) ``` 时间默认使用 24 小时制。通过 `hour_cycle` 可以切换为带上午/下午(AM/PM)段的 12 小时制: ```rust DatePickerState::new(window, cx) .time_precision(TimePrecision::Minute) .hour_cycle(HourCycle::H12) // 09:30 PM ``` `default_time` 是用户修改前日期所带的时间,默认为 `00:00`。未设置 `date_format` 时,显示格式跟随精度和小时制,例如 `%Y/%m/%d %H:%M` 或 `%Y/%m/%d %I:%M %p`。 用 `date_time` 和 `set_date_time` 读写完整的值;`date` 和 `set_date` 仍然只处理日期部分,并保留当前时间。 ```rust use chrono::Local; date_time_picker.update(cx, |state, cx| { state.set_date_time(Local::now().naive_local(), window, cx); }); if let DateTime::Single(Some(at)) = date_time_picker.read(cx).date_time() { println!("Selected {at}"); } ``` 范围模式即使设置了 `time_precision` 也只编辑日期。需要带时间的范围时,把两个 picker 并排放置,由业务层校验先后顺序: ```rust h_flex() .gap_2() .child(DatePicker::new(&start_picker)) .child("–") .child(DatePicker::new(&end_picker)) ``` 在时间字段中,Up/Down 调整当前选中的段,Left/Right 和 Tab/Shift-Tab 在段之间移动,输入数字会填入当前段并自动跳到下一段,`a`/`p` 切换上午或下午,Backspace 重置当前段。 ### 自定义日期格式 ```rust let date_picker = cx.new(|cx| { DatePickerState::new(window, cx) .date_format("%Y-%m-%d") }); DatePicker::new(&date_picker) // Other format examples: // "%m/%d/%Y" -> 12/25/2023 // "%B %d, %Y" -> December 25, 2023 // "%d %b %Y" -> 25 Dec 2023 ``` ### 占位文本 ```rust DatePicker::new(&date_picker) .placeholder("Select a date...") ``` ### 可清空 ```rust DatePicker::new(&date_picker) .cleanable(true) ``` ### 不同尺寸 ```rust DatePicker::new(&date_picker).large() DatePicker::new(&date_picker) DatePicker::new(&date_picker).small() ``` ### 禁用状态 ```rust DatePicker::new(&date_picker).disabled(true) ``` ### 自定义外观 ```rust DatePicker::new(&date_picker).appearance(false) div() .border_b_2() .px_6() .py_3() .border_color(cx.theme().border) .bg(cx.theme().secondary) .child(DatePicker::new(&date_picker).appearance(false)) ``` ## 日期限制 ### 禁用周末 ```rust use gpui_kit::component::calendar; let date_picker = cx.new(|cx| { DatePickerState::new(window, cx) .disabled_matcher(vec![0, 6]) }); DatePicker::new(&date_picker) ``` ### 禁用日期区间 ```rust use chrono::{Local, Days}; let now = Local::now().naive_local().date(); let date_picker = cx.new(|cx| { DatePickerState::new(window, cx) .disabled_matcher(calendar::Matcher::range( Some(now), now.checked_add_days(Days::new(7)), )) }); DatePicker::new(&date_picker) ``` ### 禁用日期间隔 ```rust let date_picker = cx.new(|cx| { DatePickerState::new(window, cx) .disabled_matcher(calendar::Matcher::interval( Some(now), now.checked_add_days(Days::new(5)) )) }); DatePicker::new(&date_picker) ``` ### 自定义禁用规则 ```rust let date_picker = cx.new(|cx| { DatePickerState::new(window, cx) .disabled_matcher(calendar::Matcher::custom(|date| { date.day0() < 5 })) }); DatePicker::new(&date_picker) let date_picker = cx.new(|cx| { DatePickerState::new(window, cx) .disabled_matcher(calendar::Matcher::custom(|date| { date.weekday() == chrono::Weekday::Mon })) }); ``` ## 自定义年份范围 默认情况下,日期选择器在年份选择模式下会显示以今天为中心前后各 50 年。可通过 `set_year_range` 配置更大的范围——例如用于生日选择,需要回溯到 1900 年。 `range` 参数使用**半开区间** `(start, end)`,`end` **不包含**在内。若要包含当前年份,传入 `(1900, current_year + 1)`。 ```rust use chrono::Datelike; // 生日选择器:允许选择 1900 年到当前年(含) let birthday_picker = cx.new(|cx| { let current_year = chrono::Local::now().year(); let mut picker = DatePickerState::new(window, cx) .date_format("%Y-%m-%d"); picker.set_year_range((1900, current_year + 1), window, cx); picker }); DatePicker::new(&birthday_picker) .cleanable(true) .placeholder("选择生日") ``` `set_year_range` 对单日期和范围两种模式均有效。 ## 预设范围 ### 单日期预设 ```rust use chrono::{Utc, Duration}; let presets = vec![ DateRangePreset::single( "Yesterday", (Utc::now() - Duration::days(1)).naive_local().date(), ), DateRangePreset::single( "Last Week", (Utc::now() - Duration::weeks(1)).naive_local().date(), ), DateRangePreset::single( "Last Month", (Utc::now() - Duration::days(30)).naive_local().date(), ), ]; DatePicker::new(&date_picker) .presets(presets) ``` ### 日期范围预设 ```rust let range_presets = vec![ DateRangePreset::range( "Last 7 Days", (Utc::now() - Duration::days(7)).naive_local().date(), Utc::now().naive_local().date(), ), DateRangePreset::range( "Last 30 Days", (Utc::now() - Duration::days(30)).naive_local().date(), Utc::now().naive_local().date(), ), DateRangePreset::range( "Last 90 Days", (Utc::now() - Duration::days(90)).naive_local().date(), Utc::now().naive_local().date(), ), ]; DatePicker::new(&date_picker) .number_of_months(2) .presets(range_presets) ``` ## 处理选择事件 ```rust let date_picker = cx.new(|cx| DatePickerState::new(window, cx)); cx.subscribe(&date_picker, |view, _, event, _| { match event { DatePickerEvent::Change(value) => { match value.date() { Date::Single(Some(selected_date)) => { println!("Single date selected: {}", selected_date); } Date::Range(Some(start), Some(end)) => { println!("Date range selected: {} to {}", start, end); } Date::Range(Some(start), None) => { println!("Range start selected: {}", start); } _ => { println!("Date cleared"); } } } } }); ``` ## 显示多个月份 ```rust DatePicker::new(&date_picker) .number_of_months(2) DatePicker::new(&date_picker) .number_of_months(3) ``` ## 高级示例 ### 仅工作日可选 ```rust use chrono::Weekday; let business_days_picker = cx.new(|cx| { DatePickerState::new(window, cx) .disabled_matcher(calendar::Matcher::custom(|date| { matches!(date.weekday(), Weekday::Sat | Weekday::Sun) })) }); DatePicker::new(&business_days_picker) .placeholder("Select business day") ``` ### 限制最大范围 ```rust use chrono::Days; let max_30_days_picker = cx.new(|cx| DatePickerState::range(window, cx)); cx.subscribe(&max_30_days_picker, |view, picker, event, _| { match event { DatePickerEvent::Change(value) => { let Date::Range(Some(start), Some(end)) = value.date() else { return; }; let duration = end.signed_duration_since(start).num_days(); if duration > 30 { picker.update(cx, |state, cx| { state.set_date(Date::Range(Some(start), None), window, cx); }); } } } }); DatePicker::new(&max_30_days_picker) .number_of_months(2) .placeholder("Select up to 30 days") ``` ### 季度预设 ```rust use chrono::{NaiveDate, Datelike}; fn quarter_start(year: i32, quarter: u32) -> NaiveDate { let month = (quarter - 1) * 3 + 1; NaiveDate::from_ymd_opt(year, month, 1).unwrap() } fn quarter_end(year: i32, quarter: u32) -> NaiveDate { let month = quarter * 3; let start = NaiveDate::from_ymd_opt(year, month, 1).unwrap(); NaiveDate::from_ymd_opt(year, month, start.days_in_month()).unwrap() } let year = Local::now().year(); let quarterly_presets = vec![ DateRangePreset::range("Q1", quarter_start(year, 1), quarter_end(year, 1)), DateRangePreset::range("Q2", quarter_start(year, 2), quarter_end(year, 2)), DateRangePreset::range("Q3", quarter_start(year, 3), quarter_end(year, 3)), DateRangePreset::range("Q4", quarter_start(year, 4), quarter_end(year, 4)), ]; DatePicker::new(&date_picker) .presets(quarterly_presets) ``` ## 示例 ### 事件日期选择 ```rust let event_date = cx.new(|cx| { let mut picker = DatePickerState::new(window, cx) .date_format("%B %d, %Y") .disabled_matcher(calendar::Matcher::custom(|date| { *date < Local::now().naive_local().date() })); picker }); DatePicker::new(&event_date) .placeholder("Choose event date") .cleanable(true) ``` ### 预订系统日期范围 ```rust let booking_range = cx.new(|cx| DatePickerState::range(window, cx)); let booking_presets = vec![ DateRangePreset::range("This Weekend", /* weekend dates */), DateRangePreset::range("Next Week", /* next week dates */), DateRangePreset::range("This Month", /* this month dates */), ]; DatePicker::new(&booking_range) .number_of_months(2) .presets(booking_presets) .placeholder("Select check-in and check-out dates") ``` ### 财务周期选择 ```rust let financial_period = cx.new(|cx| { DatePickerState::range(window, cx) .date_format("%Y-%m-%d") }); DatePicker::new(&financial_period) .number_of_months(3) .presets(quarterly_presets) .placeholder("Select reporting period") ``` --- # Resizable Source: /versions/v0.7.1/zh-CN/component/resizable 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/resizable 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Resizable 组件系统用于构建可拖拽调整大小的面板布局,支持横向与纵向分割、嵌套布局、尺寸限制和拖拽句柄,适合实现 IDE、仪表盘或分栏视图。 ## 导入 ```rust use gpui_kit::component::resizable::{ h_resizable, v_resizable, resizable_panel, ResizablePanelGroup, ResizablePanel, ResizableState, ResizablePanelEvent }; ``` ## 用法 使用 `h_resizable` 创建横向布局,使用 `v_resizable` 创建纵向布局。 第一个参数是 [ResizablePanelGroup] 的 `id`。 在 GPUI 中,`id` 在当前布局作用域内必须唯一。 ```rust h_resizable("my-layout") .on_resize(|state, window, cx| { let state = state.read(cx); let sizes = state.sizes(); }) .child( resizable_panel() .size(px(200.)) .child("Left Panel") ) .child( div() .child("Right Panel") .into_any_element() ) ``` 纵向布局写法类似: ```rust v_resizable("vertical-layout") .child( resizable_panel() .size(px(100.)) .child("Top Panel") ) .child( div() .child("Bottom Panel") .into_any_element() ) ``` ### 分隔条外观 分隔条静止时是一条细线。指针接近后,线上会浮出一条指示条,并随 hover、按下、拖拽三级逐步变长、变实——拖拽开始一两个像素后指针就离开了手柄自身的命中带,这条指示条让「正在拖拽」仍然看得出来。 `h_resizable`、`v_resizable` 和 Dock 都已装上这套外观。`resize_handle_appearance()` 也已导出,供自建手柄使用,或显式传给 `with_handle_appearance`: ```rust gpui_kit::base::h_resizable("my-layout") .with_handle_appearance(resize_handle_appearance()) ``` 指示条的时长与缓动取自主题的 motion token;系统开启了减弱动态效果时,它直接到位,不播过渡。 ### 面板尺寸约束 ```rust resizable_panel() .size(px(200.)) .size_range(px(150.)..px(400.)) .child("Constrained Panel") ``` ### 多面板布局 ```rust h_resizable("multi-panel", state) .child( resizable_panel() .size(px(200.)) .size_range(px(150.)..px(300.)) .child("Left Panel") ) .child( resizable_panel() .child("Center Panel") ) .child( resizable_panel() .size(px(250.)) .child("Right Panel") ) ``` ### 嵌套布局 ```rust v_resizable("main-layout", window, cx) .child( resizable_panel() .size(px(300.)) .child( h_resizable("nested-layout", window, cx) .child( resizable_panel() .size(px(200.)) .child("Top Left") ) .child( resizable_panel() .child("Top Right") ) ) ) .child( resizable_panel() .child("Bottom Panel") ) ``` ### 嵌套面板组 ```rust h_resizable("outer", window, cx) .child( resizable_panel() .size(px(200.)) .child("Left Panel") ) .group( v_resizable("inner", window, cx) .child( resizable_panel() .size(px(150.)) .child("Top Right") ) .child( resizable_panel() .child("Bottom Right") ) ) ``` ### 条件显示面板 ```rust resizable_panel() .visible(self.show_sidebar) .size(px(250.)) .child("Sidebar Content") ``` ### 带尺寸上下限的面板 ```rust resizable_panel() .size_range(px(100.)..Pixels::MAX) .child("Flexible Panel") resizable_panel() .size_range(px(200.)..px(500.)) .child("Constrained Panel") resizable_panel() .size(px(300.)) .size_range(px(300.)..px(300.)) .child("Fixed Panel") ``` ## 示例 ### 文件浏览器布局 ```rust struct FileExplorer { show_sidebar: bool, } impl Render for FileExplorer { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { h_resizable("file-explorer", window, cx) .child( resizable_panel() .visible(self.show_sidebar) .size(px(250.)) .size_range(px(200.)..px(400.)) .child( v_flex() .p_4() .child("📁 Folders") .child("• Documents") .child("• Pictures") .child("• Downloads") ) ) .child( v_flex() .p_4() .child("📄 Files") .child("file1.txt") .child("file2.pdf") .child("image.png") .into_any_element() ) } } ``` ### IDE 布局 ```rust struct IDELayout { main_state: Entity, sidebar_state: Entity, bottom_state: Entity, } impl Render for IDELayout { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { h_resizable("ide-main", self.main_state.clone()) .child( resizable_panel() .size(px(300.)) .size_range(px(200.)..px(500.)) .child( v_resizable("sidebar", self.sidebar_state.clone()) .child( resizable_panel() .size(px(200.)) .child("File Explorer") ) .child( resizable_panel() .child("Outline") ) ) ) .child( resizable_panel() .child( v_resizable("editor-area", self.bottom_state.clone()) .child( resizable_panel() .child("Code Editor") ) .child( resizable_panel() .size(px(150.)) .size_range(px(100.)..px(300.)) .child("Terminal / Output") ) ) ) } } ``` ### 仪表盘布局 ```rust struct Dashboard { layout_state: Entity, widget_state: Entity, } impl Render for Dashboard { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { v_resizable("dashboard", self.layout_state.clone()) .child( resizable_panel() .size(px(120.)) .child("Header / Navigation") ) .child( resizable_panel() .child( h_resizable("widgets", self.widget_state.clone()) .child( resizable_panel() .size(px(300.)) .child("Chart Widget") ) .child( resizable_panel() .child("Data Table") ) .child( resizable_panel() .size(px(250.)) .child("Stats Panel") ) ) ) .child( resizable_panel() .size(px(60.)) .child("Footer") ) } } ``` ### 设置面板 ```rust struct SettingsPanel { settings_state: Entity, } impl SettingsPanel { fn new(cx: &mut Context) -> Self { let settings_state = ResizableState::new(cx); cx.subscribe(&settings_state, |this, _, event: &ResizablePanelEvent, cx| { match event { ResizablePanelEvent::Resized => { this.save_layout_preferences(cx); } } }); Self { settings_state } } fn save_layout_preferences(&self, cx: &mut Context) { let sizes = self.settings_state.read(cx).sizes(); println!("Saving layout: {:?}", sizes); } } impl Render for SettingsPanel { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { h_resizable("settings", self.settings_state.clone()) .child( resizable_panel() .size(px(200.)) .size_range(px(150.)..px(300.)) .child( v_flex() .gap_2() .p_4() .child("Categories") .child("• General") .child("• Appearance") .child("• Advanced") ) ) .child( resizable_panel() .child( div() .p_6() .child("Settings Content Area") ) ) } } ``` ## 最佳实践 1. 为独立布局使用各自的 `ResizableState`。 2. 始终为面板设置合理的最小和最大尺寸。 3. 通过订阅 `ResizablePanelEvent` 持久化用户布局。 4. 使用 `.group()` 构建清晰的嵌套结构。 5. 避免无意义的深层嵌套以减少复杂度。 6. 为拖拽句柄保留足够交互空间,提升体验。 --- # ColorPicker Source: /versions/v0.7.1/zh-CN/component/color-picker 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/color-picker 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 ColorPicker 是一个通用的颜色选择组件,提供直观的颜色选择界面。它支持颜色面板、十六进制输入、精选颜色,以及 RGB、HSL 和十六进制格式,并支持 alpha 透明通道。 ## 导入 ```rust use gpui_kit::component::color_picker::{ ColorPicker, ColorPickerEvent, ColorPickerState, ColorSelect, }; ``` ## 用法 ### 基础 Color Picker ```rust use gpui_kit::{Entity, Window, Context}; let color_picker = cx.new(|cx| ColorPickerState::new(window, cx) .default_value(cx.theme().primary) ); ColorPicker::new(&color_picker) ``` ### 处理事件 ```rust use gpui_kit::{Subscription, Entity}; let color_picker = cx.new(|cx| ColorPickerState::new(window, cx)); let _subscription = cx.subscribe(&color_picker, |this, _, ev, _| match ev { ColorPickerEvent::Change(color) => { if let Some(color) = color { println!("Selected color: {}", color.to_hex()); // Handle color change } } }); ColorPicker::new(&color_picker) ``` ### 设置默认颜色 ```rust use gpui_kit::Hsla; let color_picker = cx.new(|cx| ColorPickerState::new(window, cx) .default_value(cx.theme().blue) ); ``` ### 不同尺寸 ```rust ColorPicker::new(&color_picker).small() ColorPicker::new(&color_picker) ColorPicker::new(&color_picker).large() ColorPicker::new(&color_picker).xsmall() ``` ### 自定义精选颜色 ```rust use gpui_kit::Hsla; let featured_colors = vec![ cx.theme().red, cx.theme().green, cx.theme().blue, cx.theme().yellow, ]; ColorPicker::new(&color_picker) .featured_colors(featured_colors) ``` ### 用图标替代色块 ```rust use gpui_kit::component::IconName; ColorPicker::new(&color_picker) .icon(IconName::Palette) ``` ### 带标签 ```rust ColorPicker::new(&color_picker) .label("Background Color") ``` ### 自定义锚点位置 ```rust use gpui_kit::Anchor; ColorPicker::new(&color_picker) .anchor(Anchor::TopRight) ``` ### Color Select `ColorSelect` 把颜色选择器绘制成一个带边框的字段,外观和 `Select` 一致:左边是当前颜色的色块,中间是 hex 值,右边是 Caret。点击字段任意位置都会打开同一个弹出面板。它适合用在表单里,让颜色字段和周围的输入框保持相同的高度和边框;工具栏里需要紧凑色块时,仍然使用 `ColorPicker`。 ```rust use gpui_kit::component::{Sizable as _, form::field}; field() .label("Theme color") .child(ColorSelect::new(&color_picker)) // 尺寸与 Input、Select 一致。 ColorSelect::new(&color_picker).large() // 未选择颜色时显示的占位文字。 ColorSelect::new(&color_picker).placeholder("Pick a color") ``` ## 颜色选择界面 ### 调色板 组件内置多组颜色家族: - **Stone**:中性色与石灰灰阶 - **Red**:红色系 - **Orange**:橙色系 - **Yellow**:黄色系 - **Green**:绿色系 - **Cyan**:青色系 - **Blue**:蓝色系 - **Purple**:紫色系 - **Pink**:粉色系 每个颜色家族都提供多个深浅层级,方便精准选色。 ### 精选颜色区域 顶部的精选颜色区域可用于放置品牌色或常用色。若未指定,则默认使用当前主题中的核心颜色: - 当前主题的主色 - 主题颜色的浅色变体 - 常用界面色,如 red、blue、green、yellow、cyan、magenta ### Hex 输入框 组件提供十六进制输入框,可直接输入颜色值: - 支持标准 6 位格式 `#RRGGBB` - 实时校验并预览 - 会自动同步到组件状态 - 按 Enter 确认 ## 颜色格式 ### RGB 颜色内部使用 GPUI 的 `Hsla` 表示,但可以转换为 RGB 相关值: ```rust let color = cx.theme().blue; // Access RGB components through Hsla methods ``` ### HSL ColorPicker 原生使用 HSL/HSLA 表示: ```rust use gpui_kit::Hsla; let color = Hsla::hsl(240.0, 100.0, 50.0); let hue = color.h; let saturation = color.s; let lightness = color.l; ``` ### Hex 标准 Web 十六进制格式: ```rust let hex_string = color.to_hex(); if let Ok(color) = Hsla::parse_hex("#3366FF") { // Use parsed color } ``` ## Alpha 通道 支持透明度: ```rust use gpui_kit::hsla; let semi_transparent = hsla(0.5, 0.8, 0.6, 0.7); let transparent_blue = cx.theme().blue.opacity(0.5); ``` ColorPicker 在选择颜色时会保留 alpha 值,也可通过 HSLA 的 alpha 分量进一步修改。 ## API 参考 - [ColorPicker] - [ColorSelect] - [ColorPickerState] - [ColorPickerEvent] ## 示例 ### 主题颜色编辑器 ```rust struct ThemeEditor { primary_color: Entity, secondary_color: Entity, accent_color: Entity, } impl ThemeEditor { fn new(window: &mut Window, cx: &mut Context) -> Self { let primary_color = cx.new(|cx| ColorPickerState::new(window, cx) .default_value(cx.theme().primary) ); let secondary_color = cx.new(|cx| ColorPickerState::new(window, cx) .default_value(cx.theme().secondary) ); let accent_color = cx.new(|cx| ColorPickerState::new(window, cx) .default_value(cx.theme().accent) ); Self { primary_color, secondary_color, accent_color, } } fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .gap_4() .child( h_flex() .gap_2() .items_center() .child("Primary Color:") .child(ColorPicker::new(&self.primary_color)) ) .child( h_flex() .gap_2() .items_center() .child("Secondary Color:") .child(ColorPicker::new(&self.secondary_color)) ) .child( h_flex() .gap_2() .items_center() .child("Accent Color:") .child(ColorPicker::new(&self.accent_color)) ) } } ``` ### 品牌色选择器 ```rust use gpui_kit::component::Sizable as _; let brand_colors = vec![ Hsla::parse_hex("#FF6B6B").unwrap(), Hsla::parse_hex("#4ECDC4").unwrap(), Hsla::parse_hex("#45B7D1").unwrap(), Hsla::parse_hex("#96CEB4").unwrap(), Hsla::parse_hex("#FFEAA7").unwrap(), ]; ColorPicker::new(&color_picker) .featured_colors(brand_colors) .label("Brand Color") .large() ``` ### 工具栏颜色选择器 ```rust use gpui_kit::component::{Sizable as _, IconName}; ColorPicker::new(&text_color_picker) .icon(IconName::Type) .small() .anchor(Anchor::BottomLeft) ``` ### 调色板构建器 ```rust struct ColorPalette { colors: Vec>, } impl ColorPalette { fn add_color(&mut self, window: &mut Window, cx: &mut Context) { let color_picker = cx.new(|cx| ColorPickerState::new(window, cx)); cx.subscribe(&color_picker, |this, _, ev, _| match ev { ColorPickerEvent::Change(color) => { if let Some(color) = color { this.update_palette_preview(); } } }); self.colors.push(color_picker); cx.notify(); } fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { h_flex() .gap_2() .children( self.colors.iter().map(|color_picker| { ColorPicker::new(color_picker).small() }) ) .child( Button::new("add-color") .icon(IconName::Plus) .ghost() .on_click(cx.listener(|this, _, window, cx| { this.add_color(window, cx); })) ) } } ``` ### 颜色校验 ```rust let color_picker = cx.new(|cx| ColorPickerState::new(window, cx)); let _subscription = cx.subscribe(&color_picker, |this, _, ev, _| match ev { ColorPickerEvent::Change(color) => { if let Some(color) = color { if this.validate_contrast(color) { this.apply_color(color); } else { this.show_contrast_warning(); } } } }); ``` [ColorPicker]: https://docs.rs/gpui-component/latest/gpui_component/color_picker/struct.ColorPicker.html [ColorSelect]: https://docs.rs/gpui-component/latest/gpui_component/color_picker/struct.ColorSelect.html [ColorPickerState]: https://docs.rs/gpui-component/latest/gpui_component/color_picker/struct.ColorPickerState.html [ColorPickerEvent]: https://docs.rs/gpui-component/latest/gpui_component/color_picker/enum.ColorPickerEvent.html --- # Textarea Source: /versions/v0.7.1/zh-CN/component/textarea 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/textarea 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `Textarea` 用于普通多行文本。单行输入请使用 [Input](/versions/v0.7.1/zh-CN/component/input),源代码编辑请使用 [Editor](/versions/v0.7.1/zh-CN/component/editor)。 ## 导入 ```rust use gpui_kit::component::input::{Textarea, TextareaState}; ``` ## 基础用法 ```rust let notes = cx.new(|cx| { TextareaState::new(window, cx) .rows(5) .placeholder("备注") }); Textarea::new(¬es) ``` ## 自动增高 ```rust let message = cx.new(|cx| { TextareaState::new(window, cx) .auto_grow(2, 8) .placeholder("输入消息") }); Textarea::new(&message) ``` 组件最多增长到 `max_rows`,之后内容在内部滚动。 ## 值与事件 ```rust let value = notes.read(cx).value(); notes.update(cx, |state, cx| { state.set_value("更新后的备注", window, cx); }); cx.subscribe(¬es, |this, state, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { this.notes = state.read(cx).value(); cx.notify(); } }); ``` `TextareaState` 还提供 `insert`、`replace`、`cursor_position`、 `soft_wrap`、`searchable` 和 `submit_on_enter`。 ## 尺寸 ```rust Textarea::new(¬es).large() Textarea::new(¬es) // medium(默认) Textarea::new(¬es).small() ``` 尺寸会同时改变文字大小和文本四周的内边距,但不决定高度:固定高度用 `h` 设置,随内容增高的 Textarea 由 `rows` 或 `auto_grow` 决定高度。 ## 外观 ```rust Textarea::new(¬es) .h(px(160.)) .bordered(true) .disabled(false) .readonly(false) .aria_label("备注") ``` 与 `disabled` 不同,只读 Textarea 保持正常外观,仍然可以聚焦、选中和复制,只是拒绝用户对内容的修改。 `Textarea` 不提供只适用于单行 Input 的前后缀、密码显示切换和清除按钮;相关操作应组合在 Textarea 外部。 ## 原子行内 token 在多行消息中加入人员提及、文件引用或命令时,可以使用 token。通过 `TextareaState::replace_with_token` 插入,或用 `set_value` 恢复已有草稿。默认标签可以直接使用,需要图标等自定义内容时再设置 renderer: ```rust Textarea::new(&state) .token(|token, _, _| InputToken::new(token).icon(IconName::File)) ``` 从 `gpui_kit::component::input` 导入 `InputToken`,从 `gpui_kit::component` 导入 `IconName`。token 会整块换到下一行,自动增高会相应调整输入框高度。换行符应放在 token 之间的普通文本中。使用 `on_token_hover` 可在不选中、不编辑的情况下显示提示或预览;禁用 token 不会上报悬停进入事件;禁用已悬停的 token 时会发送离开事件。编辑、激活、草稿保存、模式限制和 JavaScript API 见 [Input:原子行内 token](/versions/v0.7.1/zh-CN/component/input#原子行内-token)。 --- # Switch Source: /versions/v0.7.1/zh-CN/component/switch 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/switch 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Switch 是一个二元开关组件,适合表示开启 / 关闭状态。它支持平滑动画、不同尺寸、标签、禁用状态和自定义颜色。 使用 `on_change` 接收请求的新值,由状态所有者保存并调用 `cx.notify()`。原有的 `on_click` 保留为兼容名称;两者设置的是同一个回调,最后一次设置生效。 ## 导入 ```rust use gpui_kit::component::switch::Switch; ``` ## 用法 ### 基础 Switch ```rust Switch::new("my-switch") .checked(false) .on_change(|checked, _, _| { println!("Switch is now: {}", checked); }) ``` ### 受控 Switch ```rust struct MyView { is_enabled: bool, } impl Render for MyView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { Switch::new("switch") .checked(self.is_enabled) .on_change(cx.listener(|view, checked, _, cx| { view.is_enabled = *checked; cx.notify(); })) } } ``` ### 带标签 ```rust Switch::new("notifications") .label("Enable notifications") .checked(true) .on_change(|checked, _, _| { println!("Notifications: {}", if *checked { "enabled" } else { "disabled" }); }) ``` ### 不同尺寸 ```rust Switch::new("small-switch") .small() .label("Small switch") Switch::new("medium-switch") .label("Medium switch") Switch::new("custom-switch") .with_size(Size::Small) .label("Custom size") ``` ### 禁用状态 ```rust Switch::new("disabled-off") .label("Disabled (off)") .disabled(true) .checked(false) Switch::new("disabled-on") .label("Disabled (on)") .disabled(true) .checked(true) ``` ### 自定义颜色 `color()` 用于覆盖选中状态下的背景色;禁用态透明度会自动叠加: ```rust Switch::new("switch") .label("Success") .checked(true) .color(cx.theme().success) Switch::new("switch") .label("Danger") .checked(true) .color(cx.theme().danger) Switch::new("switch") .label("Disabled") .checked(true) .color(cx.theme().success) .disabled(true) ``` ### 带 Tooltip ```rust Switch::new("switch") .label("Airplane mode") .tooltip("Enable airplane mode to disable all wireless connections") .checked(false) ``` ### 键盘焦点 Switch 默认是一个 Tab 停靠点,获得焦点时会在轨道外侧绘制主题焦点环,与 `Checkbox`、`Button` 一致。若焦点样式由外层绘制,传入 `focus_ring(false)` 关闭焦点环;用 `tab_stop` / `tab_index` 调整它在 Tab 顺序中的位置。 ```rust Switch::new("switch") .label("Custom tab order") .tab_index(2) .tab_stop(true) // 外层行自己绘制焦点样式 Switch::new("switch") .label("Quiet focus") .focus_ring(false) ``` ## API 参考 ### Switch | 方法 | 说明 | | --- | --- | | `new(id)` | 使用给定 ID 创建开关 | | `checked(bool)` | 设置当前选中状态 | | `label(text)` | 设置标签文本 | | `label_side(side)` | 设置标签位置,`Side::Left` 或 `Side::Right` | | `disabled(bool)` | 设置禁用状态 | | `tooltip(text)` | 添加提示文本 | | `color(color)` | 设置选中时的背景色,默认 `theme.primary` | | `on_change(fn)` | 点击回调,参数为新的 `&bool` 状态 | | `focus_ring(bool)` | 获得焦点时是否在轨道外侧绘制焦点环,默认 `true` | | `tab_stop(bool)` | 是否参与 Tab 焦点遍历,默认 `true` | | `tab_index(isize)` | 在 tab group 内的 Tab 顺序,默认 `0` | ### 样式 实现了 `Sizable` 和 `Disableable` trait: - `small()`:小尺寸,开关区域约 `28x16px` - `medium()`:中尺寸,默认,开关区域约 `36x20px` - `with_size(size)`:显式设置尺寸 - `disabled(bool)`:禁用状态 ## 示例 ### 设置面板 ```rust struct SettingsView { marketing_emails: bool, security_emails: bool, push_notifications: bool, } v_flex() .gap_4() .child( v_flex() .gap_2() .child( h_flex() .items_center() .justify_between() .child( v_flex() .child(Label::new("Marketing emails").text_lg()) .child( Label::new("Receive emails about new products and features") .text_color(theme.muted_foreground) ) ) .child( Switch::new("marketing") .checked(self.marketing_emails) .on_change(cx.listener(|view, checked, _, cx| { view.marketing_emails = *checked; cx.notify(); })) ) ) ) ``` ### 紧凑设置列表 ```rust v_flex() .gap_3() .child( Switch::new("wifi") .label("Wi-Fi") .label_side(Side::Left) .checked(true) .small() ) .child( Switch::new("bluetooth") .label("Bluetooth") .label_side(Side::Left) .checked(false) .small() ) ``` ## 动画 Switch 包含平滑切换动画: - 切换动画时长约 150ms - 背景色会在关闭色与激活色之间过渡 - 圆点位置会平滑移动 - 禁用状态下不会触发交互动效 --- # MessageScroller Source: /versions/v0.7.1/zh-CN/component/message-scroller 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/message-scroller 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `MessageScroller` 将 GPUI 的可变高度虚拟列表与会话常见的尾部跟随行为组合起来。它负责虚拟 row、滚动状态、append/prepend 的结构同步和可选的“跳到最新”按钮;消息数据、稳定消息 ID、未读规则和请求状态仍由应用持有。 ## 适用场景与所有权 适合以下会话列表: - 新消息到达时,用户在底部会自动跟随,向上阅读时保持当前位置。 - 历史消息从顶部插入时,当前可见内容保持稳定。 - 流式响应导致已有 row 高度变化,需要局部重新测量。 - 需要未读边界、任意 index 定位或自定义跳转按钮。 `MessageScroller` 不保存消息模型、消息 ID 到 index 的映射、网络请求、未读持久化或空/错误状态。应用负责把数据变化与状态 Entity 的变化放在同一个更新流程中。 ## 导入 ```rust use std::time::Duration; use gpui_kit::{div, IntoElement as _, ParentElement as _, StyleRefinement, Styled as _}; use gpui_kit::component::{ ActiveTheme as _, button::ButtonVariants as _, message_scroller::{MessageScroller, MessageScrollerState}, Sizable as _, StyledExt as _, }; ``` ## 创建状态 把 `MessageScrollerState` 存在应用 view 的 Entity 中,与消息集合一起拥有: ```rust let scroller = cx.new(|cx| MessageScrollerState::new(messages.len(), cx)); // 父 view 读取滚动状态或渲染 scroller 时,观察 Entity 以响应滚动事件。 cx.observe(&scroller, |_, _, cx| cx.notify()).detach(); ``` 状态构造器会把 GPUI `ListState` 设置为尾部跟随,并安装一次延迟 scroll handler。GPUI 触发 handler 时可能仍持有内部 list 借用,因此不要把 Entity 更新直接塞进 list 的同步回调之外的自定义借用逻辑。 ## 渲染 row 传入按 index 渲染 row 的闭包。GPUI 会虚拟化 row,应用只需要渲染当前 index 对应的消息: ```rust MessageScroller::new( "conversation", scroller.clone(), move |index, window, cx| { render_message(&messages[index], window, cx) }, ) .w_full() .h_96() ``` `render_message(...)` 可以返回 `Message`、`MessageGroup` 或应用自己的 row。row 的稳定 element ID 应由应用根据消息 ID 生成;`MessageScroller` 只接收 index,不保存 index 到 ID 的映射。 空列表不会调用 row renderer。空状态、加载历史、网络错误和权限提示应由应用在 scroller 外层或 `item_count == 0` 的分支中组合: ```rust if messages.is_empty() { div() .size_full() .flex() .items_center() .justify_center() .child("还没有消息") .into_any_element() } else { MessageScroller::new("conversation", scroller.clone(), render_message) .into_any_element() } ``` 不要把空状态硬编码进 `MessageScroller`,这样应用才能区分“没有消息”“正在加载”和“加载失败”。 ## Append、流式响应与重新测量 消息数据和列表结构必须同步更新: ```rust messages.push(new_message); scroller.update(cx, |state, cx| { state.append(1, cx); }); cx.notify(); ``` 只有列表正在跟随尾部时,`append(...)` 才会自动滚到新的尾部;用户向上阅读时,新消息保留在列表底部并使“跳到最新”按钮出现。 流式响应通常不会增加 row 数量,而是改变现有消息的文本和高度。更新消息后重新测量对应的 index: ```rust messages[index].content.push_str(delta); scroller.update(cx, |state, cx| { state.remeasure_items(index..index + 1, cx); }); cx.notify(); ``` 如果字体、窗口宽度或文本布局全局变化,重新测量所有 row: ```rust scroller.update(cx, |state, cx| state.remeasure(cx)); ``` `remeasure_items(...)` 和 `remeasure(...)` 只标记布局重新计算,不改变应用消息数据。调用方应保证 range 在当前 `item_count()` 范围内。 ## 尾部跟随 状态提供两个 reader: ```rust let following_tail = scroller.read(cx).is_following_tail(); let scrolled_up = scroller.read(cx).is_scrolled_up(); ``` `is_following_tail()` 表示新增 row 是否会推动 viewport;`is_scrolled_up()` 表示用户已经离开最新内容且当前不在末尾。应用可以用后者显示自己的提示或通知,但通常直接保留内置跳转按钮即可。 用户滚动到末尾后,list 会恢复尾部跟随。调用 `scroll_to_end(...)` 会显式恢复跟随模式并滚到最新 row: ```rust scroller.update(cx, |state, cx| state.scroll_to_end(cx)); ``` “正在生成”的状态、暂停自动滚动和“新消息”提示属于应用交互;MessageScroller 只负责尾部跟随的列表行为。 ## Prepend 历史消息 加载更早消息时,先在应用数据开头插入,再告诉状态增加了多少 row: ```rust messages.splice(0..0, earlier_messages); scroller.update(cx, |state, cx| { state.prepend(earlier_count, cx); }); cx.notify(); ``` `prepend(...)` 会通过 GPUI list 的 splice 保留当前 item 锚点,使用户正在阅读的内容尽量保持在原来的 viewport 位置。不要只更新 `messages` 而忘记更新 scroller;否则 renderer 的 index 与 list 的 row 数量会失去同步。 任意结构替换使用 `splice(...)`: ```rust // 用新的 3 条记录替换 index 10..12 的两条记录。 messages.splice(10..12, replacement_messages); scroller.update(cx, |state, cx| { state.splice(10..12, 3, cx); }); ``` range 必须满足 `start <= end <= item_count()`;无效 range 会返回 `false`,且不改变状态。`append(...)` 和 `prepend(...)` 同样返回是否成功。 ## 未读与 index 定位 未读 ID 和消息 ID 属于应用模型。先把 ID 转成当前 index,再调用 `scroll_to_item(...)`: ```rust if let Some(index) = messages .iter() .position(|message| message.id == first_unread_id) { scroller.update(cx, |state, cx| { state.scroll_to_item(index, cx); }); } ``` `scroll_to_item(...)` 以 index 为 viewport 起始位置并暂停尾部跟随;靠近末尾时受可用滚动范围限制,index 超出当前数量时返回 `false`。它是唯一的定位原语:未读边界、搜索结果、书签消息、回复目标、深链接都先在应用侧解析成 index。组件没有 ID-native 的定位、turn anchor、peek 或可见 ID API;这些行为应由应用维护 ID/index 映射,并在需要时组合自己的 header、提示或高亮。 ## Reset 与初始位置 切换会话或重新加载一组完全不同的数据时,先替换应用数据,再 `reset(...)`: ```rust messages = load_thread(thread_id); scroller.update(cx, |state, cx| { state.reset(messages.len(), cx); }); cx.notify(); ``` `reset(...)` 会重新安装 row 数量并恢复尾部跟随。若产品需要从未读位置或已保存 index 打开会话,可在 reset 后由应用调用 `scroll_to_item(...)`;已保存位置的持久化和恢复条件不属于组件状态。 ## 跳到最新按钮 默认会在用户离开尾部时显示内置按钮。可以本地化标签、关闭按钮或自定义样式: ```rust MessageScroller::new("conversation", scroller.clone(), render_message) .with_jump_button_label("跳到最新") .with_jump_button_style( StyleRefinement::default() .border_color(cx.theme().border), ) .with_jump_button_transition(Duration::from_millis(250)) ``` `Duration::ZERO` 会关闭按钮的进入/离开过渡;系统启用 reduced motion 时直接使用最终状态。 需要完全由应用提供按钮时,可以关闭内置入口,并根据 `is_scrolled_up()` 和 `scroll_to_end(...)` 组合自己的 Button: ```rust MessageScroller::new("conversation", scroller.clone(), render_message) .jump_button(false) ``` `with_jump_button_renderer(...)` 接收已经配置好默认行为的 `Button`,因此可以调整 variant、语义尺寸、图标、可见 label 或实例样式,同时保留内置滚动操作: ```rust MessageScroller::new("conversation", scroller.clone(), render_message) .with_jump_button_label("跳到最新") .with_jump_button_renderer(|button| { button.outline().large().label("跳到最新") }) ``` 如果只需要修改 Button 的样式,优先使用 `with_jump_button_style(...)`;需要替换 label 或 variant 时使用 renderer。 ## Scrollbar、列表和 row 样式 三个 style slot 对应不同布局边界: ```rust MessageScroller::new("conversation", scroller.clone(), render_message) .scrollbar(false) .with_content_style( StyleRefinement::default().bg(cx.theme().background), ) .with_list_style( StyleRefinement::default().px_4().py_3(), ) .with_row_style( StyleRefinement::default().pb_6(), ) ``` | Builder | 作用范围 | | --- | --- | | `with_content_style(...)` | 内部 viewport 与 scrollbar 所在的容器。 | | `with_list_style(...)` | GPUI list 本身,包括默认 list padding 的 refinement。 | | `with_row_style(...)` | 每个 renderer row 外层的全宽包装。 | | `Styled` | `MessageScroller` 根容器。 | 组件默认只在 row 之间保留 `pb_8()` 间距(类似 CSS gap),最后一行到消息区下方内容的间距由 list 自己的底部 padding 承担。自定义 `with_row_style(...)` 时应明确自己是否要额外增加间距,避免重复 padding。GPUI list 只在垂直方向偏移 row,因此 list padding 的水平分量(默认值与 refinement 均是)由每个 row 包装层承载。 `with_bottom_fade(color)` 让消息区底缘渐隐到指定颜色:被裁切一半的 row 融入 scroller 背后的表面,而不是在行中间生硬截断。渐隐只在读者离开末尾时显示——滚到最底时下方没有被裁内容,不再遮挡最后一行。传入 scroller 所在表面的颜色;默认关闭。 ## 虚拟化、性能与可变高度 - `MessageScroller` 使用 GPUI `list(...)`,只创建 viewport 附近的 row;不需要额外 Provider、Viewport、Content 或 Item wrapper。 - renderer 应保持轻量,不要在 render 闭包中同步执行网络、文件读取或昂贵解析;先在应用状态层准备数据。 - 消息内容变化但 row 数量不变时使用 `remeasure_items(...)`,全局字体或宽度变化时使用 `remeasure(...)`。 - prepend 前后保持同一消息 ID 到数据记录的顺序,避免应用在 list 更新期间重排未涉及的消息。 - `item_count()` 是 list 记录的事实来源;应用数组长度与它不一致时应先用 `reset` 或 `splice` 修复结构。 ## 可访问性 - 默认跳转按钮是可聚焦的 `Button`;`with_jump_button_label(...)` 只设置本地化 tooltip。若使用 renderer 替换为 icon-only 外观,应通过 `.label("跳到最新")` 保留可读名称,或关闭内置按钮后由应用提供自己的带 label Button。 - “跳到最新”“加载更早消息”“正在生成”“加载失败”等状态应提供文本或明确的 Button label,不依赖滚动位置和颜色。 - 键盘用户应能访问消息 row 中的 Link、Button、附件操作和应用自定义滚动入口。 - 消息区 viewport 以 log 区域(`Role::Log`)对外声明,辅助技术可以把追加的 row 当作实时新增内容播报。- 消息区上的滚轮事件是被包含的:list 还能滚动时事件不会带动外层滚动容器;到达顶部或底部边缘后才交给外层,与平台滚动容器的链式行为一致。 - 空状态和错误状态应由应用渲染可读内容;不要让一个空的虚拟列表看起来像加载失败。 - 自定义 jump transition、row 动画或流式高亮时,遵循系统 reduced motion,并提供静态最终状态。 ## 组件边界 GPUI 版本保留必要的滚动行为,省略 React primitive 中重复的 Provider、Viewport、Content、Item 和 Button 导出: - `Entity` 提供状态所有权与通知,不需要 React Context。 - GPUI `list(...)` 已经负责 viewport、虚拟内容、item 测量和滚动锚点。 - index renderer 已经是 row 边界,再增加 `MessageScrollerItem` 只会包装任意内容。 - 跳转操作复用现有 `Button`,应用可以关闭内置按钮并自行组合。 - 消息 ID、未读 ID、错误状态和历史加载属于业务域,由应用保留。 ## API 参考 ### `MessageScrollerState` | 方法 | 说明 | | --- | --- | | `new(item_count, cx)` | 创建指定 row 数量并启用尾部跟随的 Entity 状态。 | | `item_count()` | 返回当前虚拟 row 数量。 | | `is_scrolled_up()` | 判断是否离开尾部且当前不在末尾。 | | `is_following_tail()` | 判断是否会继续跟随新增内容。 | | `reset(item_count, cx)` | 重置 row 数量并恢复尾部跟随。 | | `splice(old_range, count, cx)` | 用指定数量替换已有 range;无效 range 返回 `false`。 | | `append(count, cx)` | 在尾部增加 row。 | | `prepend(count, cx)` | 在开头增加 row,并保留当前滚动锚点。 | | `remeasure(cx)` | 标记所有 row 重新测量。 | | `remeasure_items(range, cx)` | 标记指定 range 重新测量。 | | `scroll_to_item(index, cx)` | 定位到指定 index;越界返回 `false`。 | | `scroll_to_end(cx)` | 恢复尾部跟随并滚到最新内容。 | ### `MessageScroller` | 方法 | 说明 | | --- | --- | | `new(id, state, renderer)` | 创建虚拟消息列表;renderer 接收 `(index, window, cx)`。 | | `scrollbar(bool)` | 显示或隐藏内置 scrollbar。 | | `jump_button(bool)` | 显示或隐藏内置“跳到最新”按钮。 | | `with_jump_button_label(label)` | 设置按钮使用的本地化 tooltip 文本;不会替代 Button 的可读 label。 | | `with_content_style(style)` | 调整 viewport 容器。 | | `with_list_style(style)` | 调整 GPUI list。 | | `with_row_style(style)` | 调整每个 row 外层包装。 | | `with_jump_button_style(style)` | 在按钮默认样式之后应用 refinement。 | | `with_jump_button_renderer(renderer)` | 修改已配置行为的 Button,并保留滚动操作。 | | `with_jump_button_transition(duration)` | 设置按钮显示/隐藏过渡;零时长关闭过渡。 | | `with_bottom_fade(color)` | 底缘渐隐到周围表面的颜色;默认关闭。 | | `Styled` | 调整根容器。 | ### 类型链接 - [MessageScroller] - [MessageScrollerState] [MessageScroller]: https://docs.rs/gpui-component/latest/gpui_component/message_scroller/struct.MessageScroller.html [MessageScrollerState]: https://docs.rs/gpui-component/latest/gpui_component/message_scroller/struct.MessageScrollerState.html --- # Table Source: /versions/v0.7.1/zh-CN/component/table 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/table 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Table 是一个简单、无状态、可组合的表格组件,用于渲染表格型数据。与 [DataTable] 不同,它不包含虚拟滚动、排序或列管理能力,更适合直接用声明式 API 展示较小且静态的数据。 ## 导入 ```rust use gpui_kit::component::table::{ Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption, }; ``` ## 用法 ### 基础表格 ```rust Table::new() .child(TableHeader::new().child( TableRow::new() .child(TableHead::new().child("Name")) .child(TableHead::new().child("Email")) .child(TableHead::new().text_right().child("Amount")) )) .child(TableBody::new() .child(TableRow::new() .child(TableCell::new().child("John")) .child(TableCell::new().child("john@example.com")) .child(TableCell::new().text_right().child("$100.00"))) .child(TableRow::new() .child(TableCell::new().child("Jane")) .child(TableCell::new().child("jane@example.com")) .child(TableCell::new().text_right().child("$200.00"))) ) .child(TableCaption::new().child("A list of recent invoices.")) ``` ### 带 Footer ```rust Table::new() .child(TableHeader::new().child( TableRow::new() .child(TableHead::new().child("Invoice")) .child(TableHead::new().child("Status")) .child(TableHead::new().text_right().child("Amount")) )) .child(TableBody::new() .child(TableRow::new() .child(TableCell::new().child("INV001")) .child(TableCell::new().child("Paid")) .child(TableCell::new().text_right().child("$250.00"))) ) .child(TableFooter::new().child( TableRow::new() .child(TableCell::new().child("Total")) .child(TableCell::new().child("")) .child(TableCell::new().text_right().child("$250.00")) )) ``` ### 列宽 可以在 `TableHead` 和 `TableCell` 上使用 `.w()` 设置固定列宽: ```rust TableRow::new() .child(TableHead::new().w(px(80.)).child("ID")) .child(TableHead::new().child("Name")) .child(TableHead::new().w(px(120.)).child("Date")) ``` ### 文本对齐 ```rust TableHead::new().text_center().child("Status") TableCell::new().text_right().child("$1,000.00") ``` ### 去掉边框 所有表格子组件都实现了 `Styled`,可以直接自定义样式: ```rust Table::new() .border_0() .rounded_none() .child(/* ... */) ``` ### 自定义样式 ```rust TableRow::new() .bg(cx.theme().table_even) .child(/* ... */) TableCell::new() .px_4() .child("Custom padded content") ``` ## 子组件 | 组件 | 说明 | | --- | --- | | `Table` | 根容器,带边框、圆角和背景 | | `TableHeader` | 表头区域 | | `TableBody` | 表体区域 | | `TableFooter` | 表尾区域 | | `TableRow` | 一行数据 | | `TableHead` | 表头单元格 | | `TableCell` | 数据单元格 | | `TableCaption` | 表格下方说明文字 | ## API 摘要 ### Table - `new()` - 创建新表格 - 实现了 `Styled`、`ParentElement`、`Sizable`、`RenderOnce` ### TableHead / TableCell - `new()` - 创建单元格 - `w(width)` - 设置固定宽度 - `text_center()` - 居中对齐 - `text_right()` - 右对齐 ### TableHeader / TableBody / TableFooter / TableRow / TableCaption - `new()` - 创建实例 - 实现了 `Styled`、`ParentElement`、`RenderOnce` ## Table 和 DataTable 的区别 | 特性 | Table | DataTable | | --- | --- | --- | | 虚拟滚动 | No | Yes | | 列排序 | No | Yes | | 列宽调整 | No | Yes | | 列拖动 | No | Yes | | 单元格选择 | No | Yes | | 行选择 | No | Yes | | 无限加载 | No | Yes | | 键盘导航 | No | Yes | | 状态管理 | Stateless | TableState | | 适用场景 | 小型静态数据 | 大型交互式数据集 | [DataTable]: ./data-table.md --- # Sidebar Source: /versions/v0.7.1/zh-CN/component/sidebar 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/sidebar 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Sidebar 是一个灵活的应用导航组件,支持折叠状态、嵌套菜单项、头部与底部区域以及响应式布局。它非常适合文件浏览器、后台管理面板和多层级导航界面。 ## 导入 ```rust use gpui_kit::component::sidebar::{ Sidebar, SidebarHeader, SidebarFooter, SidebarGroup, SidebarMenu, SidebarMenuItem, SidebarToggleButton }; ``` ## 用法 ### 基础 Sidebar ```rust use gpui_kit::component::{sidebar::*, Side}; Sidebar::new() .header( SidebarHeader::new() .child("My Application") ) .child( SidebarGroup::new("Navigation") .child( SidebarMenu::new() .child( SidebarMenuItem::new("Dashboard") .icon(IconName::LayoutDashboard) .on_click(|_, _, _| println!("Dashboard clicked")) ) .child( SidebarMenuItem::new("Settings") .icon(IconName::Settings) .on_click(|_, _, _| println!("Settings clicked")) ) ) ) .footer( SidebarFooter::new() .child("User Profile") ) ``` ### 可折叠 Sidebar ```rust let mut collapsed = false; Sidebar::new() .collapsed(collapsed) .collapsible(true) .header( SidebarHeader::new() .child( h_flex() .child(Icon::new(IconName::Home)) .when(!collapsed, |this| this.child("Home")) ) ) ``` 配合切换按钮: ```rust SidebarToggleButton::new() .collapsed(collapsed) .on_click(|_, _, _| { collapsed = !collapsed; }) ``` ### 嵌套菜单 ```rust SidebarMenuItem::new("Projects") .icon(IconName::FolderOpen) .active(true) .children([ SidebarMenuItem::new("Web App").active(false), SidebarMenuItem::new("Mobile App").active(true), SidebarMenuItem::new("Desktop App"), ]) ``` ### 多分组 ```rust Sidebar::new() .child( SidebarGroup::new("Main") .child( SidebarMenu::new() .child(SidebarMenuItem::new("Dashboard").icon(IconName::Home)) .child(SidebarMenuItem::new("Analytics").icon(IconName::BarChart)) ) ) ``` ### Badge 与后缀 ```rust use gpui_kit::component::{Badge, Switch}; SidebarMenuItem::new("Notifications") .icon(IconName::Bell) .suffix( Badge::new() .count(5) .child("5") ) ``` ### 右侧放置 ```rust Sidebar::new() .side(Side::Right) .width(300) .header( SidebarHeader::new() .child("Right Panel") ) ``` ### 右键菜单 ```rust use gpui_kit::component::menu::PopupMenu; SidebarMenuItem::new("Project Files") .icon(IconName::Folder) .context_menu(|menu, _, _| { menu.link("Open in Editor", "https://editor.example.com") .separator() .menu_with_description("Rename", "Rename this project", Box::new(RenameAction)) .menu_with_description("Delete", "Delete this project", Box::new(DeleteAction)) }) ``` ### 自定义宽度与样式 ```rust Sidebar::new() .width(280) .border_width(2) .header( SidebarHeader::new() .p_4() .rounded(cx.theme().radius) .child("Custom Styled Sidebar") ) ``` ## 主题 Sidebar 使用一组独立的主题颜色: ```rust cx.theme().sidebar cx.theme().sidebar_foreground cx.theme().sidebar_border cx.theme().sidebar_accent cx.theme().sidebar_accent_foreground cx.theme().sidebar_primary cx.theme().sidebar_primary_foreground ``` ## 示例 ### 文件浏览器 ```rust Sidebar::new() .header( SidebarHeader::new() .child( h_flex() .gap_2() .child(IconName::Folder) .child("Explorer") ) ) ``` ### 管理后台 ```rust Sidebar::new() .header( SidebarHeader::new() .child( h_flex() .gap_2() .child("Admin Panel") ) ) ``` ### 设置侧栏 ```rust Sidebar::new() .width(300) .header( SidebarHeader::new() .child("Settings") ) ``` --- # Shimmer Source: /versions/v0.7.1/zh-CN/component/shimmer 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/shimmer 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `ShimmerText` 为文字提供连续的 loading 高光,适合 thinking、上传中、处理中的短状态。`ShimmerStyle` 保存动画的时长、颜色、宽度、方向和是否只播放一次;它可以独立使用,也可以传给 `Marker` 或 `AttachmentTitle`。 Shimmer 只负责文字表现,不保存 loading 状态。是否显示 shimmer、什么时候改为完成文本,仍由应用状态决定。 ## 适用场景 - AI 回复生成中的 “正在思考…” 或 “正在生成…”。 - 文件标题处于 `Uploading` / `Processing` 状态。 - 轻量的文本占位或后台任务状态。 如果需要骨架布局或占位矩形,使用 `Skeleton`;如果需要旋转进度指示,使用 `Spinner`;如果状态只需要静态文字,不要增加动画。 ## 导入 ```rust use std::time::Duration; use gpui_kit::Styled as _; use gpui_kit::component::{ ActiveTheme as _, StyledExt as _, attachment::{ Attachment, AttachmentContent, AttachmentDescription, AttachmentStatus, AttachmentTitle, }, marker::{Marker, MarkerContent, MarkerLoadingStyle}, shimmer::{ShimmerStyle, ShimmerText}, }; ``` ## 基础用法 ```rust ShimmerText::new("正在思考…") ``` `ShimmerText` 默认使用当前文字上下文的字号、字体、颜色、换行和截断规则。它的默认配置为: | 配置 | 默认值 | 说明 | | --- | --- | --- | | `duration` | 两秒 | 完成一次从左到右的高光扫过。 | | `highlight_color` | 自动计算 | 根据文字颜色和当前主题生成明亮但可读的高光。 | | `spread` | 相对 `0.3` | 高光半宽占文字宽度的比例,也可以传 `Pixels` 设置固定宽度。 | | `reverse` | `false` | 从左向右移动。 | | `once` | `false` | 默认循环播放。 | 需要给同一文本的多个 sibling 设置独立动画身份时,可以设置 `.id(...)`: ```rust ShimmerText::new("正在生成…") .id("assistant-status") ``` ## ShimmerStyle 可以创建一个可复用配置,再传给多个文字: ```rust let processing_shimmer = ShimmerStyle::new() .duration(Duration::from_secs(3)) .spread(0.4) .reverse(true); ShimmerText::new("正在处理文件…") .with_shimmer_style(processing_shimmer); ``` `ShimmerText` 也提供同名的快捷 builder: ```rust ShimmerText::new("正在处理文件…") .duration(Duration::from_secs(3)) .spread(0.4) .reverse(true) ``` ### 时长 `duration(...)` 设置一次完整 sweep 的时长: ```rust ShimmerText::new("正在连接…") .duration(Duration::from_secs(4)) ``` 零时长会被限制为至少一毫秒,避免动画时钟失效。加载状态通常使用较慢的周期;短周期会增加注意力和运动感,应只用于确实需要强调的状态。 ### 颜色 默认高光会跟随文字颜色和亮/暗主题。产品有明确强调色时,可以使用主题中的语义 token: ```rust ShimmerText::new("正在同步…") .highlight_color(cx.theme().primary) ``` 也可以在共享样式中设置: ```rust let shimmer = ShimmerStyle::new() .highlight_color(cx.theme().info) .spread(0.35); ``` 不要在组件调用点写固定 hex 颜色;自定义颜色应来自当前主题或应用自己的 token 层,并在亮色和暗色主题中检查对比度。 ### Spread `spread(...)` 设置高光半宽。传 `f32` 表示相对文字宽度的比例,有限值会被限制在 `0.05..=1.0`;传 `Pixels` 表示固定的绝对半宽(最小一像素),适合让长短不一的对齐 label 共享同一条高光宽度: ```rust ShimmerText::new("正在上传…") .spread(0.15); // 窄高光 ShimmerText::new("正在上传…") .spread(0.75); // 宽高光 ShimmerText::new("正在上传…") .spread(px(48.)); // 固定宽度高光 ``` 非有限值会保留原配置。较窄的 spread 更克制,较宽的 spread 更容易被注意到。 ### 方向与单次播放 需要从右向左移动时使用 `reverse(true)`;`once(true)` 让高光完成一次 sweep 后停止: ```rust ShimmerText::new("正在准备结果…") .reverse(true) .once(true) ``` 反向只改变动画方向,不改变布局、文字颜色或可访问文本。`once` 适合把一次视觉提示与状态迁移联系起来,但它不会自动改变文字,也不会通知应用;状态完成后仍应由应用重新渲染普通文字。 ## 与 Marker 组合 Marker 的 Shimmer loading 会把 `MarkerContent::text(...)` 中的文字替换为 `ShimmerText`: ```rust Marker::new() .loading(true) .with_loading_style(MarkerLoadingStyle::Shimmer) .content(MarkerContent::new().text("正在思考…")) ``` 可以把统一配置传给 Marker: ```rust Marker::new() .loading(true) .with_loading_style(MarkerLoadingStyle::Shimmer) .with_shimmer_style( ShimmerStyle::new() .duration(Duration::from_secs(3)) .highlight_color(cx.theme().primary) .spread(0.4), ) .content(MarkerContent::new().text("正在生成回复…")) ``` 显式组合的 `MarkerIcon` 和 Separator 装饰线保持静止;如果需要 Spinner,应选择 `MarkerLoadingStyle::Spinner`。 ## 与 Attachment 组合 `AttachmentTitle` 在父级状态为 `Uploading` 或 `Processing` 时自动使用 shimmer: ```rust Attachment::new() .status(AttachmentStatus::Processing) .content( AttachmentContent::new() .title(AttachmentTitle::new("report.pdf")) .description(AttachmentDescription::new("正在生成预览")), ) ``` 标题可以覆盖自己的动画配置,父级 status 仍负责决定是否处于 loading: ```rust Attachment::new() .status(AttachmentStatus::Uploading) .content( AttachmentContent::new().title( AttachmentTitle::new("large-export.zip") .with_shimmer_style( ShimmerStyle::new() .duration(Duration::from_secs(3)) .spread(0.25) .once(true), ), ), ) ``` 如果调用方给 `AttachmentTitle` 设置了显式 `AttachmentStatus::Complete`,标题不会播放 shimmer;`.with_shimmer_style(...)` 只配置动画,不会开启 loading。 ## 在消息与气泡中使用 `ShimmerText` 是普通元素,任何接受文字 child 的位置都可以使用: ```rust use gpui_kit::component::{ bubble::{Bubble, BubbleContent, BubbleVariant}, message::{Message, MessageContent}, }; Message::new() .content( MessageContent::new().bubble( Bubble::new() .with_variant(BubbleVariant::Ghost) .content(BubbleContent::new().child( ShimmerText::new("助手正在思考…"), )), ), ) ``` 生成完成后应用应切换到最终消息内容,不要让动画在操作结束后继续运行。 ## 样式、主题与 reduced motion `ShimmerText` 实现 `Styled`,可以像普通文字一样调整字号、颜色、最大宽度和布局: ```rust ShimmerText::new("正在生成…") .text_sm() .font_medium() .text_color(cx.theme().muted_foreground) .max_w_full() ``` 动画文本保持 `StyledText` 的布局,因此会继承换行和截断规则。高光的背景与 foreground 由当前主题计算;应用应避免在父级同时设置难以读取的背景和文字颜色。 系统启用 reduced motion 时,`ShimmerText` 会直接渲染静态 `StyledText`,不会请求动画帧。应用无需额外写一个重复的动画分支,但仍应让文字本身完整表达状态。 ## 可访问性指引 - shimmer 是视觉提示,状态文字必须本身有意义,不能只显示无文本的高光。“正在思考…”或“正在上传 report.pdf…”比无标签的动画条更有用。 - 颜色和移动方向不能承担唯一语义;完成、失败、暂停等状态应由应用文字或控件表达。 - 操作完成、失败或取消时,停止或替换 shimmer。 - 高光不提供角度、RTL 自动适配或单独的 disable builder。需要关闭时,不渲染 `ShimmerText`,直接渲染普通文字;需要 RTL 语义时由应用布局和文本方向处理,也可以用 `.reverse(true)` 手动控制移动方向。 - 如果应用在 ShimmerText 周围增加 Button、Link 或 overlay,交互控件仍需要自己的可读 label 和键盘路径。 - 显式高光颜色应在亮色和暗色主题中都验证过,避免低对比组合。 ## 何时不使用 Shimmer - 需要表示确定百分比时使用 `Progress`。 - 需要持续旋转指示器时使用 `Spinner`。 - 需要多行占位布局时使用 `Skeleton`。 - 已经有稳定结果时直接渲染普通 `StyledText`,不要让动画继续运行。 ## API 参考 ### `ShimmerStyle` | 方法 | 默认值 | 说明 | | --- | --- | --- | | `new()` | 同 `Default` | 创建主题高光、两秒周期的循环配置。 | | `duration(Duration)` | 两秒 | 设置完整 sweep 时长;最小为一毫秒。 | | `highlight_color(Hsla)` | 主题计算 | 设置显式高光颜色,覆盖主题计算。 | | `spread(f32 \| Pixels)` | 相对 `0.3` | 设置高光半宽:`f32` 为相对比例(限制在 `0.05..=1.0`),`Pixels` 为绝对宽度(最小 1px)。 | | `reverse(bool)` | `false` | 设置是否从右向左移动。 | | `once(bool)` | `false` | 设置是否只完成一次 sweep。 | ### `ShimmerText` | 方法 | 默认值 | 说明 | | --- | --- | --- | | `new(text)` | 默认样式、按文字生成身份 | 创建 loading 文字。 | | `id(ElementId)` | 基于文字的身份 | 区分文字相同的 sibling。 | | `with_shimmer_style(ShimmerStyle)` | 默认样式 | 应用完整的可复用配置。 | | `duration(Duration)` | 两秒 | 直接设置时长。 | | `highlight_color(Hsla)` | 主题计算 | 直接设置颜色。 | | `spread(f32 \| Pixels)` | 相对 `0.3` | 直接设置半宽。 | | `reverse(bool)` | `false` | 直接设置方向。 | | `once(bool)` | `false` | 直接设置播放次数。 | | `Styled` 方法 | 继承文字样式 | 调整字号、颜色、宽度、字体和布局。 | ### 相关组件 - [`Marker`] — 支持 spinner 或 shimmer loading 的状态行。 - [`AttachmentTitle`] — 感知状态、可自定义 shimmer 的文件标题。 - [`Progress`] — 确定进度。 - [`Spinner`] — 紧凑的不确定进度指示。 [ShimmerStyle]: https://docs.rs/gpui-component/latest/gpui_component/shimmer/struct.ShimmerStyle.html [ShimmerText]: https://docs.rs/gpui-component/latest/gpui_component/shimmer/struct.ShimmerText.html [Marker]: https://docs.rs/gpui-component/latest/gpui_component/marker/struct.Marker.html [AttachmentTitle]: https://docs.rs/gpui-component/latest/gpui_component/attachment/struct.AttachmentTitle.html [Progress]: https://docs.rs/gpui-component/latest/gpui_component/progress/struct.Progress.html [Spinner]: https://docs.rs/gpui-component/latest/gpui_component/spinner/struct.Spinner.html --- # DescriptionList Source: /versions/v0.7.1/zh-CN/component/description-list 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/description-list 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 DescriptionList 是一个用于展示键值对的通用组件,支持横向和纵向布局、多列、分隔线和不同尺寸,适合展示元数据、规格信息和摘要数据。 ## 导入 ```rust use gpui_kit::component::description_list::{DescriptionList, DescriptionItem, DescriptionText}; ``` ## 用法 ### 基础列表 ```rust DescriptionList::new() .item("Name", "GPUI Kit", 1) .item("Version", "0.1.0", 1) .item("License", "Apache-2.0", 1) ``` ### 使用 DescriptionItem Builder ```rust DescriptionList::new() .children([ DescriptionItem::new("Name").value("GPUI Kit"), DescriptionItem::new("Description").value("UI components for building desktop applications"), DescriptionItem::new("Version").value("0.1.0"), ]) ``` ### 不同布局 ```rust DescriptionList::horizontal() .item("Platform", "macOS, Windows, Linux", 1) .item("Repository", "https://github.com/longbridge/gpui-kit", 1) DescriptionList::vertical() .item("Name", "GPUI Kit", 1) .item("Description", "A comprehensive Rust desktop framework", 1) ``` ### 多列和跨列 ```rust DescriptionList::new() .columns(3) .child(DescriptionItem::new("Name").value("GPUI Kit").span(1)) .children([ DescriptionItem::new("Version").value("0.1.0").span(1), DescriptionItem::new("License").value("Apache-2.0").span(1), DescriptionItem::new("Description") .value("Full-featured UI components for desktop applications") .span(3), DescriptionItem::new("Repository") .value("https://github.com/longbridge/gpui-kit") .span(2), ]) ``` ### 分隔线 ```rust DescriptionList::new() .item("Name", "GPUI Kit", 1) .item("Version", "0.1.0", 1) .separator() .item("Author", "Longbridge", 1) .item("License", "Apache-2.0", 1) ``` ### 不同尺寸 ```rust DescriptionList::new() .large() .item("Title", "Large Description List", 1) DescriptionList::new() .item("Title", "Medium Description List", 1) DescriptionList::new() .small() .item("Title", "Small Description List", 1) ``` ### 无边框 ```rust DescriptionList::new() .bordered(false) .item("Name", "GPUI Kit", 1) .item("Type", "UI Library", 1) ``` ### 自定义标签宽度 ```rust use gpui_kit::px; DescriptionList::horizontal() .label_width(px(200.0)) .item("Very Long Label Name", "Short Value", 1) .item("Short", "Very long value that needs more space", 1) ``` ### 富文本内容 ```rust use gpui_kit::component::text::markdown; DescriptionList::new() .columns(2) .children([ DescriptionItem::new("Name").value("GPUI Kit"), DescriptionItem::new("Description").value( markdown( "UI components for building **fantastic** desktop applications.", ).into_any_element() ), ]) ``` ### 混合内容示例 ```rust DescriptionList::new() .columns(3) .label_width(px(150.0)) .children([ DescriptionItem::new("Project Name").value("GPUI Kit").span(1), DescriptionItem::new("Version").value("0.1.0").span(1), DescriptionItem::new("Status").value("Active").span(1), DescriptionItem::Separator, DescriptionItem::new("Description").value( "A comprehensive Rust desktop framework built on GPUI" ).span(3), DescriptionItem::new("Repository").value( "https://github.com/longbridge/gpui-kit" ).span(2), DescriptionItem::new("License").value("Apache-2.0").span(1), DescriptionItem::new("Platforms").value("macOS, Windows, Linux").span(2), DescriptionItem::new("Language").value("Rust").span(1), ]) ``` ## 示例 ### 用户资料信息 ```rust DescriptionList::new() .columns(2) .bordered(true) .children([ DescriptionItem::new("Full Name").value("John Doe"), DescriptionItem::new("Email").value("john@example.com"), DescriptionItem::new("Phone").value("+1 (555) 123-4567"), DescriptionItem::new("Department").value("Engineering"), DescriptionItem::Separator, DescriptionItem::new("Bio").value( "Senior software engineer with 10+ years of experience in Rust and system programming." ).span(2), ]) ``` ### 系统信息 ```rust DescriptionList::vertical() .small() .bordered(false) .children([ DescriptionItem::new("Operating System").value("macOS 14.0"), DescriptionItem::new("Architecture").value("Apple Silicon (M2)"), DescriptionItem::new("Memory").value("16 GB"), DescriptionItem::new("Storage").value("512 GB SSD"), DescriptionItem::new("GPU").value("Apple M2 10-core GPU"), ]) ``` ### 产品规格 ```rust DescriptionList::new() .columns(3) .large() .children([ DescriptionItem::new("Model").value("MacBook Pro").span(1), DescriptionItem::new("Year").value("2023").span(1), DescriptionItem::new("Screen Size").value("14-inch").span(1), DescriptionItem::new("Processor").value("Apple M2 Pro").span(2), DescriptionItem::new("Base Price").value("$1,999").span(1), DescriptionItem::Separator, DescriptionItem::new("Key Features").value( "Liquid Retina XDR display, ProMotion technology, P3 wide color gamut" ).span(3), ]) ``` ### 配置项展示 ```rust DescriptionList::horizontal() .label_width(px(180.0)) .bordered(false) .children([ DescriptionItem::new("Theme").value("Dark Mode"), DescriptionItem::new("Font Size").value("14px"), DescriptionItem::new("Auto Save").value("Enabled"), DescriptionItem::new("Backup Frequency").value("Every 30 minutes"), DescriptionItem::new("Language").value("English (US)"), ]) ``` ## 设计建议 - 简单键值对优先使用横向布局。 - 值较长或结构较复杂时优先使用纵向布局。 - 列数尽量控制在 3 到 4 列以内。 - 使用分隔线对相关信息分组。 - 标签保持简洁且语义明确。 - 使用尺寸属性统一间距和密度。 - 嵌入式场景下可考虑关闭边框。 --- # Checkbox Source: /versions/v0.7.1/zh-CN/component/checkbox 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/checkbox 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Checkbox 是一个用于二元选择的复选框组件,支持标签、禁用状态和不同文字尺寸。 使用 `on_change` 接收请求的新值,由状态所有者保存并调用 `cx.notify()`。原有的 `on_click` 保留为兼容名称;两者设置的是同一个回调,最后一次设置生效。 ## 导入 ```rust use gpui_kit::component::checkbox::Checkbox; ``` ## 用法 ### 基础 Checkbox ```rust Checkbox::new("my-checkbox") .label("Accept terms and conditions") .checked(false) .on_change(|checked, _, _| { println!("Checkbox is now: {}", checked); }) ``` `on_change` 会在用户切换状态时触发,接收到的是切换后的新状态。 ### 受控 Checkbox 这份完整的 **Tested consumer recipe** 将值保留在渲染所有者上,从 `on_change` 接收请求的新值、保存后再通知: ```rust use gpui_kit::component::checkbox::Checkbox; use gpui_kit::{Context, IntoElement, Render, Window}; pub struct ControlledCheckbox { checked: bool, } impl ControlledCheckbox { pub fn new() -> Self { Self { checked: false } } pub fn is_checked(&self) -> bool { self.checked } } impl Render for ControlledCheckbox { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { Checkbox::new("marketing-emails") .label("Receive product updates") .checked(self.checked) .on_change(cx.listener(|this, checked, _, cx| { this.checked = *checked; cx.notify(); })) } } ``` ### 不同尺寸 ```rust use gpui_kit::component::Sizable as _; Checkbox::new("cb").text_xs().label("Extra Small") Checkbox::new("cb").text_sm().label("Small") Checkbox::new("cb").label("Medium") Checkbox::new("cb").text_lg().label("Large") ``` ### 禁用状态 ```rust use gpui_kit::component::Disableable as _; Checkbox::new("checkbox") .label("Disabled checkbox") .disabled(true) .checked(false) ``` ### 不带标签 ```rust Checkbox::new("checkbox") .checked(true) ``` ### 自定义 Tab 顺序 ```rust Checkbox::new("checkbox") .label("Custom tab order") .tab_index(2) .tab_stop(true) ``` ## API 参考 - [Checkbox] ### 样式 实现了 `Sizable` 和 `Disableable` trait: - `text_xs()`:超小字号 - `text_sm()`:小字号 - `text_base()`:默认字号 - `text_lg()`:大字号 - `disabled(bool)`:禁用状态 ## 示例 ### 复选框列表 ```rust v_flex() .gap_2() .child(Checkbox::new("cb1").label("Option 1").checked(true)) .child(Checkbox::new("cb2").label("Option 2").checked(false)) .child(Checkbox::new("cb3").label("Option 3").checked(false)) ``` ### 表单集成 ```rust struct FormView { agree_terms: bool, subscribe: bool, } v_flex() .gap_3() .child( Checkbox::new("terms") .label("I agree to the terms and conditions") .checked(self.agree_terms) .on_change(cx.listener(|view, checked, _, cx| { view.agree_terms = *checked; cx.notify(); })) ) .child( Checkbox::new("subscribe") .label("Subscribe to newsletter") .checked(self.subscribe) .on_change(cx.listener(|view, checked, _, cx| { view.subscribe = *checked; cx.notify(); })) ) ``` [Checkbox]: https://docs.rs/gpui-component/latest/gpui_component/checkbox/struct.Checkbox.html --- # Notification Source: /versions/v0.7.1/zh-CN/component/notification 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/notification 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Notification 是一个 toast 通知系统,用于向用户显示短暂消息。通知会出现在窗口右上角,并可在超时后自动消失。它支持多种类型、标题、自定义内容和操作按钮,适合状态反馈、确认信息和异步操作提示。 ## 导入 ```rust use gpui_kit::component::{ notification::{Notification, NotificationType}, WindowExt }; ``` ## 用法 ### 通知的渲染位置 窗口的 [Root](/versions/v0.7.1/zh-CN/component/root) 会自动挂载并渲染通知层。使用 `gpui_kit::open_window` 打开窗口,或将应用视图包裹在 `Root::new` 中即可,无需在视图中手动渲染浮层。 ### 基础通知 ```rust window.push_notification("This is a notification.", cx); Notification::new() .message("Your changes have been saved.") ``` ### 通知类型 ```rust window.push_notification( (NotificationType::Info, "File saved successfully."), cx, ); window.push_notification( (NotificationType::Success, "Payment processed successfully."), cx, ); window.push_notification( (NotificationType::Warning, "Network connection is unstable."), cx, ); window.push_notification( (NotificationType::Error, "Failed to save file. Please try again."), cx, ); ``` ### 带标题 ```rust Notification::new() .title("Update Available") .message("A new version of the application is ready to install.") .with_type(NotificationType::Info) ``` ### 自动隐藏 ```rust Notification::new() .message("This notification stays until manually closed.") .autohide(false) Notification::new() .message("This will disappear automatically.") .autohide(true) ``` 指针悬停在通知上或某条通知获得键盘焦点时倒计时暂停,指针移开或焦点离开后继续。窗口未激活时倒计时照常进行,不能错过的消息应关闭自动隐藏或使用系统通知投递。 ### 通知位置 通知默认出现在窗口右上角。可以为所有通知设置全局默认值,也可以为单条通知覆盖。每个位置分别维护自己的堆叠。 ```rust use gpui_kit::Anchor; // 全局默认值(默认:`Anchor::TopRight`) Theme::update(cx, |theme| theme.notification.placement = Anchor::BottomRight); // 单条通知覆盖 Notification::info("Download complete.") .placement(Anchor::BottomLeft) ``` 支持的值有 `Anchor::TopLeft`、`Anchor::TopCenter`、`Anchor::TopRight`、`Anchor::LeftCenter`、`Anchor::RightCenter`、`Anchor::BottomLeft`、`Anchor::BottomCenter` 和 `Anchor::BottomRight`。 ### 操作按钮 ```rust Notification::new() .title("Connection Lost") .message("Unable to connect to server.") .with_type(NotificationType::Error) .autohide(false) .action(|_, cx| { Button::new("retry") .primary() .label("Retry") .on_click(cx.listener(|this, _, window, cx| { println!("Retrying connection..."); this.dismiss(window, cx); })) }) ``` ### 可点击通知 ```rust Notification::new() .message("Click to view details") .on_click(cx.listener(|_, _, _, cx| { println!("Notification clicked"); cx.notify(); })) ``` ### 自定义内容 ```rust use gpui_kit::component::text::markdown; let markdown_content = r#" ## Custom Notification - **Feature**: New dashboard available - **Status**: Ready to use - [Learn more](https://example.com) "#; Notification::new() .content(|_, window, cx| { markdown(markdown_content).into_any_element() }) ``` ### 唯一通知 ID 如果你要手动管理通知,例如长任务状态或持久警告,可以为通知分配唯一 ID。 ```rust struct UpdateNotification; Notification::new() .id::() .message("System update available") .autohide(false) struct TaskNotification; Notification::warning("Task failed to complete") .id1::("task-123") .title("Task Failed") ``` 后续可以通过: ```rust window.remove_notification::(cx); ``` 来移除对应通知。 ### 系统通知 通知也可以投递到操作系统的通知中心。使用 `NotificationDelivery` 选择通知的去向:应用内 toast(`InApp`,默认)、系统通知中心(`System`)、或两者都发(`InAppAndSystem`)。 ```rust use gpui_kit::component::notification::{Notification, NotificationDelivery}; // 单条通知覆盖;`.system()` 和 `.in_app_and_system()` 是 // `.delivery(NotificationDelivery::...)` 的简写。 Notification::info("Your download is ready.") .title("Download complete") .system() // 或为所有通知设置全局默认值 Theme::update(cx, |theme| { theme.notification.delivery = NotificationDelivery::InAppAndSystem }); ``` 通知的标题和消息分别成为系统通知的标题和正文;两者都缺失时不会投递。用相同的 `.id::()` 再次推送会替换之前的系统通知,`window.remove_notification::(cx)` / `window.clear_notifications(cx)` 会将其撤回。toast 自动隐藏时,系统通知会保留在通知中心。 点击系统通知会激活应用及其窗口、关闭对应的应用内 toast(如有)、并以默认的 `ClickEvent` 触发 `on_click`。`NotificationDelivery::System` 模式下没有 toast,因此 `on_close` 不会被调用。 `gpui_kit::component::init` 会注册应用级的 `on_system_notification_response` 处理器,之后请勿再自行注册——gpui 只保留一个。应用通过 `cx.show_system_notification` 直接发送的系统通知不受影响。 平台要求: | 平台 | 要求 | 撤回 | | --- | --- | --- | | macOS | 必须从可信位置(如 `/Applications`)的打包 `.app` 运行;`cargo run` 裸跑时静默禁用。首次投递会触发系统授权弹窗,拒绝后系统会记住该选择,后续投递静默失败 | 支持 | | Windows | 启动早期调用 `cx.set_app_identity(identifier, name)` | 支持 | | Linux | 需要 XDG 通知守护进程 | 不支持(自然过期) | ## 示例 ### 表单校验失败 ```rust Notification::error("Please correct the following errors before submitting.") .title("Validation Failed") .autohide(false) ``` ### 文件上传进度 ```rust struct UploadNotification; window.push_notification( Notification::info("Uploading file...") .id::() .title("File Upload") .autohide(false), cx, ); ``` --- # Spinner Source: /versions/v0.7.1/zh-CN/component/spinner 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/spinner 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Spinner 用于显示旋转中的加载动画,适合异步请求、处理中状态和其他需要即时反馈的场景。它支持自定义图标、颜色、尺寸以及内置旋转动画。 ## 导入 ```rust use gpui_kit::component::spinner::Spinner; ``` ## 用法 ### 基础用法 ```rust Spinner::new() ``` ### 自定义颜色 ```rust use gpui_kit::component::ActiveTheme; Spinner::new() .color(cx.theme().blue) Spinner::new() .color(cx.theme().green) Spinner::new() .color(cx.theme().cyan) ``` ### 不同尺寸 ```rust Spinner::new().xsmall() Spinner::new().small() Spinner::new() Spinner::new().large() Spinner::new().with_size(px(64.)) ``` ### 自定义图标 ```rust use gpui_kit::component::IconName; Spinner::new() .icon(IconName::LoaderCircle) Spinner::new() .icon(IconName::LoaderCircle) .large() .color(cx.theme().cyan) Spinner::new() .icon(IconName::Loader) .color(cx.theme().primary) ``` ## 可用图标 ### 加载图标 - `Loader`,默认的线形旋转图标 - `LoaderCircle`,圆形加载图标 ### 其他兼容图标 - 理论上可使用 `IconName` 中任意图标,但带旋转语义的图标效果最好 ## 动画 Spinner 内置旋转动画: - 时长:`0.8` 秒 - 缓动:ease-in-out - 循环:无限重复 - 变换:360 度旋转 ## 尺寸参考 | 尺寸 | 方法 | 近似像素 | | --- | --- | --- | | 超小 | `.xsmall()` | ~12px | | 小 | `.small()` | ~14px | | 中 | 默认 | ~16px | | 大 | `.large()` | ~24px | | 自定义 | `.with_size(px(n))` | `n` px | ## 示例 ### 加载状态 ```rust Spinner::new() Spinner::new() .color(cx.theme().blue) Spinner::new() .large() .color(cx.theme().primary) ``` ### 不同加载图标 ```rust Spinner::new() .color(cx.theme().muted_foreground) Spinner::new() .icon(IconName::LoaderCircle) .color(cx.theme().blue) Spinner::new() .icon(IconName::LoaderCircle) .large() .color(cx.theme().green) ``` ### 状态型 Spinner ```rust Spinner::new() .small() .color(cx.theme().muted_foreground) Spinner::new() .icon(IconName::LoaderCircle) .color(cx.theme().blue) Spinner::new() .icon(IconName::LoaderCircle) .color(cx.theme().green) ``` ### 在界面组件中使用 ```rust Button::new("submit-btn") .loading(true) .icon( Spinner::new() .small() .color(cx.theme().primary_foreground) ) .label("Loading...") // 在卡片头部 div() .flex() .items_center() .gap_2() .child("Processing...") .child( Spinner::new() .small() .color(cx.theme().muted_foreground) ) ``` ## 性能说明 - 动画基于 transform,性能开销较低 - 多个 Spinner 可共享相同动画节奏 - 组件本身较轻,适合频繁更新的界面 - 大量同时显示时,优先使用更小尺寸 ## 常见模式 ### 条件加载 ```rust .when(is_loading, |this| { this.child( Spinner::new() .small() .color(cx.theme().muted_foreground) ) }) ``` ### 文字配合加载图标 ```rust h_flex() .items_center() .gap_2() .child( Spinner::new() .small() .color(cx.theme().primary) ) .child("Loading data...") ``` --- # Root View Source: /versions/v0.7.1/zh-CN/component/root 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/root 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 [Root] 是由 Base 提供的统一窗口根视图。应用统一通过 `gpui_kit::open_window` 创建窗口,它始终使用这个类型。Base 不提供额外的窗口创建函数;`component::Root` 重导出 Base 类型。 Base 负责内容与浮层承载、键盘焦点遍历和文本选择复制。显式调用 `gpui_component::init` 会注册窗口展示扩展,提供对话框、抽屉、通知、tooltip、菜单、触屏选择、主题与窗口边框。必须在创建窗口前初始化。仅使用 Base 的应用调用 `gpui_base::init`,无需依赖 Component 或 Kit。Cargo feature 合并不会改变窗口根类型。 下面这份完整的 **Tested consumer recipe** 在隔离的 `gpui-kit` 消费者工作区中编译。它先初始化 GPUI Kit,再打开一个以 `Root` 包裹应用视图为根的窗口。 ```rust use gpui_kit::{ AppContext as _, Context, IntoElement, ParentElement as _, Render, Styled as _, Window, WindowOptions, div, }; pub fn run() { gpui_kit::application() .with_assets(gpui_kit::assets::Assets) .run(|cx| { gpui_kit::init(cx); // The window's root view is a `Root` wrapping the view, which // renders dialogs, sheets and notifications above it. gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| { cx.new(|_| BootstrapView) }) .expect("failed to open window"); }); } struct BootstrapView; impl Render for BootstrapView { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div().size_full().child("My application") } } ``` `gpui_kit::open_window` 就是 `cx.open_window` 加上 `Root` 包裹。`Root` 必须是窗口的根视图, 以提供对话框、侧边面板、通知、焦点遍历和文本选择等窗口级能力。客户端窗口边框由窗口的 decorations 模式决定;server decorations 和 layer-shell 窗口不需要配置 Root。 `open_window` 同时返回窗口和视图,所以必须在窗口内构造的视图(比如它持有 `InputState`)也能留住句柄: ```rust let (window, editor) = gpui_kit::open_window(WindowOptions::default(), cx, |window, cx| { cx.new(|cx| Editor::new(window, cx)) })?; ``` ## 关闭窗口与退出应用 应用自行定义退出和关闭窗口的 action 及快捷键,`gpui_kit::init` 不会安装这些绑定。应用应在关闭窗口或退出之前处理未保存的内容及确认流程。 ## 浮层 `Root` 统一挂载对话框、抽屉和通知层,始终渲染在应用内容之上。应用只需调用 `window.open_dialog`、`window.open_sheet` 或 `window.push_notification`,不需要手动挂载或配置开关。子视图是否缓存不影响浮层渲染。 ### 迁移到 0.7.0 `Root::render_dialog_layer`、`Root::render_sheet_layer` 和 `Root::render_notification_layer` 已删除。删除视图中对应的调用及 `.children(...)` 即可。此前自定义的层位置统一改为窗口级 Root 的浮层位置。 [Root]: https://docs.rs/gpui-base/latest/gpui_base/struct.Root.html --- # Command Source: /versions/v0.7.1/zh-CN/component/command 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/command 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 命令面板是带有分组、由 Action 派生的快捷键提示和键盘导航的命令过滤列表。可以内嵌使用,也可以组合到现有对话框中,作为 `⌘K` 风格的菜单。失效时,Command 会创建并布局测量每一条扁平化的行;随后 `v_virtual_list` 只渲染和绘制视口行。 `Command` 拥有条目和展示策略。`CommandState` 拥有交互状态:搜索输入、焦点、选择、滚动和加载状态。 ## 引入 ```rust use gpui_kit::component::command::{Command, CommandEntry, CommandGroup, CommandItem, CommandState}; ``` ## 组合方式 直接在 `Command` 上构建面板结构;创建一个空状态,并在面板显示期间复用它。 ```text Command ├── CommandItem // 未分组 ├── CommandGroup │ ├── CommandItem │ └── CommandItem ├── separator └── CommandGroup ├── CommandItem └── CommandItem CommandState // 查询、焦点、选择、滚动 ``` ## 用法 ### 内嵌 在应用初始化时定义 Action 和绑定。默认行会先在 Command 焦点作用域、再在应用作用域解析 Action 的当前绑定;只有找到绑定时才渲染 `Kbd` 提示。 ```rust use gpui_kit::{actions, KeyBinding}; actions!(my_app, [OpenProfile, OpenBilling]); // During application setup: cx.bind_keys([ KeyBinding::new("cmd-p", OpenProfile, Some("Command")), KeyBinding::new("cmd-b", OpenBilling, Some("Command")), ]); let state = cx.new(|cx| CommandState::new(window, cx)); Command::new(&state) .group( CommandGroup::new().label("Suggestions") .item(CommandItem::new().label("Calendar").icon(IconName::Calendar)) .item(CommandItem::new().label("Search Emoji").icon(IconName::Search)) .item(CommandItem::new().label("Calculator").disabled(true)), ) .separator() .group( CommandGroup::new().label("Settings") .item( CommandItem::new().label("Profile") .icon(IconName::User) .action(Box::new(OpenProfile)), ) .item( CommandItem::new().label("Billing") .action(Box::new(OpenBilling)), ), ) .placeholder("Type a command or search...") .empty(|_, _, cx| { v_flex() .items_center() .gap_2() .child(Icon::new(IconName::Search).size_8()) .child("No results found.") }) .w(px(380.)) ``` 不要提供手工格式化的快捷键字符串。`CommandItem::action` 同时提供可执行行为,并为默认行提供显示的绑定。自定义行拥有完整的展示内容,包括任何按键提示。 ### 无搜索的快捷操作 为紧凑的操作面板关闭搜索。它没有搜索框,保留全部条目,并且 `state.focus(window, cx)` 会聚焦 Command 外框,因此仍可使用方向键、Enter 和 Escape 操作。 ```rust let actions = cx.new(|cx| CommandState::new(window, cx)); Command::new(&actions) .searchable(false) .items([ CommandItem::new().label("New File").icon(IconName::Plus), CommandItem::new().label("Duplicate").icon(IconName::Copy), CommandItem::new().label("Move to Trash").icon(IconName::Delete), ]) .w(px(380.)) ``` 默认的 `.searchable(true)` 下,`state.focus(window, cx)` 和 [`Focusable::focus_handle`] 会改为聚焦搜索输入框。不可搜索的面板不会调用 `on_query`。 ### 在对话框中 使用现有的 [`WindowExt::open_dialog`] API 组合命令面板。`header` 渲染在可选搜索框和列表之上;`footer` 渲染在列表之下。在可搜索面板中,Escape 会清空非空查询。否则——包括具有隐藏的程序化查询的不可搜索面板——Command 会调用 `on_cancel`,然后传播 Cancel。应由宿主 Dialog 完成关闭——不要在 `on_cancel` 中再次关闭它。 ```rust use gpui_kit::component::WindowExt as _; let state = self.command_state.clone(); window.open_dialog(cx, move |dialog, _, _| { let state = state.clone(); dialog.close_button(false).p_0().content(move |content, _, _| { content.child( Command::new(&state) .bordered(false) .placeholder("Type a command or search...") .items([ CommandItem::new().label("Profile"), CommandItem::new().label("Billing"), ]) .on_confirm(|index, window, cx| { window.push_notification(format!("Selected {index}"), cx); }) // Record local cleanup only; Dialog handles the propagated Cancel. .on_cancel(|window, cx| { window.push_notification("Command palette cancelled", cx); }) .header(|state, _, cx| { h_flex() .justify_between() .px_3() .py_2() .border_b_1() .border_color(cx.theme().border) .child("Commands") .child(format!("{} matches", state.matched_count())) }) .footer(|_, _, cx| { h_flex() .gap_3() .px_3() .py_2() .border_t_1() .border_color(cx.theme().border) .child("↑↓ Navigate") .child("Enter Select") .child("Escape Close") }), ) }) }); ``` ### 回调与 Action 回调配置在 `Command` 上,而不是从 `CommandState` 订阅。它们直接通知面板所有者: ```rust Command::new(&state) .items(entries) .on_query(|query, window, cx| { // Start or update an application-owned search. }) .on_select(|index, window, cx| { // Preview the newly highlighted IndexPath. }) .on_confirm(|index, window, cx| { // Finish with this IndexPath, whether or not it has an Action. }) .on_cancel(|window, cx| { // Clean up local palette state before Cancel propagates. }) ``` `IndexPath` 始终对应最近一次 `Command` render 传入的模型,而不是内部过滤后的可见位置。 通过 `.items(...)` 传入的条目位于 section 0,`row` 等于它在该迭代器中的位置; 显式 group 使用其 group 与 item 位置;两种形式混用时,显式 group 排在隐式未分组 section 之后。 搜索过滤只改变可见内容,不改变这些坐标。 `on_query` 只在可搜索查询实际变化时运行。重新过滤可能移动高亮,因此所选 `IndexPath` 变化时会先运行 `on_select`,再运行 `on_query`。这些回调与 `on_confirm` 都会在当前 `CommandState` 更新释放其租用后交付。键盘和指针导致的高亮变化会运行 `on_select`,但从不分发 Action。只要来源窗口仍然存活,确认已启用条目时,会先分发其 Action,再调用 `on_confirm`;如果该 Action 关闭窗口,回调将无法交付。没有 Action 的条目仍会调用 `on_confirm`。在可搜索面板中,Escape 会先清空非空查询;否则调用 `on_cancel` 并继续传播 Cancel。 ### 动态条目 将异步或变化的条目保存在所有者视图中,然后在该视图渲染时,根据所有者的当前数据重新构建 Command。不要通过条目构建器或 `set_entries` 修改 state。 ```rust struct StockSearch { state: Entity, results: Vec, } impl StockSearch { fn render_palette(&self, owner: WeakEntity) -> Command { let results = self.results.clone(); Command::new(&self.state) .items(results) .on_query(move |query, window, cx| { _ = owner.update(cx, |this, cx| this.search(query, window, cx)); }) } } ``` 当查询、选择和滚动改变时,已安装的模型会保留在 `CommandState` 中,因此这些交互不需要重新渲染所有者。之后的所有者渲染会安装新模型;若所选 `IndexPath` 仍存在则保留选择,并重新测量行。 ## 搜索 Command 默认在条目的 label 和 keywords 中进行忽略大小写的子串匹配。空查询会匹配全部条目。分组中的条目全被过滤时,其标题会隐藏;过滤后位于首尾或相邻的分隔线不会显示。 ```rust CommandItem::new().label("Profile") .keywords(["account", "user"]) ``` 自定义或远程搜索时,在 `on_query` 中更新所有者持有的条目,并在等待时调用 `state.set_loading(true, window, cx)`,以隐藏空状态文案。响应到达后渲染新条目。 ## 自定义行与虚拟滚动 `CommandItem::child` 会用惰性子元素工厂替换条目的图标和 label 内容。该工厂可能因测量、进入视口、排版或宽度失效而多次运行,因此必须无副作用。 失效时,Command 会在向 `v_virtual_list` 提供独立尺寸前创建并布局测量每一条扁平化的行。因此,自定义行可以拥有不同的固有高度;`v_virtual_list` 仍只渲染和绘制视口行。应按列表可用宽度构建行,并在所有者更新条目前保持其渲染内容稳定。 ```rust Command::new(&state) .item(CommandItem::new().label("compact").child(|_, _| { h_flex().w_full().py_1().child("Compact custom row") })) .item(CommandItem::new().label("expanded").child(|_, cx| { v_flex() .w_full() .py_4() .child("Expanded custom row") .child(div().text_xs().text_color(cx.theme().muted_foreground).child("Extra detail")) })) ``` ## Command | 方法 | 签名与说明 | | --- | --- | | `new` | `new(&Entity) -> Command` 为 state 创建面板。 | | `item` / `items` | `item(CommandItem) -> Self` 与 `items(impl IntoIterator) -> Self` 添加未分组条目。 | | `group` / `separator` | `group(CommandGroup) -> Self` 添加分组;`separator() -> Self` 添加分隔线。 | | `searchable` | `searchable(bool) -> Self` 显示或隐藏搜索框和本地过滤。默认:`true`。 | | `on_query` | `on_query(F) -> Self`,其中 `F: Fn(&str, &mut Window, &mut App) + 'static`,在可搜索查询变化后运行。 | | `on_select` | `on_select(F) -> Self`,其中 `F: Fn(IndexPath, &mut Window, &mut App) + 'static`,在高亮路径变化时运行。 | | `on_confirm` | `on_confirm(F) -> Self`,使用相同的 `IndexPath` 回调约束;在确认的 Action 分发后运行。 | | `on_cancel` | `on_cancel(F) -> Self`,其中 `F: Fn(&mut Window, &mut App) + 'static`,在 Escape 不会清空可搜索查询时,于 Cancel 传播前运行。 | | `placeholder` | `placeholder(impl Into) -> Self` 设置搜索框占位文本。 | | `empty` | `empty(F) -> Self` 渲染无匹配时的自定义内容。 | | `max_h` | `max_h(impl Into) -> Self` 设置列表最大高度。默认:`18.75rem`(300px)。 | | `bordered` | `bordered(bool) -> Self` 绘制外边框和圆角。默认:`true`。 | | `header` | `header(F) -> Self`,其中 `F: Fn(&CommandState, &mut Window, &mut App) -> E + 'static`、`E: IntoElement`;渲染在搜索框和列表之上。 | | `footer` | `footer(F) -> Self`,使用相同的回调约束;渲染在列表之下。 | `Command` 实现了 [`Styled`],因此 `w`、`max_w`、`bg` 和其他样式可作用于面板外框。 ## CommandItem | 方法 | 说明 | | --- | --- | | `new` | 创建条目;Command 在内部生成渲染 identity。 | | `label` | 设置可见 label 和默认搜索文本。 | | `icon` | 为默认行设置前置图标。 | | `action` | `action(Box) -> Self` 设置点击或确认时分发的行为。默认行会显示其解析后的绑定。 | | `checked` | 绘制尾部勾选。解析后的 Action 绑定会占用该位置。 | | `keywords` | 添加默认匹配词。 | | `disabled` | `Disableable::disabled(bool) -> Self` 使条目不可交互,并在键盘导航时跳过。 | | `child` | `child(F) -> Self`,其中 `F: Fn(&mut Window, &mut App) -> E + 'static`、`E: IntoElement`;惰性替换默认行内容。 | ## CommandGroup | 方法 | 说明 | | --- | --- | | `new` | 创建无标题分组。 | | `label` | 设置分组标题;所有条目被过滤时标题隐藏。 | | `item` / `items` | 向分组添加一个或多个 `CommandItem`。 | | `heading` | 返回可选标题。 | `CommandEntry` 是 item、group 或 separator 的公共枚举。当所有者保存混合的动态条目集合时很有用;渲染时应将每个变体重新应用到新构建的 `Command`。 ## CommandState | 方法 | 签名与说明 | | --- | --- | | `new` | `new(&mut Window, &mut Context) -> Self` 创建空的交互状态。 | | `query` / `set_query` | 读取查询,或通过 `set_query(query, window, cx)` 模拟输入。 | | `selected_index` | 返回高亮条目在原始 entries 中的 `IndexPath`;section 表示顶层 entry,row 表示分组内条目。 | | `matched_count` | 返回匹配条目数。 | | `focus` | `focus(&self, &mut Window, &mut App)`:可搜索时聚焦输入框,否则聚焦 Command 外框。 | | `set_loading` / `is_loading` | 显示或读取搜索加载动画;加载时隐藏空状态文案。 | ## 键盘快捷键 | 按键 | 行为 | | --- | --- | | `↑` / `↓` | 移动高亮,循环并跳过禁用项。 | | `Enter` | 确认当前高亮项。 | | `Escape` | 在可搜索面板中清空非空查询;否则调用 `on_cancel` 并传播 `Cancel`。 | ## 最佳实践 1. 在 `Command` 上构建静态条目、分组、分隔线、搜索能力和过滤器。 2. 将动态条目和异步结果保存在面板所有者中;渲染时据此重新构建 `Command`。 3. 绑定真实的 `Action`,而不是提供快捷键文本,以使提示和分发保持同步。 4. 保持 `child` 工厂无副作用;当行需要自定义展示或可变高度时使用它。 5. 在 `on_cancel` 后让宿主 Dialog 拥有取消行为;使用 header 和 footer 承载应用自有的状态和提示。 6. 每个独立渲染的面板使用各自的 [`CommandState`]。 [Command]: https://docs.rs/gpui-component/latest/gpui_component/command/struct.Command.html [CommandState]: https://docs.rs/gpui-component/latest/gpui_component/command/struct.CommandState.html [CommandGroup]: https://docs.rs/gpui-component/latest/gpui_component/command/struct.CommandGroup.html [WindowExt::open_dialog]: https://docs.rs/gpui-component/latest/gpui_component/trait.WindowExt.html#tymethod.open_dialog [Focusable::focus_handle]: https://docs.rs/gpui/latest/gpui/trait.Focusable.html#tymethod.focus_handle [Styled]: https://docs.rs/gpui/latest/gpui/trait.Styled.html --- # Marker Source: /versions/v0.7.1/zh-CN/component/marker 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/marker 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `Marker` 是一种轻量的全宽会话行,适合状态文字、时间线边界、未读提示和系统消息。它只提供通用布局与视觉变体,不定义 `Online`、`Typing`、`Read` 等业务状态;图标、内容、颜色和交互行为由应用组合。 ## 适用场景 - 一行简短的系统状态或会话提示。 - 带有左右装饰线的日期、未读边界或阶段标题。 - 需要 Spinner 或文字 shimmer 的 loading 状态。 - 需要在行中放置 `Button` 或 `Link`,但不希望把整行变成一个按钮。 只有一个数量或状态标签时,`Badge` 或 `Tag` 更直接;只有一条带文字的分隔线时,使用 `Separator`。 ## 导入 ```rust use gpui_kit::{ParentElement as _, StyleRefinement, Styled as _}; use gpui_kit::component::{ button::{Button, ButtonVariants as _}, marker::{ Marker, MarkerAlignment, MarkerContent, MarkerIcon, MarkerLoadingStyle, MarkerVariant, }, shimmer::{ShimmerStyle, ShimmerText}, spinner::Spinner, ActiveTheme as _, Colorize as _, Icon, IconName, Sizable as _, StyledExt as _, }; ``` ## 结构 ```text Marker ├── MarkerIcon # 可选,紧凑图标 slot ├── MarkerContent # 文本或富内容 slot └── 任意 child # Button、Link 或其他 GPUI element ``` `MarkerIcon` 和 `MarkerContent` 是有默认尺寸与文字布局的具名 slot;`.child(...)` 仍可用于应用自己的组合。`Marker` 本身保持为语义容器,不会替应用创建状态 enum。 ## Plain、Separator 与 Border `MarkerVariant` 提供三个有明确用途的表面: | Variant | 用途 | 默认布局 | | --- | --- | --- | | `Plain` | 普通状态或系统消息,默认值。 | 全宽紧凑行。 | | `Separator` | 日期、阶段或未读边界。 | 内容两侧显示主题边框线。 | | `Border` | 需要底部边界的状态行。 | 内容下方显示语义边框。 | ```rust Marker::new() .content(MarkerContent::new().text("会话已归档")); Marker::new() .with_variant(MarkerVariant::Separator) .content(MarkerContent::new().text("今天")); Marker::new() .with_variant(MarkerVariant::Border) .content(MarkerContent::new().text("3 条未读消息")) ``` Separator 的装饰线是内部实现,不携带语义内容。文本本身应说明它代表的日期、边界或状态。 ## 对齐 Marker 占满整行,`alignment(...)` 决定 child 在行内的位置,以及折行文字的对齐方式: ```rust Marker::new() .alignment(MarkerAlignment::Center) .icon(MarkerIcon::new().child(Icon::new(IconName::Info))) .content(MarkerContent::new().text("消息已端到端加密")); Marker::new() .alignment(MarkerAlignment::End) .content(MarkerContent::new().text("已送达")) ``` 不设置时,`Separator` 的文字居中在两条装饰线之间,其余 variant 从起始侧开始;显式设置对任何 variant 都生效,此时 `Separator` 只保留文字远端那一侧的线:`Start` 只画后面的线,`End` 只画前面的线。会话里的系统提示行(已停止回答、请求失败并附带重试)通常居中;`End` 适合挂在发出消息下方的送达状态。 ## 状态内容与图标 应用可以组合 Icon、Spinner、Badge 或自己的富内容: ```rust Marker::new() .text_color(cx.theme().primary) .icon( MarkerIcon::new() .child(Icon::new(IconName::CircleCheck)), ) .content(MarkerContent::new().text("在线")); Marker::new() .icon(MarkerIcon::new().child(Spinner::new().xsmall())) .content(MarkerContent::new().text("Alice 正在输入…")) ``` `MarkerIcon` 会保留紧凑的图标槽尺寸。图标和文字的间距、文字字号与行高跟随共享设计系统;自定义 child 不会被 Marker 改写其内部语义。 ## Loading 样式 通过 `.loading(true)` 启用 loading;loading 不会改变 Marker 的 variant 或普通布局。默认样式是 `Spinner`: ```rust Marker::new() .loading(true) .with_loading_style(MarkerLoadingStyle::Spinner) .content(MarkerContent::new().text("正在加载消息…")) ``` 没有显式 `MarkerIcon` 时,Spinner loading 会自动添加紧凑 Spinner;如果已经组合 `MarkerIcon`,显式图标优先: ```rust Marker::new() .loading(true) .with_loading_style(MarkerLoadingStyle::Spinner) .icon(MarkerIcon::new().child(Icon::new(IconName::LoaderCircle))) .content(MarkerContent::new().text("同步中…")) ``` 需要文字高光时使用 `Shimmer`: ```rust Marker::new() .loading(true) .with_loading_style(MarkerLoadingStyle::Shimmer) .content(MarkerContent::new().text("正在思考…")) ``` 只有通过 `MarkerContent::text(...)` 添加的文本会使用平滑移动的 shimmer。普通 child 仍然可用,但 loading 时会使用轻微透明度变化;图标和 Separator 装饰线保持静止。 ## 配置 Shimmer Marker 可以接受一个可复用的 `ShimmerStyle`。默认动画周期为两秒、spread 为 `0.3`、方向为从左到右并循环播放: ```rust use std::time::Duration; Marker::new() .loading(true) .with_loading_style(MarkerLoadingStyle::Shimmer) .with_shimmer_style( ShimmerStyle::new() .duration(Duration::from_secs(3)) .highlight_color(cx.theme().primary) .spread(0.45) .reverse(true) .once(true), ) .content(MarkerContent::new().text("正在处理…")) ``` 可以分别调整单项配置: ```rust // 更慢的循环 ShimmerStyle::new().duration(Duration::from_secs(4)); // 更窄的高光带;值会限制在 0.05..=1.0 ShimmerStyle::new().spread(0.15); // 使用当前主题中的语义色 ShimmerStyle::new().highlight_color(cx.theme().primary); // 从右向左移动,并只完成一次 ShimmerStyle::new().reverse(true).once(true) ``` `duration(Duration::ZERO)` 会被限制为至少一毫秒;`spread` 可传相对比例 `f32`(限制在 `0.05..=1.0`)或绝对宽度 `Pixels`,非有限值会保留当前值。显式 highlight color 适合产品有明确强调色的场景,默认值会根据文本色和当前主题计算。 ## 独立使用 ShimmerText 需要在 Marker 之外显示 thinking 或上传文案时,直接使用 `ShimmerText`: ```rust ShimmerText::new("正在上传 report.pdf…") .with_shimmer_style(ShimmerStyle::new().spread(0.4)) .text_sm() .text_color(cx.theme().muted_foreground) ``` 也可以使用 `duration(...)`、`highlight_color(...)`、`spread(...)`、`reverse(...)` 和 `once(...)` 的快捷 builder: ```rust ShimmerText::new("Generating…") .duration(std::time::Duration::from_secs(3)) .highlight_color(cx.theme().primary) .spread(0.35) .reverse(true) ``` `ShimmerText` 实现 `Styled`,会继承周围的字号、字体、文字颜色、换行和截断样式。完整参数与 reduced motion 行为见 [Shimmer] 文档。 ## 链接和按钮 Marker 可以包含交互 child,但交互应保持局部、明确: ```rust Marker::new() .with_variant(MarkerVariant::Border) .content( MarkerContent::new() .text("同步失败") .child( Button::new("retry-sync") .ghost() .small() .label("重试"), ), ) ``` URL 使用 `Link`,应用操作使用 `Button`。Marker 不会自动给任意 child 添加 loading、selected 或 focus 语义。 ## 分隔线样式 `separator_style(...)` 只作用于 Separator 两侧的内部装饰线;内容本身的颜色和布局通过 `Marker`、`MarkerContent` 的 `Styled` refinement 调整: ```rust Marker::new() .with_variant(MarkerVariant::Separator) .separator_style( StyleRefinement::default() .bg(cx.theme().muted_foreground.opacity(0.35)), ) .content( MarkerContent::new() .text_color(cx.theme().muted_foreground) .text("2026 年 8 月 25 日"), ) ``` 如果需要完全不同的分隔布局,可以使用 `h_flex()` 与 `Separator`;不要把内部装饰线当作应用状态的独立节点。 ## 自定义样式与主题 token `Marker`、`MarkerIcon` 和 `MarkerContent` 都实现 `Styled`。默认样式之后应用调用方 refinement,因此只覆盖必要部分: ```rust Marker::new() .px_3() .py_2() .rounded(cx.theme().radius_lg) .bg(cx.theme().muted) .text_color(cx.theme().foreground) .icon( MarkerIcon::new() .text_color(cx.theme().primary) .child(Icon::new(IconName::Star)), ) .content(MarkerContent::new().text("已置顶消息")) ``` 优先使用 `cx.theme()` 的语义颜色与主题圆角。Marker 的外层布局、图标 slot、内容 slot 和 Separator 装饰线各自可以定制;这些 refinement 不会改变 loading 状态的业务所有权。 ## 可访问性与 reduced motion - 状态、进度、日期和未读边界必须有可读文本;颜色和装饰线只能提供辅助层次。 - Marker 默认是纯展示元素。表示流式或加载进度的行可以设置 `.id(...)` 加 `.role(Role::Status)`,让辅助技术播报其更新;role 依赖 id 提供的稳定标识。 - 只有图标的交互 child 使用 `Button` 并提供可见的 `.label(...)` 或其他可读名称;tooltip 只作为补充提示。 - Separator 线是装饰性的,语义应来自 `MarkerContent` 中的文字。 - `MarkerContent::text(...)` 的 Shimmer 在系统启用 reduced motion 时会显示静态文字,不请求动画帧。 - Marker 的普通 child 可能是自定义动画;应用应在 reduced motion 下提供清晰的静态状态。 - 不要让整个 Marker 成为 hover 才能发现的唯一操作入口;将操作放入明确可聚焦的 Button 或 Link。 ## 何时不需要 Marker - 只有数量、圆点或短状态时使用 `Badge`。 - 独立的标签状态使用 `Tag`。 - 只有一条带文字的分隔线时使用 `Separator::horizontal().label(...)`。 - 应用专属的 icon + text 行没有共享 Marker 语义时,使用 `h_flex()`。 - 需要头像、发送者、消息内容和 footer 时,使用 `Message`。 当这些内容需要统一的会话行表面,或要在 plain、separator、border 之间切换时,再使用 Marker。 ## API 参考 ### `Marker` | 方法 | 说明 | | --- | --- | | `new()` | 创建默认的 plain marker。 | | `with_variant(MarkerVariant)` | 设置 `Plain`、`Separator` 或 `Border`。 | | `alignment(MarkerAlignment)` | 设置 child 在行内的位置:起始侧、居中或结束侧。不设置时 `Separator` 居中、其余起始侧。 | | `loading(bool)` | 开启或关闭 loading。默认关闭。 | | `with_loading_style(MarkerLoadingStyle)` | 选择 `Spinner` 或 `Shimmer`。 | | `with_shimmer_style(ShimmerStyle)` | 配置文字 shimmer。 | | `separator_style(StyleRefinement)` | 调整 Separator 的内部装饰线。 | | `id(ElementId)` | 设置稳定标识,让 marker 进入无障碍树。 | | `role(Role)` | 设置辅助技术播报的 role;流式更新用 `Role::Status`,需要配合 `id(...)`,默认纯展示。 | | `icon(MarkerIcon)` | 添加配置过的图标 slot。 | | `content(MarkerContent)` | 添加配置过的内容 slot。 | | `child(element)` | 添加任意 child。 | | `Styled` | 调整 marker 的布局、颜色、间距和 surface。 | ### `MarkerIcon` / `MarkerContent` | 类型 | 方法 | 说明 | | --- | --- | --- | | `MarkerIcon` | `new()` / `child(element)` | 创建紧凑图标 slot。 | | `MarkerContent` | `new()` / `text(text)` | 创建内容 slot,`text` 允许 Shimmer 直接替换文字渲染。 | | 两者 | `Styled` | 调整各自的尺寸、颜色、文字和布局。 | ### 类型链接 - [Marker] - [MarkerVariant] - [MarkerAlignment] - [MarkerLoadingStyle] - [MarkerIcon] - [MarkerContent] - [ShimmerStyle] - [ShimmerText] [Marker]: https://docs.rs/gpui-component/latest/gpui_component/marker/struct.Marker.html [MarkerVariant]: https://docs.rs/gpui-component/latest/gpui_component/marker/enum.MarkerVariant.html [MarkerAlignment]: https://docs.rs/gpui-component/latest/gpui_component/marker/enum.MarkerAlignment.html [MarkerLoadingStyle]: https://docs.rs/gpui-component/latest/gpui_component/marker/enum.MarkerLoadingStyle.html [MarkerIcon]: https://docs.rs/gpui-component/latest/gpui_component/marker/struct.MarkerIcon.html [MarkerContent]: https://docs.rs/gpui-component/latest/gpui_component/marker/struct.MarkerContent.html [ShimmerStyle]: https://docs.rs/gpui-component/latest/gpui_component/shimmer/struct.ShimmerStyle.html [ShimmerText]: https://docs.rs/gpui-component/latest/gpui_component/shimmer/struct.ShimmerText.html --- # Toggle Source: /versions/v0.7.1/zh-CN/component/toggle 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/toggle 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Toggle 是一种按钮式的二元切换组件,用于表示选中 / 未选中、开启 / 关闭等状态。与传统 Switch 不同,Toggle 更像可按下或弹起的按钮,适合工具栏、筛选器和多选项场景。 ## 导入 ```rust use gpui_kit::component::button::{Toggle, ToggleGroup}; ``` ## 用法 ### 基础 Toggle ```rust Toggle::new("toggle1") .label("Toggle me") .checked(false) .on_click(|checked, _, _| { println!("Toggle is now: {}", checked); }) ``` `on_click` 回调接收的是切换后的新状态。 ### 图标 Toggle ```rust use gpui_kit::component::IconName; Toggle::new("toggle2") .icon(IconName::Eye) .checked(true) .on_click(|checked, _, _| { println!("Visibility: {}", if *checked { "shown" } else { "hidden" }); }) ``` ### 受控 Toggle ```rust struct MyView { is_active: bool, } impl Render for MyView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { Toggle::new("active") .label("Active") .checked(self.is_active) .on_click(cx.listener(|view, checked, _, cx| { view.is_active = *checked; cx.notify(); })) } } ``` ### 样式变体 ```rust Toggle::new("ghost-toggle") .ghost() .label("Ghost") Toggle::new("outline-toggle") .outline() .label("Outline") ``` ### 不同尺寸 ```rust Toggle::new("xs-toggle") .icon(IconName::Star) .xsmall() Toggle::new("small-toggle") .label("Small") .small() Toggle::new("medium-toggle") .label("Medium") Toggle::new("large-toggle") .label("Large") .large() ``` ### 禁用状态 ```rust Toggle::new("disabled-toggle") .label("Disabled") .disabled(true) .checked(false) Toggle::new("disabled-checked-toggle") .label("Selected (Disabled)") .disabled(true) .checked(true) ``` ## Toggle 与 Switch 的区别 | 特性 | Toggle | Switch | | --- | --- | --- | | 外观 | 按钮式,可按下 / 弹起 | 传统滑块式开关 | | 场景 | 工具栏、筛选器、二元选择 | 设置项、偏好项、开关状态 | | 状态表达 | 背景和按压感变化 | 滑块位置变化 | | 分组能力 | 支持 `ToggleGroup` | 主要单独使用 | ## 与 ToggleGroup 配合使用 ### 基础分组 ```rust ToggleGroup::new("filter-group") .child(Toggle::new(0).icon(IconName::Bell)) .child(Toggle::new(1).icon(IconName::Bot)) .child(Toggle::new(2).icon(IconName::Inbox)) .child(Toggle::new(3).label("Other")) .on_click(|checkeds, _, _| { println!("Selected toggles: {:?}", checkeds); }) ``` ### 受控分组 ```rust struct FilterView { notifications: bool, bots: bool, inbox: bool, other: bool, } impl Render for FilterView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { ToggleGroup::new("filters") .child(Toggle::new(0).icon(IconName::Bell).checked(self.notifications)) .child(Toggle::new(1).icon(IconName::Bot).checked(self.bots)) .child(Toggle::new(2).icon(IconName::Inbox).checked(self.inbox)) .child(Toggle::new(3).label("Other").checked(self.other)) .on_click(cx.listener(|view, checkeds, _, cx| { view.notifications = checkeds[0]; view.bots = checkeds[1]; view.inbox = checkeds[2]; view.other = checkeds[3]; cx.notify(); })) } } ``` ### 分段样式 ToggleGroup 使用 `segmented()` 可以把一组 Toggle 渲染成贴合的分段控件。这个样式只影响外观, 交互语义仍然是当前的多选模型:`on_click` 会收到每一项最新状态组成的 `Vec`。 ```rust ToggleGroup::new("formatting") .segmented() .outline() .child(Toggle::new(0).label("Bold").checked(self.bold)) .child(Toggle::new(1).label("Italic").checked(self.italic)) .child(Toggle::new(2).label("Code").checked(self.code)) .on_click(cx.listener(|view, states, _, cx| { view.bold = states[0]; view.italic = states[1]; view.code = states[2]; cx.notify(); })) ``` 分段样式默认使用 `0px` 间距,相邻项会共享一个连续边框。需要保留间距时可以传入 非零 `gap`: ```rust use gpui_kit::px; ToggleGroup::new("quick-actions") .segmented() .outline() .gap(px(8.)) .small() .child(Toggle::new(0).label("Star")) .child(Toggle::new(1).label("Watch")) .child(Toggle::new(2).label("Pin")) ``` 如果业务需要互斥选择,请继续在视图状态中自行保证只有一项 `checked(true)`, 直到后续提供专门的单选 API。 ## 最佳实践 1. 需要按钮式反馈时优先使用 Toggle,而不是 Switch。 2. 一组相关选项应使用 `ToggleGroup` 统一管理。 3. 图标型 Toggle 最好补充 tooltip 或可访问标签。 4. Toggle 状态应与实际业务状态保持同步,避免视觉与数据不一致。 --- # Attachment Source: /versions/v0.7.1/zh-CN/component/attachment 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/attachment 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `Attachment` 用于在会话中展示文件或媒体条目。根组件负责状态、方向、尺寸和整体 surface,并自带移除与重试两个控件;`AttachmentMedia`、`AttachmentContent`、`AttachmentActions` 分别提供预览、元数据和操作入口。应用可以在这些 slot 中继续组合 `Button`、`Progress`、`Icon` 和其他 GPUI element。 ## 适用场景 - 文件上传队列、消息附件、图片预览和处理中的媒体。 - 需要让标题、描述和预览随着上传生命周期改变外观的场景。 - 需要在横向紧凑卡片与纵向预览卡片之间复用同一套语义结构的场景。 选择文件、打开预览、取消上传和重试等业务行为仍由应用通过子控件持有;`Attachment` 不保存文件模型或网络状态。 ## 导入 ```rust use gpui_kit::{Axis, ParentElement as _, Styled as _}; use gpui_kit::component::{ ActiveTheme as _, Icon, IconName, Size, Sizable as _, StyledExt as _, attachment::{ Attachment, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentGroup, AttachmentMedia, AttachmentStatus, AttachmentTitle, }, button::{Button, ButtonVariants as _}, progress::Progress, shimmer::ShimmerStyle, }; ``` ## 组合结构 ```text Attachment ├── AttachmentMedia # 图标、图片预览和 overlay ├── AttachmentContent # 标题、描述以及任意附加内容 │ ├── AttachmentTitle │ ├── AttachmentDescription │ └── Progress(可选) └── AttachmentActions # Button 或其他操作 ``` 具名方法保留状态继承和方向布局;`.child(...)` 仍可添加任意 element,但会擦除具体类型。 ## 基础文件附件 最小的文件附件可以只提供元数据: ```rust Attachment::new() .content( AttachmentContent::new() .title(AttachmentTitle::new("quarterly-report.pdf")) .description(AttachmentDescription::new("PDF · 2.4 MB")), ) ``` 常见的文件卡片同时提供类型图标和移除操作: ```rust Attachment::new() .media(AttachmentMedia::new().child(Icon::new(IconName::FileText))) .content( AttachmentContent::new() .title(AttachmentTitle::new("quarterly-report.pdf")) .description(AttachmentDescription::new("PDF · 2.4 MB")), ) .actions( AttachmentActions::new().child( Button::new("remove-report") .ghost() .xsmall() .icon(IconName::Close) .label("移除") .tooltip("移除附件"), ), ) ``` `Attachment::new()` 默认状态为 `Complete`、方向为 `Axis::Horizontal`、尺寸为 `Size::Medium`:一张 56px 高、带内容时 232px 宽的 chip,媒体框 38px,圆角取 `radius_tokens().lg`(`XSmall` 取 `md`),失败时描边为 destructive 实色。 ## 媒体和图片预览 将 `ImageSource` 传给 `.src(...)` 即可显示图片;媒体 slot 也可以继续保留图标或其他 child: ```rust Attachment::new() .media( AttachmentMedia::new() .src(preview_source) .child(Icon::new(IconName::Image)), ) .content( AttachmentContent::new() .title(AttachmentTitle::new("preview.png")) .description(AttachmentDescription::new("PNG · 1280 × 720")), ) ``` 有图片源时,图片使用 `ObjectFit::Cover` 填满媒体区域,圆角由图片自己承担(gpui 的裁剪是矩形的)。没有图片源时,媒体 slot 展示图标;`Uploading` / `Processing` 时图标位置换成主色 Spinner,`Failed` 时媒体区域使用 destructive 语义颜色并换成警示图标,回到 `Complete` 时再显示 child。 纵向且没有 content 的附件是「图片 tile」:一个媒体满铺到描边内侧的方块,媒体圆角比卡片小一个描边宽度,两者同心。 ### 预览上的 overlay `.overlay(...)` 会把内容居中覆盖在整个媒体区域上。overlay 不会随图片的 loading opacity 一起变暗,适合 Spinner、播放按钮或错误提示: ```rust Attachment::new() .status(AttachmentStatus::Uploading) .media( AttachmentMedia::new() .src(preview_source) .overlay(Progress::new("preview-progress").value(68.)), ) ``` 也可以将 overlay 与自定义 `div()`、`Button` 组合。图片本身不会被调淡:`Uploading` / `Processing` 时图片上压一层半透明暗幕加白色 Spinner,`Failed` 时暗幕更深、中间是重试控件(见[移除与重试控件](#移除与重试控件))或警示图标;overlay 画在暗幕之上。`Pending` 与 `Complete` 不加暗幕。 ## 上传生命周期 `AttachmentStatus` 只表达通用生命周期,具体的文件数据和请求状态由应用持有: | 状态 | 用途 | 默认视觉提示 | | --- | --- | --- | | `Pending` | 已选择,等待上传。 | 边框使用 dashed 样式。 | | `Uploading` | 正在传输文件。 | 标题显示 shimmer;媒体显示 Spinner,给了 `progress(...)` 则是进度环(图片上叠暗幕),chip 底边多一条进度条,描述自动带上百分比。 | | `Processing` | 上传完成,服务端正在处理。 | 标题显示 shimmer;媒体显示 Spinner(图片上叠暗幕)。 | | `Failed` | 上传或处理失败。 | 边框与描述使用 destructive 语义色;给了 `on_retry` 媒体显示重试控件 / 警示图标,没给则显示禁止图标(视为被拒绝)。 | | `Complete` | 已准备好供用户使用。 | 普通完成状态。 | 状态文字应写入描述,不能只依赖边框颜色: ```rust Attachment::new() .status(AttachmentStatus::Uploading) .content( AttachmentContent::new() .title(AttachmentTitle::new("design-assets.zip")) .description(AttachmentDescription::new("上传中 · 68%")) .child(Progress::new("attachment-progress").value(68.)), ) ``` `Progress::loading(true)` 可用于不确定进度;确定百分比时使用 `.value(...)`。Progress 是独立组件,因此应用可以按自己的请求模型更新数值或状态。 `AttachmentStatus` 还提供 `is_pending()`、`is_uploading()`、`is_processing()`、`is_failed()`、`is_complete()` 和 `is_in_progress()`,适合在应用状态层生成描述或决定操作按钮。 ## 尺寸与方向 Attachment 实现 `Sizable`,支持 `xsmall()`、`small()`、默认 medium、`large()`,也可以使用 `with_size(Size::...)`: ```rust Attachment::new() .xsmall() .content( AttachmentContent::new() .title(AttachmentTitle::new("compact.txt")) .description(AttachmentDescription::new("TXT · 4 KB")), ); Attachment::new() .large() .media(AttachmentMedia::new().src(preview_source)) .content( AttachmentContent::new() .title(AttachmentTitle::new("presentation.pptx")) .description(AttachmentDescription::new("演示文稿")), ) ``` 语义尺寸一次给定整套几何(以 rem 计,随根字号缩放): | 尺寸 | chip 高 | chip 宽 | 媒体 | 标题 | | --- | --- | --- | --- | --- | | `XSmall` | 40px | 176px | 28px | 11px | | `Small` | 48px | 200px | 32px | 12px | | `Medium` | 56px | 232px | 38px | 13px | | `Large` | 64px | 272px | 44px | 14px | 横向卡片只在带 content 时取固定宽度,一排 chip 才能对齐、长文件名截断而不是撑开卡片;图片 tile 是边长等于 chip 高的方块。`Size::Size(...)` 按自定义基值等比缩放 `Medium` 的几何。需要别的量度时用普通的 GPUI 宽度 refinement(`w_auto()`、`w_full()`、`w(...)`);优先用语义尺寸,避免在应用里为每个附件写独立高度。 `Axis::Horizontal` 适合消息中的紧凑附件,`Axis::Vertical` 适合图片预览: ```rust Attachment::new() .axis(Axis::Vertical) .media(AttachmentMedia::new().src(preview_source)) .content( AttachmentContent::new() .title(AttachmentTitle::new("preview.png")) .description(AttachmentDescription::new("PNG · 1280 × 720")), ) ``` 纵向模式下有 content 时根组件使用主题 scale 中的预览宽度,没有 content 时使用更紧凑的方形媒体尺寸;媒体区域保持 `aspect_ratio(1.)`。这些是可覆盖的默认布局,不应被当作组件外部的固定像素契约。 `AttachmentMedia` 也实现 `Sizable`,因此可以独立覆盖预览尺寸: ```rust Attachment::new() .media( AttachmentMedia::new() .with_size(Size::Large) .src(preview_source), ) ``` 显式 media 尺寸优先于根 Attachment 的尺寸;其他 `Styled` refinement 仍会在默认值之后应用。 ## 内容、长文件名与操作 `AttachmentTitle` 和 `AttachmentDescription` 默认单行并截断。长文件名应提供可见的描述或 Tooltip,避免把卡片横向撑开: ```rust Attachment::new() .content( AttachmentContent::new() .title( AttachmentTitle::new("2026-08-25-very-long-export-name.json"), ) .description(AttachmentDescription::new("JSON · 18.4 MB")), ) ``` 标题会自动单行截断。需要在 hover 或辅助说明中显示完整文件名时,应由应用在外层交互容器上组合 Tooltip,或把完整名称放入可见描述;只要内容需要自动继承状态,就应继续使用 `.title(...)` 和 `.description(...)`。 操作 slot 可以包含多个 Button: ```rust Attachment::new() .content( AttachmentContent::new() .title(AttachmentTitle::new("failed-upload.zip")) .description(AttachmentDescription::new("上传失败,请重试")), ) .actions( AttachmentActions::new() .child(Button::new("retry").small().label("重试")) .child( Button::new("remove") .ghost() .xsmall() .icon(IconName::Close) .label("移除") .tooltip("移除附件"), ), ) ``` `AttachmentActions` 不定义专用的 `AttachmentAction`,因此可以直接复用 Button 的 variant、尺寸、disabled、loading、tooltip 和事件 API。 ## 整卡点击 设置 `.id(...)` 和 `.on_click(...)` 可以让整张卡片响应点击(例如打开预览)。点击层绘制在 `AttachmentActions` 之下,操作按钮仍然独立可点: ```rust Attachment::new() .id("design-attachment") .on_click(|_, window, cx| { // 打开预览。 }) .content( AttachmentContent::new() .title(AttachmentTitle::new("design-mockups.png")) .description(AttachmentDescription::new("PNG · 1.8 MB")), ) .actions( AttachmentActions::new() .child(Button::new("remove").ghost().xsmall().icon(IconName::Close)), ) ``` 点击状态需要稳定标识,因此 handler 只在配合 `.id(...)` 时生效。可点击的卡片 hover 时会显示 muted 底色,让它读起来是可交互的。点击意味着什么——对话框、浏览器、文件预览还是选择——由应用决定。删除、重试等次要操作应留在 `AttachmentActions` 中,不要依赖整卡点击;同时把卡片的主操作以 `Button` 或 `Link` 的形式提供在键盘可达的位置——点击层只是指针便利,不参与焦点。 ## 移除与重试控件 composer 要能移除附件、重试失败的上传。这两个控件是内建的,各产品长得一样,也不用再在外面包一层: ```rust Attachment::new() .id(("attachment", item.id)) .status(item.status) .on_remove(cx.listener(move |this, _, _, cx| this.remove(item.id, cx))) .on_retry(cx.listener(move |this, _, _, cx| this.retry(item.id, cx))) .axis(Axis::Vertical) .media(AttachmentMedia::new().src(thumbnail)) ``` `on_remove` 是骑在卡片右上角外侧的小圆钮:底色用 surface 色、一圈细描边、前景色的 ✕,就是常见的卡片关闭钮;桌面端悬停才出现,触屏平台常显。卡片会为探出的部分预留空间,一排卡片仍然对齐。`on_retry` 只在 `Failed` 时生效:图片预览的暗幕里出现圆形重试钮,具名描述后面跟一个本地化的「重试」链接;`Failed` 而没有 `on_retry` 视为被拒绝,媒体显示禁止图标。这些控件都把元素状态挂在 `.id(...)` 上,所以只在配合 `id` 时生效。移除、重试具体做什么由应用决定。 还有两个 builder 把 composer 的场景补齐: ```rust Attachment::new() .id(("attachment", item.id)) .status(AttachmentStatus::Uploading) .progress(item.percent) // 0..=100 .content( AttachmentContent::new() .title(AttachmentTitle::new("Q3 statement.pdf")) .description(AttachmentDescription::new("上传中")), ); Attachment::new() .id(("attachment", item.id)) .status(AttachmentStatus::Failed) .tooltip("图片超过 20 MB · 移除后再发送") .on_remove(cx.listener(move |this, _, _, cx| this.remove(item.id, cx))) .axis(Axis::Vertical) .media(AttachmentMedia::new().src(thumbnail)) ``` `progress(percent)` 把上传中的 Spinner 换成确定进度的圆环,横向卡片底边画一条主色进度条,具名描述后面自动接上「· 62%」;其它状态下忽略。`tooltip(text)` 在悬停卡片时显示文字,失败或被拒绝的原因就放这里。 ## 状态继承与局部覆盖 通过具名 `.title(...)` 和 `.description(...)` 添加的 child 会继承父级状态: ```rust Attachment::new() .status(AttachmentStatus::Failed) .content( AttachmentContent::new() .title(AttachmentTitle::new("archive.zip")) .description(AttachmentDescription::new("上传失败")), ) ``` 如果某个 child 的状态与父级不同,可以显式覆盖: ```rust Attachment::new() .status(AttachmentStatus::Failed) .content( AttachmentContent::new() .title(AttachmentTitle::new("archive.zip")) .description( AttachmentDescription::new("文件已恢复") .status(AttachmentStatus::Complete), ), ) ``` 显式 child 状态优先于继承状态。普通 `.child(AttachmentTitle::new(...))` 会擦除类型,因此不会自动继承父级状态;需要状态感知表现时使用具名 builder。 标题在 `Uploading` 和 `Processing` 时使用 `ShimmerText`。可以复用 `ShimmerStyle` 调整动画: ```rust AttachmentTitle::new("design-assets.zip") .with_shimmer_style( ShimmerStyle::new() .duration(std::time::Duration::from_secs(3)) .spread(0.45) .reverse(true) .once(true), ) ``` ## 分组 `AttachmentGroup` 是可横向滚动的附件行,需要稳定的 element id 保存滚动状态: ```rust AttachmentGroup::new("message-attachments") .child(first_attachment) .child(second_attachment) .child(third_attachment) ``` 当附件数量可能超出消息宽度时使用该组件。选择、拖拽排序、snap 或自定义滚动按钮属于应用容器,不由 `AttachmentGroup` 保存。 超出宽度时有两个配套 builder: ```rust AttachmentGroup::new("composer-attachments") // 某一侧还有附件没滚出来时,把该侧边缘渐变到底下的表面色。 .with_edge_fade(cx.theme().background) // 自己驱动滚动(例如翻页按钮)时传入 handle。 .track_scroll(&self.attachments_scroll) .children(attachments) ``` `with_edge_fade(color)` 只在该侧仍有内容被遮住时画一段短渐变,放得下就不画;渐变盖在附件之上但不接收指针事件。`track_scroll(&handle)` 用调用方的 `ScrollHandle` 取代组内自持的滚动状态,应用因此可以移动这一行、读取它的偏移。 ## 自定义样式与主题 token 根组件和所有公开 slot 都实现 `Styled`,调用方 refinement 会在默认布局后应用: ```rust Attachment::new() .rounded(cx.theme().radius_lg) .bg(cx.theme().group_box) .border_color(cx.theme().border) .content( AttachmentContent::new() .gap_1() .title( AttachmentTitle::new("custom-theme.txt") .text_color(cx.theme().foreground), ) .description( AttachmentDescription::new("使用主题 token") .text_color(cx.theme().muted_foreground), ), ) ``` 优先使用 `cx.theme()` 的语义颜色、圆角和共享尺寸;不要在调用点写固定 hex 颜色或按单个组件复制 spacing scale。可分别调整: - `Attachment`:整体宽度、背景、边框、圆角、padding 和 gap。 - `AttachmentMedia`:预览尺寸、圆角、背景和 overlay。 - `AttachmentContent`:内容宽度、文字层级和元数据间距。 - `AttachmentTitle` / `AttachmentDescription`:截断、字体和语义颜色。 - `AttachmentActions`:操作间距、位置和按钮布局。 - `AttachmentGroup`:横向 gap、padding 和滚动容器样式。 ## 组件边界 此 API 有意将以下职责留给组合层: - 直接使用 `Button`,不增加 `AttachmentAction`,保留 Button 的完整 variant、尺寸、事件和可访问性选项。 - 整卡点击通过 `.id(...)` 加 `.on_click(...)` 提供;卡片只上报点击,打开对话框、浏览器、预览还是切换选择由应用决定。 - 直接使用 `Progress`,不增加附件专属进度包装。 - `AttachmentGroup` 只负责横向间距和 overflow,不保存选择、拖拽、snap 或业务数据。 这些边界让附件仍然保持可组合,应用可以根据产品行为选择普通 Button、Link、Popover 或自己的容器。 ## 可访问性 - 文件名和状态应以文本表达;失败状态不能只靠 destructive 边框或文字颜色。 - icon-only action 应提供可见的 `.label(...)` 或其他可读名称;tooltip 只作为“移除附件”“重试上传”等补充提示。 - `Progress` 的百分比和不确定状态应通过可读文本或其他状态说明补充,不能只展示一条进度条。 - 纵向图片预览上的 overlay Button 仍应可聚焦,不要让装饰层遮蔽操作目标。 - `AttachmentGroup` 的横向滚动应能够通过键盘和系统滚动输入访问;不要把唯一入口做成 hover 才出现的按钮。 - 上传和处理中的 shimmer 会遵循系统 reduced motion;应用自定义 overlay 动画时也应提供静态结果。 ## API 参考 ### `Attachment` | 方法 | 说明 | | --- | --- | | `new()` | 创建 `Complete`、横向、medium 尺寸的附件。 | | `id(ElementId)` | 内建控件(点击层、移除、重试)的稳定标识。 | | `on_click(handler)` | 整卡点击;需配合 `id(...)`,绘制在 actions 之下。 | | `on_remove(handler)` | 角上的移除控件;需配合 `id(...)`。 | | `on_retry(handler)` | `Failed` 时的重试控件;需配合 `id(...)`。 | | `progress(percent)` | `Uploading` 时的进度环、底部进度条与「· 62%」。 | | `tooltip(text)` | 悬停提示,例如失败原因;需配合 `id(...)`。 | | `status(AttachmentStatus)` | 设置根生命周期状态。 | | `axis(Axis)` | 设置 `Horizontal` 或 `Vertical` 布局。 | | `media(AttachmentMedia)` | 设置预览 slot。 | | `content(AttachmentContent)` | 设置元数据 slot。 | | `actions(AttachmentActions)` | 设置操作 slot。 | | `xsmall()` / `small()` / `large()` | 通过 `Sizable` 选择语义尺寸。 | | `Styled` | 调整根 surface 和布局。 | ### `AttachmentMedia` | 方法 | 说明 | | --- | --- | | `new()` | 创建空媒体 slot。 | | `src(ImageSource)` | 设置图片预览源。 | | `overlay(element)` | 在媒体区域上方居中添加 overlay,画在状态暗幕之上。 | | `with_size(Size)` | 覆盖从根组件继承的媒体尺寸。 | | `child(element)` | 添加图标或其他媒体 child。 | | `Styled` | 调整媒体背景、圆角和尺寸。 | ### `AttachmentContent` | 方法 | 说明 | | --- | --- | | `new()` | 创建空元数据 slot。 | | `title(AttachmentTitle)` | 添加会继承状态的标题。 | | `description(AttachmentDescription)` | 添加会继承状态的描述。 | | `child(element)` | 添加任意自定义内容,不参与状态继承。 | | `Styled` | 调整内容布局和文字 refinement。 | ### `AttachmentTitle` / `AttachmentDescription` | 方法 | 说明 | | --- | --- | | `new(text)` | 创建单行标题或描述。 | | `status(AttachmentStatus)` | 显式覆盖从父级继承的状态。 | | `with_shimmer_style(ShimmerStyle)` | 自定义标题 loading shimmer;仅 `AttachmentTitle` 提供。 | | `Styled` | 调整文字样式、颜色、截断等。 | ### `AttachmentActions` / `AttachmentGroup` | 类型 | 方法 | 说明 | | --- | --- | --- | | `AttachmentActions` | `new()` / `child(element)` | 创建操作 slot 并组合 Button 或其他控件。 | | `AttachmentGroup` | `new(id)` / `child(element)` | 创建带稳定 id 的横向滚动附件组。 | | `AttachmentGroup` | `track_scroll(&ScrollHandle)` / `with_edge_fade(color)` | 用调用方的 handle 驱动滚动;某侧遮住内容时把该侧边缘渐变到 `color`。 | | 两者 | `Styled` | 调整间距、位置和容器布局。 | ### 类型链接 - [Attachment] - [AttachmentStatus] - [AttachmentMedia] - [AttachmentContent] - [AttachmentTitle] - [AttachmentDescription] - [AttachmentActions] - [AttachmentGroup] [Attachment]: https://docs.rs/gpui-component/latest/gpui_component/attachment/struct.Attachment.html [AttachmentStatus]: https://docs.rs/gpui-component/latest/gpui_component/attachment/enum.AttachmentStatus.html [AttachmentMedia]: https://docs.rs/gpui-component/latest/gpui_component/attachment/struct.AttachmentMedia.html [AttachmentContent]: https://docs.rs/gpui-component/latest/gpui_component/attachment/struct.AttachmentContent.html [AttachmentTitle]: https://docs.rs/gpui-component/latest/gpui_component/attachment/struct.AttachmentTitle.html [AttachmentDescription]: https://docs.rs/gpui-component/latest/gpui_component/attachment/struct.AttachmentDescription.html [AttachmentActions]: https://docs.rs/gpui-component/latest/gpui_component/attachment/struct.AttachmentActions.html [AttachmentGroup]: https://docs.rs/gpui-component/latest/gpui_component/attachment/struct.AttachmentGroup.html --- # Tree Source: /versions/v0.7.1/zh-CN/component/tree 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/tree 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Tree 是一个用于展示层级数据的通用组件,支持展开/折叠、键盘导航、自定义项渲染以及编程式选中控制。它非常适合文件浏览器、菜单树和嵌套数据结构。 ## 导入 ```rust use gpui_kit::component::tree::{tree, TreeState, TreeItem, TreeEntry}; ``` ## 用法 ### 基础树 ```rust let tree_state = cx.new(|cx| { TreeState::new(cx).items(vec![ TreeItem::new("src", "src") .expanded(true) .child(TreeItem::new("src/lib.rs", "lib.rs")) .child(TreeItem::new("src/main.rs", "main.rs")), TreeItem::new("Cargo.toml", "Cargo.toml"), TreeItem::new("README.md", "README.md"), ]) }); tree(&tree_state, |ix, entry, selected, window, cx| { ListItem::new(ix) .child( h_flex() .gap_2() .child(entry.item().label.clone()) ) }) ``` ### 文件树与图标 ```rust tree(&tree_state, |ix, entry, selected, window, cx| { let item = entry.item(); let icon = if !entry.is_folder() { IconName::File } else if entry.is_expanded() { IconName::FolderOpen } else { IconName::Folder }; ListItem::new(ix) .selected(selected) .pl(px(16.) * entry.depth() + px(12.)) .child( h_flex() .gap_2() .child(icon) .child(item.label.clone()) ) }) ``` ### 动态加载 ```rust impl MyView { fn load_files(&mut self, path: PathBuf, cx: &mut Context) { let tree_state = self.tree_state.clone(); cx.spawn(async move |cx| { let items = build_file_items(&path).await; tree_state.update(cx, |state, cx| { state.set_items(items, cx); }) }).detach(); } } ``` ### 选择处理 ```rust struct MyTreeView { tree_state: Entity, selected_item: Option, } impl MyTreeView { fn handle_selection(&mut self, item: TreeItem, cx: &mut Context) { self.selected_item = Some(item.clone()); println!("Selected: {} ({})", item.label, item.id); cx.notify(); } } ``` ### 禁用项 ```rust TreeItem::new("protected", "Protected Folder") .disabled(true) .child(TreeItem::new("secret.txt", "secret.txt")) ``` ### 编程式控制 ```rust if let Some(entry) = tree_state.read(cx).selected_entry() { println!("Current selection: {}", entry.item().label); } tree_state.update(cx, |state, cx| { state.set_selected_index(Some(2), cx); }); ``` ## API 参考 ### TreeState - `new(cx)` - `items(items)` - `set_items(items, cx)` - `selected_index()` - `set_selected_index(ix, cx)` - `set_selected_item(item, cx)` - `selected_entry()` - `scroll_to_item(ix, strategy)` ### TreeItem - `new(id, label)` - `child(item)` - `children(items)` - `expanded(bool)` - `disabled(bool)` ### TreeEntry - `item()` - `depth()` - `is_folder()` - `is_expanded()` - `is_disabled()` ## 键盘导航 | 按键 | 行为 | | --- | --- | | `↑` | 选中上一个节点 | | `↓` | 选中下一个节点 | | `←` | 折叠当前节点或移动到父级 | | `→` | 展开当前节点 | | `Enter` | 切换展开/折叠 | | `Space` | 自定义动作 | --- # Bubble Source: /versions/v0.7.1/zh-CN/component/bubble 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/bubble 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `Bubble` 是聊天内容的可组合表面。它负责对齐、内容宽度和 reaction 的定位;`BubbleContent` 负责背景、边框、圆角、padding 和文字样式。应用可以把 `Button`、`Link`、`Collapsible`、`Tooltip` 或 `Popover` 放在 bubble 内部,而不需要为每一种消息内容增加专用组件。 ## 适用场景 - 用于一条消息中的文本、代码、文件摘要或其他富内容。 - 用 `BubbleGroup` 堆叠同一发送者的连续消息。 - 用 `BubbleReactions` 把可聚焦的 `Button` 放在 bubble 的上方或下方。 - 已经有 `Message` 时,让 `Message` 负责发送者、header、footer 和整体对齐;Bubble 只负责消息表面。 如果内容只是普通的图标和文本行,或需要完整的消息生命周期状态,请直接组合 `h_flex()`、`Marker`、`Message` 等更合适的组件。 ## 导入 基础组合需要以下类型: ```rust use gpui_kit::{ParentElement as _, Styled as _}; use gpui_kit::component::{ bubble::{ Bubble, BubbleContent, BubbleGroup, BubbleReactionSide, BubbleReactions, BubbleVariant, }, button::{Button, ButtonVariants as _}, collapsible::Collapsible, h_flex, link::Link, message::MessageAlignment, popover::Popover, ActiveTheme as _, Colorize as _, IconName, Sizable as _, StyledExt as _, v_flex, }; ``` 这些 trait import 让文中的 `child(...)`、主题读取、语义尺寸和 Button variant builder 与实际 crate API 对齐。 ## 结构 ```text Bubble ├── BubbleContent # 可见的内容表面 └── BubbleReactions # 可选,附着在表面边缘 └── Button / 其他交互控件 ``` `Bubble` 的直接 `.child(...)` 会把 child 添加到 `BubbleContent`。需要精确控制 surface 样式、或需要区分多个富内容区域时,使用 `.content(BubbleContent::new()...)`。 ## 基础用法 最短的文本 bubble 可以直接添加 child: ```rust Bubble::new() .alignment(MessageAlignment::Start) .child("可以帮我检查一下吗?") ``` 显式创建 `BubbleContent` 适合需要调整 surface 的场景: ```rust Bubble::new() .alignment(MessageAlignment::Start) .content( BubbleContent::new() .child("这里可以继续添加任意 GPUI element。"), ) ``` `Bubble::new()` 默认使用 `BubbleVariant::Filled`。Bubble 默认不绑定对齐,适合由外层 `Message` 传播对齐;独立使用时应设置 `.alignment(...)`。 ## 对齐 `Bubble` 与 `Message` 共用 `MessageAlignment`: ```rust Bubble::new() .alignment(MessageAlignment::Start) .child("收到的消息"); Bubble::new() .alignment(MessageAlignment::End) .child("发出的消息") ``` | 值 | 含义 | | --- | --- | | `MessageAlignment::Start` | 放在消息行的起始侧。 | | `MessageAlignment::End` | 放在消息行的结束侧。 | | 未设置 | 保留给父级布局决定;放进 `MessageContent` 时通常使用这一方式。 | Bubble 的普通 variant 最大宽度为可用宽度的 80%,`Ghost` variant 会占满父级宽度。宽度仍可以在 `Bubble` 或 `BubbleContent` 上通过 `Styled` refinement 调整。 ## 样式变体 | Variant | 适合表达的语义 | 默认 surface | | --- | --- | --- | | `Filled` | 主要消息内容,默认值。 | `primary` 与 `primary_foreground`。 | | `Secondary` | 次级强调的消息。 | `muted` 底与 `secondary_foreground`(主题的 `secondary` 是按钮角色,比 shadcn 的会话 secondary 深一档)。 | | `Muted` | 低强调度的普通内容。 | `muted` 与普通前景色。 | | `Tinted` | 轻微使用 primary 色调的内容。 | 由主题背景与 primary 混合。 | | `Outline` | 需要清晰边界但不需要填充色的内容。 | 背景色与 `border`。 | | `Ghost` | 作为消息布局中的无表面富内容。 | 无 padding、边框和背景,也不裁切子元素(没有表面可裁,裁了只会切掉富内容的阴影与探出的控件)。 | | `Destructive` | 失败、拒绝或无效结果。 | 语义 destructive 色。 | ```rust for variant in [ BubbleVariant::Filled, BubbleVariant::Secondary, BubbleVariant::Muted, BubbleVariant::Tinted, BubbleVariant::Outline, BubbleVariant::Ghost, BubbleVariant::Destructive, ] { let bubble = Bubble::new() .alignment(MessageAlignment::Start) .with_variant(variant) .child("同一内容可以切换不同语义表面"); // 在应用的 view 中渲染 bubble。 } ``` 在实际界面中应根据内容语义选择 variant,不要仅为增加色彩而并列使用所有 variant。`Destructive` 也应配合文字说明,不能只依靠颜色表达失败。 ## 富内容 `BubbleContent` 接受任意 GPUI element,因此代码块、文件摘要和多段内容都可以由应用组合: ```rust use gpui_kit::{div, ParentElement as _, Styled as _}; use gpui_kit::component::{h_flex, v_flex, ActiveTheme as _, Sizable as _, StyledExt as _}; Bubble::new() .alignment(MessageAlignment::Start) .content( BubbleContent::new().child( v_flex() .gap_2() .child("请查看下面的文件:") .child( h_flex() .gap_2() .child("📄") .child("quarterly-report.pdf"), ) .child( div() .text_xs() .text_color(cx.theme().muted_foreground) .child("PDF · 2.4 MB"), ), ), ) ``` `TextView` 子元素会跟随样式变体的文字颜色,`Filled` 的 `primary` 底色上的链接、代码和表格也一样,所以 Markdown 回复不需要按变体单独设置样式。 较长文本仍由调用方决定换行、截断和最小宽度。可将 `min_w_0()`、`max_w_full()` 等 refinement 放在富内容的内部容器上,使内容不会把消息行撑开。 ### 链接和按钮 链接和应用命令应该保留自己的语义:URL 使用 `Link`,应用操作使用 `Button`。它们可以直接放进 `BubbleContent`: ```rust Bubble::new() .alignment(MessageAlignment::Start) .content( BubbleContent::new() .child("文档已更新:") .child( Link::new("release-notes") .href("https://example.com/release-notes") .child("查看 release notes"), ) .child( Button::new("retry") .ghost() .small() .label("重试"), ), ) ``` 不要把整个 bubble 变成一个不可区分的点击区域。每个操作都应有明确的焦点目标、标签和结果。 ### 折叠内容 Bubble 没有专用的 `ShowMore` API。需要折叠长文本时,直接组合 `Collapsible`,并由应用状态控制 `open`: ```rust Bubble::new() .alignment(MessageAlignment::Start) .content( BubbleContent::new().child( Collapsible::new() .open(show_details) .child( Button::new("toggle-details") .ghost() .small() .label(if show_details { "收起详情" } else { "显示详情" }), ) .content( div() .text_sm() .child("这里放置较长的诊断信息或工具输出。"), ), ), ) ``` `Collapsible::open(...)` 是静态配置;切换状态、键盘 action 和状态持有仍属于应用 view。 ### Tooltip 和 Popover Tooltip 应附着在具体的 icon button 或 link 上。Button 支持通过 `tooltip(...)` 添加提示: ```rust Bubble::new() .content( BubbleContent::new().child( Button::new("copy-message") .ghost() .icon(IconName::Copy) .label("复制") .tooltip("复制消息"), ), ) ``` 需要显示更多操作或上下文内容时,可以组合 `Popover`。Popover 的触发器与内容由应用提供: ```rust Popover::new("message-options") .trigger( Button::new("message-options-trigger") .ghost() .icon(IconName::Ellipsis) .label("更多操作"), ) .child(Button::new("copy").label("复制")) .child(Button::new("report").label("报告问题")) ``` Popover 需要在包含 `Root` 的窗口中使用,触发器应保持可聚焦;菜单项的权限、关闭后续行为和业务状态由应用负责。 ## 分组 `BubbleGroup` 只负责以统一间距堆叠连续 bubble,不保存发送者或时间信息: ```rust BubbleGroup::new() .child( Bubble::new() .alignment(MessageAlignment::Start) .with_variant(BubbleVariant::Muted) .child("第一条消息"), ) .child( Bubble::new() .alignment(MessageAlignment::Start) .with_variant(BubbleVariant::Muted) .child("同一发送者的第二条消息"), ) ``` 跨发送者、头像、header 和 footer 的组合应使用 `MessageGroup` 或应用自己的消息列表。 ## Reactions `BubbleReactions` 负责 reaction 区域的边缘定位与基础表面。对于需要和 reaction 表面连成一体的按钮,使用类型明确的 `action(Button)`: ```rust Bubble::new() .alignment(MessageAlignment::Start) .child("看起来没问题。") .reactions( BubbleReactions::new() .side(BubbleReactionSide::Bottom) .alignment(MessageAlignment::End) .action( Button::new("like") .ghost() .small() .label("👍 2"), ) .action( Button::new("reply") .ghost() .small() .label("回复"), ), ) ``` reaction 可以附着到上边缘: ```rust Bubble::new() .child("需要在上方显示状态的消息") .reactions( BubbleReactions::new() .side(BubbleReactionSide::Top) .alignment(MessageAlignment::Start) .action(Button::new("status").ghost().label("处理中")), ) ``` `action(Button)` 会让 `BubbleReactions` 识别出这是语义操作。当 reaction 区域 包含任意一个类型化操作时,组件会移除装饰性内边距,并将每个类型化 Button 的圆角设为当前主题的最大圆角(`radius_full()`),使按钮和外层表面连成一个 整体。传入的 `Button` 仍可以自定义变体、尺寸、图标、`.on_click(...)` 点击回调和 `.tooltip(...)`;类型化操作会保留这个胶囊圆角以维持整体外观,需要不同圆角时 使用下面的通用路径。多个按钮可以重复调用 `.action(...)`。 需要放入 emoji、文本、自定义子元素或直接 `Button` 之外的包装组件时,继续使用 `.child(...)`。这个通用路径保持向后兼容,也不会自动启用操作按钮的紧凑样式。 即使同一区域混合了 `.child(...)`,只要存在一个 `.action(...)`,整个 reaction 区域 仍会采用紧凑表面布局;普通子元素继续按通用路径渲染: ```rust BubbleReactions::new() .child("👍 2") .action( Button::new("reply") .ghost() .small() .label("回复"), ) ``` 像 `Popover` 这样的嵌套交互包装组件也应继续使用 `.child(...)`,因为 `action(...)` 接收的是直接的 `Button`。如果包装组件的触发按钮需要和 reaction 表面共用几何样式,可以在 `BubbleReactions` 上显式使用 `p_0()`,并在触发按钮上 使用主题的最大圆角;需要给普通 Button 保留其他圆角或表面样式时也使用这个通用路径: ```rust BubbleReactions::new().p_0().child( gpui_kit::component::popover::Popover::new("bubble-more") .trigger( Button::new("bubble-more-trigger") .ghost() .small() .label("更多") .rounded(cx.theme().radius_full()), ) .child(Button::new("bubble-copy").label("复制")), ) ``` reaction 表面的默认样式仍由组件提供;调用方的 `Styled` 样式调整会在默认值之后 应用,因此可以继续调整 `BubbleReactions` 或 `Button` 的其他样式。`.action(...)` 会统一管理 Button 的胶囊圆角;需要恢复额外内边距或使用不同圆角时,使用 `.child(...)`,或在 reaction 区域上明确调用 `px_2()`、`p_0()` 等样式方法。 `BubbleReactions` 不额外提供 `BubbleAction` 或 reaction 数据模型;计数、选中状态、 提交动作和错误提示由 Button 外层的应用状态负责。 ## 自定义样式与主题 token 所有公开 part 都实现 `Styled`。默认样式先应用,调用方 refinement 后应用,因此可以只调整需要改变的部分: ```rust Bubble::new() .alignment(MessageAlignment::Start) .px_2() .content( BubbleContent::new() .rounded(cx.theme().radius_lg) .bg(cx.theme().muted) .text_color(cx.theme().foreground) .border_color(cx.theme().border) .child("遵循当前主题的自定义消息表面"), ) ``` 优先从 `cx.theme()` 读取语义颜色、圆角和间距。Bubble 的外层布局、可见 surface 和 reactions 各自有样式入口,应用可以在不复制组件内部布局的情况下调整背景、文字、边框、间距、最大宽度和 reaction 位置。 ## 可访问性 - Reaction 使用 `Button`;可见 `.label(...)` 是控件的可读名称,tooltip 只作为补充提示。`👍 2` 这类文本应同时说明它是“点赞”动作。 - 链接使用 `Link`,应用操作使用 `Button`,避免用普通 `div` 模拟控件。 - `Destructive`、`Tinted` 等颜色只提供视觉层次;失败、状态或结果必须在文本中说明。 - 折叠区域的触发器应是可聚焦的 `Button`,并在状态改变时更新“展开/收起”等可读标签。 - 需要键盘操作的 Popover 触发器和内容应遵循 `Root` 提供的焦点与 overlay 生命周期。 - 系统启用 reduced motion 时,Bubble 本身没有额外动画;应用为 child 增加动画时也应提供静态结果。 ## 何时不需要 Bubble - 只有状态文本和分隔线:使用 `Marker`。 - 需要头像、发送者、时间和送达状态:使用 `Message`,将 Bubble 放进 `MessageContent`。 - 只有一个独立操作:使用 `Button` 或 `Link`。 - 只需要一组普通横向内容:使用 `h_flex()` 或 `v_flex()`,避免为简单布局增加 Bubble 表面。 ## API 参考 ### `Bubble` | 方法 | 说明 | | --- | --- | | `new()` | 创建默认的 filled bubble;默认不设置对齐。 | | `alignment(MessageAlignment)` | 设置起始侧或结束侧对齐。 | | `with_variant(BubbleVariant)` | 设置 bubble surface variant。 | | `content(BubbleContent)` | 替换可见内容 surface;已添加的直接 children 会并入其中。 | | `reactions(BubbleReactions)` | 添加可选 reaction 区域。 | | `child(element)` | 通过 `ParentElement` 将 child 添加到 `BubbleContent`。 | | `Styled` | 调整外层布局、宽度、间距和其他 GPUI 样式。 | ### `BubbleContent` | 方法 | 说明 | | --- | --- | | `new()` | 创建空的内容 surface。 | | `child(element)` | 添加任意 GPUI element。 | | `Styled` | 调整 padding、背景、文字、边框和圆角。 | ### `BubbleGroup` | 方法 | 说明 | | --- | --- | | `new()` | 创建连续 bubble 的垂直 stack。 | | `child(element)` | 按顺序添加 bubble 或其他 element。 | | `Styled` | 调整 stack 间距、宽度和布局。 | ### `BubbleReactions` | 方法 | 说明 | | --- | --- | | `new()` | 创建默认在底部、结束侧对齐的 reaction 区域。 | | `side(BubbleReactionSide)` | 选择 `Top` 或 `Bottom`。 | | `alignment(MessageAlignment)` | 选择 reaction 区域的起始侧或结束侧对齐。 | | `action(Button)` | 添加类型化操作;与 reaction 表面共用主题最大圆角,并自动使用紧凑布局。 | | `child(element)` | 添加 emoji、文本或任意 GPUI 子元素;保留通用组合能力。 | | `Styled` | 调整 reaction 表面与定位 refinement。 | ### 类型链接 - [Bubble] - [BubbleContent] - [BubbleGroup] - [BubbleReactions] - [BubbleVariant] - [BubbleReactionSide] [Bubble]: https://docs.rs/gpui-component/latest/gpui_component/bubble/struct.Bubble.html [BubbleContent]: https://docs.rs/gpui-component/latest/gpui_component/bubble/struct.BubbleContent.html [BubbleGroup]: https://docs.rs/gpui-component/latest/gpui_component/bubble/struct.BubbleGroup.html [BubbleReactions]: https://docs.rs/gpui-component/latest/gpui_component/bubble/struct.BubbleReactions.html [BubbleVariant]: https://docs.rs/gpui-component/latest/gpui_component/bubble/enum.BubbleVariant.html [BubbleReactionSide]: https://docs.rs/gpui-component/latest/gpui_component/bubble/enum.BubbleReactionSide.html --- # Focus Trap Source: /versions/v0.7.1/zh-CN/component/focus-trap 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/focus-trap 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Focus Trap 是一个用于将键盘焦点限制在特定容器内的工具能力,可防止用户通过 Tab 键把焦点移出当前区域。它对对话框、侧边面板和自定义覆盖层的可访问性非常重要。 **注意:** [Dialog](/versions/v0.7.1/zh-CN/component/dialog) 和 [Sheet](/versions/v0.7.1/zh-CN/component/sheet) 已内置 focus trap。只有在构建自定义类模态组件时,才需要手动使用 `focus_trap()`。 ## 导入 ```rust use gpui_kit::component::FocusTrapElement; ``` ## 用法 ### 基础 Focus Trap ```rust let container_handle = cx.focus_handle(); v_flex() .child(Button::new("btn1").label("Button 1")) .child(Button::new("btn2").label("Button 2")) .child(Button::new("btn3").label("Button 3")) .focus_trap("trap1", &container_handle) // Pressing Tab will cycle: btn1 -> btn2 -> btn3 -> btn1 // Focus will not escape to elements outside this container ``` ### 多个 Focus Trap 你可以在同一个应用中放置多个彼此独立的 focus trap 区域: ```rust let trap1_handle = cx.focus_handle(); let trap2_handle = cx.focus_handle(); v_flex() .gap_4() .child( h_flex() .gap_2() .child(Button::new("trap1-1").label("Area 1 - Button 1")) .child(Button::new("trap1-2").label("Area 1 - Button 2")) .child(Button::new("trap1-3").label("Area 1 - Button 3")) .focus_trap("trap1", &trap1_handle) ) .child( h_flex() .gap_2() .child(Button::new("trap2-1").label("Area 2 - Button 1")) .child(Button::new("trap2-2").label("Area 2 - Button 2")) .focus_trap("trap2", &trap2_handle) ) ``` ### 与 Dialog 配合 [Dialog] 已自动内置 focus trap,无需手动添加: ```rust window.open_dialog(cx, |dialog, _, _| { dialog .title("Settings") .child( v_flex() .gap_3() .child(Button::new("save").label("Save")) .child(Button::new("cancel").label("Cancel")) .child(Button::new("reset").label("Reset")) ) }) ``` ### 与 Sheet 配合 [Sheet] 也已自动内置 focus trap: ```rust window.open_sheet(cx, |sheet, _, _| { sheet .title("Filter Options") .child( v_flex() .gap_2() .child(Checkbox::new("option1").label("Option 1")) .child(Checkbox::new("option2").label("Option 2")) .child(Button::new("apply").label("Apply Filters")) ) }) ``` ## 工作原理 Focus trap 系统主要由三部分组成: 1. **FocusTrapContainer**:包装容器并将其注册为焦点陷阱区域。 2. **FocusTrapManager**:全局状态管理器,用于跟踪当前所有活跃的 focus trap。 3. **Root Integration**:由 [Root] 视图拦截 Tab/Shift-Tab 事件,并执行焦点循环。 当用户按下 Tab 或 Shift-Tab 时: 1. [Root] 会判断当前焦点是否位于某个 focus trap 中。 2. 如果是,则只计算该 trap 内部的下一个可聚焦元素。 3. 当焦点即将离开 trap 时,会循环回到开头或末尾。 4. 这样就能阻止焦点逸出当前容器。 ### 已内置 Focus Trap 的组件 以下组件已经内置 focus trap,不需要手动调用: - **[Dialog]** - **[Sheet]** ## API 参考 - [FocusTrapElement](https://docs.rs/gpui-component/latest/gpui_component/trait.FocusTrapElement.html) - [FocusTrapContainer](https://docs.rs/gpui-component/latest/gpui_component/struct.FocusTrapContainer.html) ## 示例 ### 自定义模态框 ```rust struct CustomModal { container_handle: FocusHandle, } impl CustomModal { fn new(cx: &mut App) -> Self { Self { container_handle: cx.focus_handle(), } } } impl Render for CustomModal { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .absolute() .inset_0() .flex() .items_center() .justify_center() .child( v_flex() .gap_4() .p_6() .bg(cx.theme().background) .rounded(cx.theme().radius_lg) .shadow_lg() .border_1() .border_color(cx.theme().border) .child("This is a modal dialog") .child( h_flex() .gap_2() .child(Button::new("ok").primary().label("OK")) .child(Button::new("cancel").label("Cancel")) ) .focus_trap("modal", &self.container_handle) ) } } ``` ### 嵌套 Focus Trap 当多个 trap 嵌套时,最内层 trap 优先: ```rust let outer_handle = cx.focus_handle(); let inner_handle = cx.focus_handle(); div() .child( v_flex() .gap_4() .p_4() .border_1() .border_color(cx.theme().border) .child(Button::new("outer-1").label("Outer Button 1")) .child( h_flex() .gap_2() .p_4() .bg(cx.theme().accent.opacity(0.1)) .child(Button::new("inner-1").label("Inner Button 1")) .child(Button::new("inner-2").label("Inner Button 2")) .focus_trap("inner", &inner_handle) ) .child(Button::new("outer-2").label("Outer Button 2")) .focus_trap("outer", &outer_handle) ) ``` ### 条件启用 Focus Trap ```rust struct ModalView { is_modal: bool, container_handle: FocusHandle, } impl Render for ModalView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let content = v_flex() .gap_2() .child(Button::new("btn1").label("Button 1")) .child(Button::new("btn2").label("Button 2")) .child(Button::new("btn3").label("Button 3")); if self.is_modal { content.focus_trap("conditional", &self.container_handle) .into_any_element() } else { content.into_any_element() } } } ``` ## 可访问性说明 - Focus trap 对模态对话框和覆盖层满足 WCAG 要求非常关键。 - 始终要提供关闭方式,例如 ESC、关闭按钮或取消按钮。 - 激活 trap 后,应让首个可聚焦元素获得焦点。 - 不要滥用 focus trap,只在真正的模态交互中使用。 - 保证容器内部的键盘导航顺序合理。 ## 另请参阅 - [Root View System](/versions/v0.7.1/zh-CN/component/root) - [Dialog](/versions/v0.7.1/zh-CN/component/dialog) - [Sheet](/versions/v0.7.1/zh-CN/component/sheet) - [focus-trap-react](https://github.com/focus-trap/focus-trap-react) [Root]: https://docs.rs/gpui-component/latest/gpui_component/struct.Root.html [FocusTrapElement]: https://docs.rs/gpui-component/latest/gpui_component/trait.FocusTrapElement.html [Dialog]: ./dialog.md [Sheet]: ./sheet.md --- # Popover Source: /versions/v0.7.1/zh-CN/component/popover 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/popover 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Popover 用于在触发元素附近展示浮动内容。它支持多种定位方式、自定义内容、不同触发方式以及自动关闭行为,适合用来实现提示卡片、上下文菜单、小表单和局部操作面板。 ## 导入 ```rust use gpui_kit::component::popover::{Popover}; ``` ## 用法 ### 基础 Popover 任何实现了 [Selectable] 的元素都可以作为触发器,例如 [Button]。 任何实现了 [RenderOnce] 或 [Render] 的元素都可以作为 Popover 内容,可以直接通过 `.child(...)` 添加。 ```rust use gpui_kit::ParentElement as _; use gpui_kit::component::{button::Button, popover::Popover}; Popover::new("basic-popover") .trigger(Button::new("trigger").label("Click me").outline()) .child("Hello, this is a popover!") .child("It appears when you click the button.") ``` ### 自定义定位 `anchor` 命名的是 **Popover 自身的锚点**,不是触发器的角点。 `Top*` 表示弹层在触发器下方,`Bottom*` 表示在上方; `LeftCenter` 表示在右侧,`RightCenter` 表示在左侧。 弹层只限制在窗口内,不改变 anchor,也不自动翻转。 ```rust use gpui_kit::{Anchor, px}; use gpui_kit::component::popover::Popover; Popover::new("anchored") .anchor(Anchor::TopCenter) .offset(px(8.)) .arrow(true) .trigger(Button::new("details").label("详情")) .child("上下文详情") ``` | 选项 | 含义 | 默认值 | | --- | --- | --- | | `anchor(Anchor)` | 弹层锚点,支持 `TopCenter`、`BottomCenter` 等八种位置 | `TopLeft` | | `offset(Pixels)` | 触发器到弹层的间距;有箭头时为到箭头尖端的间距 | `0.25rem` | | `arrow(bool)` | 在 anchor 对应边显示箭头 | `false` | 箭头跟随 anchor 的起始、居中或末端对齐,并向内避开圆角。 箭头额外占用 `0.375rem`,使用弹层背景色(未设置时使用主题的 popover 色)。 `offset` 和 `arrow` 都不会切换定位策略。 例如 `Anchor::TopLeft` 会让 Popover 出现在触发器正下方,并与其左对齐: ```text [ Trigger ] ┌──────────────┐ │ Popover │ └──────────────┘ ``` ```rust use gpui_kit::component::Anchor; Popover::new("top-center") .anchor(Anchor::TopCenter) .trigger(Button::new("btn").label("Top Center").outline()) .child("在触发器下方居中") ``` ### 在 Popover 中渲染 View 你也可以把实现了 [Render] 的 `Entity` 作为 Popover 内容: ```rust let view = cx.new(|_| MyView::new()); Popover::new("form-popover") .anchor(Anchor::BottomLeft) .trigger(Button::new("show-form").label("Open Form").outline()) .child(view.clone()) ``` ### 使用 `content` 构造动态内容 如果你需要根据状态动态构造内容,或者希望在闭包中拿到 Popover 的上下文,可以使用 `content`: ```rust use gpui_kit::ParentElement as _; use gpui_kit::component::popover::Popover; Popover::new("complex-popover") .anchor(Anchor::BottomLeft) .trigger(Button::new("complex").label("Complex Content").outline()) .content(|_, _, _| { div() .child("This popover has complex content.") .child( Button::new("action-btn") .label("Perform Action") .outline() ) }) ``` `content` 回调会在每次渲染 Popover 时执行,因此不要在闭包里频繁创建重量级对象或进行高成本计算。 ### 右键触发 如果你想把 Popover 当作自定义上下文菜单来用,可以指定鼠标按键: ```rust use gpui_kit::MouseButton; Popover::new("context-menu") .anchor(Anchor::BottomRight) .mouse_button(MouseButton::Right) .trigger(Button::new("right-click").label("Right Click Me").outline()) .child("Context Menu") .child(Separator::horizontal()) .child("This is a custom context menu.") ``` ### 手动关闭 如果你希望在内容内部主动关闭 Popover,可以发出 `DismissEvent`: ```rust use gpui_kit::component::{DismissEvent, popover::Popover}; Popover::new("dismiss-popover") .trigger(Button::new("dismiss").label("Dismiss Popover").outline()) .content(|_, cx| { div() .child("Click the button below to dismiss this popover.") .child( Button::new("close-btn") .label("Close Popover") .on_click(cx.listener(|_, _, _, cx| { cx.emit(DismissEvent); })) ) }) ``` ### 自定义样式 Popover 同样支持 `appearance(false)` 来关闭默认样式,并通过 [Styled] trait 完整自定义外观: ```rust Popover::new("custom-popover") .appearance(false) .trigger(Button::new("custom").label("Custom Style")) .bg(cx.theme().accent) .text_color(cx.theme().accent_foreground) .p_6() .rounded_xl() .shadow_2xl() .child("Fully custom styled popover") ``` ### 受控打开状态 通过 `open` 和 `on_open_change`,你可以把 Popover 的开关状态交给外部状态管理: ```rust use gpui_kit::component::popover::Popover; struct MyView { popover_open: bool, } Popover::new("controlled-popover") .open(self.open) .on_open_change(cx.listener(|this, open: &bool, _, cx| { this.popover_open = *open; cx.notify(); })) .trigger(Button::new("control-btn").label("Control Popover").outline()) .child("This popover's open state is controlled programmatically.") ``` ### 默认打开 如果只想设置首次渲染时默认打开,可以使用 `default_open(true)`: ```rust use gpui_kit::component::popover::Popover; Popover::new("default-open-popover") .default_open(true) .trigger(Button::new("default-open-btn").label("Default Open").outline()) .child("This popover is open by default when first rendered.") ``` ### 自定义触发器 触发器是任意实现了 [Selectable] 的元素。Popover 打开期间会对触发器调用 `open(true)`,而不是 `selected(true)`,这样触发器可以区分「我的 Popover 正开着」 和「我是当前选中项」这两件事。 `open` 与 `is_open` 默认落到 `selected` 和 `is_selected`,因此只实现了选中态的 触发器行为不变,[Button] 触发器打开时的外观也与选中态一致。如果你的元素已经 用 `selected` 表达别的含义(例如侧栏行选中表示当前视图),就覆盖这两个方法: ```rust use gpui_kit::component::Selectable; struct SidebarRow { /// 这一行是当前视图。 selected: bool, /// 这一行的账户 Popover 正开着。 open: bool, } impl Selectable for SidebarRow { fn selected(mut self, selected: bool) -> Self { self.selected = selected; self } fn is_selected(&self) -> bool { self.selected } fn open(mut self, open: bool) -> Self { self.open = open; self } fn is_open(&self) -> bool { self.open } } ``` [Button]: https://docs.rs/gpui-component/latest/gpui_component/button/struct.Button.html [Selectable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Selectable.html [Render]: https://docs.rs/gpui/latest/gpui/trait.Render.html [RenderOnce]: https://docs.rs/gpui/latest/gpui/trait.RenderOnce.html [Styled]: https://docs.rs/gpui/latest/gpui/trait.Styled.html [`Anchor`]: https://docs.rs/gpui-component/latest/gpui_component/enum.Anchor.html --- # Toolbar Source: /versions/v0.7.1/zh-CN/component/toolbar 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/toolbar 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Toolbar 是一个透明的水平操作容器,用于在面板标题、标签栏和自定义命令表面中排列按钮、分隔线和简短标签。背景和边框由外层容器负责。 其设计参考了原生 UI 框架中的工具栏:macOS 的 `NSToolbar` 和 Windows 的 `ToolStrip`。 ## 引入 ```rust use gpui_kit::component::toolbar::Toolbar; ``` ## 组合 使用 `child` 添加实现了 `Sizable` 的控件。Toolbar 会在渲染时把最终尺寸应用到这些控件,因此 `.small()` 写在控件之前或之后都得到相同结果。字符串、分隔线、弹性占位和需要保留自身尺寸的自定义布局使用 `content`。所有项目按源码顺序渲染。 - **命令**:直接传入 `Button`;Toolbar 会应用统一尺寸,并强制使用安静的 `ghost + compact` 外观。按需链式调用 `label`、`icon`、`tooltip`、`on_click` 等。 - **仅图标的按钮**:务必加上 `tooltip`,它同时也是无障碍名称。 - **分隔线**:通过 `content` 传入 `Separator::vertical()`,并指定高度。 - **不可交互的标签**:通过 content 方法传入字符串。 ## 用法 ### 命令 ```rust Toolbar::new("toolbar") .child( Button::new("new") .icon(IconName::Plus) .label("New") .on_click(|_, window, cx| { /* ... */ }), ) .content(Separator::vertical().h_5()) .child( Button::new("undo") .icon(IconName::Undo2) .tooltip("Undo") .on_click(|_, window, cx| { /* ... */ }), ) .content(div().flex_1()) .child( Button::new("more") .icon(IconName::Ellipsis) .tooltip("More options") .on_click(|_, window, cx| { /* ... */ }), ) ``` ### 尺寸 通过 `Sizable` 一起改变工具栏高度、间距、文字和内部控件尺寸:`xsmall`(28px)、`small`(32px,默认)和 `medium`(48px)。调用顺序不影响尺寸传播。 ```rust Toolbar::new("toolbar") .child(Button::new("new").icon(IconName::Plus).label("New")) .child(Button::new("find").icon(IconName::Search).tooltip("Find")) .small() ``` ### 标签与自定义元素 ```rust Toolbar::new("toolbar") .content("Dashboard") .content(Separator::vertical().h_5()) .content( h_flex() .items_center() .gap_1() .child(Icon::new(IconName::CircleCheck).xsmall()) .child("Saved"), ) .content(div().flex_1()) .child(Button::new("settings").icon(IconName::Settings2).tooltip("Settings")) ``` ### 自定义样式 `Toolbar` 默认透明且没有边框。它实现了 `Styled`,独立命令表面可以按需添加外观。 ```rust Toolbar::new("toolbar") .bg(cx.theme().secondary) .border_color(cx.theme().border) .content("Ready") ``` ## 分组 用 `ToolbarGroup` 把相关控件包在一起并赋予可访问名称,辅助技术会把这一组控件读作一个整体。它实现了 `Sizable`,父 Toolbar 会把最终尺寸经由 group 传递给每个控件: ```rust use gpui_kit::component::toolbar::ToolbarGroup; Toolbar::new("document-toolbar") .child( ToolbarGroup::new("history-group") .label("History") .gap_2() // 与工具栏自身的项间距保持一致 .child(Button::new("undo").icon(IconName::Undo2).tooltip("Undo")) .child(Button::new("redo").icon(IconName::Redo2).tooltip("Redo")), ) ``` 与 Base UI 的 `Toolbar.Group` 不同,group 无法禁用其子控件:该 API 通过 React context 传播到 Base UI 自己的按钮 primitive,GPUI 组合模型对任意子控件没有等价机制。禁用内部控件是调用方的职责。 分隔线等非尺寸化元素使用 `content`;可调尺寸控件使用 `child`,由 Toolbar 统一传播尺寸。 ## 键盘 工具栏向辅助技术暴露 `Toolbar` 语义,并拥有漫游键盘焦点,符合 ARIA toolbar 模式,与 Base UI 的 `Toolbar` 一致: | 按键 | 行为 | | --- | --- | | `←` / `→` | 将焦点移动到上一个 / 下一个控件(水平工具栏) | | `↑` / `↓` | 将焦点移动到上一个 / 下一个控件(垂直工具栏) | | `Tab` | 进入或离开工具栏;工具栏本身不是 tab 停靠点 | 焦点在两端环绕。内部输入框保留自己的方向键光标行为;请把输入框放在工具栏的末尾。该行为来自无样式的 `gpui_base::Toolbar` primitive,因此在 base 层上构建自定义工具栏的应用也能获得同样的契约。 ## API 参考 ### Toolbar | 方法 | 说明 | | ---------------- | ------------------------------------------ | | `new()` | 创建一个空的工具栏(small 尺寸) | | `child(c)` / `children(cs)` | 按源码顺序添加可调尺寸控件 | | `content(c)` / `contents(cs)` | 按源码顺序添加非尺寸化内容 | | `with_size(size)` | 设置工具栏尺寸 —— `xsmall`、`small` 或 `medium` | | `disabled(value)` | 禁用方向键导航;宿主同时负责禁用内部控件 | 控件方法要求 `Sizable + IntoElement`,content 方法接受通用元素。`Toolbar` 同时实现了 `Styled` 和 `Sizable`。 ## 注意事项 - 需要把后续项目推到尾端时,插入 `content(div().flex_1())`。 - 保持主要命令始终可见;低频操作应放入下拉菜单或溢出菜单,不要藏在 hover 后面。 - Toolbar 默认没有背景和边框,由宿主表面提供。 --- # Radio Source: /versions/v0.7.1/zh-CN/component/radio 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/radio 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Radio 用于在一组选项中选择唯一结果。适合“多选一”的场景,例如设置项、问卷和支付方式选择等。 使用 `on_change` 接收请求的新值,由状态所有者保存并调用 `cx.notify()`。原有的 `on_click` 保留为兼容名称;两者设置的是同一个回调,最后一次设置生效。 ## 导入 ```rust use gpui_kit::component::radio::{Radio, RadioGroup}; ``` ## 用法 ### 基础单选按钮 ```rust Radio::new("radio-option-1") .label("Option 1") .checked(false) .on_change(|checked, _, _| { println!("Radio is now: {}", checked); }) ``` ### 受控单选按钮 ```rust struct MyView { radio_checked: bool, } impl Render for MyView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { Radio::new("radio") .label("Select this option") .checked(self.radio_checked) .on_change(cx.listener(|view, checked, _, cx| { view.radio_checked = *checked; cx.notify(); })) } } ``` ### RadioGroup(推荐) ```rust struct MyView { selected_option: Option, } impl Render for MyView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { RadioGroup::horizontal("options") .children(["Option 1", "Option 2", "Option 3"]) .selected_index(self.selected_option) .on_change(cx.listener(|view, selected_index: &usize, _, cx| { view.selected_option = Some(*selected_index); cx.notify(); })) } } ``` ### 不同尺寸 ```rust Radio::new("small").label("Small").xsmall() Radio::new("medium").label("Medium") Radio::new("large").label("Large").large() ``` ### 禁用状态 ```rust Radio::new("disabled") .label("Disabled option") .disabled(true) .checked(false) Radio::new("disabled-checked") .label("Disabled and checked") .checked(true) .disabled(true) ``` ### 多行标签与自定义内容 ```rust Radio::new("custom") .label("Primary option") .child( div() .text_color(cx.theme().muted_foreground) .child("This is additional descriptive text that provides more context.") ) .w(px(300.)) ``` ### 自定义 Tab 顺序 ```rust Radio::new("radio") .label("Custom tab order") .tab_index(2) .tab_stop(true) ``` ## Radio Group 用法 ### 横向布局 ```rust RadioGroup::horizontal("horizontal-group") .children(["First", "Second", "Third"]) .selected_index(Some(0)) .on_change(cx.listener(|view, index, _, cx| { println!("Selected index: {}", index); cx.notify(); })) ``` ### 纵向布局 ```rust RadioGroup::vertical("vertical-group") .child(Radio::new("option1").label("United States")) .child(Radio::new("option2").label("Canada")) .child(Radio::new("option3").label("Mexico")) .selected_index(Some(1)) .disabled(false) ``` ### 带样式的分组 ```rust RadioGroup::vertical("styled-group") .w(px(220.)) .p_2() .border_1() .border_color(cx.theme().border) .rounded(cx.theme().radius) .child(Radio::new("option1").label("Option 1")) .child(Radio::new("option2").label("Option 2")) .child(Radio::new("option3").label("Option 3")) .selected_index(Some(0)) ``` ### 禁用整个分组 ```rust RadioGroup::vertical("disabled-group") .children(["Option A", "Option B", "Option C"]) .selected_index(Some(1)) .disabled(true) ``` ## API 参考 ### Radio | 方法 | 说明 | | --- | --- | | `new(id)` | 使用给定 ID 创建单选按钮 | | `label(text)` | 设置标签文本 | | `checked(bool)` | 设置选中状态 | | `disabled(bool)` | 设置禁用状态 | | `on_change(fn)` | 点击回调,参数为新的 `&bool` 选中状态 | | `tab_stop(bool)` | 是否允许通过 Tab 聚焦,默认 `true` | | `tab_index(isize)` | 设置 Tab 顺序,默认 `0` | ### RadioGroup | 方法 | 说明 | | --- | --- | | `new(id)` | 创建未选中任何项的纵向分组 | | `horizontal(id)` | 创建横向分组 | | `vertical(id)` | 创建纵向分组 | | `layout(Axis)` | 设置布局方向 | | `child(Radio)` | 添加单个 Radio | | `children(items)` | 通过迭代器批量添加 Radio | | `selected_index(Option)` | 设置选中项索引 | | `disabled(bool)` | 禁用分组内所有 Radio | | `on_change(fn)` | 选择变化回调,参数为选中的 `&usize` 索引 | ### 样式 Radio 和 RadioGroup 都实现了 `Styled` trait。 Radio 还实现了 `Sizable` trait: - `xsmall()`:超小尺寸 - `small()`:小尺寸 - `medium()`:中尺寸,默认值 - `large()`:大尺寸 ## 最佳实践 1. 互斥选项优先使用 `RadioGroup`,不要手动管理一组独立的 `Radio`。 2. 标签要明确,用户应当一眼看懂每个选项的含义。 3. 对必填项可以提供合理的默认选中项。 4. 选项顺序应符合业务逻辑,例如频率、重要性或字母顺序。 5. 单选项数量应保持适中,通常建议 2 到 7 个。 6. 多组单选项应配合清晰标题和视觉分组。 7. 选项较少时可横向排列,较多时更适合纵向排列。 --- # Form Source: /versions/v0.7.1/zh-CN/component/form 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/form 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Form 负责字段和底部操作区的布局。应用负责字段值、校验、提交,以及根据可用宽度选择列数。 ## 导入 ```rust use gpui_kit::component::form::{field, v_form, h_form, Form, Field}; ``` ## 组合约定 `Form::new()` 默认使用单列,标签位于控件上方。`label_layout(Axis::Horizontal)` 将标签放到控件旁边;`columns(2)` 独立控制字段排列为两列。原有的 `horizontal()`、`vertical()`、`layout(Axis)`、`h_form()` 和 `v_form()` 继续可用。 ```rust Form::new() .label_layout(Axis::Horizontal) .columns(2) .child(Field::new().label("Name").child(Input::new(&name_input))) .child(Field::new().label("Email").child(Input::new(&email_input))) .footer(Button::new("save").label("Save")) ``` `child` 接收 Field。操作按钮放入 `footer`;底部操作区跨越所有列,内容靠末端对齐。应用为按钮绑定提交逻辑,Form 不会自动提交。完整的状态、回调和窗口初始化用法见[应用示例](https://github.com/longbridge/gpui-kit/tree/main/examples/ai_recipes)。 ## 用法 ### 基础表单 ```rust v_form() .child( field() .label("Name") .child(Input::new(&name_input)) ) .child( field() .label("Email") .child(Input::new(&email_input)) .required(true) ) ``` ### 横向布局 ```rust h_form() .label_width(px(120.)) .child( field() .label("First Name") .child(Input::new(&first_name)) ) .child( field() .label("Last Name") .child(Input::new(&last_name)) ) ``` ### 多列表单 ```rust v_form() .columns(2) .child( field() .label("First Name") .child(Input::new(&first_name)) ) .child( field() .label("Last Name") .child(Input::new(&last_name)) ) .child( field() .label("Bio") .col_span(2) .child(Input::new(&bio_input)) ) ``` ## 容器与布局 ### 纵向布局 ```rust v_form() .gap(px(12.)) .child(field().label("Name").child(input)) .child(field().label("Email").child(email_input)) ``` ### 横向布局 ```rust h_form() .label_width(px(100.)) .child(field().label("Name").child(input)) .child(field().label("Email").child(email_input)) ``` ### 自定义尺寸 ```rust v_form() .large() .label_text_size(rems(1.2)) .child(field().label("Title").child(input)) v_form() .small() .child(field().label("Code").child(input)) ``` ## 校验与说明 ### 必填字段 ```rust field() .label("Email") .required(true) .child(Input::new(&email_input)) ``` ### 字段描述 ```rust field() .label("Password") .description("Must be at least 8 characters long") .child(Input::new(&password_input)) ``` ### 动态描述 ```rust field() .label("Bio") .description_fn(|_, _| { div().child("Use at most 100 words to describe yourself.") }) .child(Input::new(&bio_input)) ``` ### 字段可见性 ```rust field() .label("Admin Settings") .visible(user.is_admin()) .child(Switch::new("admin-mode")) ``` ## 提交处理 ### 基础提交模式 ```rust struct FormView { name_input: Entity, email_input: Entity, } impl FormView { fn submit(&mut self, cx: &mut Context) { let name = self.name_input.read(cx).value(); let email = self.email_input.read(cx).value(); if name.is_empty() || email.is_empty() { return; } self.handle_submit(name, email, cx); } } v_form() .child(field().label("Name").child(Input::new(&self.name_input))) .child(field().label("Email").child(Input::new(&self.email_input))) .child( field() .label_indent(false) .child( Button::new("submit") .primary() .child("Submit") .on_click(cx.listener(|this, _, _, cx| this.submit(cx))) ) ) ``` ### 操作按钮组 ```rust v_form() .child(field().label("Title").child(Input::new(&title))) .child(field().label("Content").child(Input::new(&content))) .child( field() .label_indent(false) .child( h_flex() .gap_2() .child(Button::new("save").primary().child("Save")) .child(Button::new("cancel").child("Cancel")) .child(Button::new("preview").outline().child("Preview")) ) ) ``` ## 字段分组 ### 相关字段组合 ```rust v_form() .child( field() .label("Name") .child( h_flex() .gap_2() .child(div().flex_1().child(Input::new(&first_name))) .child(div().flex_1().child(Input::new(&last_name))) ) ) .child( field() .label("Address") .items_start() .child( v_flex() .gap_2() .child(Input::new(&street)) .child( h_flex() .gap_2() .child(div().flex_1().child(Input::new(&city))) .child(div().w(px(100.)).child(Input::new(&zip))) ) ) ) ``` ### 自定义字段组件 ```rust field() .label("Theme Color") .child(ColorPicker::new(&color_state).small()) field() .label("Birth Date") .description("We'll send you a birthday gift!") .child(DatePicker::new(&date_state)) ``` ### 条件字段 ```rust v_form() .child( field() .label("Account Type") .child(Select::new(&account_type)) ) .child( field() .label("Company Name") .visible(is_business_account) .child(Input::new(&company_name)) ) ``` ## 网格与定位 ### 列跨度 ```rust v_form() .columns(3) .child(field().label("First").child(input1)) .child(field().label("Second").child(input2)) .child(field().label("Third").child(input3)) .child( field() .label("Full Width") .col_span(3) .child(Input::new(&full_width)) ) ``` ### 响应式布局 ```rust v_form() .columns(if is_mobile { 1 } else { 2 }) .child(field().label("Name").child(name_input)) .child(field().label("Email").child(email_input)) .child( field() .label("Bio") .when(!is_mobile, |field| field.col_span(2)) .child(bio_input) ) ``` ## 示例 ### 注册表单 ```rust struct RegistrationForm { first_name: Entity, last_name: Entity, email: Entity, password: Entity, confirm_password: Entity, terms_accepted: bool, } ``` ### 设置表单 ```rust v_form() .column(2) .child( field() .label("Profile") .label_indent(false) .col_span(2) .child(Separator::horizontal()) ) .child( field() .label("Display Name") .child(Input::new(&display_name)) ) ``` --- # Message Source: /versions/v0.7.1/zh-CN/component/message 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/message 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `Message` 为聊天与会话界面提供消息行结构。它负责整体对齐、头像位置和具名 slot 的默认间距;应用负责消息数据、发送者、时间戳、送达状态和操作逻辑。 ## 适用场景 - 需要把头像、发送者、时间、bubble、附件和 footer 组合成一条消息。 - 需要同时支持接收消息和发送消息的起始侧/结束侧布局。 - 需要把多个连续消息堆叠为一个发送者分组。 - 需要以 Ghost Bubble 展示 Markdown、代码或没有卡片表面的富内容。 只有一条状态提示时使用 `Marker`;只有一个内容 surface 时使用 `Bubble`;不要为每种消息类型创建专用的 Message wrapper。 ## 导入 ```rust use gpui_kit::{ParentElement as _, StyleRefinement, Styled as _}; use gpui_kit::component::{ ActiveTheme as _, Colorize as _, Sizable as _, StyledExt as _, attachment::{Attachment, AttachmentContent, AttachmentTitle}, avatar::Avatar, bubble::{Bubble, BubbleVariant}, button::{Button, ButtonVariants as _}, message::{ Message, MessageAlignment, MessageAvatar, MessageContent, MessageFooter, MessageGroup, MessageHeader, }, }; ``` ## 结构 ```text Message ├── MessageAvatar # 可选,发送者身份 └── inner stack ├── MessageHeader # 可选,发送者与时间 ├── MessageContent # 可选,Bubble、附件、Markdown 等 └── MessageFooter # 可选,送达状态、reaction、操作 ``` 所有具名 slot 都可以继续添加任意 GPUI element,并分别实现 `Styled`。`Message` 不持有消息模型,也不会替应用决定 header 或 footer 的文本。 ## 基础用法 一条完整消息可以同时提供 avatar、header、content 和 footer: ```rust Message::new() .avatar_slot( MessageAvatar::new() .child(Avatar::new().name("Alice").size_8()), ) .header( MessageHeader::new() .child("Alice") .child("10:24"), ) .content( MessageContent::new().bubble( Bubble::new().child("可以帮我检查一下吗?"), ), ) .footer(MessageFooter::new().child("已读")) ``` 只需要头像和内容时,可以使用便利的 `.avatar(...)`: ```rust Message::new() .avatar(Avatar::new().name("Alice").size_8()) .content(MessageContent::new().bubble( Bubble::new().child("收到的消息"), )) ``` ## 对齐 `MessageAlignment` 会作用于消息行和 MessageContent 内的 Bubble: ```rust Message::new() .alignment(MessageAlignment::Start) .avatar(Avatar::new().name("Alice")) .content(MessageContent::new().bubble( Bubble::new().child("对方的消息"), )); Message::new() .alignment(MessageAlignment::End) .avatar(Avatar::new().name("我")) .content(MessageContent::new().bubble( Bubble::new().with_variant(BubbleVariant::Secondary).child("我发送的消息"), )) ``` | 值 | 用途 | | --- | --- | | `Start` | 接收消息或起始侧消息。 | | `End` | 发送消息或结束侧消息。组件会反转头像与内容的行方向。 | `MessageContent` 内的 Bubble 可以不设置自己的 alignment,让 Message 统一传播布局。独立使用 Bubble 时再显式设置 alignment。 ## Avatar、Header、Content 与 Footer ### Avatar `.avatar(...)` 会把任意 element 包装进 `MessageAvatar`;需要调整 slot 自身时使用 `.avatar_slot(...)`: ```rust Message::new() .avatar_slot( MessageAvatar::new() .p_0() .child(Avatar::new().name("Support").size_8()), ) .content(MessageContent::new().bubble( Bubble::new().child("我们已经处理了你的请求。"), )) ``` `MessageAvatar` 保留共享的 avatar 尺寸基线,并始终与消息内容的底边对齐;footer 渲染在头像行之下、按内容列缩进。身份 fallback、头像图片和名称文字由 `Avatar` 负责。 ### Header Header 适合放发送者、时间和其他低强调元信息: ```rust Message::new() .header( MessageHeader::new() .child("Alice") .child("·") .child("10:24"), ) .content(MessageContent::new().bubble( Bubble::new().child("消息内容"), )) ``` Header 默认有水平内容 inset。需要和 Ghost Bubble 对齐时,Message 会根据 content 自动处理;也可以显式调用 `.content_inset(false)` 或 `.content_inset(true)`。 ### Content Content 是消息主体,可以包含多个 Bubble、附件、图片、代码块或应用自己的富文本 renderer: ```rust Message::new() .content( MessageContent::new() .bubble(Bubble::new().child("先看结论:")) .bubble( Bubble::new() .with_variant(BubbleVariant::Ghost) .child("这是第二段无表面富内容。"), ), ) ``` `MessageContent::bubble(...)` 会保留 Bubble 的 Ghost 元信息,用于协调 Header 和 Footer 的 inset;用普通 `.child(...)` 添加的 Bubble 不参与这个类型级传播。 ### Footer Footer 可放送达状态、reaction、Button 或其他次级信息: ```rust Message::new() .content(MessageContent::new().bubble( Bubble::new().child("需要回复的内容"), )) .footer( MessageFooter::new() .child("未读") .child(Button::new("reply").ghost().small().label("回复")), ) ``` Footer 内的 Button、Link 和其他控件由应用提供自己的事件、disabled、loading 和标签状态。 ## 富内容、附件与操作 Message 通过组合现有组件表达不同内容,不增加重复的消息专用控件: ```rust Message::new() .avatar(Avatar::new().name("Alice")) .header(MessageHeader::new().child("Alice").child("刚刚")) .content( MessageContent::new() .bubble(Bubble::new().child("请查看这个文件。")) .child( Attachment::new().content( AttachmentContent::new() .title(AttachmentTitle::new("quarterly-report.pdf")), ), ), ) .footer( MessageFooter::new() .child(Button::new("download").outline().small().label("下载")), ) ``` 需要 Markdown、代码或 HTML 时,在 `MessageContent` 中放入应用选择的 text renderer;Message 只提供布局和 alignment,不改变富文本的选择、复制和交互行为。 ## 消息分组 `MessageGroup` 用于堆叠同一发送者的连续消息: ```rust MessageGroup::new() .child( Message::new() .avatar(Avatar::new().name("Alice")) .content(MessageContent::new().bubble( Bubble::new().child("第一条消息"), )), ) .child( Message::new() .content(MessageContent::new().bubble( Bubble::new().child("同一发送者的第二条消息"), )), ) ``` 分组只负责垂直 stack 和共享间距;发送者变化、分组边界、时间戳和 avatar 是否重复显示由应用决定。需要不同发送者之间的间距时,在外层列表或自定义 group style 中表达。 ## Ghost surface 与内容 inset Ghost Bubble 没有背景、边框、padding,可用于 Markdown、代码或需要与消息行直接对齐的富内容: ```rust Message::new() .header( MessageHeader::new() .child("系统") .child("刚刚"), ) .content( MessageContent::new().bubble( Bubble::new() .with_variant(BubbleVariant::Ghost) .child("已完成索引更新。"), ), ) .footer(MessageFooter::new().child("无需进一步操作")) ``` 当 `MessageContent::bubble(...)` 中包含 Ghost Bubble 时,Header 和 Footer 默认移除水平 inset;这样元信息与无表面内容左边缘对齐。调用方可以覆盖该行为: ```rust Message::new() .header(MessageHeader::new().content_inset(true).child("保留 inset")) .content(MessageContent::new().bubble( Bubble::new().with_variant(BubbleVariant::Ghost).child("内容"), )) .footer(MessageFooter::new().content_inset(false).child("移除 inset")) ``` `.content_inset(...)` 是 slot 的显式设置,优先于 Message 根据 Ghost Bubble 推导的默认值。`.px_0()` 等普通 `Styled` refinement 仍可用于更细的布局调整。 ## 自定义样式与主题 token `Message`、`MessageGroup`、`MessageAvatar`、`MessageHeader`、`MessageContent` 与 `MessageFooter` 都实现 `Styled`。具名 slot 之间的 stack 使用 `with_stack_style(...)`: ```rust Message::new() .with_stack_style(StyleRefinement::default().gap_3()) .p_3() .rounded(cx.theme().radius_lg) .bg(cx.theme().muted.opacity(0.35)) .avatar_slot( MessageAvatar::new() .bg(cx.theme().secondary) .child(Avatar::new().name("A")), ) .header(MessageHeader::new().px_0().child("Alice · 10:24")) .content(MessageContent::new().bubble( Bubble::new().child("遵循当前主题的消息 surface"), )) ``` 推荐使用 `cx.theme()` 的语义颜色、圆角和共享 design scale。Message 的外层、inner stack、avatar、header、content 和 footer 都有独立的样式入口,调用方可以调整表面、间距、文字层级和对齐,而不需要复制 Message 的布局实现。`Message` 自身不设字号与行高:Header、Footer 各有自己的排版,内容的排版归 Bubble 或调用方,放进 Ghost Bubble 的 Markdown 等富内容保持其渲染器的排版。 ## 组件边界 - `Message` 不持有发送者、时间戳、送达状态、reaction 或操作状态;这些数据由应用生成对应的 child。 - `MessageContent::bubble(...)` 是专门用于 Bubble 的类型化便利入口,用于传播 Ghost surface 元信息;其他 element 使用普通 `.child(...)`。 - 应用操作使用 `Button`,URL 使用 `Link`,附件使用 `Attachment`;不创建消息专用的 Action、Link 或 Attachment wrapper。 - 需要消息列表、尾部跟随、未读定位或历史加载时,使用 `MessageScroller` 管理虚拟列表;Message 只负责单行布局。 ## 可访问性 - Avatar 是身份辅助信息,不应是唯一的发送者标识;Header 应提供可读发送者或系统来源。 - Message 默认是纯展示元素。会话里的每条消息可设置 `.id(...)` 加 `.role(Role::ListItem)`,让辅助技术能在消息之间移动;role 依赖 id 提供的稳定标识。 - 时间、送达状态、失败状态和未读信息应作为可读文本提供,不能只用颜色、位置或 icon 表达。 - Footer 中的 icon-only Button 应提供可见的 `.label(...)` 或其他可读名称,tooltip 只作为补充提示;发送者操作应使用明确的 Button/Link 语义。 - Bubble、Attachment 和富文本 child 的键盘行为由各自组件负责;Message 不会自动为普通 `div` 增加焦点。 - 应用自定义消息动画时,应在 reduced motion 下保持静态结果;Message 自身没有额外动画。 - 长消息和代码内容应保持可读的换行、选择和滚动策略,不要依赖 hover 才能访问完整内容。 ## API 参考 ### `Message` | 方法 | 说明 | | --- | --- | | `new()` | 创建默认起始侧对齐的消息。 | | `alignment(MessageAlignment)` | 设置起始侧或结束侧对齐。 | | `id(ElementId)` | 设置稳定标识,供元素状态与无障碍树使用。 | | `role(Role)` | 设置辅助技术播报的 role,如 `Role::ListItem`;需要配合 `id(...)`,默认纯展示。 | | `with_stack_style(StyleRefinement)` | 调整 Header、Content、Footer 内部 stack。 | | `avatar(element)` | 将任意 element 包装进 `MessageAvatar`。 | | `avatar_slot(MessageAvatar)` | 设置完整的 avatar slot。 | | `header(MessageHeader)` | 设置 header slot。 | | `content(MessageContent)` | 设置 content slot。 | | `footer(MessageFooter)` | 设置 footer slot。 | | `Styled` | 调整消息行自身的布局与 surface。 | ### `MessageGroup` | 方法 | 说明 | | --- | --- | | `new()` | 创建连续消息的垂直 stack。 | | `child(element)` | 按顺序添加消息。 | | `Styled` | 调整分组间距、宽度和布局。 | ### `MessageAvatar` | 方法 | 说明 | | --- | --- | | `new()` | 创建身份 slot。 | | `child(element)` | 添加 Avatar 或其他身份内容。 | | `Styled` | 调整 slot 的尺寸、背景和位置。 | ### `MessageHeader` / `MessageFooter` | 方法 | 说明 | | --- | --- | | `new()` | 创建对应的元信息或次级内容 slot。 | | `content_inset(bool)` | 显式保留或移除默认水平 inset。 | | `child(element)` | 添加文本、状态或操作。 | | `Styled` | 调整文字、间距和布局。 | ### `MessageContent` | 方法 | 说明 | | --- | --- | | `new()` | 创建消息主体 slot。 | | `bubble(Bubble)` | 添加 Bubble,并参与 Ghost surface 的 inset 协调。 | | `child(element)` | 添加任意富内容,不参与 Bubble 类型元信息传播。 | | `Styled` | 调整主体的 stack、宽度和对齐。 | ### 类型链接 - [Message] - [MessageAlignment] - [MessageGroup] - [MessageAvatar] - [MessageHeader] - [MessageContent] - [MessageFooter] [Message]: https://docs.rs/gpui-component/latest/gpui_component/message/struct.Message.html [MessageAlignment]: https://docs.rs/gpui-component/latest/gpui_component/message/enum.MessageAlignment.html [MessageGroup]: https://docs.rs/gpui-component/latest/gpui_component/message/struct.MessageGroup.html [MessageAvatar]: https://docs.rs/gpui-component/latest/gpui_component/message/struct.MessageAvatar.html [MessageHeader]: https://docs.rs/gpui-component/latest/gpui_component/message/struct.MessageHeader.html [MessageContent]: https://docs.rs/gpui-component/latest/gpui_component/message/struct.MessageContent.html [MessageFooter]: https://docs.rs/gpui-component/latest/gpui_component/message/struct.MessageFooter.html --- # Menu Source: /versions/v0.7.1/zh-CN/component/menu 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/menu 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 # PopupMenu Menu 组件同时提供上下文菜单和弹出菜单,支持图标、键盘快捷键、子菜单、分隔线、勾选项以及自定义元素,并内置可访问性与键盘导航支持。 ## 导入 ```rust use gpui_kit::component::{ menu::{PopupMenu, PopupMenuItem, ContextMenuExt, DropdownMenu}, Button }; use gpui_kit::{actions, Action}; ``` ## 用法 ### ContextMenu 右键点击元素时显示上下文菜单: ```rust use gpui_kit::component::menu::ContextMenuExt; div() .id("my-element") .child("Right click me") .context_menu(|menu, window, cx| { menu.menu("Copy", Box::new(Copy)) .menu("Paste", Box::new(Paste)) .separator() .menu("Delete", Box::new(Delete)) }) ``` ### DropdownMenu 下拉菜单通常由按钮或其它可交互元素触发: ```rust use gpui_kit::component::popup_menu::{PopupMenuExt as _, PopupMenuItem}; let view = cx.entity(); Button::new("menu-btn") .label("Open Menu") .dropdown_menu(|menu, window, cx| { menu.menu("New File", Box::new(NewFile)) .menu("Open File", Box::new(OpenFile)) .link("Documentation", "https://gpui-kit.com/") .separator() .item(PopupMenuItem::new("Custom Action") .on_click(window.listener_for(&view, |this, _, window, cx| { // Custom action logic here this. }) ) .separator() .menu("Exit", Box::new(Exit)) }) ``` 每个菜单项都可以关联一个 [Action]。这种设计可以更好地接入 GPUI 的 action 与快捷键系统,在适用时自动显示对应快捷键。 因此,推荐优先使用 [Action] 定义菜单行为。 如果你不想使用 [Action],也可以通过 `item` 方法配合 [PopupMenuItem] 创建自定义菜单项,并使用 `on_click` 直接处理点击事件。 ### 锚点位置 控制下拉菜单相对触发器的显示位置: ```rust use gpui_kit::Anchor; Button::new("menu-btn") .label("Options") .dropdown_menu_with_anchor(Anchor::TopRight, |menu, window, cx| { menu.menu("Option 1", Box::new(Action1)) .menu("Option 2", Box::new(Action2)) }) ``` ### 图标 ```rust use gpui_kit::component::IconName; menu.menu_with_icon("Search", IconName::Search, Box::new(Search)) .menu_with_icon("Settings", IconName::Settings, Box::new(OpenSettings)) .separator() .menu_with_icon("Help", IconName::Help, Box::new(ShowHelp)) ``` ### 禁用状态 ```rust menu.menu("Available Action", Box::new(Action1)) .menu_with_disabled("Disabled Action", Box::new(Action2), true) .menu_with_icon_and_disabled( "Unavailable", IconName::Lock, Box::new(Action3), true ) ``` ### 勾选状态 ```rust let is_enabled = true; menu.menu_with_check("Enable Feature", is_enabled, Box::new(ToggleFeature)) .menu_with_check("Show Sidebar", sidebar_visible, Box::new(ToggleSidebar)) ``` 默认情况下,勾选图标显示在菜单项左侧;如果菜单项已有图标,勾选图标会替换左侧图标。 也可以通过 `check_side` 将勾选图标放到右侧: ```rust menu.check_size(Side::Right) .menu_with_check("Enable Feature", is_enabled, Box::new(ToggleFeature)) ``` ### 分隔线 ```rust menu.menu("New", Box::new(NewFile)) .menu("Open", Box::new(OpenFile)) .separator() .menu("Copy", Box::new(Copy)) .menu("Paste", Box::new(Paste)) .separator() .menu("Exit", Box::new(Exit)) ``` ### 标签 ```rust menu.label("File Operations") .menu("New", Box::new(NewFile)) .menu("Open", Box::new(OpenFile)) .separator() .label("Edit Operations") .menu("Copy", Box::new(Copy)) .menu("Paste", Box::new(Paste)) ``` ### 链接菜单项 ```rust menu.link("Documentation", "https://docs.example.com") .link_with_icon( "GitHub", IconName::GitHub, "https://github.com/example/repo" ) .separator() .external_link_icon(false) .link("Support", "https://support.example.com") ``` ### 自定义元素 ```rust use gpui_kit::component::{h_flex, v_flex}; menu.menu_element(Box::new(CustomAction), |window, cx| { v_flex() .child("Custom Element") .child( div() .text_xs() .text_color(cx.theme().muted_foreground) .child("This is a subtitle") ) }) .menu_element_with_icon( IconName::Info, Box::new(InfoAction), |window, cx| { h_flex() .gap_1() .child("Status") .child( div() .text_sm() .text_color(cx.theme().success) .child("✓ Connected") ) } ) ``` ### 键盘快捷键 ```rust actions!(my_app, [Copy, Paste, Cut]); cx.bind_keys([ KeyBinding::new("ctrl-c", Copy, Some("editor")), KeyBinding::new("ctrl-v", Paste, Some("editor")), KeyBinding::new("ctrl-x", Cut, Some("editor")), ]); menu.action_context(focus_handle) .menu("Copy", Box::new(Copy)) .menu("Paste", Box::new(Paste)) .menu("Cut", Box::new(Cut)) ``` 快捷键按菜单项 action 实际派发的位置来解析:设置了 `action_context` 时按它解析, 否则按菜单触发元素所在的 key context 解析。快捷键提示与菜单项在同一帧显示。 ### 子菜单 ```rust menu.submenu("File", window, cx, |submenu, window, cx| { submenu.menu("New", Box::new(NewFile)) .menu("Open", Box::new(OpenFile)) .separator() .menu("Recent", Box::new(ShowRecent)) }) .submenu("Edit", window, cx, |submenu, window, cx| { submenu.menu("Undo", Box::new(Undo)) .menu("Redo", Box::new(Redo)) }) ``` ### 带图标的子菜单 ```rust menu.submenu_with_icon( Some(IconName::Folder.into()), "Project", window, cx, |submenu, window, cx| { submenu.menu("Open Project", Box::new(OpenProject)) .menu("Close Project", Box::new(CloseProject)) } ) ``` ### 可滚动菜单 菜单项很多时可以启用滚动。可滚动菜单中的子菜单与普通菜单一样正常打开: ```rust Button::new("large-menu") .label("Many Options") .dropdown_menu(|menu, window, cx| { let mut menu = menu .scrollable(true) .max_h(px(300.)) .label("Select an option"); for i in 0..100 { menu = menu.menu( format!("Option {}", i), Box::new(SelectOption(i)) ); } menu }) ``` ### 菜单尺寸 ```rust menu.min_w(px(200.)) .max_w(px(400.)) .max_h(px(300.)) .scrollable(true) ``` ### Action 上下文 ```rust let focus_handle = cx.focus_handle(); menu.action_context(focus_handle) .menu("Copy", Box::new(Copy)) .menu("Paste", Box::new(Paste)) ``` ## API 参考 - [PopupMenu] - [context_menu] - [PopupMenuItem] ## 示例 ### 文件管理器上下文菜单 ```rust div() .id("file-manager") .child("Right-click for options") .context_menu(|menu, window, cx| { menu.menu_with_icon("Open", IconName::FolderOpen, Box::new(Open)) .separator() .menu_with_icon("Copy", IconName::Copy, Box::new(Copy)) .menu_with_icon("Cut", IconName::Scissors, Box::new(Cut)) .menu_with_icon("Paste", IconName::Clipboard, Box::new(Paste)) .separator() .submenu("New", window, cx, |submenu, window, cx| { submenu.menu_with_icon("File", IconName::File, Box::new(NewFile)) .menu_with_icon("Folder", IconName::Folder, Box::new(NewFolder)) }) .separator() .menu_with_icon("Delete", IconName::Trash, Box::new(Delete)) .separator() .menu("Properties", Box::new(ShowProperties)) }) ``` ### 不使用 action 添加菜单项 ```rust use gpui_kit::component::{menu::PopupMenuItem, Button}; Button::new("custom-item-menu") .label("Options") .dropdown_menu(|menu, window, cx| { menu.item( PopupMenuItem::new("Custom Action") .disabled(false) .icon(IconName::Star) .on_click(|window, cx| { println!("Custom Action Clicked!"); }) ) .separator() .menu("Standard Action", Box::new(StandardAction)) }) ``` ### 带快捷键的编辑器菜单 ```rust actions!(editor, [Save, SaveAs, Find, Replace, ToggleWordWrap]); cx.bind_keys([ KeyBinding::new("ctrl-s", Save, Some("editor")), KeyBinding::new("ctrl-shift-s", SaveAs, Some("editor")), KeyBinding::new("ctrl-f", Find, Some("editor")), KeyBinding::new("ctrl-h", Replace, Some("editor")), ]); let editor_focus = cx.focus_handle(); Button::new("editor-menu") .label("Edit") .dropdown_menu(|menu, window, cx| { menu.action_context(editor_focus) .menu("Save", Box::new(Save)) .menu("Save As...", Box::new(SaveAs)) .separator() .menu("Find", Box::new(Find)) .menu("Replace", Box::new(Replace)) .separator() .menu_with_check("Word Wrap", true, Box::new(ToggleWordWrap)) }) ``` ### 带自定义元素的设置菜单 ```rust Button::new("settings") .label("Settings") .dropdown_menu(|menu, window, cx| { menu.label("Display") .menu_element_with_check(dark_mode, Box::new(ToggleDarkMode), |window, cx| { h_flex() .gap_2() .child("Dark Mode") .child( div() .text_xs() .text_color(cx.theme().muted_foreground) .child(if dark_mode { "On" } else { "Off" }) ) }) .separator() .label("Account") .menu_element_with_icon( IconName::User, Box::new(ShowProfile), |window, cx| { v_flex() .child("John Doe") .child( div() .text_xs() .text_color(cx.theme().muted_foreground) .child("john@example.com") ) } ) .separator() .link_with_icon("Help Center", IconName::Help, "https://help.example.com") .menu("Sign Out", Box::new(SignOut)) }) ``` ## 键盘快捷键 | 按键 | 行为 | | --- | --- | | `↑` / `↓` | 在菜单项之间移动 | | `←` / `→` | 在子菜单之间移动 | | `Enter` / `Space` | 激活当前菜单项 | | `Escape` | 关闭菜单 | | `Tab` | 关闭菜单并聚焦下一个元素 | ## 最佳实践 1. 使用分隔线对相关操作分组。 2. 在整个应用中保持图标语义一致。 3. 将最常用的操作放在靠前位置。 4. 为高频操作提供快捷键。 5. 根据上下文只展示相关菜单项。 6. 对复杂层级使用子菜单而不是把所有项堆在一起。 7. 使用清晰、动作导向的文案。 8. 菜单项很多时开启滚动并设置合理高度。 [PopupMenu]: https://docs.rs/gpui-component/latest/gpui_component/menu/struct.PopupMenu.html [PopupMenuItem]: https://docs.rs/gpui-component/latest/gpui_component/menu/struct.PopupMenuItem.html [context_menu]: https://docs.rs/gpui-component/latest/gpui_component/menu/trait.ContextMenuExt.html#method.context_menu [Action]: https://docs.rs/gpui/latest/gpui/trait.Action.html --- # Settings Source: /versions/v0.7.1/zh-CN/component/settings 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/component/settings 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 > Since: v0.5.0 Settings 组件用于构建应用设置界面,支持页面分组、按标题/描述/自定义关键词搜索过滤以及多种字段类型,适合实现类似 macOS 或 iOS 设置页的结构。 ## 导入 ```rust use gpui_kit::component::setting::{Settings, SettingPage, SettingGroup, SettingItem, SettingField}; ``` ## 用法 ### 构建设置界面 可组合以下组件来组织设置页: - [Settings]:顶层设置容器,持有多个设置页面。 - [SettingPage]:一组相关设置的页面。 - [SettingGroup]:基于 [GroupBox] 风格的设置分组。 - [SettingItem]:单个设置项,包含标题、描述和字段。 - [SettingField]:具体字段,如 Input、Dropdown、Switch 等。 整体层级如下: ``` Settings SettingPage SettingGroup SettingItem Title Description (optional) SettingField ``` ### 基础示例 ```rust use gpui_kit::component::setting::{Settings, SettingPage, SettingGroup, SettingItem, SettingField}; Settings::new("my-settings") .pages(vec![ SettingPage::new("General") .group( SettingGroup::new() .title("Basic Options") .item( SettingItem::new( "Enable Feature", SettingField::switch( |cx: &App| true, |val: bool, cx: &mut App| { println!("Feature enabled: {}", val); }, ) ) ) ) ]) ``` ### 多页面 如果你希望某个页面默认展开,可在 [SettingPage] 上使用 `default_open(true)`。 ```rust Settings::new("app-settings") .pages(vec![ SettingPage::new("General") .default_open(true) .group(SettingGroup::new().title("Appearance").items(vec![...])), SettingPage::new("Software Update") .group(SettingGroup::new().title("Updates").items(vec![...])), SettingPage::new("About") .group(SettingGroup::new().items(vec![...])), ]) ``` ### 搜索时的选择行为 搜索时,如果当前页面仍包含匹配的设置项,就保留当前页面;否则选择第一个匹配页面。 当前选中的分组仍匹配时保留该分组,否则退回页面级选择。 清空搜索会保留当前页面,不会恢复搜索前的选择。 没有匹配结果时不显示页面内容,并暂存选择,供结果恢复时使用。 ### 分组样式 ```rust use gpui_kit::component::group_box::GroupBoxVariant; Settings::new("my-settings") .with_group_variant(GroupBoxVariant::Outline) .pages(vec![...]) Settings::new("my-settings") .with_group_variant(GroupBoxVariant::Fill) .pages(vec![...]) ``` 单个分组可以覆盖全局分组样式,例如让某一页的内容直接呈现,而其他页面 保留全局的卡片外观: ```rust SettingGroup::new() .variant(GroupBoxVariant::Normal) .items(vec![...]) ``` ## Setting Page ### 基础页面 ```rust SettingPage::new("General") .group(SettingGroup::new().title("Options").items(vec![...])) ``` ### 多分组 ```rust SettingPage::new("General") .groups(vec![ SettingGroup::new().title("Appearance").items(vec![...]), SettingGroup::new().title("Font").items(vec![...]), SettingGroup::new().title("Other").items(vec![...]), ]) ``` ### 图标 ```rust SettingPage::new("General") .icon(IconName::Settings) .groups(vec![...]) ``` ### 标题后缀 使用 `title_suffix` 在页头标题后渲染自定义元素,例如一个点击后打开帮助文档的 info 图标按钮: ```rust SettingPage::new("General") .title_suffix(|_, _| { Button::new("help") .icon(IconName::Info) .ghost() .xsmall() .on_click(|_, _, cx| cx.open_url("https://example.com/help")) }) .groups(vec![...]) ``` ### 默认展开 ```rust SettingPage::new("General") .default_open(true) .groups(vec![...]) ``` ### 支持重置 ```rust SettingPage::new("General") .resettable(true) .groups(vec![...]) ``` ## Setting Group ### 基础分组 ```rust SettingGroup::new() .title("Appearance") .items(vec![ SettingItem::new(...), SettingItem::new(...), ]) ``` ### 单项分组 ```rust SettingGroup::new() .title("Font") .item(SettingItem::new(...)) ``` ### 无标题分组 ```rust SettingGroup::new() .items(vec![...]) ``` ### 分组表面外的底部说明 用 `footer` 在分组的背景或边框下方渲染辅助内容。它与分组标题左对齐,并像描述文字一样以小号 muted 文本渲染,直接传入纯文本即可;闭包接收当前窗口和应用上下文,可用于更复杂的内容。它随分组一起滚动和过滤,不会成为独立的可搜索设置项,也不会新增侧栏入口;分组仍需至少一个设置项才会显示。 ```rust SettingGroup::new() .item(SettingItem::new( "Update source", SettingField::render(|_, _, _| "GitHub Releases"), )) .footer(|_, _| "Changes apply to this device only.") ``` ## Setting Item ### 基础设置项 ```rust SettingItem::new("Title", SettingField::switch(...)) .description("Description text") ``` ### 使用 render closure 的自定义项 ```rust SettingItem::render(|options, _, _| { h_flex() .w_full() .justify_between() .child("Custom content") .child( Button::new("action") .label("Action") .with_size(options.size) ) .into_any_element() }) ``` ### 纵向布局 设置项默认是横向布局;可通过 `layout(Axis::Vertical)` 切换为纵向布局: ```rust SettingItem::new( "CLI Path", SettingField::input(...) ) .layout(Axis::Vertical) .description("This item uses vertical layout.") ``` ### Markdown 描述 ```rust use gpui_kit::component::text::markdown; SettingItem::new( "Documentation", SettingField::element(...) ) .description(markdown("Rust doc for the `gpui-component` crate.")) ``` ### 禁用状态 通过 `disabled(true)` 可以将设置项置为不可交互状态:整行会变暗,内置字段 (Switch、Checkbox、Input、Dropdown、NumberInput)也会被自动禁用。 ```rust SettingItem::new( "Dark Mode", SettingField::switch(...) ) .description("Switch between light and dark themes.") .disabled(true) ``` 对于 [SettingItem::render] 自定义项,整行同样会自动变暗,但其中可交互的控件 需要渲染闭包通过 `options.disabled` 自行处理: ```rust SettingItem::render(|options, _, _| { h_flex() .child("Custom content") .child( Button::new("action") .label("Action") .with_size(options.size) .disabled(options.disabled) ) .into_any_element() }) .disabled(true) ``` ### 搜索关键词 通过 `keywords` 可以为设置项附加额外的搜索关键词。这些关键词仅用于搜索匹配, 不会被渲染出来。例如,标题为 "Enable Two-factor auth" 的设置项可以通过 "MFA" 搜索到: ```rust SettingItem::new( "Enable Two-factor auth", SettingField::switch(...) ) .keywords(["MFA", "2FA"]) ``` 这对于没有标题和描述、但仍希望能被搜索到的 [SettingItem::render] 自定义项同样 有用: ```rust SettingItem::render(|options, _, _| { h_flex().child("Custom content").into_any_element() }) .keywords(["Advanced", "Network"]) ``` ## Setting Fields [SettingField] 枚举提供了多种常见字段类型。 ### Switch ```rust SettingItem::new( "Dark Mode", SettingField::switch( |cx: &App| cx.theme().mode.is_dark(), |val: bool, cx: &mut App| { // Handle value change }, ) .default_value(false) ) ``` ### Checkbox ```rust SettingItem::new( "Auto Switch Theme", SettingField::checkbox( |cx: &App| AppSettings::global(cx).auto_switch_theme, |val: bool, cx: &mut App| { AppSettings::global_mut(cx).auto_switch_theme = val; }, ) .default_value(false) ) ``` ### Input ```rust SettingItem::new( "CLI Path", SettingField::input( |cx: &App| AppSettings::global(cx).cli_path.clone(), |val: SharedString, cx: &mut App| { AppSettings::global_mut(cx).cli_path = val; }, ) .default_value("/usr/local/bin/bash".into()) ) .layout(Axis::Vertical) .description("Path to the CLI executable.") ``` ### Dropdown ```rust SettingItem::new( "Font Family", SettingField::dropdown( vec![ ("Arial".into(), "Arial".into()), ("Helvetica".into(), "Helvetica".into()), ("Times New Roman".into(), "Times New Roman".into()), ], |cx: &App| AppSettings::global(cx).font_family.clone(), |val: SharedString, cx: &mut App| { AppSettings::global_mut(cx).font_family = val; }, ) .default_value("Arial".into()) ) ``` ### NumberInput ```rust use gpui_kit::component::setting::NumberFieldOptions; SettingItem::new( "Font Size", SettingField::number_input( NumberFieldOptions { min: 8.0, max: 72.0, ..Default::default() }, |cx: &App| AppSettings::global(cx).font_size, |val: f64, cx: &mut App| { AppSettings::global_mut(cx).font_size = val; }, ) .default_value(14.0) ) ``` ### 使用 render closure 创建自定义字段 ```rust SettingItem::new( "GitHub Repository", SettingField::render(|options, _window, _cx| { Button::new("open-url") .outline() .label("Repository...") .with_size(options.size) .on_click(|_, _window, cx| { cx.open_url("https://github.com/example/repo"); }) }) ) ``` ### 自定义字段元素 如果某个字段逻辑较复杂并且需要复用,可以实现 [SettingFieldElement] trait: ```rust use gpui_kit::component::setting::{SettingFieldElement, RenderOptions}; struct OpenURLSettingField { label: SharedString, url: SharedString, } impl SettingFieldElement for OpenURLSettingField { type Element = Button; fn render_field(&self, options: &RenderOptions, _: &mut Window, _: &mut App) -> Self::Element { let url = self.url.clone(); Button::new("open-url") .outline() .label(self.label.clone()) .with_size(options.size) .on_click(move |_, _window, cx| { cx.open_url(url.as_str()); }) } } ``` 然后在设置项中这样使用: ```rust SettingItem::new( "GitHub Repository", SettingField::element(OpenURLSettingField { label: "Repository...".into(), url: "https://github.com/longbridge/gpui-kit".into(), }) ) ``` ## API 参考 - [Settings] - [SettingPage] - [SettingGroup] - [SettingItem] - [SettingField] - [NumberFieldOptions] ### 尺寸 实现了 [Sizable] trait: - `xsmall()`:超小尺寸 - `small()`:小尺寸 - `medium()`:中尺寸,默认值 - `large()`:大尺寸 - `with_size(Size)`:指定具体尺寸 ## 示例 ### 完整设置页示例 ```rust use gpui_kit::{App, SharedString}; use gpui_kit::component::{ Settings, SettingPage, SettingGroup, SettingItem, SettingField, setting::NumberFieldOptions, group_box::GroupBoxVariant, Size, }; Settings::new("app-settings") .with_size(Size::Medium) .with_group_variant(GroupBoxVariant::Outline) .pages(vec![ SettingPage::new("General") .resettable(true) .default_open(true) .groups(vec![ SettingGroup::new() .title("Appearance") .items(vec![ SettingItem::new( "Dark Mode", SettingField::switch( |cx: &App| cx.theme().mode.is_dark(), |val: bool, cx: &mut App| { // Handle theme change }, ) ) .description("Switch between light and dark themes."), ]), SettingGroup::new() .title("Font") .items(vec![ SettingItem::new( "Font Family", SettingField::dropdown( vec![ ("Arial".into(), "Arial".into()), ("Helvetica".into(), "Helvetica".into()), ], |cx: &App| "Arial".into(), |val: SharedString, cx: &mut App| { // Handle font change }, ) ), SettingItem::new( "Font Size", SettingField::number_input( NumberFieldOptions { min: 8.0, max: 72.0, ..Default::default() }, |cx: &App| 14.0, |val: f64, cx: &mut App| { // Handle size change }, ) ), ]), ]), SettingPage::new("Software Update") .resettable(true) .group( SettingGroup::new() .title("Updates") .items(vec![ SettingItem::new( "Auto Update", SettingField::switch( |cx: &App| true, |val: bool, cx: &mut App| { // Handle auto update }, ) ) .description("Automatically download and install updates."), ]) ), ]) ``` [Settings]: https://docs.rs/gpui-component/latest/gpui_component/setting/struct.Settings.html [SettingPage]: https://docs.rs/gpui-component/latest/gpui_component/setting/struct.SettingPage.html [SettingGroup]: https://docs.rs/gpui-component/latest/gpui_component/setting/struct.SettingGroup.html [SettingItem]: https://docs.rs/gpui-component/latest/gpui_component/setting/struct.SettingItem.html [SettingField]: https://docs.rs/gpui-component/latest/gpui_component/setting/enum.SettingField.html [SettingFieldElement]: https://docs.rs/gpui-component/latest/gpui_component/setting/trait.SettingFieldElement.html [NumberFieldOptions]: https://docs.rs/gpui-component/latest/gpui_component/setting/struct.NumberFieldOptions.html [GroupBox]: ./group-box.md [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html --- # Examples Source: /versions/v0.7.1/zh-CN/shell/examples 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/shell/examples 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 仓库自带四个示例,合起来覆盖独立应用、可停靠布局、由 Host 状态驱动的脚本,以及动画帧完全不进入 JavaScript 的原生 motion。 | | 怎么跑 | 展示了什么 | | --- | --- | --- | | [Todo list](#todo-list) | 一个独立应用 | 脚本这一侧的全部:留存输入、dialog、toast、受授权约束的存储、资源、类型 | | [工作区](#工作区) | 一个独立应用 | 可停靠布局:熬过重启的面板,以及全部由脚本绘制的 chrome | | [报价面板](#报价面板) | gallery 里的一块面板 | Host 那一半:HostModule 、一个实体被两种语言读取、实时的成本读数 | | [原生动画](#原生动画) | gallery 内独立的脚本 View | 由 GPUI 保留并采样的像素目标 transition 与 spring | ## 一个完整的应用 这里的示例每个都只讲一件事。想看一个完整产品放在一个仓库里——OAuth、实时 WebSocket 报价流、虚拟化自选列表、用留存嵌套 View 承载的价格图表,以及它自己的 Rust Host 二进制——见 [**longbridge/longbridge-lite**](https://github.com/longbridge/longbridge-lite)。 它是一个只读的 Longbridge 桌面客户端,几千行 JavaScript,也是目前基于这个运行时 写出来的最大的东西。 ## Todo list ```bash cargo run -p gpui-shell -- examples/js_todolist ``` `examples/js_todolist/` 的目的是把整个运行时都跑一遍,而不是做到最小——`gpui-shell` 哪里坏了,通常先在这里露出来。 ```text main.js View:状态、筛选、所有事件处理 ui.js 呈现层,以函数形式导出 storage.js 持久化,以及没拿到授权时怎么办 confirm.js 确认 dialog,它自己也是一个 View icons/ 四个 SVG,相对应用根目录解析 gpui-kit.d.ts 自动生成;jsconfig.json 与 types.d.ts 把类型接上 ``` 其中有四件事值得照抄。 **`ui.js` 是一个由函数构成的组件库。** 它导出 `label`、`muted`、`title`、`button`、`iconButton`、`checkbox`、`field`、`row`、`surface`、`rule` 与 `emptyState`,于是 `main.js` 读起来就像在用一个组件库: ```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` 传给这些 helper,由 helper 直接通过 `cx.theme()` 读取 token。这样做没有额外代价,因为[一次函数调用产出的正是一份新的描述](/versions/v0.7.1/zh-CN/shell/elements)。这也是对“基础层不提供任何带样式的控件”的回答——带样式的那一层你在自己的文件里写一次,从此不必重复。 **存储是把拒绝吸收掉,而不是先去问有没有权限。** Host 没授予存储时 `store` 会抛异常,而这是关于 Host 的一个事实,不是这个应用的错误: ```js export function load() { try { const saved = store.get(KEY); return Array.isArray(saved) ? saved : []; } catch (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”。在边界上把拒绝吸收掉,然后如实告诉用户。 **dialog 是一个函数,不是一个元素。** `confirm.js` default 导出一个返回内容函数的函数;`main.js` 用 `window.open_dialog(confirmClear(count, onConfirm))` 打开它。数量和回调是闭包捕获的,不是交接过去的。见 [Overlays](/versions/v0.7.1/zh-CN/shell/overlays)。 **类型是配好的,一共三个文件。** `jsconfig.json` 打开 `checkJs`,`gpui-kit.d.ts` 由 `gpui-shell types` 生成,`types.d.ts` 放这个应用自己的形状——`Todo`、`Filter`。编辑器补全与 `checkJs` 报错从此就能用,不需要任何构建步骤。 ## 工作区 ```bash cargo run -p gpui-shell -- examples/js_dock ``` `examples/js_dock/` 是一个可停靠的工作区——左边是文件列表,中间是文档,布局会以你离开时的样子回来。 ```text main.js 工作区本体:面板、dock 与持久化 ui.js chrome:标签页、dock 外框、落点提示 ``` 其中三件事是重点。 **base 不画 chrome,所以这些全在 `ui.js` 里。** 标签栏、dock 的标题条、折叠控件、缩放把手与落点提示都是用普通样式接口写出来的普通元素。一个这些都没有的 area 照样能停靠、拖动、调整大小、持久化,只是除了面板本身之外什么都不画。 **标签页带的是命令,不是处理器。** chrome 描述会缓存到原生状态改变为止,所以其中的脚本事件处理器没有可靠的生命周期。`select_tab(group, tab.index)` 与 `close_panel(group, tab.id)` 完全不携带脚本值——它们只是指名某个容器、以及要请它做什么。 **面板就是多了两个方法的 View。** `Document.serialize()` 返回它的标题与编辑次数;重启之后 `deserialize(data)` 把它们收回来。关于这块面板的其他一切——它在哪、是否正在显示——都是布局的事,永远不会传到脚本。 完整接口见 [Dock 与面板](/versions/v0.7.1/zh-CN/shell/dock)。 ## 报价面板 ```bash cargo run -- shell ``` gallery 的 Shell story 并排跑着两块面板:左边那块由 Rust 的 `shell_story.rs` 画,右边那块由 JavaScript 的 `crates/story/js/quotes/main.js` 画,两边读的是同一份数据。 脚本自己不持有任何状态。这块行情板是一个 Rust 的 `Entity`,从 story 在运行时启动前注册的 [HostModule](/versions/v0.7.1/zh-CN/shell/host-module) import 进来: ```text import { quotes, ticks, watch, watch_all } from "market"; ``` 主题值来自调用作用域内的 `cx.theme()` Snapshot,而不是第二个 HostModule 。 因为两块面板读的是同一个实体,两边一旦对不上就会立刻看出来——这也是它算一个测试而不只是演示的原因。改 `main.js` 就能改变右边那块面板,中间不需要 `cargo build`;面板旁边有一个 “Reload script” 按钮。 底下就是这套文档反复引用的那组计数读数:每秒的脚本次数对每秒的帧数,还有一个 feed 选择器,可以让其中一个动而另一个不动。这就是[那条性能主张](/versions/v0.7.1/zh-CN/shell#性能-脚本不在每一帧里)在一个运行着的窗口里的样子。 ## 原生动画 `crates/story/js/motion/main.js` 刻意使用与 quote benchmark 分离的 `ScriptView`,避免动画活动污染 render-frequency 测量。它可以在 `.transition(...)` 与 `.spring(...)` 之间切换,再重新设定 opacity 以及像素值的 width、height、left 与 top。 脚本只运行一次来发布新目标。后续每一个动画帧都由 GPUI 原生调度与采样,不会重新进入 JavaScript。示例只使用数值像素目标——没有 `rem`、百分比或 `auto`——并使用稳定 id,让留存通道能够跨 description 重建继续存在。 ## 从哪儿开始 把 `examples/js_todolist` 复制到你自己的目录里跑起来——它是一个类型都配好了的完整应用。把 `main.js` 削回到一个只有 `init` 与 `render` 的 `View`,留着 `ui.js`,再从那里往上加。 要写 Host 的话,`crates/story/src/stories/shell_story.rs` 是另一侧的可用参考:它构建运行时、注册 HostModule 、挂载一个 `ScriptView`,并按需重载它。[Hosting](/versions/v0.7.1/zh-CN/shell/hosting) 走的是同样这几个调用。 --- # API Reference Source: /versions/v0.7.1/zh-CN/shell/api 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/shell/api 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 脚本接口的一份清单:有什么,以及它来自哪个模块。其余页面解释每样东西为什么是这个样子——这一页是用来查名字的。 权威不在这一页。runtime 会为自己的版本生成 `gpui-kit.d.ts`,并在应用加载时尽力刷新到源码旁;`gpui-shell types ` 执行同一次写入,并会明确报告失败。生成文件的头部带有 `gpui-shell` 版本,也包含该应用注册的 HostModule 。请忽略这个文件而不要提交,并在脚本顶部写上 `// @ts-check` 让编辑器照着它检查。manifest 声明的 Git 依赖同样不在这一页:它们由同一次刷新链接进 `node_modules`,名字、签名与文档都来自 package 自身。见[依赖](/versions/v0.7.1/zh-CN/shell/dependencies)。 ## 模块 每个内建模块都以它所暴露的公开 Rust 层命名,所以一条 import 能说明脚本依赖哪一层。`gpui-kit` 还包含从 JavaScript 使用 GPUI 所需的 shell 桥接: View、留存实体、调度与共享类型。一个名字只属于一个模块,这里不为了方便做 re-export。 ```js import { View, div } from "gpui-kit"; import { Button, v_flex } from "gpui-base"; import { fps_monitor } from "gpui-fps"; ``` | 模块 | 提供 | | ------------ | ---------------------------------------------------------------- | | `gpui-kit` | GPUI 自己的元素,加上这个运行时补上的部分: View、样式接口与调度 | | `gpui-base` | 布局辅助函数、组件与主题 | | `gpui-shell` | shell 桥接层自有的纯类型概念;没有运行时导出 | | `gpui-fps` | 性能 HUD | 有两个名字从不需要 import,但原因不同。`window` 是真正的全局:没有谁把它交给你,它本来就在作用域里。`cx` 恰恰相反——它从来不是全局的,只会作为参数到达:`render(cx)`、`init(props, cx)`、每个处理器的第二个参数、`cx.spawn` body 的形参。标准运行时模块——`fs/promises`、`path`、`crypto`、`process`、`net`、`websocket` 等等——受 Host 授权门控,记录在 [Capabilities](/versions/v0.7.1/zh-CN/shell/capabilities)。 API 形态跟随 Rust 原型:`App` 上的方法放在 `cx`,`Window` 上的方法放在 `window` 全局对象,关联构造器写成 `Type.new(...)`,自由函数保持小写。没有直接 GPUI 或 Base 原型的名字,属于实现它的公开层。表中也会列出仅存在于类型系统的名字,但它们不是运行时可调用的值。 ## `gpui-kit` 模块 ### 元素 | 名称 | 说明 | | ----------------- | ----------------------------------------------------------------------------------- | | `Element` | 通过链式方法构建、只属于当前 render pass 的描述 | | `div()` | 自身不带布局的元素 | | `svg(path)` | 来自应用根目录的矢量图,按周围的文字颜色着色 | | `image(path)` | 来自应用根目录的全彩图片,保留原色 | | `list(…)` | GPUI 的惰性列表:行高任意,边画边测量 | | `uniform_list(…)` | GPUI 的等高列表:测量一行,其余按它排布 | | `PathBuilder` | GPUI 的路径构建器类型及其工厂;`fill()` 与 `stroke(width)` 都返回一个 `PathBuilder` | | `Background` | `solid`、`stop`、`linear_gradient`、`pattern_slash`、`checkerboard` | `PathBuilder.fill()` 与 `.stroke(width)` 返回一个句柄,可链式调用 `move_to`、`line_to`、`curve_to`、`cubic_bezier_to`、`arc_to`、`add_polygon`、`close` 与 `dash_array`,最后以 `build()` 收尾。用 `window.paint_path(path, background)` 把结果画出来——它是唯一一个通过对象取到的元素构造器,因为它镜像的东西在 Rust 侧就是窗口上的一个方法。 `list` 和 `uniform_list` 是 GPUI 自己的惰性列表,参数是 `(id, item_count, get_key, render)`,即 `gpui-base` 的 `v_virtual_list` 去掉 `item_sizes` 的形状:没有尺寸表,因为 GPUI 自己测量各项。`uniform_list` 测量一行,其余各行按它排布,`render(range, cx)` 和虚拟列表一样按区间返回元素数组。`list` 会测量画出的每一项并记住尺寸,`render(index, cx)` 为一项返回一个元素,所以高度不等的行或面板不必事先说明有多高。两者都只绘制屏幕内的内容外加折叠线下方的一小段,自己处理滚动,并按名字与 `Scrollbar` 配对;都不接受 `VirtualListScrollHandle`。 字符串本身也是元素,和 GPUI 里 `&str` 实现 `IntoElement` 完全一样:`.child("hello")` 就是写文本的方式,样式来自持有它的那个元素。 ### View | 名称 | 说明 | | ----------- | ------------------------------------------------------------- | | `View` | 每个 View 的基类;继承它,并把子类作为 default export | | `ViewClass` | 一个具体的 `View` 子类,也就是 `cx.new` 接受的东西 | | `Entity` | 对一个嵌套 View 的留存所有权:`set_props(props)`、`release()` | 子类定义只执行一次的 `init?(props, cx)`,以及返回一个 `Element`、`Entity` 或字符串、在 View 被置为失效时执行的 `render(cx)`。可选的 `update(props)` 在父 View 改变嵌套 View 的 props 时执行。 ### 调度 | 名称 | 说明 | | ------- | ---------------------------------------------------------- | | `Task` | 一个正在运行的任务:`cancel()`、`is_done()` | | `Timer` | `after(ms, handler, opts?)` 与 `every(ms, handler, opts?)` | ### 焦点 | 名称 | 说明 | | ------------- | ------------------------------------------------ | | `FocusHandle` | 脚本自己持有的焦点目标;[它的成员](#focushandle) | ### 共享类型 | 名称 | 说明 | | ------------------ | -------------------------------------------------------------------------------------------------------- | | `Length` | 数字(像素)、`"12px"`、`"1.5rem"`、`"50%"` 或 `"auto"` | | `DefiniteLength` | 同上,但不含 `"auto"` | | `AbsoluteLength` | 只有像素或 rem | | `Axis` | `"horizontal"` 或 `"vertical"`,镜像 `gpui_kit::Axis` | | `Color` | 一个 `gpui-base` 的 `ColorToken`,或 `#rgb` / `#rrggbb` / `#rrggbbaa` 字面量 | | `Role` | 一个无障碍 role,镜像 `gpui_kit::Role` 的 snake_case 拼写 | | `Anchor` | 锚定浮层的哪个角固定在它的触发元素上 | | `MouseButton` | `"left"`、`"right"` 或 `"middle"` | | `ClickEvent` | `click_count`、`modifiers` | | `MouseMoveEvent` | `position`、`local_position`、`bounds`、`modifiers` | | `MouseButtonEvent` | `button`、`click_count`、`position`、`modifiers`,以及元素绘制之后才有的局部几何 | | `ScrollWheelEvent` | 以像素表示的 `delta`;设备按行上报时还有 `delta_lines`;以及 `touch_phase` | | `KeyEvent` | `keystroke`(整个组合键,平台修饰键在所有平台上都拼作 `cmd`)、`key`、`key_char`、`modifiers`、`is_held` | | `ActionEvent` | `action`——脚本给这个 action 起的名字 | | `KeyBinding` | `cx.bind_keys` 的一项:`keystroke`、`action`、可选的 `context` | | `Modifiers` | `shift`、`control`、`alt`、`platform` | | `Point` | `x`、`y` | | `Size` | `width`、`height` | | `Path` | 由 `PathBuilder.build()` 产出的不可变原生几何 | | `Background` | 由 `Background.solid(...)` 等工厂创建的可复用原生背景:`opacity(factor)`、`color_space(space)` | | `BackgroundStop` | 一个渐变色标,来自 `Background.stop(color, percentage)` | #### `FocusHandle` 由 `cx.focus_handle()` 创建,用 `track_focus(handle)` 交给元素,并用 `release()` 释放。 | 方法 | 说明 | | ----------------------- | -------------------------------- | | `focus(): void` | 把键盘移到跟踪它的那个元素上 | | `is_focused(): boolean` | 那个元素当前是否持有键盘 | | `release(): boolean` | 释放句柄,并返回它当时是否还活着 | ## `gpui-shell` 模块 这些是 JavaScript 桥接层自身引入的纯类型概念。它们只用于类型检查;这个模块没有运行时值。 | 名称 | 说明 | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `LengthString` | shell 长度桥接接受的字符串形式 | | `PathCoordinate` | 像素,或所绘元素边界的百分比 | | `Props` | 跨 JavaScript View 桥接传递的属性包 | | `ElementBounds` | shell 事件使用的、带 `width` 与 `height` 的 `Point` | | `ScopePhase` | `"render"`、`"event"`、`"task"`、`"layout"` 或 `"none"` | | `TaskOptions` | `{ owner?: View \| null }`——任务随之取消的 View。默认是当前运行的 View;`null` 比任何 View 都活得久 | | `DialogOptions` | `{ escape_dismissable?: boolean, backdrop_dismissable?: boolean }`,两者默认都是 `true` | | `ToastOptions` | `{ title: string, description?: string, level?: "info" \| "success" \| "warning" \| "error", timeout?: number \| null, id?: string }`。`level` 默认 `"info"`;`timeout` 默认五秒,`null` 表示留到被关掉 | | `MotionProperty` | `"opacity"`、`"width"`、`"height"`、`"left"`、`"top"` | | `MotionEasing` | `"linear"`、`"ease-in"`、`"ease-out"`、`"ease-in-out"` | | `TransitionPolicy` | `duration`、`delay`、`easing` | | `SpringPolicy` | `response`、`damping`、`epsilon` | `ScopePhase` 描述当前 `Context` 属于哪一种 shell 调用。它和 GPUI 的 `DispatchPhase` 无关;后者控制事件分发时 capture 与 bubble 的顺序。 ## `cx` 上下文 两种 context 的成员相同,但生命周期不同。`render` 与事件处理器收到的 `Context` 只属于那次 Host 调用;把它留到调用之后,包括跨越 `await`,都会得到 stale-context 错误。下面的 `AsyncContext` 才是为跨越 `await` 准备的那一种。 | 成员 | 说明 | | -------------------------- | -------------------------------------------------------------------- | | `notify()` | 请求重新渲染;在 `render` 期间抛异常,因为渲染中通知自己是一个死循环 | | `bind_keys(bindings)` | 安装键绑定并返回安装了几条;对应 `App::bind_keys` | | `stop_propagation()` | 让这次事件不再向上传到外层的处理器;对应 `App::stop_propagation` | | `propagate()` | 在同一次分发中撤销上面那一步;对应 `App::propagate` | | `phase()` | 这次调用处于哪个 `ScopePhase` | | `theme()` | 当前 `gpui_kit::base::Theme` 的语义 token 投影 | | `open_url(url)` | 把一个绝对的 `http`/`https` URL 交给系统处理器 | | `read_from_clipboard()` | 剪贴板里的文本,没有文本时是 `undefined` | | `write_to_clipboard(text)` | 替换剪贴板里的文本 | | `focus_handle()` | 一个新的 `FocusHandle`;属于 `init` 或事件处理器,绝不属于 `render` | | `new(Class, props?)` | 创建一个留存的嵌套 View,并返回拥有它的 `Entity` | | `spawn(body, opts?)` | 执行 `body(cx)` 并接管它返回的 promise,让 rejection 得到上报 | | `sleep(ms?)` | 在 GPUI 的 foreground executor 上,`ms` 之后 resolve | | `timer` | `Timer`:`after` 与 `every` | 其中好几个都指名了它所镜像的 GPUI 方法:`open_url` 是 `App::open_url`,`read_from_clipboard` 与 `write_to_clipboard` 是 `App::read_from_clipboard` 与 `App::write_to_clipboard`,`focus_handle` 是 `App::focus_handle`(GPUI 没有 `FocusHandle::new`,这里同样没有),`new` 是 `AppContext::new`,`spawn` 是 `App::spawn`。 ### `AsyncContext` `AsyncContext` 继承 `Context`,不增加任何成员。区别在生命周期,不在接口:普通的 `Context` 只为一次 Host 调用发言,一旦那次调用返回就明确报错;而 `AsyncContext` 不指名任何一次调用——用到它时才解析当时正在执行的那一次,只有在一次都没有时才拒绝。它对应 GPUI 的 `AsyncApp`。 有三处会交出一个:`init`、`cx.spawn` 的 body,以及 `cx.timer` 的回调。这三处的职责正是「安排或延续比启动它的那次调用活得更久的工作」。 ## `window` 全局对象 这个全局对象的类型是 `gpui-kit` 导出的 `Window`。调用处不需要 import,也没有谁把它交给你。每次调用都读取当前正在跑的那次 Host 调用,不在任何调用中时抛异常,所以没有句柄要持有,也没有东西会过期。浮层属于窗口,而不属于打开它的那个 View——这就是这些方法在这里、而不在 `Context` 上的原因。 | 成员 | 说明 | | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | `open_dialog(content, options?)` | 打开一个 dialog,并返回栈的新深度 | | `close_dialog()` | 关闭最上层的 dialog,并回答有没有找到 | | `close_all_dialogs()` | 关闭所有 dialog,并回答关掉了几个 | | `has_active_dialog()` | 是否有 dialog 打开;与其余方法不同,它在 `render` 中合法 | | `open_sheet(content)` | 在右侧打开 sheet,替换掉原本在那里的内容 | | `open_sheet_at(placement, content)` | 同上,贴靠你指定的 `gpui-base` `Placement` | | `close_sheet()` | 关闭 sheet,并回答原本有没有打开 | | `has_active_sheet()` | sheet 是否打开;在 `render` 中合法 | | `push_toast(options)` | 弹出一个 toast,并返回它的 id | | `remove_toast(id)` | 撤回一个 toast,并回答它当时是否还在显示 | | `clear_toasts()` | 撤回所有 toast,并回答撤回了几个 | | `paint_path(path, background)` | 用原生背景绘制不可变几何;对应 `Window::paint_path` | | `dispatch_action(action)` | 沿本窗口的焦点路径派发一个 action;对应 `Window::dispatch_action` | | `rem_size()` / `line_height()` | 窗口的排版度量,单位是像素 | | `viewport_size()` / `bounds()` | 可绘制区域,以及窗口在屏幕上的位置 | | `mouse_position()` | 指针位置,窗口坐标 | | `appearance()` | `"light"` 或 `"dark"` | | `is_window_active()` / `is_fullscreen()` / `is_maximized()` | 平台窗口的状态 | | `set_rem_size(size)` | 重新缩放所有以 rem 表达的尺寸 | | `refresh()` | 重绘窗口里的每一个 View | | `focus_next()` / `focus_prev()` | 把键盘移到相邻的一个 tab stop | | `activate_window()` / `minimize_window()` / `zoom_window()` / `toggle_fullscreen()` | 平台窗口控制 | | `localStorage` | Web Storage,背后是 Host 放好的一个文件,跨重启存活 | | `sessionStorage` | Web Storage,只在内存里,随进程一起消失 | 上面这些度量——从 `rem_size()` 一直到 `is_maximized()`——在 `render` 中都是合法的:一个要按窗口尺寸决定自身大小的 View,只能在绘制它的那一趟里问。而所有*改变*窗口的调用在 `render` 中都会被拒绝,理由和 `cx.notify()` 一样:一帧去改自己正在绘制的窗口,就是这一帧在和自己较劲。 `open_dialog`、`open_sheet` 与 `open_sheet_at` 接受的是**一个返回元素的函数**,而不是元素:dialog 活得比打开它的那次调用久,每次重绘时这个函数都会再执行一次。除了两个 `has_active_*` 查询与 `paint_path`,这里的一切在 `render` 中都不合法。见 [Overlays](/versions/v0.7.1/zh-CN/shell/overlays)。 ### 存储 [Web Storage API](https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Storage_API),原样照搬。两个 store 同时也是裸的全局变量——`localStorage.getItem(k)` 与 `window.localStorage.getItem(k)` 是同一次调用——因为在浏览器里也是如此。 | 成员 | 说明 | | --------------------- | --------------------------- | | `length` | 已存的键数量 | | `key(index)` | 该位置上的键,越界为 `null` | | `getItem(key)` | 值,键不存在时为 `null` | | `setItem(key, value)` | 存入,值会被转成字符串 | | `removeItem(key)` | 忘掉一个键 | | `clear()` | 全部忘掉 | | `flush()` | 写入落盘后 resolve | 值是字符串,所以有结构的东西照 web 上的写法走 `JSON.stringify` 与 `JSON.parse`。`flush()` 是唯一多出来的成员:浏览器不需要它,因为它的存储从头到尾都是同步的。`localStorage` 受 capability 管辖,Host 没授权时抛异常;`sessionStorage` 不受管辖,因为它持有的东西从不离开进程。见 [Capabilities](/versions/v0.7.1/zh-CN/shell/capabilities#storage)。 ## `gpui-base` 模块 这里的组件拥有行为、焦点,以及屏幕阅读器听到的内容,而自身几乎什么都不画。画面归脚本所有,用[样式接口](/versions/v0.7.1/zh-CN/shell/styling)写出来。每个名字都链接到它在 [gpui-base 文档](/versions/v0.7.1/zh-CN/base)里的页面,那里描述了它完整的 Rust 接口与行为。 ### 布局 | 名称 | 说明 | | --------------------------------------------------------- | -------------------------------------------------- | | `h_flex()` | 一行 | | `v_flex()` | 一列 | | [`h_resizable(id)`](/versions/v0.7.1/zh-CN/base/primitives/resizable) | 一行带可拖拽分隔条的窗格;尺寸按这个 id 存在窗口里 | | [`v_resizable(id)`](/versions/v0.7.1/zh-CN/base/primitives/resizable) | 同上,纵向堆叠 | | [`resizable_panel()`](/versions/v0.7.1/zh-CN/base/primitives/resizable) | 可调整组里的一个窗格,用在别处都不合法 | ### 控件 | 名称 | 说明 | | -------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | [`Button`](/versions/v0.7.1/zh-CN/base/primitives/button) | 激活、焦点、disabled 与 selected 状态 | | [`Link`](/versions/v0.7.1/zh-CN/base/primitives/link) | 通过系统浏览器打开的外部 HTTP(S) 资源 | | [`Checkbox`](/versions/v0.7.1/zh-CN/base/primitives/checkbox) | 受控的勾选;勾选标记自己画 | | [`Switch`](/versions/v0.7.1/zh-CN/base/primitives/switch) | 受控的 switch | | [`Radio`](/versions/v0.7.1/zh-CN/base/primitives/radio) | 一组中的一个选项;只报告 `true`,从不报告取消选中 | | [`Toggle`](/versions/v0.7.1/zh-CN/base/primitives/toggle) | 一个会保持按下的按钮 | | [`RadioGroup`](/versions/v0.7.1/zh-CN/base/primitives/radio-group) | 被报读为一组的一批 radio;自身不持有选中项 | | [`ToggleGroup`](/versions/v0.7.1/zh-CN/base/primitives/toggle-group) | 被报读为 toolbar 的一批 toggle | | [`Tabs`](/versions/v0.7.1/zh-CN/base/primitives/tabs) | 自身不持有选中项的 tab 列表 | | [`Tab`](/versions/v0.7.1/zh-CN/base/primitives/tabs) | 一个 tab:`selected(...)` 进,`on_click(...)` 出 | | [`Progress`](/versions/v0.7.1/zh-CN/base/primitives/progress) | 只有报读,没有进度条;单独的 `Progress.new(...)` 什么都不画 | | [`ProgressTrack`](/versions/v0.7.1/zh-CN/base/primitives/progress) | 凹槽:一个由你设定尺寸与颜色的普通元素 | | [`ProgressIndicator`](/versions/v0.7.1/zh-CN/base/primitives/progress) | 已填充的部分;按你报读的百分比设置它的宽度 | | [`Avatar`](/versions/v0.7.1/zh-CN/base/primitives/avatar) | 渲染它的 `image` 槽;没有图片时渲染 `fallback`。它自己不画圆形、尺寸或背景 | | [`AvatarImage`](/versions/v0.7.1/zh-CN/base/primitives/avatar) | 图片槽:`AvatarImage.new(path)`,用在别处无效 | | [`AvatarFallback`](/versions/v0.7.1/zh-CN/base/primitives/avatar) | 兜底槽:一个普通盒子,放首字母、图形或 `svg` | | [`Pagination`](/versions/v0.7.1/zh-CN/base/primitives/pagination) | 一个 navigation landmark,带报读的标签;页码按钮由脚本自己画 | | `pagination_items(current, total, visible?)` | 该画哪些页码、省略号落在哪。`visible` 默认 7,最小 5;总页数 ≤ 1 时返回空 | | [`Accordion`](/versions/v0.7.1/zh-CN/base/primitives/accordion) | 一个 group,装 item | | [`AccordionItem`](/versions/v0.7.1/zh-CN/base/primitives/accordion) | 一个条目:`open(...)` 进,trigger 的 `on_change(...)` 出;它把自己的 `open` 传给下面两半 | | [`AccordionHeader`](/versions/v0.7.1/zh-CN/base/primitives/accordion) | 标题:`AccordionHeader.new(trigger)`,`aria_level(n)` 报读层级(默认 3) | | [`AccordionPanel`](/versions/v0.7.1/zh-CN/base/primitives/accordion) | 展开的区域。关闭时不在树里,除非 `keep_mounted(true)` | | [`AccordionTrigger`](/versions/v0.7.1/zh-CN/base/primitives/accordion) | 按钮:报读展开状态,`on_change` 请求相反的那个 | | [`CalendarState`](/versions/v0.7.1/zh-CN/base/primitives/calendar) | 留存的日历状态:月网格、当前月份、选中的日期 | | [`SliderState`](/versions/v0.7.1/zh-CN/base/primitives/slider) | 留存的 slider 状态,也是一次拖拽写入的地方 | | [`Slider`](/versions/v0.7.1/zh-CN/base/primitives/slider) | 根:报读数值,并拥有 release | | [`SliderTrack`](/versions/v0.7.1/zh-CN/base/primitives/slider) | 按下与拖拽的表面 | | [`SliderIndicator`](/versions/v0.7.1/zh-CN/base/primitives/slider) | 凹槽,也是每个指针位置据以测量的那个盒子 | | [`SliderThumb`](/versions/v0.7.1/zh-CN/base/primitives/slider) | 滑块;shell 给它位置,你给它外观 | slider 的四个部件接受同一个 `SliderState`,而且四个都不能少——没有 `SliderIndicator` 的 slider 根本拖不动。 ### 文本编辑 | 名称 | 说明 | | ------------------------------------------------------ | ----------------------------------------------------------- | | [`InputState`](/versions/v0.7.1/zh-CN/base/primitives/input) | 留存的文本状态:`InputState.new({ placeholder, value })` | | [`Input`](/versions/v0.7.1/zh-CN/base/primitives/input) | 包住留存文本状态的框 | | [`NumberInput`](/versions/v0.7.1/zh-CN/base/primitives/number-input) | 建立在同一个 `InputState` 上的 spinbutton,三个插槽都有分量 | | [`TextareaState`](/versions/v0.7.1/zh-CN/base/primitives/textarea) | 留存的多行文本状态;`rows` 是一个选项 | | [`Textarea`](/versions/v0.7.1/zh-CN/base/primitives/textarea) | 包住留存多行状态的框 | | [`OtpState`](/versions/v0.7.1/zh-CN/base/primitives/otp-input) | 留存的一次性验证码状态;长度在创建时固定 | | [`OtpInput`](/versions/v0.7.1/zh-CN/base/primitives/otp-input) | 定长验证码,格子由 shell 画、由脚本设定样式 | 没有专门的数字状态类型:给 `InputState` 设上 `set_step`、`set_min` 与 `set_max`,它就成了数字状态。 ### 容器与浮层 | 名称 | 说明 | | ----------------------------------------------------- | ----------------------------------------------------------------- | | [`Collapsible`](/versions/v0.7.1/zh-CN/base/primitives/collapsible) | 仅在 `open` 时渲染它的 `content` 插槽;不带 role、箭头或触发器 | | [`Popover`](/versions/v0.7.1/zh-CN/base/primitives/popover) | 锚定在触发元素上、由按下打开的浮层 | | [`HoverCard`](/versions/v0.7.1/zh-CN/base/primitives/hover-card) | 同上,但由指针停留打开,并有自己的打开状态 | | [`Popup`](/versions/v0.7.1/zh-CN/base/primitives/popup) | 光秃秃的锚定浮层:`Popup.new(id, trigger)`,填入 `content` 即打开 | | [`Select`](/versions/v0.7.1/zh-CN/base/primitives/select) | combobox 的根:role、报读的打开状态、键盘——但不含任何画面 | | [`Combobox`](/versions/v0.7.1/zh-CN/base/primitives/combobox) | 同一个根,被报读为一个触发器是可编辑输入框的 combobox | | [`DatePicker`](/versions/v0.7.1/zh-CN/base/primitives/date-picker) | 日期选择器的根:`DatePicker.new(id, focus_handle)`;它不持有日期 | 在这些之上动手之前,有两处缺口值得先知道:打开的 `Select` 或 `Combobox` 列表的方向键导航要你自己接(零件都在,见下),而 Enter 与 Escape 到不了 `DatePicker`。两者都写在各自类型的声明里,也就是它们真正咬人的地方。 ### 表格与列表 | 名称 | 说明 | | ------------------------------------------------------- | --------------------------------------------------------------------- | | [`Table`](/versions/v0.7.1/zh-CN/base/primitives/table) | 语义表格的根,组合方式与 HTML 组合表格一致 | | [`TableHeader`](/versions/v0.7.1/zh-CN/base/primitives/table) | 表头行组 | | [`TableBody`](/versions/v0.7.1/zh-CN/base/primitives/table) | 表体行组 | | [`TableRow`](/versions/v0.7.1/zh-CN/base/primitives/table) | 一行:`.new(id, row_index)`,从 1 开始 | | [`TableHead`](/versions/v0.7.1/zh-CN/base/primitives/table) | 一个列头:`.new(id, column_index)`,从 1 开始 | | [`TableCell`](/versions/v0.7.1/zh-CN/base/primitives/table) | 一个数据单元格:`.new(id, column_index)`,从 1 开始 | | [`TableCaption`](/versions/v0.7.1/zh-CN/base/primitives/table) | caption 该在的视觉位置;它不带 caption role | | [`v_virtual_list(…)`](/versions/v0.7.1/zh-CN/base/virtual-list) | 只描述屏幕内内容的纵向列表 | | [`h_virtual_list(…)`](/versions/v0.7.1/zh-CN/base/virtual-list) | 另一个轴上的同一件事;`item_sizes` 是宽度 | | [`VirtualListScrollHandle`](/versions/v0.7.1/zh-CN/base/virtual-list) | 虚拟列表的滚动位置,跨帧保留 | | [`Scrollbar`](/versions/v0.7.1/zh-CN/base/primitives/scrollbar) | `new(id)`、`horizontal(id)`、`vertical(id)`——一条由你自己摆放的滚动条 | 两种虚拟列表都接受 `(id, item_count, item_sizes, get_key, render)`。`render(range, cx)` 是这套接口里唯一由 Host 在一帧*进行中*调用的回调,所以在它内部注册处理器、创建留存状态与调用 `cx.notify()` 都会被拒绝。 ### Dock | 名称 | 是什么 | | -------------------------------------- | ----------------------------------------------------------------- | | `DockArea.new(id, options?)` | 一个可停靠布局,retained;`options` 为 `{ version?: number }` | | `DockArea.register_panel(name, Class)` | 教会运行时用 `Class` 重建 `name` 这块面板;返回加了命名空间的名字 | | `dock_area(area)` | 画出它,并承载六个 chrome handler | | `dock_content()` | 一侧 dock 自己的面板,在你画的 chrome 里应该出现的位置 | area 上的方法是 `add_panel(view, options)`、`remove_panel(id)`、`panels()`、`dump()`、`load(state)`、`has_dock`、`is_dock_open`、`toggle_dock`、`remove_dock`、`dock_size`、`set_dock_size`、`set_dock_collapsible`、`is_locked`、`set_locked`、`is_zoomed`、`zoom_out`、`on("layout_changed", handler)` 与 `release()`。 **每一次编辑都在发起它的那次调用返回之后按调用顺序应用**——面板的主体来自 `cx.new(Class)`,那时它自己还在构造中——所以 `panels()` 与 `dump()` 读到的是本轮编辑之前的布局。见 [Dock 与面板](/versions/v0.7.1/zh-CN/shell/dock)。 ### 留存句柄 每一个都只创建一次——在 `init` 或事件处理器里,绝不在 `render` 里——并且每一个都有 `release(): boolean`,返回它当时是否还活着。释放之后再用会抛异常。 `on(...)` 是替换该事件的处理器,而不是再加一个,返回值表示之前是否已经有一个。 #### `InputState` 来自 `InputState.new(options?)`,其中 `options` 是 `{ placeholder?: string, value?: string }`。 | 方法 | 说明 | | -------------------------------------- | ---------------------------------------------------------------------------------- | | `value(): string` | 当前文本 | | `set_value(next: string): void` | 替换它 | | `on(event, handler): boolean` | `event` 为 `"change"`、`"submit"`、`"focus"` 或 `"blur"`;handler 收 `(event, cx)` | | `set_step(step: number \| null): void` | `NumberInput` 的步长,`null` 表示没有 | | `set_min(min: number \| null): void` | 数值下界,或 `null` | | `set_max(max: number \| null): void` | 数值上界,或 `null` | | `set_masked(masked: boolean): void` | 文本是否按密码绘制 | | `set_loading(loading: boolean): void` | 是否显示加载状态 | #### `TextareaState` 来自 `TextareaState.new(options?)`,其中 `options` 是 `{ placeholder?: string, value?: string, rows?: number }`。 | 方法 | 说明 | | --------------------------------------------------------- | ----------------------------------------------------------------------- | | `value(): string` | 当前文本 | | `set_value(next: string): void` | 替换它 | | `on(event, handler): boolean` | `"change"`、`"submit"`、`"focus"` 或 `"blur"`,handler 收 `(event, cx)` | | `set_rows(rows: number): void` | 可见行数 | | `set_auto_grow(min_rows: number, max_rows: number): void` | 在这两者之间随内容增高 | | `set_soft_wrap(wrap: boolean): void` | 长行是否折行 | #### `SliderState` 来自 `SliderState.new(options?)`,其中 `options` 是 `{ min?, max?, step?, scale?: "linear" | "logarithmic", value?: SliderValue }`。默认是 `0..100`、步长 `1`、从 `min` 起。`"logarithmic"` 需要 `min` 大于零。 | 方法 | 说明 | | ------------------------------------ | -------------------------------------------------------------------- | | `value(): SliderValue` | 当前值:一个数字,区间滑块则是 `[start, end]` | | `set_value(next: SliderValue): void` | 替换它 | | `min_value(): number` | 创建时的下界 | | `max_value(): number` | 上界 | | `step_value(): number` | 步长 | | `on(event, handler): boolean` | 拖动中的 `"change"` 或结束时的 `"release"`;handler 收 `(value, cx)` | #### `OtpState` 来自 `OtpState.new(length, options?)`,其中 `options` 是 `{ value?: string, masked?: boolean }`。长度在创建时就固定了。 | 方法 | 说明 | | ----------------------------------- | ------------------------------------------------------------------------------------------------- | | `value(): string` | 目前已输入的数字 | | `set_value(next: string): void` | 替换它们 | | `len(): number` | 它持有几位 | | `is_masked(): boolean` | 是否遮蔽绘制 | | `set_masked(masked: boolean): void` | 改变这一点 | | `focus(): void` | 把键盘移进去 | | `on(event, handler): boolean` | 每次编辑后的 `"change"`、填满时的 `"complete"`,或 `"focus"` / `"blur"`;handler 收 `(event, cx)` | #### `VirtualListScrollHandle` 来自 `VirtualListScrollHandle.new()`,用 `track_scroll(handle)` 交给列表。 | 方法 | 说明 | | ------------------------------------------------ | -------------------------------------------------------------------------- | | `scroll_to_item(index: number, strategy?): void` | 在下一帧之前把某一项带到屏幕上;`strategy` 是 `"top"`(默认)或 `"center"` | | `scroll_to_bottom(): void` | 滚到末尾 | ### 日历 `CalendarState` 存在的理由是 `month_days()`——哪些日期落在哪一周、相邻月份的日子补在哪里、这个月需要几行。格子由脚本自己画。 ```js const grid = this.calendar.month_days()[0]; v_flex().children( grid.map((week) => h_flex().children( week.map((day) => Button.new(day) .selected(day === this.calendar.value()) .on_click((_, cx) => { this.calendar.set_value(day); cx.notify(); }) .child(String(Number(day.slice(8)))), ), ), ), ); ``` base 的 `Calendar` 元素**没有**绑定,这是个决定而不是遗漏:它遍历同一份网格,每个格子调用一次渲染回调——一帧最多四十二次跨语言调用,而且发生在 GPUI 的 layout 过程里,为的是一批本身不带任何行为的格子。在这里读到网格自己画,是同样的活,少了那四十二次穿越。 日期一律是 `"YYYY-MM-DD"`:按文本排序即是按时间排序,`new Date(s)` 能直接读——需要星期名或本地化月份名时用它。 | 方法 | 说明 | | ------------------------------- | ------------------------------------------------------------ | | `month_days()` | 网格:按月分组的“周”,每周固定七天,首尾两周带相邻月份的日子 | | `year()` / `month()` | 网格对应的年份与月份(1–12) | | `today()` | 状态创建时读到的今天 | | `value()` / `set_value(next)` | 选中的日期:一天、`[start, end]` 区间,或 `null` | | `next_month()` / `prev_month()` | 把网格前后移一个月;在 `render` 中不合法 | | `on("change", handler)` | 唯一的事件,报告一个日期被选中 | ### 主题 | 名称 | 说明 | | --------------------- | ------------------------------------------------------------------- | | `set_theme(theme)` | 用应用自己的主题替换 `gpui-base` 当前生效的语义 token | | `ColorToken` | 已安装调色板定义的语义颜色名称 | | `Theme` | `cx.theme()` 返回的东西:语义 token,加上 `appearance` 与 `is_dark` | | `SemanticThemeTokens` | `colors`、`spacing`、`radius` | | `ColorTokens` | 每个语义角色一个 `Color` | | `SpacingTokens` | `xxs` `xs` `sm` `md` `lg` `xl` `xxl` | | `RadiusTokens` | `none` `sm` `md` `lg` `xl` `full` | 读主题用 `cx.theme()`。`set_theme` 留在 `gpui-base`,因为主题属于这一层;但修改仍然要求当前存在一次 Host 调用,只能从事件处理器或 task 调用,不能在 `render` 或 layout 中调用。 ### 其他类型 | 名称 | 说明 | | ----------------------- | -------------------------------------------------------------------------------------------- | | `ScrollbarMode` | `"scrolling"`、`"hover"` 或 `"always"` | | `ItemRange` | 虚拟列表的可见项,写作半开区间 `[start, end)` | | `SliderValue` | 一个数字,或区间 slider 的 `[start, end]` | | `InputEvent` | 文本状态的事件 payload;submit 事件带可选的 `secondary` 与 `shift` 标志 | | `OtpEvent` | 当前为空的 OTP 事件 payload;值从 `OtpState` 读取 | | `PartType` | `gpui-base` 中没有自身身份的子部件共同使用的 `new()` 形态 | | `Placement` | `"top"`、`"bottom"`、`"left"` 或 `"right"`,镜像 `gpui_kit::base::Placement` | | `ComponentType` | `gpui-base` 中带身份的组件构造器共同使用的 `new(id)` 形态 | | `DockPlacement` | `"center"`、`"left"`、`"right"` 或 `"bottom"` | | `DockPanel` | `panels()` 报告的一块面板:`id`、`name`、`placement`、`node`、`index`、`active` 与三个标志位 | | `DockGroup` / `DockTab` | 一个标签组与它的一个标签页,也就是 `tab_bar` 与 `empty_group` 拿到的东西 | | `DockRegion` | 一侧 dock,也就是 `dock` handler 拿到的东西 | | `DockDrop` | 被拖动的面板会落在哪里 | ### 组合模式 其中五个组件不是一个元素,而是一种搭法,上面的表格说不出这件事。下面每段都是能跑起来的最小写法,并且都经过运行时校验。 **受控控件。** `Checkbox`、`Switch`、`Radio` 与 `Toggle` 自身不持有状态:值由你读进去、再写回来。它们什么都不画,所以勾选标记是一个子元素。 ```js Checkbox.new("done") .checked(this.checked) .on_change((checked, cx) => { this.checked = checked; cx.notify(); }) .child(this.checked ? "done" : "not done"); ``` **`Progress` 负责报读,进度条是你的。** root 带的是 role 和屏幕阅读器要念的 `0..=100`,它自己什么都不画。 ```js Progress.new("upload") .value(62) .child( ProgressTrack.new() .w(200) .h(6) .bg(cx.theme().colors.muted) .child(ProgressIndicator.new().w(124).h(6).bg(cx.theme().colors.primary)), ); ``` **滑块是四个部件,四个都不能少**——没有 `SliderIndicator` 的滑块根本拖不动,因为每一个指针位置都是相对它的盒子测量的。四个部件收的是同一份状态。 ```js Slider.new(this.volume).child( SliderTrack.new(this.volume) .w(200) .h(16) .child(SliderIndicator.new(this.volume).h(4).bg(cx.theme().colors.primary)) .child( SliderThumb.new(this.volume).w(12).h(12).bg(cx.theme().colors.background), ), ); ``` **`Select` 管键盘,`Popup` 管那块面。** root 持有 combobox 的语义与展开状态;列表是放在它里面的一个 `Popup`。它需要两个 focus handle——一个给触发元素,一个给内容——没有第一个,屏幕上就没有任何东西持有键盘。 ```js Select.new("mode") .accessibility_label("Mode") .open(this.open) .track_focus(this.trigger) .content_focus_handle(this.list) .on_open_change((open, cx) => { this.open = open; cx.notify(); }) .child( Popup.new("mode-list", trigger) .anchor("bottom_left") .when(this.open, (el) => el.content(list)), ); ``` 展开后用方向键移动高亮这件事要你自己写:base 期待里面的东西用自己的按键绑定来跑高亮,而它不会替你跑。零件都在——把 `on_key_down` 放在键盘被移过去的那个 content 元素上,自己移动高亮;或者在自己的 `key_context` 下把 ↑ / ↓ 绑到 action。开箱状态是:指针可用,Escape 关闭,Enter 与 ↓ 展开,高亮不动。 **虚拟列表和它的滚动条按名字配对。** 列表自己不画滚动条,而且配对在运行前不做任何校验,所以两半都要写。 ```js v_flex() .relative() .h(200) .child( v_virtual_list( "rows", rows.length, 28, (index) => rows[index].id, (range) => rows.slice(range.start, range.end).map((row) => div().child(row.name)), ).size_full(), ) .child(Scrollbar.vertical("rows").absolute().inset_0()); ``` **嵌套 View 创建一次,然后作为子元素挂上。** `cx.new` 属于 `init` 或事件处理器;实体在任何接受子元素的位置都能当子元素。 ```js init(props, cx) { this.chart = cx.new(PriceChart, { symbol }); } render() { return v_flex().child(this.chart); } ``` ## `gpui-fps` 模块 | 名称 | 说明 | | --------------- | --------------------------------------------------- | | `fps_monitor()` | 原生 `gpui-fps` HUD,每个窗口共享一个,固定在右上角 | 它的父元素必须设置 `relative()`。HUD 自己拥有完整外观;普通样式与子元素对它不起作用。 ## 元素方法 所有元素共享同一个 prototype,所以下面每个方法在任何元素上都能通过类型检查——某个方法实际适合哪个组件,类型并不表达。交给一个不承接它的组件的行为 builder 会被写进日志,而不是被悄悄丢掉。 元素 builder 方法都返回同一个元素,所以一条链就是一个表达式。`map` 是例外:与 GPUI 的 `FluentBuilder.map` 一样,它原样返回回调的结果。元素被用作子元素时即被消费,并且属于构建它的那一趟渲染。 ### 组合 | 方法 | 作用 | | ------------------------- | ---------------------------------------------------------------------------- | | `map(transform)` | 把当前元素交给 `transform`,并返回其结果;对应 GPUI 的 fluent builder helper | | `child(value)` | 添加一个子元素:元素、`Entity`,或字符串、数字、布尔值 | | `children(iterable)` | 按顺序添加多个 | | `when(condition, branch)` | `condition` 为真时应用 `branch`,让链保持完整 | | `id(name)` | 给这个元素一个稳定的名字,作为它的身份 | ### 插槽 插槽不是子元素:元素被组件消费,渲染在组件决定的位置。 | 方法 | 作用 | | --------------------------- | ------------------------------------------------------------------ | | `content(element)` | `Collapsible`、`Popover`、`HoverCard` 或 `Popup` 的内容 | | `image(element)` | `Avatar` 的图片槽,接受一个 `AvatarImage` | | `fallback(element)` | `Avatar` 的兜底槽,接受一个 `AvatarFallback` | | `header(element)` | `AccordionItem` 的 header 槽,接受一个 `AccordionHeader` | | `panel(element)` | `AccordionItem` 的 panel 槽,接受一个 `AccordionPanel` | | `trigger(element)` | `Popover` 或 `HoverCard` 的触发器 | | `input(element)` | `NumberInput` 的编辑器插槽;留空则画出裸编辑器 | | `decrement_button(element)` | `NumberInput` 减少按钮的外观——重放到 base 的按钮上,而不是直接渲染 | | `increment_button(element)` | 增加按钮,重放方式相同 | | `controls_right()` | 把两个步进按钮叠放在文本右侧 | ### 事件 | 方法 | 交付什么 | | -------------------------------- | --------------------------------------------------------------------------- | | `on_click(handler)` | 激活时的 `(ClickEvent, cx)` | | `on_mouse_move(handler)` | 指针悬停在元素上时的 `(MouseMoveEvent, cx)` | | `on_hover(handler)` | 指针进入与离开时的 `(hovered, cx)` | | `on_key_down(handler)` | 该元素持有键盘时按下按键的 `(KeyEvent, cx)` | | `on_key_up(handler)` | 同一条焦点路径上松开按键的 `(KeyEvent, cx)` | | `on_mouse_down(button, handler)` | 按下该按钮时的 `(MouseButtonEvent, cx)` | | `on_mouse_up(button, handler)` | 松开时的 `(MouseButtonEvent, cx)` | | `on_mouse_down_out(handler)` | 在该元素之外任意位置按下时的 `(MouseButtonEvent, cx)` | | `on_scroll_wheel(handler)` | 滚轮或触控板滚动时的 `(ScrollWheelEvent, cx)` | | `on_action(action, handler)` | 该命名 action 被派发到此元素或其内部时的 `(ActionEvent, cx)` | | `on_change(handler)` | 开关变化时的 `(checked, cx)`;新值由脚本保存 | | `on_step(handler)` | `("increment" \| "decrement", cx)`,并且它会**取代**内置的步进 | | `on_item_click(handler)` | 虚拟列表某一行被点击时的 `(key, cx)`,按 key 而不是按下标 | | `on_open_change(handler)` | 脚本之外的东西改变了 `Popover` 的打开状态时的 `(open, cx)` | | `on_confirm(handler)` | 在打开的 `Select` 或 `Combobox` 中按下回车;无参数 | | `on_dismiss(handler)` | 在打开的 `Select` 或 `Combobox` 中按下 Escape,早于 `on_open_change(false)` | | `on_resize(handler)` | 可调整组的拖拽结束后的 `(sizes, cx)` | ### Actions 与键绑定 一个 action 是比按键高一层的东西。`cx.bind_keys` 说哪个组合键在什么上下文里意味着 `"save"`,元素上的 `on_action("save", ...)` 说 `"save"` 做什么;菜单项或工具栏按钮用 `window.dispatch_action("save")` 派发同一个名字,就能走到同一个处理器,而两边都不必知道对方存在。 ```js init(_props, cx) { cx.bind_keys([{ keystroke: "cmd-s", action: "save", context: "Editor" }]); } render(_cx) { return div() .key_context("Editor") .track_focus(this.handle) .on_action("save", (event, cx) => this.save(cx)); } ``` `context` 是一个匹配元素 `key_context(...)` 的谓词表达式,所以同一个组合键可以在列表里是一个意思、在编辑器里是另一个意思。同一个元素上注册多个 `on_action` 是可以的,彼此独立;一个它们都没认领的 action 会继续往外层元素传。 上面这组——`on_key_down`、`on_key_up`、四个指针事件、`on_action` 与 `key_context`——接线在 `div`、`h_flex`、`v_flex`、`Button`、`Link`、`Checkbox`、`Switch`、`Radio`、`Toggle`、`Tabs` 与 `Tab` 上。写在其余组件上的处理器会被记录下来但永远到不了 GPUI,日志里会说明——把它包一层,写在外层元素上。 接线了不等于收得到。按键沿焦点路径传递,所以一个不接受脚本焦点句柄的组件——比如 `Tab`——听得到按下、永远听不到按键,无论两者接线得多好。 ### 控件状态 | 方法 | 设置什么 | | ---------------------- | -------------------------------------------------------------------------------------------------- | | `disabled(value)` | 阻止激活并报告该状态;外观自己画 | | `selected(value)` | `Button` 的 selected 状态 | | `checked(value)` | `Checkbox`、`Switch` 或 `Radio` 的受控值 | | `pressed(value)` | `Toggle` 的受控状态 | | `value(percent)` | 报读的进度百分比,钳制在 `0..=100`;它不会让屏幕上任何东西移动 | | `indeterminate(value)` | 把 `Progress` 的数值从无障碍树里撤下 | | `open(value)` | `Collapsible` 是否渲染内容,或浮层是否正在显示 | | `default_open(value)` | 非受控的 `Popover` 是否以打开状态开始 | | `keep_mounted(value)` | 关闭的 `AccordionPanel` 是否留在树里。默认关;开启后它的内容能跨越一次关闭保住滚动位置或半填的输入 | | `start(value)` | `SliderThumb` 是区间 slider 的哪一个滑块 | | `href(url)` | `Link` 的绝对 HTTP(S) 目标 | ### 无障碍 | 方法 | 报读什么 | | ------------------------------ | -------------------------------------------------------------- | | `accessibility_label(text)` | 屏幕阅读器读出的内容;纯图标控件没有它就什么都不会被读出 | | `role(name)` | 这个元素把自己报读成什么——仅限朴素元素、`Button` 与 `Checkbox` | | `aria_selected(value)` | 脚本自己搭的列表里某一项的选中状态 | | `aria_active_descendant()` | 在祖先持有键盘时,把本元素报读为当前焦点项 | | `set_position(position, size)` | 从 1 开始的位置与总数——“第 2 个 tab,共 5 个” | | `row_count(count)` | `Table` 的总行数,包含未渲染的行 | | `column_count(count)` | `Table` 的总列数 | | `aria_level(level)` | `AccordionHeader` 报读的标题层级,默认 3;只报读,不改字号 | | `axis(value)` | `RadioGroup` 或 `ToggleGroup` 的方向;只有语义,不做任何布局 | | `tooltip(text)` | 只对指针有效的悬停说明,不能替代 `accessibility_label` | ### 焦点与键盘 | 方法 | 作用 | | ------------------------------ | -------------------------------------------------------- | | `track_focus(handle)` | 让这个元素成为该 handle 所指的对象 | | `content_focus_handle(handle)` | `Select` 或 `Combobox` 打开时把键盘移到哪里 | | `tab_index(index)` | 这个元素在 Tab 顺序中的位置;同时也把它变成一个 tab stop | | `tab_stop(value)` | Tab 能否落到这里,不改变它在顺序中的位置 | ### 滚动与面板 | 方法 | 作用 | | --------------------------------------------------- | ---------------------------------------------- | | `overflow_scroll()` | 接管双轴的滚轮与触控滚动 | | `overflow_x_scroll()` / `overflow_y_scroll()` | 单轴上的同一件事 | | `overflow_scrollbar()` | 双轴滚动并绘制基础层的滚动条 | | `overflow_x_scrollbar()` / `overflow_y_scrollbar()` | 单轴上的同一件事 | | `mode(value)` | `Scrollbar` 的显示策略;不写则跟随主题 | | `scroll_size(width, height)` | `Scrollbar` 据以计算滑块的内容尺寸 | | `viewport_from_layout()` | 让 `Scrollbar` 从自身的盒子取 viewport | | `track_scroll(handle)` | 给虚拟列表一个脚本可以驱动的滚动位置 | | `with_item_to_measure_index(index)` | 虚拟列表在它滚动的那个轴上测量哪一项 | | `size_range(min, max?)` | `resizable_panel()` 可被拖拽的范围,单位为像素 | ### 锚定浮层 | 方法 | 设置什么 | | ------------------------- | ----------------------------------------------- | | `anchor(value)` | 哪个角固定在触发元素上;无论怎样都会被钳进窗口 | | `mouse_button(value)` | 哪个指针按键打开 `Popover` | | `open_delay(ms)` | 指针要在 `HoverCard` 触发器上停留多久;默认 600 | | `close_delay(ms)` | `HoverCard` 关闭前等待多久;默认 300 | | `overlay_closable(value)` | 在打开的 `Popover` 之外按下是否将其关闭 | ### Dock 命令 dock 的 chrome 画出来的元素*做什么*。缓存的 chrome 描述没有脚本事件处理器的生命周期,所以其中不能注册事件处理器——取而代之的是不携带任何脚本值的命令,由 base 完成实际动作。每一个的第一个参数都是它所在 handler 拿到的那个对象;它们只能挂在 `div`、`h_flex` 或 `v_flex` 上。 | 方法 | 触发 | 作用 | | ------------------------------ | ---- | --------------------------------------------- | | `select_tab(group, index)` | 点击 | 显示那个标签页 | | `close_panel(group, panel_id)` | 点击 | 关闭该面板(如果它所在的 group 允许) | | `toggle_zoom(group)` | 点击 | 放大 group,或还原 | | `drag_tab(group, index)` | 拖动 | 让该元素成为这个标签页的拖动源 | | `drop_tab(group, index?)` | 放下 | 在此接收被拖来的面板;不给 index 就追加到末尾 | | `toggle_dock(dock)` | 点击 | 展开或收起这侧 dock | | `resize_dock(dock)` | 拖动 | 拖动 dock 的边;每个位置都由 base 钳制 | ### Dock chrome 四个 handler,全都可选,且只能挂在 `dock_area(...)` 上。每一个都会先在 GPUI 的 layout pass 内部被调用,拿到的是 base 已经解析好的状态;描述会缓存到该状态或 handler 改变为止。 | 方法 | 画什么 | | ------------------------------ | ---------------------------------------------------- | | `tab_bar(handler)` | 一个 group 当前显示面板上方的标签栏 | | `empty_group(handler)` | 没有可显示面板的 group 显示什么 | | `drop_indicator(handler)` | 被拖动的面板会落在哪里 | | `dock(handler)` | 一侧 dock 包住内容的外框;把 `dock_content()` 放进去 | ### 动效 | 方法 | 作用 | | ------------------------------ | ---------------------------------------------- | | `transition(property, policy)` | 完全在原生 GPUI 代码里,对之后的目标变化做动画 | | `spring(property, policy?)` | 改用弹簧 | property 取 `"opacity"`、`"width"`、`"height"`、`"left"`、`"top"` 之一,每一帧都不会进入 JavaScript。 ### 样式模板 每一个都接受一个函数,函数收到一个游离的元素用来收集样式;返回值会被忽略,所以写成一条链或写成块状函数体都可以。 | 方法 | 作用于什么 | | ---------------------------- | -------------------------------------------------------------- | | `hover(declare)` | 指针悬停在元素上时 | | `active(declare)` | 元素被按下时 | | `focus(declare)` | 元素持有焦点时 | | `range_style(declare)` | `SliderIndicator` 已填充的部分——只管它长什么样,从不管它在哪里 | | `cell_style(declare)` | `OtpInput` 的每个格子;没有它屏幕上什么都没有 | | `cell_active_style(declare)` | 叠在上面一层,用于下一个数字将落入的那个格子 | | `caret_style(declare)` | 那个格子为空时,里面闪烁的光标 | ### 样式方法 元素上其余的一切都是样式。它们分成两族,而且从不重叠: - **带参数的方法**,手工绑定:size、padding、margin、position、flex、border、radius 与 paint 各族。每个方法接受哪种长度类型跟随它的 Rust 签名,所以 `.p("auto")` 是类型错误,理由与它在运行时抛异常完全相同。 - **无参方法**,从 GPUI 的反射表生成:`flex_col`、`items_center`、`gap_2`、`rounded_md`、`text_sm`、`size_full`、`truncate` 以及这一族的其余成员。生成的声明就是当前构建所用 GPUI 版本的完整清单。 两者都记录在 [Styling](/versions/v0.7.1/zh-CN/shell/styling) 里,还有长度与颜色的语法,以及调色板定义的 token。 ## HostModule Host 在 Rust 侧注册的模块,按名字 import,和其它模块没有区别: ```js import { quotes } from "market"; ``` 它不属于任何内建模块。生成的类型声明里每个注册过的模块各有一段 `declare module`,所以模块名和每一个导出名都会被检查。见 [HostModule](/versions/v0.7.1/zh-CN/shell/host-module)。 --- # Dock and Panels Source: /versions/v0.7.1/zh-CN/shell/dock 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/shell/dock 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 # Dock 与面板 只能铺满整个窗口的 View 算不上一个应用。**dock area** 把脚本 View 变成*面板*:可拖动、可停靠、可放大,重启之后仍然停在用户上次放的位置。 ```js import { View, div } from "gpui-kit"; import { DockArea, dock_area, v_flex } from "gpui-base"; class Notes extends View { render() { return div().p(16).child("Notes"); } } export default class Workspace extends View { init(_props, cx) { DockArea.register_panel("notes", Notes); this.dock = DockArea.new("workspace"); this.dock.add_panel(cx.new(Notes), { name: "notes", placement: "left", size: 240, }); } render() { return dock_area(this.dock).size_full(); } } ``` 这样就已经能停靠、拖动、调整大小、放大与持久化了。它不会画出标签栏,因为 **base 完全不画 chrome**——见[绘制 chrome](#绘制-chrome)。 ## base 给了什么,没给什么 `gpui_kit::base::dock` 已经把停靠系统里难做的那一半做好了:一棵**纯数据**的布局树、一个能按持久化文件里的名字重建面板的 `PanelRegistry`,以及跟着每块面板走的一份 payload。容器用稳定的 node id 寻址,面板用稳定的 panel id 寻址,因此一次拖动改的是一个值,而不是拆掉再重建一堆 View。 它没有的是外观。引擎什么都不画——没有标签栏、没有 dock 外框、没有拖拽条、没有落点提示——这些全都作为「返回元素的回调」交还给你。这不是需要绕开的限制,而正是这套东西能被脚本用起来的原因:外观不是覆盖在某个默认外观之上的一层,因为根本没有默认外观。 ## area 是 retained 的 `DockArea.new(id)` 创建的是跨帧存活的状态,和 `InputState` 一样;而且理由是其他 handle 都没有的一条:**布局是用户改的**。拖动、调整大小、关掉一个标签页、折叠一侧 dock,全都发生在脚本没有渲染的时候。一个从描述里重建出来的 dock,会把这些统统还原成上一次渲染所描述的样子。 所以它在 `init` 里创建一次,`render` 只负责*画*: ```js init() { this.dock = DockArea.new("workspace", { version: 1 }); } render() { return dock_area(this.dock).size_full(); } ``` `DockArea.new` 需要一次活的 Host 调用,所以它属于 `init` 或事件处理器——绝不能放在 `render` 里。每个会改动布局的方法同样如此;从 `render` 里调用会在写下它的那一行被拒绝,而不是产出一帧「画的是一套布局、描述的是另一套」的画面。 ## 编辑在调用返回时生效 面板的主体来自 `cx.new(Class)`——你把它交出去的那一刻,它自己都还在构造中;`load` 还会再构造更多面板。这些都不可能在脚本正在运行时发生。所以**每一次编辑都会排队,等到发起它的那次调用返回之后再按调用顺序应用**。 实际影响只有一句话:`panels()` 和 `dump()` 读到的是本轮编辑*之前*的布局。 ```js init(_props, cx) { this.dock = DockArea.new("workspace"); this.dock.add_panel(cx.new(Notes), { name: "notes" }); this.dock.panels(); // 还是空的——这次 add 尚未应用 this.dock.on("layout_changed", (cx) => { this.dock.panels(); // 三块面板、一个 dock 尺寸、一次标签页移动 cx.notify(); }); } ``` `layout_changed` 会在每次编辑时触发,所以要用定时器落盘,而不是在事件里直接写。 ## 面板 面板就是恰好被 dock 拿在手里的一个 View。`add_panel` 接收这个 View 并说明它去哪里: ```js this.dock.add_panel(cx.new(Editor, { file }), { name: "editor", // 必填——保存布局时用它归档 placement: "center", // "center" | "left" | "right" | "bottom" size: 240, // 当这块面板是该 dock 里的第一块时,用它作为初始尺寸 closable: true, zoomable: true, visible: true, }); ``` `name` 必填,因为它不是装饰:保存布局写的是它,`register_panel` 也靠它找回类。命名空间由运行时加好——`shell:/`——所以两个都把面板叫 `inbox` 的应用永远不会撞车,脚本面板也不可能盖掉 Host 面板。 `panels()` 报告现在有什么,以及在哪里: ```js this.dock.panels(); // [{ id, name, placement, node, index, active, visible, closable, zoomable }, …] ``` `id` 就是 `remove_panel(id)` 要的那个,也是关闭按钮交给 `close_panel` 的那个。 ## 熬过一次重启 两半,缺一不可。 **注册类**,让保存的布局能重建它: ```js DockArea.register_panel("editor", Editor); ``` **保存并恢复布局**,它就是纯数据: ```js init(_props, cx) { DockArea.register_panel("editor", Editor); this.dock = DockArea.new("workspace", { version: 1 }); const saved = localStorage.getItem("layout"); if (saved) this.dock.load(JSON.parse(saved)); else this.dock.add_panel(cx.new(Editor), { name: "editor" }); this.dock.on("layout_changed", () => localStorage.setItem("layout", JSON.stringify(this.dock.dump()))); } ``` 面板自己的状态会跟着位置一起走。 View 类上有两个可选方法负责这件事: | 方法 | 何时调用 | 说明 | | ------------------- | --------------- | -------------------------------------------------------------------------- | | `serialize()` | 保存布局时 | 运行时**没有 Host 调用**:返回纯数据,别碰别的——不要碰 entity,不要碰 `cx` | | `deserialize(data)` | View 刚重建之后 | 有一次真正的 Host 调用,因此可以碰 entity | `version` 由你在保存格式变化时递增;base 会拒绝加载在别的 version 下写出的布局,于是旧文件是被忽略,而不是被一知半解地读进来。 ### 卸载了的应用仍然保留位置 这是最值得围绕着设计的一条性质。 如果某块面板的名字下没有注册任何东西——应用被卸载了,或者类被改名了——这块面板**不会被丢掉**。一个什么都不画的占位面板会顶上,并原样报告它拿到的状态,于是下一次保存会把这块面板的名字、payload 和位置原封不动写回去。卸载一个应用,把窗口用上一周,再装回来:它的面板会回到原来的位置,带着原来的状态。 再往里一步也是同样的承诺:一块*已经*注册、但类在构造时抛异常的面板,会以同样的方式被带下去——一个写坏的脚本,代价是这一次会话里看不到那块面板的内容,而不是丢掉它在布局里的位置。 ## 绘制 chrome 四个 handler,全都可选,挂在 `dock_area(...)` 元素上: | Handler | 画什么 | | -------------------------------- | ----------------------------------- | | `tab_bar(group => …)` | 一个 group 当前显示面板上方的标签栏 | | `empty_group(group => …)` | 没有可显示面板的 group 显示什么 | | `drop_indicator(drop => …)` | 被拖动的面板会落在哪里 | | `dock(dock => …)` | 一侧 dock 包住内容的外框 | 每一个都会先在 GPUI 的 layout pass 内部被调用,拿到的是 base **已经解析好的**状态——从不包含拖拽事件、鼠标位置或命中测试,因为 base 会把这些自己挂到拿回去的元素上。生成的描述按 handler 与解析后的状态缓存;未变化的帧只在 Rust 中重放,不会进入 JavaScript。 ```js dock_area(this.dock) .size_full() .tab_bar((group, cx) => h_flex() .h(30) .bg(cx.theme().colors.secondary) .children( group.tabs .filter((tab) => tab.visible) .map((tab) => h_flex() .id("tab-" + tab.id) .px(10) .items_center() .bg( tab.active ? cx.theme().colors.background : cx.theme().colors.secondary, ) .select_tab(group, tab.index) .drag_tab(group, tab.index) .child(tab.name) .child( div() .id("x-" + tab.id) .close_panel(group, tab.id) .child("×"), ), ), ), ); ``` ### 命令,不是回调 再看一眼上面那个标签页:它带的是 `select_tab` 和 `drag_tab`,不是 `on_click`。这是这套 API 里唯一一条值得理解、而不是死记的规则。 chrome 描述会被缓存,并且可以比生成它的 handler 调用活得更久。因此,在其中注册的脚本回调没有可靠的事件生命周期,而且每次原生状态变化都可能再创建一个。这样的注册会在写下它的那一行被拒绝;chrome 改用原生命令。 **命令**完全不携带脚本值。它只是指名 area 里的某个容器、以及要请它做什么,剩下的由 base 完成: | 命令 | 触发 | 作用 | | ------------------------------ | ---- | --------------------------------------------- | | `select_tab(group, index)` | 点击 | 显示那个标签页 | | `close_panel(group, panel_id)` | 点击 | 关闭该面板(如果它所在的 group 允许) | | `toggle_zoom(group)` | 点击 | 放大 group,或还原 | | `drag_tab(group, index)` | 拖动 | 让该元素成为这个标签页的拖动源 | | `drop_tab(group, index?)` | 放下 | 在此接收被拖来的面板;不给 index 就追加到末尾 | | `toggle_dock(dock)` | 点击 | 展开或收起这侧 dock | | `resize_dock(dock)` | 拖动 | 拖动 dock 的边 | 每一个的第一个参数都是它所在 handler 拿到的那个对象。它们只能挂在 `div`、`h_flex` 或 `v_flex` 上:`Button` 自己构造内部结构,没有地方安放这些命令。 拖动产生的一切,base 都会在下一帧看到它之前钳制、吸附并取整,所以一个缩放把手只是一块命中区加一点颜色,仅此而已。 ### dock handler 自己安放内容 `dock` 是唯一一个除了状态之外还会拿到一个元素的 handler,而它返回什么就*替换*掉这侧 dock 的内容。把 `dock_content()` 放在面板该出现的位置: ```js .dock((dock, cx) => v_flex() .size_full() .relative() .child( h_flex() .h(30) .justify_between() .child(dock.placement.toUpperCase()) .child(div().id("collapse").toggle_dock(dock).child(dock.open ? "–" : "+")), ) .child(dock_content().flex_1()) .child(div().absolute().right(0).w(4).h_full().cursor_col_resize().resize_dock(dock)), ) ``` 忘了写 `dock_content()` 的 handler 仍然会显示它的面板——面板会画在它返回的内容之后,并带一条警告——而不是悄悄丢掉。 ## 完整接口 ```js area.add_panel(view, options); area.remove_panel(id); area.panels(); area.dump(); area.load(state); area.has_dock(placement); area.is_dock_open(placement); area.toggle_dock(placement); area.remove_dock(placement); area.dock_size(placement); area.set_dock_size(placement, size); area.set_dock_collapsible(placement, collapsible); area.is_locked(); area.set_locked(locked); area.is_zoomed(); area.zoom_out(); area.on("layout_changed", handler); area.release(); ``` 被锁定的 area 不能重新排列,也不能接受放入操作;dock 仍可调整大小。因此「锁定布局」固定的是面板所在位置,而不是面板的可用尺寸。 ## 一个完整的例子 ```bash cargo run -p gpui-shell -- examples/js_dock ``` `examples/js_dock/` 是一个工作区:左侧 dock 里是文件列表,中间是文档,标签栏与 dock 外框画在 `ui.js` 里,布局用定时器写进 `localStorage`。它是用上本页每一部分的最短的完整程序。 ## 从 Rust 使用 `gpui_kit::shell::dock` 是公开的,因此 Host 不写脚本也能接到同一处接缝。`ScriptPanel` 把 `ScriptView` 包成 `gpui_kit::base::dock::Panel`;`register_panel(application, panel, script, cx)` 教会注册表用一个 `PanelScript` 重建它;`ScriptDockSkin` 把 base 的两个 renderer trait 统一转发给一个 `DockChrome`。`tab_group_data`、`dock_data` 与 `drop_indicator_data` 是引擎交给脚本代码的那几个 JSON 转换,Host 自己写绑定时同样用得上。 --- # Hosting Source: /versions/v0.7.1/zh-CN/shell/hosting 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/shell/hosting 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 [Getting Started](/versions/v0.7.1/zh-CN/shell/getting-started) 给的是把脚本 View 放上屏幕的那四行。这一页是 Rust 接口的其余部分:该调什么、什么时候调,以及那两三处“看起来该调的那个其实是错的”。 ## 运行时 一个 `ShellRuntime` 拥有一个 VM。它是一个带内部可变性的 `Rc`——既不是 `Send` 也不是 `Sync`——所以它待在拥有 `App` 的那个线程上。 ```rust gpui_kit::shell::init(cx); // gpui-base、默认 token 调色板、样式表 let runtime = ShellRuntime::new(cx)?; // 一个 VM,并注册为当前 App 的默认 runtime ``` `new(cx)` 让回调、 HostModule 与 hot reload 不必由 Host 层层传递句柄,也能找到默认 runtime。明确管理多个 VM 的 Host 可以用 `new_isolated()` 创建其他 runtime,并自行保留这些句柄。 `gpui-shell` 通过 GPUI 的 inspector reflection table 暴露 fluent style 方法,release 构建也不例外。因此,依赖这个 crate 会为 Cargo 统一后的依赖图启用 `gpui-base/inspector` feature。这是 JavaScript 样式接口正常工作的必要条件;嵌入方 应把 release 构建中新增的检测代码与依赖计入构建成本。 ## 加载与实例化 普通应用窗口只需一次加载,并直接获得它的 `ShellRoot`: ```rust cx.open_window(options, move |window, cx| { let root = runtime.load(&app_root, window, cx); #[cfg(debug_assertions)] if let Ok(watch) = runtime.watch(&root, window, cx) { watch.forget(); } root })?; ``` 存在 `gpui-shell.json` 时,`load` 会验证其中的身份信息,并采用其 entry。capabilities 是能力请求,不等于 Host 已经批准;两条路径都按 Host 当前的默认 policy 运行,没有 manifest 时入口为 `main.js`。两条路径都会刷新 `gpui-kit.d.ts`;加载失败会渲染可选择文字的错误界面,而不是让 Host panic。需要自行处理结构化错误的 Host 使用 `try_load`。失败状态的 root 没有可供监听的应用,因此 `watch` 会返回 `Err`;这里忽略这个错误,才能保留可选择的失败界面。 下面的低层方法只供需要把脚本 View 装进既有 Rust 组合的 Host 使用。 加载把源码变成一个**View 类型**——脚本 default 导出的那个类。实例化把这个类型变成一个**View 对象**,也就是一个活的实例: ```rust let view_type = runtime.load_app(&root, "main.js")?; // 一个目录 let view_type = runtime.load_source("inline", source)?; // 一个字符串,测试用 let object = runtime.instantiate(&view_type, window, cx)?; ``` `load_app` 会解析目录、读取入口文件、求值该模块。这里的每一种失败都是一个带着脚本自身调用栈的 `ShellError`——语法错误、解析到应用根目录之外的 import、缺失或形态不对的 default 导出。 实例化会执行脚本的 `init`,因此它需要一个活的 `Window`:`init` 里可能会创建 `InputState` 这类留存状态。 ## 挂载 脚本 View 和别的 GPUI View 没有两样,它挂在**一个 `ShellRoot` 之下**: ```rust cx.open_window(options, move |window, cx| { let object = runtime.instantiate(&view_type, window, cx).expect("view"); let content = cx.new(|_| ScriptView::new(runtime.clone(), object)); cx.new(|cx| ShellRoot::new(content.into(), window, cx)) }) ``` `ShellRoot` 持有 dialog 栈、sheet、toast 栈、焦点恢复与 Tab 导航——正是 `Root` 对一个 `gpui-component` 窗口所起的作用。`window.open_dialog` 这一类调用要经由它找到根 View,所以挂在别的根 View 之下的脚本会拿到一条讲清原因的拒绝,而不是悄无声息地没反应。 Host 也可以直接驱动同样这几个界面,插件面板与 Host 自己的 UI 因此落在同一个栈里: ```rust root.update(cx, |root, cx| { root.open_dialog(view.into(), window, cx); root.push_toast(ToastRequest::new("Saved").with_level(ToastLevel::Success), window, cx); root.close_all_dialogs(window, cx); }); ``` ## Host 状态变了,怎么刷新 View 这是最容易调错的一个,而且调错了不会报错。 ```text cx.notify() ── 把这个 View 再画一遍 (不跑脚本) view.refresh(cx) ── 而且它的描述已经过期了 (脚本会跑) ``` 因为脚本的一次 `render` [不等于一帧渲染](/versions/v0.7.1/zh-CN/shell/state#render-什么时候执行),光调 `cx.notify()` 重绘的是已经存在的那份 Snapshot。如果 Host 改动的是脚本**会读到**的东西——某个 HostModule 背后的实体、一项设置、一份文档——就必须告诉 View:描述本身已经过期了。 ```rust runtime.refresh(&root, cx)?; ``` runtime 会先确认 `root` 装载的是它自己的应用,再让脚本 View 失效并安排重绘。 Host 不需要拿到具体的 `ScriptView`,也不会因为手工 downcast 或混用另一个 runtime 的 View 而刷新错误对象。 反过来调错则立刻看得见——界面就是不更新——这与 GPUI 里忘了调 `cx.notify()` 是同一种失败方式。 ## 脚本能碰到什么 三项 Host 设置的生命周期不同。Capabilities 会在每个新 View 加载时冻结;store handle 与 HostModule registry 则是该 View 共享的实时 Host 配置,替换后会在下一次调用生效: ```rust gpui_kit::shell::set_capabilities( Capabilities::new() .read_roots([app_root.clone()]) .write_roots([data_dir.clone()]) .store(true), ); gpui_kit::shell::set_store_path(data_dir.join("store.json")); gpui_kit::shell::export_module(market_module(&market))?; ``` 三项的默认都是“什么都没有”:没有文件访问、没有存储位置、没有 HostModule 。见 [Capabilities](/versions/v0.7.1/zh-CN/shell/capabilities) 与 [HostModule](/versions/v0.7.1/zh-CN/shell/host-module)。 独立二进制还会检查 `/gpui-shell.json`。其中已识别的字段提供应用身份、可选的应用/Shell 版本元数据、entry 与 capability 请求;只有 `id`、`name` 和 `entry` 必填。Embedder 若要让每个加载的应用拥有不同 grant 与 HostModule registry,也可以直接构造 `Policy`。 ## 观察它花了多少 运行时把两件事分开计数,而这两个数之间的差就是重点: ```rust let reading = runtime.read_metrics(); reading.script_renders(); // 跟着 cx.notify()、重载、主题变化走 reading.materializations(); // 跟着帧走 reading.script_render_time(); // 脚本 render 里的总耗时 reading.native_time(); // 其中花在 HostModule 里的部分 reading.slowest_script_render(); reading.structure_repeat_rate(); // 一次重建产出的结构,与它替换掉的那份是否相同 ``` `RuntimeMetrics::since(&earlier)` 给出两次读数之间的差值,每秒速率就是这么算的。这里没有重置:计数器属于运行时,把它们清零会把正在读它们的其他人一起挪动。要量某一段,就自己留一个基线再相减——Shell story 每次切换 feed 都会取一次基线,所以它的读数回答的是“这个 feed 要花多少”,而不是“这个窗口从打开到现在干了多少”。 回归测试可以直接对 `script_renders` 做断言;[基准测试里的第三个数](/versions/v0.7.1/zh-CN/shell/engine#那次实测)靠的正是这一点。 `structure_repeats()` 与 `structure_changes()` 回答的是另一个问题:在那些有上一份描述可比的重建里,有多少次产出的**结构**完全相同——相同的组件、相同的 builder 方法、相同的树,只有其中的取值变了。运行时不会因为这个答案而少做任何事;它存在,是为了给[Snapshot 缓存止步于哪里](/versions/v0.7.1/zh-CN/shell/performance#snapshot-缓存止步于哪里)量个尺寸。 View 的第一次构建没有前一份可比,两个计数都不计它。 ## 开发构建的配置 Host 的 debug 构建,**单次脚本渲染大约比 release 慢三倍**,而差距全部来自两个依赖。 在一个实时应用上实测——一个每笔行情都重渲染的行情终端,用运行时自带的 [`RuntimeMetrics`](#观察它花了多少): | `[profile.dev.package]` | 平均脚本渲染 | 平均物化 | | -------------------------------- | ------------ | ---------- | | 不配,或只写 `rquickjs` | 31.5 ms | 3.9 ms | | `rquickjs-sys` + `rquickjs-core` | **11.3 ms** | **1.2 ms** | | release(对照) | 11.0 ms | 1.2 ms | 所以: ```toml [profile.dev.package] rquickjs-sys = { opt-level = 3 } rquickjs-core = { opt-level = 3 } ``` **只写 `rquickjs` 没有任何作用**,这正是坑所在:它是一个薄门面,把 `rquickjs-core` 重新导出而已,写它既没优化到解释器,也没优化到绑定。`rquickjs-sys` 编译的是 QuickJS 本体——C 源码,经 `cc` 构建,而 `cc` 读的是**那个包**在 profile 里的优化级别; `rquickjs-core` 则是每一个跨界值的转换所在。没优化的解释器,正是让 debug 构建 用起来像另一个产品的原因。 `llrt_*` 那批**不需要**这么做。同一个应用上实测,它们带来的差异在噪声范围内: `fs`、`net`、`crypto` 之类根本不在渲染路径上,优化它们换不来脚本作者能感知的东西。 这些设置只在**构建出二进制的那个 workspace 根**生效。库无法替依赖它的应用设定 profile, 所以 `gpui-shell` 没办法替你配好——每个 Host 都得自己写一遍。 ## 退出请求 脚本里的 `process.exit(code)` 是**一个请求,绝不是 `exit(2)`**。一个插件不能把 Host 进程带走,而 Host 可能还有未保存的状态。运行时把这个请求交给 Host,由 Host 决定怎么办: ```rust gpui_kit::shell::on_exit_request(|request, window, cx| { match request.view() { Some(view) => close_the_panel_showing(view, window, cx), None => cx.quit(), } }); ``` `request.code()` 是脚本要求的退出码,`request.view()` 在有的情况下会指出请求来自哪个 View——插件 Host 关掉的应该是**那个**插件的面板,若换成关窗口,就等于让一个插件终结了别人的工作。 **授权了 exit 却没装处理器的 Host,会在调用现场被告知**,而不是永远不知道:`process.exit()` 会抛出异常,并点名 `on_exit_request`。一个没人回应的请求,是朝着讨好方向说的谎——脚本拿到了成功,而什么都没发生。 ## Hot-reload 一个调用就能开起来,`--watch` 用的也是这一个: ```rust runtime.watch(&root, window, cx)?.forget(); ``` `runtime.watch` 从已加载的 root 读取解析后的目录与 manifest entry,不再让 Host 维护第二份可能漂移的元数据。它不暗藏构建模式策略:CLI 在解析到 `--watch` 后启用监听,嵌入式 Host 则可以把调用放进 `#[cfg(debug_assertions)]`。返回的 `Watcher` 持有这次监听:把它 drop 掉,循环就停;`.forget()` 则让它跟随 View 继续运行。 View、运行时或窗口任意一个消失时,循环也会自己结束。 一次重载会重新读取**每一个**模块,入口也在内——一个悄悄用了旧 import 的 hot-reload 比没有更糟,因为它看起来是成功的。它会先把所有可能失败的活干完,再去碰活着的那个 View:新代码加载失败时,上一个 View 继续运行,错误进 `tracing`,窗口里由一条固定 id 的 toast 报出来;下一次成功的重载会撤掉这条 toast。 View 本身能挺过重载。`ScriptView::replace_object` 只换掉脚本产出的那部分,实体保留下来,随之保留的还有窗口、焦点与元素身份。 插件 unload 是比移除单个 view 更强的生命周期边界:manager 会在丢弃插件前取消所有携带该插件 `Policy` 的 outstanding task,包括没有 owner 的工作。任何 continuation 都不能继续保留或使用已卸载插件的权限。 ## 脚本出错的时候 抛异常的脚本不会把界面一起带走。最后一份可用的 Snapshot 仍然挂在那里,失败信息报在它上面,读者的滚动位置、焦点、正在读的内容都还在。在有什么让 View 失效之前,运行时不会重跑那个失败的 `render`。 记得装一个 `tracing` subscriber。运行时通过 `tracing` 报告脚本错误、未处理的 promise rejection 与非法 phase 调用,target 是 `gpui_kit::shell::script`;没有 subscriber 的话这些全部被丢弃,症状就是一个悄悄不再响应的 View。 ## 还没有的东西 - **给卡住的脚本做监管。** 解释器自己的中断会切断一次调用,但没有东西会去重启一个反复撞上中断的运行时。 --- # Overlays Source: /versions/v0.7.1/zh-CN/shell/overlays 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/shell/overlays 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Dialog、sheet 与 toast 是**Host**能力,通过全局的 `window` 访问。它们不是脚本画出来的东西。 Dialog 不是一个浮动的 `div`。它是窗口层叠顺序中的一个位置、一个焦点陷阱、一个 Escape 目标,以及一个关于“按下遮罩意味着什么”的承诺——而这些都必须由窗口的根 View 决定,因为只有能同时看到所有浮层的东西才能给它们排序。脚本自己画的 dialog 一样都拥有不了;两个脚本各画一个 dialog,拥有得更少。 所以脚本说的是**放什么**到用户面前,根 View 说的是它放在哪里、以及怎么离开。跨越这条边界的东西很少:一个返回元素的函数、一个贴靠的边、一句要显示的话。 这些 API 放在 `window` 而不是 `cx` 上,是因为 dialog 属于窗口,不属于打开它的 view:`cx.notify()` 重新渲染一个 view,`window.open_dialog()` 则改变窗口当前显示的内容。`gpui-component` 也采用相同的职责划分,因此 Rust 与 JavaScript 的 API 保持一致。以后若要暴露焦点、尺寸或窗口外观等能力,也可以继续放在 `window` 上。 ## 接口 `window` 是**全局的**。不需要 import——而且和 `cx` 不同:`cx` 是每次 Host 调用作为参数交给你的,`window` 则是本来就在作用域里。 回调参数如果叫 `window`,会遮蔽这个全局——这是普通的作用域规则,不是错误;而且将来即使某个回调真的传入一个 `window`,那也是同一个对象,因为 `window` 是 ambient 的:它读的是当前正在跑的那次调用。这也正是它今天不是参数的原因。Rust 里它必须是参数,因为 Rust 没有可读的 ambient 状态;这里可以读,`fs` 和 `store` 不是参数也是同一个道理。 **不要照抄 Rust 的 `|event, window, cx|`** 脚本的处理函数签名是 `(event, cx)`。写成三个参数会把 `window` 绑到 context 上,而 `cx` 是 undefined,报错读起来是 `close_dialog is not a function`。加上 `// @ts-check`,生成的声明会在你写下那一行就报出来。 ```js const depth = window.open_dialog(() => confirmClear(count), { escape_dismissable: false, backdrop_dismissable: false, }); window.close_dialog(); // -> 有没有关掉东西? window.close_all_dialogs(); // -> 关掉了几个 window.has_active_dialog(); window.open_sheet(() => filters()); // 默认贴右边 window.open_sheet_at("left", () => nav()); window.close_sheet(); // -> 有没有关掉东西? window.has_active_sheet(); window.push_toast({ title: "Saved", description: "3 files", level: "success", timeout: 4000, id: "save" }); window.remove_toast("save"); window.clear_toasts(); ``` ## Dialog `window.open_dialog(content, options?)` 接受的是**一个返回元素的函数**,不是元素: ```text expected a function returning an element; open_dialog and open_sheet take a function, not an element and not a view class ``` 理由是生命周期,不是口味。元素属于创建它的那次 render pass 的 arena,而 dialog 活得比打开它的那次调用更久——在 open 时建出来的元素会属于错的那一趟。这个函数在 dialog 绘制时运行,此后每次重绘再运行一次,和 `render` 的契约完全一样。 **它闭包捕获的东西就是 dialog 的状态。** 没有 `props`:dialog 拿到要显示的内容的方式,和脚本里其他任何值一样——它就在作用域里。 ```js // confirm.js import { v_flex, h_flex } from "gpui-base"; export default (count, onConfirm) => () => v_flex() .w(360) .p(24) .gap(12) .child(`Delete ${count} completed items?`) .child("This cannot be undone.") .child( h_flex() .justify_end() .gap(8) .child(cancelButton(() => window.close_dialog())) .child(deleteButton((_event, cx) => { onConfirm(cx); window.close_dialog(); })), ); ``` ```js // main.js window.open_dialog(confirmClear(this.completed, (cx) => this.deleteCompleted(cx))); ``` 注意根 View 提供了什么、又没有提供什么。它提供遮罩、位置、层叠、焦点陷阱,以及承载内容的表面;宽度、内边距、边框、文字与按钮和这个运行时里的其他一切一样,都是脚本的。 | 选项 | 默认 | 作用 | | --- | --- | --- | | `escape_dismissable` | `true` | Escape 是否关闭它 | | `backdrop_dismissable` | `true` | 按下遮罩是否关闭它 | 未知选项会被拒绝而不是被忽略,这正是重点: ```text unknown option `escapeDismissable` for window.open_dialog(content, options); expected escape_dismissable or backdrop_dismissable ``` 一个被悄悄忽略的 `escapeDismissable` 看起来像是生效了,而那个 dialog 照样可以被 Escape 关掉。 `open_dialog` 返回的是**栈的新深度**,不是句柄。根 View 按位置而不是按身份寻址 dialog,所以句柄就必须承诺“关掉**这个** dialog”,而那不是一个存在的操作。深度才是脚本用得上的东西——用来断言确实打开了一个,或者退回到某个已知层级。`close_dialog` 返回它有没有找到可关的;`close_all_dialogs` 返回关掉了几个。 **不要把 `cx` 带进 dialog** 打开 dialog 的那个回调里的 `cx` 属于那个回调。等到 dialog 自己的按钮被按下时,它已经过期,使用它会报出 stale context 错误。请闭包捕获**数据**,并从 dialog 自身回调的参数里取 `cx`——上面的例子给 `onConfirm` 传的是一个 `cx`,而不是捕获一个,正是这个原因。 overlay 调用本身没有这个隐患:它们是 ambient 的,和 `fs`、`store` 一样,没有句柄可以留到调用之后。 ## Sheet ```js window.open_sheet(() => filtersPanel(filters)); window.open_sheet_at("left", () => navigation()); ``` 同时最多打开一个 sheet。`window.open_sheet` 贴靠右边;`window.open_sheet_at` 接受 `"left"`、`"right"`、`"top"` 或 `"bottom"`。它没有任何选项,因为总共只有一个,并且在没有 dialog 压在上面时由 Escape 或它的遮罩关闭。 ```text unknown sheet placement `middle`; expected left, right, top or bottom ``` ## Toast Toast 是唯一**是数据而不是 View**的浮层——没有类、没有实例,也没有什么要脚本去渲染——所以它的全部内容以一个选项对象的形式跨越边界。 | 字段 | 默认 | 含义 | | --- | --- | --- | | `title` | 必填 | 用户读到的那句话 | | `description` | — | 第二行 | | `level` | `info` | `info`、`success`、`warning` 或 `error` | | `timeout` | 5 秒 | 毫秒数,或 `null` 表示一直留到被关闭 | | `id` | 自动生成 | 身份,用于替换与关闭 | 省略 `timeout` 使用默认值,显式写 `null` 让 toast 常驻,所以这两者不能合并成一个选项。 `id` 是把“反复失败”变成“一条长期存在的信息”而不是一堆通知的关键。`--watch` 的循环用的正是这个:一次失败的重载会用固定 id 发一条常驻的错误 toast,于是把一份坏文件存五次是替换而不是叠出五条;下一次成功重载再用 `remove_toast` 撤回它。 ```text unknown toast level `fatal`; expected info, success, warning or error ``` 同时挂载三条 toast。更早的留在管理器里,随着较新的离开再出现,所以一次爆发是被节流而不是被丢弃。 ## 窗口本身 同一个 `window` 全局也回答窗口自身的问题,而不只是它上面浮着什么。 ```js render(cx) { const { width, height } = window.viewport_size(); return v_flex() .when(width < 600, (el) => el.flex_col()) .text_size(window.rem_size() * 0.875); } ``` **度量在 `render` 中是合法的**,而且这正是它们的用处:一个要按窗口尺寸决定自身布局的 View,只能在绘制它的那一趟里问。 | 成员 | 说明 | | --- | --- | | `rem_size()` / `line_height()` | 窗口的排版度量,单位是像素 | | `viewport_size()` | 可绘制区域 | | `bounds()` | 窗口在屏幕上的位置与大小;比 viewport 大出标题栏那部分 | | `mouse_position()` | 指针位置,窗口坐标 | | `appearance()` | `"light"` 或 `"dark"` | | `is_window_active()` / `is_fullscreen()` / `is_maximized()` | 平台窗口的状态 | **改变窗口的调用在 `render` 中会被拒绝**,理由和 `cx.notify()` 一样:一帧去改自己正在绘制的窗口,就是这一帧在和自己较劲。 | 成员 | 说明 | | --- | --- | | `set_rem_size(size)` | 重新缩放所有以 rem 表达的尺寸 | | `refresh()` | 重绘窗口里的每一个 View | | `focus_next()` / `focus_prev()` | 把键盘移到相邻的一个 tab stop | | `dispatch_action(action)` | 沿本窗口的焦点路径派发一个 action | | `activate_window()` / `minimize_window()` / `zoom_window()` / `toggle_fullscreen()` | 平台窗口控制 | `zoom_window()` 是平台自己的“缩放”,不是缩放系数——要改的是后者的话,用 `set_rem_size`。 ## 层叠与关闭 从后往前绘制: 1. **内容**——脚本的根 View。 2. **Sheet**——最多一个,贴靠某条边。Sheet 是窗口里的一个*位置*,所以它位于 dialog 栈之下:从 sheet 里唤起的 dialog 必须可读,而在 dialog 之下唤起的 sheet 不能盖住它。 3. **Dialog 栈**——按打开顺序,最早的在最下面。 4. **Toast**——在所有东西之上。Toast 报告的是用户刚做的那个操作的结果,而“正开着一个 dialog”恰恰是这个结果最重要的时刻,所以它是唯一永不被遮挡的一层。 只有最上层的 dialog 画遮罩:三层 dialog 让窗口变暗一次而不是三次,而那唯一一层遮罩正是把活跃的 dialog 与它背后失效的那些区分开的东西。 关闭永远是**一层,绝不连锁**: - **Escape** 只关闭最上层的 dialog。下层 dialog 渲染时禁用键盘处理,所以连按 Escape 会一层一层退栈,并且在还有 dialog 打开时永远不会波及 sheet。 - `escape_dismissable: false` 撤掉的是**按键绑定**,不是底层的取消动作。脚本放在 dialog 里的关闭控件照样有效——这正是“不可关闭的 dialog”意味着用户必须回答它,而不是被困在里面。 - **按下遮罩**关闭最上层的 dialog,且仅当它是以 `backdrop_dismissable` 打开的。 - **回车在这一层什么都不做。** Base 的 dialog host 把回车视为“确认并关闭”;那属于 dialog 自己的主按钮,而主按钮归脚本所有,所以根 View 否决了内建的确认行为,而不是去猜哪份内容需要它。 - **Sheet** 只在没有 dialog 打开时由 Escape 或它的遮罩关闭,因为压在上面的 dialog 持有焦点。 - `close_all_dialogs` 是唯一会整体退栈的操作,而且它不动 sheet。 **焦点**沿着栈自身的历史恢复。打开浮层时记录当前焦点并把焦点交给浮层,关闭时恢复。关掉第二个 dialog 会把焦点还给第一个,关掉第一个则还给二者打开之前窗口所在的位置。Tab 与 Shift-Tab 遵守焦点陷阱,所以在浮层里按 Tab 是在浮层内循环,而不是走进它背后的内容。 ## `ScopePhase` 规则 **浮层只能从事件回调或任务中打开与关闭。** ```text window.open_dialog(content, options) is not allowed during the `render` phase; overlays may only be opened or closed while handling an event or a task ``` 打开或关闭浮层会修改窗口,而 `render` phase 正在读它。GPUI 的借用模型无法表达“脚本在这里可以 notify、在那里不行”,所以运行时显式携带 [`ScopePhase`](/versions/v0.7.1/zh-CN/shell/state#scopephase),每一个浮层入口都拒绝 `render`、`layout`,以及根本不在任何 Host 调用中的情形——最后这种情况下也没有窗口可以触达。 拒绝信息会写明它是从哪个 phase 发出的,因为那是作者唯一的线索。 ## 浮层需要 `ShellRoot` 上述每一个调用最终都会到达窗口的根 View。第一层 View 不是 `ShellRoot` 的窗口会拒绝它们,并指明这是哪一类错误——Host 接线问题,不是脚本问题: ```text window.open_dialog(content, options) needs a ShellRoot as the window's first view; this window was opened with another view ``` 见 [Getting Started](/versions/v0.7.1/zh-CN/shell/getting-started#把运行时接进-rust-应用)。 ## 还没有的东西 - **Dialog 的返回值。** `open_dialog` 返回的是深度,不是一个在 dialog 关闭时 settle 的 promise。请像上面的例子那样闭包捕获一个回调,或者让 dialog 写回打开方会读取的状态。 - **Tooltip 与右键菜单。** Popover 和 HoverCard 已可作为锚定浮层使用;专用的 tooltip 与右键菜单 API 尚未公开。 - **定位选项。** Dialog 居中,sheet 贴边,两者都不能指定位置。 --- # Elements Source: /versions/v0.7.1/zh-CN/shell/elements 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/shell/elements 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `gpui-shell` 里的元素是一段**描述**,不是一个对象。它只在一次渲染中存在,被使用时即被消费。本页讲能构建什么、怎么组合,以及一段描述被用了两次时运行时会做什么。 ## 构造器 每个模块只装它自己那个包提供的东西: ```js import { div, svg, image } from "gpui-kit"; import { h_flex, v_flex, Button, Link, Checkbox, Switch, Input, InputState, } from "gpui-base"; import { fps_monitor } from "gpui-fps"; ``` 函数是小写的,组件类型首字母大写并通过 `.new` 构造。这与 Rust 侧一一对应:那边 `div()` 同样是自由函数,`Button::new(id)` 同样是类型上的关联函数。 | 构造器 | 来自 | 产出 | | ------------------ | ----------- | --------------------------------------------------------------- | | `div()` | `gpui-kit` | 自身不带布局的元素 | | `value` | `gpui-kit` | 文本元素,参数会被转成字符串 | | `svg(path)` | `gpui-kit` | 来自应用自身目录、跟随主题着色的矢量图标 | | `image(path)` | `gpui-kit` | 来自应用自身目录的全彩图片 | | `h_flex()` | `gpui-base` | 一行 | | `v_flex()` | `gpui-base` | 一列 | | `Button.new(id)` | `gpui-base` | base 的 `Button`:激活、焦点、disabled 与 selected 状态,无样式 | | `Link.new(id)` | `gpui-base` | 可聚焦的外部 HTTP(S) 链接;用 `.href(url)` 设置目标 | | `Checkbox.new(id)` | `gpui-base` | base 的受控 checkbox,无样式也无勾选标记 | | `Switch.new(id)` | `gpui-base` | base 的受控 switch,无样式 | | `Input.new(state)` | `gpui-base` | 由 [`InputState`](/versions/v0.7.1/zh-CN/shell/state#留存状态) 支撑的文本框 | | `fps_monitor()` | `gpui-fps` | 原生 `gpui-fps` 性能 HUD,每个窗口共享一个 monitor | 这是入门够用的一组,不是全部。base 绑定的组件——`Select`、`Combobox`、`Tabs`、`Table`、`VirtualList`、`Slider`、`Popover`、`Avatar`、`Accordion`、`Pagination`、`CalendarState` 等等——完整清单在 [API 参考](/versions/v0.7.1/zh-CN/shell/api#gpui-base-模块)里。 ### 性能监视器 `fps_monitor()` 直接公开原生 `gpui-fps` HUD,不会把采样或绘制搬进 JavaScript。monitor 在首次使用时创建,并按窗口复用。一个窗口最多渲染一次,并把它放在设置了 `relative()` 的父元素中: ```js div().relative().size_full().child(content).child(fps_monitor()); ``` 默认固定在右上角。可以沿用已有的 anchor 取值调整位置,例如 `fps_monitor().anchor("bottom_left")`。HUD 自己拥有完整外观,普通元素样式、children 和交互状态不会作用于它。 ### 为什么是 `.new(id)` 而不是 `new Button(id)` JavaScript 的习惯写法是 `new Button(id)`。运行时不提供它,理由正是本页的主题:`new` 承诺的是一个有身份的对象——可以保存、可以挂在实例上、可以再次使用。而描述恰恰不是这种东西。`Button.new(id)` 读起来是“构造一段描述”,它做的也正是这件事,并且与 Rust 侧一字不差。 View 是相反的情形,用的就是标准写法:`class Counter extends View`。 View 确实有身份、有跨帧状态,并且由 GPUI 拥有。同一份文件里出现两种构造形态,是因为这两类东西的生命周期本来就不同。 ### id `Button`、`Link`、`Checkbox` 与 `Switch` 的 `id` 用于跨渲染标识元素,GPUI 据此保留焦点与元素状态。请保持它稳定,并在兄弟节点之间唯一——用 `` `item-${item.id}` ``,而不是一个会在列表被筛选时移位的数组下标。 其余元素——`div`、`h_flex`——的身份是**它在这次渲染所构建的树里所处的位置**。只要树的形状不变,这就够用;而一旦上方多出一个条件子节点,它下面的每个元素都会移位,按下状态、焦点以及其他按身份记录的东西都跟着移位。 `.id(name)` 用来说明“这是哪个元素”,而不是“它落在了哪里”: ```js div() .id("toolbar") .active((el) => el.opacity(0.7)); ``` 凡是身份必须扛得住邻居变化的元素,都给它取个名字。`Button`、`Link`、`Checkbox` 与 `Switch` 已经从 `new(id)` 拿到了身份,会忽略这里的名字——并且是给出警告,而不是默不作声。 ### 文本 **字符串本身就是元素。** GPUI 为 `&str`、`String` 与 `SharedString` 实现了 `IntoElement`,所以文本的写法就是把字符串交给承载它的元素,没有 `text()` 可调: ```js v_flex().child(`${this.remaining} of ${this.items.length} remaining`).child(42); ``` 样式由承载它的元素带,和 Rust 那边完全一致: ```js div().text_size(12).font_semibold().child("AAPL"); ``` 字符串子元素最终变成一个包含它的 `div`——这正是 `div().child(s)` 已经说明的事。 ### 图片 ```js svg("icons/check.svg").w(14).h(14).flex_none(); image("images/brand.png").w(120).h(40); ``` 两种路径都相对于**应用根目录**——也就是交给 `gpui-shell` 的那个目录——而不是相对于调用构造器的文件。这个不对称常常让人意外,所以值得直说:`import "./ui.js"` 相对于发起 import 的文件解析,和所有 JavaScript 模块系统一样;而 `svg("icons/check.svg")` 与 `image("images/brand.png")` 相对于应用根目录解析,和 Web 应用的 public 目录一样。运行时无法知道是哪个模块调用了 asset 构造器,因此按文件解析的资源路径对它并不可得。 越出应用目录的路径会被拒绝。缺失的文件会按路径去重报告一次,并附上查找位置,而不是安静地什么都不画。 单个 asset 最多 16 MiB。列举 asset tree 时最多接受 10,000 个 entry 与累计 1 MiB 的 UTF-8 文件名,避免 asset discovery 无界增长内存。 单色图标应使用 `svg()`:它会继承周围的文字颜色,所以深色按钮里的图标不用脚本说第二遍就是浅色的。Logo、照片或插画等需要保留源文件颜色的内容应使用 `image()`。 ```js renderIcon(cx) { return div() .bg(cx.theme().colors.foreground) .text_color(cx.theme().colors.surface) .child(svg("icons/check.svg").w(11).h(11)); // 以 surface 绘制 } ``` ## 组合 | 方法 | 作用 | | -------------------------- | ------------------------------------ | | `.child(element)` | 添加一个子元素,该子元素随即被消费 | | `.children(iterable)` | 按顺序添加多个 | | `.when(condition, branch)` | 仅当 `condition` 为真时应用 `branch` | ```js v_flex() .gap(8) .child(this.header()) .children(this.visible().map((item) => this.row(item))) .when(this.items.length === 0, (el) => el.child("Nothing yet")); ``` `.when` 的存在是为了不让一个条件把链断成两截。`branch` **必须返回该元素**——不返回的分支会立刻抛异常,而不是悄悄丢掉它构建的一切: ```text when(...) must return the element ``` 这与 GPUI 自己的 `FluentBuilder`,以及本仓库 Rust 侧“元素构建保持一条流式链”的风格规则同源。 如果条件是在两个元素之间二选一,普通三元表达式比 `when` 更清楚: ```js .child( visible.length === 0 ? emptyState("No items yet", "Type above and press Add.") : v_flex().children(visible.map((item) => this.row(item))), ) ``` ## 行为方法 这些不是样式。它们把状态报告给基础层,由基础层处理交互,外观仍然交给你。 | 方法 | 用于 | 作用 | | --------------------------------- | ----------------------------------- | -------------------------------------------------- | | `.on_click(handler)` | `Button` | `handler(event, cx)`,点击**以及**键盘激活都会触发 | | `.on_change(handler)` | `Checkbox`、`Switch` | `handler(checked, cx)`,由脚本保存新值 | | `.disabled(value)` | `Button`、`Checkbox`、`Switch` | 阻止激活并报告该状态 | | `.selected(value)` | `Button` | 报告 selected 状态 | | `.checked(value)` | `Checkbox`、`Switch` | 受控值 | | `.accessibility_label(text)` | `Button`、`Checkbox` | 屏幕阅读器读出的内容 | | `.tooltip(text)` | `div`、`h_flex`、`v_flex`、`Button` | 指针停留后显示的说明文字 | | `.id(name)` | `div`、`h_flex`、`v_flex` | 一个稳定的身份,取代“在树中的位置” | | `.overflow_scrollbar()` | `div`、`h_flex`、`v_flex` | 双轴滚动并绘制原生 scrollbar | | `.overflow_x_scrollbar()` | `div`、`h_flex`、`v_flex` | 水平滚动并绘制原生 scrollbar | | `.overflow_y_scrollbar()` | `div`、`h_flex`、`v_flex` | 垂直滚动并绘制原生 scrollbar | | `.on_key_down(handler)` | [可接输入的元素](#哪些元素接了输入) | 该元素持有键盘时的 `handler(event, cx)` | | `.on_key_up(handler)` | [可接输入的元素](#哪些元素接了输入) | 松开时同上 | | `.on_mouse_down(button, handler)` | [可接输入的元素](#哪些元素接了输入) | 按下 `"left"`、`"right"` 或 `"middle"` | | `.on_mouse_up(button, handler)` | [可接输入的元素](#哪些元素接了输入) | 松开 | | `.on_mouse_down_out(handler)` | [可接输入的元素](#哪些元素接了输入) | 在该元素**之外**任意位置按下 | | `.on_scroll_wheel(handler)` | [可接输入的元素](#哪些元素接了输入) | 滚轮与触控板滚动 | | `.on_action(action, handler)` | [可接输入的元素](#哪些元素接了输入) | 命名 action 被派发到它或它内部 | | `.key_context(name)` | [可接输入的元素](#哪些元素接了输入) | 该元素与其子树所处的按键绑定上下文 | disabled、selected 与 checked 的**外观**由你来画。基础层只报告状态,脚本不说就什么都不会变: ```js Button.new("clear") .disabled(this.completed === 0) .when(this.completed === 0, (el) => el.opacity(0.4)) .child("Clear completed"); ``` `.accessibility_label` 对纯图标控件最重要——没有它,这类控件什么都不会被读出来: ```js Button.new(`remove-${item.id}`) .accessibility_label(`Remove “${item.caption}”`) .child(svg("icons/trash.svg").w(14).h(14)); ``` ### 受控值只报告意图 base 的 checkbox 不会自己改状态。它只报告用户的请求,由脚本决定: ```js Checkbox.new(`item-${item.id}`) .checked(item.done) // 值来自脚本状态 .on_change((done, cx) => { // 回调只是一个请求 this.toggle(item.id, done, cx); }) .child(indicator(item.done)) .child(label(item.caption)); ``` 运行时绝不会替脚本悄悄维护一个 checked 标志。如果它这么做,脚本作者与 Rust 作者会对同一个控件持有不同的心智模型,而这两类作者共存于同一个应用里。 ### 事件对象 `on_click` 的处理函数收到的是一个普通对象,字段名与 Rust 结构一致: ```js .on_click((event, cx) => { // event.click_count === 1 // event.modifiers === { shift, control, alt, platform } }); ``` `platform` 在 macOS 上是 Command,其他平台是 Windows 键。这里只暴露基础层已经归一化过的语义——Base 把“回车激活按钮”与“点击按钮”归为同一个回调,脚本看不到这个差别。 按键处理器拿到的组合键有两种形态。`keystroke` 是整串,拼法和写绑定时一致;`key` 与 `modifiers` 是同一个组合键拆开的样子,只关心其中一半时用它: ```js .on_key_down((event, cx) => { if (event.keystroke === "cmd-s") { this.save(); cx.stop_propagation(); } }); ``` **平台修饰键在所有平台上都拼作 `cmd`**,Linux 与 Windows 也一样。GPUI 会按编译目标平台来拼——`cmd-`、`super-`、`win-`——这对给人读的 keymap 是对的,对给程序比较的字符串是错的:同一份脚本要在三个平台上跑,`event.keystroke === "cmd-s"` 必须在三个平台上是同一件事。 指针处理器拿到按钮、当前连击次数以及落点。`local_position` 与 `bounds` 在元素第一次绘制之前是没有的: ```js .on_mouse_down("right", (event, cx) => { // event.button === "right" // event.click_count === 1 // event.local_position?.x —— 相对这个元素 this.openMenuAt(event.position, cx); }); ``` 滚动处理器拿到的一律是像素;设备按行上报时,原始行数也在: ```js .on_scroll_wheel((event, cx) => { this.offset += event.delta.y; // 一律是像素 // event.delta_lines?.y —— 只有设备按行上报时才有 cx.notify(); }); ``` ### 哪些元素接了输入 上面这八个方法是 GPUI 自己的 `InteractiveElement` 构建器,shell 把它们装在 `div`、`h_flex`、`v_flex`、`Button`、`Link`、`Checkbox`、`Switch`、`Radio`、`Toggle`、`Tabs` 与 `Tab` 上。 其余组件各自构建自己的 base 类型、挂自己的监听器,所以写在它们上面的处理器会被记进描述、但永远到不了 GPUI。日志里会说明,而不是留给你自己去发现: ```text `on_key_down` is not wired on a Select: the shell installs GPUI's input listeners on the element it owns outright, which is a plain `div`, `h_flex` or `v_flex`. Wrap it and write `on_key_down` on the wrapper ``` **接线了不等于收得到。** 按键沿焦点路径传递,指针沿 hitbox 传递,所以一个不接受焦点句柄的组件——`Tab` 就是——听得到按下、永远听不到按键,无论两者接线得多好。哪些组件接受焦点句柄,见[焦点与无障碍](#焦点与无障碍)。 ### Actions 与按键绑定 action 是比按键高一层的东西。`cx.bind_keys` 说哪个组合键在什么上下文里意味着 `"save"`;`on_action` 说 `"save"` 做什么。菜单项或工具栏按钮派发同一个名字就会走到同一个处理器,而两边都不必知道对方存在: ```js init(_props, cx) { cx.bind_keys([ { keystroke: "cmd-s", action: "save", context: "Editor" }, { keystroke: "ctrl-k ctrl-c", action: "comment", context: "Editor" }, ]); } render(_cx) { return div() .key_context("Editor") .track_focus(this.handle) .on_action("save", (_event, cx) => this.save(cx)) .child( Button.new("save") .on_click(() => window.dispatch_action("save")) .child("Save"), ); } ``` `context` 是一个匹配元素 `key_context(...)` 的谓词,所以同一个组合键可以在列表里是一个意思、在编辑器里是另一个意思。keymap 属于应用而不属于某个窗口,所以在一个 View 里绑的组合键,在它的谓词匹配的任何地方都生效。 同一个元素上注册多个 `on_action` 是可以的,彼此独立。一个它们都没认领的 action 会继续往外层传——这正是内层面板处理 Save、外层窗口处理 Quit 的做法。 整份绑定列表会在安装任何一条之前先校验完:因为第四条有拼写错误而只装了一半的 keymap,比一条都没装更糟,而脚本没有办法知道装进去的是哪一半。 **事件处理器请用箭头函数** 箭头函数不绑定自己的 `this`,所以处理函数里的 `this` 仍然是 View 实例。用 `function () {}` 写会拿到错误的 `this`。这是为本运行时写脚本时最常见的一处错误,人和模型都一样。 ## 焦点与无障碍 焦点目标由脚本自己持有。`cx.focus_handle()` 创建一个——对应 GPUI 的 `App::focus_handle`,那边并没有 `FocusHandle::new` 可供镜像——它像 [`InputState`](/versions/v0.7.1/zh-CN/shell/state#留存状态) 一样挂在 View 上,再用 `.track_focus(handle)` 交给某个元素: ```js init(props, cx) { this.search = cx.focus_handle(); } render() { return Button.new("search") .tab_index(1) .track_focus(this.search) .child("Search"); } ``` `cx.focus_handle()` 需要一次活的 Host 调用;而在 `render` 里创建的 handle 每一帧都是新的,它所跟踪的焦点会被下一次重绘丢掉。所以它属于 `init` 或事件处理器,在 `render` 里调用会抛错。 | handle 上的方法 | 回答什么 | | --------------------- | ---------------------------- | | `handle.focus()` | 把键盘移到跟踪它的那个元素上 | | `handle.is_focused()` | 那个元素此刻是否持有键盘 | | `handle.release()` | 释放这个 handle | `Tab` 与 `Shift-Tab` 由窗口根 View 处理:它按下表的顺序双向行走,并遵守已打开的 dialog 或 sheet 的 focus trap。 | 方法 | 作用于 | 效果 | | --------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | | `.track_focus(handle)` | `div`、`h_flex`、`v_flex`、`Button`、`Checkbox`、`Radio`、`Toggle` | 把元素绑定到脚本持有的 handle | | `.tab_index(n)` | 上述这些,外加 `Link`、`Switch` | 元素在窗口 Tab 顺序中的位置;同时也把它变成一个 tab stop | | `.tab_stop(value)` | 与 `tab_index` 相同 | Tab 是否能落到它上面。`false` 保留它在顺序中的位置但不可达 | | `.role(name)` | `div`、`h_flex`、`v_flex`、`Button`、`Checkbox` | 屏幕阅读器把这个元素读作什么 | | `.aria_selected(value)` | `div`、`h_flex`、`v_flex` | 脚本自己搭的列表里某一项的选中状态 | | `.aria_active_descendant()` | `div`、`h_flex`、`v_flex` | 在祖先持有键盘时,把本元素报读为当前焦点项——比如输入框保持焦点的 combobox 中被高亮的那一项 | 三张表的范围不同,是因为组件本身不同。`Button`、`Checkbox`、`Radio`、`Toggle` 的焦点 handle 由一个你可以替换的值构建;`Link` 与 `Switch` 自己构建 handle,且没有可替换它的 builder。除 `Button` 与 `Checkbox` 之外的每个组件都自带 role——`Tab` 就是 tab,`Radio` 就是 radio——只有这两个把 role 当作可覆盖项,这正是「让一个按钮被读作菜单项」得以成立的原因。组件无法承接的调用会**写进日志**,而不是被悄悄丢掉: ```text `role` is not wired on a Tab: base's Tab owns this part of its own focus and accessibility. Put it on an element around it ``` 朴素元素六个方法全都接受,脚本正是靠它们搭出 base 没有对应组件的 listbox、toolbar 或 dialog: ```js div() .id(`cadence-${index}`) .role("list_box_option") .aria_selected(index === this.chosen) .when(index === this.chosen, (el) => el.aria_active_descendant()) .child(name); ``` role 的取值逐字镜像 `gpui_kit::Role` 的 snake_case 拼写——`list_box`、`list_box_option`、`combo_box`、`menu_item`——整套取值以 `Role` 联合类型写在 `gpui-kit.d.ts` 里,编辑器能补全;不在其中的名字会在调用处失败: ```text unknown accessibility role `listbox`; the names mirror gpui_kit::Role in snake_case — see the Role type in gpui-kit.d.ts ``` ## 元素是一次性的 这条规则最容易让新读者意外,所以下面写清它长什么样、以及为什么成立。 ```js const row = h_flex().child("hello"); v_flex().child(row).child(row); // 抛异常 ``` ```text element `h_flex` was already added to a parent; elements are single-use values ``` 跨帧保存也是同样的失败: ```js init() { this.header = h_flex().child("Todo"); // 错误 } render() { return v_flex().child("Todo list").child(this.header); } ``` ```text this element belongs to a previous render pass; elements are single-use values and must be rebuilt each time render runs ``` 有一处毛刺值得知道:arena 每一趟都会清空并复用下标,所以一个过期元素偶尔会正好持有运行时刚分配给“它要挂上去的那个节点”的下标。误用仍然会被抓到,但信息变成 `an element cannot be added to itself`。两者含义相同——这个元素属于一趟已经结束的渲染。 ### 为什么 这条限制来自 GPUI 本身:`RenderOnce::render` **按值**取走 `self`,`.child()` 也按值取走子元素。Rust 里编译器用移动语义强制这一点:使用已移动的值是编译错误。JavaScript 既没有移动语义也没有编译器,于是运行时在运行期强制同一条规则——而描述 arena 本来就有做这件事所需的记录,因为节点被挂载的那一刻就会被标记为已有父节点。 另一种做法是在重复使用时复制描述。这一条被否决了:它会让同一段脚本在 Rust 与 JavaScript 里含义不同,而重复使用几乎总是错误而非本意。 ### 可行的写法 在 `render` 里构建,把重复部分抽成**每次返回新元素的函数**: ```js const label = (value, cx) => div().text_size(12).text_color(cx.theme().colors.foreground).child(value); render(cx) { return v_flex() .child(label("first", cx)) .child(label("second", cx)); } ``` [示例应用](https://github.com/longbridge/gpui-kit/tree/main/examples/js_todolist)就是这样写的:`ui.js` 把 `button`、`label`、`icon`、`checkbox` 等导出为函数,`main.js` 调用它们。读起来像一个组件库,而且不花什么代价——一次函数调用就是一段新描述的来源。 ## 回调属于它所在的那次渲染 传给 `.on_click` 的处理函数属于那次渲染产出的那份描述——而不是属于某一帧。那份描述会[被之后的每一帧复用,直到有东西让它失效](/versions/v0.7.1/zh-CN/shell/state#render-什么时候执行),处理函数在这期间一直可调用。描述里只记录一个 id;Rust 装配的闭包持有对运行时的弱引用加上这个 id。 被替换掉的那份描述会多保留一代,因为事件可能针对一个已经被取代的帧派发。再晚到达的事件会被丢弃并记一条 `debug` 日志,而不是报错——作者没有做错什么,也没有什么可修。 实际后果是:渲染期注册的回调不是订阅。需要活得比本次渲染更久的东西——比如响应输入框的 `change` 事件——见 [State and Views](/versions/v0.7.1/zh-CN/shell/state#输入事件)。 ## 未知方法是错误 既不是样式也不属于上面那批行为方法的调用,会在调用点失败;如果有相近的名字,会给出建议: ```text unknown element method `items_centre` (did you mean `items_center`?) ``` ```text unknown element method `on_clicked`; it is neither a style method nor one of child, children, when, on_click, on_change, disabled, selected, checked, id ``` 这件事比看上去重要。拼错的样式名不会改变画面——它只是没起作用——没有诊断的话完全不可见。运行时如何在不给每次渲染加负担的前提下产生这条信息,见 [Styling](/versions/v0.7.1/zh-CN/shell/styling#未知方法)。 ## 还没有的东西 元素接口现在已经包含 Tabs、Table、Progress、表单控件、Popover/HoverCard 锚定浮层、Textarea、Scrollbar、PathBuilder、VirtualList,以及一个由脚本绘制 chrome 的 [dock area](/versions/v0.7.1/zh-CN/shell/dock)。仍刻意缺少: - 更高层的 List、Tree 系统,以及尚未接入的其他 `gpui-base` 组件; - `gpui.memo`——它能让未变化的子树跳过重建描述的那部分脚本工作。 焦点现在归脚本所有,但还不完整。仍然缺少的部分: - **复合控件内部的键盘导航,需要自己写。** Tab 与 Shift-Tab 能在控件之间移动;在 listbox、菜单或 tab list *内部*移动的方向键不会自动出现。零件现在都有了——`on_key_down`、`cx.bind_keys` 与 `key_context`——但把 ↑ / ↓ 变成高亮移动这件事仍然是脚本的活。 - **窗口尚无焦点时的第一次 Tab。** 只要还没有任何元素持有焦点,根 View 的 Tab 绑定就没有可达的分发路径;焦点必须先以别的方式进入——点击,或者 `handle.focus()`。 - **`Tab`、`Tabs`,以及 table、group、progress 的各个部件**不在 Tab 顺序里。base 本身就把它们排除在键盘焦点之外,对它们调用 `tab_index` 会被记录而不是被承接。 - **`Link` 与 `Switch` 上的 `track_focus`**,原因相同:它们自己构建 handle,且不暴露替换它的 builder。 --- # Dependencies Source: /versions/v0.7.1/zh-CN/shell/dependencies 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/shell/dependencies 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 # 依赖 应用用相对路径 import 自己的文件。除此之外,它写下的每一条 import 只有两个来源:运行时提供的**内建模块**——`gpui-kit`、`gpui-base`、`gpui-shell`、`gpui-fps`,以及标准运行时的 `fs/promises`、`path`、`crypto`、`net`、`websocket`——或者一个**依赖**:manifest 声明、gpui-shell 在 entry module 求值之前从 Git 抓取的 JavaScript package。 这里没有 registry,没有包管理器,也没有安装步骤。一个依赖就是一个 Git remote、一个 ref,加上脚本 import 它时用的名字。 ## Shell package 依赖可以是 manifest 指向的任何一个 Git 仓库。`omarchy-ui` 属于其中一类,而这一类有自己的名字:**shell package**——为 gpui-shell 而写、而不是为 Node 或浏览器而写的 JavaScript package,就像 crate 是为 Cargo 而写的一样。五件事决定一个仓库是不是 shell package: | shell package | 原因 | | ----------------------------------------------------------- | ------------------------------------------------------- | | 发布 ES module,且不需要构建步骤 | 运行时直接求值 checkout 里的文件,而且 `require` 不存在 | | 根目录 `package.json` 带 `"type": "module"` 与 `main` | 它让声明只需一行,并同时向运行时和编辑器指明 entry | | 只 import 内建模块与自己的文件 | 其余的都解析不了——它无法反向伸回 import 它的应用 | | 把 `gpui-kit` 与 `gpui-base` 当作由环境提供,而非自己的依赖 | 它们来自加载它的运行时,版本由 Host 决定 | | 不声明任何属于自己的 capability | 它的 `fs` 与 `fetch` 调用都跑在使用方应用的授权之下 | 这个名字不会被任何代码读取:依赖是靠被声明来识别的,不是靠被贴标签。它的作用是让一个作者写下、另一个作者找到;写给搜索引擎的那一份,是仓库上的 `gpui-shell` topic。 [`omarchy-ui`](https://github.com/huacnlee/omarchy-ui) 就是一个 shell package,本页余下部分都以它为例。 ## 声明一个依赖 `omarchy-ui` 是一个提供展示组件与主题工具的 shell package。在 `gpui-shell.json` 里加一行就够了: ```json { "id": "com.example.projects", "name": "Projects", "entry": "main.js", "dependencies": { "omarchy-ui": "huacnlee/omarchy-ui" } } ``` map key 就是脚本使用的裸模块名——package 内部并不参与决定它。是 manifest 给 package 起名,就像 import 的 `as` 子句那样:两个应用可以用不同名字引用同一个 remote,而仓库改名也不会改变 import: ```js import { AppShell, Button, CenteredWorkspace, MutedText, PageColumn, Surface, Title, } from "omarchy-ui"; export function render(cx) { const card = new Surface() .children([ new Title("Projects").build(cx), new MutedText("Choose a project to continue").build(cx), new Button("project-create") .label("Create project…") .onClick((_event, context) => context.notify()) .build(cx), ]) .build(cx); const page = new PageColumn("projects-page").child(card).build(cx); return new AppShell() .content( new CenteredWorkspace("projects-workspace").content(page).build(cx), ) .build(cx); } ``` 其他什么都不用改。脚本还是[快速开始](/versions/v0.7.1/zh-CN/shell/getting-started)里的那个脚本,依赖只是拓宽了它能 import 的范围。 ## 什么能解析,解析到哪里 | 写法 | 解析结果 | | ------------------------------------- | ---------------------------------------------------- | | `"omarchy-ui"` | package entry——见 [package entry](#package-entry) | | `"omarchy-ui/src/style"` | checkout 内的该文件,`.js` 后缀可省略 | | package 内部的 `"./theme.js"` | 该 package 自己 checkout 内的文件 | | package 内部的 `"gpui-kit"` | 内建模块,与应用代码中完全一致 | | 另一个已声明的依赖名 | 那个 package 的 entry——已声明的 package 之间互相可见 | | 从 package 内部用裸名 import 应用文件 | 拒绝:package 不能反向伸回 import 它的应用 | 解析结果一旦离开起点所在的 checkout,就会在模块加载前被拒绝,因此 `../` 无法走出一个 package、进入旁边的缓存。在应用目录内,同一条边界就是应用根目录,也正是[沙箱](/versions/v0.7.1/zh-CN/shell/capabilities#沙箱)对相对 import 已有的规则。 **依赖不是第二层沙箱。** 它在应用自己的上下文里求值,持有的授权与 manifest 完全相同:package 读文件,用的就是你的 `fs.read` 范围。声明一个依赖,等于像引入一个 Rust crate 那样信任它的代码,而你钉住的 ref 决定了那究竟是哪一份代码。 ## 选择版本 字符串形式是严格的 GitHub 简写或完整 Git URL,两者都可带可选的 `#ref`: ```json { "dependencies": { "default-main": "huacnlee/omarchy-ui", "named-ref": "huacnlee/omarchy-ui#v1.2.0", "commit": "https://github.com/huacnlee/omarchy-ui#0123456789abcdef0123456789abcdef01234567", "remote-head": "https://github.com/huacnlee/omarchy-ui" } } ``` | 形式 | 选中 | | -------------------------- | ---------------------------- | | `owner/repository` | `main` | | `owner/repository#ref` | 该 branch、tag 或 commit-ish | | `https://…/repository` | remote 的 `HEAD` | | `https://…/repository#ref` | 该 branch、tag 或 commit-ish | 简写形式故意收得很紧:恰好一组 `owner/repository`,字符限于字母数字与 `.`、`-`、`_`,最多一个 `#`,前后不能有空白,fragment 必须是合法的 Git ref 名。其余一律作为 manifest 错误,而不是从一个笔误里猜出 URL。完整 URL 可以是任意 Git 传输方式,包括 `ssh://` 与 `git@host:owner/repo`。 **branch、tag 或 remote `HEAD` 在每次加载应用时都会重新 fetch 并解析;commit ID 永远选中同一个 commit。** 依赖一个 branch,意味着下次开窗时代码会在你脚下变化——这在你自己开发 package 时很方便,而在你发布之后就是一个供应链决定。凡是不由你掌控的东西,请钉住 tag 或 commit。 抓取需要 Host 的 `PATH` 上有 `git`,并且发生在脚本 capability 存在之前——这是 gpui-shell 代表应用运行 Git,不是脚本在访问网络,因此它不受 `capabilities.network` 管辖,也不需要 `fs.execute`。当没有东西要抓(缓存里已有该 commit)时,这次加载完全不产生网络访问;而当依赖是移动 ref 且当前没有网络时,fetch 失败,应用不会加载。 ## Package entry checkout 就绪后,gpui-shell 读取 package 根目录的 `package.json`,把字符串类型的 `main` 作为 entry。`omarchy-ui` 发布的是: ```json { "type": "module", "main": "src/index.js", "types": "src/index.d.ts" } ``` 于是 `import { Title } from "omarchy-ui"` 求值的是 `src/index.js`。没有 `package.json`,或者其中没有 `main` 时,entry 是根目录的 `index.js`。 运行时读 `main`,编辑器读 `types`。两者出自同一个文件——这正是为什么一个附带 `.d.ts` 的 package 不需要应用做任何事,就能在调用处拥有完整类型。 JSON 格式错误、非字符串 `main`,以及缺失、不是文件或逃出 checkout 的 entry,都会在应用 JavaScript 执行前令加载失败。 ## Object 形式 最初的 object 形式保持完全兼容。它必须显式且只指定一个 `branch` 或 `tag`,其 repository 相对的 `entry` 默认是 `index.js`: ```json { "dependencies": { "omarchy-ui": { "git": "https://github.com/huacnlee/omarchy-ui", "tag": "v1.2.0", "entry": "src/index.js" } } } ``` 现有 manifest 无需迁移。改用字符串形式,意味着由 package 通过 `package.json` 的 `main`(或根目录 `index.js`)自己发布 entry,而不是让每个使用方各写一遍。 ## 缓存 ```text ~/.gpui-shell/cache/dependencies/ ├── locks/.lock ├── mirrors/.git └── checkouts/// ``` `` 是去掉 fragment 后完整 URL 的 SHA-256,它既是 remote 的身份,也是缓存的身份。每个 remote 一把锁,串行化 mirror 更新。checkout 按 commit 寻址且从不改写,因此并发启动与旧的 hot-reload generation 各自读到自己开始时的那棵树。 每次使用都会拿 mirror 的 configured origin 与 manifest 校验,且比较的是原始配置值——Git 的 `url.*.insteadOf` 仍可以选择另一个实际 fetch URL,镜像站或企业内网替换因此照常可用。Git 以非交互方式运行,禁用凭据提示,每条命令限时 30 秒,于是一个要求输入密码的仓库会给出错误信息,而不是把一个正等着打开的窗口挂在那里。 这份缓存不会被自动清理。它是内容寻址的,所以直接删掉是安全的:下次加载会重新抓取需要的部分。 ## 编辑器看见的东西 运行时靠 manifest 回答 `import { Title } from "omarchy-ui"`。编辑器则是从 import 所在文件向上查找 `node_modules`,它从来没听说过 `gpui-shell.json`。放着不管,一条正确的 import 会被标红成找不到的模块,它背后的每个名字也随之失去类型、参数提示和文档。 因此每次加载——以及 `gpui-shell types`——都会把 materialize 出来的 checkout 按 manifest 给的名字链接进应用的 `node_modules`: ```text projects/ ├── gpui-shell.json ├── main.js ├── gpui-kit.d.ts 运行时生成——请忽略 ├── jsconfig.json 只生成一次,之后归你 └── node_modules/ └── omarchy-ui → ~/.gpui-shell/cache/dependencies/checkouts// ``` 这样编辑器读到的,就是运行时即将执行的同一批文件,它显示的签名与 JSDoc 都来自 package 自身,不会与实际运行的代码脱节。 只有 gpui-shell 自己写下的条目会被替换或删除——指向自身依赖缓存的 symlink,或带有它标记文件的目录。同名的已安装 package 不会被动到;manifest 中已移除的依赖,其链接也会一并清除。若平台拒绝创建 symlink(例如未开启开发者模式、权限不足的 Windows 进程),gpui-shell 改为写入一个转发该 checkout 的小 package:裸 import 的类型效果相同,只有 package subpath import 无法解析。 当目录里既没有 `jsconfig.json` 也没有 `tsconfig.json` 时会生成一份 `jsconfig.json`,且只生成一次——已有的配置永远不会被替换。它不是装饰:靠推断得到的 `moduleResolution` 可能落到那种从不查看 `node_modules` 的解析方式,把运行时明明能解析的依赖标红;而默认的 `lib` 会把浏览器的全局对象塞给脚本,它们的声明与 `gpui-kit.d.ts` 产生冲突,于是描述 API 的那个文件本身反倒被报成错误。 `node_modules` 和 `gpui-kit.d.ts` 一样属于生成物,两者都应加入忽略列表: ```text gpui-kit.d.ts node_modules/ ``` 这个目录之所以叫 `node_modules`,是因为所有编辑器只认这一个位置;这里没有包管理器参与,也没有任何东西来自 registry。这个名字还换来了安静:TypeScript 会把从这里解析到的内容视为 external library,于是依赖自身的 implicit-`any` 之类诊断不会混进你自己的诊断里。 ## 抓取与链接何时发生 | 调用 | 抓取并链接 | 失败时 | | ----------------------------------------------- | ---------- | ------------------------------------ | | `gpui-shell ` | 是 | 加载失败;仅链接这一步是 best-effort | | `gpui-shell check ` | 是 | 作为 check 失败报告 | | `gpui-shell types ` | 是 | 报告错误,并带非零退出码 | | 嵌入式 Host 的 `ShellRuntime::load` | 是 | 加载失败;仅链接这一步是 best-effort | | `gpui_kit::shell::write_dependency_links(root)` | 是 | 以 error 返回给调用方 | 加载依赖抓取,所以无法 materialize 的依赖会让加载失败。写编辑器链接则不然:只读的应用目录是失去编辑器类型的理由,不是拒绝运行的理由。`gpui-shell types` 存在的意义正是这个差别——它做同样的事,并把没能完成的部分报出来。 hot-reload 对待 package 的方式与对待应用文件一致:每次加载都是一个新的 module generation,所以重启应用就足以让一个 branch 依赖前进到新的 commit。 ## 可能的失败 以下每一种都在应用 JavaScript 求值之前报告: | 信息 | 原因 | | ---------------------------------------------------------- | -------------------------------------------------- | | `GitHub shorthand must contain exactly owner/repository …` | 简写里带了路径、scheme 或非法字符 | | `a string dependency #Git ref must not be empty` | 末尾多了一个 `#` | | `could not clone Git dependency …` | Git 失败:remote 不存在、没有凭据、没有网络 | | `git timed out after 30 seconds …` | fetch 卡住,通常是弹出了交互式凭据提示 | | `Git dependency … cache origin is …, expected …` | 两个 manifest 对同一条缓存记录不一致;删掉它再试 | | `Git dependency … package.json main must be a string` | `main` 是对象,或是逃出 checkout 的路径 | | `Git dependency … has no entry …` | `main` 或 object 形式的 `entry` 指向不存在的东西 | | `cannot resolve module … from …` | subpath import 指向不存在的文件,或离开了 checkout | ## 发布一个 shell package shell package 就是一个普通的 Git 仓库:`omarchy-ui` 没有构建产物、没有 lockfile,也没有发布步骤。在决定它是不是 shell package 的那五件事之外,让它用起来舒服的是这些: - **根目录 `package.json` 里的 `main`**,使用方只写一行、不用写 `entry`。旁边的 `"type": "module"` 让编辑器和运行时对「源码是 ES module」保持一致。 - **一个统一 re-export 公开面的 entry。** 由 `src/index.js` 决定使用方能叫出哪些名字;checkout 里的其余文件仍可通过 subpath 触达——那是有用的应急出口,却是糟糕的公开 API。 - **类型放在源码旁边**,通过 `package.json` 的 `types` 指出。生成的 `.d.ts` 与 JSDoc 都可以,两者都会顺着链接抵达调用处——使用方的 `jsconfig.json` 不需要任何 `paths` 配置。 - **为发布打 tag**,让使用方可以钉住 `#v1.2.0`,而不是跟着 `main` 走。 - **在仓库上加 `gpui-shell` topic**,让想找 shell package 的人能找到它。 ## 继续阅读 | 页面 | 内容 | | -------------------------------- | --------------------------------------------------- | | [能力](/versions/v0.7.1/zh-CN/shell/capabilities) | manifest 的其余部分:身份、版本,以及脚本能触达什么 | | [快速开始](/versions/v0.7.1/zh-CN/shell/getting-started) | `gpui-shell types`、`check`,以及依赖加入的那份声明 | | [API 参考](/versions/v0.7.1/zh-CN/shell/api) | package 与你的代码一同 import 的内建模块 | --- # Getting Started Source: /versions/v0.7.1/zh-CN/shell/getting-started 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/shell/getting-started 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `gpui-shell` 首先是给一个 Rust GPUI 应用加上 JavaScript 扩展点的办法:由 Host 构建运行时、决定脚本能碰到什么,并把脚本 View 挂在它想挂的位置。直接运行一个脚本目录——也就是下面那个 `gpui-shell` 二进制——是随之而来的开发便利,而不是它的定位。 ## 把运行时接进 Rust 应用 `gpui-shell` 二进制本身是一个很薄的 Host:解析命令行、装上日志 sink、建一个运行时、开一个窗口。任何嵌入这个库的 Host 做的也是同样四件事。 ```rust use gpui_kit::shell::{Capabilities, ShellRuntime}; gpui_platform::application() .with_assets(gpui_kit::shell::AppAssets::new(root.clone())) .run(move |cx| { // 初始化 gpui-base、shell 的默认 token 调色板,以及样式反射表。 gpui_kit::shell::init(cx); let runtime = ShellRuntime::new(cx).expect("script runtime"); // 在 Host 开口之前,什么都不允许。 gpui_kit::shell::set_store_path(store_directory.join("store.json")); gpui_kit::shell::set_capabilities( Capabilities::new() .read_roots([root.clone()]) .write_roots([store_directory.clone()]) .store(true), ); cx.open_window(Default::default(), move |window, cx| { runtime.load(&root, window, cx) }) .expect("window"); }); ``` 其中两行承载的是规则而不是机制。 **`runtime.load(...)` 返回窗口的 `ShellRoot`**,作用与 `gpui-component` 窗口中的 `Root` 相同。它持有 dialog 栈、sheet、toast 栈、焦点恢复与 Tab 导航。manifest 负责选择应用入口并记录能力请求,但不会自行批准这些请求。带 manifest 与不带 manifest 的目录都使用 Host 当前的默认 policy;后者采用 `main.js`。 **能力默认为空。** `Capabilities::default()` 什么都不授予——没有文件、没有存储、没有剪贴板、没有进程。由 Host 决定,因为只有 Host 知道它对即将运行的这段代码信任到什么程度。见 [Capabilities](/versions/v0.7.1/zh-CN/shell/capabilities)。 同时也要装上 `tracing` subscriber。运行时通过 `tracing` 报告脚本错误、未处理的 promise rejection 以及 phase 非法的调用;没有 subscriber 时,这些全部被丢弃,症状是一个安静地不再响应的界面。 ## 它加载的那个脚本 一个文件就够了。新建一个目录,放一个 `main.js`: ```js // hello/main.js import { View } from "gpui-kit"; import { v_flex, Button } from "gpui-base"; export default class Hello extends View { init() { this.clicks = 0; } render(cx) { return v_flex() .size_full() .items_center() .justify_center() .gap(12) .bg(cx.theme().colors.background) .child( div() .text_color(cx.theme().colors.foreground) .child(`Clicked ${this.clicks} times`), ) .child( Button.new("click") .h(28) .px(12) .items_center() .justify_center() .border(1) .border_color(cx.theme().colors.border) .bg(cx.theme().colors.surface) .text_color(cx.theme().colors.foreground) .on_click((_event, cx) => { this.clicks += 1; cx.notify(); }) .child("Click me"), ); } } ``` ```bash cargo run -p gpui-shell -- hello ``` 这个文件里有四件事值得现在就点明,因为后面所有内容都建立在它们之上。 **能力由哪个包提供,就从哪个模块导入。** `"gpui-kit"` 是 GPUI 自身的元素和运行时补上的部分——`View`、`div`、`text`、存储、调度。`"gpui-base"` 是 gpui-base 的布局辅助、组件和主题——`v_flex`、`Button`、`InputState`。`"gpui-fps"` 是它的性能浮层。一个名字只属于其中一个模块,所以一行 import 就说清了脚本依赖的是哪一层。运行时还提供一层刻意收窄的 JavaScript 标准能力:`buffer`、`path`、`url`、`crypto`、`zlib`、`console`、`process`、`os`、`fs/promises`、`net`、`websocket`,以及全局 `fetch`。应用相对导入仍被限制在应用目录内。`node:fs` 这类 `node:` 别名、包查找和 CommonJS `require` 不属于契约。 **`main.js` 必须 `export default` 一个继承 `View` 的类。** `init` 在 View 创建时只执行一次;`render` 返回一个元素、留存的 `Entity` 或字符串,并且是在 View 失效时执行,而不是每帧执行——见 [`render` 什么时候执行](/versions/v0.7.1/zh-CN/shell/state#render-什么时候执行)。 **样式方法是 snake_case,你自己写的代码是 camelCase。** `items_center`、`on_click`、`text_color`、`gap_2` 保留了 Rust 的拼写,因为无参样式接口是从 GPUI 的反射表生成的,而不是手写的。应用自己声明的一切——变量、方法、对象的键——都是普通的 JavaScript camelCase。这个对比是刻意的:snake_case 的调用是 Host 接口,camelCase 的是你的代码。 **没有任何东西会自动重绘。** 没有 signal,没有 `useState`,也没有依赖数组。改完状态,自己调用 `cx.notify()`。 ## 单独运行一个脚本 一个脚本目录也可以直接跑起来,不必先写 Host 。自带的示例就是这么运行的;一段脚本在被它将来所属的那个应用加载之前,通常也是这样开发的。`gpui-shell` 没有发布到 crates.io,所以先克隆仓库,再在仓库根目录运行: ```bash cargo run -p gpui-shell -- examples/js_todolist ``` 窗口里会出现一个可用的 todo list:带留存状态的输入框、受控 checkbox、一个确认 dialog、一个 toast、从应用自身目录加载的图标,以及在未获授权时退化为内存存储的持久化。它的目的是把整个运行时都跑一遍,而不是做到最小——哪里坏了,通常先在这里露出来。 参数是一个**目录**,不是文件。运行时解析该目录,默认读取其中的 `main.js`,取出该模块 default 导出的类、构造一个实例,并把它挂载为窗口的根 View。如果目录中存在 `gpui-shell.json`,二进制会先验证它,并采用其中声明的 `entry` 与 capabilities。 ## 不运行也能检查脚本 JavaScript 没有编译器,这个运行时也不打算造一个。它补上的是编译器本该替你做的那件事: ```bash cargo run -p gpui-shell -- check hello ``` `check` 会加载应用,并向一个永远不显示的窗口渲染一帧,成功退出 `0`,失败退出 `1`。因为脚本接口是动态的——未知的样式方法、类型不对的参数、被重复使用的元素,都是运行期事实——所以“构建并渲染一次”是唯一诚实的检查方式。它能报出: - 语法错误,并带上脚本自身的调用栈; - 无法解析的 import,以及越出应用目录的 import; - 缺失或形态不对的 default 导出; - 未知的样式方法,并给出 `did you mean` 建议; - 类型不对的样式参数,例如 `.p("auto")`; - 被使用了两次的元素。 它不开窗口,因此可以放进编辑器、CI,或者一个 agent 的循环里。 加上 `--print-spec` 可以顺带打印构建出的元素描述: ```bash cargo run -p gpui-shell -- check hello --print-spec ``` 这份输出是 arena 自己的调试输出——在它变成真实元素之前,由组件与记录操作构成的那棵树。当问题是“我这条链到底记录了什么”时,它很有用。 ## 生成 TypeScript 声明 ```bash cargo run -p gpui-shell -- types hello ``` 它会在应用旁边写出 `gpui-kit.d.ts`。在脚本顶部加上 `// @ts-check`,编辑器就会补全整套 API,并在运行之前、在调用点上直接拒绝拼错的样式方法、不存在的颜色 token,或者 `.p("auto")`。 它同时会把编辑器需要的其余部分一并配好:manifest 声明的每个 Git 依赖都会被抓取并按声明的名字链接进 `node_modules`,于是 `import { style } from "omarchy-ui"` 解析到的正是运行时将要执行的那批文件,连同该 package 自己的类型、参数与 JSDoc;若目录里既没有 `jsconfig.json` 也没有 `tsconfig.json`,还会生成一份 `jsconfig.json`。详见[依赖](/versions/v0.7.1/zh-CN/shell/dependencies)。 这份声明可信,是因为它**从运行时实际派发所依据的那几张表生成**,而不是照着文档抄的: - 样式方法名来自 JavaScript prelude 构建元素原型时遍历的同一份列表; - 每个有参方法的参数类型是**探测**出来的——生成器逐一询问运行时该方法接受哪些字面量,所以 length、definite length、absolute length、颜色与裸数字之间的区别,由真正做校验的那段代码决定; - 颜色的联合类型来自已安装调色板的 token 名。 有三件事声明刻意不表达,因为没有类型能表达:能力是否被**授权**(被拒绝的 `fs.readFile` 一样能通过类型检查);元素与 `cx` 的**生命周期**(TypeScript 没有仿射类型,重复使用元素照样能通过类型检查,也照样会抛异常);以及**某个方法适用于哪个组件**(所有元素共用一个原型,所以 `.checked(true)` 声明在全部元素上,在 `div` 上只是不起作用)。 升级运行时之后重新生成即可;输出是确定性的,所以 diff 是可以审阅的。 ## Hot-reload ```bash cargo run -p gpui-shell -- hello --watch cargo run -p gpui-shell -- hello --dev # 隐含 --watch ``` `--watch` 每秒轮询应用目录四次,对一串连续写入去抖 200 ms,然后重载。一次重载会重新读取**每一个**模块,入口也在内——一个悄悄用了旧 import 的 hot-reload 比没有更糟,因为它看起来是成功的。 重载会在碰到实时 View 之前,先把所有可能失败的工作做完。如果新代码加载失败,之前的 View 继续运行,错误输出到 stderr,同时窗口里出现一个带固定 id 的 toast;下一次成功重载会把它撤回。存了一份坏代码,不会因此丢掉窗口。 `--dev` 隐含 `--watch`,并在构造运行时之前开启 development mode。它恢复动态代码构造器并让内建原型保持可写,但 capability 检查完全不变。见 [Capabilities](/versions/v0.7.1/zh-CN/shell/capabilities#沙箱)。 ## 命令一览 ```text gpui-shell [--watch] [--dev] gpui-shell check [--print-spec] gpui-shell types gpui-shell --help | --version ``` | 参数 | 含义 | | -------------- | -------------------------------------------------- | | `` | 应用根目录,或其中的 `main.js` | | `check` | 不开窗口地加载并渲染一次,退出码 `0` 或 `1` | | `types` | 写出 `gpui-kit.d.ts`、链接 manifest 依赖、生成配置 | | `--watch` | 源码变化时重载 | | `--dev` | 开发模式,隐含 `--watch` | | `--print-spec` | 配合 `check`,额外打印构建出的元素描述 | --- # GPUI Shell Source: /versions/v0.7.1/zh-CN/shell 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/shell 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `gpui-shell` 的存在,是为了让一个用 Rust 写的 GPUI 应用**能被 JavaScript 扩展**。 **首要目标是插件扩展。** Host 应用编译一次、发布一次,此后新增一块面板、一个侧边工具或一段业务逻辑,都以脚本的形式加载进同一个进程——不必重新编译,不必重新分发二进制,想加一块面板的人也不必 fork 整个 Host。 **次要目标是用 JavaScript 写完整的应用。** CLI 可以直接跑起一个应用目录。这本身就是一条能用的路径,同时也是插件的开发方式:先把脚本单独跑通,再挂进 Host。 **它不是 Electron,也不是 Tauri。** 没有 WebView,没有 DOM,没有 HTML 与 CSS,没有浏览器引擎,也没有 Node.js。脚本 View 失效时会描述界面;GPUI 可以在之后的帧里复用这份描述,而不重新执行该 View 的脚本 `render`。这些帧使用的元素模型与渲染器和基于 `gpui-base` 的 Rust 应用相同。这不表示空闲窗口持续绘制,也不表示帧中的所有工作都不会进入 JavaScript:虚拟列表项与 Dock chrome 回调是例外。在实测构建里,带上整个运行时增加了 [13.5 MiB 二进制](/versions/v0.7.1/zh-CN/engine#链接它要付多少)。 这两个目标建立在同一条分工上。`gpui-shell` 直接构建在 [`gpui-base`](/versions/v0.7.1/base) 之上,[QuickJS](https://github.com/quickjs-ng/quickjs) 跑在 Host 自己的线程上。由 Host 构建运行时、决定脚本能碰到什么,而脚本在同一个进程里画出真正的界面。Rust 负责渲染、布局、文本编辑、虚拟化、焦点、浮层以及全部系统能力;脚本负责界面组合、视觉呈现与业务逻辑。 ```js import { View } from "gpui-kit"; import { v_flex, Button } from "gpui-base"; export default class Counter extends View { init() { this.count = 0; } render(cx) { return v_flex() .size_full() .items_center() .justify_center() .gap(20) .bg(cx.theme().colors.background) .child( div() .text_3xl() .text_color(cx.theme().colors.foreground) .child(`${this.count}`), ) .child( Button.new("increment") .h(32) .px(14) .items_center() .justify_center() .bg(cx.theme().colors.primary) .text_color(cx.theme().colors.primary_foreground) .rounded(6) .on_click((_event, cx) => { this.count += 1; cx.notify(); }) .child("Increment"), ); } } ``` ## 为什么插件优先 `crates/base/src/dock` 已经具备了插件系统所需的一半:布局是纯数据,`PanelRegistry` 能按持久化文件里的名字重建面板,每块面板还带着一份属于自己的 `serde_json::Value`。缺的另一半是——面板的实现必须编进 Host 的二进制,因此没有人能在不 fork 的前提下贡献一块面板。`gpui-shell` 补的正是这一半。 「插件优先」不是一句定位口号。下面这些设计决策,如果只面向独立脚本,每一条都可以是另一种选择;放在插件的语境下才成为必然: | 设计决策 | 为什么它由插件推导而来 | | ----------------------------------------------------- | ------------------------------------------------------------------------------- | | `Capabilities::default()` 是空集,由 Host 授予 | 插件是别人写的代码,授权必须来自 Host,而不能由插件在自己的 manifest 里声明即得 | | 每个插件一份独立 `Policy`,卸载即取消它名下的全部任务 | 多个插件共用同一个运行时,授权之间不能相互渗透 | | 脚本出错是可恢复的异常,Host 进程存活 | 一个插件写崩了,不该把整个应用一起带走 | | 重绘只重放快照,从不进入 VM | 帧预算由 Host 负责,插件的 JavaScript 不能压在上面 | | `HostModule` 把 Host 自己的 Rust 借给脚本 | 只有脚本跑在 Host 内部时才有意义——独立应用没有 Host 可借 | | Dock 面板在应用被卸载后仍保留位置与状态 | 插件会被装了又卸;重新装回来时,面板还在原来的位置,状态也还在 | | 基座不提供任何视觉,呈现权整个交给脚本 | 插件要长得像 Host 的一部分,就必须能掌控每一个像素 | 独立脚本应用用得上其中的很少几条。它真正获得的是迭代速度——hot-reload、`check`,以及自动生成的 `gpui-kit.d.ts`。这也是它排在第二位的原因:它是插件被开发和验证的地方,而不是这套运行时的目的本身。 文本编辑、语法高亮、LSP、虚拟化与动画采样都留在 Rust。这条线是职责划分,而不是对脚本的限制:所有必须贴着 GPU 与系统运行的部分都归 Host,插件因此不会成为应用性能与稳定性上的变量。 **插件是目标,但接口还没有全部开放** 插件之下的机制已经建成并有测试覆盖——manifest 解析与发现、加载与卸载、每个插件独立的 policy 与数据目录。脚本现在已经可以贡献面板并绘制 dock 的 chrome:`DockArea`、`dock_area(...)` 与 `DockArea.register_panel` 都已公开,带着脚本面板的布局也能熬过一次重启。还缺的是贡献注册表的其余部分(`gpui.command`、`gpui.keymap`)、授权 UI,以及一个用上 `PluginManager` 的 CLI。**今天能完整跑通的是独立脚本这条路径,dock 也在其中。** 见 [Dock 与面板](/versions/v0.7.1/zh-CN/dock)。 ## 核心特点 ### 架构:脚本负责描述,Host 负责渲染 脚本从不持有 GPUI 元素,它记录的是元素的**描述**——builder 链上的每一次调用都会往一块 arena 里写入一条操作,等某一帧需要时,Rust 再把这些操作重放成真实元素。布局、绘制、命中测试、滚动、IME 与文本编辑全部留在 Rust,不会回调进脚本。[一次渲染是怎么走完的](#一次渲染是怎么走完的)完整走了一遍这个过程。 引擎是这套设计的一个参数,而不是其中一部分。今天只有 QuickJS 一种,但这条分界线之上的全部模块——arena、把描述变成真实元素的 `materialize`、CallScope、样式表、主题、能力模型、浮层 Host 、hot-reload——源码里都没有出现任何 VM 的名字。见 [The Engine Seam](/versions/v0.7.1/zh-CN/engine)。 ### 能力:一整层应用层,而不是一套控件 脚本拿到的,正是一个基于 `gpui-base` 的 Rust 应用能拿到的东西:元素与布局、链接与控件、建立在语义主题 token 之上的流式样式接口、通过 `init` / `render` / `cx.notify()` 管理的 View 状态、由 Host 留存的状态(例如文本输入的 rope 与选区)、dialog / sheet / toast、异步任务、原生 transition 与 spring,以及需要授权才能用的文件、存储、剪贴板、进程、HTTP、TCP 与 WebSocket 接口。 围绕它的还有:`--watch` 保存文件即 hot-reload,`gpui-shell.json` 在代码运行前声明身份与最小权限,自动生成的 `gpui-kit.d.ts` 把整套 API 描述给编辑器或模型,`check` 则在应用跑起来之前就报出问题。 `gpui-kit.d.ts` 可以加进 `.gitignore`,它是自动生成的。 ### 性能:脚本不在每一帧里 `render` **不是**每帧跑一次。脚本 View 失效时,它把界面描述进一份 Snapshot;之后 GPUI 请求新帧时,可以复用这份 Snapshot,而不执行该 View 的 `render`。指针 hover、光标闪烁、列表滚动和原生动画不一定使脚本 View 失效;虚拟列表项渲染器等帧路径回调另当别论。空闲窗口请求多少帧,不由屏幕刷新率直接决定。 运行时把两件事分开计数,gallery 的 Shell story(`cargo run -- shell`)把这两个数摆在界面上: 示意的一秒时间线,假设期间请求了 60 帧。脚本数据没有变化时,该 View 的 JavaScript render 轨道为空;价格每 50 毫秒变化一次时,该 View 重建约 20 次,其余帧复用 Snapshot。图中没有描述空闲时的帧频。 示意的一秒时间线,假设期间请求了 60 帧。脚本数据没有变化时,该 View 的 JavaScript render 轨道为空;价格每 50 毫秒变化一次时,该 View 重建约 20 次,其余帧复用 Snapshot。图中没有描述空闲时的帧频。 | 界面在做什么 | 每秒画的帧 | 每秒跑的 JavaScript | | ----------------------------------- | ---------- | ------------------- | | 只是重绘,JavaScript 的数据没有变化 | 60 | 0 | | 价格每 50 ms 变动一次 | 60 | 19 | 这是一个假设期间请求了 60 帧的示意,不表示空闲行为,也不保证实际帧率。真正生成多少帧取决于调度与负载;脚本 render 次数取决于失效与合并。第二行里另有 41 帧使用的是已有描述。 脚本描述成本在该 View 失效时支付,而不是每一帧都支付。在实测的 443 节点基准里,执行 `render` 并把界面记进 Snapshot 用了 1.1 ms;缓存后的帧执行 GPUI 工作用了 1.3 ms,没有执行脚本 `render`。这些数字属于特定负载与机器,不能作为复杂应用的持续帧率保证。 | | 基准中每次请求帧的成本 | | ------------- | ------------------------------------------------------------------- | | 没有 Snapshot | 1.1 ms (JS render) + 1.3 ms (Rust render) = **2.4 ms/frame render** | | 有 Snapshot | **1.3 ms** | 面板变大也不改变缓存帧无需重新执行脚本 View 的性质。[基准测试](/versions/v0.7.1/zh-CN/engine#那次实测)覆盖到 8,403 个节点;其中缓存帧的测试断言该 View 的脚本 `render` 不会运行,但耗时仍随面板规模显著增长。 ### 体积:一个脚本运行时只要 +13.5 MiB 跑一个真实脚本应用的 Host,二进制 **26.1 MiB**、常驻内存 **81 MiB**——QuickJS 和整个标准运行时都在里面。相比同一个应用不带它的版本,取这个依赖的代价是**二进制 +13.5 MiB、内存 +14 MiB**。 这个数是个常数,不是比例:组件 gallery——体量是它的五倍——增加的同样是 13.5 MiB。[链接它要付多少](/versions/v0.7.1/zh-CN/engine#链接它要付多少)给出了测量所用的那一对程序,以及这些兆字节都去了哪里。 以上数字都取自一台 MacBook Pro(M3,8 核,24 GB):帧数与次数来自 Shell story,毫秒数来自 release 构建的基准测试,二进制与内存数字来自 `examples/hello_world` 和 `gpui-shell` CLI 的 release 构建。 ### 安全:默认什么都没有,语言本身也一并收紧 `Capabilities::default()` 是空集——没有文件访问、没有存储、没有剪贴板、不能执行进程、没有网络。 Host 在加载 View 之前决定授权, View 随后在自己的整个生命周期里保持这份授权;`fs` 接口上的每一条路径都走**同一个**解析器,任何落在授权根之外的结果都会被拒绝。 在授权之下,沙箱还收紧了语言本身——因为一个 VM 早晚要同时承载多个插件:`eval` 与四个函数编译器全部移除,内置原型被冻结,避免一个插件改动 `Object.prototype` 波及另一个;模块解析被限制在应用目录内;堆(256 MiB)、解释器栈(1 MiB)与单次调用耗时(`render` 为 50 ms)都有上限。其中的耗时上限是一个 `catch` 无法吞掉的中断,这一点由测试保证。见 [Capabilities](/versions/v0.7.1/zh-CN/capabilities)。 ## 一次渲染是怎么走完的 脚本如何变成界面:脚本描述元素,Rust 把它们变成真实元素,GPUI 负责绘制 脚本如何变成界面:脚本描述元素,Rust 把它们变成真实元素,GPUI 负责绘制 这张图画的是一帧的过程,而这张图的形状基本解释了本节文档的其余部分。 GPUI 的元素是**被消费**的值:`RenderOnce::render` 按值取走 `self`,`.child()` 按值取走子元素, View 每次重绘都从零重建整棵元素树。因此一个 JavaScript 对象永远不可能**就是**一个 GPUI 元素——它没有东西可以长期持有。 所以脚本不构建元素,而是**描述**元素。builder 链上的每一次调用,都会把一条操作记录进一块元素描述 arena;脚本手里的对象只带一个指向 arena 的整数下标。当 GPUI 要求 View 渲染时,Rust 把这些记录下来的操作重放成真实元素、交给 GPUI,然后整块清空 arena。布局、绘制、命中测试、滚动与 IME 全程不再回到脚本。 由此直接推出三条结论,每条对应下面一个页面: - **元素是一次性的。** 描述在本次渲染结束时就消失了,所以被保存下来的元素在下次使用时抛出异常,而不是画出一个意料之外的东西。见 [Elements](/versions/v0.7.1/zh-CN/elements)。 - **`cx` 只属于产生它的那次调用。** 它带着一个 generation 编号,每次使用都与实时的调用栈比对;一个跨过 `await` 仍在使用的 `cx` 会给出明确错误,而不是去访问一个早已失效的栈帧。见 [State and Views](/versions/v0.7.1/zh-CN/state)。 - **回调属于注册它的那次渲染。** 下一次渲染会整体替换它们,这正是脚本闭包不会在 Host 里堆积的原因。见 [Elements](/versions/v0.7.1/zh-CN/elements)。 这三条都是“把脚本绑到一个会消费其值的元素模型上”必然的结果。 ## 呈现权在脚本一侧 大多数脚本层的做法,是把一批做好的控件交给脚本去摆放。这里没有这样的控件可交,因为它下面那一层同样没有。 `gpui-base` 的控件完全不带视觉样式。Rust 里的 `Button::new("save")` 没有内边距、没有背景、没有圆角、没有尺寸,这就是接口约定。JavaScript 绑定原样保留了这一点:`Button.new("save")` 不写样式时,除了它的子元素之外什么都不画。 结论才是重点:**因为基础层不提供任何呈现,呈现权就完整地落在脚本一侧**——颜色、间距、hover 状态、圆角,全部由脚本决定。这与 Rust 应用选择基于 `gpui-base` 而不是 `gpui-component` 时做的取舍完全一样;区别在于,这里的取舍写在一个存盘就能立刻看到结果的文件里,中间不需要 `cargo build`。 多打的字换来的是整个应用层。改一个按钮的圆角,不必再回到 Rust。 ## 适用场景 - **为已有的 GPUI 应用增加插件能力——首要场景。** 插件跑在 Host 进程内,能力由 Host 一项一项授予,起点是什么都没有。扩展产品不再意味着 fork 或者发一个新版本:界面与业务逻辑以脚本形式交付,改动不需要重新编译、也不需要重新分发二进制;插件出错会呈现为一个可恢复的错误,而不是把 Host 一起带走。 - **基于 `gpui-shell` 编写纯 JavaScript 的应用——次要场景。** 整个应用层——元素、样式、 View 状态、浮层与系统接口——都在 JavaScript 一侧,而渲染、文本编辑、虚拟化与每一个动画帧仍留在 Rust。这里也是一个插件在挂进 Host 之前被写出来、被验证的地方。 ## 它在架构中的位置 ```text JavaScript app main.js · views · styling · business logic │ import { … } from "gpui-kit" ▼ gpui-shell engine seam · element specs · CallScope style sheets · theme tokens · capabilities ShellRoot (dialog / sheet / toast) · scheduler │ ▼ gpui-base behavior · state · unstyled infrastructure │ ▼ gpui elements · styles · rendering · GPU · platform ``` `gpui-shell` 与 `gpui-component` 是并列关系,而不是在它下游:两者都是 `gpui-base` 的使用者,都补上了 Base 不提供的那一层呈现。`gpui-component` 用 Rust 提供了一套成品且统一的呈现;`gpui-shell` 提供的是让脚本自己去提供呈现的那套机制。 ## 接着读 | 页面 | 内容 | | --------------------------------------- | ---------------------------------------------------------------------------------- | | [Getting Started](/versions/v0.7.1/zh-CN/getting-started) | 运行示例、最小应用、`check` 与 `types` | | [Examples](/versions/v0.7.1/zh-CN/examples) | 仓库里的独立应用、 Host 状态与原生动画示例 | | [Elements](/versions/v0.7.1/zh-CN/elements) | 构造器、`child` / `children` / `when`,以及元素为什么是一次性的 | | [Styling](/versions/v0.7.1/zh-CN/styling) | 流式样式接口、长度与颜色、语义 token、状态样式 | | [State and Views](/versions/v0.7.1/zh-CN/state) | `init` / `render`、`cx.notify()`、留存状态、异步 | | [Overlays](/versions/v0.7.1/zh-CN/overlays) | dialog、sheet、toast,以及 phase 规则 | | [Capabilities](/versions/v0.7.1/zh-CN/capabilities) | `gpui-shell.json`、默认拒绝、文件、存储、进程与网络 API | | [依赖](/versions/v0.7.1/zh-CN/dependencies) | shell package:什么样的仓库算一个,manifest 如何命名与钉住它,以及编辑器拿到的类型 | | [Hosting](/versions/v0.7.1/zh-CN/hosting) | Rust 这一侧的全貌:挂载、刷新、指标、退出、hot-reload | | [HostModule](/versions/v0.7.1/zh-CN/host-module) | 把 Host 自己的 Rust 借给脚本,以及那条纯数据边界 | | [Dock 与面板](/versions/v0.7.1/zh-CN/dock) | 把脚本 View 变成可停靠面板、为它绘制 chrome,以及重启后什么会留下 | | [Performance](/versions/v0.7.1/zh-CN/performance) | 脚本的成本:失效频率乘以描述规模、 View 这条边界,以及那几个计数器 | | [The Engine Seam](/versions/v0.7.1/zh-CN/engine) | QuickJS、这条分界线存在的理由,以及把脚本成本与帧成本分开的三项实测 | ## 当前状态 该 crate 处于 **M0** 里程碑:一条可行性基线,而不是稳定接口。它没有发布到 crates.io,脚本 API 预计还会变化。本节文档写到的都是已经实现并可用的部分;缺失的部分,会写在你最可能去找它的那一页上。 设计详见 [GPUI Shell 设计文档](https://github.com/longbridge/gpui-kit/blob/main/docs/gpui-shell.md),代码位于 [`crates/shell`](https://github.com/longbridge/gpui-kit/tree/main/crates/shell)。 --- # Performance Source: /versions/v0.7.1/zh-CN/shell/performance 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/shell/performance 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 一个 View 的 `render()` 不是每帧都跑。它产出一份描述并存成 Snapshot,之后的每一帧都由 Rust 从这份 Snapshot 画出来,不再进入 JavaScript——[首页那一节](/versions/v0.7.1/zh-CN/shell#性能-脚本不在每一帧里)讲的就是这件事。 一旦重绘不进入 JavaScript,剩下要算的就只有两样: ```text JavaScript cost = View invalidation frequency × cost to describe the View ``` 两样都不由屏幕刷新率单独决定。窗口即使以 120 Hz 重绘,也不会仅因为呈现的帧更多,就多执行这个 View 的 JavaScript;没有失效的 View 不会执行脚本 `render`。左边取决于你何时调用 `cx.notify()`,也包括主题切换等其他失效原因;右边是一次调用背后压了多少界面。屏幕刷新率只是调度上限或目标,并不表示空闲窗口会持续重绘。 两样也都在你手里:左边是你在哪里调用 `cx.notify()`,右边是一次 `notify` 背后压了多少界面。这一页剩下的内容讲的就是这两件事,以及出问题时怎么分辨是哪一个。 ## 每个 View 都有自己的 Snapshot GPUI Shell 给每一个 JavaScript View 一份属于它自己的 Snapshot:这个 View 的 `render` 产出的那份描述,保存在 Rust 一侧。 **只要 View 本身没有变化,它的 Snapshot 就一直被复用。** 当 GPUI 确实请求下一帧时,可以使用这份 Snapshot,而不执行这个 View 的 JavaScript `render`。这本身不说明空闲窗口多久呈现一帧,也不说明该帧其余 CPU 与 GPU 工作的成本。 ```text View changed ──▶ render() ──▶ new Snapshot ──▶ frame View unchanged ─────────────────▶ existing Snapshot ──▶ frame ``` Snapshot 是按 View 存的,不是按窗口存的。一个窗口里有一百个 View,就有一百份 Snapshot,各自独立失效: | 发生了什么 | 会执行什么 | | --- | --- | | `Watchlist` 调用 `cx.notify()` | `Watchlist.render`,其余什么都不跑 | | 父 View 调用 `cx.notify()` | 父 View 的 `render`。每个子 View 用自己的 Snapshot 回答这一帧 | | `this.chart.set_props({ symbol })` | 那个子 View 的 `update` 与 `render`。父 View 不重建 | | 子 View 的子 View 调用 `cx.notify()` | 那个子 View 的 `render`。失效不会向上传播 | | 主题切换 | 每一个 View——因为 Snapshot 里烘进了它构建时的颜色 | 一个窗口,画成互相嵌套的 View:侧栏、一块装着四行(每行本身也是 View)的自选清单、图表,以及装着两个子 View 的详情面板。三个阶段循环。价格跳动时,只有 MSFT 那一行被标为「render 在执行」,其余每个 View 都从自己已有的 Snapshot 画出来。列表重排时,自选清单本身执行,而它的四行不执行——父 View 记录的是每个子 View 的一个句柄,不是子 View 的描述。主题切换时所有 View 同时执行,因为 Snapshot 里烘进了它构建时的颜色。 一个窗口,画成互相嵌套的 View:侧栏、一块装着四行(每行本身也是 View)的自选清单、图表,以及装着两个子 View 的详情面板。三个阶段循环。价格跳动时,只有 MSFT 那一行被标为「render 在执行」,其余每个 View 都从自己已有的 Snapshot 画出来。列表重排时,自选清单本身执行,而它的四行不执行——父 View 记录的是每个子 View 的一个句柄,不是子 View 的描述。主题切换时所有 View 同时执行,因为 Snapshot 里烘进了它构建时的颜色。 ## 把大 View 拆成小 View View 是整体重建的,内部没有局部重建:如果一个 View 的描述有四百个节点,那么任何一点变化都会把这四百个节点全部重建一遍,无论变化多小。 这就是大 View 贵的原因。它画的所有东西共用一份 Snapshot,于是变化最频繁的那部分数据,会连带让那些从不变化的部分一起失效。在一个行情终端里,一个价格动一下,图表、侧栏、盘口也会被重新描述一遍——不是因为它们变了,而是因为它们和价格在同一个 View 里。 拆分就是解法。把各自独立变化的部分用 `cx.new` 拆成各自的 View,一次变化就只会落到一份 Snapshot 上,而不是全部: ```js import { View } from "gpui-kit"; export default class Terminal extends View { init(props, cx) { this.sidebar = cx.new(Sidebar); this.watchlist = cx.new(Watchlist, { symbols: props.symbols }); this.chart = cx.new(PriceChart, { symbol: props.symbols[0] }); this.detail = cx.new(Detail, { symbol: props.symbols[0] }); } render() { return h_flex() .child(this.sidebar) .child(this.watchlist) .child(v_flex().child(this.chart).child(this.detail)); } } ``` 在本页测量的那块 40 行看板上,描述整块面板要 **0.315 ms**,描述其中一行只要 **0.012 ms**——361 个节点对 9 个。 嵌套本身的开销和这个差距比几乎可以忽略:父 View 为每个子 View 记录的是一个句柄,不是子 View 的描述。所以界面复杂本身不是性能问题, View 太大才是。 而且「为了性能而拆」指的是拆成 **View**,不是拆成多个插件、多个应用或多个进程。需要第二个应用,是因为你想要第二份**授权**,那是 [Capabilities](/versions/v0.7.1/zh-CN/shell/capabilities) 的事,而不是因为你想要第二份缓存。 ## 只为用户看得见的变化 notify `cx.notify()` 就是这里全部的依赖系统,而它只表达一件事:**我的描述过期了。** 它不是事件通知,把它当事件通知用,是让 JavaScript 变贵的最常见方式。 行情回调是典型场景: ```js onQuote(quote, cx) { this.quotes.set(quote.symbol, quote); cx.notify(); // Notifies even when this symbol is not visible. } ``` 如果这个 View 从两千只订阅里只画二十只,这句 `notify` 会为它根本没画的标的的每一跳,付一次完整的面板描述。解法是一个条件,不是更快的 render: ```js onQuote(quote, cx) { this.quotes.set(quote.symbol, quote); if (this.visible.has(quote.symbol)) cx.notify(); } ``` 同一个想法推出三条规则: - **让变化的那个 View 失效。** 只属于某个子 View 的状态,就应该放在那个子 View 上、在那里 notify,而不是放在挂载它的父 View 上。 - **下一帧之前的多次通知可以共用一次脚本 render。** 见下;各个回调及其其他工作仍会执行,所以只为 View 真正显示的变化发通知。 - **在 Host 一侧,`cx.notify()` 与 `ScriptView::refresh` 是两个不同的请求。** 单纯的 `notify` 只是重绘已有的描述。如果 Rust 改的是脚本通过 [HostModule](/versions/v0.7.1/zh-CN/shell/host-module) 读到的状态,那描述已经陈旧,只有 `refresh` 能说明这一点。见 [Hosting](/versions/v0.7.1/zh-CN/shell/hosting#host-状态变了-怎么刷新-view)。 ### notify 到底做了什么,谁在合并它 `cx.notify()` 不重建任何东西。它只是在这个 View 上置一个标志,表示「我的描述可能过期了」,然后请求 GPUI 绘制。重建发生在之后的那一帧里,而且只在标志仍然置位时才发生。 所以两帧之间的所有 notify 都会合并成一次 `render`——无论它们来自三个事件回调、一个循环里的 task,还是 Host: ```text notify notify notify ──▶ one frame ──▶ one render() ``` 一个标志置三次等于置一次。什么都没有被丢掉:三个回调都执行了,状态也都改了;它们共享的只是随后那一次重建。 **合并让每个 View 每帧最多执行一次脚本 render。** 例如,某窗口在实测的一秒里呈现了 120 帧,同时行情送来 1,000 次通知,那么该 View 在这些帧中最多执行 120 次 render。这不会让 1,000 个回调变成零成本,也不能证明实际帧率就是 120 FPS,更不表示屏幕每次刷新都会产生一帧。 运行时没有另外可调的脚本 render 节流。GPUI 的调度会把这些请求合并到它处理的下一帧中;这一帧何时呈现,仍取决于窗口、平台与负载。 ### 这份缓存占多少内存 一个 View 持有**两份**描述:它已发布的那份,以及被它替换掉的那份。留着第二份,是因为事件仍可能派发到一个已经被取代的帧上,而那一帧需要的回调属于那份较旧的描述。 没有第三份。发布新描述时最旧的那份会被丢弃,丢弃它同时会退役随它注册的那些回调。所以上限就是每个存活的 View 两份描述,而且不随时间累积:一个重渲染过一百万次的 View,持有的东西和一个只渲染过两次的 View 完全一样。关掉一个面板,它的 View 就没了,两份描述一起走。 这也是「该拆大 View 而不必怕拆」的另一个理由:一百个小 View 持有的是一百对小描述,加起来仍然只是把这个界面描述了两遍,而不是一百遍。 ## 帧率与呈现延迟是两类问题 一个运行中的界面可能出两种问题,而只有一种会体现在 FPS 上: ```text Frame rate Is motion smooth? State → display How long until the user sees a state change? ``` 漏掉一次 `cx.notify()`,数据可能已经过期,却没有出现掉帧。如果其他活动继续触发画面,GPUI 可以沿用上一份描述,帧率读数看起来也可能正常;如果没有请求新帧,窗口则可能保持静止。直到另一次失效刷新了这个 View,过期数据才会变化。仅靠帧率指标无法发现漏掉的通知。 | 症状 | 哪个数字不对 | 常见原因 | | --- | --- | --- | | 脚本状态未变化,但窗口重绘时仍卡顿 | 帧时间 / 帧率 | 布局、绘制或虚拟列表按行执行的工作可能过大;见[那次实测](/versions/v0.7.1/zh-CN/shell/engine#那次实测) | | 行情在跑的时候窗口卡 | 帧率**和**失效频率 | 某个 View 重建得太频繁、太大,或两者都有 | | 画面很流畅,但数据慢半拍 | 呈现延迟 | 某次 `notify` 被漏掉、被压在 `await` 之后,或该用 `refresh` 的地方用了 Host 侧的 `cx.notify()` | 这两件事要分开诊断。FPS 从没掉过,并不能证明失效逻辑是对的。 ## 怎么读那几个计数器 运行时把这两类事件分开计数,Host 用 `runtime.read_metrics()` 读取——接口本身以及“留一个基线再相减”得到每秒速率的用法,见[观察它花了多少](/versions/v0.7.1/zh-CN/shell/hosting#观察它花了多少)。 | 读数 | 它回答什么 | | --- | --- | | `script_renders()` | JavaScript 执行了多少次。跟着 `cx.notify()`、hot-reload 与主题切换走,永远不跟帧走 | | `materializations()` | 脏 Snapshot 被转成元素多少次;干净的窗口帧可复用 GPUI 子树 | | `mean_script_render()` | 一次描述要花多少,包含其中的 Host 调用 | | `mean_native()` | 其中有多少是在 HostModule 函数里,而不是在描述界面 | | `slowest_script_render()` | 这一段里最慢的那一次构建 | | `frame_script_calls()` | 从帧路径进入 VM 的次数——只有[虚拟列表](/versions/v0.7.1/zh-CN/shell/elements)的 item 渲染器与 [Dock](/versions/v0.7.1/zh-CN/shell/dock) 的 chrome 回调会计入 | | `structure_repeat_rate()` | 在有上一份描述可比的重建里,有多大比例产出了相同的**结构**——见下 | 一份读数的形状说明什么: - **每秒 `script_renders` 远高于数据实际变化的频率**——`notify` 正在为用户看不见的东西触发。加条件。 - **`script_renders` 正常,但 `mean_script_render` 高**——View 太大。把它拆开。 - **`mean_native` 占了 `mean_script_render` 的大部分**——成本在描述过程中调用的那些 Host 函数上,而不在描述本身。在 `render` 之前把它们一次性读进字段,不要按节点调用。 - **`slowest_script_render` 远高于均值**——某一次构建付了其余各次没付的东西:首次渲染物化的一份集合,或一个很少走到、却描述得多得多的分支。如果是均值整体在漂,那是系统负载,不是这个。 ## Snapshot 缓存止步于哪里 Snapshot 消除的是**没有变化**的成本,它不消除**变化很小**的成本。 一份 Snapshot 把结构和取值一起存着: ```text StockRow ├── Symbol("AAPL") ├── Price("230.42") └── Change("+1.42%") ``` 当价格变成 `230.51`,结构完全一样,只有一个叶子不同——但要表达这一点,唯一的办法就是产出一份新的描述,于是整个 View 被重新描述一遍:每个 `div()`、每个 `.gap()`、每个 `.bg()`、每一次进入 Rust 的跨越。这就是 dirty render 那条路径,行情一快,跑的就是它。 三条泳道,长条按同一比例绘制。这个 View 读到的东西没变:没有长条,不执行任何 JavaScript,这一帧从已有的 Snapshot 画出来。取值变了,也就是今天的情形:无论变化多小,整块面板都被重新描述一遍,0.315 毫秒。同样的变化,若这一行本身是一个留存的 View:0.012 毫秒,约为二十六分之一,因为描述的是 9 个节点而不是 361 个。 三条泳道,长条按同一比例绘制。这个 View 读到的东西没变:没有长条,不执行任何 JavaScript,这一帧从已有的 Snapshot 画出来。取值变了,也就是今天的情形:无论变化多小,整块面板都被重新描述一遍,0.315 毫秒。同样的变化,若这一行本身是一个留存的 View:0.012 毫秒,约为二十六分之一,因为描述的是 9 个节点而不是 361 个。 可用的杠杆就是本页开头那一个:**把必须重建的 View 缩小。** 在上面那块看板上,描述整块面板 0.315 ms,描述其中一行 0.012 ms——361 个节点对 9 个。把这一行放进它自己的 View,就是把前一个数字变成后一个,而这是今天就能做的。 `structure_repeats()` 与 `structure_changes()` 是用来核对这条线划得对不对的。它们统计一次重建产出的**结构**与被替换那份是否相同——只有其中的取值不同。如果某块面板报出来的比例很低,这件事本身就值得知道:你以为只有一个数字在变,实际上有东西在改变结构。 --- # State and Views Source: /versions/v0.7.1/zh-CN/shell/state 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/shell/state 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 View 是这个运行时里唯一有身份、能跨帧存活、并且由 GPUI 拥有的东西。其余一切——元素、回调、传给某次调用的 `cx`——都属于产生它的那一次调用。 ## 定义 View ```js import { View } from "gpui-kit"; export default class Counter extends View { init(props) { this.count = props?.start ?? 0; } render(cx) { return v_flex().child(`${this.count}`); } } ``` `init` 在 View 创建时执行一次。跨帧存活的状态在这里建立——普通字段,以及 View 需要的任何[留存实体](#留存状态)。 `render` **返回一个元素、留存的 `Entity` 或字符串**,并且是在 View 被置为失效时执行,而不是每帧执行——见 [`render` 什么时候执行](#render-什么时候执行)。返回其它东西会立刻失败: ```text render(cx) must return an element, an Entity, or a string ``` `main.js` 必须 `export default` 一个 View 类。 Host 构造一个实例并把它挂载为窗口的根 View;default 导出不是类的模块会被拒绝,并说明原因。 永远不要把元素存在实例上。见 [Elements](/versions/v0.7.1/zh-CN/shell/elements#元素是一次性的)。 ## `cx.notify()` 没有任何东西会自动重绘。这里没有 signal、没有 observable,也没有自动依赖追踪。改完状态,然后请求重新渲染: ```js add(cx) { this.items = [...this.items, { id: this.nextId, caption, done: false }]; this.nextId += 1; cx.notify(); } ``` 这与整个前端生态的默认假设正好相反,所以有必要直说:**这里没有 `useState`,也没有依赖数组。** 运行时不加自动追踪,有三个理由。 GPUI 本身就是显式 `notify` 的模型,两套响应式心智模型放进同一个应用会互相干扰而不是彼此配合。自动追踪意味着要把每个 View 实例包进 `Proxy`,这是渲染路径上一笔长期开销——而 QuickJS 没有 JIT 来摊薄它。而漏写 `notify` 的症状是确定的:界面不更新。找出这种问题,远比排查一个触发过多的自动系统便宜。 一次事件回调内的多次 `notify` 会合并为一次重绘——也合并为一次 `render`。 ## `render` 什么时候执行 `render` **不是**每帧执行一次。GPUI 可能因与脚本 View 的数据无关的原因请求一帧,例如指针划过按钮、文本光标闪烁、列表滚动或动画推进。这些事件不必重新执行该 View 的脚本 `render`,也不表示空闲窗口会按屏幕刷新率持续绘制。虚拟列表项渲染器等帧路径回调则是另一种情况;见 [Performance](/versions/v0.7.1/zh-CN/shell/performance#怎么读那几个计数器)。 所以一次 `render` 调用描述的不是*这一帧*。它把界面描述一次,写进运行时保留的一份 Snapshot: ```text cx.notify() ──▶ render() ──▶ Snapshot ──┬──▶ frame ├──▶ frame └──▶ frame … ``` Snapshot 只在有东西让它失效时才重建: - 事件回调或异步任务里的 `cx.notify()` - [hot-reload](/versions/v0.7.1/zh-CN/shell/getting-started) 替换了脚本 - 主题切换——因为 `bg(cx.theme().colors.surface)` 在 `render` 执行时记录真实颜色,已经烘进了 Snapshot - Host 调用 `ScriptView::refresh`——Rust 用它表示“我改了脚本会读到的状态”(通过 [HostModule](/versions/v0.7.1/zh-CN/shell/host-module))。Host 侧单纯的 `cx.notify()` 只是重绘,不会跑脚本:这是两个不同的请求 其余情况都在 Rust 里复用你已经产出的那份描述,不执行任何 JavaScript。 三条值得记住的推论: **脚本 `render` 成本跟着失效走,不单独跟着屏幕刷新率走。** 一个每秒收到十次通知的 View,该秒最多需要十次重建;下一帧前的通知还可能合并。复用 Snapshot 的重绘不会用 JavaScript 重新描述这个 View,但帧中的其他工作仍有成本。 **hover、focus 与 active 样式永远不回调脚本。** `.hover(s => s.opacity(0.8))` 在构建 Snapshot 时就被解析成原生样式描述,之后由 GPUI 自己套用。指针在界面上移动不会执行任何 JavaScript。[`Input`](#留存状态) 的光标与选区同理。 **一次失败的 `render` 不会毁掉界面。** Snapshot 只在 `render` 成功返回后才发布,所以抛异常的脚本会让上一份描述——以及随它注册的那些回调——原封不动地留着。失败以横幅的形式**盖在**仍然可用的界面之上,说明当前画面比最新版本旧了一版,并把详情交出去供粘贴;你的滚动位置和焦点都还在。首次渲染就失败的 View 没有可保留的东西,会拿到整屏的错误界面。两种情况下,在有东西再次让 View 失效之前,运行时都不会重跑那次失败的 `render`。 ## ScopePhase 每一次从 Rust 进入脚本的调用都会开启一个带 **phase** 的作用域,phase 决定这次调用的 `cx` 能做什么。 | `ScopePhase` | 时机 | 允许 | 不允许 | | --- | --- | --- | --- | | `render` | 构建元素树 | 读状态、构建元素、注册回调 | `notify`、打开浮层、创建留存状态 | | `event` | 处理点击或变更 | 全部 | 阻塞 | | `task` | 恢复异步工作 | 全部 | 阻塞 | | `layout` | 在 GPUI 布局过程中渲染一个虚拟化项 | 读状态、构建元素 | `notify`、打开浮层、创建留存状态 | `cx.phase()` 返回当前 phase,不在任何 Host 调用中时返回 `"none"`。 `cx.theme()` 返回这次调用中 gpui-base 当前语义主题的深度只读 Snapshot:既包含直接颜色角色,也包含 `colors`、`spacing`、`radius`、`appearance` 与 `is_dark`。优先使用它,而不是兼容用的 `theme()` 导出,因为 context 写法明确表达了调用生命周期与当前 Host 主题。 每一条拒绝都是一条具体信息,而不是未定义行为: ```text cx.notify() is not allowed during the `render` phase; request a re-render from an event handler instead ``` 渲染中通知自己是一个死循环,所以它被拒绝而不是被延后。 ## 两种 `cx` 在 GPUI 里 `&mut Window` 与 `&mut App` 是借用:它们的存活期恰好是一次调用。脚本对象比任何借用都活得久,所以脚本侧的 `cx` 不能持有它们。GPUI 为确实需要跨调用持有的代码准备了第二种——`AsyncApp`,由 `cx.spawn` 交给它的闭包——这里也一样。 **`Context`** 是 `render` 和每个事件处理器收到的那种。它持有一个 **generation 编号**,每次使用都与实时的作用域栈比对,所以把它留到调用之外得到的是一条错误,而不是一帧被破坏的画面: ```text cx is no longer valid: it was captured during an earlier call and used later. Use cx.spawn or take cx from the callback arguments instead. ``` **`AsyncContext`** 是 `init` 收到的那种,也是 `cx.spawn` 与 `cx.timer` 交给回调的那种。它不指名任何一次调用——用到它时才解析当时正在执行的那一次——所以 `await` 不会把它带走: ```js async save(cx) { await cx.sleep(100); cx.notify(); // The same cx remains valid here. } ``` 这三处正是职责为「安排或延续比启动它的那次调用活得更久的工作」的地方。其余场合要的就是严格的那种,被告知「你留得太久了」正是它的价值所在。 `cx` 上除了函数什么都没有——`Object.keys(cx)` 只看得到方法,看不到 generation——所以脚本无法伪造一个。 没有第三种拿到它的办法。模块顶层和裸 `constructor` 不会被交给 context,也无从索取——这是设计而非缺口:GPUI 根本没有模块顶层,在那里启动的工作不属于任何 View,没有东西拥有它,也没有东西取消它。把它放进 `init`,那正是 View 被交给 context 的地方。 ## 留存状态 View 自己的字段放普通数据。带有跨帧机制的东西——文本框的内容、光标位置与撤销历史——存放在 GPUI 实体里,脚本持有一个**句柄**。 ```js import { InputState, Input } from "gpui-base"; init() { this.draft = InputState.new({ placeholder: "What needs doing?" }); this.draft.on("submit", (_event, cx) => this.add(cx)); } render(cx) { return Input.new(this.draft) .flex_1() .h(28) .px(8) .border(1) .border_color(cx.theme().colors.input) .bg(cx.theme().colors.surface) .text_size(12); } ``` | 调用 | 作用 | | --- | --- | | `InputState.new({ placeholder, value })` | 创建状态,两个选项都可省略 | | `state.value()` | 当前文本 | | `state.set_value(text)` | 替换文本 | | `state.on(event, handler)` | 订阅,见下 | | `state.release()` | 释放句柄 | | `Input.new(state)` | 渲染它的元素 | **在 `init` 或事件回调里创建,绝不要在 `render` 里创建。** 创建实体需要一个实时窗口,而 `render` 本来也是最不该做这件事的地方: ```text InputState.new(...) cannot run during render; create state in init() or in an event handler and keep it on the view ``` 脚本持有的是句柄而不是实体——实体归 GPUI 所有。使用已释放的句柄会抛异常,而不是返回 `undefined`;因为 `undefined` 在 JavaScript 里往往飘出很远才炸,那时源头已经找不到了: ```text this input state has been released ``` `Input` 是唯一由运行时给出默认值的元素,而且只有三条:垂直居中的一行、占满宽度、点击框内任意位置获得焦点。每一条都是脚本可以覆盖、但不该被迫记住的默认——没有第一条,文本会贴在给定高度的顶部,在屏幕上看起来像 bug 而不是缺一条样式。 ### 输入事件 ```js this.draft.on("submit", (event, cx) => this.add(cx)); ``` | 事件 | 触发于 | | --- | --- | | `change` | 文本发生变化 | | `submit` | 按下回车;`event.secondary` 与 `event.shift` 说明按法 | | `focus` | 获得焦点 | | `blur` | 失去焦点 | 与渲染期注册的 `on_click` 不同,这个订阅**活得比创建它的那次渲染更久**。订阅由运行时的句柄存储持有而不是由脚本持有,因为脚本没有地方放它,而“因为某个值被回收所以处理函数不再触发”是那种没人找得到的 bug。它随句柄一起释放。 事件名拼错会列出合法值: ```text unknown input event `changed`; expected one of: change, submit, focus, blur ``` ### 日历状态 `CalendarState` 是同样的模式,留存的东西不同:正在看的是哪个月、选中的是哪一天,以及由此推出的那张日期网格。 ```js init(_props, cx) { this.calendar = CalendarState.new(); this.calendar.on("change", (date, cx) => this.pick(date, cx)); } render(cx) { const grid = this.calendar.month_days()[0]; return v_flex().children( grid.map((week) => h_flex().gap(4).children( week.map((day) => Button.new(day) .selected(day === this.calendar.value()) .on_click((_e, cx) => { this.calendar.set_value(day); cx.notify(); }) .child(String(Number(day.slice(8)))), ), ), ), ); } ``` `month_days()` 是它存在的理由:哪些日期落在哪一周、相邻月份的日子补在哪里、这个月需要几行。格子是你自己画的——base 的 `Calendar` 元素**没有**绑定,因为它遍历同一份网格、每个格子调用一次渲染回调,一帧最多四十二次跨语言调用,而且发生在 GPUI 的 layout 过程里,为的是一批本身不带行为的格子。 日期一律是 `"YYYY-MM-DD"`,区间是 `[start, end]`,没选是 `null`。区间即使终点还没定也保持成对——`["2026-08-03", null]` 不会塌成它的起点,因为“选了一天”和“区间开了个头”对 base 是两种状态,它自己的逻辑在这上面分支。 | 方法 | 说明 | | --- | --- | | `CalendarState.new()` | 创建状态;和其他留存状态一样,只能在 `init` 或事件处理器里 | | `month_days()` | 网格:按月分组的“周”,每周固定七天 | | `year()` / `month()` / `today()` | 网格对应的年月,以及创建时读到的今天 | | `value()` / `set_value(next)` | 选中的日期 | | `next_month()` / `prev_month()` | 前后移一个月;在 `render` 中不合法 | | `on("change", handler)` | 唯一的事件,报告一个日期被选中 | | `release()` | 丢弃句柄 | ## 异步工作 脚本代码用的是普通的 JavaScript 异步方式——`async` 函数与原生 promise。运行时补上的是裸 QuickJS 没有的那部分:一个时钟、待执行工作的 owner,以及负责推动 job 队列的人。 | 导出 | 作用 | | --- | --- | | `cx.sleep(ms)` | 在 GPUI 的 foreground executor 上,`ms` 之后 resolve 的 promise | | `cx.spawn(body, opts?)` | 调用 `body(cx)` 并接管它返回的 promise | | `cx.timer.after(ms, handler, opts?)` | 调用一次 `handler(cx)` | | `cx.timer.every(ms, handler, opts?)` | 反复调用 `handler(cx)` | 调度挂在 `cx` 上,因为 GPUI 就是这么放的——`App::spawn`,以及由 context 交出的 executor 上的 timer。不需要 import 任何东西。 它们产生的工作全部在主线程上运行。脚本可见的东西从不离开主线程:这里没有 `Worker`,VM 与 GPUI 的 `App` 都是主线程独占的。 ```js flash(cx) { this.saved = true; cx.notify(); cx.spawn(async (cx) => { await cx.sleep(1500); this.saved = false; cx.notify(); }); } ``` 这段不需要任何 import:`cx` 就是处理器的第二个参数,而它的 body 收到的 `cx` 是能挺过 `await` 的异步那种。 **`cx.spawn` 会接管 promise,这正是它的意义。** 未处理的 rejection 是 JavaScript 最常见的静默失败:工作停了,界面保持原状,什么都没写到任何地方。在这里它会带着脚本自己的调用栈进入 `tracing::error!`。 ### 归属与取消 每个任务都属于某个 View——`opts.owner`,或者创建它时正在运行的那个 View。任务持有弱引用,所以当发起这项工作的面板消失时,回调会被跳过,而不是写进一份再也不会被渲染的状态。 ```js const handle = cx.timer.every(1000, (cx) => this.tick(cx)); handle.cancel(); handle.is_done(); ``` `owner: null` 表示退出这套归属、比任何 View 都活得久;它是今天除了当前 View 之外运行时唯一接受的值。 取消一个 `sleep` 会让它的 promise **永远 pending**。这就是取消对 promise 的含义:后续代码不执行,也不为一段主动要求停止的代码凭空发明一个错误。 `timer.every` 的间隔从上一次调用结束开始计时,所以慢的处理函数会推迟下一次 tick,而不是把 tick 堆起来。 ### Timer 与标准 Host API ```text setTimeout -> cx.timer.after(ms, callback) setInterval -> cx.timer.every(ms, callback) clearTimeout / clearInterval -> call cancel() on the Task returned by after / every ``` `setTimeout`、`setInterval`、`clearTimeout` 与 `clearInterval` 都是会抛错的 stub。一次性工作使用 `cx.timer.after`,重复工作使用 `cx.timer.every`;要停止其中任意一种,都对返回的 `Task` 调用 `cancel()`。全局 `fetch`,以及 [Capabilities](/versions/v0.7.1/zh-CN/shell/capabilities) 中记录的安全标准模块(包括 `websocket`),都是真实的异步 Host API。CommonJS `require` 仍不可用;请使用 ES module。 浏览器 DOM 与存储并不存在:没有 `document` 或 `localStorage`。全局 `window` 是 gpui-shell 用来承载 dialog、sheet 与 toast 的 overlay host,并不是浏览器 `Window`,也不提供 DOM。 ## 还没有的东西 - **全局与跨 View 状态。** 除了 [Capabilities](/versions/v0.7.1/zh-CN/shell/capabilities) 里的持久化层和普通模块作用域,没有别的 store。 - **Action 与快捷键。** `gpui.action` 与 `gpui.keymap` 设计了但没有绑定;今天唯一的按键处理是 `ShellRoot` 安装的那几个(Tab、Shift-Tab、Escape)。 - **多窗口。** 窗口由 Host 打开,没有 `gpui.open_window`。 - **`gpui.gc_stats()`**,以及会读取它的调试面板。 --- # HostModule Source: /versions/v0.7.1/zh-CN/shell/host-module 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/shell/host-module 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 [Capabilities](/versions/v0.7.1/zh-CN/shell/capabilities) 管的是脚本**不能**碰什么。这一篇讲的是另一半:Host 主动递出去的东西。 脚本无法加载 native 扩展。`dlopen` 进来的 Rust 没有稳定 ABI,而且一旦进了进程,它就持有进程的全部权限——允许这种事的沙箱等于没有沙箱。所以方向是反的:**Host 在编译期注册它愿意暴露的那部分 Rust**,脚本能够到的就只有这些,一点不多。 ```rust use gpui_kit::shell::{HostModule, HostValue}; gpui_kit::shell::export_module( HostModule::new("workspace") .function("project_name", |_| Ok(HostValue::from("gpui-component"))) .function("version", |_| Ok(HostValue::from("0.1.0"))), )?; ``` ```js import { project_name } from "workspace"; project_name(); // "gpui-component" ``` 注册好的模块就是一个普通的 ES module,由解析 `gpui-kit` 和 `path` 的同一个 loader 负责。一次调用注册一个模块,重名会替换掉先前那个而不是合并进去——有三个模块的 Host 就调三次 `export_module`。本页余下的部分讲它的代价和它拒绝的东西。 ## 为什么是 import 而不是查表 显而易见的另一种做法是 runtime registry lookup,返回一包函数: ```js // 这里没有采用的形态。 const workspace = native("workspace"); workspace.projectName(); // 拼错了:迟早会抛 ``` ```js // 实际采用的形态。 import { projectName } from "workspace"; // 拼错了:链接阶段就失败 ``` 它输两次,而且都输在**你什么时候才发现**上: - **导出名拼错了要到运行时才炸。** `workspace.projectName()` 能通过类型检查、能加载、能渲染,然后在第一次真正走到它的那一帧抛出——对于只有某个分支才会碰到的名字,那一帧可能离出错的那次编辑很远。import 在模块图链接时解析,所以同样的拼写错误会在应用跑第一行之前就把它拦住,并指名模块和导出。 - **类型声明什么都说不了。** 只有 Host 知道自己注册了什么,所以 lookup 最多只能给出 `Record any>`;想要真类型的应用只能手写一份 `.d.ts`,而没有任何东西拿它跟 registry 对过账。而 module specifier 是一个类型声明**可以**写在上面的名字,所以声明[直接从 registry 生成](#给它们写类型),拼错在编辑器里就是红的。 import **没有**冻结的是名字背后的那个函数。每个导出都是一个转发桩,每次调用都重新经过注册表,所以撤销一个模块仍然立即生效:脚本手里那个已经 import 进来的函数会得到一次拒绝,而不是那个已被收回的闭包。被固定下来的只有**名字的集合**,固定在 import 它的那个模块被链接的时刻——这也是 Host 必须**先**调用 `export_module`、再加载应用的原因。 ## 注册表本身就是授权 默认注册表是**空的**,和 `Capabilities::default()` 同一个形状。什么都没注册的 Host 就是没有授予任何扩展面,脚本 import 一个模块时会被指名告知: ```text HostModule `market` is not available: this Host registered none. HostModule access is granted by the embedding application, with gpui_kit::shell::export_module(...). ``` 注册了东西之后,消息就变成告诉你有什么: ```text unknown HostModule `marker`; this Host registered: market, theme ``` ```text HostModule `market` has no function `quote`; it provides: quotes, ticks, watch, watch_all ``` 这上面刻意没有再叠一层"每个模块单独授权"。名单是 Host 定的,所以**名单就是授权**——撤销某一项的办法是导出一个同名模块、或者清空整个集合,下一次调用即生效,不必重启。 对于要跑多个应用的 Host,每个公开的 `Policy` 各自带着自己冻结的 capabilities 和自己的模块注册表——用 `Policy::with_host_module` 一个一个加进去,形状和上面一样。这就是同一个 runtime 里的两个插件如何拿到不同权限、而不需要在 `await` 边界上来回换 thread-local 状态。身份和申请的系统权限写在 `gpui-shell.json` 里;HostModule 不在其中,因为它是 Host 注册的可执行行为。 ## runtime 自己留用的名字 HostModule 和内置模块、[Standard Runtime](/versions/v0.7.1/zh-CN/shell/engine) 共用同一个 specifier 命名空间,而 resolver 先走到后两者。所以注册一个 `path` 并不会遮蔽真正的 `path`——它只会注册一个永远没人能 import 到的模块,而且悄无声息。 `export_module` 直接拒绝这样的名字,并说清楚它归谁: ```text `path` is one of the runtime's own module names and cannot be registered: a script importing it reaches the runtime, never this module. The reserved names are: gpui, gpui-base, gpui-fps, buffer, console, crypto, fs/promises, net, os, path, process, url, websocket, zlib ``` 完整名单是 `gpui_kit::shell::RESERVED_SPECIFIERS`。除此之外的名字都归你——也不会被应用目录里的同名文件遮蔽,因为 HostModule 的解析顺序在应用自己的文件之前。 ## 边界上只有纯数据 Host function 收到的是 `HostArguments`,返回的是 `HostValue`:null、布尔、数字、字符串、数组、对象。这六种是脚本引擎和 JSON 都能承载的交集,也正是同一份注册表能服务[引擎接缝](/versions/v0.7.1/zh-CN/shell/engine)后面任意引擎的原因。 它永远不会收到脚本句柄。句柄会让 Host 把一个脚本值的引用留到产生它的那次调用之后——也留到那个让周围上下文有效的 call scope 之后。 参数按位置取出,类型检查和错误消息都是现成的: | 调用 | 得到 | | ---------------------- | -------------------------------------------- | | `arguments.string(0)` | `&str`,或一个说明实际来的是什么的错误 | | `arguments.number(0)` | `f64` | | `arguments.integer(0)` | `i64`,拒绝带小数的数字 | | `arguments.boolean(0)` | `bool` | | `arguments.value(0)` | 原始的 `HostValue`,给那些接受多种形状的函数 | | `arguments.get(0)` | `Option<&HostValue>`,给可选参数 | 返回一条记录用的是 builder 而不是 map,因为对象往往**就是**脚本要渲染的那一行,字段顺序应该由 Host 说了算: ```rust use gpui_kit::shell::HostObject; HostObject::new() .field("symbol", "AAPL.US") .field("last", 224.22) .field("watched", true) ``` 错误是一句话,不是一个类型:`HostError::new("no such symbol")` 到了脚本那边就是一个可以 catch 的 `Error`。 ## Host function 的三条规矩 **不许回调进脚本引擎。** 一次 host 调用发生在一次脚本调用里面,而后者又在一次 Host 调用里面;从这里重新进入 VM,就是在引擎栈帧还在、渲染过程还没结束的时候去跑脚本代码。不持有任何脚本句柄让这件事很难被误写出来,而 dispatcher 干脆直接拒绝嵌套调用,这样即使 Host 找到了别的路径,得到的也是一个可诊断的错误而不是未定义行为。 **读写 Host 状态才是重点。** 函数通过 `gpui_kit::shell::with_current_app` 拿到环境里的 `App`,不在一次活跃调用中时它是 `None`: ```rust fn with_app(read: impl FnOnce(&mut App) -> R) -> Result { gpui_kit::shell::with_current_app(read) .ok_or_else(|| HostError::new("only reachable while a script call is in progress")) } ``` **从里面发出的 `cx.notify()` 在调用退栈之后才送达。** 所以 Host function 可以改一个 entity 并请求所有观察它的 View 重渲染,而这次重渲染不会发生在调用它的那段脚本的下面。 ## 不该占住线程的活 `function` 是同步的:它返回一个值,脚本拿到那个值。慢的那种会占住渲染线程。 `async_function` 返回的是一个 future,脚本拿到的是 promise: ```rust HostModule::new("db") .declarations("export function query(sql: string): Promise;") .async_function("query", |arguments| { // 同步的一半:在主线程上,在调用方的 scope 里。可以读 Host 状态, // 在这里拒绝就是在调用点抛出。 let sql = arguments.string(0)?.to_owned(); let pool = with_app(|cx| cx.global::().handle())?; // 异步的一半:在 GPUI 的后台执行器上。 Ok(async move { Ok(pool.query(&sql).await?.into_host_value()) }) }) ``` ```js import { query } from "db"; const rows = await query("select 1"); ``` ### 切成两半就是这个设计本身 闭包在主线程上跑,返回 future。所以参数检查、以及把工作需要的东西复制出来,都发生在 `with_current_app` 还答得上话的时候。之后 future 是 `Send + 'static`,在别处被驱动,那里既没有 `App` 也没有脚本引擎可碰。 这跟[上面那三条](#host-function-的三条规矩)是同一条规矩,只不过从"强制执行"变成了"物理上做不到"。同步的函数体靠一个运行时守卫被摁住"不许重进引擎";异步的那一半根本没法把这件事表达出来,因为后台线程上没有引擎可进。 ### 脚本看到什么 - **同步那一半的拒绝,在调用点抛出。** `arguments.string(0)?` 失败是写下这次调用的地方抛 `TypeError`,而不是一个要 await 才听得到的 rejected promise。 - **future 的失败会 reject 这个 promise**,消息里带着 `module.function`,所以 `await` 外面包 `try`/`catch` 就是正常写法。 - **被取消的调用会永远 pending。** View 消失、或者它的应用被重载,那么续体不会执行,也不会给一段被要求停下来的代码编造一个错误——跟 `cx.sleep` 的答案一致。 返回类型里的 `Promise` 要你自己写。注册表只核对两边的名字,不读签名,所以声明里漏掉 `Promise` 不会被任何东西抓到。 ## 给它们写类型 模块在 Rust 里、紧挨着注册代码,描述自己的 TypeScript 面貌: ```rust HostModule::new("market") .declarations(r#" /** One row of the board, as it crosses the boundary. */ export interface Quote { symbol: string; last: string; watched: boolean } /** Every row on the board. */ export function quotes(): Quote[]; /** Flips one row's watched flag and answers the new value. */ export function watch(symbol: string): boolean; "#) .function("quotes", /* … */) .function("watch", /* … */) ``` 生成的 `gpui-kit.d.ts` 会把这段原样放进 `declare module "market"`,于是 `import { quotes } from "market"` 得到的检查和 `import { div } from "gpui-kit"` 完全一样。 把它写在这里、而不是脚本旁边的 `.d.ts` 里,是让两半保持为一件事的关键。`.d.ts` 会是第二个文件、第二种语言,而且没有任何东西把它绑在注册表上。`export_module` 会拿声明的导出和实际注册的对账,不一致就拒绝: ```text HostModule `market` declares a different set of functions than it registers; registered but not declared: quotes; declared but not registered: prices ``` 现在改了一边的函数名,得到的是启动时的一句话,而不是一个还在不断补全某个 Host 早就删掉的函数的编辑器。 不写声明也可以,代价只是精度。没有声明的模块会以宽松签名生成: ```ts declare module "audit" { import { HostValue } from "gpui-kit"; export function observe(...args: HostValue[]): HostValue; } ``` 模块名和每一个导出名仍然是被检查的——而且这个形状是诚实的,因为跨越边界的东西正好就是 `HostValue`(脚本这边这个类型,就是 Rust 那边的同名类型)。写成 `any` 会比运行时更宽:脚本传一个函数过来能通过类型检查,然后在调用时被拒绝。 ## 一个真实的例子 Gallery 的 Shell story 注册了一个 market 模块,这就是它那段脚本拥有的全部扩展面。主题值走的是 `cx.theme()`。 Host 侧长这样: ```rust fn market_module(market: &Entity) -> HostModule { let read = market.clone(); let flip = market.clone(); HostModule::new("market") .declarations(MARKET_TYPES) .function("quotes", move |_| with_app(|cx| read.read(cx).to_host_value())) .function("watch", move |arguments| { let symbol = arguments.string(0)?; with_app(|cx| { flip.update(cx, |market, cx| { let watched = market.watch(&symbol)?; // 在这次调用退栈之后才送达,所以它不会重新进入引擎: // story 和脚本 View 会一起重渲染。 cx.notify(); Ok(HostValue::from(watched)) }) })? }) } gpui_kit::shell::export_module(market_module(&market))?; ``` 用它的脚本是这样——读的是旁边那个 Rust 面板正在渲染的同一个 `Market` entity: ```js import { quotes, watch } from "market"; const rows = quotes(); const watched = rows.filter((quote) => quote.watched).length; ``` `cargo run -- shell` 跑起来。两个面板通过两条路径读同一个 entity,一旦对不上就会立刻看出来。 ## 还没有的东西 - **类和对象身份。** 模块导出的是函数。导出一个类意味着把一个活的 Host 对象交给脚本,这被上面那条纯数据边界排除了;今天用一个返回记录的工厂函数就能做同样的事。 - **同一注册表内的按函数授权。** policy 授予的是 Host 组装好的那个注册表,不会再为每个函数加一个开关。 - **向 Host 流式传输或回调。** 脚本不能把函数交给 HostModule;模块只能被调用。 --- # The Engine Seam Source: /versions/v0.7.1/zh-CN/shell/engine 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/shell/engine 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 脚本引擎位于一条内部接口之后。这条分界线(seam)之上的一切——元素描述 arena、把描述变成真实元素的 `materialize`、CallScope、样式表、主题、能力模型、浮层 Host 、hot-reload——都与引擎无关,只有引擎模块知道脚本值长什么样。 ```bash cargo run -p gpui-shell -- examples/js_todolist ``` [QuickJS](https://github.com/quickjs-ng/quickjs) 经由 [`rquickjs`](https://github.com/DelSkayn/rquickjs) 随发行版一起提供——后者 vendored 的是 `quickjs-ng` 这个分支——它也是今天唯一的引擎。它仍然放在 `quickjs` 这个 cargo feature 之后,不启用任何引擎构建是**编译错误**,而不是编出一个什么都不导出的 crate。 ## 为什么会有这条分界线 引擎选择是这个运行时里唯一无法在纸面上判定的决定。 其余的设计都可以从 GPUI 的元素模型推出来,在白板上就能争清楚。引擎不行,因为整套方案的成立与否取决于一个数字:**脚本代码描述一个真实界面需要多久。** builder 链上的每一次方法调用都是一次跨语言边界,如果这笔单次调用成本过高,任何设计都救不回来。 变的是这个数字*拿来跟什么比*。脚本的一次 `render` 不再等于一帧渲染:View 失效时会重建描述,[之后请求的帧可以复用它](/versions/v0.7.1/zh-CN/shell/state#render-什么时候执行)——所以下面这笔脚本成本是按失效付,不是按重绘付。这让边界成本不像从前那么关键,但它并没有变成免费,而且仍然是决定要不要引入第二个引擎的那个数字。 所以这条分界线是一种“不必提前判断正确”的做法。决定交给实测,而第二个引擎会是一个新模块,不是一次重写。 JavaScript 是默认,理由只有一条,而且是产品理由不是技术理由:**应用代码用它写出来更好读。** 呈现权在脚本手上,应用的绝大部分就是组合元素、写样式、处理事件——这类代码的可读性直接决定这个运行时值不值得用。类、箭头函数、模板字符串与解构,恰好落在这类代码上。附带收益是 JavaScript 在模型训练语料里覆盖最好,这对[三种场景](/versions/v0.7.1/zh-CN/shell#适用场景)之一是决定性的。 代价也如实写出来:QuickJS **没有 JIT**——它是字节码解释器,热点循环与单次调用成本原则上都赢不了带 JIT 的引擎。这是一笔真实的取舍,而下面的基准正是它一旦要紧就会显形的地方。 ## 那次实测 这里有三笔不同的成本,把它们当成一笔正是最初的错误。基准测试描述一个 40 × 5 的样式化单元格网格——443 个描述节点,每个约十次记录操作——并把每笔成本分开报告: ```bash cargo test -p gpui-shell --release --lib benchmark -- --nocapture ``` | | 测的是什么 | 443 个节点 | 什么时候付 | | --- | --- | --- | --- | | **A** | 脚本 → Snapshot | **1.4 ms** | 基准中每次失效 | | **B** | Snapshot → GPUI 元素 | **0.7 ms** | 基准中每次 materialize | | **C** | 一整帧的缓存重绘 | **1.8 ms**,**没有执行脚本 `render`** | 基准中每次重绘 | 要用 release 跑,否则数字没有意义。本页所有绝对数值都来自一台 MacBook Pro(M3,8 核,24 GB)上的 release 构建,会随机器变化。 **C 既有断言,也有计时。** 对一个未变化的 View 重绘五十次,没有执行这个 View 的脚本 `render`;若执行了,基准会直接失败。这项测试没有测量空闲窗口的重绘频率,也不能排除其他界面中的帧路径回调。 只测一个规模,看不出这三笔成本里哪几笔会随规模增长,所以第四个测试把同一个面板一路放大到 8,403 个节点。它挂在 `--ignored` 之后,因为最大的那一档要跑好几秒: ```bash cargo test -p gpui-shell --release --lib benchmark -- --ignored --nocapture ``` 描述一次,443 个节点是 1.1 ms;面板放大到 2,103、4,203、8,403 个节点,则依次是 5.1、10.3、20.5 ms。而一次实测的缓存重绘(包含 GPUI 布局与绘制)对应是 1.3、5.9、12.0、27.0 ms。两笔耗时都大致随节点数增长;本测试的缓存重绘不会重新执行该 View 的 JavaScript。对这个负载,可以得出三点: - **4,203 个节点时,Snapshot 明显改变帧预算。** 实测 12 ms 的缓存重绘落在 60 Hz 的 16.67 ms 预算内;假如每帧再加上 10.3 ms 的描述成本,就会超过预算。这是针对该基准的预算比较,不是应用持续达到 60 FPS 的实测结论。 - **描述这笔成本没有消失,只是挪了位置。** 8,403 个节点的约 20 ms 在 View 失效时支付,而不是每次缓存重绘都支付;但它仍然是约 20 ms,所以单次调用成本依旧是评判第二个引擎的那个数字。 - **最大测试规模下,缓存帧不执行脚本 `render` 仍然很贵。** 实测 27 ms 的重绘包含 GPUI 元素、布局与绘制工作。如此大的 View 值得考虑虚拟化;单换更快的脚本引擎不能消除这笔实测帧成本。 把 A 对照设计给出的预算,答案仍是“在预算之内,但余量比预期少”:目标是一次脚本 `render` 花 1.5 ms,实测达标;但那份预算是按 800 个节点、每次记录操作约 150 ns 推出来的,而实测是 443 个节点上约 320 ns,三倍规模的面板放不进一次描述。变的是这件事有多要紧。假设旧模型在一秒内的 120 帧中每帧都重建这份 1.4 ms 的描述,仅描述就要花 168 ms;当前缓存帧路径只在失效时支付脚本成本。设计为超大面板列出的三条手段——压低单次调用成本、缓存未变化的子树、对长列表做虚拟化——依然[还没有实现](/versions/v0.7.1/zh-CN/shell/elements#还没有的东西),但它们现在是优化项,不再是前提条件。 同一次实测还带来了两个实现选择,今天在运行时里看得到: - **元素是共用同一个原型的普通对象**,样式方法由一段 JavaScript prelude 遍历名字列表装到该原型上。不是每个元素一个 class,不是每次属性访问新建闭包,也不是 3000 个 Rust 闭包。 - **带诊断的 `Proxy` 原型不是默认。** 用 `Proxy` 包住原型以便说出拼错的方法名,代价是整个描述过程的约 30%;所以运行时保留普通原型,只在一次渲染失败后用诊断原型重跑一遍,纯粹为了产出那条信息。见 [Styling](/versions/v0.7.1/zh-CN/shell/styling#未知方法)。 ### 实时行情负载实测 合成基准把各项成本拆开测;Longbridge 行情终端则让它们在同一个真实负载中同时发生。下面的样本来自 release 构建,窗口处于活动状态,显示器分辨率为 3,840 × 2,160、刷新率为 144 Hz。Watchlist 持续接收实时行情,同时显示选中标的的详情与五日价格图。120 FPS 的目标对应每帧 **8.33 ms** 预算;屏幕刷新率与目标是两个不同数字,也都不保证实际达到的帧率。 通过可选的运行时计数器按一秒区间采样,并将脚本描述成本与 native materialize 分开: | 测量项 | 实测范围 | | --- | --- | | 完整 JavaScript `render` 加 Spec recording | 每次脏渲染 **12.0–13.5 ms** | | Snapshot materialize | 每次 **0.93–1.08 ms** | | 行情更新触发的脚本渲染 | 每秒 **8–20 次** | | 窗口活动时的 materialize | 每秒 **59–78 次** | 一次活动窗口下的 FPS HUD 样本为 **69 FPS**、帧时间 **10.9 ms**、掉帧率 **18.3%**。HUD 的测量包含 GPUI layout 与 paint,因此不能与前两项运行时计数直接互换,但它确认了端到端负载没有达到 8.33 ms 的目标。 有效结论比“JavaScript 很慢”更具体:未变化的 Snapshot 能在约 1 ms 内 materialize,明显低于一帧预算;但行情造成一次脏更新时,应用会重建并记录完整描述,完成 materialize 之前总计要花约 12–13.5 ms。因此,这个负载的主要成本是反复使根 Script View 失效;只优化 native materializer 无法恢复 120 FPS。 这些数据有意排除了 debug 构建,也排除了窗口失去活动状态后的样本。两者都会显著改变调度与帧呈现,FPS 不适合用于架构比较。这组数据也是工作负载实测,不替代上面可复现的 crate 基准:行情频率、可见内容、硬件与显示时序都会改变绝对值。 ## 线程与内存 VM 与 GPUI 的 `App` 共用一个线程——主线程——在同一个进程里。`ShellRuntime` 是一个内部用 `RefCell` 的 `Rc`,既不是 `Send` 也不是 `Sync`。这里没有 worker,也没有第二个 VM。 Host 进程。主线程上,GPUI 的 App 与 QuickJS VM 通过 FFI 边界互相调用普通函数。后台 worker 处理计时器与阻塞 I/O,再回到前台执行器 settle,期间不接触 VM。内存分为四块:上限 256 MiB 的 JavaScript 堆、由 Snapshot 拥有的描述 arena、按 Snapshot generation 索引的回调 arena,以及只存活一次绘制的 GPUI 帧 arena。 Host 进程。主线程上,GPUI 的 App 与 QuickJS VM 通过 FFI 边界互相调用普通函数。后台 worker 处理计时器与阻塞 I/O,再回到前台执行器 settle,期间不接触 VM。内存分为四块:上限 256 MiB 的 JavaScript 堆、由 Snapshot 拥有的描述 arena、按 Snapshot generation 索引的回调 arena,以及只存活一次绘制的 GPUI 帧 arena。 后台工作从不接触 VM。计时器(`cx.sleep`、`cx.timer`)在那里倒计时,文件、进程、fetch、TCP 与 WebSocket 也把阻塞工作交给那里。结果回到前台执行器 settle,所以 JavaScript 续体仍在主线程、在一个 `Task` scope 里执行。元素产出之后,GPUI 也会用自己的线程完成自身工作。 做性能分析时,有三条推论要紧: - **一次 builder 调用就是一次函数调用。** 它只跨过 FFI 边界,此外别无其他——没有序列化,没有 IPC 往返,除了参数本身的转换之外也没有拷贝。基准测试按记录下来的操作数报告这笔成本,四档面板测下来落在 **240–340 ns**。 - **脚本工作仍与 UI 共用线程。** 文件、进程、fetch、TCP 与 WebSocket 会把阻塞工作交给后台 worker,再回到 foreground executor settle;但 JavaScript 计算与 HostModule 调用仍和 GPUI 在同一线程上,必须保持有界。 - **失控的脚本无法从另一个线程抢占。** 切断它的是解释器自己的中断——`render` 里 50 ms、事件处理器里 500 ms——而且 `catch` 吞不掉它。 内存分成四块,各有各的归属,也各有各的释放时机: | 是什么 | 待在哪里 | 什么时候释放 | | --- | --- | --- | | 对象、闭包、模块作用域 | QuickJS 堆,上限 256 MiB | 它自己的 GC 跑过,或者运行时销毁 | | 元素描述 arena | Rust 侧;移交给它产出的那份 Snapshot | 那份 Snapshot 销毁时 | | 已注册的回调 | Rust 侧的 arena,按 Snapshot 的 generation 索引 | 那份 Snapshot 销毁并退役它那一代时 | | GPUI 元素 | GPUI 自己的帧 arena | 构建它们的那次绘制结束时 | 一个 View 持有的是**两份** Snapshot 而不是一份:当前生效的那份描述,以及被它替换掉的上一份。上一份要多留一代,因为一个已经在途的帧可能仍在读它,提前释放会把那一帧还需要的回调一并退役掉。 跨过边界的东西没有一个是对象。元素句柄是指向 arena 的一个整数下标;由 Host 留存的状态——`InputState` 的 rope、光标与选区——待在一个 GPUI 实体里,脚本通过句柄寻址它;每一个参数与返回值都是纯数据。 ## 链接它要付多少 Host 在取这个依赖之前必须知道两个数:二进制大多少,内存多占多少。 测的是本仓库里最小的两个真实程序,这样得到的是这个 crate 的代价,而不是某个应用碰巧还装了什么的代价: | | `hello_world` | `gpui-shell` 跑 `js_todolist` | 增加 | | --- | --- | --- | --- | | 二进制,stripped | 12.6 MiB | 26.1 MiB | **+13.5 MiB** | | 二进制,unstripped | 16.5 MiB | 33.8 MiB | +17.3 MiB | | 常驻内存 | 67 MiB | 81 MiB | **+14 MiB** | `hello_world` 是 41 行 Rust,跑在 `gpui` 和 `gpui-component` 上——一个窗口和一个计数器。`gpui-shell` CLI 是能跑起一个脚本应用的最小 Host;这里它跑的是 `examples/js_todolist`,四个模块共 519 行 JavaScript,背后是一个活的 QuickJS 运行时。内存取四次运行的中位数,丢弃缓存还冷时读数偏高的第一次;二进制是 `--release`、workspace 默认 profile,用 `strip(1)` 处理。 **+13.5 MiB 是个常数,这是这里最有用的一点。** 同一对测量放到组件 gallery 上——一个体量五倍于此的程序——stripped 增加的还是 13.5 MiB,只是占比从 +107% 变成 +19.8%。两次独立测量在三位有效数字上一致,这才让它成为关于 `gpui-shell` 的事实,而不是对某一个应用的一次读数。 内存那两行看起来矛盾,其实不矛盾。在 gallery 上这个差异**落在测量噪音里**:那个构建多次运行读数在 194–208 MiB 之间,而 14 MiB 本来就小于它自身的波动范围。最小程序能分辨出来,是因为 67 MiB 里没有那么多东西可以把它藏起来。 ### 二进制大在哪 主要不是 QuickJS。解释器本身一到两 MB,剩下的是随它一起来的标准运行时。`fetch`、`websocket` 和 `crypto` 带进了 `hyper`、`rustls`、`ring`、`h2`、一份 `webpki` 根证书库和压缩相关的 crate,而 `gpui-component` 一个都不带——`hello_world` 里没有 HTTP、没有 TLS、也没有 `tokio`。整个这套栈都是从这个 crate 进来的。 这也解释了这张表更早的一版为什么写的是 +4.7 MiB:那是标准运行时之前的数字。`fs`、`net`、`crypto`、`fetch`、`websocket`、`zlib` 都是之后才加进来的。 而且今天没有"只要元素表面、不要这些"的配置。`quickjs` 是唯一的引擎 feature 而且是 `default`,用 `--no-default-features` 构建会直接 `compile_error!`,标准运行时也在这同一个 feature 里面。把两者拆开是新工作,不是把一个已经存在的开关暴露出来。 有两处看起来能省、实际不能的地方,都是实测出来的,不是推出来的: - **去掉那五个 Shell 并不注册的上游 crate**——`llrt_fetch`、`llrt_fs`、`llrt_net`、`llrt_os`、`llrt_console`,它们此前只为一处编译期断言而成为依赖——二进制**一个字节都不变**。它们那些重量级 feature 解析到的 crate,其它依赖本来就会拉进来。不过它们还是被移除了:14 个不占字节的 crate 仍然要付编译时间和供应链审计面,而且依赖一个 Shell 刻意不用的上游 `fetch`,读起来就像它在用。 - **把 `reqwest` 收窄到 `fetch.rs` 真正用到的范围**——去掉 `charset`、`multipart`、`socks`、`stream` 和 `macos-system-configuration`,这几个那个文件一个都够不着——省下 **0.1 MiB**。 所以这 13.5 MiB 里没有水分。它就是 `hyper`、`rustls`、`ring` 和解释器本身;Host 只要想要 `fetch`、`websocket`、`crypto` 里的任何一个,就得把它们全部链接进来。 ### 每个运行时 上面这些数字对应一个运行时。挂多个的 Host——比如每个插件一个的插件 Host——每次都要付一遍引擎的构造成本:一个 QuickJS runtime 和 context、模块注册表、全局对象、 Host 安装器,以及一段 43 KB 的 prelude,每次构造都会解析一遍。[Capabilities](/versions/v0.7.1/zh-CN/shell/capabilities#沙箱) 里那个 256 MiB 的堆上限是天花板,不是预留;脚本不分配就不会占用。 ## 分界线两侧各有什么 这个比例本身就是这条分界线存在的论据:上面是真正的设计,下面是“脚本值长什么样”。 | 分界线之上——与引擎无关 | 分界线之下——由引擎实现 | | --- | --- | | 渲染 Snapshot:脚本 `render` 一次产出什么、之后的帧复用什么 | 把引擎值转换成运行时里与引擎无关的值类型 | | 元素描述 arena、一次性检查与调试树 | 模块系统的形态——ES module 加解析器,还是 `require` 加路径表 | | `materialize`:把描述变成真实 GPUI 元素,纯 Rust | 方法派发——共享原型上的函数,还是 `__index` 元方法 | | CallScope:phase、generation,以及整个 crate 唯一的 `unsafe` | 回调句柄类型 | | 样式表、有参样式与拼写建议 | 把与引擎无关的错误类型转成该语言自己的异常 | | 默认 token 调色板与颜色 token 解析 | View 如何定义——`class extends View`,还是元表 | | 能力模型与路径解析 | 沙箱中与语言相关的部分 | | 长度与颜色的转换 | | | 与引擎无关的错误类型、回调 arena、错误浮层 | | | `ScriptView`、`ShellRoot`、hot-reload | | 左侧的模块,源码里没有一处出现 VM 的名字。这才是这条分界线为真的证据:它不是一个 trait,而是“crate 的其余部分只通过十来个入口触达引擎、此外别无他途”这一事实。 在这里用 trait 反而更糟。两个句柄类型——View 类与 View 实例——在 QuickJS 一侧各自带着生命周期标注,硬套一层 trait 只会把这份复杂度搬进类型系统,而不会消掉它。 契约里最吃重的一条规则关于*什么时候*而不是做什么:**引擎的 `build_snapshot` 是进入脚本 `render` 的唯一入口,而且没有任何东西按帧调用它。** 一个会见缝插针地渲染的引擎——重绘时、悬停时、定时器到点时——会把脚本成本重新压回帧预算,而这正是这条分界线要防的耦合。基准 C 就是抓这件事的。 ## 可移植性 如果将来真的增加第二个引擎,**脚本在两者之间不可移植。** 它们会是不同的语言: View 在 JavaScript 里是 `class Counter extends View`,换一门语言就是别的写法。 必须相同的是围绕它的其余一切——绑定接口、渲染协议、phase 规则、能力模型、错误信息。设计提出的要求是行为层面的:同一个用例在两个引擎下必须产出**同一棵描述树**,同样的应用活动必须触发**同样次数的脚本 `render`**。这正是防止这条分界线腐烂成两个各行其是的运行时的东西。 ## 已知缺口:异步还没有完全落在分界线之上 这条分界线的契约目前不覆盖异步。 QuickJS 要求 Host 自己去清空 job 队列——没人来问,`await` 之后的代码就永远不执行——而这不是每个引擎都有的形态。所以调度器无法整体落在分界线之上。它需要引擎再提供两个操作:把一个 Host 任务变成脚本可等待的值,以及跑完待执行的 job。 Promise job 会在 Host 调用边界被 drain;render 如果只是发现还有 pending job,会排入一次 foreground drain,而不是在 paint 路径上执行任意 continuation。这保住了核心不变量:异步 continuation 可以让 View 失效,但一帧绝不会仅仅因为自己是一帧就重新进入 JavaScript。 在这两条被处理之前,调度器是 QuickJS 专属的。它将来要遵守的规则,与任何新增能力的规则一样:除非确实无法表达,否则加在分界线之上。 ## 为什么不用 WebAssembly,也不用独立进程 这条分界线会引出的两个问题。 `gpui-shell` 把 VM 跑在**Host 进程内、主线程上**,与 GPUI 的 `App` 在一起。正是这一点,才让单次记录调用停在 240–340 ns。独立进程会在每一次记录 builder 调用上加一次 IPC 往返;即便有了 Snapshot 把频率降下来,这份预算依然没有。同样的理由也解释了为什么没有 `Worker`:VM 与 `App` 都是主线程独占的。 wasm 目标是分界线画在这个位置的另一个理由。QuickJS 是纯 C,能编到 WebAssembly;不是每个候选引擎都能,有些还会生成机器码,这在禁止可写可执行内存的平台上是一项约束。这些事实都不决定今天的引擎,但它们是“引擎是架构的一个参数,而不是架构的一部分”这句话被写下来的原因。 --- # Capabilities Source: /versions/v0.7.1/zh-CN/shell/capabilities 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/shell/capabilities 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 脚本默认**什么都拿不到**。没有文件访问、没有剪贴板、没有进程执行、没有网络。`Capabilities::default()` 就是空集,并有一条断言把它钉在那里。 唯一的例外是存储,而且只在 manifest 这一层:没有写 `storage` 的应用会拿到属于它自己的 `localStorage`,就像浏览器不问自答地给每个 origin 一个那样。这是关于作者**要写什么**的约定,不是模型上的口子——Rust 侧的 `Capabilities` 在 Host 开口之前照样拒绝,manifest 也照样可以写 `"storage": false`。见 [Storage](#storage)。 授权由 Host 决定,因为只有 Host 知道它对即将运行的这段代码信任到什么程度。至于它主动**递出去**的东西——它自己的、有意暴露的那部分 Rust——见 [HostModule](/versions/v0.7.1/zh-CN/shell/host-module)。 View 在加载时冻结 capabilities;修改默认值只影响之后加载的应用,不会悄悄改变已经按某项授权运行的代码。 ```rust gpui_kit::shell::set_capabilities( Capabilities::new() .read_roots([application_root.clone()]) .write_roots([data_directory.clone()]) .storage(true) .exit(true), ); ``` ## 本地运行的应用被授予什么 从命令行运行一个目录,是一次明确的信任行为——与 `node app.js` 一样——所以 `gpui-shell ` 授予的是一组具体且很窄的能力: | | | | -------- | ------------------------------ | | 读 | 应用目录,以及它自己的存储目录 | | 写 | 它自己的存储目录 | | 存储 | 授予 | | 剪贴板 | **不**授予 | | 进程执行 | **不**授予 | | 退出请求 | 授予 | | 网络 | **不**授予 | 因此应用可以读自己的源码与资源、使用自己的存储,除此之外没有别的。它刻意比“全部放开”要窄,因为将来安装的插件会走同一条代码路径、由 manifest 来决定授权——而一个对本地运行足够宽松的默认,继承过去就是错的默认。 ## 拒绝信息会写明怎么修 每一条拒绝都以“要声明什么”结尾,而不只是说了句拒绝: ```text filesystem read is not granted; declare capabilities.fs.read in the manifest ``` ```text `/etc/passwd` is outside every granted read root; add its directory to capabilities.fs.read in the manifest ``` ```text storage is not granted; set capabilities.storage to true ``` ```text running `git` is not granted; add it to capabilities.fs.execute in the manifest ``` ```text process.exit() is not granted; set capabilities.process.exit to true in the manifest ``` ## Manifest 目录通过 **`gpui-shell.json`** 被识别。Manifest 是惰性数据——发现阶段只读取身份、可选版本元数据、Git 依赖与请求的权限,不执行 entry module。它识别 `id`、`name`、`version`、`shell-version`、`entry`、`dependencies` 与 `capabilities`;只有 `id`、`name` 和 `entry` 必填: ```json { "id": "com.example.quotes", "name": "Quotes", "version": "1.0.0", "shell-version": "0.7.1", "entry": "main.js", "dependencies": { "omarchy-ui": "huacnlee/omarchy-ui" }, "capabilities": { "fs": { "read": ["${pluginDir}"], "write": ["${dataDir}"] }, "network": { "hosts": ["stream.example.com"], "http": [ { "scheme": "https", "host": "api.example.com", "methods": ["GET"], "path_prefixes": ["/v1/"] } ] }, "storage": true, "clipboard": { "read": false, "write": true }, "process": { "exit": false } } } ``` `dependencies` 把裸模块名映射到一个 JavaScript package,gpui-shell 会在 entry module 运行之前从 Git 抓取它——`import { Title } from "omarchy-ui"`。字符串形式 接受严格的 GitHub 简写或完整 Git URL,可带可选的 `#ref`;显式指定 `branch` 或 `tag` 的 object 形式同样保持支持。每次加载还会把 package 链接到编辑器能找到的 位置,于是这条 import 会带上 package 自己的类型与文档。版本选择、package entry、 缓存,以及编辑器看见的东西,详见[依赖](/versions/v0.7.1/zh-CN/shell/dependencies)。 这个块里的每项授权省略时都默认**拒绝**,只有 `storage` 默认给予——要拒绝它就写 `"storage": false`。 未知字段、非法 reverse-DNS id、显式填写但不合法的 SemVer、不兼容的 `shell-version`、逃出目录的 entry,以及未知 `${...}` placeholder 都会在代码执行前令 manifest 失效。省略 `version` 时显示为 `unknown`。省略 `shell-version` 时接受当前 runtime;显式填写时,它表示应用所需的最早兼容 gpui-shell 版本。版本不低于该要求的 runtime 都会被接受。独立 CLI 会拒绝非法 manifest,不会在假设已经不一致时继续执行 entry。 每条 scoped `network.http` 规则除了 host、method 与 path 外,还会绑定请求的 scheme 与有效端口。`scheme` 默认为 `https`;`port` 默认为该 scheme 的标准端口,仅非默认 endpoint 需要显式填写。 ## `fs` ```js import * as fs from "fs/promises"; ``` 每个调用都返回 promise。`await` 它们,或者接 `.then`——另见下面关于 `render` 的提示。 | 调用 | resolve 结果 | | ------------------------------------------- | -------------------------------- | | `fs.readFile(path)` | `Uint8Array` | | `fs.readFile(path, "utf8")` | UTF-8 文本 | | `fs.writeFile(path, contents)` | — | | `fs.readdir(path)` | 按名字排序的名称数组 | | `fs.readdir(path, { withFileTypes: true })` | 带 `isDirectory()` 的 `Dirent[]` | | `fs.exists(path)` | `true` / `false` | | `fs.unlink(path)` | — | | `fs.rmdir(path)` | — | | `fs.mkdir(path, options?)` | — | ```js const source = await fs.readFile("notes.md", "utf8"); await fs.writeFile("notes.md", source + "\n"); ``` 相对路径相对某个已授权的根解析;绝对路径必须本来就在某个根之内。这套接口里的每一条路径都经过**同一个解析器**,所以不存在第二处让穿越漏洞藏身的地方。它先做归一化——`../../etc/passwd` 在到达文件系统之前就被拒绝——然后把「是否在根之内」这件事交给文件系统判定,而不是判定字符串:授权承诺的是一个**目录**,而 `data/escape/passwd` 在字面上位于根之内,一旦 `escape` 是符号链接就读到了 `/etc/passwd`。路径中已经存在的最深一段会被连同链接一起解析,其结果必须仍在根之下;解析不到任何目标的符号链接会被直接拒绝,而不是猜它指向哪里。 **授权是一个句柄,不是一个字符串。** 解析器交回一个打开的目录,它无法被诱导指向自身之外的任何东西;读、写、列目录、删除、建目录全部对着**它**执行——于是一条路径永远不会被解析两次,「判定允许」与「实际使用」之间也就没有窗口。 这一点要紧,是因为显而易见的写法行不通。先检查路径再调 `std::fs`,路径被解析了两次:检查时就在的链接会被抓住,而在两次解析**之间**替换掉某个目录组件的,会被第二次解析跟出根目录。这里用的是 [`cap-std`](https://docs.rs/cap-std)——在 Linux 上是 `openat2(RESOLVE_BENEATH)`,其他平台是逐级 `openat` 遍历。 其中三项的行为值得说明,理由都是同一个: **被拒绝的路径抛异常,而不是返回 `false`。** “你不能看”和“它不存在”是两个不同的事实,把它们合并会让脚本能一次一个布尔值地探测自己根目录之外的文件系统。 **删文件和删目录是两个调用**,和 Rust 一样——单独一个 "remove" 说不清目录算不算在内。`remove_dir` 只收空目录:写权限是按根授予的,递归删除会把一次路径笔误变成整个应用数据目录的丢失。真要这么做的脚本可以自己遍历。 **`mkdir` 就是别处那个 `mkdir`。** 不带参数时只建一层,父目录不存在就报错;`{ recursive: true }` 才把父目录一起建出来。它原来叫 `create_dir_all`——那个名字确实说清了它做什么,代价是它不是每个脚本作者已经认识的那个名字。 **`read_dir` 已排序。** 渲染列表的脚本不该自己再排一遍,也不该继承文件系统的任意顺序。 **每个调用都返回 promise。** 系统调用在主线程之外执行——磁盘要花多久没有上界,而在这里阻塞会同时卡住帧和 VM,而且卡在中断预算看不见的地方,因为那段时间花在内核里。 **拒绝仍然在调用点抛出**,而不是变成 rejected promise。能力检查几乎不花时间,留在调用线程上;而没人 await 的 rejected promise,等于没人看得见的拒绝。 `readFile` 会拒绝超过 64 MiB 的文件,并指出文件名和上限。没有这个上限的话,替代方案是一个必须塞进 JavaScript 堆的字符串——而那个堆本身也有上限——于是失败会表现为 VM 内部的内存耗尽,而不是一句你能据以行动的话。 `writeFile` 每次最多接受 8 MiB。`readdir` 最多返回 10,000 个 entry 或累计 1 MiB 的 UTF-8 文件名(先触及哪个就按哪个停止),避免恶意目录让一个 promise 造成无界分配。 **仍然不要在 `render` 里读文件** `render` 描述界面,它没法 await。在 `init` 或事件回调里读,把结果留在 View 上,拿到后 `cx.notify()`。 ## Storage [Web Storage API](https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Storage_API),和浏览器里的是同一个。不需要 import:`localStorage` 与 `sessionStorage` 是全局变量,同时也挂在 `window` 上。 ```js localStorage.setItem("todolist.items", JSON.stringify(items)); const saved = localStorage.getItem("todolist.items"); // 键不存在时为 null localStorage.removeItem("todolist.items"); localStorage.length; localStorage.key(0); localStorage.clear(); ``` | 成员 | 说明 | | ------------------- | --------------------------- | | `length` | 已存的键数量 | | `key(index)` | 该位置上的键,越界为 `null` | | `getItem(key)` | 值,键不存在时为 `null` | | `setItem(key, val)` | 存入,值会被转成字符串 | | `removeItem(key)` | 忘掉一个键 | | `clear()` | 全部忘掉 | | `flush()` | 写入落盘后 resolve | **两者只差在活多久。** `localStorage` 是 Host 放好的一个文件,跨重启存活;`sessionStorage` 是内存,随进程一起消失。这也是只有前者是一项 capability 的原因:`sessionStorage` 里的东西从不离开进程,没有什么可授权的,因此在一个什么都没授权的 Host 上它照样能用。 **值是字符串**,和 web 上完全一样——`setItem` 会把拿到的东西转成字符串。有结构的东西进出各走一趟 `JSON.stringify` 和 `JSON.parse`,这跟你在浏览器里会写的代码是同一段: ```js localStorage.setItem( "window", JSON.stringify({ title: "Notes", size: [640, 480] }), ); const window = JSON.parse(localStorage.getItem("window") ?? "{}"); ``` 每个成员都是同步的,这是刻意的:`getItem` 在 `render` 里也可达,所以值缓存在内存里,读取从缓存回答。每次渲染读一次文件是荒唐的。 **一次修改安排一次写入,而不是执行一次写入。** 文件在后台线程写出——先写临时文件再改名覆盖目标,所以写到一半崩溃留下的是之前完整的配置,而不是一个被截断的文件——并且同时只有一次写入在途,于是一连串 `setItem` 汇成一个文件,而不是一次一个文件。写入在途期间发生的改动,由下一次写入带上。 需要确认落盘时 `await localStorage.flush()`。这是相对浏览器接口唯一多出来的一个成员,它存在是因为浏览器根本不必回答这个问题——它的存储从头到尾都是同步的。它是**屏障,不是第二个写入者**:等待此前所有修改抵达磁盘,写入失败时用写入自己的错误 reject。若让它自己再写一次,就会与自动写入抢同一个临时文件,两者之间没有任何顺序保证——旧版本可能最后落盘,把新版本抹掉。 cache 与等待队列都有上限:单个存储文件序列化后最多 8 MiB,最多 4,096 个 key,单个 value 最多 1 MiB。同时最多允许 1,024 个尚未完成的 `flush()` barrier;更多调用会 reject,而不是无限增长 waiter 列表。 ### 存储在哪里 存储按应用划分,位置由 Host 选择——应用不能指定自己的存储位置,否则两个应用可以故意撞在一起。 **Host 给应用起名字,数据跟着这个名字走:** ```rust let data = gpui_kit::shell::set_bundle_id("com.example.notes")?; gpui_kit::shell::set_capabilities(Capabilities::new().write_roots([data])); ``` | 平台 | 位置 | | ----------------- | ----------------------------------------------------------------------- | | Linux 与其他 Unix | `$XDG_DATA_HOME/gpui-shell/apps//store.json`,默认 `~/.local/share` | | macOS | `~/Library/Application Support/gpui-shell/apps//store.json` | | Windows | `%APPDATA%\gpui-shell\apps\\store.json` | id 就是身份,所以目录被改名、被移动、被一次升级整个替换掉,数据都还在——这正是用户说"我的设置"时指的东西。改用路径作 key,一次升级就等于悄悄让用户从头开始。 **运行时不会去某个文件里找这个 id。** 只有安装了这个应用的那一层知道它叫什么;运行时自己挑一个 manifest 去读,等于对一件不属于它的事情宣称权威。 被"指向"某个目录的 Host——这个命令行、一个 dev server——没有这样一个名字,而在那种情况下路径确实就是身份。`gpui_kit::shell::bundle_id_for_path(root)` 用目录名加完整路径的摘要造一个,于是同一个目录总是访问到同一份数据,同一份源码的两个 checkout 也互不干扰。这在你正在编辑它时是对的,在它已经被安装之后是错的——而这正是声明一个真名字带来的区别。 id 允许 `a-z`、`0-9`、`.`、`-`、`_`,不允许 `..`。这不是整洁问题:它会被拼到用户数据目录后面,没检查的 id 能够到目录里的其他东西。数据放在那里而不是应用内部,因为应用目录可能只读、往往是一个 git checkout,也不是用户预期自己数据所在的地方。 ### 未被授权时的退化 未被授权的 `localStorage` 会抛异常,而写得好的应用会把它当作关于 Host 的一个事实,而不是一个错误: ```js // storage.js —— 取自示例应用 export function load() { try { const saved = localStorage.getItem(KEY); if (saved === null) return []; const items = JSON.parse(saved); return Array.isArray(items) ? items : []; } catch (error) { console.warn( `todolist: storage unavailable, starting empty (${error.message})`, ); return []; } } ``` 示例的页脚随后会在界面上说明这一点——“Not saved — this host did not grant storage, so the list lasts for this run only”——这才是对的形态:在边界处吸收拒绝,并对用户说实话。 ## 剪贴板 ```js cx.write_to_clipboard("copied"); const text = cx.read_from_clipboard(); // 剪贴板中没有文本时为 undefined ``` 名字取自 `App::write_to_clipboard` 与 `App::read_from_clipboard`,挂在 `cx` 上是因为 GPUI 就放在那里。不需要 import。 读与写是**两项独立授权**,拒绝信息会指出缺的是哪一半: ```text writing the clipboard is not granted; declare capabilities.clipboard.write in the manifest ``` 剪贴板需要一次实时的 Host 调用——GPUI 的 `App` 只在一次调用期间存在——所以一个没有活调用的 `cx` 会直说,而不是 panic: ```text cx.read_from_clipboard() needs a live host call; call it from render, an event handler or a task ``` ## `console` ```js console.info("loaded", count, { source: "disk" }); console.warn("could not save"); ``` `debug`、`log`、`info`、`warn` 与 `error`。它是全局的——与其他 JavaScript 运行时一样——不需要 import;shell 原本把同一个对象再以 `gpui.log` 导出了一遍,那只多了一个名字,别的什么也没多。 **不需要任何能力**:能跑起来的脚本本来就能说话,禁掉它只会让作者失去自己的诊断信息,别的什么都拦不住。 多余的参数会以空格分隔追加在后面,与 `console.log` 的行为一致。结构化的值以 JSON 打印,因为那是读日志的人想看到的形式。 输出通过 `tracing` 走,target 是 `gpui_kit::shell::script`,所以在日志过滤里脚本输出与 Host 输出是可分开的。**没有安装 `tracing` subscriber 的 Host 会把这些全部丢弃**——连同运行时自己报告的抛异常的处理函数、未处理的 rejection 与 phase 非法的调用。`gpui-shell` 二进制安装的是一个 `INFO` 级别的 stderr sink,`--dev` 下是 `DEBUG`。 ## `process` ```js import process from "process"; // 同时也是一个全局 const { code, stdout, stderr } = await process.run("git", ["status"]); process.exit(0); ``` `process.run` 返回 promise,理由是 `fs` 那条的加强版。文件读取没有时间上界;子进程连上界的影子都没有——它可以算上几分钟、等一个永远不来的输入,甚至活得比窗口还久。在这个线程上等它,会把帧和 VM 一起卡住,而且卡在内核里,interrupt budget 看不见。 输出是**捕获的,不是继承的**:跑一条命令的脚本几乎总是想要它说了什么,而在一个窗口程序里,子进程往 Host 的 stdout 写,是写到没人会看的地方。`code` 成功时是 `0`,被信号杀死时是 `-1`——那种情况本来就没有退出码。 执行是有界的:30 秒、stdout 8 MiB、stderr 8 MiB。触及任一上限都会终止并回收子进程,同时 reject promise。取消所属任务或销毁 runtime 也会终止子进程。子进程从清空的环境开始,不会继承 Host secret;shell 也不提供添加环境变量的选项。 它受执行授权约束,授权有三种形态:拒绝(默认)、命令名白名单,或不受限。被拒绝的命令**在调用处抛出**而不是 reject,和被拒绝的 `fs` 路径一样——没人 await 的 rejected promise,等于没人看见的拒绝。 `process.exit` 在运行时内部是**一个请求,绝不是 `exit(2)`**。它把退出码交给 Host 安装的处理函数,由后者决定怎么做——关闭插件的面板、关闭窗口、结束进程。一个插件不能把 Host 进程带走,而 Host 可能还有未保存的状态。 处理函数不是可选的:授予了这项能力却没有安装处理函数的 Host,会让这次调用**直接失败**并指明是 Host 漏了什么。没人应答的请求比拒绝更糟,因为脚本分辨不出这两者。`gpui-shell` 这个二进制安装的是「 Host 本身就是进程」时该有的策略——按脚本要求的退出码结束进程。 这个名字上的撞车是刻意的。`process` 正是 JavaScript 作者——或者生成 JavaScript 的模型——会去伸手拿的名字,所以运行时把自己受能力约束的接口放在那里,而不是把这个名字空着、任其看起来像 Node 的却行为不同。 `process.exit` 使用独立的 `capabilities.process.exit` 授权。文件系统访问权不会隐式获得关闭面板、窗口或进程的权限。 ## 沙箱 除了能力授权之外,运行时还会裁剪语言本身。以下全部在**未开启开发模式**时生效。 **没有动态代码。** `globalThis.eval` 被直接删除——`ReferenceError` 不会被特性探测误认为是一个可用的 `eval`,而一个抛异常的桩会。四个函数编译器全部被替换:`Function`,以及通过 `(async function(){}).constructor`、`(function*(){}).constructor` 和异步生成器等价物可达的那三个。`Function` 是被*替换*而不是删除,并保留了真正的 `Function.prototype`,所以 `x instanceof Function` 与 `.call` / `.apply` / `.bind` 继续可用,只有构造会抛异常。 **冻结内建原型。** `Object`、`Array`、`Function`、`String` 与 `Number` 的原型被冻结。一个 VM 将来会承载多个插件,这使得这些原型成为共享可变状态:一个插件给 `Object.prototype` 加一个可枚举属性,就改变了其他所有插件以及运行时自身 prelude 的 `for...in`。代价是真实的——一个给 `Array.prototype` 打补丁的库会在 import 时就停止工作——所以明知要运行这类库的 Host 可以关掉冻结,并保留沙箱的其余部分。 **模块解析被限制在应用根目录内。** `import "./ui.js"` 相对发起 import 的文件解析;任何解析到应用目录之外的结果都会被拒绝。动态 `import()` 刻意保持可用——延迟加载将来靠它——并且由同一个解析器约束。 **资源上限**,让失控的脚本报错而不是把窗口一起带走: | 上限 | 值 | | ------------------------------------------ | ---------------------------------------------------------------------------- | | 堆 | 256 MiB——泄漏表现为一个可捕获的 JavaScript 异常,而不是整个 Host 被 OOM kill | | 解释器栈 | 1 MiB——深递归表现为 `RangeError`,而不是原生栈溢出 | | 已加载的 JavaScript module | 每个源码文件 8 MiB | | 尚未完成的 host task | 每个 runtime 1,024 个 | | 单次调用耗时:render 与 layout | 50 ms | | 单次调用耗时:event 与 task | 500 ms | | 单次调用耗时:不在任何调用中,例如模块求值 | 5 秒 | 时钟在每一次 Host 调用时重置,这正是渲染路径能比事件回调有更紧预算的原因。**中断无法被 `catch` 吞掉**——这一点有测试来度量,因为如果能被吞掉,中断就根本不是一道防线。每个 WebSocket 另有一条由 `read`、`write` 与 `close` 共用的 8-command 队列;队列已满时新操作会 reject,并要求调用方等待 outstanding work。 这里没有 quickjs-libc 的 `std`:quickjs-libc 从一开始就没有被编进这个构建。运行时仍提供下文列出的、经过审计的小型 `os` 模块。 **开发模式** `--dev` 会启用源码监听,并在构造运行时之前调用 `gpui_kit::shell::set_development_mode(true)`。它会恢复动态代码构造器并让内建原型保持可写。 开发模式从不放宽能力约束。它让语言更好摆弄,但不会发出任何人没有声明过的访问权限——因为一项作者从没写下来的授权,就是一项在生产环境里会缺失的授权。 ## 网络与安全标准 API 全局 `fetch(url, options?)` 返回 promise,结果提供 `{ status, ok, url, , json() }`。它的授权比原始网络更窄:每次请求与 redirect 都必须匹配声明的 HTTP host、method,以及精确 path 或 path prefix;HTTPS 永不降级到 HTTP,authorization 与调用方 header 也不会跨 origin。 `net.connect(host, port)` 与 `websocket` 模块具名导出的 `WebSocket.connect(url, { headers? })` 使用 `capabilities.network.hosts`。`WebSocket` 不会安装成浏览器全局,也不是构造器。Raw TCP 的 `read()` 返回 `Uint8Array`,到达 EOF 时返回 `null`,因此传输分块不会经过有损文本解码。WebSocket 支持文本与 `Uint8Array` 消息,并通过单一 actor 串行化写入;它不会跟随 redirect。Connect、handshake 与 write 操作都有 30 秒 timeout。每个 socket 同一时间只允许一个 outstanding `read()`;第二个会立即 reject,而不是与第一个争抢下一条消息。凭证 header 与握手控制 header 会被拒绝。Raw TCP 与 WebSocket 权限有意比 HTTP request grant 更宽。 DNS 解析是有界的进程级共享服务:所有应用共用两个 resolver worker 和一个最多 64 个请求的队列。排队沿用每次连接已有的 deadline,所以饱和时会以 timeout 失败,不会无界增长内存或线程。这是资源收敛,不是每应用的服务质量保证;同一进程中运行互不信任应用的 Host,不会获得应用之间的 DNS 公平性。 运行时还提供 `buffer`、`path`、`url`、`crypto`、`zlib`、`console`、`process` 与 `os`。它们是生成的 `gpui-kit.d.ts` 所声明、经过审计的 LLRT/Host 子集;`node:` 别名和任意 Node 内建模块不属于 shell 契约。 ## 还没有的东西 - **向用户询问授权。** 授权在应用加载之前就已决定,不会在使用的那一刻弹出询问。 --- # Styling Source: /versions/v0.7.1/zh-CN/shell/styling 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/shell/styling 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 呈现权在脚本,所以一个应用的大部分代码都写在这里。所有元素接受同一套样式接口,写成一条流式链——与 Rust 侧写的一模一样: ```js render(cx) { return v_flex().size_full().bg(cx.theme().colors.surface).p(12).gap(8).rounded(6); } ``` ```rust // 同一件事,Rust 侧、基于 gpui-base。 v_flex().size_full().bg(surface).p(px(12.)).gap(px(8.)).rounded(px(6.)) ``` ## 统一的样式 API 所有样式方法都通过同一套链式 API 调用。根据 GPUI 是否能够自动导出方法信息,实现分为以下两类。 **无参方法来自 GPUI 的反射表。** `flex_col`、`items_center`、`gap_2`、`rounded_md`、`text_sm`、`size_full`、`font_semibold`、`truncate`、`cursor_pointer`——整个家族都取自 `gpui_kit::base::styled_ext_reflection_methods` 与 `gpui_kit::styled_reflection::methods`,零维护成本。这些名字没有一个写在运行时的任何地方。上游 GPUI 新增一个样式方法,脚本接口就有了,生成的 `gpui-kit.d.ts` 也有了。 本文写作时的这次构建里有 **3,148** 个。这个数字就是 GPUI 当前有多少个 `fn(self) -> Self` 形态的样式方法,GPUI 变它就变。`gpui-shell types` 会打印你这次构建的准确数字。 **有参方法无法被反射**,所以有 **57** 个是手工绑定的。这份列表是样式层里唯一手工维护的表,而且刻意保持很小。 两类方法不会重名。测试会检查每个名字只出现一次,发现冲突时直接让构建失败。 ## 长度 裸数字是像素。字符串自带单位。 ```js .p(12) // 12px .w("50%") // 父容器的一半 .h("auto") .gap("0.5rem") ``` 某个方法接受其中哪些,取决于**它的 Rust 签名**——因为正是那个签名在拒绝不合法的形式。GPUI 有三种互相嵌套的长度类型,运行时保留了这个区分而没有把它拍平: | 类型 | 接受 | 拒绝 | | ---------------- | --------------------------------------------- | ---------------- | | `Length` | 数字、`"12px"`、`"1.5rem"`、`"50%"`、`"auto"` | — | | `DefiniteLength` | 数字、`"12px"`、`"1.5rem"`、`"50%"` | `"auto"` | | `AbsoluteLength` | 数字、`"12px"`、`"1.5rem"` | 百分比、`"auto"` | ```text `p` cannot be "auto"; it expects a definite length such as 12 or "50%" ``` ```text `rounded` expects an absolute length such as 8 or "0.5rem"; percentages and "auto" are not allowed here ``` `"auto"` 的内边距和百分比的圆角,在底层布局引擎里没有含义;接受它们的运行时就必须自己发明一个含义。 ### 有参方法一览 | 家族 | 方法 | 参数 | | ------ | -------------------------------------------------------------------------- | ---------------- | | 尺寸 | `w` `h` `size` `min_w` `min_h` `min_size` `max_w` `max_h` `max_size` | `Length` | | 内边距 | `p` `px` `py` `pt` `pb` `pl` `pr` | `DefiniteLength` | | 外边距 | `m` `mx` `my` `mt` `mb` `ml` `mr` | `Length` | | 定位 | `inset` `top` `bottom` `left` `right` | `Length` | | Flex | `gap` `gap_x` `gap_y` | `DefiniteLength` | | Flex | `flex_basis` | `Length` | | Flex | `flex_grow` `flex_shrink` | 数字 | | 边框 | `border` `border_t` `border_b` `border_l` `border_r` `border_x` `border_y` | `AbsoluteLength` | | 圆角 | `rounded` 及 `_t` `_b` `_l` `_r` `_tl` `_tr` `_bl` `_br` 各形式 | `AbsoluteLength` | | 绘制 | `bg` `text_color` `text_bg` `border_color` | 颜色 | | 绘制 | `text_size` | `AbsoluteLength` | | 绘制 | `line_height` | `DefiniteLength` | | 字体 | `font_family` | 字符串 | | 绘制 | `opacity` | 数字 | `line_height` 是唯一值得专门记住的例外:**裸数字是倍数,不是像素**。`line_height(1.45)` 表示字号的 1.45 倍,因为业界其他地方都是这个含义,而 1.45px 从来不是任何人的意思。字符串仍然走普通的长度语法。 ### 刻意没有绑定的 `shadow`、`cursor`、`text_align`、`text_overflow`、`font_weight` 与 `scrollbar_width` 接受的是 Rust 结构体或枚举而不是标量,因此没有作为有参方法暴露。它们每一个都有一个被反射到、今天就能用的无参形式:`shadow_sm`、`cursor_pointer`、`text_center`、`truncate`、`font_bold`。真正的 shadow API 应当与 token 工作一起做,而不是做成一串位置参数。 ## 颜色 颜色通常从调用期主题读取。语义 token 名字符串仍为兼容性保留,固定颜色也可以使用十六进制字面量: ```js render(cx) { return element .bg(cx.theme().colors.surface) // 跟随主题 .text_color("#1e88e5"); // 不跟随 } ``` 调色板定义了十七个 token: | | | | ---- | -------------------------------------------------------------------- | | 基底 | `background`、`foreground` | | 表面 | `surface`、`surface_foreground` | | 强调 | `primary`、`primary_foreground`、`secondary`、`secondary_foreground` | | 弱化 | `muted`、`muted_foreground` | | 高亮 | `accent`、`accent_foreground`、`selection` | | 危险 | `destructive`、`destructive_foreground` | | 框架 | `border`、`input`、`ring` | 十六进制字面量接受 `#rgb`、`#rrggbb` 与 `#rrggbbaa`。 **优先使用 `cx.theme().colors` 中的值。** 字面量绕开了主题,切换主题时不会波及到它。示例应用恰好说明了这一点:它沿用 `crates/base/examples/showcase` 的视觉语言——那份 Rust 示例只能写死颜色,因为 Base 不带调色板——而示例应用读的是语义 token,因此同一份代码能跟随主题,Rust 版的 showcase 做不到。 拼错 token 会列出整个集合,而不是含糊地失败: ```text unknown color token `surfacee`; expected one of: background, foreground, surface, … — or a #rrggbb literal ``` ### 当前 token 来自哪里 gpui-shell 不拥有调色板或主题文件格式,而是读取 Host 提供的 `gpui_kit::base::Theme`。JavaScript 应用也可以通过 `set_theme({ appearance, tokens })` 替换同一份 Base Snapshot;主题名称和 registry 始终属于应用状态。 ## 状态样式 `hover`、`active` 与 `focus` 接受一个函数,函数收到一个用于收集声明的游离元素: ```js renderSave(cx) { return Button.new("save") .bg(cx.theme().colors.surface) .border(1) .border_color(cx.theme().colors.border) .hover((style) => style.bg(cx.theme().colors.muted).border_color(cx.theme().colors.foreground)) .active((style) => style.bg(cx.theme().colors.border)) .focus((style) => style.border_color(cx.theme().colors.ring)) .child("Save"); } ``` 函数的返回值会被忽略,所以链式写法和块状写法都能用。里面写的就是**普通的样式方法**——“什么是样式”没有第二套语法,上面所有长度与颜色规则原样适用。 有两处实现细节会泄漏到使用者这边,值得知道: - **`active` 与 `focus` 需要稳定的元素身份。** 普通 `div` 会按需获得一个,由它在描述中的位置推出;只要树是稳定的,这个身份跨渲染就是稳定的。`Button`、`Checkbox` 与 `Input` 本来就有。 - **`Switch` 会忽略状态样式。** switch 的根节点不是可交互元素——它的 track 才是——所以挂在根上的状态样式无处落地。运行时会记一条警告,提示改为给它外面那一行加样式,而不是不声不响地丢掉这条声明。 ## 滚动溢出内容 滚动属于元素行为,不是样式声明。先为 viewport 设置有限的宽度或高度,再指定它负责的滚动方向: ```js v_flex() .id("activity") .h(240) .overflow_y_scroll() .children(this.rows.map((row) => row)); ``` `.overflow_scroll()` 同时启用两个方向,`.overflow_x_scroll()` 只启用横向滚动,`.overflow_y_scroll()` 只启用纵向滚动。稳定的 `.id(...)` 会让原生滚动位置在多次脚本 render 之间始终归属于同一个 viewport。 对应的 `.overflow_scrollbar()`、`.overflow_x_scrollbar()` 与 `.overflow_y_scrollbar()` 保持相同的滚动行为,同时绘制 gpui-component 的原生 scrollbar。它们需要稳定的 `.id(...)`,确保每个 viewport 分别保留自己的 scrollbar 与滚动位置状态。 ## 主题值 从正在 render 或处理事件的 context 读取语义值: ```js render(cx) { return v_flex() .gap(cx.theme().spacing.md) .rounded(cx.theme().radius.lg) .bg(cx.theme().colors.surface) .child(`${cx.theme().appearance}: ${cx.theme().is_dark ? "dark" : "light"}`); } ``` 这个 Snapshot 是深度只读的。`theme()` 仍作为兼容入口保留,但优先使用 `cx.theme()`。应用可以从 event 或 task 调用 `set_theme({ appearance, tokens })`,传入自己管理的完整颜色、间距与圆角 token Snapshot。gpui-shell 只把它写入 gpui-base 并重建使用 token 的脚本 View,不拥有主题名称、palette 或文件格式。 ## 原生动画 `.transition(property, policy)` 与 `.spring(property, policy?)` 会为 `opacity`、`width`、`height`、`left` 和 `top` 的后续目标变化制作动画。动画由 GPUI 原生保留并逐帧推进:脚本改变目标并调用 `cx.notify()` 后,动画帧**不会重新进入 JavaScript**。 ```js div() .id("drawer") .left(this.open ? 320 : 16) .opacity(this.open ? 1 : 0.5) .transition("left", { duration: 220, easing: "ease-out" }) .spring("opacity", { response: 260, damping: 0.85 }); ``` 参与动画的长度目标**只能是数值像素**。`"50%"`、`"1rem"` 与 `"auto"` 之类相对值无法采样成稳定的原生通道,因此会被拒绝。请给元素稳定的 `.id(...)`(控件已使用构造器 id),否则树位置变化会改变动画 identity。 ## 未知方法 ```text unknown style method `text_colour` (did you mean: text_color?) ``` 建议来自对完整名字表的 Levenshtein 匹配,阈值卡得很紧——两次编辑,对较长标识符放宽到名字长度的三分之一。给错的建议比不给更糟。 这条信息背后有一处漂亮的机制,也解释了源码里的一个数字。QuickJS 报告缺失方法时只给一句 `TypeError: not a function`,**不带属性名**,所以拼错的样式名本来会毫无线索地到达使用者。用 `Proxy` 包住元素原型可以解决这一点——代价是实测占整个描述过程的约 30%(443 个节点从 1.09 ms 涨到 1.42 ms)。 于是运行时默认使用快速的普通原型,只有当一次渲染以 “not a function” 失败时,才**用带诊断 `Proxy` 的原型把这次渲染重跑一遍**,纯粹为了产出那条信息。出错是罕见的,每次渲染多付 30% 不是。 ## 还没有的东西 - **语义状态样式。** `gpui-base` 有一层 `state_style`,为 checked、selected、disabled 定义了优先级顺序。它还没有被绑定;今天请用 `.when(condition, …)` 表达这些状态。 - **Keyframe 动画。** 已有目标值 transition 与 spring;任意 keyframe 和逐帧 JavaScript callback 仍不存在。 - **样式中的 spacing 与 radius token。** 调色板带有 spacing 与 radius 标尺,但样式方法接受的是长度而不是 token 名——只有颜色会去查 token。应用自己定义一份标尺常量即可,示例里的 `SPACE` 对象就是这么做的。 --- # Dock Source: /versions/v0.7.1/zh-CN/base/dock 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/dock 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Dock 是 `gpui-base` 的无样式停靠布局基础。它把可持久化布局模型、面板生命周期、拖放与查询行为放在 Base,把标签、边框、图标、空状态和其他产品视觉交给应用提供的 renderer。 ## 模型 布局是一棵由分栏、标签组、面板和边缘 dock 组成的树。稳定面板 ID 是移动、恢复、查询和事件关联的基础;不要用当前位置作为身份。 ### 关键类型 `DockArea` 承载工作区和交互状态;布局节点描述结构;面板 trait/工厂负责由持久数据创建内容;renderer trait 把模型映射为应用外观。 ## 开始使用 先创建持久 `DockArea` entity,注册面板工厂和 renderer,再安装初始布局。渲染期间只读取模型;添加、移动、关闭、缩放和锁定操作放在回调中执行并通知 GPUI。 ## 描述布局 用嵌套节点声明水平/垂直分栏、标签组和面板。分栏尺寸属于相邻槽位的约束数据,不属于面板视觉样式。载入外部或旧版本布局后先规范化:移除空节点、折叠无意义嵌套并修正非法比例,同时保留稳定 ID。 ## 面板与生命周期 面板提供身份、标题/元数据、渲染内容以及可选的激活、停用、关闭、保存和恢复 hook。hook 可能发生在拖放、布局替换、窗口关闭或恢复期间,必须保持幂等,不能依赖一次 render 创建的临时状态。资源与订阅跟随持久 entity 生命周期。 ## DockArea 操作 可以整体安装布局,也可以向指定标签组添加面板、在节点间移动面板、打开或关闭边缘 dock、缩放当前面板、锁定结构,以及按 ID 查询面板或节点。修改前验证目标仍存在,因为事件发生后布局可能已经变化。 ## 直接编辑布局树 直接编辑适合批量迁移和恢复,不适合普通交互。编辑后必须规范化并通过 `DockArea` 安装,使索引、焦点、事件和渲染状态同步更新;不要绕过宿主只修改一份外部副本。 ## 提供外观 `DockAreaRenderer` 负责工作区边框、边缘 dock 和空状态;`TabGroupRenderer` 负责标签、活动态、关闭入口和标签拖动反馈。renderer 接收只读上下文和明确回调,不应拥有领域状态,也不应在 render 中修改布局。 ## 拖放 拖动数据使用稳定面板身份。命中测试决定插入标签、分割方向或边缘 dock,提交前再次验证源与目标。 ## 事件与持久化 订阅布局、活动面板、面板关闭和拖放事件来同步应用状态。持久化稳定 ID、节点种类、分栏比例、标签顺序、活动项、边缘 dock 与面板自有数据,不要序列化 GPUI entity、焦点句柄或 renderer。恢复时容忍未知面板类型,并对版本化数据做迁移和规范化。 ## 架构取舍 Dock 的数据模型比单一分栏组件更重,但换来可查询、可移动、可持久化和可替换表现。Base 使用中性的 tree/panel/tab 命名,产品可以在 renderer 和面板工厂层映射成自己的术语。 ## 可运行示例 ```bash cargo run -p gpui-base-examples -- dock ``` ```rust use super::*; use gpui::{ AnyElement, Axis, Div, Entity, EventEmitter, FocusHandle, Focusable, MouseButton, MouseMoveEvent, MouseUpEvent, SharedString, Stateful, rgba, }; use std::cell::RefCell; const SURFACE: u32 = 0xffffff; const CHROME: u32 = 0xf4f4f5; const BORDER: u32 = 0xd4d4d8; const MUTED: u32 = 0x71717a; const ACCENT: u32 = 0x2563eb; const DROP_TARGET: u32 = 0x2563eb33; const TAB_BAR_HEIGHT: gpui::Pixels = px(26.); const RESIZE_STRIP: gpui::Pixels = px(4.); /// One dockable view. Its only obligation to base is a stable name; the title /// and body are this example's own, and reach the skin through a downcast of /// the handle base was given. struct ShowcasePanel { name: &'static str, title: SharedString, body: SharedString, focus_handle: FocusHandle, } impl ShowcasePanel { fn new( name: &'static str, title: impl Into, body: impl Into, cx: &mut App, ) -> Entity { cx.new(|cx| Self { name, title: title.into(), body: body.into(), focus_handle: cx.focus_handle(), }) } } impl Panel for ShowcasePanel { fn panel_name(&self) -> &'static str { self.name } } impl EventEmitter for ShowcasePanel {} impl Focusable for ShowcasePanel { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } impl Render for ShowcasePanel { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div() .size_full() .flex() .flex_col() .gap_1() .p_3() .text_xs() .child(div().child(self.title.clone())) .child( div() .text_color(super::example_rgb(MUTED)) .child(self.body.clone()), ) } } /// The preview that follows the cursor while a tab is dragged. /// /// Base's own `DragPanel` renders nothing, because a preview is appearance. struct DragPreview { title: SharedString, } impl Render for DragPreview { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div() .px_2() .py_1() .text_xs() .bg(super::example_rgb(SURFACE)) .text_color(super::example_rgb(ACCENT)) .border_1() .border_color(super::example_rgb(ACCENT)) .child(self.title.clone()) } } /// A panel's title, recovered across the renderer seam. /// /// Base carries every panel as `Arc`, which knows its /// `panel_name` and nothing else — a title is presentation, and base has no /// opinion about it. A skin gets one back by downcasting to the concrete /// handle base was handed. fn panel_title(panel: &Arc, cx: &App) -> SharedString { panel .as_any() .downcast_ref::>() .map(|panel| panel.read(cx).title.clone()) .unwrap_or_else(|| panel.panel_name(cx).into()) } /// Everything this example draws. Base draws none of it. #[derive(Clone, Default)] struct ShowcaseDockSkin { /// The dock a resize drag is currently sizing, captured on mouse down. /// /// A resize follows the pointer anywhere in the area, not only over the /// strip, so the listener that tracks it sits on the area frame — which is /// not handed a `DockContext`. The strip stashes its own here instead. resizing: Rc>>, } impl ShowcaseDockSkin { /// The strip on a dock's inner edge that resizes it: a wide hit area with /// a hairline inside, so the edge reads as a line rather than a bar. fn render_resize_strip(&self, dock: &DockContext) -> impl IntoElement { let placement = dock.placement(); let dock = dock.clone(); let resizing = self.resizing.clone(); div() .absolute() .flex() .items_center() .justify_center() .map(|this| match placement { DockPlacement::Left => this .top_0() .right_0() .h_full() .w(RESIZE_STRIP) .cursor_col_resize(), DockPlacement::Bottom => this .top_0() .left_0() .w_full() .h(RESIZE_STRIP) .cursor_row_resize(), _ => this .top_0() .left_0() .h_full() .w(RESIZE_STRIP) .cursor_col_resize(), }) .child( div() .bg(super::example_rgb(BORDER)) .map(|line| match placement { DockPlacement::Bottom => line.h(px(1.)).w_full(), _ => line.w(px(1.)).h_full(), }), ) .on_mouse_down(MouseButton::Left, move |_, _, cx| { cx.stop_propagation(); *resizing.borrow_mut() = Some(dock.clone()); }) } } impl DockAreaRenderer for ShowcaseDockSkin { fn frame(&self, _: &mut Window, _: &mut App) -> Stateful
{ let dragging = self.resizing.clone(); let finished = self.resizing.clone(); div() .id("showcase-dock") .size_full() .flex() .flex_row() .overflow_hidden() .bg(super::example_rgb(CHROME)) .on_mouse_move(move |event: &MouseMoveEvent, window, cx| { // Cloned out before the call, so the borrow is released before // resizing reaches back into another frame reading this cell. let dock = dragging.borrow().clone(); let Some(dock) = dock else { return; }; dock.resize_to(event.position, window, cx); }) .on_mouse_up(MouseButton::Left, move |_: &MouseUpEvent, _, _| { finished.borrow_mut().take(); }) } fn center_frame(&self, _: &mut Window, _: &mut App) -> Stateful
{ div() .id("showcase-dock-center") .flex() .flex_1() .flex_col() .overflow_hidden() } fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, _: &mut App) -> Stateful
{ div() .id(("showcase-dock-split", node.as_u64())) .size_full() .flex_1() .min_h(px(0.)) .overflow_hidden() } /// Only the paint: base keeps the hit area, the cursor and the drag. fn render_split_handle( &self, handle: &ResizeHandleContext, _: &mut Window, _: &mut App, ) -> Option { Some( div() .bg(super::example_rgb(if handle.is_active() { ACCENT } else { BORDER })) .map(|line| match handle.axis() { Axis::Horizontal => line.w(px(1.)).h_full(), Axis::Vertical => line.h(px(1.)).w_full(), }) .into_any_element(), ) } fn render_dock( &self, dock: &DockContext, content: AnyElement, _: &mut Window, _: &mut App, ) -> AnyElement { // A closed dock takes no space; the toolbar is what brings it back. if !dock.is_open() { return div().into_any_element(); } div() .flex() .flex_none() .relative() .overflow_hidden() .map(|this| match dock.placement() { DockPlacement::Bottom => this.w_full().h(dock.size()).flex_col(), _ => this.h_full().w(dock.size()).flex_row(), }) .child(content) .child(self.render_resize_strip(dock)) .into_any_element() } fn tab_group_renderer(&self) -> Rc { Rc::new(self.clone()) } } impl TabGroupRenderer for ShowcaseDockSkin { fn frame(&self, _: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful
{ div() .id("showcase-tab-group") .size_full() .flex() .flex_col() .min_h(px(0.)) .overflow_hidden() .bg(super::example_rgb(SURFACE)) } fn content_frame(&self, _: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful
{ // Relative, because the drop indicator is positioned against it. div() .id("showcase-tab-content") .relative() .flex_1() .min_h(px(0.)) .overflow_hidden() } fn render_tab_bar(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> AnyElement { div() .flex() .flex_row() .items_center() .h(TAB_BAR_HEIGHT) .flex_none() .overflow_hidden() .bg(super::example_rgb(CHROME)) .border_b_1() .border_color(super::example_rgb(BORDER)) .children( group .panels() .iter() .enumerate() // A hidden panel keeps its place in the tree and its tab // slot; it is the skin that leaves it undrawn. .filter(|(_, panel)| panel.visible(cx)) .map(|(ix, panel)| { let selected = ix == group.active_ix(); let title = panel_title(panel, cx); div() .id(("showcase-tab", ix)) .px_2() .h_full() .flex() .items_center() .text_xs() .cursor_pointer() .map(|this| match selected { true => this .bg(super::example_rgb(SURFACE)) .text_color(super::example_rgb(ACCENT)), false => this.text_color(super::example_rgb(MUTED)), }) .child(title.clone()) .on_click({ let group = group.clone(); move |_, window, cx| group.select_tab(ix, window, cx) }) .when_some(group.drag_panel(ix, cx), |this, drag| { this.on_drag(drag, move |_, _, _, cx| { cx.new(|_| DragPreview { title: title.clone(), }) }) }) }) .collect::>(), ) .into_any_element() } /// Base resolves where a drop would land; painting it is all that is left. fn render_drop_indicator( &self, indicator: DropIndicator, _: &mut Window, _: &mut App, ) -> Option { let to = indicator.to(); Some( div() .absolute() .left(to.origin().x) .top(to.origin().y) .w(to.size().width) .h(to.size().height) .bg(rgba(DROP_TARGET)) .into_any_element(), ) } } /// Build the area once, at showcase construction: a `DockArea` is an entity, /// and rebuilding it every frame would discard the layout the viewer arranged. pub(in super::super) fn build_dock(window: &mut Window, cx: &mut App) -> Entity { let explorer = ShowcasePanel::new( "Explorer", "Explorer", "Drag this tab into the other group to move it there.", cx, ); let search = ShowcasePanel::new( "Search", "Search", "Two panels share this tab group. Click a tab to switch.", cx, ); let editor = ShowcasePanel::new( "Editor", "Editor", "Drag a tab towards an edge of this group to split there.", cx, ); let terminal = ShowcasePanel::new( "Terminal", "Terminal", "The bottom dock shares the column with the center region.", cx, ); let problems = ShowcasePanel::new("Problems", "Problems", "Nothing here.", cx); let area = cx.new(|cx| { DockArea::new("showcase-dock", Some(1), window, cx) .with_renderer(Rc::new(ShowcaseDockSkin::default())) }); area.update(cx, |area, cx| { area.set_center( DockLayout::h_split() .child( DockLayout::tabs().panel(explorer).panel(search), Some(px(200.)), ) .child(DockLayout::tabs().panel(editor), None), window, cx, ); area.set_dock( DockPlacement::Bottom, DockLayout::tabs().panel(terminal).panel(problems), window, cx, ); area.set_dock_size(DockPlacement::Bottom, px(140.), window, cx); }); area } impl BaseShowcase { /// A toggle for one dock, so a closed dock can be brought back. fn dock_toggle( &self, placement: DockPlacement, label: &'static str, cx: &Context, ) -> impl IntoElement { let open = self.dock.read(cx).is_dock_open(placement); let area = self.dock.clone(); div() .id(label) .px_2() .py_1() .text_xs() .cursor_pointer() .border_1() .border_color(super::example_rgb(BORDER)) .map(|this| match open { true => this .bg(super::example_rgb(SURFACE)) .text_color(super::example_rgb(ACCENT)), false => this.text_color(super::example_rgb(MUTED)), }) .child(label) .on_click(move |_, window, cx| { area.update(cx, |area, cx| area.toggle_dock(placement, window, cx)); }) } pub(in super::super) fn dock(&self, cx: &Context) -> impl IntoElement { // Fills whatever the showcase gives it — the surrounding container // opts this example out of the centered, intrinsically-sized box the // smaller parts use, so a percentage size resolves here. div() .size_full() .flex() .flex_col() .overflow_hidden() .border_1() .border_color(super::example_rgb(BORDER)) .child( div() .flex() .flex_none() .items_center() .gap_2() .p_2() .bg(super::example_rgb(CHROME)) .border_b_1() .border_color(super::example_rgb(BORDER)) .child(self.dock_toggle(DockPlacement::Bottom, "Bottom", cx)) .child(div().text_xs().text_color(super::example_rgb(MUTED)).child( "Drag a tab onto another group to merge it, or towards an edge to split", )), ) .child(div().flex_1().min_h(px(0.)).child(self.dock.clone())) } } ``` ## 集成检查清单 - 面板、标签组和需要持久化的节点使用稳定 ID。 - 领域状态保存在面板或应用 entity,renderer 只负责表现。 - 布局修改只发生在事件回调,并在安装/恢复后规范化。 - 处理未知面板、空布局、关闭否决、拖放目标失效和版本迁移。 - 验证键盘焦点、标签顺序、锁定、缩放、边缘 dock、减少动态效果和高对比度。 --- # TextView Source: /versions/v0.7.1/zh-CN/base/text-view 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/text-view 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `gpui-base` 现在拥有完整的 `TextView` 实现,可渲染 Markdown 和常用 HTML。解析、链接、图片、列表、表格、代码块、滚动、行数限制、插件、文本选择和复制都不依赖 `gpui-component`。 上方可运行示例只依赖 `gpui-base`。其中 Rust 代码块特意没有着色,因为语法高亮默认不开启。 ## 设置窗口 应用启动时调用一次 `gpui_kit::base::init`,并在每个窗口渲染一个 `TextSelectionLayer`。它统一协调 `TextView`、[`SelectableText`](/versions/v0.7.1/zh-CN/base/text-selection) 和自定义文本 renderer 的选择行为。 ```rust use gpui_kit::prelude::*; use gpui_kit::{Context, Render, Window}; use gpui_kit::base::{TextSelectionLayer, TextView}; impl Render for AppView { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div() .size_full() .child(TextSelectionLayer) .child(TextView::markdown( "readme", "# Hello\n\n选择并复制这段 **Markdown**。", )) } } ``` 如果应用已经调用 `gpui_kit::component::init`,其中已包含 Base 初始化;`gpui-component::Root` 也会安装窗口选择层。 TextView 默认支持选择。拖动选区靠近视口边缘时,共享选择层会自动滚动相关的 `overflow_*_scroll` 区域,不需要额外设置 TextView 的滚动或选择参数。只有明确需要禁用选择时才使用 `.selectable(false)`。 ## Markdown 与 HTML 短内容可以使用自动生成调用点 ID 的 helper,需要明确稳定 ID 时使用构造器: ```rust use gpui_kit::base::{html, markdown, TextView}; let short_markdown = markdown("一段 **Markdown**。"); let short_html = html("

一段 HTML。

"); let preview = TextView::markdown("document-preview", markdown_source).scrollable(true); let article = TextView::html("article", html_source); ``` `scrollable(true)` 让视图填满容器并垂直滚动;未设置时视图随内容增长。`max_lines(n)` 可把非滚动预览限制在最多 `n` 行正文高度。 ## 可直接使用的默认样式 所有构造方式都会使用 `TextViewStyle::default()`。默认值已经包含可读的正文、次要文字、链接、选择色、代码背景、边框、标题、段落、行内代码和表格样式。只使用 Base 的项目不需要先定义一套样式才能显示文本。 应用可以只覆盖自己设计系统负责的颜色: ```rust use gpui_kit::base::TextViewStyle; let style = TextViewStyle::default() .with_foreground(app_colors.foreground) .with_muted_foreground(app_colors.muted_foreground) .with_link(app_colors.link) .with_selection(app_colors.selection); TextView::markdown("themed", source).style(style) ``` `TextViewStyle::from_theme(&theme)` 可读取 `gpui_kit::base::Theme` 的语义颜色。使用上层组件主题时,可调用 `gpui_kit::component::text::text_view_style(cx.theme())`。 没有显式设置 `.style()` 的 TextView 可以跟随容器设置的文字颜色,这样放在聊天气泡这类有填充底色的容器里也能看清。在应用默认值里开启: ```rust use gpui_kit::base::{TextViewDefaults, TextViewStyle}; TextViewDefaults::new() .with_style(TextViewStyle::default()) .with_inherit_text_color(true) .install(cx); ``` 正文使用继承来的颜色。如果这个颜色和样式的正文颜色在明度上相差很大,说明底色与页面明暗相反,此时链接、次要文字、代码和表格背景、边框、选择色也都从它推算,并且不再使用已安装的语法高亮。只有在每个窗口的根部都设置了文字颜色时才开启;GPUI Component 的 Root 会设置,Component 也默认开启了这一项。 ## 语法高亮由使用者开启 `gpui-base` 默认不启用语法高亮,也不包含 tree-sitter 语言依赖。应用未提供 `code_block_highlighter` 时,围栏代码块只使用中性的代码背景和普通前景色。 回调接收 `CodeBlock`,并返回 UTF-8 字节范围及对应的 GPUI `HighlightStyle`: ```rust use gpui_kit::HighlightStyle; use gpui_kit::base::TextView; TextView::markdown("highlighted", source).code_block_highlighter(|block| { my_highlighter(block.lang(), block.code()) .into_iter() .map(|(range, color)| { ( range, HighlightStyle { color: Some(color), ..Default::default() }, ) }) .collect() }) ``` 范围相对于 `CodeBlock::code()`;无效范围会被丢弃。高亮器实现和语言注册完全由应用管理。 ## Markdown 扩展 `MarkdownExtensions` 默认使用兼容 CommonMark/GFM 的解析方式。YAML frontmatter 不属于这两项标准,因此默认关闭。当 block parser 或插件处理 `markdown_ast::Node::Yaml` 时,需要明确启用该 construct: ```rust use gpui_base::{MarkdownExtensions, TextView}; let extensions = MarkdownExtensions::default().frontmatter(); TextView::markdown("metadata", source) .markdown_extensions(extensions) ``` 如果没有匹配的插件,已启用的 YAML frontmatter 会使用现有的 YAML code-block fallback。可以通过 `.plugin(...)` 挂载自定义插件; `gpui-component` 提供带主题样式的 `FrontmatterPlugin`;Base 不依赖该 presentation。 ## Inline plugin 与 Block plugin 一样,Inline plugin 实现 `MarkdownPlugin`,通过 `.plugin(...)` 注册。`MarkdownPlugin` 默认 `is_block() == false`,使用 `render_inline`;Block plugin 继续使用 `render`。 ```rust use gpui::{App, Styled, Window, div}; use gpui_base::{ InlineElement, InlineRenderContext, MarkdownNode, MarkdownParseContext, MarkdownPlugin, TextView, markdown_ast, }; struct FormulaPlugin; impl MarkdownPlugin for FormulaPlugin { fn name(&self) -> &str { "formula" } fn parse( &self, node: &markdown_ast::Node, _: &MarkdownParseContext<'_>, ) -> Option { let markdown_ast::Node::InlineMath(math) = node else { return None; }; Some( MarkdownNode::new("formula", math.value.clone()) .text(math.value.clone()) .accessibility_label(format!("Formula: {}", math.value)), ) } fn render_inline( &self, node: &MarkdownNode, _: &InlineRenderContext, _: &mut Window, _: &mut App, ) -> Option { Some(InlineElement::new(div().italic().child(node.as_text().to_string()))) } } TextView::markdown("inline-formulas", "Formulas $x^2$ and $y^2$") .plugin(FormulaPlugin) ``` `render_inline` 返回 `Some(InlineElement::new(element))`,支持任意 GPUI `IntoElement`,包括带样式的文本、图片和组合元素。样式、hover 和子元素事件直接使用原生 GPUI API。renderer 收到的 `InlineRenderContext` 包含实际文本样式、字号、行高和 rem 大小。这些渲染类型不依赖 Markdown;本例的解析和注册仍属于 Markdown API。 TextView 测量元素的固有尺寸,将整个元素作为一个原子对象排版。需要指定基线时,在 `InlineElement` 上调用 `.with_baseline(px(...))`,数值为从顶部到基线的逻辑像素距离。只能在对象前后换行。固定尺寸的元素即使超过行宽,也保留真实尺寸;需要限宽时使用 GPUI 样式约束。TextView 不会整体缩放元素子树。 当 parser 捕获值或插件配置改变,但注册名称不变时,使用 `MarkdownExtensions::parser_revision(config_version)` 触发重新解析。每次 render 重建相同配置时应保持该 revision 不变。 可以直接用原生 `HoverCard` 包裹 trigger 来显示资料卡。Markdown 示例使用 `StyledText` 设置淡色 `@` 和用户名下划线,通过 `Anchor::TopCenter` 居中定位 `HoverCard`。`[@huacnlee](mention:huacnlee)` 的纯文本复制输出账号,Markdown 复制保留原始链接语法。 选择以整个渲染元素为单位。双击选中对象,三击选中所在混排行;拖选可以双向跨越文字与连续对象。子元素事件保留原生 GPUI 行为,插件中的交互控件应与 TextView 的选择手势协调。 `source_range()` 返回包括分隔符在内的全局 UTF-8 字节范围。`.text(...)` 提供纯文本复制和降级内容;`.markdown(...)` 提供 Markdown 复制内容,默认使用节点原始源码。未提供纯文本时使用源码。`.accessibility_label(...)` 提供无障碍名称,默认使用纯文本。`render_inline` 返回 `None` 时使用原子文本降级。图片的加载中和失败内容由插件通过 `img(...).with_loading(...).with_fallback(...)` 提供。 异步资源应由应用缓存:保留 `TextViewState`,准备完成后通过弱 entity 更新缓存并调用 `state.invalidate_inline_layout(cx)`。这会重新测量行内内容和虚拟列表高度,不重解析文档,也不丢弃已有逻辑选区。缓存键应区分源码、字号和主题,过期结果应丢弃。渲染回调应读取已准备的资源,不应在布局期间同步调用公式排版引擎。`examples/markdown` 提供公式实现和预览缩放控件。 默认解析 inline math 语法,通过 Plugin 自定义渲染,无需额外开关。行内代码里的美元符号仍保留为代码。没有 Plugin 认领某个 math 节点时,TextView 按原始 `$...$` 源码渲染为普通文本,因此正文中单纯出现美元符号的句子(`spent $5 and $10`)显示和复制都保持原样。块级公式同样会被解析:`$$` 围栏产生块级节点,由 Block plugin(`is_block() == true`)渲染;无人认领时降级为代码块。 ## 保留状态与动态更新 内容需要持续更新时使用 `TextViewState`: ```rust use gpui_kit::base::{TextView, TextViewState}; let document = cx.new(|cx| TextViewState::markdown(initial_source, cx)); TextView::new(&document) document.update(cx, |state, cx| state.set_text(updated_source, cx)); ``` `TextViewMotion` 是视图的动效策略。Base 负责播放,但不带任何时长:所有时长默认为零,未加样式的视图会直接显示流式到达的文字。给 `stream_fade` 一个时长,更新追加的文字就会在落点处淡入;可选的 `stream_fade_stagger` 让同一次更新里后面的词比前一个词稍晚开始: ```rust use std::time::Duration; use gpui_kit::base::{Easing, TextView, TextViewMotion}; TextView::new(&document).motion( TextViewMotion::default() .with_stream_fade(Duration::from_millis(350)) .with_stream_fade_stagger(Duration::from_millis(30)) .with_stream_fade_easing(Easing::EaseOut), ) ``` 不设错位时每次更新整块一起淡入。设了错位时,追加的文字按词拆分(词带上其后的空白),中日韩文字按字拆分;一次追加很长时会压缩错位,保证最后一个词在一个淡入时长内开始。追踪器比较的是渲染后的文字而不是源码字节,因此 `set_text` 传入以当前文本为前缀的更长文本会被视为追加;流式过程中被补齐的 Markdown 标记(`**bo` 变成粗体 `bold`)只让发生变化的字形重新淡入,不会整段闪烁。每次只比较更新触及的块,并且只在还有文字在淡入时才请求下一帧。系统开启减少动态效果时跳过淡入。 `TextViewState::set_range_highlights` 在 `rendered_text()`(与纯文本复制得到的文字一致)的指定范围后面绘制背景,应用可以借此显示搜索结果或引用位置,无需重新解析或修改文档样式。这些范围只参与绘制、不参与排版,因此不会改变布局。Markdown 源码里的范围(例如 `selected_source_range()` 返回的范围)可以用 `rendered_text().range_for_source(range)` 转换过来。`reveal_range` 通过视图自身的列表、外层 `gpui::list`,或者其他容器上的 `TextView::on_reveal`,把范围起点所在的行滚动到可见区域内;规则详见[高亮文本范围](/versions/v0.7.1/zh-CN/component/text-view#高亮文本范围)和[滚动到范围](/versions/v0.7.1/zh-CN/component/text-view#滚动到范围)。 通过 `SelectionFormat` 可以选择复制渲染文本或 Markdown 源码。链接路由、代码块操作、表格操作、图片和 Markdown 插件继续使用与兼容 API 相同的 builder,详见 [gpui-component TextView 文档](/versions/v0.7.1/zh-CN/component/text-view)。 ## 可运行源码 网页预览和本地命令使用同一份 Base-only 源码: ```rust use gpui_base::{TextView, TextViewStyle}; use super::*; use crate::showcase::palette::ExamplePalette; pub const MARKDOWN: &str = include_str!("../../../../../examples/fixtures/test.md"); fn text_view_style(palette: ExamplePalette) -> TextViewStyle { let is_dark = palette.canvas == ExamplePalette::for_dark(true).canvas; TextViewStyle::default() .with_foreground(gpui::rgb(palette.foreground).into()) .with_muted_foreground(gpui::rgb(palette.muted_foreground).into()) .with_link(gpui::rgb(palette.resolve(0x007fff)).into()) .with_code_background(gpui::rgb(palette.elevated).into()) .with_border(gpui::rgb(palette.border).into()) .with_inline_code(gpui::HighlightStyle { background_color: Some(gpui::rgb(palette.elevated).into()), ..Default::default() }) .with_dark(is_dark) } impl BaseShowcase { pub(in super::super) fn text_view(&self, window: &Window) -> impl IntoElement { let palette = ExamplePalette::from_window(window); let style = text_view_style(palette); div() .id("text-view-example") .debug_selector(|| "text-view-example".into()) .w_full() .h(px(560.)) .max_h_full() .text_color(gpui::rgb(palette.foreground)) .child( div() .debug_selector(|| "text-view-markdown".into()) .size_full() .min_h_0() .overflow_hidden() .child( TextView::new(&self.text_view) .size_full() .px_4() .scrollable(true) .style(style), ), ) } } #[cfg(test)] mod tests { use std::time::Duration; use gpui::{ Modifiers, MouseButton, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point, px, }; use gpui_base::{TextSelection, TextViewStyle}; use super::text_view_style; use crate::showcase::BaseShowcase; use crate::showcase::palette::ExamplePalette; #[test] fn text_view_style_uses_dark_palette_colors() { let style = text_view_style(ExamplePalette::for_dark(true)); assert_eq!(style.foreground(), gpui::rgb(0xffffff).into()); assert_eq!(style.muted_foreground(), gpui::rgb(0xa3a3a3).into()); assert_eq!(style.code_background(), gpui::rgb(0x262626).into()); assert_eq!(style.border(), gpui::rgb(0x404040).into()); assert_eq!(style.selection(), TextViewStyle::default().selection()); assert!(style.is_dark()); } #[gpui::test] fn text_view_showcase_renders_with_base_defaults(cx: &mut TestAppContext) { cx.update(gpui_base::init); let (view, cx) = cx.add_window_view(|window, cx| BaseShowcase::new("text-view", window, cx)); let cx: &mut VisualTestContext = cx; cx.run_until_parked(); let example = cx .debug_bounds("text-view-example") .expect("example bounds"); let markdown = cx .debug_bounds("text-view-markdown") .expect("Markdown bounds"); let document = view.read_with(cx, |view, cx| view.text_view.read(cx).bounds()); assert_eq!(markdown.left(), example.left()); assert_eq!(markdown.right(), example.right()); assert_eq!(document.left() - example.left(), px(16.)); assert_eq!(example.right() - document.right(), px(16.)); } #[gpui::test] fn text_view_showcase_drag_selection_settles(cx: &mut TestAppContext) { cx.update(gpui_base::init); let (_, cx) = cx.add_window_view(|window, cx| BaseShowcase::new("text-view", window, cx)); let cx: &mut VisualTestContext = cx; cx.run_until_parked(); let bounds = cx .debug_bounds("text-view-example") .expect("example bounds"); // Exercise selection inside the visible, virtualized Markdown blocks. let start = point(bounds.left() + px(36.), bounds.top() + px(36.)); let end = point(bounds.right() - px(36.), bounds.top() + px(180.)); cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default()); cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default()); assert!(cx.update(|window, cx| TextSelection::has_selection(window, cx))); } #[gpui::test] fn text_view_showcase_scrolls_the_document_inside_a_fixed_viewport(cx: &mut TestAppContext) { cx.update(gpui_base::init); let (view, cx) = cx.add_window_view(|window, cx| BaseShowcase::new("text-view", window, cx)); let cx: &mut VisualTestContext = cx; cx.run_until_parked(); let viewport = cx .debug_bounds("text-view-markdown") .expect("Markdown viewport bounds"); let example = cx .debug_bounds("text-view-example") .expect("TextView example bounds"); let scroll_before = view.read_with(cx, |view, cx| { let offset = view.text_view.read(cx).list_state().logical_scroll_top(); (offset.item_ix, offset.offset_in_item) }); cx.simulate_event(ScrollWheelEvent { position: example.center(), delta: ScrollDelta::Pixels(point(px(0.), px(-120.))), ..Default::default() }); cx.update(|window, cx| window.draw(cx).clear(cx)); let after = cx .debug_bounds("text-view-markdown") .expect("Markdown viewport bounds after scrolling"); let scroll_after = view.read_with(cx, |view, cx| { let offset = view.text_view.read(cx).list_state().logical_scroll_top(); (offset.item_ix, offset.offset_in_item) }); assert_eq!( after, viewport, "the TextView viewport itself must stay fixed" ); assert_ne!( scroll_after, scroll_before, "the TextView's virtual list must consume the wheel event" ); } #[gpui::test] fn dragging_selection_scrolls_the_containing_region_without_text_view_parameters( cx: &mut TestAppContext, ) { cx.update(gpui_base::init); let (view, cx) = cx.add_window_view(|window, cx| BaseShowcase::new("text-view", window, cx)); let cx: &mut VisualTestContext = cx; cx.run_until_parked(); let markdown = cx .debug_bounds("text-view-markdown") .expect("Markdown section bounds"); let scroll_before = view.read_with(cx, |view, cx| { let offset = view.text_view.read(cx).list_state().logical_scroll_top(); (offset.item_ix, offset.offset_in_item) }); let start = point(markdown.left() + px(24.), markdown.top() + px(24.)); let edge = point(markdown.left() + px(120.), markdown.bottom() - px(2.)); cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); cx.simulate_mouse_move(edge, MouseButton::Left, Modifiers::default()); cx.executor().advance_clock(Duration::from_millis(64)); cx.run_until_parked(); cx.simulate_mouse_up(edge, MouseButton::Left, Modifiers::default()); let scroll_after = view.read_with(cx, |view, cx| { let offset = view.text_view.read(cx).list_state().logical_scroll_top(); (offset.item_ix, offset.offset_in_item) }); assert!( scroll_after != scroll_before, "dragging at the viewport edge must scroll the TextView document" ); cx.executor().advance_clock(Duration::from_millis(64)); cx.run_until_parked(); let scroll_stopped = view.read_with(cx, |view, cx| { let offset = view.text_view.read(cx).list_state().logical_scroll_top(); (offset.item_ix, offset.offset_in_item) }); assert_eq!( scroll_stopped, scroll_after, "selection auto-scroll must stop on mouse-up" ); } } ``` ```bash cargo run -p gpui-base-examples -- text-view ``` --- # Plot Source: /versions/v0.7.1/zh-CN/base/plot 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/plot 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `gpui_kit::base::plot` 包含图表中属于行为和几何、而不属于设计的部分:把数据映射成像素的比例尺,把柱、线、面积和圆弧细分成路径的图形,坐标轴、网格和标签,把 [`Plot`] 变成子元素的 element,以及交互式 tooltip 背后的 hover 跟踪。 它不选择任何颜色、字体或动效时序。每个图形的填充和描边都由调用方传入,hover 动效则完全取决于样式层投射的配置。GPUI Component 的样式化图表,如 `LineChart`、`BarChart`、`PieChart` 等,都构建在它之上;不使用 GPUI Component 的设计系统也可以基于同一套基础实现自己的图表。 GPUI Component 以 `gpui_kit::component::plot` 原样重新导出这个模块,已有的导入路径无需修改。 ## 导入 ```rust use gpui_kit::base::plot::{ AxisText, Grid, Plot, PlotAxis, PlotElement, PlotHover, TooltipState, scale::{Scale, ScaleBand, ScaleLinear, ScaleOrdinal, ScalePoint}, shape::{Arc, Area, Bar, Line, Pie, Stack}, }; ``` ## 编写一个 Plot Plot 实现 [`Plot`] trait。`paint` 接收 Plot 占据的区域,并用窗口的绘制 API 在其中作画: ```rust use gpui_kit::base::plot::{Plot, PlotElement, scale::{Scale, ScaleLinear, ScalePoint}, shape::Line}; use gpui_kit::*; struct Sparkline { values: Vec, stroke: Hsla, } impl Plot for Sparkline { fn paint(&mut self, bounds: Bounds, window: &mut Window, _: &mut App) { let width = bounds.size.width.as_f32(); let height = bounds.size.height.as_f32(); let indexes: Vec = (0..self.values.len()).collect(); let x = ScalePoint::new(indexes.clone(), [0., width]); let y = ScaleLinear::new(self.values.clone(), [height, 0.]); let values = self.values.clone(); Line::new() .data(indexes) .x(move |i| x.tick(i)) .y(move |i| y.tick(&values[*i])) .stroke(self.stroke) .stroke_width(1.5) .paint(&bounds, window); } } ``` Plot 通过 [`PlotElement`] 成为 element。它会填满容器,依次执行 `prepaint`、`paint` 和 hover 流程,并绘制 Plot 返回的浮层: ```rust impl IntoElement for Sparkline { type Element = PlotElement; fn into_element(self) -> Self::Element { PlotElement::new(self) } } div().h_16().w_full().child(Sparkline { values, stroke }) ``` GPUI Component 的 `#[derive(IntoPlot)]` 会自动生成这段实现;不使用 GPUI Component 时手写即可。 Plot 的尺寸跟随父元素,因此需要给父元素一个明确的高度。 ## 比例尺 比例尺把数据的定义域映射到像素或其他值的值域。 | 比例尺 | 定义域 | 用途 | | --- | --- | --- | | `ScaleLinear` | 连续数值 | 数值轴;值域可以自上而下(`[height, 0.]`) | | `ScaleBand` | 离散类别 | 柱状图:`band_width()` 是每根柱子的宽度,`tick` 是起点 | | `ScalePoint` | 离散类别 | 以类别为 x 值的折线图和面积图 | | `ScaleOrdinal` | 离散类别 | 把类别映射到颜色 | `ScaleLinear` 和各个图表接受任意 [`PlotValue`](https://docs.rs/gpui-base/latest/gpui_base/plot/trait.PlotValue.html):`f32`、`f64`,启用 `decimal` feature 后还有 `rust_decimal::Decimal`。整数不在其列,因为线性比例尺要做除法,整数除法会截断位置。 ## 图形 `Bar`、`Line`、`Area` 和 `Arc` 通过把数据项转换成像素的访问器完成绘制;`Pie` 根据数值计算圆弧布局,`Stack` 为堆叠柱状图和面积图计算堆叠序列。`RadialLine` 与 Sankey 布局(`Sankey`、`SankeyLink`)分别用于雷达图和流向图。各图形的用法见 [GPUI Component 中的 Plot](/versions/v0.7.1/zh-CN/component/plot)。 `Line` 和 `Area` 用 `Curve` 决定点与点之间怎么连接,通过 `.curve(..)` 设置:`Curve::Natural`(默认)是穿过每个点的平滑曲线,`Curve::Linear` 是直线段,`Curve::StepAfter` 是阶梯线,每个值保持到下一个点。0.7.0 之前 `Curve` 叫 `StrokeStyle`,`.curve` 叫 `.stroke_style`。 ```rust use gpui_kit::base::plot::{Curve, shape::Line}; Line::new() .data(&values) .x(|d| x.tick(&d.label)) .y(|d| y.tick(&d.value)) .curve(Curve::StepAfter) .stroke(color) .paint(&bounds, window); ``` 每帧都要重绘的图形可以用 [`PathCaches`] 按 Plot 的 id 在帧之间保留已细分的路径。 ## 坐标轴、网格与标签 `PlotAxis` 绘制 x 轴与 y 轴线及 `AxisText` 标签,`Grid` 绘制实线或虚线的水平、垂直网格线,`PlotLabel` 在 Plot 坐标上绘制文字。`axis_gutter(font_size)` 给出带该字号标签的 x 轴在 Plot 下方占用的高度(默认 `TEXT_SIZE` 时为 18px)。Builder 只记录参数,标签在绘制时才定位,因此调用顺序不影响结果。颜色和字号都是参数,字体取自外层的文字样式。 ## Hover 与 tooltip Plot 在 `Plot::id` 中返回 id 即可启用 hover;若 `Plot::interactive` 返回 `false`,则保留 id 对应的状态和入场,但没有 hitbox 和 hover。之后 `PlotElement` 每帧跟踪光标(会识别遮挡,Plot 上方打开的弹出层会清除 hover),并依次询问 Plot: 1. `tooltip_state`:把光标映射成 [`TooltipState`],包括悬停的索引、十字线位置和数据点,或返回 `None`。 2. `hover`:在绘制前接收当前的 [`PlotHover`]。光标离开后它会继续保留,同时 `progress()` 逐渐回落到零,让强调效果在最后一个数据项上淡出,而不是突然消失。首个悬停帧上 `is_entering()` 为 true。 3. `tooltip`:返回浮层,例如十字线、数据点和提示框。浮层绘制在 Plot 之上;可能超出 Plot 边界的提示框应当用 `deferred` 包裹自身。 Base 本身不绘制任何浮层,浮层由样式层构建。在 `Plot::tooltip` 中,浮层渲染在 Plot 的 element 作用域内,因此无需传参即可读取 hover 状态: | 函数 | 返回值 | | --- | --- | | `hover_progress(window, cx)` | hover 淡入的程度,范围 `0..=1`;在 Plot 之外为 `1` | | `is_hover_entering(window, cx)` | 当前是否是首个悬停帧 | | `pointer_spring(cx)` | 指示器跟随悬停数据项所用的弹簧 | | `PlotHover::glide(id, target, window, cx)` | 在指示器弹簧上跟随 `target` 的位置,首个悬停帧直接采用目标值 | ## 动效 Base 不内置任何 Plot 动效:默认情况下 hover 会立即出现和消失,指示器直接跳到每个数据项。样式层通过 Base 主题投射自己的时序: ```rust use std::time::Duration; use gpui_kit::base::{PlotMotion, PlotTheme, Spring, Theme, motion::Transition}; let motion = PlotMotion::default() .with_pointer(Spring::new(Duration::from_millis(120)).with_epsilon(0.1)) .with_enter(Transition::new(Duration::from_millis(120))) .with_exit(Transition::new(Duration::from_millis(120))) .with_appear(Transition::new(Duration::from_millis(1000))); Theme::global_mut(cx).plot = PlotTheme::new().with_motion(motion); ``` `with_appear` 设置 plot 的 id 第一次绘制时数据画出来的方式。plot 在 `Plot::appear` 里拿到进度,调用时机在 `Plot::hover` 和 `Plot::paint` 之前;`PlotAppear::staggered` 把进度切给多个图形,每个错开一点开始。plot 在 `Plot::appear_generation` 返回 `Some` 即开启入场,值变化时重播;默认的 `None` 不跟踪任何状态,也不请求帧。有 id 但 `Plot::interactive` 为 false 的 plot 同样会入场;入场结束前不跟踪 hover。 ### Appear scope 入场状态存在 plot 的元素状态里,所以 plot 一旦不再绘制(比如虚拟列表滚走的一行),回来时会重新入场。`PlotAppearScope` 按全局元素 id 和入场 generation 记下其中已经入场完毕的 plot,之后直接交给它们完成态的入场,不请求帧: ```rust PlotAppearScope::new(("transcript", conversation_id), list(state, render_row).flex_1()) ``` generation 变化仍会重播;入场到一半被移走的 plot 回来后从头入场。记录存在作用域自己的元素状态里,只要作用域每帧都在绘制就一直保留:关闭视图,或给作用域换一个 id,其中的 plot 就会重新入场。嵌套时最内层的作用域生效。作用域不参与布局,自带尺寸的子元素(比如 `list`)照常生效。 GPUI Component 会在主题变化时把自己的 motion token 投射到这里。动效遵循操作系统的“减少动态效果”设置,开启后所有值都会立即采用目标值。 --- # Motion Source: /versions/v0.7.1/zh-CN/base/motion 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/motion 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `gpui-base` 负责确定性的动效采样与生命周期,并把视觉选择留给应用。它提供稳定 keyed state、中断与反向、animation frame 请求和 reduced-motion 处理,不强加产品级时长或样式。 运行本文配套的交互示例: ```bash cargo run -p gpui-base-examples --bin motion ``` 示例包含五个相互独立的页面,可通过顶部标签逐个查看。 ## 能力一览 | 示例 | API | 演示内容 | | --- | --- | --- | | Sliding time | `transition` | 08:00–20:00 的四位独立滚动数字,目标会在前一次过渡完成前继续变化 | | Spring | `spring` | 快速切换目标时仍保持速度连续的分段选择器指示块 | | Keyframes | `Keyframes`、`Timing`、`animate_keyframes` | 持续循环的多段活动信号 | | Stagger | `Stagger` | 无分配地为列表计算错峰时间 | | Presence | `Presence` | 退出动画完成前继续挂载内容 | | Sequence | `Sequence` | 三个串联步骤——滑入、填满、停留后淡出——每一步在前一步结束时开始 | 此外还提供 `Easing`、`Discrete`、`MotionTransform` 和 `MotionReveal`,它们与同一套 primitive 组合,不需要额外动画 runtime。 ## Transition 已知时长、向目标值变化时使用 `transition`。每个独立运动值都要有稳定 ID: ```rust let opacity = transition( ("save-dialog", "opacity"), if open { 1.0 } else { 0.0 }, Transition::new(Duration::from_millis(180)).easing(Easing::EaseOut), window, cx, ); ``` 运动中改变目标会从当前采样值继续;直接反向还会缩短返回时长。`transition_with_status` 额外返回 `Idle`、`Delayed`、`Running` 或 `Finished`。 `Easing` 支持 CSS 关键字曲线、cubic Bézier、全部 step position 和分段 `linear()` stops,无效参数会返回类型化错误。 ## Spring 目标可能在运动中变化时使用 `spring`。它同时保留位置与速度,适合选择指示器和空间值回落。 ```rust let x = spring( "selected-indicator", selected_x, Spring::new(Duration::from_millis(420)).with_damping(0.72), window, cx, ); ``` 指针直接控制数值时,不要让 spring 追赶指针;拖动中使用 `with_travel(false)`,释放后再恢复。 `with_damping` 要求有限且非负的 ratio;`with_epsilon` 要求有限且大于零,并以目标值自身的单位解释。builder 会在无效的可信常量上 panic;配置值或用户输入应使用 `try_with_damping` 和 `try_with_epsilon`。归一化值通常保留默认的 `0.001`,像素移动可以使用 `0.1` 等较粗容差。 ## Keyframes 与 Timing `Keyframes` 定义经过校验的值序列;`Timing` 按绝对 elapsed time 采样,支持正负 delay、有限或无限迭代,以及 normal、reverse 和 alternate 播放方向。 offset 必须从 `0` 开始、以 `1` 结束并保持单调。不可插值属性使用 `Discrete`。 `animate_keyframes` 会在传入的稳定 ID 下保留播放起始时间。使用相同 ID 重新渲染只会继续当前序列,不会重新开始。需要重播时,把应用持有的 generation 放进 ID,例如 `("notification-enter", generation)`,并在每次重播时递增它。 ## Presence 与 Stagger `Presence` 将逻辑可见性与实际挂载分开,阶段包括 entering、present、exiting 和 absent。`should_render()` 为 true 时继续渲染,并把 `progress` 应用到所选视觉属性。退出中重新打开会从当前进度反向。 `Stagger` 可以从首项、末项、中心或指定位置开始,为每个 index 计算 delay;它不分配时间表,也不接管列表 identity。 ## Sequence `Sequence` 把多个 transition 串成链,每一步在前一步结束时开始。它在首次采样的那一帧从 `from` 出发,每个 ID 只播放一次;采样结果包含当前值、正在播放的 step 序号,以及一个只在最后一步完成后才为 `Finished` 的 `MotionStatus`。 ```rust let opacity = Sequence::new(("toast", "opacity"), 0.0) .with_step(1.0, Transition::new(Duration::from_millis(160))) .with_step(0.0, Transition::new(Duration::from_millis(200)).delay(Duration::from_secs(3))) .sample(window, cx); div().opacity(*opacity.value()) ``` 每一步在绝对时刻结束,下一步从该时刻开始,而不是从发现它结束的那一帧开始,因此掉帧不会让后续步骤延后。零时长的步骤会在同一帧内完成。改变正在播放那一步的目标值,会让 sequence 从当时的采样值重新开始第一步;尚未到达的步骤会在到达时读取。需要重播时,把应用持有的 generation 放进 ID。Reduced motion 会直接采用最后一步的目标值,并且不留下待处理 frame。 `Stagger` 可以作为第一步的 delay 与 sequence 组合: ```rust Sequence::new(("row", index), px(12.)) .with_step(px(0.), Transition::new(Duration::from_millis(120)).delay(stagger.delay(index, count))) .sample(window, cx) ``` ## 测量式展开 `MotionReveal` 按 child 的自然尺寸测量,再根据 progress 裁剪可见高度。`Collapsible::motion_id(...)` 是控件层的便捷入口;没有 motion ID 时仍保持即时挂载/卸载。 ## Reduced motion 与性能 Transition、spring、keyframes、presence 和 reveal 控件都遵守 GPUI 的 reduced-motion 偏好。有限动画会直接同步目标、更新 retained state,并且不留下待处理 frame。动画不能成为表达状态的唯一方式。 这个偏好来自操作系统。`gpui_base::init`(因此 `gpui_component::init` 也一样)会把系统设置读入 `App::set_reduce_motion`:macOS 的「减弱动态效果」(`NSWorkspace.accessibilityDisplayShouldReduceMotion`)、Windows 的「动画效果」(`SPI_GETCLIENTAREAANIMATION`,关闭即为减弱动效),以及 Linux 上 XDG desktop portal `org.freedesktop.appearance` 命名空间的 `reduced-motion` 键——它经 D-Bus 在 `init` 返回后片刻送达,之后持续跟随其变化。其他目标(包括 wasm)不改动这个标志。应用一旦自己调用 `cx.set_reduce_motion(...)`,就接管了这个标志:Base 只在标志仍是自己上次写入的值时才会写入。macOS 和 Windows 只在 `init` 时读取一次;需要重新读取时调用 `gpui_base::apply_system_reduce_motion(cx)`。 benchmark 覆盖的纯稳定采样路径——timing/easing、关键帧查找、解析式 spring 积分和 stagger delay 计算——均为零分配。Keyed transition、spring、presence 和 reveal 生命周期由 GPUI retained state 与 frame-request 测试覆盖,因为这些更新属于框架生命周期,而不是纯采样器。采样使用绝对时间,关键帧查找使用二分搜索。运行 release benchmark: ```bash cargo bench -p gpui-base --bench motion ``` 选择最小且合适的 primitive:固定时长目标使用 `transition`,频繁变化的空间目标使用 `spring`,编排序列使用 keyframes,卸载前退出使用 `Presence`,前后相继的步骤使用 `Sequence`,列表错峰使用 `Stagger`。 ## Benchmark 结果 以下数据来自 Linux x86_64 release 构建,每项运行 31 个 batch、每个 batch 迭代 200 次: | 工作负载 | Median | P95 | Worst | 内存分配 | | --- | ---: | ---: | ---: | ---: | | 1,000 次 scalar timing + easing 采样 | 26.490 µs | 26.567 µs | 27.290 µs | 0 | | 1,000 次 keyframe 采样,2 frames | 21.656 µs | 21.707 µs | 21.729 µs | 0 | | 1,000 次 keyframe 采样,8 frames | 25.197 µs | 25.251 µs | 25.269 µs | 0 | | 1,000 次 keyframe 采样,32 frames | 27.932 µs | 27.969 µs | 27.971 µs | 0 | | 1,000 次解析式 spring 积分采样 | 6.042 µs | 6.106 µs | 6.216 µs | 0 | | 1,000 次 stagger delay 计算 | 0.574 µs | 0.583 µs | 0.587 µs | 0 | Scalar timing/easing 工作负载低于 100 µs median 预算。这些数值是可复现的开发基线,并非跨平台性能保证;对特定平台性能有要求时,应在对应目标平台重新运行 benchmark。 --- # Getting Started Source: /versions/v0.7.1/zh-CN/base/getting-started 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/getting-started 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 应用统一通过 `gpui_kit::open_window(options, cx, build)` 创建窗口,它挂载 Base `Root`,返回窗口句柄和内容 Entity。Base 负责窗口结构和浮层承载,不依赖 Component。需要带样式的浮层时,在创建窗口前显式初始化 Component。 ## 安装 使用与 `gpui-base` 匹配的 GPUI 仓库版本: ```toml [dependencies] gpui-base = { git = "https://github.com/longbridge/gpui-kit" } gpui = { git = "https://github.com/zed-industries/zed" } gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit"] } ``` ## 初始化 打开窗口前调用一次 `gpui_kit::base::init`。若应用已调用 `gpui_kit::component::init`,其中已经包含 Base 初始化。 ```rust use gpui_kit::AppContext as _; fn main() { gpui_platform::application().run(|cx| { gpui_kit::base::init(cx); // 在这里打开应用窗口。 }); } ``` ## 渲染控件并设置样式 Base 控件刻意不提供产品专属的内边距、颜色或圆角,请用普通 GPUI 方法设置样式: ```rust use gpui_kit::prelude::*; use gpui_kit::{px, rgb}; use gpui_kit::base::Button; Button::new("save") .px_3().py_2().rounded(px(6.)) .bg(rgb(0x2563eb)).text_color(rgb(0xffffff)) .on_click(|_, _, _| println!("save")) .child("Save") ``` 跨渲染保持每个 `ElementId` 稳定,GPUI 才能保留元素和焦点状态。Checkbox、Switch、Radio、Toggle 等受控组件会通过回调报告下一个值;把它存进视图,并在下一次渲染时传回。 ## 默认颜色 Token `gpui-base` 通过 `ColorTokens::light()` 和 `ColorTokens::dark()` 提供可直接使用的浅色、深色语义调色板。`ColorTokens::default()` 使用浅色调色板。两套颜色均使用 `Hsla`,并与 `gpui-component` 的默认浅色、深色主题保持相同的语义角色。 ```rust use gpui_kit::base::{ColorTokens, SemanticThemeTokens, Theme}; // 根据应用当前外观选择对应调色板。 let colors = if is_dark { ColorTokens::dark() } else { ColorTokens::light() }; Theme::global_mut(cx).tokens = SemanticThemeTokens { colors, ..Default::default() }; ``` 调色板描述的是语义角色,而不是某个组件的专用颜色:`background` 与 `foreground`、`surface` 与 `surface_foreground`、`primary`、`secondary`、`muted`、`accent`、`destructive`、`border`、`input`、`ring` 和 `selection`,以及对应的前景色。能从既有角色推导的细节,Base 组件就直接推导,例如链接颜色取自 `primary`,不再为它单独加 token。`selection` 之所以自成一个角色,是因为没有别的角色能替代:选区绘制在文字下方,必须保证文字依然清晰,而 `accent` 和 `ring` 都无法保证这一点。 调用 `gpui_kit::component::init` 时,当前浅色或深色主题会自动映射到同一套 Base token。只使用 `gpui-base` 的应用应在外观模式变化时安装对应的调色板。 ## 运行共享示例 ```sh cargo run -p gpui-base-examples -- button ``` 将 `button` 替换为[原语目录](/versions/v0.7.1/zh-CN/base/primitives)中的 slug。网站会把同一份展示代码编译为 `wasm32-unknown-unknown` 并加载到各原语页面。 --- # GPUI Base Source: /versions/v0.7.1/zh-CN/base 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `gpui-base` 是 GPUI Kit 的无样式基础层。它提供交互行为、受控状态、焦点管理、无障碍语义、动画、虚拟列表和主题 token,同时将布局与视觉设计完整留给应用。 ## 如何选择 | 使用 | 适用场景 | | --- | --- | | `gpui-base` | 需要创建自己的设计系统,并掌控每个视觉选择 | | `gpui-component` | 需要一套具有完整视觉设计、可直接使用的组件 | 依赖始终由上层指向基础层:`gpui-component` 构建于 `gpui-base` 之上,应用也可以直接使用任意一层。 ## 基本原则 - **行为内置**:控件提供一致的指针、键盘、焦点和状态行为。 - **表现由应用决定**:直接组合 GPUI 样式方法和 children,不需要覆盖默认视觉。 - **部件可组合**:primitive 暴露有意义的子部件,而不是把结构隐藏在单体组件中。 - **状态明确**:受控输入报告变化,最终状态由 view 持有。 ## 开始使用 从[入门指南](/versions/v0.7.1/zh-CN/getting-started)开始,使用 [TextView](/versions/v0.7.1/zh-CN/text-view) 渲染可选择的 Markdown 与 HTML,或阅读[文本选择](/versions/v0.7.1/zh-CN/text-selection)为自定义 renderer 接入窗口级选择。每个页面都提供 Rust 代码和可运行的 WASM 示例。 [History](/versions/v0.7.1/zh-CN/history) 介绍两种用途明确不同的状态结构:`History` 是包含根条目、当前条目、后退与前进分支的导航轨迹,`UndoHistory` 则记录分组的 undo 与 redo 事务。[Plot](/versions/v0.7.1/zh-CN/plot) 是图表的无样式基础:比例尺、图形、坐标轴、把 `Plot` 变成子元素的 element,以及 hover 跟踪;颜色和动效时序都交给你的设计系统。 --- # History Source: /versions/v0.7.1/zh-CN/base/history 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/history 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `History` 与 `UndoHistory` 分别保存两种不同的应用状态。两者都不持有 GPUI 状态,且都由调用方把返回的值应用到模型;但它们的操作含义不同: - `History` 是浏览器式的线性导航轨迹,支持后退和前进。 - `UndoHistory` 将改动记录为 undo 事务,并能把多个改动合成一次用户操作。 ## 引入 ```rust use gpui_kit::base::{History, UndoHistory}; ``` ## 如何选择 应根据状态的含义选择类型,而不是根据操作它的 UI 命令名称选择: - 当每个条目表示一个位置,后退或前进需要返回到达的位置,并且必须保留当前根条目时,使用 `History`。 - 当每个条目表示一项可逆改动,并且 undo 或 redo 需要返回一次用户事务中的全部改动时,使用 `UndoHistory`。 - 当分组依赖比时间或显式边界更丰富的领域语义时,使用领域专用的管理器。例如 Input 使用私有事务管理器来理解输入、删除、选择区和 IME 组合输入。 在 gpui-component 内部,`NavStack` 使用 `History` 进行页面导航;`UndoHistory` 供任何需要分组撤销 / 重做的状态使用;Input 则有意保留其专用的私有 undo manager。 ## `History`:导航轨迹 每到一个位置就 push 一条。当前条目是轨迹中的最后一个值。例如访问完 `A -> B -> C` 后,当前是 `C`;后退会返回新的当前条目 `B`: ```rust let mut history = History::new(); history.push("A"); history.push("B"); history.push("C"); assert_eq!(history.back(), Some("B")); assert_eq!(history.current(), Some(&"B")); ``` `back()` 不会越过根条目,到根时返回 `None`。`forward()` 会恢复最近一个此前离开的条目。后退后再 push 新条目会丢弃前进分支,和浏览器打开新页面时的行为相同。`max_entries` 限制从根到当前的活动条目:降低上限会立即删除最旧的多余活动条目;达到上限时前进,会先删除最旧的活动条目,再恢复下一个条目。 `entries()` 按从根到当前条目的顺序迭代。完整的 `A -> B -> C` 轨迹会依次得到 `A`、`B`、`C`;`entries().rev()` 则得到 `C`、`B`、`A`。`forward_entries()` 从最近的前进条目到最远的前进条目迭代。用 `retain` 删除已失效的位置,用 `replace_current` 原地更新当前的位置,用 `remove_current` 删除当前条目而不丢弃前进分支。 | 方法 | 作用 | | -------------------------------------------- | -------------------------------------------------- | | `new()` | 创建空轨迹。`max_entries` 默认是 1000。 | | `max_entries(n)` | 限制从根到当前的条目数,并立即删除最旧的多余条目。 | | `push(entry)` | 让 `entry` 成为当前条目,并丢弃前进分支。 | | `back()`、`forward()` | 在轨迹中移动,返回移动后的当前条目。 | | `current()` | 返回当前条目。 | | `can_back()`、`can_forward()` | 判断对应方向是否可以移动。 | | `entries()`、`forward_entries()` | 按导航顺序迭代当前轨迹和前进分支。 | | `replace_current(entry)`、`remove_current()` | 更新或删除当前条目。 | | `retain(keep)`、`clear()` | 从两侧删除不保留的条目,或清空轨迹。 | ## `UndoHistory`:分组 undo 与 redo 每次需要由应用回退的改动都 push 一条。要把一次拖拽作为一项可撤销操作,请显式地把它的所有更新分组。`undo()` 以最新在前的顺序返回一个组里的改动,确保最近的改动先被回退;`redo()` 按最旧在前的顺序返回同一组,以原始顺序重新应用: ```rust let mut history = UndoHistory::new(); history.start_grouping(); history.push("从 x=0 移到 x=10"); history.push("从 x=10 移到 x=20"); history.end_grouping(); assert_eq!( history.undo(), Some(vec!["从 x=10 移到 x=20", "从 x=0 移到 x=10"]), ); assert_eq!( history.redo(), Some(vec!["从 x=0 移到 x=10", "从 x=10 移到 x=20"]), ); ``` 对于边界不明确的改动,`group_interval` 会把时间间隔足够短的连续 push 合成一个事务。成功 undo 或 redo 会结束这段定时分组窗口,下一次 push 会创建新事务。显式分组与此独立:只要显式分组仍在进行,push 就会继续追加到当前事务,包括刚完成 undo 之后。新的 push 会清空 redo 事务。回放改动时,用 `set_ignoring(true)` 防止回放本身被再次记录。 | 方法 | 作用 | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `new()` | 创建空 undo 历史。`max_undos` 默认是 1000。 | | `max_undos(n)` | 限制 undo 事务数并立即删除最旧的多余事务;redo 也会遵守该上限。 | | `group_interval(duration)` | 将相隔很近的连续 push 合并为一个事务。 | | `start_grouping()`、`end_grouping()` | 让后续 push 追加到当前事务;结束分组会停止这种显式追加行为。和上例一样,空历史中的第一个 push 会创建该事务。 | | `push(change)` | 在当前或一个新事务中记录改动,并清空 redo。 | | `undo()`、`redo()` | undo 时最新改动在前,redo 时最旧改动在前地返回最新事务。 | | `can_undo()`、`can_redo()` | 判断是否有可用事务。 | | `set_ignoring(bool)`、`is_ignoring()` | 控制是否记录 push。 | | `clear()` | 清空 undo 与 redo 事务。 | --- # VirtualList Source: /versions/v0.7.1/zh-CN/base/virtual-list 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/virtual-list 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 Virtual List 只绘制当前屏幕内的项目,因此可处理任意长度的列表。不同于 `gpui_kit::uniform_list`,每一项可以有不同尺寸,适合可变行高表格、聊天记录和大纲树。它属于基础设施而不是带外观的组件:你预先提供尺寸,再提供渲染范围的闭包。 ## 为什么要预先提供尺寸 虚拟化必须在不渲染项目的情况下知道总范围和可见项。`uniform_list` 用统一尺寸换取零逐项数据;边滚动边测量会造成滚动条跳动;`VirtualList` 用预先提供的逐项尺寸换取精确偏移和稳定滚动条。无法预先精确测量时可使用合理估算,或固定行高并裁剪内容。 ## 开始使用 ```rust use std::rc::Rc; use gpui_kit::base::{v_virtual_list, VirtualListScrollHandle}; use gpui_kit::{px, size}; let sizes = Rc::new(vec![size(px(280.), px(32.)); 100_000]); v_virtual_list(cx.entity(), "customers", sizes, |_this, range, _window, _cx| { range.map(|ix| div().h_8().px_2().child(format!("Customer {ix}"))).collect() }) .track_scroll(&self.scroll_handle) .size_full() ``` 闭包只收到可见范围及少量超绘制,并为每个索引返回一个元素。横向列表使用 `h_virtual_list`。 ## 尺寸契约 - 纵向列表只读取 `height`,横向列表只读取 `width`。 - 交叉轴通过布局一个项目测量,默认使用第 0 项;不具代表性时调用 `.with_item_to_measure_index(3)`。 - `item_sizes.len()` 就是项目数,必须与数据一致。 尺寸表使用 `Rc>>`,应保存在 entity 中并只克隆句柄,不要在 `render` 中重建。 ## 滚动与滚动条 `VirtualListScrollHandle` 持有跨渲染的滚动位置,支持 `scroll_to_item(index, ScrollStrategy::Top)`、`scroll_to_bottom()` 和 `base_handle()`。它实现了 `ScrollbarHandle`,可直接传给 `Scrollbar::vertical(&self.scroll)`;外层容器需要 `relative()`。 ## 尺寸行为与热路径 `ListSizingBehavior::Auto`(默认)使用父级提供的空间;`Infer` 根据项目推导尺寸。虚拟列表必须放在有边界的父级中。可见范围变化的每一帧都会运行渲染闭包,因此其中不要做 I/O、排序或过滤,也不要为每行创建持久 GPUI entity。元素 ID 应来自稳定数据键或项目索引,状态更新放在回调中并调用 `cx.notify()`。 每帧工作量与可见项目数而非总数成正比;只有尺寸表随总数增长。大量统一高度项目更适合 `gpui_kit::uniform_list`。 ## 完整 Rust 示例 ```bash cargo run -p gpui-base-examples -- virtual-list ``` ```rust use super::*; const ITEM_COUNT: usize = 100_000; impl BaseShowcase { pub(in super::super) fn virtual_list(&self, cx: &mut Context) -> impl IntoElement { let sizes = Rc::new(vec![size(px(280.), px(32.)); ITEM_COUNT]); div() .relative() .w_72() .h_48() .overflow_hidden() .border_1() .border_color(super::example_rgb(0x171717)) .child( v_virtual_list( cx.entity(), "example-virtual-list", sizes, |_, range, _, _| { range .map(|ix| { div() .w_full() .h_8() .px_2() .text_xs() .flex() .items_center() .border_b_1() .border_color(gpui::black()) .justify_between() .child( div() .flex() .items_center() .gap_2() .child( div() .size(px(18.)) .flex_none() .flex() .items_center() .justify_center() .line_height(px(18.)) .border_1() .border_color(gpui::black()) .child(format!("{}", (ix % 9) + 1)), ) .child(format!("Customer {:06}", ix + 1)), ) .child(format!("ID-{:06}", 100_000 + ix)) }) .collect() }, ) .track_scroll(&self.virtual_scroll) .size_full(), ) .child(Scrollbar::vertical(&self.virtual_scroll).mode(ScrollbarMode::Always)) } } ``` ## 检查清单 - 在 entity 中保存尺寸表和滚动句柄。 - 保持尺寸表与数据长度一致,并选择有代表性的测量项。 - 提供有边界的父级;添加滚动条时父级使用 `relative()`。 - 保持逻辑顺序、项目数和稳定身份,让辅助技术获得连贯列表。 --- # Number Input Source: /versions/v0.7.1/zh-CN/base/primitives/number-input 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/number-input 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 带可复用递增、递减和步进行为的数字输入。 和所有 GPUI Base 原语一样,Number Input 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- number-input ``` ## 导入 ```rust use gpui_kit::base::{Decrement, Increment, NumberInput, NumberInputText}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/number-input.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/number-input.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 父级或持久 entity 保存数值;按钮和文本输入共同更新同一状态。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use gpui::{ AnyElement, Context, InteractiveElement, IntoElement, ParentElement as _, Styled as _, div, px, relative, }; use gpui_base::{Button, NumberInput}; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn number_input(&self, cx: &mut Context) -> impl IntoElement { let valid = self.input.read(cx).value().parse::().is_ok(); fn render_btn(this: Button, icon: AnyElement) -> Button { this.w(px(24.)) .flex_1() .min_h_0() .line_height(relative(1.)) .flex() .items_center() .justify_center() .bg(gpui::black()) .text_color(gpui::white()) .hover(|this| this.bg(gpui::black().opacity(0.8))) .child(icon) } fn minus_icon() -> AnyElement { div() .w(px(8.)) .h(px(1.)) .bg(gpui::white()) .into_any_element() } fn plus_icon() -> AnyElement { div() .relative() .size(px(8.)) .child( div() .absolute() .top(px(3.5)) .left_0() .w_full() .h(px(1.)) .bg(gpui::white()), ) .child( div() .absolute() .left(px(3.5)) .top_0() .h_full() .w(px(1.)) .bg(gpui::white()), ) .into_any_element() } div() .w(px(200.)) .flex() .flex_col() .gap_1() .text_xs() .child(div().text_xs().child("Quantity")) .child( NumberInput::new(&self.input) .controls_right() .w_full() .h_7() .flex() .items_center() .border_1() .border_color(if valid { super::example_rgb(0x171717) } else { super::example_rgb(0x737373) }) .input(div().w_full().px_2().child(self.input.clone())) .decrement_button(|button| render_btn(button, minus_icon())) .increment_button(|button| render_btn(button, plus_icon())), ) .child( div() .text_xs() .text_color(super::example_rgb(0x737373)) .child(if valid { "Step: 1" } else { "Enter a number" }), ) } } ``` ## 可访问性 提供标签、范围与当前值语义,并让增减操作可通过键盘完成。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Input Source: /versions/v0.7.1/zh-CN/base/primitives/input 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/input 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `Input` 是 `gpui-base` 的单行文本控件。它负责编辑、焦点、选择、键盘输入、IME、掩码、验证与事件,表现由应用提供。普通多行文本使用 [Textarea](/versions/v0.7.1/zh-CN/base/primitives/textarea),源代码使用 [Editor](/versions/v0.7.1/zh-CN/base/primitives/editor)。 ## 导入 ```rust use gpui_kit::base::input::{Input, InputEvent, InputState}; ``` ## 基本用法 持久状态只创建一次,再用对应 entity 渲染 `Input`: ```rust let input = cx.new(|cx| { InputState::new(window, cx) .placeholder("Account name") .default_value("Ada") }); Input::new(&input) ``` 通过状态读取和更新值: ```rust let value = input.read(cx).value(); input.update(cx, |state, cx| state.set_value("Grace", window, cx)); ``` ## 掩码与验证 ```rust let password = cx.new(|cx| { InputState::new(window, cx) .placeholder("Password") .masked(true) .validate(|value, _| value.chars().count() >= 8) }); ``` 格式化值可按需组合 `mask_pattern`、`pattern`、`min`、`max`、`step` 或 `step_by`。`unmask_value()` 返回掩码输入的底层值。 ## 事件 `InputState` 会发出 `InputEvent::Change`、`PressEnter`、`Focus` 和 `Blur`。订阅事件后读取新值,并在更新宿主状态后调用 `cx.notify()`。 ## 表现 `gpui-base` 不安装产品样式。向状态提供 `InputEditorStyle`,并把控件组合进自己的边框容器。若需要现成主题、尺寸、边框、前后缀槽位和清除按钮,请使用 [`gpui-component` Input](/versions/v0.7.1/zh-CN/component/input)。 ## 可运行示例 ```bash cargo run -p gpui-base-examples -- input ``` 实现位于 [`input.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/input.rs)。 ## 原子行内 token 需要让用户整块选中、删除人员提及或资源引用时,可以使用 token。通过已有的 InputState 插入: ```rust use gpui_kit::base::input::InlineToken; input.update(cx, |state, cx| { state.replace_with_token( InlineToken::new("person-1", "@alice").with_label("Alice"), window, cx, ).expect("有效的引用"); }); ``` token 默认显示为无装饰标签。通过 `token` 槽位提供自己的单行元素,通过 `on_token_click` 打开引用。通过 `on_token_hover` 上报指针进入与离开(`InlineTokenHoverEvent::is_hovered`),不选中、不编辑,可用于提示或预览;禁用 token 从不上报悬停进入,但禁用正在悬停的 token 会发送其退出事件。复制和 `value()` 返回 `@alice` 这样的真实文本。用 `content()` 保存草稿、`set_value(content)` 恢复草稿,可以保留其中的引用。 自定义展示、草稿恢复和范围单位见 [Input 的 token 示例](/versions/v0.7.1/zh-CN/component/input#原子行内-token)。数据类型从 `gpui_kit::base::input` 导入。JavaScript 使用 `gpui-base` 中的 `InputState.new()`,它提供相同的 token 方法。 --- # Slider Source: /versions/v0.7.1/zh-CN/base/primitives/slider 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/slider 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 轨道、已选区和滑块可独立设置样式的状态驱动范围输入。 和所有 GPUI Base 原语一样,Slider 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- slider ``` ## 导入 ```rust use gpui_kit::base::{Slider, SliderState}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/slider.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/slider.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 SliderState 持久保存值和范围,拖动及键盘操作更新它。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use super::*; use gpui::relative; impl BaseShowcase { pub(in super::super) fn slider(&self, cx: &mut Context) -> impl IntoElement { let percentage = self.slider.read(cx).percentage().end; let thumb_size = 14.; div() .w_56() .text_xs() .child( div() .mb_2() .flex() .justify_between() .child("Volume") .child("Drag to adjust"), ) .child( Slider::new(&self.slider).w_full().h_7().child( SliderTrack::new(&self.slider) .relative() .w_full() .h_full() .child( div() .absolute() .top(px(13.)) .left_0() .w_full() .h(px(2.)) .bg(super::example_rgb(0xd4d4d4)), ) .child( SliderIndicator::new(&self.slider) .absolute() .top(px(13.)) .left_0() .w_full() .h(px(2.)) .child( div() .absolute() .top_0() .bottom_0() .left_0() .right(relative(1. - percentage)) .bg(super::example_rgb(0x171717)), ), ) .child( SliderThumb::new(&self.slider) .absolute() .top(px(7.)) .left(relative(percentage)) .ml(px(-thumb_size / 2.)) .size(px(thumb_size)) .bg(super::example_rgb(0xffffff)) .border_1() .border_color(super::example_rgb(0x171717)), ), ), ) } } ``` ## 可访问性 暴露范围、当前值和方向,并保留方向键、Page Up/Down 等键盘操作。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Alert Dialog Source: /versions/v0.7.1/zh-CN/base/primitives/alert-dialog 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/alert-dialog 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 用于需要用户明确决定之操作的模态确认界面。 和所有 GPUI Base 原语一样,Alert Dialog 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- alert-dialog ``` ## 导入 ```rust use gpui_kit::base::{AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogDescription, AlertDialogPopup, AlertDialogTitle, AlertDialogTrigger}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/alert-dialog.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/alert-dialog.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 打开状态和确认/取消结果由应用管理。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use gpui::relative; use super::*; impl BaseShowcase { pub(in super::super) fn alert_dialog(&self, cx: &mut Context) -> impl IntoElement { let open = self.alert_dialog_open; let entity = cx.entity().downgrade(); let open_entity = entity.clone(); let ok_entity = entity.clone(); let cancel_entity = entity.clone(); let action_entity = entity.clone(); div() .child( Button::new("open-alert-dialog") .h_7() .line_height(relative(1.)) .px_3() .flex() .items_center() .justify_center() .bg(gpui::black()) .text_color(gpui::white()) .on_click(move |_, _, cx| { _ = open_entity.update(cx, |this, cx| { this.alert_dialog_open = true; cx.notify(); }); }) .child("Delete project"), ) .child( AlertDialog::new(cx) .open(open) .on_open_change(move |open, _, _, cx| { _ = entity.update(cx, |this, cx| { this.alert_dialog_open = open; cx.notify(); }); }) .on_ok(move |_, _, cx| { _ = ok_entity.update(cx, |this, cx| { this.alert_dialog_open = false; cx.notify(); }); true }) .backdrop( AlertDialogBackdrop::new() .absolute() .inset_0() .bg(super::example_rgb(0x000000)) .opacity(0.18), ) .popup( AlertDialogPopup::new() .w_72() .p_3() .bg(super::example_rgb(0xffffff)) .border_1() .border_color(super::example_rgb(0x171717)) .child(AlertDialogTitle::new().child("Delete project?")) .child( AlertDialogDescription::new() .mt_2() .text_xs() .text_color(super::example_rgb(0x525252)) .child( "This permanently deletes Acme Studio and all of its data.", ), ) .child( div() .mt_3() .flex() .justify_end() .gap_2() .child( AlertDialogCancel::new().child( Button::new("cancel-delete") .px_3() .h_7() .flex() .items_center() .text_xs() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .on_click(move |_, _, cx| { _ = cancel_entity.update(cx, |this, cx| { this.alert_dialog_open = false; cx.notify(); }); }) .child("Cancel"), ), ) .child( AlertDialogAction::new().child( Button::new("confirm-delete") .px_3() .h_7() .flex() .items_center() .text_xs() .border_1() .border_color(super::example_rgb(0x171717)) .bg(super::example_rgb(0x171717)) .text_color(super::example_rgb(0xffffff)) .on_click(move |_, _, cx| { _ = action_entity.update(cx, |this, cx| { this.alert_dialog_open = false; cx.notify(); }); }) .child("Delete"), ), ), ), ), ) } } ``` ## 可访问性 将焦点限制在模态层内,提供标题与说明,并确保取消操作始终可用。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Dialog Source: /versions/v0.7.1/zh-CN/base/primitives/dialog 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/dialog 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 带焦点管理、遮罩、标题和关闭部件的可组合模态界面。 和所有 GPUI Base 原语一样,Dialog 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- dialog ``` ## 导入 ```rust use gpui_kit::base::{Dialog, DialogBackdrop, DialogClose, DialogDescription, DialogPopup, DialogTitle, DialogTrigger}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/dialog.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/dialog.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 打开状态可以受控;触发器、关闭按钮与遮罩根据产品策略更新它。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use super::*; use gpui::{MouseButton, relative}; impl BaseShowcase { pub(in super::super) fn dialog(&self, cx: &mut Context) -> impl IntoElement { let open = self.dialog_open; let entity = cx.entity().downgrade(); let open_entity = entity.clone(); div() .child( Button::new("open-dialog") .h_7() .line_height(relative(1.)) .px_3() .flex() .items_center() .justify_center() .bg(gpui::black()) .text_color(gpui::white()) .on_click(move |_, _, cx| { _ = open_entity.update(cx, |this, cx| { this.dialog_open = true; cx.notify(); }); }) .child("Edit profile"), ) .child( Dialog::new(cx) .open(open) .on_open_change(move |open, _, _, cx| { _ = entity.update(cx, |this, cx| { this.dialog_open = open; cx.notify(); }); }) .backdrop( DialogBackdrop::new() .absolute() .inset_0() .bg(super::example_rgb(0x000000)) .opacity(0.2), ) .popup( DialogPopup::new() .w_72() .p_3() .flex() .flex_col() .items_stretch() .text_xs() .bg(super::example_rgb(0xffffff)) .border_1() .border_color(super::example_rgb(0xd4d4d4)) .child( DialogTitle::new() .font_weight(gpui::FontWeight::SEMIBOLD) .child("Edit profile"), ) .child( DialogDescription::new() .mt_2() .text_color(super::example_rgb(0x737373)) .child("Update the public details shown on your profile."), ) .child(div().mt_3().text_sm().child("Display name")) .child( InputBase::new("dialog-name") .mt_2() .w_full() .h_7() .px_2() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .on_mouse_down(MouseButton::Left, { let input = self.input.clone(); move |_, window, cx| { input.update(cx, |state, cx| state.focus(window, cx)); } }) .child(self.input.clone()), ) .child( div() .mt_3() .flex() .justify_end() .gap_2() .child( gpui_base::DialogClose::new().child( Button::new("dialog-cancel") .h_7() .line_height(relative(1.)) .px_3() .flex() .items_center() .justify_center() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .child("Cancel"), ), ) .child( Button::new("dialog-save") .h_7() .line_height(relative(1.)) .px_3() .flex() .items_center() .justify_center() .bg(super::example_rgb(0x171717)) .text_color(super::example_rgb(0xffffff)) .on_click({ let entity = cx.entity().downgrade(); move |_, _, cx| { _ = entity.update(cx, |this, cx| { this.dialog_open = false; cx.notify(); }); } }) .child("Save changes"), ), ), ), ) } } ``` ## 可访问性 打开后管理焦点,关联标题与说明,并提供键盘可达的关闭方式。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Tabs Source: /versions/v0.7.1/zh-CN/base/primitives/tabs 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/tabs 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 带受控选择的标签列表和可访问标签控件。 和所有 GPUI Base 原语一样,Tabs 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- tabs ``` ## 导入 ```rust use gpui_kit::base::{Tab, Tabs}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/tabs.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/tabs.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 父级保存活动标签,激活标签后更新对应面板。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use super::*; impl BaseShowcase { pub(in super::super) fn tabs(&self, cx: &mut Context) -> impl IntoElement { let selected = self.selected_tab; div() .w_72() .text_xs() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .child( Tabs::new("example-tabs") .flex() .px_2() .pt_1() .border_b_1() .border_color(super::example_rgb(0xd4d4d4)) .children( ["Overview", "Activity", "Settings"] .into_iter() .enumerate() .map(|(index, label)| { let entity = cx.entity().downgrade(); Tab::new(index) .selected(self.selected_tab == index) .px_2() .h_7() .flex() .items_center() .border_b_2() .border_color(if self.selected_tab == index { super::example_rgb(0x171717) } else { super::example_rgb(0xffffff) }) .when(self.selected_tab == index, |this| { this.font_weight(gpui::FontWeight::SEMIBOLD) }) .on_click(move |_, _, cx| { _ = entity.update(cx, |this, cx| { this.selected_tab = index; cx.notify(); }); }) .child(label) }), ), ) .child( div().min_h_20().p_3().child(match selected { 0 => div().child("Workspace overview").child( div() .mt_1() .text_color(super::example_rgb(0x737373)) .child("12 components · 4 contributors · updated today"), ), 1 => div().child("Recent activity").child( div() .mt_1() .text_color(super::example_rgb(0x737373)) .child("Button example was updated 8 minutes ago."), ), _ => div().child("Project settings").child( div() .mt_1() .text_color(super::example_rgb(0x737373)) .child("Manage notifications and member access."), ), }), ) } } ``` ## 可访问性 保留标签列表、标签与面板关系,以及方向键和焦点行为。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Pagination Source: /versions/v0.7.1/zh-CN/base/primitives/pagination 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/pagination 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 显式管理当前页和总页数的受控分页导航。 和所有 GPUI Base 原语一样,Pagination 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- pagination ``` ## 导入 ```rust use gpui_kit::base::{Pagination, PaginationState}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/pagination.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/pagination.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 PaginationState 保存页数边界与当前页,应用处理页码变化。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use gpui::{ Context, IntoElement, ParentElement as _, Styled as _, div, prelude::FluentBuilder as _, px, }; use gpui_base::{Button, Pagination, PaginationItem, PaginationState}; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn pagination(&self, cx: &mut Context) -> impl IntoElement { let entity = cx.entity().downgrade(); let state = PaginationState::new(self.page, 8).on_change(move |page, _, cx| { _ = entity.update(cx, |this, cx| { this.page = page; cx.notify(); }); }); let items = state.items(); Pagination::new("example-pagination", state.clone()) .flex() .items_center() .gap_2() .text_xs() .children(items.into_iter().map(move |item| { match item { PaginationItem::Page(page) => { let state = state.clone(); Button::new(("page", page)) .size_7() .p_0() .flex() .items_center() .justify_center() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .when(page == state.current_page(), |this| { this.bg(super::example_rgb(0x171717)) .text_color(super::example_rgb(0xffffff)) }) .on_click(move |_, window, cx| state.request_page(page, window, cx)) .child(page.to_string()) .into_any_element() } PaginationItem::Ellipsis(_) => div() .w(px(20.)) .h_7() .flex() .items_center() .justify_center() .child("…") .into_any_element(), } })) } } ``` ## 可访问性 标记当前页,为上一页、下一页和具体页码提供可访问名称。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Avatar Source: /versions/v0.7.1/zh-CN/base/primitives/avatar 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/avatar 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 带可组合后备内容的人物或实体图像。 和所有 GPUI Base 原语一样,Avatar 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- avatar ``` ## 导入 ```rust use gpui_kit::base::{Avatar, AvatarFallback, AvatarImage}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/avatar.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/avatar.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 图像加载失败时显示后备内容。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use super::*; impl BaseShowcase { pub(in super::super) fn avatar(&self) -> impl IntoElement { div().flex().items_start().gap_2().children( [ ("AM", 0xf5f5f5), ("JL", 0xe5e5e5), ("SK", 0xd4d4d4), ("+3", 0xffffff), ] .into_iter() .map(|(initials, background)| { Avatar::new() .size(px(34.)) .overflow_hidden() .border_1() .border_color(super::example_rgb(0xa3a3a3)) .fallback( AvatarFallback::new() .flex() .size_8() .items_center() .justify_center() .bg(super::example_rgb(background)) .text_xs() .text_color(super::example_rgb(0x262626)) .child(initials), ) }), ) } } ``` ## 可访问性 为有信息含义的图像提供替代文本;纯装饰图像应从可访问树中隐藏。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Accordion Source: /versions/v0.7.1/zh-CN/base/primitives/accordion 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/accordion 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 由可独立设置样式的标题、触发器和面板组成的折叠组。 和所有 GPUI Base 原语一样,Accordion 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- accordion ``` ## 导入 ```rust use gpui_kit::base::{Accordion, AccordionHeader, AccordionItem, AccordionPanel, AccordionTrigger}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/accordion.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/accordion.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 管理每一项的展开状态,并在触发器激活时更新它。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use super::*; impl BaseShowcase { pub(in super::super) fn accordion(&self, cx: &mut Context) -> impl IntoElement { let items = [ ( "What is GPUI Base?", "Unstyled, accessible primitives for building native GPUI interfaces.", ), ( "Can I bring my own theme?", "Yes. Every visual detail remains application-owned.", ), ( "Does it support keyboard input?", "Focus, activation, and semantic state are built into the primitives.", ), ]; Accordion::new("example-accordion") .w(px(270.)) .border_t_1() .border_color(super::example_rgb(0xd4d4d4)) .children( items .into_iter() .enumerate() .map(|(index, (question, answer))| { let open = self.accordion_items[index]; let entity = cx.entity().downgrade(); AccordionItem::new() .open(open) .header(AccordionHeader::new( AccordionTrigger::new(format!("accordion-trigger-{index}")) .on_change(move |next, _, _, cx| { _ = entity.update(cx, |this, cx| { this.accordion_items[index] = next; cx.notify(); }); }) .w_full() .flex() .items_center() .justify_between() .h_7() .border_b_1() .border_color(super::example_rgb(0xd4d4d4)) .text_xs() .child(question) .child( div() .text_color(super::example_rgb(0x737373)) .child(if open { "−" } else { "+" }), ), )) .panel( AccordionPanel::new() .px_1() .py_1() .border_b_1() .border_color(super::example_rgb(0xd4d4d4)) .text_xs() .text_color(super::example_rgb(0x525252)) .child(answer), ) }), ) } } ``` ## 可访问性 让触发器可聚焦、可用键盘操作,并向辅助技术暴露展开状态。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Combobox Source: /versions/v0.7.1/zh-CN/base/primitives/combobox 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/combobox 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 结合文本输入、键盘导航建议和选择行为的组合框。 和所有 GPUI Base 原语一样,Combobox 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- combobox ``` ## 导入 ```rust use gpui_kit::base::{Combobox}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/combobox.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/combobox.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 持久状态保存查询、候选项、焦点项和选择;输入与选择事件由应用处理。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use super::*; use gpui::MouseButton; impl BaseShowcase { pub(in super::super) fn combobox( &self, _window: &mut Window, cx: &mut Context, ) -> impl IntoElement { let open = self.combobox_open; let query = self.combobox_query.read(cx).value().to_lowercase(); let selected = self.combobox_selection.clone(); let entity = cx.entity().downgrade(); let query_state = self.combobox_query.clone(); let open_query_state = self.combobox_query.clone(); let trigger_entity = cx.entity().downgrade(); let trigger_query_state = self.combobox_query.clone(); let combobox = Combobox::new("example-combobox") .open(open) .on_open_change(move |open, window, cx| { _ = entity.update(cx, |this, cx| { this.combobox_open = open; cx.notify(); }); if open { open_query_state.update(cx, |state, cx| state.focus(window, cx)); } }) .w_56() .child( div() .id("combobox-trigger") .w_full() .h_7() .px_2() .flex() .items_center() .justify_between() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .text_xs() .bg(super::example_rgb(0xffffff)) .on_click(move |_, window, cx| { _ = trigger_entity.update(cx, |this, cx| { this.combobox_open = !open; cx.notify(); }); if !open { trigger_query_state.update(cx, |state, cx| state.focus(window, cx)); } }) .child(selected) .child( div() .text_color(super::example_rgb(0x737373)) .child(super::chevron(false)), ), ); let popup = div() .w_56() .text_xs() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .bg(super::example_rgb(0xffffff)) .child( InputBase::new("combobox-search") .w_full() .h_7() .px_2() .border_b_1() .border_color(super::example_rgb(0xe5e5e5)) .on_mouse_down(MouseButton::Left, move |_, window, cx| { query_state.update(cx, |state, cx| state.focus(window, cx)); }) .child(self.combobox_query.clone()), ) .child( div().p_1().children( ["GPUI", "React", "SwiftUI", "Vue"] .into_iter() .filter(|label| query.is_empty() || label.to_lowercase().contains(&query)) .map(|label| { let entity = cx.entity().downgrade(); div() .id(format!("combobox-{label}")) .px_2() .h_7() .flex() .items_center() .text_xs() .hover(|s| s.bg(super::example_rgb(0xf5f5f5))) .on_click(move |_, _, cx| { _ = entity.update(cx, |this, cx| { this.combobox_selection = label.into(); this.combobox_open = false; cx.notify(); }); }) .child(label) }), ), ); Popup::new("example-combobox-popup", combobox).when(open, |this| this.content(popup)) } } ``` ## 可访问性 保留组合框语义、活动后代关系以及上下键、Enter 和 Escape 操作。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Button Source: /versions/v0.7.1/zh-CN/base/primitives/button 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/button 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `Button` 提供按钮行为和语义结构,不强加产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- button ``` ## 导入 ```rust use gpui_kit::base::Button; ``` ## 结构与 API 示例组合了 `Button`。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/button.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/button.rs)。 ## 状态与事件 激活使用 GPUI 点击处理。悬停、按下、焦点和禁用样式由应用负责。受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use gpui::relative; use super::*; impl BaseShowcase { pub(in super::super) fn button(&self) -> impl IntoElement { div() .flex() .items_center() .gap_2() .child( Button::new("primary-button") .px_3() .h_7() .line_height(relative(1.)) .flex() .items_center() .text_xs() .border_1() .border_color(super::example_rgb(0x171717)) .bg(super::example_rgb(0x171717)) .text_color(super::example_rgb(0xffffff)) .hover(|style| style.bg(super::example_rgb(0x404040))) .child("Save changes"), ) .child( Button::new("secondary-button") .px_3() .h_7() .line_height(relative(1.)) .flex() .items_center() .text_xs() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .bg(super::example_rgb(0xffffff)) .hover(|style| style.bg(super::example_rgb(0xf5f5f5))) .child("Cancel"), ) } } ``` ## 可访问性 提供可访问名称,保留键盘激活能力,并正确暴露禁用状态。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Tooltip Source: /versions/v0.7.1/zh-CN/base/primitives/tooltip 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/tooltip 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 与触发元素关联、延迟显示且可定位的说明。 和所有 GPUI Base 原语一样,Tooltip 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- tooltip ``` ## 导入 ```rust use gpui_kit::base::{Tooltip}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/tooltip.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/tooltip.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 指针悬停或键盘聚焦后延迟显示,离开或失焦后关闭。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use super::*; impl BaseShowcase { pub(in super::super) fn tooltip(&self, cx: &mut Context) -> impl IntoElement { let visible = self.tooltip_visible; let entity = cx.entity().downgrade(); let trigger = div() .id("tooltip-trigger") .on_hover(move |hovered, _, cx| { _ = entity.update(cx, |this, cx| { this.tooltip_visible = *hovered; cx.notify(); }); }) .child( Button::new("tooltip-anchor") .h_7() .px_2() .flex() .items_center() .justify_center() .border_1() .border_color(super::example_rgb(0x171717)) .bg(super::example_rgb(0xffffff)) .child("Command menu"), ); Popup::new("example-tooltip-popup", trigger) .text_xs() .when(visible, |this| { this.content( Tooltip::new("example-tooltip") .px_2() .h_7() .flex() .items_center() .justify_center() .border_1() .border_color(super::example_rgb(0x171717)) .bg(super::example_rgb(0x171717)) .text_color(super::example_rgb(0xffffff)) .child("Open command menu · ⌘K"), ) }) } } ``` ## 可访问性 Tooltip 只补充说明,不能承载完成任务所必需的信息;触发器必须可聚焦。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Toggle Group Source: /versions/v0.7.1/zh-CN/base/primitives/toggle-group 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/toggle-group 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 将多个 Toggle 协调为单选或多选组。 和所有 GPUI Base 原语一样,Toggle Group 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- toggle-group ``` ## 导入 ```rust use gpui_kit::base::{Toggle, ToggleGroup}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/toggle-group.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/toggle-group.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 组模式决定只保留一个值还是一组值;变更由父级持久化。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use super::*; impl BaseShowcase { pub(in super::super) fn toggle_group(&self, cx: &mut Context) -> impl IntoElement { let italic = self.toggle_group_selection & 1 != 0; let underline = self.toggle_group_selection & 2 != 0; let entity = cx.entity().downgrade(); ToggleGroup::new("example-toggle-group") .flex() .text_xs() .gap_0() .child(self.toggle(cx)) .child( Toggle::new("italic-toggle") .pressed(italic) .size_7() .flex() .items_center() .justify_center() .border_1() .border_l_0() .border_color(super::example_rgb(0x171717)) .when(italic, |this| { this.bg(super::example_rgb(0x171717)) .text_color(super::example_rgb(0xffffff)) }) .accessibility_label("Italic") .child("I") .on_change({ let entity = entity.clone(); move |next, _, _, cx| { _ = entity.update(cx, |this, cx| { if next { this.toggle_group_selection |= 1 } else { this.toggle_group_selection &= !1 }; cx.notify(); }); } }), ) .child( Toggle::new("underline-toggle") .pressed(underline) .size_7() .flex() .items_center() .justify_center() .border_1() .border_l_0() .border_color(super::example_rgb(0x171717)) .when(underline, |this| { this.bg(super::example_rgb(0x171717)) .text_color(super::example_rgb(0xffffff)) }) .accessibility_label("Underline") .child("U") .on_change(move |next, _, _, cx| { _ = entity.update(cx, |this, cx| { if next { this.toggle_group_selection |= 2 } else { this.toggle_group_selection &= !2 }; cx.notify(); }); }), ) } } ``` ## 可访问性 提供组名称,清楚表达每项的按下状态,并支持一致的键盘导航。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Hover Card Source: /versions/v0.7.1/zh-CN/base/primitives/hover-card 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/hover-card 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 与指针或键盘触发器关联的延迟浮动卡片。 和所有 GPUI Base 原语一样,Hover Card 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 iOS 和 Android 上,点击触发元素切换卡片开关,点击外部关闭;忽略悬停及其打开、关闭延迟。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- hover-card ``` ## 导入 ```rust use gpui_kit::base::{HoverCard}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/hover-card.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/hover-card.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 悬停或聚焦触发器后延迟打开,离开后关闭。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use super::*; impl BaseShowcase { pub(in super::super) fn hover_card(&self) -> impl IntoElement { HoverCard::new("example-hover-card") .trigger( div() .id("hover-trigger") .px_3() .py_1() .text_xs() .text_color(super::example_rgb(0x171717)) .underline() .child("Hover over gpui-base"), ) .content(|_, _, _| { div() .id("hover-content") .w(px(210.)) .p_2() .text_xs() .bg(super::example_rgb(0xffffff)) .border_1() .border_color(super::example_rgb(0xd4d4d4)) .child( div() .flex() .items_center() .gap_2() .child( div() .size_7() .flex() .items_center() .justify_center() .border_1() .border_color(super::example_rgb(0x171717)) .text_sm() .child("G"), ) .child( div().text_sm().child("gpui-base").child( div() .text_sm() .text_color(super::example_rgb(0x737373)) .child("@gpui-base"), ), ), ) .child( div() .mt_2() .text_sm() .text_color(super::example_rgb(0x737373)) .child("Unstyled primitives for GPUI."), ) }) } } ``` ## 可访问性 不要把完成任务所必需的操作只放在 Hover Card 中;键盘焦点也应能触发。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Primitives Source: /versions/v0.7.1/zh-CN/base/primitives 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 GPUI Base 原语只提供行为,不规定视觉表现。每个页面都会说明公开导入路径和最小可用组合。页面上方的在线示例由 `crates/base/examples` 构建,也可以作为原生 GPUI 应用运行。 ## 原语目录 - [Accordion](/versions/v0.7.1/zh-CN/base/accordion) — 由可独立设置样式的标题、触发器和面板组成的折叠组。 - [Alert Dialog](/versions/v0.7.1/zh-CN/base/alert-dialog) — 用于需要明确确认之操作的模态对话框。 - [Avatar](/versions/v0.7.1/zh-CN/base/avatar) — 带可组合后备内容的人物或实体图像。 - [Button](/versions/v0.7.1/zh-CN/base/button) — 无样式、可访问且支持键盘激活的按钮。 - [Calendar](/versions/v0.7.1/zh-CN/base/calendar) — 支持选择匹配器和自定义日期渲染的状态驱动日历。 - [Checkbox](/versions/v0.7.1/zh-CN/base/checkbox) — 指示器可单独设置样式的受控三态复选框。 - [Collapsible](/versions/v0.7.1/zh-CN/base/collapsible) — 不限定触发器样式的可折叠内容区域。 - [Color Picker](/versions/v0.7.1/zh-CN/base/color-picker) — 构建自定义颜色选择器所需的状态和交互基础。 - [Combobox](/versions/v0.7.1/zh-CN/base/combobox) — 结合文本输入、键盘导航建议和选择行为的组合框。 - [Date Picker](/versions/v0.7.1/zh-CN/base/date-picker) — 将日历与弹出层组合起来、可感知焦点的日期输入。 - [Dialog](/versions/v0.7.1/zh-CN/base/dialog) — 带焦点管理、遮罩、标题和关闭部件的可组合模态层。 - [Hover Card](/versions/v0.7.1/zh-CN/base/hover-card) — 与指针或键盘触发器关联的延迟浮动卡片。 - [Input](/versions/v0.7.1/zh-CN/base/input) — 支持选择、掩码、验证和数值步进的单行输入。 - [Textarea](/versions/v0.7.1/zh-CN/base/textarea) — 支持固定行数、换行和自动增高的多行文本框。 - [Editor](/versions/v0.7.1/zh-CN/base/editor) — 支持高亮、行号槽、折叠、装饰和 LSP 扩展的代码编辑器基础。 - [Link](/versions/v0.7.1/zh-CN/base/link) — 样式由应用定义的可访问链接控件。 - [Nav Stack](/versions/v0.7.1/zh-CN/base/nav-stack) — 支持 push、pop、forward 与 replace 的视图导航栈,过渡生命周期可动画。 - [Number Input](/versions/v0.7.1/zh-CN/base/number-input) — 带递增、递减和步进行为的数字输入。 - [OTP Input](/versions/v0.7.1/zh-CN/base/otp-input) — 由共享文本状态驱动的多单元格验证码输入。 - [Pagination](/versions/v0.7.1/zh-CN/base/pagination) — 显式管理当前页与总页数的受控分页器。 - [Popover](/versions/v0.7.1/zh-CN/base/popover) — 支持受控或内部开关状态的锚定浮层。 - [Popup](/versions/v0.7.1/zh-CN/base/popup) — 底层触发器与锚定浮动内容宿主。 - [Progress](/versions/v0.7.1/zh-CN/base/progress) — 用轨道和指示器报告任务完成度。 - [Radio](/versions/v0.7.1/zh-CN/base/radio) — 具有选中与禁用语义的受控单选项。 - [Radio Group](/versions/v0.7.1/zh-CN/base/radio-group) — 为单项选择提供分组与键盘导航。 - [Resizable](/versions/v0.7.1/zh-CN/base/resizable) — 用于可调整分栏布局的面板组和拖拽手柄。 - [Scrollbar](/versions/v0.7.1/zh-CN/base/scrollbar) — 连接 GPUI 滚动句柄或统一列表句柄的滚动条。 - [Select](/versions/v0.7.1/zh-CN/base/select) — 由锚定且支持键盘导航的弹层驱动的选择控件。 - [Sheet](/versions/v0.7.1/zh-CN/base/sheet) — 从边缘进入并管理关闭和焦点的模态层。 - [Slider](/versions/v0.7.1/zh-CN/base/slider) — 轨道、指示区和滑块可独立设置样式的范围输入。 - [Switch](/versions/v0.7.1/zh-CN/base/switch) — 轨道与滑块可分别设置样式的受控开关。 - [Table](/versions/v0.7.1/zh-CN/base/table) — 用于组合表头、表体、行和单元格的语义化表格原语。 - [Tabs](/versions/v0.7.1/zh-CN/base/tabs) — 带受控选择的标签列表和可访问标签控件。 - [Time Field](/versions/v0.7.1/zh-CN/base/time-field) — 分段编辑一天中的时间,支持 24 或 12 小时制。 - [Toast](/versions/v0.7.1/zh-CN/base/toast) — 受管理、带动画的临时状态消息栈。 - [Toggle](/versions/v0.7.1/zh-CN/base/toggle) — 用于格式等持久选择的受控双态按钮。 - [Toggle Group](/versions/v0.7.1/zh-CN/base/toggle-group) — 将多个 Toggle 协调为单选或多选组。 - [Tooltip](/versions/v0.7.1/zh-CN/base/tooltip) — 与触发元素关联、延迟显示且可定位的说明。 - [Tree](/versions/v0.7.1/zh-CN/base/tree) — 显式管理展开与选择状态的虚拟化层级列表。 --- # Editor Source: /versions/v0.7.1/zh-CN/base/primitives/editor 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/editor 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `Editor` 是源代码编辑控件。它建立在共享文本引擎之上,增加语言、行号槽、折叠、空白字符显示、文本装饰、高亮、搜索基础、诊断与 LSP 扩展。单行值使用 [Input](/versions/v0.7.1/zh-CN/base/primitives/input),普通多行文本使用 [Textarea](/versions/v0.7.1/zh-CN/base/primitives/textarea)。 ## 语言编辑规则 Base 编辑器接受 `LanguageConfig` 及独立的 `auto_close` / `smart_indent` 选项。 它读取已注册的语言配置,不加载解析器。Component 在初始化时安装 `LanguageProvider`, 提供内置语言名称、默认规则及语法提供者;Base 使用者也可通过 `set_language_provider` 安装自己的服务,并用 `set_language_config` 配置语言规则。 配置字段及语言注册方式参见 [语言编辑规则](/versions/v0.7.1/zh-CN/component/editor#语言编辑规则)。直接使用 Base 时,从 `gpui_kit::base::input` 导入相同的配置类型。 ## 快捷键 Base 与样式组件共享键盘和鼠标行为。各平台快捷键、多光标编辑和矩形列选的细节请参阅 [快捷键与矩形列选](/versions/v0.7.1/zh-CN/component/editor#快捷键与矩形列选)。 ## 搜索 编辑器内置搜索面板。编辑器聚焦时按 `Ctrl-F`(Windows/Linux)或 `Cmd-F`(macOS)打开。编程式 API(`open_search`、`close_search`、`set_searchable`)与只读行为参见 [搜索](/versions/v0.7.1/zh-CN/component/editor#搜索)。 ## 导入 ```rust use gpui_kit::base::input::{Editor, EditorState, TabSize}; ``` ## 基本用法 ```rust let editor = cx.new(|cx| { EditorState::new(window, cx) .language("rust") .line_number(true) .folding(true) .tab_size(TabSize { tab_size: 4, hard_tabs: false }) .default_value("fn main() {\n println!(\"Hello\");\n}") }); Editor::new(&editor) ``` ## 空白字符与装饰 通过 `show_whitespaces(true)` 显示空白字符,通过 `create_decorations_collection` 创建随文本编辑自动跟踪范围的装饰集合。保留句柄用于更新或清空条目;丢弃句柄不会移除装饰。 `create_range_decorations_collection` 创建独立的 `RangeDecoration` 填充或边框集合,只参与绘制。 文本装饰和几何装饰共享 UTF-8 范围归一化与编辑跟踪。 所有权、边界亲和性、删除、撤销重做、折叠、绘制顺序及索引语义参见 [几何范围装饰](/versions/v0.7.1/zh-CN/component/editor#几何范围装饰);直接使用 Base 时,从 `gpui_kit::base::input` 导入相同的类型。 ## 高亮与语言功能 `InputHighlighterFactory`、`InputHighlighter`、诊断类型和 LSP provider trait 是提供给设计系统作者的底层扩展点,作用于共享的 `InputBaseState`。样式组件的应用通常应通过编辑器集成配置它们,而不是普通文本框。 可运行展示使用 `syntect`,在 WASM 中选择兼容的 `fancy-regex` 后端。Syntect 只识别语法 scope;适配器把它们映射为语义名称,再由 `HighlightStyleResolver` 从应用主题解析颜色和字体样式。示例会在每次编辑后重新解析短代码;生产集成可以在 `InputHighlighter` 中保留增量解析状态。 ## 字体与表现 Editor 没有独立字体设置,而是使用环境文本样式。可在外层元素设置 `font_family`、`text_size`、字重和行高。应用负责编辑器颜色、行号槽、折叠图标和覆盖层;使用 `InputEditorStyle`、`FoldIconRenderer` 与 provider trait 接入。现成视觉方案参见 [`gpui-component` Editor](/versions/v0.7.1/zh-CN/component/editor)。 ## 可运行示例 ```bash cargo run -p gpui-base-examples -- editor ``` --- # Popup Source: /versions/v0.7.1/zh-CN/base/primitives/popup 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/popup 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 底层触发器与锚定浮动内容宿主。 和所有 GPUI Base 原语一样,Popup 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- popup ``` ## 导入 ```rust use gpui_kit::base::{Popup}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/popup.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/popup.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 应用负责打开状态和关闭策略;Popup 负责锚定及浮层结构。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use gpui::{ Context, IntoElement, ParentElement as _, Styled as _, div, prelude::FluentBuilder as _, relative, }; use gpui_base::{Button, Popup}; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn popup(&self, cx: &mut Context) -> impl IntoElement { let open = self.popup_open; let entity = cx.entity().downgrade(); Popup::new( "example-popup", Button::new("popup-trigger") .h_7() .line_height(relative(1.)) .px_3() .flex() .items_center() .justify_center() .bg(gpui::black()) .text_color(gpui::white()) .on_click(move |_, _, cx| { _ = entity.update(cx, |this, cx| { this.popup_open = !this.popup_open; cx.notify(); }); }) .child(if open { "Close popup" } else { "Open popup" }), ) .when(open, |this| { this.content( div() .w_64() .p_2() .text_xs() .bg(super::example_rgb(0xffffff)) .border_1() .border_color(super::example_rgb(0x171717)) .child("Anchored surface") .child( div() .mt_1() .text_sm() .text_color(super::example_rgb(0x737373)) .child("Popup positions content relative to its trigger."), ), ) }) } } ``` ## 可访问性 根据所组合控件补充正确角色、名称、焦点管理与 Escape 关闭行为。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Nav Stack Source: /versions/v0.7.1/zh-CN/base/primitives/nav-stack 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/nav-stack 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 后进先出的视图栈,同一时刻只显示一个:把新视图 push 到当前视图之上,pop 回到下面那个,或 replace 掉栈顶。它对应 SwiftUI 的 `NavigationStack`、Qt 的 `StackView` 和 WinUI 的 `Frame`。底层是一份视图的 [History](/versions/v0.7.1/zh-CN/base/history),活动条目从根页面排列到当前页面。pop 掉的页面会成为前进条目,直到下一次 push 丢弃这条前进分支,所以 `forward` 能像 WinUI 的 `GoForward` 一样把它带回来。 和所有 GPUI Base 原语一样,Nav Stack 只提供行为和语义结构,不规定产品视觉语言。页面是你创建的视图,页面之间怎么切换由你的 item renderer 决定。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- nav-stack ``` ## 导入 ```rust use gpui_kit::base::{NavMotion, NavOperation, NavPage, NavStack, NavStackState}; use gpui_kit::base::motion::{PresencePhase, Transition}; ``` ## 结构与 API `NavStackState` 就是栈。它放在 GPUI entity 里,按根在前的顺序持有 `AnyView`,每次变化后 emit `NavStackEvent`。 | 方法 | 作用 | | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | | `push(view, motion, cx)` | 压到当前栈顶之上。压入空栈时立即生效,与 Qt 的 `initialItem` 一致。 | | `pop(motion, cx)` | 弹出栈顶并返回它。根页面永远不会被弹出,深度为 1 时返回 `None`。 | | `pop_to_root(motion, cx)` | 用一次过渡弹掉根以上的全部页面,并返回它们。 | | `forward(motion, cx)` | 把最近弹掉的页面带回到当前栈顶之上并返回它。上次 push 之后没有弹过页面时返回 `None`。 | | `replace(view, motion, cx)` | 用 `view` 换掉栈顶并返回被换掉的页面,前进页保留。空栈时等于 push。 | | `clear(cx)` | 立即清空栈和前进页。 | | `depth()`、`is_empty()`、`current()`、`views()`、`forward_views()` | 读取栈。`depth() > 1` 时显示返回按钮,`forward_views()` 非空时显示前进按钮。 | `NavStack` 是元素。它持有 entity,用 `transition` 指定每次变化的时长,把每个已挂载的视图作为 `NavPage` 交给 `item` renderer。元素本身负责尺寸、背景和裁剪;它已经设置了定位,让一次变化中的两个页面可以重叠。 `NavPage` 是 renderer 收到的东西,已经铺满容器。读取 `phase()`(`Entering`、`Present` 或 `Exiting`)、`operation()`(`Push`、`Pop`、`Replace`,稳定后为 `None`)和 `progress()`(已缓动,`0.0` 到 `1.0`,一次变化中两个页面共用),用 GPUI 样式修饰后返回。 权威实现位于 [`components/nav_stack.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/nav_stack.rs),原生与浏览器预览编译的是同一文件。 ## 动画 动画在两个层面决定,默认都没有: - **整个栈。** 不带 `transition` 的 `NavStack` 永远不动画,每次变化立即切换。给它一个 `Transition` 就会动画,再用 `item` renderer 决定怎么动。 - **单次变化。** `push`、`pop`、`pop_to_root` 和 `replace` 都接收一个 `NavMotion`,对应 UIKit 的 `animated:` 和 Qt 的 `StackView.Immediate`。`NavMotion::Animated` 走栈的 transition;`NavMotion::Immediate` 即便栈配了动画也立即切换,启动时恢复栈、从命令直接跳到某页时用它。 ```rust stack.update(cx, |stack, cx| stack.push(detail, NavMotion::Animated, cx)); stack.update(cx, |stack, cx| stack.push(restored, NavMotion::Immediate, cx)); ``` ## 过渡 push、pop 或 replace 之后,出场的视图会一直挂载到元素的 `Transition` 结束。绘制顺序跟随操作:push 或 replace 进来的页面盖在被它覆盖的页面上,pop 出去的页面盖在被它露出的页面上,所以滑动两个方向都正确。 ```rust NavStack::new(&self.stack) .size_full() .overflow_hidden() .transition(Transition::new(Duration::from_millis(220))) .item(|page, _, _| { let offset = match (page.phase(), page.operation()) { (PresencePhase::Entering, Some(NavOperation::Push)) => 1.0 - page.progress(), (PresencePhase::Exiting, Some(NavOperation::Pop)) => page.progress(), _ => 0.0, }; page.left(relative(offset)).into_any_element() }) ``` 系统要求减少动态效果时,无论 renderer 想画什么,栈都立即切换。过渡进行中来了新操作,新操作接管,页面从当前位置反向,不会跳变。过渡进行期间两个页面都不接收指针输入。 ## 状态与事件 把 `NavStackState` entity 放在渲染栈的视图上并 observe 它,这样从任何地方 push 都会让宿主重绘。需要导航的页面持有栈的 `WeakEntity`,示例页面就是这么做的。 `views()` 和 `forward_views()` 足够做一个历史菜单:把两者列出来,选中后连续 pop 或 forward 到那一页。示例页面把这个列表画成一行页码,前方的页面灰显。 栈不会移动焦点。`AnyView` 不带 focus handle;需要焦点的页面在被 push 时自己拿,和在其他地方一样。 ## 完整 Rust 示例 ```rust use super::*; use gpui::{AnyElement, WeakEntity, relative}; use gpui_base::NavPage; use gpui_base::motion::{PresencePhase, Transition}; use std::time::Duration; /// One page of the stack. A page knows its depth and holds the stack it lives /// in, so its own buttons can push over it, replace it, or pop it. pub(in super::super) struct ShowcasePage { depth: usize, stack: WeakEntity, } impl ShowcasePage { pub(in super::super) fn new(depth: usize, stack: WeakEntity) -> Self { Self { depth, stack } } /// A click handler that builds a page at `depth` and hands it to `apply`: /// a pushed page sits one deeper, a replacement at the same depth. fn navigate( &self, depth: usize, apply: impl Fn(&mut NavStackState, gpui::Entity, &mut Context) + 'static, ) -> impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static { let stack = self.stack.clone(); move |_, _, cx| { _ = stack.update(cx, |state, cx| { let page = cx.new(|_| ShowcasePage::new(depth, stack.clone())); apply(state, page, cx); }); } } } impl Render for ShowcasePage { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let depth = self.depth; // The trail is the stack's `History`: the pages behind this one, then // the pages popped off it, which `forward` brings back one at a time. let (behind, ahead) = self .stack .upgrade() .map(|stack| { let stack = stack.read(cx); (stack.depth(), stack.forward_views().len()) }) .unwrap_or((depth, 0)); let button = |id: &'static str, label: &'static str| { Button::new(id) .h_7() .px_2() .flex() .items_center() .border_1() .border_color(example_rgb(0x171717)) .bg(example_rgb(0xffffff)) .child(label) }; div() .size_full() .flex() .flex_col() .gap_3() .p_3() .bg(example_rgb(if depth % 2 == 1 { 0xffffff } else { 0xf5f5f5 })) .child( div() .font_weight(gpui::FontWeight::SEMIBOLD) .child(format!("Page {depth}")), ) .child( div() .flex() .gap_1() .text_color(example_rgb(0x737373)) .children((1..=behind + ahead).map(|page| { div() .px_1() .when(page == depth, |this| { this.text_color(example_rgb(0x171717)) .font_weight(gpui::FontWeight::SEMIBOLD) }) .when(page > behind, |this| this.text_color(example_rgb(0xd4d4d4))) .child(page.to_string()) })), ) .child( div() .flex() .gap_2() .child(button("push", "Push").on_click( self.navigate(depth + 1, |stack, page, cx| { stack.push(page, NavMotion::Animated, cx) }), )) .child(button("replace", "Replace").on_click(self.navigate( depth, |stack, page, cx| { stack.replace(page, NavMotion::Animated, cx); }, ))) .when(depth > 1, |this| { let stack = self.stack.clone(); this.child(button("pop", "Pop").on_click(move |_, _, cx| { _ = stack.update(cx, |stack, cx| { stack.pop(NavMotion::Animated, cx); }); })) }) .when(ahead > 0, |this| { let stack = self.stack.clone(); this.child(button("forward", "Forward").on_click(move |_, _, cx| { _ = stack.update(cx, |stack, cx| { stack.forward(NavMotion::Animated, cx); }); })) }), ) } } impl BaseShowcase { pub(in super::super) fn nav_stack(&self) -> impl IntoElement { NavStack::new(&self.stack) .w_72() .h_40() .overflow_hidden() .border_1() .border_color(example_rgb(0xd4d4d4)) .transition(Transition::new(Duration::from_millis(220))) .item(|page, _, _| slide(page)) } } /// A pushed page slides in from the right and slides back out when popped; /// the page underneath drifts a little to show depth. A replacement slides in /// over the page it replaces. The showcase's own shell uses this too. pub(in super::super) fn slide(page: NavPage) -> AnyElement { let offset = match (page.phase(), page.operation()) { (PresencePhase::Entering, Some(NavOperation::Push | NavOperation::Replace)) => { 1.0 - page.progress() } (PresencePhase::Exiting, Some(NavOperation::Pop)) => page.progress(), (PresencePhase::Exiting, Some(NavOperation::Push)) => -0.3 * page.progress(), (PresencePhase::Entering, Some(NavOperation::Pop)) => -0.3 * (1.0 - page.progress()), _ => 0.0, }; page.left(relative(offset)).into_any_element() } ``` ## 可访问性 页面切换由页面自己宣告:每页顶部放一个标题,辅助技术在 push 之后有地方落脚。过渡结束后栈只保留当前页面可交互。 ## 注意事项 页面是 entity。栈会保留在栈上的页面,以及上次 push 之后弹掉、`forward` 能带回来的页面,所以页面自己的订阅和定时器会一直活到某次 push 丢弃它或栈被清空。请在消费端设计系统中验证减少动态效果时的表现。 --- # Radio Group Source: /versions/v0.7.1/zh-CN/base/primitives/radio-group 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/radio-group 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 将单选项分组,并为单项选择提供键盘导航。 和所有 GPUI Base 原语一样,Radio Group 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- radio-group ``` ## 导入 ```rust use gpui_kit::base::{Radio, RadioGroup}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/radio-group.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/radio-group.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 组持有唯一选中值,并协调各项的焦点与选择。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use gpui::{ Context, IntoElement, ParentElement as _, Styled as _, div, prelude::FluentBuilder as _, px, }; use gpui_base::{Radio, RadioGroup}; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn radio_group(&self, cx: &mut Context) -> impl IntoElement { let entity = cx.entity().downgrade(); RadioGroup::new("example-radio-group") .w_56() .text_xs() .flex() .flex_col() .gap_2() .child(self.radio(cx)) .child( Radio::new("express-radio") .checked(self.radio_selected == 1) .on_change(move |next, _, _, cx| { if next { _ = entity.update(cx, |this, cx| { this.radio_selected = 1; cx.notify(); }); } }) .flex() .items_start() .gap_2() .child( div() .mt(px(2.)) .flex() .items_center() .justify_center() .size(px(14.)) .border_1() .border_color(super::example_rgb(0x171717)) .when(self.radio_selected == 1, |this| { this.child(div().size(px(6.)).bg(super::example_rgb(0x171717))) }), ) .child( div().child("Express").child( div() .text_xs() .text_color(super::example_rgb(0x737373)) .child("Next business day"), ), ), ) .child( Radio::new("pickup-radio") .disabled(true) .flex() .items_start() .gap_2() .opacity(0.45) .child( div() .mt(px(2.)) .size(px(14.)) .border_1() .border_color(super::example_rgb(0x171717)), ) .child( div() .child("Local pickup") .child(div().text_xs().child("Currently unavailable")), ), ) } } ``` ## 可访问性 提供组标签,保留方向键导航以及各项的选中、禁用语义。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Collapsible Source: /versions/v0.7.1/zh-CN/base/primitives/collapsible 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/collapsible 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 不规定触发器样式的可组合显隐区域。 和所有 GPUI Base 原语一样,Collapsible 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- collapsible ``` ## 导入 ```rust use gpui_kit::base::{Collapsible}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/collapsible.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/collapsible.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 父级持有打开状态;内容可结合 Motion 做进入与退出过渡。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use super::*; impl BaseShowcase { pub(in super::super) fn collapsible(&self, cx: &mut Context) -> impl IntoElement { let open = self.collapsible_open; let entity = cx.entity().downgrade(); Collapsible::new() .open(open) .w_64() .child( div() .flex() .items_center() .justify_between() .child(div().text_xs().child("@gpui/base · 3 repositories")) .child( Button::new("collapsible-trigger") .size_7() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .flex() .items_center() .justify_center() .on_click(move |_, _, cx| { _ = entity.update(cx, |this, cx| { this.collapsible_open = !this.collapsible_open; cx.notify(); }); }) .child(if open { "−" } else { "+" }), ), ) .child( div() .mt_2() .px_2() .h_7() .flex() .items_center() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .text_xs() .child("gpui-component"), ) .content(div().mt_2().flex().flex_col().gap_2().children( ["gpui-base", "gpui-storybook"].into_iter().map(|name| { div() .px_2() .h_7() .flex() .items_center() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .text_xs() .child(name) }), )) } } ``` ## 可访问性 触发器应暴露展开状态以及它所控制内容的关系。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Calendar Source: /versions/v0.7.1/zh-CN/base/primitives/calendar 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/calendar 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 支持选择匹配器和自定义日期项渲染的状态驱动日期网格。 和所有 GPUI Base 原语一样,Calendar 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- calendar ``` ## 导入 ```rust use gpui_kit::base::{Calendar, CalendarState}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/calendar.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/calendar.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 CalendarState 保存可见月份和选择;回调负责同步受控值。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use super::*; impl BaseShowcase { pub(in super::super) fn calendar(&self) -> impl IntoElement { Calendar::new("example-calendar", &self.calendar) // 7 × 32px cells + 12px padding on each side + 1px borders. .w(px(250.)) .p_3() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .item(|item, state, _, _| { match state.kind() { CalendarItemKind::Previous | CalendarItemKind::Next => item .size_7() .flex() .items_center() .justify_center() .hover(|s| s.bg(super::example_rgb(0xf5f5f5))), CalendarItemKind::MonthToggle | CalendarItemKind::YearToggle => item .px_1() .h_7() .flex() .items_center() .justify_center() .text_xs() .hover(|s| s.bg(super::example_rgb(0xf5f5f5))), CalendarItemKind::Weekday => item .size_8() .flex() .items_center() .justify_center() .text_xs() .text_color(super::example_rgb(0x737373)), CalendarItemKind::Day => item .size_8() .flex() .items_center() .justify_center() .text_xs() .when(state.is_muted(), |s| { s.text_color(super::example_rgb(0xa3a3a3)) }) .when(state.is_today() && !state.is_active(), |s| { s.border_1().border_color(super::example_rgb(0xd4d4d4)) }) .when(state.is_active(), |s| { s.bg(super::example_rgb(0x171717)) .text_color(super::example_rgb(0xffffff)) }) .when(!state.is_disabled() && !state.is_active(), |s| { s.hover(|s| s.bg(super::example_rgb(0xf5f5f5))) }), CalendarItemKind::Month | CalendarItemKind::Year => item .w(px(74.)) .h_7() .flex() .items_center() .justify_center() .text_xs() .when(state.is_active(), |s| { s.bg(super::example_rgb(0x171717)) .text_color(super::example_rgb(0xffffff)) }) .when(!state.is_active(), |s| { s.hover(|s| s.bg(super::example_rgb(0xf5f5f5))) }), } .into_any_element() }) .label(|kind, value| match kind { CalendarItemKind::Previous => "‹".into(), CalendarItemKind::Next => "›".into(), CalendarItemKind::MonthToggle | CalendarItemKind::Month => [ "", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ][value as usize] .into(), CalendarItemKind::Weekday => { ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][value as usize].into() } _ => value.to_string().into(), }) } } ``` ## 可访问性 保留日期网格语义、方向键导航、焦点与选中状态。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Sheet Source: /versions/v0.7.1/zh-CN/base/primitives/sheet 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/sheet 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 从边缘进入并管理关闭与焦点的模态界面。 和所有 GPUI Base 原语一样,Sheet 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- sheet ``` ## 导入 ```rust use gpui_kit::base::{Sheet}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/sheet.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/sheet.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 打开状态由应用或触发器控制;退出结束前内容可继续挂载。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use gpui::relative; use super::*; impl BaseShowcase { pub(in super::super) fn sheet(&self, cx: &mut Context) -> impl IntoElement { let open = self.sheet_open; let entity = cx.entity().downgrade(); let open_sheet = entity.clone(); let trigger = Button::new("open-sheet") .h_7() .px_2() .text_xs() .flex() .items_center() .justify_center() .border_1() .border_color(super::example_rgb(0x171717)) .bg(super::example_rgb(0xffffff)) .child("Open settings") .on_click(move |_, _, cx| { _ = open_sheet.update(cx, |this, cx| { this.sheet_open = true; cx.notify(); }); }); div() .size_full() .min_h_64() .text_xs() .flex() .items_center() .justify_center() .child(trigger) .when(open, |this| { this.child( Sheet::new(cx) .request_close({ let entity = entity.clone(); move |_, cx| { _ = entity.update(cx, |this, cx| { this.sheet_open = false; cx.notify(); }); } }) .overlay( div() .absolute() .inset_0() .bg(super::example_rgb(0x000000)) .opacity(0.15), ) .surface( div() .absolute() .right_0() .top_0() .h_full() .w(px(210.)) .p_3() .bg(super::example_rgb(0xffffff)) .border_1() .border_color(super::example_rgb(0x171717)) .child( div() .font_weight(gpui::FontWeight::SEMIBOLD) .child("Settings"), ) .child( div().mt_4().child("Workspace name").child( div() .mt_1() .h_7() .px_2() .flex() .items_center() .border_1() .border_color(super::example_rgb(0xa3a3a3)) .child("Acme Studio"), ), ) .child( div() .mt_2() .text_color(super::example_rgb(0x525252)) .child("Update the workspace preferences for your team."), ) .child( div() .mt_4() .py_1() .border_t_1() .border_color(super::example_rgb(0xd4d4d4)) .child("Notifications · Enabled"), ) .child( div().mt_3().flex().justify_end().child( Button::new("close-sheet") .h_7() .line_height(relative(1.)) .px_3() .flex() .items_center() .justify_center() .bg(gpui::black()) .text_color(gpui::white()) .child("Done") .on_click({ let entity = entity.clone(); move |_, _, cx| { _ = entity.update(cx, |this, cx| { this.sheet_open = false; cx.notify(); }); } }), ), ), ), ) }) } } ``` ## 可访问性 按模态层处理焦点与背景交互,提供标题和明确关闭方式。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Select Source: /versions/v0.7.1/zh-CN/base/primitives/select 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/select 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 由锚定、支持键盘导航的弹层驱动的选择控件。 和所有 GPUI Base 原语一样,Select 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- select ``` ## 导入 ```rust use gpui_kit::base::{Select}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/select.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/select.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 应用保存当前值;打开状态、焦点项与选择行为由控件协调。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use super::*; impl BaseShowcase { pub(in super::super) fn select( &self, combobox: bool, cx: &mut Context, ) -> impl IntoElement { let open = self.select_open; let selected = self.select_index.min(3); let labels = ["GPUI", "React", "SwiftUI", "Vue"]; let entity = cx.entity().downgrade(); let trigger_entity = entity.clone(); let trigger = div() .id("select-trigger") .h_7() .px_2() .text_xs() .flex() .items_center() .justify_between() .border_1() .border_color(super::example_rgb(0x171717)) .on_click(move |_, _, cx| { _ = trigger_entity.update(cx, |this, cx| { this.select_open = !open; cx.notify(); }); }) .child(labels[selected]) .child(super::chevron(open)); let options = div() .mt_1() .p_1() .border_1() .border_color(super::example_rgb(0x171717)) .bg(super::example_rgb(0xffffff)) .children(labels.into_iter().enumerate().map(|(ix, label)| { let entity = entity.clone(); div() .id(("select-option", ix)) .px_2() .py_1() .flex() .justify_between() .hover(|this| this.bg(super::example_rgb(0xf5f5f5))) .child(label) .when(ix == selected, |this| this.child("✓")) .on_click(move |_, _, cx| { _ = entity.update(cx, |this, cx| { this.select_index = ix; this.select_open = false; cx.notify(); }); }) })); if combobox { let root = Combobox::new("example-combobox") .open(open) .w_56() .child(trigger); Popup::new("example-combobox-options", root) .when(open, |this| this.content(options)) .into_any_element() } else { let root = Select::new("example-select") .open(open) .on_open_change({ let entity = entity.clone(); move |next, _, cx| { _ = entity.update(cx, |this, cx| { this.select_open = next; cx.notify(); }); } }) .accessibility_label("Framework") .w_56() .child(trigger); Popup::new("example-select-options", root) .when(open, |this| this.content(options)) .into_any_element() } } } ``` ## 可访问性 在受控根节点上设置 `.accessibility_label(...)`,并把 `.accessibility_value(...)` 设为已提交的选中项,而不是临时的搜索游标。根节点会暴露展开状态与可访问的激活操作。 激活会请求切换展开状态,并在 trigger 与内容之间移动焦点。禁用的控件不暴露激活操作。 带样式的 `Select` 会自动提供已提交的值,未选中时回退到 placeholder。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Progress Source: /versions/v0.7.1/zh-CN/base/primitives/progress 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/progress 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 用于报告任务完成度的可组合轨道与指示器。 和所有 GPUI Base 原语一样,Progress 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- progress ``` ## 导入 ```rust use gpui_kit::base::{Progress, ProgressIndicator, ProgressTrack}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/progress.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/progress.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 应用提供规范化进度值;视觉宽度由该值派生。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use gpui::{IntoElement, ParentElement as _, Styled as _, div, px}; use gpui_base::{Progress, ProgressIndicator, ProgressTrack}; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn progress(&self) -> impl IntoElement { div() .w_64() .flex() .flex_col() .gap_2() .text_xs() .child( div() .flex() .justify_between() .child("Uploading assets") .child("68%"), ) .child( Progress::new("example-progress").value(68.).child( ProgressTrack::new() .w_full() .h(px(7.)) .border_1() .border_color(super::example_rgb(0x171717)) .child( ProgressIndicator::new() .w(px(177.)) .h_full() .bg(super::example_rgb(0x171717)), ), ), ) .child( div() .flex() .justify_between() .text_sm() .text_color(super::example_rgb(0x737373)) .child("Optimizing bundle") .child("32%"), ) .child( Progress::new("example-progress-secondary") .value(32.) .child( ProgressTrack::new() .w_full() .h(px(6.)) .border_1() .border_color(super::example_rgb(0xa3a3a3)) .child( ProgressIndicator::new() .w(px(83.)) .h_full() .bg(super::example_rgb(0x737373)), ), ), ) } } ``` ## 可访问性 暴露进度角色、当前值、最小值、最大值及有意义的文本标签。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # OTP Input Source: /versions/v0.7.1/zh-CN/base/primitives/otp-input 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/otp-input 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 由共享文本状态驱动的多单元格一次性验证码输入。 和所有 GPUI Base 原语一样,OTP Input 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- otp-input ``` ## 导入 ```rust use gpui_kit::base::{OtpInput, OtpState}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/otp-input.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/otp-input.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 OtpState 保存完整验证码,各视觉单元格只是同一输入状态的投影。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use gpui::{Context, IntoElement, ParentElement as _, Styled as _, div}; use gpui_base::OtpInput; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn otp_input(&self, cx: &mut Context) -> impl IntoElement { let value: Vec = self.otp.read(cx).value().chars().collect(); let active = value.len().min(5); div() .w_56() .flex() .flex_col() .gap_1() .text_xs() .child(div().text_xs().child("Verification code")) .child( div().child( OtpInput::new(&self.otp) .flex() .gap_1() .children((0..6).map(|ix| { div() .size_7() .flex() .items_center() .justify_center() .border_1() .border_color(if ix == active { super::example_rgb(0x171717) } else { super::example_rgb(0xd4d4d4) }) .child(value.get(ix).copied().unwrap_or(' ').to_string()) })), ), ) .child( div() .text_xs() .text_color(super::example_rgb(0x737373)) .child("Enter the 6-digit code."), ) } } ``` ## 可访问性 提供整体标签,不要让辅助技术把每个视觉格误读为互不相关的输入。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Time Field Source: /versions/v0.7.1/zh-CN/base/primitives/time-field 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/time-field 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 分段编辑一天中的时间,提供完整的键盘模型,支持 24 或 12 小时制。 和所有 GPUI Base 原语一样,Time Field 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- time-field ``` ## 导入 ```rust use gpui_kit::base::{HourCycle, TimeField, TimeFieldEvent, TimeFieldState, TimePrecision}; ``` ## 结构与 API 示例在 `TimeFieldState` 之上组合 `TimeField`。字段为时、分以及可选的秒和上午/下午各渲染一个 `TimeFieldSegment`,数字段之间用 `:` 分隔。用 `Styled` 布局和装饰根元素,通过 `TimeField::render_segment` 装饰每一段;该插槽会收到 `TimeFieldSegmentState`,包含段类型、当前值以及是否选中。权威实现位于 [`components/time_field.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/time_field.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 `TimeFieldState` 保存时间、精度(`TimePrecision::Minute` 或 `Second`)和小时制(默认 `HourCycle::H23`,也可以是 `H12`)。`set_time` 替换值但不触发事件;用户编辑会触发 `TimeFieldEvent::Change`。 整个字段是一个 Tab 停靠点。Up/Down 调整当前选中的段,并在段内循环、不向上一级进位;Left/Right 和 Tab/Shift-Tab 在段之间移动;输入数字时使用两位缓冲,当前段无法再容纳更多数字时自动跳到下一段;`a`/`p` 切换上午或下午;Backspace/Delete 重置当前段。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use gpui::{ Context, IntoElement, ParentElement as _, Styled as _, div, prelude::FluentBuilder as _, }; use gpui_base::TimeField; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn time_field(&self, cx: &mut Context) -> impl IntoElement { let value = self.time_field.read(cx).time(); div() .w_56() .flex() .flex_col() .gap_1() .text_xs() .child(div().child("Reminder time")) .child( TimeField::new("example-time-field", &self.time_field) .flex() .items_center() .h_7() .px_2() .border_1() .border_color(super::example_rgb(0xa3a3a3)) .bg(super::example_rgb(0xffffff)) .render_segment(|segment, state, _, _| { segment .px_0p5() .when(state.is_selected(), |this| { this.bg(super::example_rgb(0xdbeafe)) }) .into_any_element() }), ) .child( div() .text_color(super::example_rgb(0x737373)) .child(format!("Selected {}", value.format("%H:%M:%S"))), ) } } ``` ## 可访问性 根元素以 `Role::TimeInput` 暴露,值为格式化后的时间。请为字段提供标签,并让选中的段在视觉上与其他段明显区分。 ## 注意事项 使用等宽数字或固定段宽,避免输入数字时字段宽度变化。在消费端设计系统中验证焦点、选中、禁用和高对比度状态。 --- # Date Picker Source: /versions/v0.7.1/zh-CN/base/primitives/date-picker 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/date-picker 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 将日历行为与弹出层组合的焦点感知日期输入。 和所有 GPUI Base 原语一样,Date Picker 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- date-picker ``` ## 导入 ```rust use gpui_kit::base::{DatePicker}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/date-picker.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/date-picker.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 应用持有日期与打开状态,并处理输入或日历产生的变更。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use super::*; impl BaseShowcase { pub(in super::super) fn date_picker(&self, cx: &mut Context) -> impl IntoElement { let open = self.date_open; let entity = cx.entity().downgrade(); let trigger_entity = entity.clone(); let trigger = Button::new("date-trigger") .w_full() .h_7() .px_3() .flex() .items_center() .justify_between() .border_1() .border_color(super::example_rgb(0xa3a3a3)) .bg(super::example_rgb(0xffffff)) .on_click(move |_, _, cx| { _ = trigger_entity.update(cx, |this, cx| { this.date_open = !open; cx.notify(); }); }) .child("Aug 12, 2026") .child(super::chevron(false)); let popup = Popup::new("date-picker-popup", trigger).when(open, |this| { this.content( div() .w(px(250.)) .bg(super::example_rgb(0xffffff)) .child(self.calendar()), ) }); DatePicker::new("example-date-picker", &self.date_focus) .open(open) .on_open_change(move |open, _, cx| { _ = entity.update(cx, |this, cx| { this.date_open = open; cx.notify(); }); }) .w(px(250.)) .text_xs() .child(popup) } } ``` ## 可访问性 输入应有标签;弹出日历需保留键盘导航和清晰焦点。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Resizable Source: /versions/v0.7.1/zh-CN/base/primitives/resizable 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/resizable 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 用于用户可调分栏布局的面板组和调整手柄。 和所有 GPUI Base 原语一样,Resizable 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- resizable ``` ## 导入 ```rust use gpui_kit::base::{ResizablePanel, ResizablePanelGroup, ResizableState, h_resizable, resizable_panel}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/resizable.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/resizable.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 ResizableState 持久保存面板尺寸;拖动手柄时更新约束内的比例。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 手柄外观 手柄的命中带、光标和拖拽由 Base 负责,画在里面的东西归使用方。`ResizeHandleRenderer` 拿到的 `ResizeHandleContext` 带有轴向和一个 `ResizeHandleState`——`Idle`、`Hovered`、`Pressed` 或 `Dragging`。后两个状态由 Base 自己跟踪:拖拽开始后指针几乎立刻离开那条 9px 的命中带,GPUI 的 hover 在整段拖拽里大多是 false。 renderer 返回 `None` 时保留 Base 自带的 1px 细线,因此只想改其中几个手柄时,不必把其余的一并重画。 ## 完整 Rust 示例 ```rust use gpui::{IntoElement, ParentElement as _, Styled as _, div, px}; use gpui_base::{h_resizable, resizable_panel}; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn resizable(&self) -> impl IntoElement { div() .w_72() .h_40() .text_xs() .border_1() .border_color(super::example_rgb(0x171717)) .child( h_resizable("example-resizable") .child( resizable_panel() .size(px(124.)) .size_range(px(116.)..px(210.)) .child( div() .size_full() .flex() .items_center() .justify_center() .border_r_1() .border_color(super::example_rgb(0x171717)) .p_2() .items_start() .justify_start() .flex_col() .gap_1() .child( div() .text_xs() .text_color(super::example_rgb(0x737373)) .child("PROJECT"), ) .children(["Overview", "Components", "Settings"].map( |label| { div() .w_full() .h(px(26.)) .px_2() .flex() .items_center() .whitespace_nowrap() .child(label) }, )), ), ) .child( resizable_panel().child( div() .size_full() .flex() .items_center() .justify_center() .bg(super::example_rgb(0xffffff)) .p_2() .items_start() .justify_start() .flex_col() .gap_2() .child(div().child("Workspace")) .child( div() .text_color(super::example_rgb(0x737373)) .child("Drag the divider to resize navigation."), ), ), ), ) } } ``` ## 可访问性 手柄应可聚焦、可由键盘调整,并提供方向和当前尺寸信息。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Color Picker Source: /versions/v0.7.1/zh-CN/base/primitives/color-picker 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/color-picker 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 为自定义颜色选择器提供状态与交互基础。 和所有 GPUI Base 原语一样,Color Picker 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- color-picker ``` ## 导入 ```rust use gpui_kit::base::{ColorPicker, ColorPickerEvent, ColorPickerState, ColorSwatch}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/color-picker.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/color-picker.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 ColorPickerState 保存颜色,ColorPickerEvent 报告用户变更。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use super::*; use gpui::{Focusable as _, Hsla, MouseButton}; impl BaseShowcase { pub(in super::super) fn color_picker( &self, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { // A builder-supplied default cannot reach the hex field and the sliders // without a window, so flush it on the first render. self.color_picker .update(cx, |state, cx| state.sync_pending_value(window, cx)); let picker = self.color_picker.read(cx); let open = picker.is_open(); let selected = picker.value(); let displayed = picker .displayed_color() .unwrap_or(super::example_rgb(0x171717).into()); let hex = picker.hex_input().read(cx).value(); let focus_handle = picker.focus_handle(cx); let hex_input = picker.hex_input().clone(); let state = self.color_picker.clone(); let trigger_state = state.clone(); let trigger = div() .id("color-trigger") .w_full() .h_7() .px_2() .flex() .items_center() .gap_2() .border_1() .border_color(super::example_rgb(0x171717)) .bg(super::example_rgb(0xffffff)) .on_click(move |_, _, cx| { trigger_state.update(cx, |state, cx| state.toggle_open(cx)); }) .child( div() .size(px(14.)) .bg(displayed) .border_1() .border_color(super::example_rgb(0x171717)), ) .child(hex) .child(div().flex_1()) .child(super::chevron(open)); let swatches = div().flex().gap_1().children( [0xdc2626u32, 0xd97706, 0x16a34a, 0x2563eb, 0x7c3aed] .into_iter() .enumerate() .map(|(index, value)| { let color: Hsla = super::example_rgb(value).into(); let hover_state = state.clone(); let click_state = state.clone(); ColorSwatch::new(("swatch", index), color) .selected(selected == Some(color)) .size(px(24.)) .bg(color) .border_1() .border_color(if selected == Some(color) { super::example_rgb(0x171717) } else { super::example_rgb(0xffffff) }) // Hovering previews without committing; leaving restores // the committed color. .on_hover(move |color, entered, window, cx| { hover_state.update(cx, |state, cx| { if entered { state.preview_color(color, window, cx); } else { state.clear_preview(window, cx); } }); }) .on_click(move |color, _, window, cx| { click_state .update(cx, |state, cx| state.select_color(color, window, cx)); }) }), ); let content = div() .w(px(220.)) .mt_1() .p_2() .flex() .flex_col() .gap_2() .border_1() .border_color(super::example_rgb(0x171717)) .bg(super::example_rgb(0xffffff)) .child(swatches) .child( InputBase::new("color-hex-input") .w_full() .h_7() .px_2() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .styles(|styles| { styles.focused(|style| style.border_color(super::example_rgb(0x171717))) }) .on_mouse_down(MouseButton::Left, move |_, window, cx| { hex_input.update(cx, |input, cx| input.focus(window, cx)); }) .child(picker.hex_input().clone()), ); let open_state = state.clone(); let root = ColorPicker::new("example-color-picker") .open(open) .track_focus(&focus_handle) .accessibility_label("Brand color") .on_open_change(move |open, _, cx| { open_state.update(cx, |state, cx| state.set_open(open, cx)); }) .w(px(220.)) .text_xs() .child(trigger); Popup::new("example-color-picker-popup", root).when(open, |this| this.content(content)) } } ``` ## 可访问性 为色样提供文本名称或数值,并确保键盘用户可以完成选择。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Textarea Source: /versions/v0.7.1/zh-CN/base/primitives/textarea 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/textarea 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `Textarea` 用于普通多行文本,接口聚焦于行数、换行、自动增高、值更新、插入、替换和光标位置。代码编辑器概念由 [`Editor`](/versions/v0.7.1/zh-CN/base/primitives/editor) 提供。 ## 导入 ```rust use gpui_kit::base::input::{InputEvent, Textarea, TextareaState}; ``` ## 固定行数 ```rust let notes = cx.new(|cx| { TextareaState::new(window, cx) .rows(5) .placeholder("Notes") .default_value("First line\nSecond line") }); Textarea::new(¬es) ``` ## 自动增高 文本框会在指定的最小与最大行数之间增长;达到最大值后内容改为滚动。 ```rust let message = cx.new(|cx| { TextareaState::new(window, cx) .auto_grow(2, 8) .placeholder("Write a message") }); Textarea::new(&message) ``` ## 编辑值 ```rust notes.update(cx, |state, cx| state.insert("Appended text", window, cx)); let cursor = notes.read(cx).cursor_position(cx); let value = notes.read(cx).value(); ``` 不希望视觉换行时使用 `soft_wrap(false)`。只有 Enter 应提交而非换行时才设置 `submit_on_enter(true)`。`TextareaState` 发出与 `InputState` 相同的 `InputEvent`。 ## 表现 该控件没有产品样式;边框、高度、颜色、内边距和 `InputEditorStyle` 由设计系统提供。现成样式控件参见 [`gpui-component` Textarea](/versions/v0.7.1/zh-CN/component/textarea)。 ## 可运行示例 ```bash cargo run -p gpui-base-examples -- textarea ``` ## 原子行内 token 需要让用户整块选中、删除人员提及或资源引用时,可以使用 token。通过已有的 TextareaState 插入: ```rust use gpui_kit::base::input::InlineToken; notes.update(cx, |state, cx| { state.replace_with_token( InlineToken::new("person-1", "@alice").with_label("Alice"), window, cx, ).expect("有效的引用"); }); ``` token 默认显示为无装饰标签。通过 `token` 槽位提供自己的单行元素,通过 `on_token_click` 打开引用。通过 `on_token_hover` 上报指针进入与离开(`InlineTokenHoverEvent::is_hovered`),不选中、不编辑,可用于提示或预览;禁用 token 从不上报悬停进入,但禁用正在悬停的 token 会发送其退出事件。复制和 `value()` 返回 `@alice` 这样的真实文本。用 `content()` 保存草稿、`set_value(content)` 恢复草稿,可以保留其中的引用。 自定义展示、草稿恢复和范围单位见 [Input 的 token 示例](/versions/v0.7.1/zh-CN/component/input#原子行内-token)。数据类型从 `gpui_kit::base::input` 导入。JavaScript 使用 `gpui-base` 中的 `TextareaState.new()`,它提供相同的 token 方法。 --- # Switch Source: /versions/v0.7.1/zh-CN/base/primitives/switch 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/switch 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 轨道和滑块可分别设置样式的受控开关。 和所有 GPUI Base 原语一样,Switch 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- switch ``` ## 导入 ```rust use gpui_kit::base::{Switch, SwitchThumb, SwitchTrack}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/switch.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/switch.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 父级保存开关值,激活后切换并重新渲染。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use super::*; impl BaseShowcase { pub(in super::super) fn switch(&self, cx: &mut Context) -> impl IntoElement { let checked = self.switch_checked; let entity = cx.entity().downgrade(); div() .w_64() .text_xs() .flex() .items_center() .justify_between() .child( div().child("Automatic updates").child( div() .mt_1() .text_xs() .text_color(super::example_rgb(0x737373)) .child("Install stable releases automatically."), ), ) .child( Switch::new("example-switch") .checked(checked) .on_change(move |next, _, _, cx| { _ = entity.update(cx, |this, cx| { this.switch_checked = next; cx.notify(); }); }) .child( SwitchTrack::new("example-switch-track") .checked(checked) .w(px(36.)) .h(px(20.)) .p(px(2.)) .bg(if checked { super::example_rgb(0x171717) } else { super::example_rgb(0xd4d4d4) }) .child( SwitchThumb::new(checked) .size_4() .bg(super::example_rgb(0xffffff)) .ml(if checked { px(16.) } else { px(0.) }), ), ), ) } } ``` ## 可访问性 提供标签,使用开关语义并暴露当前开启和禁用状态。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Table Source: /versions/v0.7.1/zh-CN/base/primitives/table 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/table 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 用于组合表头、表体、行和单元格的语义表格原语。 和所有 GPUI Base 原语一样,Table 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- table ``` ## 导入 ```rust use gpui_kit::base::{Table, TableBody, TableCell, TableHead, TableHeader, TableRow}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/table.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/table.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 排序、选择和数据状态由应用持有,表格原语只表达结构。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use super::*; impl BaseShowcase { pub(in super::super) fn table(&self) -> impl IntoElement { Table::new("example-table") .w_72() .text_xs() .border_1() .border_color(super::example_rgb(0xe5e7eb)) .overflow_hidden() .child( TableHeader::new("header").child( TableRow::new("header-row", 1) .flex() .bg(super::example_rgb(0xf5f5f5)) .child( TableHead::new("name-head", 1) .w(px(124.)) .px_2() .py_1() .child("Component"), ) .child( TableHead::new("status-head", 2) .w(px(84.)) .px_2() .py_1() .child("Status"), ) .child( TableHead::new("version-head", 3) .w(px(92.)) .px_2() .py_1() .child("Version"), ), ), ) .child( TableBody::new("body").children( [ ("gpui-base", "Stable", "0.4.1"), ("gpui-component", "Active", "0.4.1"), ("story-web", "Preview", "0.2.8"), ("gpui-web", "Beta", "0.1.0"), ] .into_iter() .enumerate() .map(|(ix, (name, status, version))| { TableRow::new(("body-row", ix), ix) .flex() .border_t_1() .border_color(super::example_rgb(0xe5e7eb)) .child( TableCell::new("name", 1) .w(px(124.)) .px_2() .py_1() .child(name), ) .child( TableCell::new(("status", ix), 2) .w(px(84.)) .px_2() .py_1() .child( div() .px_1() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .child(status), ), ) .child( TableCell::new(("version", ix), 3) .w(px(92.)) .px_2() .py_1() .text_color(super::example_rgb(0x737373)) .child(version), ) }), ), ) } } ``` ## 可访问性 保留表头与单元格关系;交互式行或单元格必须有清晰焦点和名称。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Checkbox Source: /versions/v0.7.1/zh-CN/base/primitives/checkbox 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/checkbox 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 指示器可独立设置样式的受控三态复选框。 和所有 GPUI Base 原语一样,Checkbox 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- checkbox ``` ## 导入 ```rust use gpui_kit::base::{Checkbox, CheckboxIndicator}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/checkbox.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/checkbox.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 父级持有选中、未选中或不确定状态,并在激活时更新。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use super::*; use gpui::{Image, ImageFormat, img}; use std::sync::Arc; const CHECK_SVG: &[u8] = br#""#; impl BaseShowcase { pub(in super::super) fn checkbox(&self, cx: &mut Context) -> impl IntoElement { let checked = self.checkbox_checked; let entity = cx.entity().downgrade(); Checkbox::new("example-checkbox") .checked(checked) .flex() .items_center() .gap_2() .on_change(move |state, _, _, cx| { _ = entity.update(cx, |this, cx| { this.checkbox_checked = state == CheckboxState::Checked; cx.notify(); }); }) .child( CheckboxIndicator::new() .checked(checked) .flex() .items_center() .justify_center() .size_4() .border_1() .border_color(super::example_rgb(0x171717)) .when(checked, |this| { this.bg(super::example_rgb(0x171717)).child( img(Arc::new(Image::from_bytes( ImageFormat::Svg, CHECK_SVG.to_vec(), ))) .size(px(12.)), ) }), ) .child(div().text_xs().child("Enable product updates")) } } ``` ## 可访问性 暴露复选框角色、当前状态、标签和禁用状态。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Toggle Source: /versions/v0.7.1/zh-CN/base/primitives/toggle 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/toggle 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 用于格式等持久选择的受控双态按钮。 和所有 GPUI Base 原语一样,Toggle 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- toggle ``` ## 导入 ```rust use gpui_kit::base::{Toggle}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/toggle.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/toggle.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 父级保存按下状态,激活时切换。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use super::*; impl BaseShowcase { pub(in super::super) fn toggle(&self, cx: &mut Context) -> impl IntoElement { let pressed = self.toggle_pressed; let entity = cx.entity().downgrade(); Toggle::new("example-toggle") .pressed(pressed) .on_change(move |next, _, _, cx| { _ = entity.update(cx, |this, cx| { this.toggle_pressed = next; cx.notify(); }); }) .size_7() .text_xs() .flex() .items_center() .justify_center() .border_1() .border_color(super::example_rgb(0x171717)) .when(pressed, |this| { this.bg(super::example_rgb(0x171717)) .text_color(super::example_rgb(0xffffff)) }) .font_weight(gpui::FontWeight::BOLD) .accessibility_label("Bold") .child("B") } } ``` ## 可访问性 暴露按钮名称、按下和禁用状态,并保留键盘激活。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Scrollbar Source: /versions/v0.7.1/zh-CN/base/primitives/scrollbar 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/scrollbar 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `Scrollbar` 是连接 GPUI 滚动句柄的自绘滚动条,支持纵向、横向和双轴视口、轨道点击、滑块拖动、可配置可见模式、类型化绘制样式、减少动态效果,以及可反向的可见性和宽度过渡。`gpui-base` 负责交互与过渡生命周期,应用或设计系统负责颜色、几何、时序和进入编排。 ## 运行示例 ```bash cargo run -p gpui-base-examples -- scrollbar ``` 原生与 WASM 共用 [`scrollbar.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/scrollbar.rs)。 ## 基本用法 把 `ScrollHandle` 保存在持久视图状态中,通过 `track_scroll` 连接可滚动内容,并在同一个 `relative()` 容器中叠加 `Scrollbar`。`Scrollbar::new` 启用双轴;单轴使用 `vertical`、`horizontal` 或 `.axis(...)`。滚动条是绝对定位覆盖层,进入动画不会移动内容或命中区域。 ## 可见模式 - `Scrolling`:滚动或拖动后显示,离开悬停区域后重新计算空闲等待。 - `Hover`:指针进入轨道后显示。 - `Always`:始终可见并跳过可见性过渡。 默认静止滑块宽 6 px,滑块悬停或拖动目标宽度为 8 px。隐藏的轨道与滑块不响应点击。 ## 全局主题与实例覆盖 使用 `ScrollbarStyles` 的 `track`、`track_hover`、`track_active`、`thumb`、`thumb_hover` 和 `thumb_active` builder 设置外观;使用 `ScrollbarMotion` 配置 `idle`、`enter`、`exit`、`expand` 以及 `ScrollbarEntrance`。再把它们装入 `Theme::global_mut(cx).scrollbar = ScrollbarTheme::new().with_mode(...).with_motion(...).with_styles(...)`。字段保持私有,可通过 reader 查询。单个实例的 `.styles(...)` 优先于全局主题。 ## 动画行为 Base 不附带产品动画。默认仅有 2 秒行为性空闲等待,进入、退出和展开时长均为零。`Fade` 原地淡入;`SlideAndFade` 让纵向滚动条从右侧、横向滚动条从底部进入。被中断的过渡从当前视觉值反向;零时长立即采用目标值。GPUI 的减少动态效果偏好也会把可见性和宽度时长降为零。 ## 自定义视口与句柄 默认视口来自 `ScrollbarHandle::viewport_bounds`。组合控件可用 `.viewport_bounds(bounds)` 指定绘制视口,或 `.viewport_from_layout()` 使用覆盖层布局;只有句柄无法报告完整范围时才用 `.scroll_size(...)` 覆盖内容尺寸。`ScrollHandle`、`UniformListScrollHandle` 和 `ListState` 已实现 `ScrollbarHandle`;自定义容器需实现视口、偏移、设置偏移和内容尺寸,必要时实现 `start_drag` / `end_drag`。 ## 稳定身份 构造器默认从调用位置派生 ID。同一调用位置生成多个独立滚动条时,应使用 `.id(("activity-list", panel_id))` 提供稳定 ID,以跨渲染保留可见性和宽度动画状态。 ## 完整示例 ```rust use super::*; impl BaseShowcase { pub(in super::super) fn scrollbar(&self) -> impl IntoElement { div() .id("example-scroll-region") .relative() .w_72() .h_48() .text_xs() .border_1() .border_color(super::example_rgb(0x171717)) .overflow_scroll() .track_scroll(&self.example_scroll) .child(div().children((1..=20).map(|row| { div() .h_7() .px_2() .flex() .items_center() .border_b_1() .border_color(super::example_rgb(0xe5e7eb)) .justify_between() .child(format!("Activity {row}")) .child(if row % 3 == 0 { "Completed" } else { "Pending" }) }))) .child(Scrollbar::new(&self.example_scroll).mode(ScrollbarMode::Always)) } } ``` ## 可访问性与交互检查 - 保留底层视口的滚轮、触控板和键盘滚动。 - 即使绘制的滑块很窄,也保留完整轨道命中区域。 - 验证普通、悬停和活动状态对比度。 - 动画不要移动布局或命中区域。 - 在减少动态效果下分别测试三种模式以及纵向、横向和双轴溢出。 --- # Toast Source: /versions/v0.7.1/zh-CN/base/primitives/toast 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/toast 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 受管理、带动画的临时状态消息栈。 和所有 GPUI Base 原语一样,Toast 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- toast ``` ## 导入 ```rust use gpui_kit::base::{Toast, ToastManager, ToastOptions, ToastStack}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/toast.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/toast.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 ToastManager 管理消息的加入、超时和移除;应用选择持续时间与操作。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use super::*; impl BaseShowcase { pub(in super::super) fn toast(&self, cx: &mut Context) -> impl IntoElement { let visible = self.toast_visible; let entity = cx.entity().downgrade(); div() .w_72() .h(px(158.)) .text_xs() .relative() .flex() .items_center() .justify_center() .child( Button::new("show-toast") .h_7() .px_2() .flex() .items_center() .justify_center() .border_1() .border_color(super::example_rgb(0x171717)) .bg(super::example_rgb(0xffffff)) .child("Save changes") .on_click({ let show_entity = entity.clone(); move |_, _, cx| { _ = show_entity.update(cx, |this, cx| { this.toast_visible = true; cx.notify(); }); } }), ) .when(visible, |this| { this.child( Toast::new("example-toast") .transition_status(ToastTransitionStatus::Present) .absolute() .right_0() .bottom_0() .w_64() .p_2() .border_1() .border_color(super::example_rgb(0x171717)) .bg(super::example_rgb(0xffffff)) .child( div() .flex() .justify_between() .child( div() .font_weight(gpui::FontWeight::SEMIBOLD) .child("Changes saved"), ) .child( Button::new("dismiss-toast") .size_6() .flex() .items_center() .justify_center() .child("×") .on_click({ let entity = entity.clone(); move |_, _, cx| { _ = entity.update(cx, |this, cx| { this.toast_visible = false; cx.notify(); }); } }), ), ) .child( div() .mt_1() .text_color(super::example_rgb(0x737373)) .child("Your preferences are now up to date."), ), ) }) } } ``` ## 可访问性 根据紧急程度使用合适的实时区域;重要内容不能只依赖自动消失的消息。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Link Source: /versions/v0.7.1/zh-CN/base/primitives/link 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/link 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 样式由应用定义的可访问链接控件。 和所有 GPUI Base 原语一样,Link 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- link ``` ## 导入 ```rust use gpui_kit::base::{Link}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/link.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/link.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 激活交由应用的导航或打开 URL 行为处理。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use gpui::{IntoElement, ParentElement as _, Styled as _, div}; use gpui_base::Link; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn link(&self) -> impl IntoElement { div() .w_56() .flex() .flex_col() .gap_2() .text_xs() .child("Navigation is application-owned") .child( Link::new("example-link") .href("/base/primitives/link") .open_with(|href, _, _, cx| cx.open_url(href)) .h_7() .px_3() .py_0() .flex() .items_center() .border_1() .border_color(super::example_rgb(0x171717)) .child("Open Link documentation →"), ) .child( Link::new("disabled-link") .href("/disabled") .disabled(true) .h_7() .px_3() .py_0() .flex() .items_center() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .text_color(super::example_rgb(0x737373)) .child("Disabled destination"), ) } } ``` ## 可访问性 使用清晰链接文本,保持键盘焦点,并正确表达目标与禁用状态。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Tree Source: /versions/v0.7.1/zh-CN/base/primitives/tree 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/tree 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 显式管理展开与选择状态的虚拟化层级列表。 和所有 GPUI Base 原语一样,Tree 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- tree ``` ## 导入 ```rust use gpui_kit::base::{Tree, TreeItem, TreeState}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/tree.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/tree.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 TreeState 保存展开、选择和虚拟滚动状态;稳定节点 ID 用于跨渲染保留身份。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use super::*; use gpui::{Image, ImageFormat, StyleRefinement, img}; use std::sync::Arc; const CHEVRON_RIGHT_SVG: &[u8] = br##""##; const CHEVRON_DOWN_SVG: &[u8] = br##""##; impl BaseShowcase { pub(in super::super) fn tree(&self) -> impl IntoElement { Tree::new(&self.tree) .w_64() .h_48() .list_style(StyleRefinement::default().flex_grow_1().size_full()) .relative() .text_sm() .border_1() .border_color(super::example_rgb(0xd4d4d4)) .py_1() .item(|_, entry, state, _, _| { let depth = entry.depth(); let icon = entry.is_folder().then(|| { let bytes = if entry.is_expanded() { CHEVRON_DOWN_SVG } else { CHEVRON_RIGHT_SVG }; img(Arc::new(Image::from_bytes( ImageFormat::Svg, bytes.to_vec(), ))) .size_3() .flex_none() }); div() .h_8() .mx_1() .px_2() .flex() .items_center() .gap_1() .when(state.is_selected(), |this| { this.bg(super::example_rgb(0xf0f0f0)) }) .when(depth > 0, |this| { this.child(div().flex_none().w(px(depth as f32 * 12.))) }) .child( div() .size_3() .flex_none() .flex() .items_center() .justify_center() .children(icon), ) .child(entry.item().label.clone()) .into_any_element() }) } } ``` ## 可访问性 暴露层级、展开与选中状态,并保留方向键、Home/End 和类型导航。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Popover Source: /versions/v0.7.1/zh-CN/base/primitives/popover 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/popover 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 支持受控或内部开关状态的锚定浮层。 和所有 GPUI Base 原语一样,Popover 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- popover ``` ## 导入 ```rust use gpui_kit::base::{Popover}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/popover.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/popover.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 使用 `.anchor(Anchor::TopCenter).offset(px(8.))` 可让弹层在触发器下方居中,并留出八像素间距。 Base 的 offset 默认为零。`Top*` 在下方,`Bottom*` 在上方, `LeftCenter` 在右侧,`RightCenter` 在左侧。anchor 描述弹层自身的锚点。 窗口边界限制不会翻转弹层或更改 anchor。 `on_position` 在内容 prepaint 前提供最终弹层和触发器边界,供自定义绘制使用。 Base 不绘制箭头;带样式的 Component Popover 提供 `.arrow(true)`(默认 `false`),箭头跟随 anchor 对齐。 触发器切换打开状态;点击外部或 Escape 可按配置关闭。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use gpui::{InteractiveElement as _, IntoElement, ParentElement as _, Styled as _, div, relative}; use gpui_base::{Button, Popover}; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn popover(&self) -> impl IntoElement { Popover::new("example-popover") .trigger( Button::new("popover-trigger") .h_7() .line_height(relative(1.)) .px_3() .flex() .items_center() .justify_center() .bg(gpui::black()) .text_color(gpui::white()) .child("Open Popover"), ) .content(|_, _, cx| { let state = cx.entity().downgrade(); div() .id("popover-content") .w_64() .p_2() .flex() .flex_col() .gap_2() .text_xs() .bg(super::example_rgb(0xffffff)) .border_1() .border_color(super::example_rgb(0xd4d4d4)) .child("Workspace access") .child( div() .text_xs() .text_color(super::example_rgb(0x737373)) .child("Anyone with the link can view."), ) .child( div().mt_1().flex().justify_end().child( Button::new("popover-done") .h_7() .line_height(relative(1.)) .px_3() .flex() .items_center() .justify_center() .bg(gpui::black()) .text_color(gpui::white()) .on_click(move |_, window, cx| { _ = state.update(cx, |state, cx| state.dismiss(window, cx)); }) .child("Done"), ), ) }) } } ``` ## 可访问性 管理触发器与内容的关系和焦点,不要让关闭后焦点丢失。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Radio Source: /versions/v0.7.1/zh-CN/base/primitives/radio 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/primitives/radio 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 具有选中和禁用语义的受控单选项。 和所有 GPUI Base 原语一样,Radio 只提供行为和语义结构,不规定产品视觉语言。请使用 GPUI 样式并组合导出的部件,使其符合你的设计系统。 ## 示例 原生示例和页面上方的 WASM 预览共用同一份实现: ```bash cargo run -p gpui-base-examples -- radio ``` ## 导入 ```rust use gpui_kit::base::{Radio}; ``` ## 结构与 API 示例组合上述公开类型。GPUI 的标准样式和事件 trait 负责表现,Base 类型负责交互结构。权威实现位于 [`components/radio.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/radio.rs),原生与浏览器预览编译的是同一文件。 ## 状态与事件 父级保存选中值,激活某一项时替换当前选择。 受控状态应保存在父渲染类型或 GPUI entity 中;在回调中更新并调用 `cx.notify()`,不要在每次渲染时重建持久 entity。 ## 完整 Rust 示例 ```rust use gpui::{ Context, IntoElement, ParentElement as _, Styled as _, div, prelude::FluentBuilder as _, px, }; use gpui_base::Radio; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn radio(&self, cx: &mut Context) -> impl IntoElement { let checked = self.radio_selected == 0; let entity = cx.entity().downgrade(); Radio::new("example-radio") .text_xs() .checked(checked) .on_change(move |next, _, _, cx| { _ = entity.update(cx, |this, cx| { if next { this.radio_selected = 0; } cx.notify(); }); }) .flex() .items_start() .gap_2() .child( div() .mt(px(2.)) .flex() .items_center() .justify_center() .size(px(14.)) .border_1() .border_color(super::example_rgb(0x171717)) .when(checked, |this| { this.child(div().size(px(6.)).bg(super::example_rgb(0x171717))) }), ) .child( div().child("Standard").child( div() .text_xs() .text_color(super::example_rgb(0x737373)) .child("3–5 business days"), ), ) } } ``` ## 可访问性 提供标签,并暴露单选角色、选中和禁用状态。 ## 注意事项 在支持的位置使用稳定元素 ID,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Text Selection Source: /versions/v0.7.1/zh-CN/base/text-selection 许可:GPUI Kit 有权授权的正文与原创图示另适用 CC BY 4.0;引用请署名 GPUI Kit、链接 https://gpui-kit.com/versions/v0.7.1/zh-CN/base/text-selection 与 https://creativecommons.org/licenses/by/4.0/ 并注明修改。代码示例适用 Apache-2.0。 `gpui-base` 的文本选择基础设施允许一次拖拽跨越多个独立绘制的文本块,同时保留每个参与者自己的布局与绘制逻辑。它适合文档、消息流和其他由多个自定义元素组成、但用户期望像连续文本一样选择的界面。 ## 开始使用 核心由窗口级 `TextSelectionState`、稳定的 `SelectableTextHandle`、prepaint 阶段注册的几何信息,以及把全局选择投影到各文本 run 的辅助 API 组成。 ## 工作方式 每个参与者在 prepaint 时报告文本、屏幕几何和逻辑顺序。窗口状态根据指针锚点与当前点计算跨参与者的选择区间;绘制时,各参与者只查询落在自身范围内的切片并绘制选中背景。状态更新发生在事件回调中,不在 `render` 中写入。 ## 安装窗口元素 在窗口内容的稳定外层安装选择宿主,使它能够接收拖拽、释放和复制操作。宿主应覆盖所有需要共同选择的参与者,但不应改变它们的布局或样式。 ## 创建稳定句柄 每个参与者的 `SelectableTextHandle` 应保存在 entity 中或由稳定数据键派生。不要在每次渲染时创建新身份,否则拖拽途中重绘会丢失锚点、逻辑顺序或选择投影。 ## 在 prepaint 注册几何 文本最终布局完成后注册边界、行与字符位置。注册顺序必须与用户看到和辅助技术读取的逻辑顺序一致。动态插入、删除或移动参与者时,用稳定身份更新对应记录。 ## 把选择投影到文本 run 绘制各 run 前查询当前窗口选择,取得与本地文本相交的字节或字符范围,再把范围转换为文本系统需要的高亮几何。注意 UTF-8 边界,不要把字节偏移当作字符索引。 ## 完整 Rust 示例 ```bash cargo run -p gpui-base-examples -- text-selection ``` ```rust use gpui::{Context, IntoElement, ParentElement as _, Styled as _, Window}; #[cfg(test)] use gpui_base::ElementExt as _; use gpui_base::{SelectableText, TextSelection}; use super::*; const PRODUCT_PARAGRAPH: &str = "Selection should feel like a natural part of reading a product brief. Start in this paragraph, continue into the next renderer, and GPUI preserves the document order while every frame supplies fresh geometry for the same stable selection handle."; const IMPLEMENTATION_PARAGRAPH: &str = "This second paragraph is deliberately long enough to wrap in the showcase. Drag across the boundary to see one continuous highlight, then use the platform copy shortcut to confirm that the copied result follows the visible reading order rather than renderer ownership."; const INTERNATIONAL_PARAGRAPH: &str = "International text should remain predictable when a line mixes café, déjà vu, Kraków, naïve, and résumé. Resize the window or drag across several wrapped lines; UTF-8 byte ranges still map back to the correct glyphs without splitting a character."; impl BaseShowcase { pub(in super::super) fn text_selection( &mut self, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { self.text_selection_active = TextSelection::has_selection(window, cx); self.text_selection_text = TextSelection::selected_text(window, cx); let active = self.text_selection_active; let selected_text = if active { self.text_selection_text.clone() } else { "Drag across any paragraphs to select text.".to_owned() }; let entity = cx.entity().downgrade(); let footer = div() .id("text-selection-footer") .h(px(150.)) .flex_none() .flex() .flex_col() .gap_2() .p_3() .bg(super::example_rgb(0xf5f5f5)) .border_1() .border_color(super::example_rgb(0xe5e5e5)) .child( div() .font_weight(gpui::FontWeight::SEMIBOLD) .child(if active { "Selection active" } else { "No selection" }), ) .child( div() .id("text-selection-preview") .flex_1() .min_h_0() .overflow_y_scroll() .text_color(super::example_rgb(0x525252)) .child(selected_text), ) .child( Button::new("clear-text-selection") .h_7() .px_2() .flex() .items_center() .justify_center() .self_start() .border_1() .border_color(super::example_rgb(0x171717)) .child("Clear selection") .on_click(move |_, window, cx| { TextSelection::clear(window, cx); _ = entity.update(cx, |this, cx| { this.text_selection_active = false; this.text_selection_text.clear(); cx.notify(); }); }), ); #[cfg(test)] let footer = { let bounds = self.text_selection_footer_bounds.clone(); footer.on_prepaint(move |value, _, _| *bounds.borrow_mut() = Some(value)) }; div() .id("text-selection-example") .w(px(620.)) .max_w_full() .h(px(520.)) .max_h_full() .flex() .flex_col() .gap_3() .child( div() .id("text-selection-scroll") .flex_1() .min_h_0() .overflow_y_scroll() .track_scroll(&self.text_selection_scroll) .flex() .flex_col() .gap_3() .p_4() .child( div() .text_lg() .font_weight(gpui::FontWeight::SEMIBOLD) .child( SelectableText::with_handle( "selection-heading", self.text_selection_handles[0].clone(), "Text selection across renderers", ) .document_order(0), ), ) .child( div() .text_color(super::example_rgb(0x525252)) .line_height(px(22.)) .child( SelectableText::with_handle( "selection-product", self.text_selection_handles[1].clone(), PRODUCT_PARAGRAPH, ) .document_order(1), ), ) .child( div() .text_color(super::example_rgb(0x525252)) .line_height(px(22.)) .child( SelectableText::with_handle( "selection-implementation", self.text_selection_handles[2].clone(), IMPLEMENTATION_PARAGRAPH, ) .document_order(2), ), ) .child( div() .text_color(super::example_rgb(0x525252)) .line_height(px(22.)) .child( SelectableText::with_handle( "selection-international", self.text_selection_handles[3].clone(), INTERNATIONAL_PARAGRAPH, ) .document_order(3), ), ), ) .child(footer) } } #[cfg(test)] mod tests { use gpui::{TestAppContext, point, px}; use crate::showcase::BaseShowcase; #[gpui::test] fn text_selection_footer_stays_fixed_when_document_scrolls(cx: &mut TestAppContext) { let (view, window) = cx.add_window_view(|window, cx| BaseShowcase::new("text-selection", window, cx)); window.update(|window, cx| window.draw(cx).clear(cx)); let (footer_bounds, scroll) = view.read_with(window, |view, _| { ( view.text_selection_footer_bounds .borrow() .expect("footer should be painted"), view.text_selection_scroll.clone(), ) }); scroll.set_offset(point(px(0.), px(-80.))); view.update(window, |_, cx| cx.notify()); window.update(|window, cx| window.draw(cx).clear(cx)); let scrolled_footer_bounds = view.read_with(window, |view, _| { view.text_selection_footer_bounds .borrow() .expect("footer should be painted after scrolling") }); assert_eq!(scrolled_footer_bounds, footer_bounds); } } ``` ## 查询与控制窗口选择 应用可以读取当前选中文本、主动清除选择,并把复制命令连接到窗口状态。程序化更新后调用 `cx.notify()`,让所有受影响参与者重绘。 ### 触摸选择 在参与者上长按会选中手指下的单词;抬起手指后,这个选区成为*触摸选区*:两端各带一个拖动 handle,并附带编辑菜单。手势和拖动由 Base 负责;展示层根据 `TextSelectionSnapshot` 之外的 `TouchSelectionSnapshot` 绘制 handle 和菜单,它以窗口坐标提供选区两端的光标行框。 通过 `TextSelection::touch_selection` 读取快照,用 `TextSelection::observe_touch_selection` 在快照变化时重绘;`begin_edge_drag`、`update_edge_drag`、`end_edge_drag` 拖动其中一端,`select_all` 全选被按下的参与者,`close_edit_menu` 在菜单自身的命令执行后关闭菜单。handle 由参与者自己绘制,位于它在绘制顺序中的位置,因此盖住文字的东西也会盖住 handle:在 prepaint 调用 `TextSelectionHandle::prepaint_touch_handles`(插入手指可按的 hitbox),在 paint 末尾、`register` 之后调用 `TextSelectionHandle::paint_touch_handles` 并传入选区颜色;`TextView` 已完成这两步。参与者通过 `TextSelectionRegistration::with_selection_edges` 上报其选区两端的绘制位置。绘制菜单的一方需要在每帧 paint 时调用 `TextSelection::register_touch_ui` 登记菜单 bounds,这样落在菜单上的按压不会清除它所属的选区;GPUI Component 的 `Root` 负责绘制菜单。 ## 高级参与者适配器 自定义文本布局可以实现参与者接口,提供命中测试、范围投影和文本提取。适配器应只桥接已有布局数据,避免在指针移动热路径中重新排版或分配大型缓冲区。 ## 用 scope 隔离模态内容 Dialog、Sheet 等模态内容应使用独立 scope,避免一次选择跨越背景与前景。模态关闭后恢复原 scope,不要让已卸载参与者留在窗口注册表中。 ## 集成检查清单 - 窗口只安装一个相应 scope 的选择宿主。 - 参与者身份和逻辑顺序跨渲染稳定。 - 只在 prepaint 注册最终几何,并正确处理 UTF-8。 - 拖拽更新有界,不在 render 中修改状态。 - 验证跨块拖拽、反向选择、复制、动态内容、滚动和模态隔离。