# GPUI Kit > A comprehensive Rust framework for building fantastic, high-performance desktop apps with GPUI. --- # Entity Source: /versions/v0.6.4/docs/entity When several Views, handlers, or async tasks need the same state, put that state in GPUI's `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. Create the Entity with `cx.new`, read it with `read`, and change it with `update`. If `Chat` implements `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. Entity access always goes through a GPUI context, allowing GPUI to coordinate updates, rendering, subscriptions, and the Entity lifecycle. ## Create an Entity Use `cx.new` in any GPUI context: ```rs struct Chat { messages: Vec, } let chat: Entity = cx.new(|_cx| Chat { messages: Vec::new(), }); ``` The closure receives `Context`, so initialization can also create child entities or register subscriptions. 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 and Zed: a parent View owns the child Views or models it renders and coordinates. ## 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() }); ``` ## 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. 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. Use the `&mut T` already provided by the current callback, or finish the current update before starting another one. ## Use a WeakEntity for back references and callbacks Cloning `Entity` creates another strong handle and keeps the Entity alive. Use `WeakEntity` when a relationship should not own its target, such as a child pointing back to its parent or a long-running callback referring to a View. ```rs struct ChatSidebar { workspace: WeakEntity, } let workspace = cx.weak_entity(); let sidebar = cx.new(|_cx| ChatSidebar { workspace }); ``` 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. Zed and gpui-kit use this pattern for async tasks, callbacks, delegates, and parent references so those relationships do not accidentally keep a View alive. ## 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. Store the returned `Subscription` on the subscribing Entity: ```rs enum ChatEvent { MessageSent, } impl EventEmitter for Chat {} struct Workspace { chat: Entity, _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, _subscriptions, } } fn on_chat_event( &mut self, _chat: Entity, _event: &ChatEvent, cx: &mut Context, ) { // Handle the typed Event. cx.notify(); } } ``` This is the pattern used by gpui-kit Views and by larger Zed and Longbridge Pro Views. Dropping `Workspace` also drops `_subscriptions`, disconnecting its callbacks. A local `let subscription = ...` is usually wrong because it is dropped at the end of the function. Detaching or storing a View subscription in a longer-lived global owner can keep callbacks and captured resources alive after the View disappears, causing a memory leak. See [Event] for `EventEmitter`, `emit`, and typed subscription design. ## Lifecycle An Entity remains alive while at least one strong `Entity` handle exists. When the final strong handle is dropped, GPUI releases its state; `WeakEntity` handles no longer upgrade successfully. 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 `_subscriptions` field; - let dropping the View release its subscriptions and captured resources. For integration code that must react immediately before state is dropped, GPUI also provides `cx.on_release(...)` for the current Entity and `cx.observe_release(...)` for another Entity. Store those returned subscriptions for exactly as long as the release callback is needed. [Entity]: https://docs.rs/gpui/latest/gpui/struct.Entity.html [WeakEntity]: https://docs.rs/gpui/latest/gpui/struct.WeakEntity.html [Event]: /docs/event --- # Installation Source: /versions/v0.6.4/docs/installation Before you start to build your application with `gpui-component`, you need to install the library. ## System Requirements We can development application on macOS, Windows or Linux. ### macOS - macOS 15 or later - Xcode command line tools ## Windows - Windows 10 or later There have a bootstrap script to help install the required toolchain and dependencies. You can run the script in PowerShell: ```ps .\script\install-window.ps1 ``` ## Linux Run `./script/bootstrap` to install system dependencies. ## Rust and Cargo We use Rust programming language to build the `gpui-component` library. Make sure you have Rust and Cargo installed on your system. - Rust 1.90 or later - Cargo (comes with Rust) To install the `gpui-component` library, you can use Cargo, the Rust package manager. Add the following line to your `Cargo.toml` file under the `[dependencies]` section: ```toml gpui-kit = "0.6" ``` `gpui-kit` depends on the matching GPUI crates for you, so your application never lists GPUI itself. `use gpui_kit::*;` is GPUI, and the layers are reachable by name: `gpui_kit::component` (the styled components), `gpui_kit::base`, `gpui_kit::assets` and `gpui_kit::platform`. For experimental iOS support and Swift UIView embedding, see [Mobile](/docs/mobile). Mobile uses `gpui-pre-mobile` and a different application bootstrap from the desktop setup above. ## Faster development builds Debug builds compile GPUI, the component library and the text stack without optimizations, which makes a `cargo run` build of your application render noticeably slower than a release build. Optimize just those crates while your own code stays a fast, debuggable debug build. Profiles 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 } ``` --- # I18n Source: /versions/v0.6.4/docs/i18n GPUI Component includes translations for its components, currently `en`, `zh-CN`, and `zh-HK`. Applications can add a language or override individual translations without copying the complete built-in locale files. This feature requires `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.6" 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 gpui_component: Calendar: week.0: fr: Di month.January: fr: Janvier DatePicker: placeholder: fr: Sélectionner une date ``` The namespace must be `gpui_component`, matching the Rust crate name `gpui-component` with hyphens converted to underscores. Translation keys can be found in [GPUI Component's built-in locale file](https://github.com/longbridge/gpui-kit/blob/main/crates/component/locales/ui.yml). ## 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| { 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. ## 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. ## 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 gpui_kit::component::set_locale("fr"); let current = gpui_kit::component::locale(); ``` Components then resolve their translated labels using the selected locale and the priority described above. The active locale is global state that GPUI does not track, so changing it does not schedule a repaint on its own. A view that displays translated text must notify itself: ```rust gpui_kit::component::set_locale("fr"); cx.notify(); ``` --- # Design Guides Source: /versions/v0.6.4/docs/design-guides 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 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. 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. ## 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 Derive corner radii from the active theme. This preserves a product's ability to become square or more rounded as one coherent system. Use `radius_full()` for circles and pills rather than a literal maximum radius. 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. 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 require a tooltip and an accessible name. 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. ### 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. ## 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. 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/key binding 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 tooltip. 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. 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. ## 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. ### 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 names, and icon-only controls have tooltips; - 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.6.4/docs/coding-guides) to translate these design decisions into GPUI architecture and code. --- # Icons & Assets Source: /versions/v0.6.4/docs/assets The [IconName] and [Icon] in GPUI Component provide a comprehensive set of icons and assets that can be easily integrated into your GPUI applications. But for minimal size applications, **we have not embedded any icon assets by default** in `gpui-component` crate. We split the icon assets into a separate crate [gpui-kit-assets] to allow developers to choose whether to include the icon assets in their applications or if you don't need the icons at all, you can build your own assets. **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. ## 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 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. ## Use default bundled assets The [gpui-kit-assets] crate provides a default bundled assets implementation that embeds the original 101 component icons listed in `crates/assets/default-icons.txt`. To use the default bundled assets, you need to add the `gpui-kit-assets` crate as a dependency in your `Cargo.toml`: ```toml [dependencies] gpui-component = { git = "https://github.com/longbridge/gpui-kit" } gpui-kit-assets = { git = "https://github.com/longbridge/gpui-kit" } ``` Then we need call the `with_assets` method when creating the GPUI application to register the asset source: ```rs use gpui_kit::*; use gpui_kit::assets::Assets; let app = gpui_kit::application().with_assets(Assets); ``` Now, we can use `IconName` and `Icon` in our application as usual, the original component icons are loaded from the default bundle. Continue [Use the icons](#use-the-icons) section to see how to use the icons in your application. ## Build you own assets You may have a specific set of icons that you want to use in your application, or you may want to reduce the size of your application binary by including only the icons you need. In this case, you can build your own assets by following these steps. The [assets](https://github.com/longbridge/gpui-kit/tree/main/crates/assets/assets/) folder in source code contains all the available icons in SVG format, every file is that GPUI Component support, it matched with the [IconName] enum. You can download the SVG files you need from the [assets] folder, or you can use your own SVG files by following the [IconName] naming convention. In GPUI application, we can use the [rust-embed] crate to embed the SVG files into the application binary. And GPUI Application providers an `AssetSource` trait to load the assets. ```rs use gpui_kit::*; use gpui_kit::assets::Assets as ComponentAssets; use gpui_kit::component::{v_flex, IconName, Root}; use rust_embed::RustEmbed; use std::borrow::Cow; /// An asset source that loads assets from the `./assets` folder. #[derive(RustEmbed)] #[folder = "./assets"] #[include = "icons/**/*.svg"] pub struct Assets; impl AssetSource for Assets { fn load(&self, path: &str) -> Result>> { if path.is_empty() { return Ok(None); } if let Some(file) = Self::get(path) { return Ok(Some(file.data)); } ComponentAssets.load(path) } fn list(&self, path: &str) -> Result> { let mut paths = ComponentAssets.list(path)?; paths.extend(Self::iter().filter_map(|p| p.starts_with(path).then(|| p.into()))); paths.sort(); paths.dedup(); Ok(paths) } } ``` We need call the `with_assets` method when creating the GPUI application to register the asset source: ```rs fn main() { // Register Assets to GPUI application. let app = gpui_kit::application().with_assets(Assets); app.run(move |cx| { // We must initialize gpui_component before using it. gpui_kit::init(cx); cx.spawn(async move |cx| { cx.open_window(WindowOptions::default(), |window, cx| { let view = cx.new(|_| Example); // The first level on the window must be Root. cx.new(|cx| Root::new(view, window, cx)) }) .expect("Failed to open window"); }) .detach(); }); } ``` ## Use the icons Now we can use the icons in our application: ```rs pub struct Example; impl Render for Example { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { v_flex() .gap_2() .size_full() .items_center() .justify_center() .text_center() .child(IconName::Inbox) .child(IconName::Bot) } } ``` ## 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.6.4/component/icon#svg-bytes) for ownership, source replacement, loading icons, and custom icon types. ## Resources - [Lucide Icons](https://lucide.dev/) - The icon set used in GPUI Component is based on the open-source Lucide Icons library, which provides a wide range of customizable SVG icons. [rust-embed]: https://docs.rs/rust-embed/latest/rust_embed/ [IconName]: https://docs.rs/gpui-kit-assets/latest/gpui_kit_assets/enum.IconName.html [Icon]: https://docs.rs/gpui_component/latest/gpui_component/icon/struct.Icon.html [assets]: https://github.com/longbridge/gpui-kit/tree/main/crates/assets/assets/ [gpui-kit-assets]: https://crates.io/crates/gpui-kit-assets --- # Event Source: /versions/v0.6.4/docs/event GPUI provides **Event** as a typed notification mechanism between Entities. An Event reports something that already happened; unlike an [**Action**](./action), it does not use Focus, Key Contexts, KeyBindings, or the Dispatch Path. ## 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, KeyBindings, and Action dispatch. ## Define and emit an Event Define the facts an Entity can report and implement `EventEmitter`: ```rust #[derive(Clone, Debug)] enum ChatEvent { DraftChanged, MessageSent { message_id: MessageId }, } impl EventEmitter for Chat {} ``` Emit the Event after the state change succeeds: ```rust fn finish_send(&mut self, message_id: MessageId, cx: &mut Context) { self.draft.clear(); cx.emit(ChatEvent::MessageSent { message_id }); } ``` Name Events as facts in the past tense: `MessageSent`, `Saved`, or `Dismissed`. A command-style name such as `SendMessage` belongs to an Action. ## Subscribe from the owner The owner stores subscriptions on the same View that subscribes. This follows the pattern used by GPUI Kit examples: ```rust struct Workspace { chat: Entity, _subscriptions: Vec, } impl Workspace { fn new(cx: &mut Context) -> Self { let chat = cx.new(Chat::new); let _subscriptions = vec![cx.subscribe(&chat, |workspace, _, event, _cx| { if matches!(event, ChatEvent::MessageSent { .. }) { workspace.refresh_conversation(); } })]; Self { chat, _subscriptions } } } ``` Do not leave the returned `Subscription` in a local variable: it is dropped when the function returns, which disconnects the observer. Keeping `_subscriptions` on `Workspace` gives both the same lifetime. When the View is dropped, its subscriptions are dropped and disconnected too. Avoid storing View-scoped subscriptions in a longer-lived global owner: keeping callbacks and captured resources alive after the View is gone can cause a memory leak. Use `cx.subscribe_in(..., window, ...)` when the callback needs `&mut Window`; keep that returned `Subscription` in the same field as well. **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. --- # ElementId Source: /versions/v0.6.4/docs/element_id The [ElementId] is a unique identifier for a GPUI element. It is used to reference elements in the GPUI component tree. Before you start using GPUI and GPUI Component, you need to understand the [ElementId]. For example: ```rs div().id("my-element").child("Hello, World!") ``` In this case, the `div` element has an `id` of `"my-element"`. The add `id` is used for GPUI for binding events, for example `on_click` or `on_mouse_move`, the `element` with `id` in GPUI we call [Stateful\]. We also use `id` (actually, it uses [GlobalElementId] internally in GPUI) to manage the `state` in some elements, by using `window.use_keyed_state`, so it is important to keep the `id` unique. ## Unique The `id` should be unique within the layout scope (In a same [Stateful\] parent). For example we have a list with multiple items: ```rs div().id("app").child( div().id("list1").child(vec![ div().id(1).child("Item 1"), div().id(2).child("Item 2"), div().id(3).child("Item 3"), ]) ).child( div().id("list2").child(vec![ div().id(1).child("Item 1"), ]) ) ``` In this case, we can named the child items with a very simple id, because they are have a parent `list1` element with an `id`. GPUI internal will generate [GlobalElementId] with the parent elements's `id`, in this example, the `Item 1` will have global_id: ```rs ["app", "list1", 1] ``` And the `Item 1` in `list2` will have global_id: ```rs ["app", "list2", 1] ``` So we can named the child items with a very simple id. [ElementId]: https://docs.rs/gpui/latest/gpui/enum.ElementId.html [GlobalElementId]: https://docs.rs/gpui/latest/gpui/struct.GlobalElementId.html [Stateful]: https://docs.rs/gpui/latest/gpui/struct.Stateful.html [Stateful\]: https://docs.rs/gpui/latest/gpui/struct.Stateful.html --- # Coding Guides Source: /versions/v0.6.4/docs/coding-guides 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.6.4/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. ## 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 feature should usually be a crate, not another file in a global `views`, `models`, or `modals` directory. Keep the model, views, commands, dialogs, and workflow for one capability together. A dialog that edits a workspace belongs to the workspace feature; only the reusable dialog primitive belongs to the UI library. ```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. A feature may depend on stable shared capabilities and UI foundations; it must not depend on the shell or reach into a sibling feature's internals. When two features need to communicate, prefer an explicit command, event, data type, or small shared service over a dependency between their views. Extract a shared crate only after the capability has a coherent name and more than one real owner. Crate boundaries are engineering boundaries. They let Cargo rebuild and test a smaller dependency subgraph, make ownership visible in `Cargo.toml`, and limit the review and regression surface of a change. They also make removal honest: a feature that cannot be detached without searching through global view and modal directories was never isolated. Do not create a crate for every screen or helper. Split where a capability has its own state and lifecycle, a stable public seam, or enough implementation to benefit from independent compilation and tests. Keep dependencies acyclic and pointing toward smaller, more stable crates. ## 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); cx.spawn(async move |cx| { cx.open_window(WindowOptions::default(), |window, cx| { let workspace = cx.new(|cx| Workspace::new(window, cx)); cx.new(|cx| Root::new(workspace, window, cx)) }) .expect("failed to open window"); }) .detach(); }); ``` `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 survives across frames; the element tree returned by `render` is a fresh description of the current frame. Keep that distinction explicit. - `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 Use a `RenderOnce`/`IntoElement` component when all inputs can be supplied by the caller and the element does not need to retain application state between frames. This is the normal choice for presentational wrappers and small controls. ```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` view when behavior spans frames or needs observation, subscriptions, focus, async work, history, measurement, 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 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 alive when the API requires it. 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` 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: ```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. If code mutates the global GPUI Component theme directly, call `Theme::sync_base(cx)` afterward so Base-owned scrollbars and resize handles receive the new projection. `Theme::change(...)` performs this 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 `Root::render` calls `window.set_rem_size(cx.theme().font_size)`. 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. Change zoom by updating the base font and refreshing the window: ```rust Theme::global_mut(cx).font_size = px(18.); Theme::sync_base(cx); window.refresh(); ``` 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 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. 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`. 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 async work 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. 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 matches Zed's `h_flex`, and it 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; **read-only** permits navigation and selection but prevents editing; **loading** prevents duplicate work while an operation is pending. - **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.6.4/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.6.4/docs/getting-started) for application setup and the component pages for current API details. --- # Testing Source: /versions/v0.6.4/docs/test 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, actions, subscriptions and async tasks, creating a window when needed. - For UI integration tests, render the production application view, dispatch events 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`, 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.6.4/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 ``` 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 Copy the following into `tests/ui.rs`. The example uses GPUI Kit's facade, initializes the component library and wraps the view in `Root`. It retains the input state on the view, as a real application should. The test enters a Unicode name, edits it with Backspace, clicks Save, checks the accessible status announcement and layout, and verifies the saved application value. The same source is compiled and run in GPUI Kit's integration suite. ```rust mod common; use gpui_kit::test::{TestSupportExt, TestWindowExt}; use gpui_kit::{ AppContext, Context, Entity, 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(Input::new(&self.name).id("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").label(), Some("Not saved")); window.click("name", cx); window.input("Ada 中文", cx); let name = window.find("name"); assert_eq!(name.focused(), Some(true)); assert_eq!(name.value(), Some("Ada 中文")); 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 中")); window.click("save", cx); let status = window.find("status"); assert!(status.visible()); assert_eq!(status.label(), Some("Saved: Ada 中")); 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 中")); }); } ``` In your own application, import the production view and its constructor from your library crate. Keeping a second implementation of the view in the test would allow the test and application to diverge. This example defines its view inline only so the entire test can be copied into a new package. From `ui-tests/`, run: ```sh cargo generate-lockfile cargo test --test ui --locked ``` Commit `Cargo.lock` with the test project. Inside the GPUI Kit checkout, run this exact example 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)` | 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. 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 | | `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. Views that open dialogs, sheets or notifications through `WindowExt` must render the corresponding `Root::render_dialog_layer`, `Root::render_sheet_layer` and `Root::render_notification_layer` children, just as the production application does. Constructing `Root` alone does not mount those overlay layers. 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. ## 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. --- # Getting Started Source: /versions/v0.6.4/docs/getting-started ## Installation Add dependencies to your `Cargo.toml`: ```toml [dependencies] gpui-kit = "0.6" anyhow = "1.0" ``` `gpui-kit` always pulls in GPUI and `gpui-base`, and by default `gpui-component` and the default icon set. To manage your own assets, keep only the features you need: ```toml gpui-kit = { version = "0.6", default-features = false, features = ["component"] } ``` See [Icons & Assets](/versions/v0.6.4/docs/assets) for more details. ## Quick Start Here's a simple example to get you started: ```rust use gpui_kit::component::button::*; use gpui_kit::component::*; use gpui_kit::*; pub struct HelloWorld; impl Render for HelloWorld { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div() .v_flex() .gap_2() .size_full() .items_center() .justify_center() .child("Hello, World!") .child( Button::new("ok") .primary() .label("Let's Go!") .on_click(|_, _, _| println!("Clicked!")), ) } } fn main() { let app = gpui_kit::application().with_assets(gpui_kit::assets::Assets); app.run(move |cx| { // This must be called before using any GPUI Component features. gpui_kit::init(cx); cx.spawn(async move |cx| { cx.open_window(WindowOptions::default(), |window, cx| { let view = cx.new(|_| HelloWorld); // This first level on the window, should be a Root. cx.new(|cx| Root::new(view, window, cx)) }) .expect("Failed to open window"); }) .detach(); }); } ``` Make sure to call `gpui_kit::init(cx);` at first line inside the `app.run` closure. This initializes the GPUI Component system. This is required for theming and other global settings to work correctly. ## Basic Concepts ### Stateless Elements GPUI Component uses stateless [RenderOnce] elements, making them simple and predictable. State management is handled at the view level, not in individual components. They are all implemented [IntoElement] types. For example: ```rs struct MyView; impl Render for MyView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .child(Button::new("btn").label("Click Me")) .child(Tag::secondary().child("Secondary")) } } ``` ### Stateful Components See the [tested application recipes](https://github.com/longbridge/gpui-kit/tree/main/examples/ai_recipes) for a complete window with retained subscriptions, icons, and overlay layers. `Root` must wrap each window, and the application content must render the dialog, sheet, and notification layers it uses. Controls such as Input, List, and DataTable use retained state entities. Store that state on the owning view and construct the styled element from it during render. Create the [Entity] once, outside render: ```rust use gpui_kit::component::{ ActiveTheme, IconName, Root, 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, window: &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") }); }), ), ) .children(Root::render_dialog_layer(window, cx)) .children(Root::render_sheet_layer(window, cx)) .children(Root::render_notification_layer(window, cx)) } } ``` ### Theming All components support theming through the built-in `Theme` system: ```rust use gpui_kit::component::{ActiveTheme, Theme}; // Access theme colors in your components cx.theme().primary cx.theme().background cx.theme().foreground ``` ### Sizing Most components support multiple sizes: ```rust Button::new("btn").small() Button::new("btn").medium() // default Button::new("btn").large() Button::new("btn").xsmall() ``` ### Variants Components offer different visual variants: ```rust Button::new("btn").primary() Button::new("btn").danger() Button::new("btn").warning() Button::new("btn").success() Button::new("btn").ghost() Button::new("btn").outline() ``` ## Icons Icons are not bundled with GPUI Component to keep the library lightweight. Continue read [Icons & Assets](/versions/v0.6.4/docs/assets) to learn how to add icons to your project. GPUI Component has an `Icon` element, but does not include SVG files by default. The examples use [Lucide](https://lucide.dev) icons. You can use any icons you like by naming the SVG files as defined in `IconName`. Add the icons you need to your project. ```rust use gpui_kit::component::{Icon, IconName}; Icon::new(IconName::Check) Icon::new(IconName::Search).small() ``` ## Next Steps Explore the component documentation to learn more about each component: - [Button](../component/button) - Interactive button component - [Input](../component/input) - Text input with validation - [Dialog](../component/dialog) - Dialog and modal windows - [DataTable](../component/data-table) - High-performance data tables - [More components...](../component/index) ## Development To run the component gallery: ```bash cargo run ``` More examples can be found in the `examples` directory: ```bash cargo run --example ``` [RenderOnce]: https://docs.rs/gpui/latest/gpui/trait.RenderOnce.html [IntoElement]: https://docs.rs/gpui/latest/gpui/trait.IntoElement.html [Render]: https://docs.rs/gpui/latest/gpui/trait.Render.html --- # RenderOnce Source: /versions/v0.6.4/docs/render-once GPUI provides `RenderOnce` for reusable components that are described by owned data and rebuilt when their parent renders. It is a good fit for buttons, rows, badges, cards, and other declarative pieces that do not own a persistent lifecycle. ```rust use gpui::{App, IntoElement, RenderOnce, SharedString, Window, div}; #[derive(IntoElement)] struct MessageRow { author: SharedString, body: SharedString, } impl MessageRow { fn new(author: impl Into, body: impl Into) -> Self { Self { author: author.into(), body: body.into(), } } } impl RenderOnce for MessageRow { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { div() .flex() .gap_2() .child(div().font_semibold().child(self.author)) .child(self.body) } } ``` `#[derive(IntoElement)]` generates the conversion that lets the value participate in GPUI's fluent Element tree: ```rust div().child(MessageRow::new("You", "Explain RenderOnce")) ``` The derive does not render the component eagerly. It wraps the `RenderOnce` value as an Element; GPUI consumes and renders it as part of the surrounding tree. ## Why `render` consumes `self` The signature is the main difference from [`Render`](./render): ```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 on the next render. Destructuring first keeps ownership clear when several fields move into different parts of the tree: ```rust fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { let MessageRow { author, body } = self; div() .child(author) .child(body) } ``` This does **not** mean the visible UI disappears after one frame. The resulting Element participates in that frame, and the parent supplies the next description when it renders again. ## State belongs outside the component value Do not put changing application state in a `RenderOnce` value and expect mutations to survive. Store persistent state in an `Entity` whose type implements `Render`, then pass the current values or an `Entity` handle into the component. ```rust #[derive(IntoElement)] struct SendButton { chat: Entity, } impl RenderOnce for SendButton { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { let chat = self.chat; Button::new("send") .child("Send") .on_click(move |_, _, cx| { chat.update(cx, |chat, cx| { chat.send_message(cx); }); }) } } ``` Element handlers are `'static`, so use `move` and capture owned values such as `SharedString`, `Entity`, or an Action. Clone a handle before moving it when the caller still needs it. 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 of its own: it cannot use `cx.listener`, retain subscriptions, or call `cx.notify()` for itself. Pass a handler, dispatch an [Action](./action), or update the state-owning `Entity` instead. ## Builder-style components Owned fields make `RenderOnce` work naturally with builder APIs. GPUI Kit and Zed use this pattern for components such as buttons, list items, labels, and modal sections: ```rust #[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() .when(self.muted, |this| this.opacity(0.6)) .child(self.label) } } ``` ## Choose the right layer | Use | When | | --- | --- | | `RenderOnce` | A reusable component is constructed from owned inputs and has no independent persistent state. | | [`Render`](./render) | A stateful `Entity` owns data, subscriptions, tasks, Focus, or a lifecycle and must render repeatedly. | | [`Element`](./element) | You need direct control over layout, prepaint, paint, hit testing, or other low-level rendering 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. 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. --- # Window Source: /versions/v0.6.4/docs/window GPUI provides `Window` as the context for one native window. It connects the rendered Element tree to platform input, 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. ## 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` | | Request another frame | `refresh`, `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. ## Focus and Action dispatch Focus is local to a Window. `window.focus(...)` selects a `FocusHandle`, and `window.focused(cx)` returns the current one. Keyboard input then uses the focused Element's Dispatch Path to match a KeyBinding and dispatch its Action. ```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. Zed and Longbridge Pro 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 rendered frame, such as an animation step. `defer` means “after the current effect cycle,” which is a different boundary. ## Async work with a Window Use `cx.spawn_in(window, ...)` when a 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, mut cx| { let Ok(messages) = fetch_messages().await else { return }; this.update_in(&mut 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. 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. [Entity]: ./entity --- # Mobile Source: /versions/v0.6.4/docs/mobile 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, touch input, 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 the [Longbridge 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. Android has a platform implementation, but the GPUI Kit integration described here has not been validated on Android or a physical iPhone. ## 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. The example targets iOS 16 or later; this is a deployment setting, not a claim that every supported OS version has been tested. 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. To use your local GPUI Kit checkout, replace the Kit dependency with: ```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 and renderer on the same release: the pinned mobile platform uses `gpui-pre` and `gpui-pre-wgpu` at `0.3.4`. Unlike the desktop [Getting Started](/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. ## 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 a double tap in an `Input`, `Textarea`, or selectable `TextView` selects the word under the finger, then shows grab handles at both ends and an edit menu with Cut, Copy, Paste, and Select All as they apply. Nothing needs to be configured; `Root` draws the menu for the window text selection. - 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, but this guide does not establish Android Kit compatibility or native Android `View` embedding. Validate those paths separately before depending on them. --- # FPS Monitor Source: /versions/v0.6.4/docs/fps `gpui-fps` overlays a performance HUD on a 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. ## 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 / FRAME`, capped by the display | The rate a full redraw of this window could sustain | | `FPS` | Frames presented per second | The rate the window is actually drawing at | They are different questions, and an application that draws on demand answers them very differently: a window sitting idle draws twice a second and could draw a hundred and twenty times a second, and only one of those numbers is a performance problem. ### 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 — so each frame the HUD asked for would be a full layout and paint of the application, and the CPU row underneath would be reporting work the HUD itself was causing. On the story gallery's Table page that was ~62% CPU with nobody touching the window. The frame cost already answers the question. `FRAME` is what a full redraw costs, so its reciprocal is the rate those redraws could sustain, and nothing has to be drawn to find it. The HUD never requests a frame. ### 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. | | `CPU` | This process, on the scale `top` and Activity Monitor use: 100 is one saturated core, so a process spread across a core and a half reads 140. | | `MEM` | Resident set. | `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. ## 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/latest/gpui/struct.Entity.html --- # Context Source: /versions/v0.6.4/docs/context The [Window], [App], [Context] and [Entity] are most important things in GPUI, it appears everywhere. - [Window] - The current window instance, which for handle the **Window Level** things. - [App] - The current application instance, which for handle the **Application Level** things. - [Context] - The Entity Context instance, which for handle the **Context Level** things. - [Entity] - The Entity instance, which for handle the **Entity Level** things. For example: ```rs fn new(window: &mut Window, cx: &mut App) {} impl RenderOnce for MyElement { fn render(self, window: &mut Window, cx: &mut App) {} } impl Render for MyView { fn render(&mut self, window: &mut Window, cx: &mut Context) {} } ``` As you can see, we always use `cx` to represent `App` and `Context`, which is the standard naming convention for GPUI, we can follow this convention to make our code more readable and maintainable. [Window]: https://docs.rs/gpui/latest/gpui/struct.Window.html [App]: https://docs.rs/gpui/latest/gpui/struct.App.html [Context]: https://docs.rs/gpui/latest/gpui/struct.Context.html [Entity]: https://docs.rs/gpui/latest/gpui/struct.Entity.html --- # Element Source: /versions/v0.6.4/docs/element An **Element** is a node in the element tree that GPUI builds for one frame. Elements perform layout, prepare hit testing, and 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() } } ``` Zed and Longbridge Pro use `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 phases in order: ```text request_layout → prepaint → paint ``` ### `request_layout` Register the element's `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. State flows forward through the frame: ```text RequestLayoutState ───────────────┐ │ │ ▼ ▼ prepaint ── PrepaintState ──► paint ``` The associated state is frame-local. Persistent application state belongs in an [Entity](./entity), while small element state that must survive frames can be associated with an [ElementId](./element_id). ## 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. The GPUI text input example uses a custom `Element` because it must shape a line during `prepaint`, register an input handler, and paint its selection and cursor. GPUI's `Svg`, `Img`, lists, and canvas use the same lifecycle. In contrast, most Zed and Longbridge Pro UI is built by composing existing elements and converting differing branches to `AnyElement`. For a reusable UI component, start with [`RenderOnce`](./render). For stateful UI owned by an Entity, use [`Render`](./render). Drop down to `Element` only when you need to control the rendering pipeline itself. ## 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. 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/latest/gpui/trait.Element.html [IntoElement]: https://docs.rs/gpui/latest/gpui/trait.IntoElement.html [AnyElement]: https://docs.rs/gpui/latest/gpui/struct.AnyElement.html --- # Render Source: /versions/v0.6.4/docs/render GPUI provides the `Render` trait for turning the current state of an `Entity` into an element tree. Use it for a long-lived View whose state changes over time, such as a Chat panel, settings page, or workspace. ```rust use gpui::{div, prelude::*, Context, IntoElement, Render, Window}; struct Chat { messages: Vec, } impl Render for Chat { fn render( &mut self, _window: &mut Window, _cx: &mut Context, ) -> impl IntoElement { div() .flex() .flex_col() .children( self.messages .iter() .cloned() .map(|message| div().child(message)), ) } } ``` `render` receives: - `&mut self`: the state stored in this Entity; - `&mut Window`: window-level state and operations; - `&mut Context`: the current Entity's GPUI context; - and returns `impl IntoElement`: a value GPUI can convert into an element tree. Returning `impl IntoElement` keeps the concrete, often deeply nested element type out of the function signature. Common values such as `div()`, GPUI Kit components, and child `Entity` values can all participate in that tree. ## Updating the View Changing an Entity does not by itself tell GPUI that its visible output changed. Mutate the state inside an Entity update and call `cx.notify()`: ```rust impl Chat { fn push_message(&mut self, message: String, cx: &mut Context) { self.messages.push(message); cx.notify(); } } ``` The relationship is: ```text Entity state changes ↓ cx.notify() ↓ GPUI invalidates Views displaying that Entity ↓ Render builds their next element trees ``` `cx.notify()` also notifies observers of the Entity. Rendering is scheduled by GPUI; it does not call `render` synchronously at the line where `notify` runs. When the same Entity is shown in multiple windows or locations, GPUI invalidates the live Views that display it. ## Keep Render Declarative Treat `render` as a description of the UI for the current state. Reading state, choosing children, and attaching handlers are normal. Avoid starting work or changing application state merely because `render` ran: - do not start network requests or background tasks; - do not create subscriptions or register application observers; - do not dispatch commands or emit Events; - do not unconditionally call `cx.notify()`, `window.refresh()`, or `window.request_animation_frame()`. GPUI may render again whenever the View is invalidated. An unconditional `cx.notify()` inside `render`, `prepaint`, `paint`, or a canvas callback can continually dirty the window and create an idle redraw loop. Start work in initialization or an explicit handler, update the Entity when results arrive, then call `cx.notify()` only when visible state actually changed. Input handlers attached while rendering are different: the closure is registered as part of the element tree and runs later, when input occurs. ```rust div() .child("Clear") .on_click(cx.listener(|this, _, _, cx| { this.messages.clear(); cx.notify(); })) ``` ## Render, RenderOnce, and Element Choose the narrowest abstraction that fits: | API | Use it for | Method receiver | Context | | --- | --- | --- | --- | | `Render` | A stateful, long-lived View backed by an `Entity` | `&mut self` | `Context` | | [`RenderOnce`](./render-once) | A reusable component assembled from owned input | `self` | `App` | | [`Element`](./element) | Custom layout, prepaint, hitboxes, or painting | phase-specific `&mut self` | `App` | An `Entity` where `T: Render` can be added directly as a child. Its Entity ID gives the View identity, and notifications can invalidate its View subtree. A `RenderOnce` component is consumed as it builds a tree and has no Entity identity of its own. Implement `Element` only when composing existing elements is not enough. ## Related Guides - [`Entity`](./entity) explains ownership, reading, and updating state. - [`Context`](./context) explains `App`, `Window`, and `Context`. - [`RenderOnce`](./render-once) covers reusable components made from owned props. - [`Element`](./element) covers GPUI's layout and paint phases. --- # Comparison Source: /versions/v0.6.4/docs/comparison How GPUI Kit compares with other desktop UI frameworks. The table is maintained by hand; please open an issue or a pull request if you spot a mistake or something outdated. | Features | GPUI Kit | [Iced] | [egui] | [Qt 6] | | --------------------- | ------------------------------ | ------------------ | --------------------- | ------------------------------------------------- | | Language | Rust | Rust | Rust | C++/QML | | Core Render | GPUI | wgpu | wgpu | QT | | License | Apache 2.0 | MIT | MIT/Apache 2.0 | [Commercial/LGPL](https://www.qt.io/qt-licensing) | | Min Binary Size [^1] | 12MB | 11MB | 5M | 20MB [^2] | | Cross-Platform | Yes | Yes | Yes | Yes | | Documentation | Simple | Simple | Simple | Good | | Web | Yes (WASM) | Yes | Yes | Yes | | UI Style | Modern | Basic | Basic | Basic | | CJK Support | Yes | Yes | Bad | Yes | | Chart | Yes | No | No | Yes | | Table (Large dataset) | Yes
(Virtual Rows, Columns) | No | Yes
(Virtual Rows) | Yes
(Virtual Rows, Columns) | | Table Column Resize | Yes | No | Yes | Yes | | Text base | Rope | [COSMIC Text] [^3] | trait TextBuffer [^4] | [QTextDocument] | | CodeEditor | Simple | Simple | Simple | Basic API | | Dock Layout | Yes | Yes | Yes | Yes | | Syntax Highlight | [Tree Sitter] | [Syntect] | [Syntect] | [QSyntaxHighlighter] | | Markdown Rendering | Yes | Yes | Basic | No | | Markdown mix HTML | Yes | No | No | No | | HTML Rendering | Basic | No | No | Basic | | Text Selection | TextView | No | Any Label | Yes | | Custom Theme | Yes | Yes | Yes | Yes | | Built Themes | Yes | No | No | No | | I18n | Yes | Yes | Yes | Yes | [Iced]: https://github.com/iced-rs/iced [egui]: https://github.com/emilk/egui [QT 6]: https://www.qt.io/product/qt6 [Tree Sitter]: https://tree-sitter.github.io/tree-sitter/ [Syntect]: https://github.com/trishume/syntect [QSyntaxHighlighter]: https://doc.qt.io/qt-6/qsyntaxhighlighter.html [QTextDocument]: https://doc.qt.io/qt-6/qtextdocument.html [COSMIC Text]: https://github.com/pop-os/cosmic-text [^1]: Release builds by use simple hello world example. [^2]: [Reducing Binary Size of Qt Applications](https://www.qt.io/blog/reducing-binary-size-of-qt-applications-part-3-more-platforms) [^3]: Iced Editor: [^4]: egui TextBuffer: --- # Action Source: /versions/v0.6.4/docs/action GPUI provides **Focus**, **Key Context**, **Action**, **KeyBinding**, and [**Event**](./event) as its core interaction mechanisms. Together they let an application route commands to the active part of a window and communicate typed state changes between entities. This guide shows how to use those mechanisms together: - **Focus** says where keyboard interaction is happening; - **`track_focus`** registers a stable `FocusHandle` on an Element so pointer input and command routing can use it; - an **Action** expresses a command and can come from a `KeyBinding`, menu, button, or code; - an [**Event**](./event) reports what an entity did or experienced to its subscribers. ## How a shortcut works Focus builds a Dispatch Path, its key contexts match a KeyBinding, and the resulting Action is dispatched to the most specific handler first Focus builds a Dispatch Path, its key contexts match a KeyBinding, and the resulting Action is dispatched to the most specific handler first Imagine a window split into a Sidebar on the left and Chat on the right. Clicking the Sidebar produces a focus path containing `Sidebar`; clicking the chat composer produces one containing `Chat`. A binding scoped to `Chat` is therefore active only on the right. The layout can make both keyboard regions explicit in one place: ```rust h_flex() .size_full() .child( // Left: clicking here activates the Sidebar context. div() .w_64() .track_focus(&self.sidebar_focus) .key_context("Sidebar") .child("Sidebar"), ) .child( // Right: clicking here activates the Chat context. div() .flex_1() .track_focus(&self.chat_focus) .key_context("Chat") .on_action(cx.listener(Self::on_action_send_message)) .child("Chat"), ) ``` Each region has its own stable `FocusHandle` and Key Context. Clicking Chat moves Focus to `chat_focus`, so `Chat` joins the active Dispatch Path and the `SendMessage` handler can receive the matched Action. Clicking Sidebar activates `Sidebar` instead, so the Chat-only binding does not match. When a key is pressed, GPUI: 1. starts at the focused element and builds a path through its ancestors; 2. collects the `key_context` values on that path and matches a `KeyBinding`; 3. dispatches the matched Action along the same path, starting with the most specific handler. The active focus path makes the same keystroke mean different things in different parts of a window without introducing a global shortcut switchboard. ## Focus is a location A `FocusHandle` is a stable identity for a keyboard target. Keep it on the entity that owns the interaction: ```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() } } ``` - `handle.is_focused(window)` checks this exact target. - `handle.contains_focused(window, cx)` also accepts a focused descendant. - `handle.focus(window, cx)` deliberately moves focus here. Use the exact check for an input caret or selected control. Use containment when a panel remains active while one of its controls has focus. ## What `track_focus` does `track_focus` registers the `FocusHandle` on the Element's dispatch node and marks that Element as able to receive Focus: ```rust div() .track_focus(&self.focus_handle) .key_context("Chat") .on_action(cx.listener(Self::on_action_send_message)) ``` That registration has several connected effects: - mouse down inside the Element moves Focus to the handle by default; - `focus`, `in_focus`, and `focus_visible` styles can read its state; - GPUI can calculate Focus containment and the Focus Path; - Key Contexts and Action handlers on that path participate in key matching and Action dispatch. A nested control can call `cx.prevent_default()` when it must keep the parent Element from taking Focus on mouse down. `track_focus` does **not** immediately give the Element Focus during render. Call `focus_handle.focus(window, cx)` when opening a view or entering an interaction. Never request Focus unconditionally from `render`, because every render would steal it back. ### Focus and Tab order are separate A tracked handle is not automatically reachable with Tab. Declare Tab behavior on the handle itself: ```rust let focus_handle = cx.focus_handle().tab_stop(true); ``` Use `tab_index(...)` for an intentional order. Calling `.tab_stop(...)` on the element does not change a handle passed to `track_focus`. For a stateless component, retain the handle across renders with keyed state: ```rust let focus_handle = window.use_keyed_state(id, cx, |_, cx| { cx.focus_handle().tab_stop(true) }); ``` ## An Action is a command protocol An Action is GPUI's core representation of an application operation: a typed command value that can be dispatched without coupling the sender to the receiver. GPUI routes it through the active focus path. The same Action serves three layers: 1. **Input mapping:** a key binding maps a keystroke to an Action. 2. **Command dispatch:** a command palette, button, popup menu, or another handler dispatches the Action. 3. **Configuration:** a Keymap serializes a command as a stable Action name plus an optional JSON payload, and GPUI's Action registry deserializes it back into the typed Action. This is the basis of Zed-style user keymaps. For example, a command palette stores Actions rather than a callback for every row: ```rust let commands: Vec<(&str, Box)> = vec![ ("Send message", Box::new(SendMessage)), ("Toggle sidebar", Box::new(ToggleSidebar)), ]; // When the user confirms the selected command: window.dispatch_action(commands[selected].1.boxed_clone(), cx); ``` In application code, use whatever owned or cloneable command entry your palette model provides; the important boundary is that selection produces an Action and dispatches it. The focused owner still decides how to handle it. Native application menus use the same protocol. On macOS, menu commands are Actions rather than ordinary element click callbacks: ```rust MenuItem::action("Send Message", SendMessage) ``` Unit Actions declared with `actions!` are registered by name. For an Action carrying configuration data, derive `Action` and `Deserialize` and give it a namespace: ```rust #[derive(Action, Clone, PartialEq, Deserialize)] #[action(namespace = chat)] struct InsertPrompt { text: SharedString, } ``` A keymap can then identify the command by its stable action name and, when needed, a JSON payload. `#[action(no_json)]` deliberately opts an Action out of JSON construction; use it for runtime-only commands that should never appear in user configuration. ### Coordinate sibling components through their owner Suppose selecting a conversation in the Sidebar should open it in Chat. The Sidebar should describe that intent with `OpenConversation`; it does not need a callback or direct reference to Chat. Their nearest common owner, `Workspace`, handles the Action and updates Chat: ```rust #[derive(Action, Clone, PartialEq)] #[action(namespace = workspace, no_json)] struct OpenConversation { conversation_id: ConversationId, } 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 is an ancestor of both Sidebar and Chat. h_flex() .on_action(cx.listener(Self::on_action_open_conversation)) .child(self.sidebar.clone()) .child(self.chat.clone()) // A conversation row in Sidebar dispatches the command. window.dispatch_action( Box::new(OpenConversation { conversation_id }), cx, ); ``` The dispatch route is now explicit: **Sidebar → Workspace → Chat**. The Action travels upward on Sidebar's current Dispatch Path until `Workspace` handles it. `Workspace` then calls Chat through the Entity API. The Action itself never travels sideways from Sidebar into Chat. **INFO — A sibling is not on the Dispatch Path** If `on_action_open_conversation` is attached only to Chat, an Action dispatched while Sidebar has Focus cannot reach it: Chat is a sibling, not an ancestor on the current Dispatch Path. The same mistake can make a shortcut appear unresponsive when its `on_action` handler sits outside the path selected by Focus. Put a cross-region handler on the nearest common owner, register the `KeyBinding`, and place its `key_context` and handler on the path where the shortcut should work. Reserve global handlers for commands that are truly application-wide. ## Build a command end to end Define and bind the command once: ```rust actions!(chat, [SendMessage]); const CHAT_CONTEXT: &str = "Chat"; fn init(cx: &mut App) { cx.bind_keys([ #[cfg(target_os = "macos")] KeyBinding::new("cmd-enter", SendMessage, Some(CHAT_CONTEXT)), #[cfg(not(target_os = "macos"))] KeyBinding::new("ctrl-enter", SendMessage, Some(CHAT_CONTEXT)), ]); } ``` Attach focus, context, and handler to the same owning region: ```rust impl Chat { fn on_action_send_message( &mut self, _: &SendMessage, _: &mut Window, cx: &mut Context, ) { self.submit_draft(); cx.notify(); } } impl Render for Chat { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .track_focus(&self.focus_handle) .key_context(CHAT_CONTEXT) .on_action(cx.listener(Self::on_action_send_message)) .child("Chat") } } ``` Every entry point dispatches the same `SendMessage` Action: `KeyBinding` covers the shortcut, the button calls `window.dispatch_action(...)` from its click handler, and popup or macOS native menus hold the same Action. Message submission is implemented once in the Action handler. Action handlers stop bubbling by default. If an inner handler declines the Action and a parent should try it, call `cx.propagate()`. A global handler registered with `cx.on_action(...)` must also propagate whenever it does not apply. Register key bindings before `cx.set_menus(...)`. Native menus capture displayed shortcuts when built, so changing bindings later does not update an existing menu automatically. ## How Action and Event work together Action and Event describe opposite directions in the same interaction: ```text ⌘ Enter → SendMessage Action → Chat sends → MessageSent Event → Workspace updates ``` An Action carries **intent inward** to the command owner. After the operation changes state, an Event carries **what happened outward** to interested owners. Read [Event](./event) for `EventEmitter`, `emit`, subscriptions, lifetime management, and the complete Action-or-Event decision guide. ## Contextual and global shortcuts Most editing and navigation shortcuts should be contextual: they only make sense while a region contains focus and should yield to a more specific child. Use `cx.on_action(...)` for a true application-wide fallback or service command. For dangerous shortcuts, also check runtime state in the owner. Longbridge Pro scopes trading bindings to a workspace and separately rejects them while an input or dialog has focus. Context decides **where a command is eligible**; the handler decides **whether it is currently allowed**. ## Debug “works only after clicking” If a shortcut only works after clicking a particular region, the click has usually moved focus into the path containing the required key context and handler. Treat this as a routing problem and inspect the same pipeline GPUI uses. Check the pipeline in order: 1. **Binding:** Is the key bound to the expected Action and context? 2. **Focus:** Which `FocusHandle` is focused before and after the click? 3. **Tracking:** Is that same handle passed to `track_focus` on a rendered element? 4. **Context:** Is the required `key_context` on the focused element or an ancestor? 5. **Handler:** Is `on_action` on the same dispatch path? 6. **Propagation:** Did a more specific handler consume the Action? 7. **Lifetime:** Was a global handler or Event subscription dropped, or did a stale handler fail to propagate? The usual fix is to make one region own the retained handle, `track_focus`, `key_context`, and `on_action`, then move focus into that region when the user enters it. These patterns follow GPUI's dispatch implementation and are exercised in GPUI Kit's Menu, Tree, Input, Dialog, and Color Picker code, Zed's panels and editors, and Longbridge Pro's workspace and trading shortcuts. --- # GPUI Kit Source: /versions/v0.6.4/docs GPUI Kit (aka: GPUI Component) is a comprehensive Rust desktop application framework built on GPUI. It combines a complete UI system with application-grade data, layout, content, and editing capabilities, and it ships as three crates that build on each other, all reachable through the single `gpui-kit` 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 60+ controls, themes, data tables, dock layout, and a code editor. - **`gpui-shell`**: Opens a Rust host to JavaScript extensions, one granted capability at a time. 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](/component), [GPUI Base](/base), and [GPUI Shell](/shell). ## Features - **60+ UI Components**: Forms, navigation, overlays, feedback, layout, and more. - **Production Ready**: Used to build Longbridge Pro from day one and refined in a publicly shipped commercial desktop application. - **Native Feel**: Modern controls inspired by macOS and Windows. - **120 FPS**: GPU-accelerated interfaces that remain smooth under load. - **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. ## Quick Example Add `gpui-kit` to your `Cargo.toml`: ```toml [dependencies] gpui-kit = "0.6" ``` Then create a simple "Hello, World!" application with a button: ```rust use gpui_kit::*; use gpui_kit::component::button::*; use gpui_kit::component::*; pub struct HelloWorld; impl Render for HelloWorld { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div() .v_flex() .gap_2() .size_full() .items_center() .justify_center() .child("Hello, World!") .child( Button::new("ok") .primary() .label("Let's Go!") .on_click(|_, _, _| println!("Clicked!")), ) } } fn main() { gpui_kit::application().run(move |cx| { // This must be called before using any GPUI Component features. gpui_kit::init(cx); cx.spawn(async move |cx| { cx.open_window(WindowOptions::default(), |window, cx| { let view = cx.new(|_| HelloWorld); // This first level on the window, should be a Root. cx.new(|cx| Root::new(view, window, cx)) }) .expect("Failed to open window"); }) .detach(); }); } ``` ## Community & Support Learn how to build interruptible 120 FPS animation in the [GPUI Base Motion guide](/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 --- # Fonts Source: /versions/v0.6.4/docs/fonts ## 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.6.4/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 the name does not match an installed font, GPUI falls back silently — so verify the exact family name on each target platform. ## Changing fonts via Theme Set the app-wide fonts on the `Theme` global, then sync to the base layer: ```rust Theme::global_mut(cx).font_family = "Inter".into(); Theme::global_mut(cx).mono_font_family = "JetBrains Mono".into(); Theme::global_mut(cx).font_size = px(18.); Theme::sync_base(cx); window.refresh(); ``` `font_size` doubles as the application zoom control — `Root` calls `window.set_rem_size(cx.theme().font_size)`, so `rem`-based spacing scales with it. See [Coding Guides](/versions/v0.6.4/docs/coding-guides) for details. ## Per-element override Any element accepts 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/latest/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**: ```rust 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::global_mut(cx).font_family = "MyFont".into(); Theme::sync_base(cx); ``` The gallery's web build bundles `Inter`, `JetBrains Mono`, `Noto Sans SC` and `IBM Plex Sans` this way — see `crates/story-web/src/lib.rs`. ## 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 } ``` Load it with `ThemeRegistry`: ```rust ThemeRegistry::watch_dir(PathBuf::from("./themes"), cx, move |cx| { if let Some(theme) = ThemeRegistry::global(cx).themes().get(&theme_name).cloned() { Theme::global_mut(cx).apply_config(&theme); } }); ``` See [Theme](/versions/v0.6.4/component/theme) for the full config reference. ## WebAssembly note Browsers expose **no system fonts** to WASM apps. The `story-web` gallery (which runs at `gpui-kit.com/gallery/`) must bundle every family it uses and re-assert them after `Theme::change`, or the text system panics. Desktop apps skip this entirely. Text the bundled fonts cannot draw can still come from the browser. The web platform renders emoji through Canvas 2D with the visitor's local fonts, so an application does not have to bundle an emoji font. The policy is chosen when the platform is constructed and cannot change afterwards: | `CanvasFontFallback` | Browser draws | | --- | --- | | `Emoji` (default) | Emoji, including skin tones, flags, keycaps and ZWJ sequences | | `EmojiAndCjk` | Emoji plus horizontal Han, kana and modern Hangul text | | `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); ``` Bundled fonts stay preferred wherever they have the glyph. The fallback draws each grapheme on its own, so CJK text rendered this way favors readability over exact spacing and font features, and its appearance depends on the fonts installed on the visitor's machine. The gallery opts into `EmojiAndCjk`: its bundled fonts hold only the glyphs its own stories use, and anything a visitor types into an input would otherwise render as tofu. --- # Sidebar Source: /versions/v0.6.4/component/sidebar 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) ) ) ) ``` --- # Table Source: /versions/v0.6.4/component/table 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 --- # Root View Source: /versions/v0.6.4/component/root The [Root] component for as the root provider of GPUI Component features in a window. We must to use [Root] as the **first level child** of a window to enable GPUI Component features. This is important, if we don't use [Root] as the first level child of a window, there will have some unexpected behaviors. This complete **Tested consumer recipe** is compiled from the isolated `gpui-kit` consumer workspace. It initializes GPUI Kit before creating a window, makes `Root` the first-level view, and renders every Root overlay layer. ```rust use gpui_kit::component::Root; 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); cx.spawn(async move |cx| { cx.open_window(WindowOptions::default(), |window, cx| { let view = cx.new(|_| BootstrapView); cx.new(|cx| Root::new(view, window, cx)) }) .expect("failed to open window"); }) .detach(); }); } struct BootstrapView; impl Render for BootstrapView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { div() .size_full() .child("My application") .children(Root::render_dialog_layer(window, cx)) .children(Root::render_sheet_layer(window, cx)) .children(Root::render_notification_layer(window, cx)) } } ``` ## Window Border By default, [Root] renders GPUI Component's client-side window border wrapper. For layer-shell fullscreen windows or other surfaces that should not render this wrapper, disable it with `bordered(false)`: ```rs cx.new(|cx| Root::new(view, window, cx).bordered(false)) ``` ## Overlays We have dialogs, sheets, notifications, we need placement for them to show, so [Root] provides methods to render these overlays: - [Root::render_dialog_layer](https://docs.rs/gpui-component/latest/gpui_component/struct.Root.html#method.render_dialog_layer) - Render the current opened modals. - [Root::render_sheet_layer](https://docs.rs/gpui-component/latest/gpui_component/struct.Root.html#method.render_sheet_layer) - Render the current opened drawers. - [Root::render_notification_layer](https://docs.rs/gpui-component/latest/gpui_component/struct.Root.html#method.render_notification_layer) - Render the notification list. Put these layers in the `render` method of the first-level view below `Root`; the tested recipe above shows their required `window, cx` arguments. Here the example we used `children` method, it because if there is no opened dialogs, sheets, notifications, these methods will return `None`, so GPUI will not render anything. [Root]: https://docs.rs/gpui-component/latest/gpui_component/root/struct.Root.html --- # TextView Source: /versions/v0.6.4/component/text-view `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](/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](/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") ``` ### 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](/base/text-view#retained-state-and-streaming-updates). ## 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`](/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.6.4/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())) }) ``` --- # Pagination Source: /versions/v0.6.4/component/pagination 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 --- # Attachment Source: /versions/v0.6.4/component/attachment `Attachment` presents one file or media item. It provides stable layout for a media preview, metadata, and optional actions while leaving upload state, selection, retry, 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_2xl()` (`radius_xl` for `XSmall`) | Shared semantic radius. | `Attachment` sizes itself to its content and 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()), ) ``` Only a source image is dimmed while `Uploading`, `Processing`, or `Failed`. Overlays and custom children keep full contrast. With no source, the media slot is a themed muted area; in a failed state it uses the destructive semantic surface and foreground so an error icon remains legible. `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` | Preview dims; typed title shimmers. | Progress value and a Cancel button. | | `Processing` | Preview dims; typed title shimmers. | “Processing…” and a non-destructive wait state. | | `Failed` | Destructive border/description; preview dims when present. | Error reason plus Retry or 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_72() // application-owned width when a fixed measure is needed ``` The named sizes adjust gap, typography, padding, media baseline, and radius as one scale. Use them to keep attachments aligned with other component densities. `Size::Size(...)` is a custom density value, not a width setter; use the normal GPUI width refinements (`w_72()`, `w(...)`, or a parent layout) when the product needs a fixed 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. ## 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. ## 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 whole-card click layer. | | `on_click(handler)` | none | Whole-card activation; requires `id(...)` and stays below the actions. | | `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. | | `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::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 --- # Combobox Source: /versions/v0.6.4/component/combobox 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 --- # Radio Source: /versions/v0.6.4/component/radio 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 --- # Shimmer Source: /versions/v0.6.4/component/shimmer `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 --- # Tabs Source: /versions/v0.6.4/component/tabs 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 --- # Dialog Source: /versions/v0.6.4/component/dialog 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 ### Setup application root view for display of dialogs You need to set up your application's root view to render the dialog layer. This is typically done in your main application struct's render method. The [Root::render_dialog_layer](https://docs.rs/gpui-component/latest/gpui_component/struct.Root.html#method.render_dialog_layer) function handles rendering any active dialogs on top of your app content. ```rust use gpui_kit::component::TitleBar; struct MyApp { view: AnyView, } impl Render for MyApp { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let dialog_layer = Root::render_dialog_layer(window, cx); div() .size_full() .child( v_flex() .size_full() .child(TitleBar::new()) .child(div().flex_1().overflow_hidden().child(self.view.clone())), ) // Render the dialog layer on top of the app content .children(dialog_layer) } } ``` ### 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") }) ``` ### 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 --- # NumberInput Source: /versions/v0.6.4/component/number-input 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 --- # Form Source: /versions/v0.6.4/component/form 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")) ) ) ) ``` --- # Menu Source: /versions/v0.6.4/component/menu # 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 --- # AlertDialog Source: /versions/v0.6.4/component/alert-dialog 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.6.4/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 Button Props Use `button_props` to customize button text and styles: ```rust use gpui_kit::component::dialog::DialogButtonProps; 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.") .button_props( DialogButtonProps::default() .ok_text("Delete") .ok_variant(ButtonVariant::Danger) .cancel_text("Keep") .show_cancel(true) ) .on_ok(|_, window, cx| { window.push_notification("Account deleted", cx); true }) }) ``` ### 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 use gpui_kit::component::dialog::DialogButtonProps; window.open_alert_dialog(cx, |alert, _, _| { alert .title("Processing") .description("A process is running. Click Continue to stop it or Cancel to keep waiting.") .button_props( DialogButtonProps::default() .ok_text("Continue") .show_cancel(true) ) .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) | | `button_props(props)` | Set button properties (text, style, visibility) | | `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 | 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 with button props: ```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.") .button_props( DialogButtonProps::default() .ok_text("Delete") .ok_variant(ButtonVariant::Danger) .show_cancel(true) ) .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 --- # Theme Source: /versions/v0.6.4/component/theme 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::global_mut(cx).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 --- # DataTable Source: /versions/v0.6.4/component/data-table # 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 }); ``` ### 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 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.) ### 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 - `selected_cell()` - Get currently selected cell - `set_selected_cell(row_ix, col_ix, cx)` - Select a specific cell - `selected_row()` - Get currently selected row - `selected_col()` - Get currently selected column - `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 [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 --- # Slider Source: /versions/v0.6.4/component/slider 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 | --- # Icon Source: /versions/v0.6.4/component/icon 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.6.4/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. Supply additional SVGs through your application's `AssetSource`, falling back to the default source for component icons. The optional `icon_assets!` macro can also select bundled SVGs for an extra source that you compose with the default. See [Icons & Assets](/versions/v0.6.4/docs/assets). ## 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 --- # DescriptionList Source: /versions/v0.6.4/component/description-list 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 --- # Scrollable Source: /versions/v0.6.4/component/scrollable 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() }, ) } } ``` --- # Stepper Source: /versions/v0.6.4/component/stepper 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 --- # Settings Source: /versions/v0.6.4/component/settings > 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![...]) ``` ## 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![...]) ``` ## 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 --- # Focus Trap Source: /versions/v0.6.4/component/focus-trap 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](/component/dialog) and [Sheet](/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](/component/root) - Manages focus trap behavior at the window level - [Dialog](/component/dialog) - Uses focus trap automatically - [Sheet](/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]: /component/dialog [Sheet]: /component/sheet --- # ColorPicker Source: /versions/v0.6.4/component/color-picker 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, ColorPickerState, ColorPickerEvent}; ``` ## 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 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] - [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 [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 --- # GroupBox Source: /versions/v0.6.4/component/group-box 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")) ``` ### 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 --- # Popover Source: /versions/v0.6.4/component/popover 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 controls how the popover attaches to the trigger, using the [`Anchor`] type. Imagine the popover has a pointer tip (like a speech bubble's tail). The anchor is where that tip sits relative to the trigger — `Anchor::TopLeft` places it at the trigger's top-left corner, `Anchor::BottomRight` at the bottom-right, and so on. The popover then hangs off that point. For example, `Anchor::TopLeft` places the popover just below the trigger, left-aligned to it: ```text [ Trigger ] ┌──────────────┐ │ Popover │ └──────────────┘ ``` ```rust use gpui_kit::component::Anchor; // Anchored to the trigger's top corners Popover::new("top-left") .anchor(Anchor::TopLeft) .trigger(Button::new("btn").label("Top Left").outline()) .child("Anchored to the trigger's top-left") Popover::new("top-center") .anchor(Anchor::TopCenter) .trigger(Button::new("btn").label("Top Center").outline()) .child("Anchored to the trigger's top-center") Popover::new("top-right") .anchor(Anchor::TopRight) .trigger(Button::new("btn").label("Top Right").outline()) .child("Anchored to the trigger's top-right") // Anchored to the trigger's bottom corners Popover::new("bottom-left") .anchor(Anchor::BottomLeft) .trigger(Button::new("btn").label("Bottom Left").outline()) .child("Anchored to the trigger's bottom-left") Popover::new("bottom-center") .anchor(Anchor::BottomCenter) .trigger(Button::new("btn").label("Bottom Center").outline()) .child("Anchored to the trigger's bottom-center") Popover::new("bottom-right") .anchor(Anchor::BottomRight) .trigger(Button::new("btn").label("Bottom Right").outline()) .child("Anchored to the trigger's bottom-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.") ``` [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 --- # Resizable Source: /versions/v0.6.4/component/resizable 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() ) ``` ### 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 --- # Collapsible Source: /versions/v0.6.4/component/collapsible 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](/base/motion) for timing, reduced-motion, and performance details. [Collapsible]: https://docs.rs/gpui-component/latest/gpui_component/collapsible/struct.Collapsible.html --- # Command Source: /versions/v0.6.4/component/command 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 --- # DatePicker Source: /versions/v0.6.4/component/date-picker 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}, calendar::{Date, Matcher}, }; ``` ## 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) ``` ### 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(date) => { match 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(Date::Range(Some(start), Some(end))) => { 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") ``` --- # Input Source: /versions/v0.6.4/component/input For multiple addons, shared frames, and textarea toolbars, see [Input Group](/versions/v0.6.4/component/input-group). A single-line text input with validation, masking, prefix/suffix elements, and different visual states. Use [Textarea](/versions/v0.6.4/component/textarea) for ordinary multi-line text and [Editor](/versions/v0.6.4/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 // The built-in context menu can be disabled. let input = cx.new(|cx| InputState::new(window, cx).context_menu(false)); // Or you can define a custom context menu. 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)) ``` --- # Switch Source: /versions/v0.6.4/component/switch 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) ``` ## 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` | ### 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 --- # Message Source: /versions/v0.6.4/component/message `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. | | 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. 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. - 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. | | `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 --- # List Source: /versions/v0.6.4/component/list 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) ) ) } } ``` --- # Notification Source: /versions/v0.6.4/component/notification 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 ### Setup application root view for display of notifications You need to set up your application's root view to render the notification layer. This is typically done in your main application struct's render method. The [Root::render_notification_layer](https://docs.rs/gpui-component/latest/gpui_component/struct.Root.html#method.render_notification_layer) function handles rendering any active modals on top of your app content. ```rust use gpui_kit::component::{TitleBar, Root}; struct Example {} impl Render for Example { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let notification_layer = Root::render_notification_layer(window, cx); div() .size_full() .child( v_flex() .size_full() .child(TitleBar::new()) .child(div().flex_1().child("Hello world!")), ) // Render the notification layer on top of the app content .children(notification_layer) } } ``` ### 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::global_mut(cx).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::global_mut(cx).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(); })) ``` --- # Bubble Source: /versions/v0.6.4/component/bubble `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, and can occupy the full row. `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")), ), ), ) ``` 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 --- # Image Source: /versions/v0.6.4/component/image The Image component provides a robust way to display images with comprehensive fallback handling, loading states, and responsive sizing. Built on GPUI's native image support, it handles various image sources including URLs, local files, and SVG graphics with proper error handling and accessibility features. ## Import ```rust use gpui_kit::{img, ImageSource, ObjectFit}; use gpui_kit::component::{v_flex, h_flex, div, Icon, IconName}; ``` ## Usage ### Basic Image ```rust // Simple image from URL img("https://example.com/image.jpg") // Local image file img("assets/logo.png") // SVG image img("icons/star.svg") ``` ### Image with Sizing ```rust // Fixed dimensions img("https://example.com/photo.jpg") .w(px(300.)) .h(px(200.)) // Responsive width with aspect ratio img("https://example.com/banner.jpg") .w(relative(1.)) // Full width .max_w(px(800.)) .h(px(400.)) // Square image img("https://example.com/avatar.jpg") .size(px(100.)) // 100x100px ``` ### Object Fit Options Control how images are scaled and positioned within their containers: ```rust // Cover - scales to fill container, may crop img("https://example.com/photo.jpg") .w(px(300.)) .h(px(200.)) .object_fit(ObjectFit::Cover) // Contain - scales to fit within container, preserves aspect ratio img("https://example.com/photo.jpg") .w(px(300.)) .h(px(200.)) .object_fit(ObjectFit::Contain) // Fill - stretches to fill container, may distort img("https://example.com/photo.jpg") .w(px(300.)) .h(px(200.)) .object_fit(ObjectFit::Fill) // Scale Down - acts like contain, but never scales up img("https://example.com/photo.jpg") .w(px(300.)) .h(px(200.)) .object_fit(ObjectFit::ScaleDown) // None - original size, may overflow or be smaller than container img("https://example.com/photo.jpg") .w(px(300.)) .h(px(200.)) .object_fit(ObjectFit::None) ``` ### Image with Fallback Handling ```rust // Basic fallback with placeholder fn image_with_fallback(src: &str, alt_text: &str) -> impl IntoElement { div() .w(px(300.)) .h(px(200.)) .bg(cx.theme().surface) .border_1() .border_color(cx.theme().border) .rounded(px(8.)) .overflow_hidden() .child( img(src) .w_full() .h_full() .object_fit(ObjectFit::Cover) // Add error handling in practice ) } // Fallback with icon placeholder fn image_with_icon_fallback(src: &str) -> impl IntoElement { div() .size(px(200.)) .bg(cx.theme().surface) .border_1() .border_color(cx.theme().border) .rounded(px(8.)) .flex() .items_center() .justify_center() .child( img(src) .size_full() .object_fit(ObjectFit::Cover) // On error, show icon: // Icon::new(IconName::Image) // .size(px(48.)) // .text_color(cx.theme().muted_foreground) ) } ``` ### Loading States ```rust // Image with loading skeleton fn image_with_loading(src: &str, is_loading: bool) -> impl IntoElement { div() .w(px(400.)) .h(px(300.)) .rounded(px(8.)) .overflow_hidden() .map(|this| { if is_loading { this.bg(cx.theme().muted) .flex() .items_center() .justify_center() .child("Loading...") } else { this.child( img(src) .w_full() .h_full() .object_fit(ObjectFit::Cover) ) } }) } // Progressive loading with placeholder fn progressive_image(src: &str, placeholder_src: &str) -> impl IntoElement { div() .relative() .w(px(400.)) .h(px(300.)) .rounded(px(8.)) .overflow_hidden() .child( // Low-quality placeholder img(placeholder_src) .absolute() .inset_0() .w_full() .h_full() .object_fit(ObjectFit::Cover) .opacity(0.5) ) .child( // High-quality image img(src) .absolute() .inset_0() .w_full() .h_full() .object_fit(ObjectFit::Cover) ) } ``` ### Responsive Images ```rust // Responsive grid images fn responsive_image_grid() -> impl IntoElement { div() .grid() .grid_cols(3) .gap_4() .child( img("https://example.com/photo1.jpg") .w_full() .aspect_ratio(1.0) // Square aspect ratio .object_fit(ObjectFit::Cover) .rounded(px(8.)) ) .child( img("https://example.com/photo2.jpg") .w_full() .aspect_ratio(1.0) .object_fit(ObjectFit::Cover) .rounded(px(8.)) ) .child( img("https://example.com/photo3.jpg") .w_full() .aspect_ratio(1.0) .object_fit(ObjectFit::Cover) .rounded(px(8.)) ) } // Hero image with text overlay fn hero_image() -> impl IntoElement { div() .relative() .w_full() .h(px(500.)) .rounded(px(12.)) .overflow_hidden() .child( img("https://example.com/hero-image.jpg") .absolute() .inset_0() .w_full() .h_full() .object_fit(ObjectFit::Cover) ) .child( div() .absolute() .inset_0() .bg(rgba(0, 0, 0, 0.4)) // Dark overlay .flex() .items_center() .justify_center() .child( v_flex() .items_center() .gap_4() .child("Hero Title") .child("Subtitle text here") ) ) } ``` ### Image Gallery ```rust // Simple image gallery fn image_gallery(images: Vec<&str>) -> impl IntoElement { v_flex() .gap_6() .child( // Main image div() .w_full() .h(px(400.)) .rounded(px(12.)) .overflow_hidden() .child( img(images[0]) .w_full() .h_full() .object_fit(ObjectFit::Cover) ) ) .child( // Thumbnail row h_flex() .gap_3() .children( images.iter().map(|src| { div() .size(px(80.)) .rounded(px(6.)) .overflow_hidden() .border_2() .border_color(cx.theme().border) .cursor_pointer() .hover(|this| this.border_color(cx.theme().primary)) .child( img(*src) .size_full() .object_fit(ObjectFit::Cover) ) }) ) ) } ``` ### SVG Images ```rust // SVG icon with custom styling img("assets/icons/logo.svg") .size(px(64.)) .text_color(cx.theme().primary) // SVG color // Inline SVG handling img("data:image/svg+xml;base64,...") .w(px(32.)) .h(px(32.)) // SVG with animation-friendly setup img("assets/spinner.svg") .size(px(24.)) .text_color(cx.theme().primary) // Add rotation animation in practice ``` ## API Reference ### Core Image Function | Function | Description | | ------------- | ------------------------------------- | | `img(source)` | Create image element from ImageSource | ### Image Sources (ImageSource) | Type | Description | Example | | ----------- | ---------------------- | --------------------------------- | | String/&str | URL or file path | `"https://example.com/image.jpg"` | | SharedUri | Shared URI reference | `SharedUri::from("file://path")` | | Local Path | Local file system path | `"assets/logo.png"` | | Data URI | Base64 encoded image | `"data:image/png;base64,..."` | ### Sizing Methods | Method | Description | | --------------- | ------------------------- | | `w(length)` | Set width | | `h(length)` | Set height | | `size(length)` | Set both width and height | | `w_full()` | Full width of container | | `h_full()` | Full height of container | | `size_full()` | Full size of container | | `max_w(length)` | Maximum width | | `max_h(length)` | Maximum height | | `min_w(length)` | Minimum width | | `min_h(length)` | Minimum height | ### Object Fit Options | Value | Description | | ---------------------- | --------------------------------- | | `ObjectFit::Cover` | Scale to fill container, may crop | | `ObjectFit::Contain` | Scale to fit within container | | `ObjectFit::Fill` | Stretch to fill container | | `ObjectFit::ScaleDown` | Like contain, but never scale up | | `ObjectFit::None` | Original size | ### Styling Methods | Method | Description | | --------------------- | ----------------------- | | `rounded(radius)` | Border radius | | `border_1()` | 1px border | | `border_color(color)` | Border color | | `opacity(value)` | Image opacity (0.0-1.0) | | `shadow_sm()` | Small shadow | | `shadow_lg()` | Large shadow | ## Examples ### Product Image Card ```rust use gpui_kit::component::{v_flex, div, Icon, IconName}; fn product_card(image_src: &str, title: &str, price: &str) -> impl IntoElement { v_flex() .gap_3() .p_4() .bg(cx.theme().card) .rounded(px(12.)) .shadow_sm() .child( div() .relative() .w_full() .h(px(200.)) .rounded(px(8.)) .overflow_hidden() .bg(cx.theme().muted) .child( img(image_src) .w_full() .h_full() .object_fit(ObjectFit::Cover) ) .child( // Wishlist button div() .absolute() .top_2() .right_2() .size(px(32.)) .bg(rgba(255, 255, 255, 0.9)) .rounded_full() .flex() .items_center() .justify_center() .cursor_pointer() .child(Icon::new(IconName::Heart).size(px(16.))) ) ) .child(title) .child(price) } ``` ### Avatar with Image ```rust fn custom_avatar(src: &str, name: &str, size: f32) -> impl IntoElement { div() .size(px(size)) .rounded_full() .overflow_hidden() .border_2() .border_color(cx.theme().background) .shadow_sm() .child( img(src) .size_full() .object_fit(ObjectFit::Cover) ) } ``` ### Image Comparison Slider ```rust fn image_comparison(before_src: &str, after_src: &str) -> impl IntoElement { div() .relative() .w_full() .h(px(400.)) .rounded(px(12.)) .overflow_hidden() .child( // Before image img(before_src) .absolute() .inset_0() .w_full() .h_full() .object_fit(ObjectFit::Cover) ) .child( // After image with clip div() .absolute() .top_0() .left_0() .w(relative(0.5)) // Show 50% initially .h_full() .overflow_hidden() .child( img(after_src) .w(px(800.)) // Full width of container .h_full() .object_fit(ObjectFit::Cover) ) ) .child( // Separator line div() .absolute() .top_0() .left(relative(0.5)) .w(px(2.)) .h_full() .bg(cx.theme().primary) ) } ``` ### Error Handling Pattern ```rust enum ImageState { Loading, Loaded, Error, } fn robust_image(src: &str, state: ImageState) -> impl IntoElement { div() .w(px(300.)) .h(px(200.)) .bg(cx.theme().muted) .rounded(px(8.)) .border_1() .border_color(cx.theme().border) .flex() .items_center() .justify_center() .map(|this| { match state { ImageState::Loading => { this.child( v_flex() .items_center() .gap_2() .child(Icon::new(IconName::Loader2).size(px(24.))) .child("Loading...") ) } ImageState::Loaded => { this.p_0() .overflow_hidden() .child( img(src) .w_full() .h_full() .object_fit(ObjectFit::Cover) ) } ImageState::Error => { this.child( v_flex() .items_center() .gap_2() .child( Icon::new(IconName::ImageOff) .size(px(32.)) .text_color(cx.theme().muted_foreground) ) .child("Failed to load image") ) } } }) } ``` ## Best Practices ### Image Optimization - Use appropriate image dimensions for display size - Compress images without sacrificing quality - Consider using modern image formats (WebP, AVIF) - Implement responsive images for different screen sizes ### Error Handling - Always provide meaningful fallbacks for failed image loads - Use skeleton loading states to maintain layout stability - Implement retry mechanisms for temporary network failures - Provide user feedback for permanent load failures ### Performance - Use lazy loading for images not immediately visible - Implement proper caching strategies - Consider using placeholder images during loading - Optimize image sizes for their display context ### User Experience - Maintain consistent aspect ratios in image grids - Provide smooth loading transitions - Use appropriate object-fit values for content type - Consider providing zoom functionality for detailed images ## Implementation Notes ### GPUI Integration - Built on GPUI's native image rendering capabilities - Supports all GPUI ImageSource types automatically - Inherits GPUI's styling and layout system - Compatible with GPUI's animation and interaction systems ### SVG Support - Full support for SVG graphics with proper scaling - SVG images can be styled with text colors for theming - Vector graphics maintain sharpness at all sizes - Supports both external SVG files and inline data URIs ### Memory Management - GPUI handles image caching and memory management automatically - Large images are efficiently managed by the graphics backend - No manual memory cleanup required for image components ### Cross-Platform Compatibility - Consistent behavior across Windows, macOS, and Linux - Native image format support varies by platform - Uses platform-optimized rendering where available --- # TitleBar Source: /versions/v0.6.4/component/title-bar 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 --- # Carousel Source: /versions/v0.6.4/component/carousel 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. --- # Alert Source: /versions/v0.6.4/component/alert 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 --- # Toolbar Source: /versions/v0.6.4/component/toolbar 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. --- # Tag Source: /versions/v0.6.4/component/tag 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 --- # Button Source: /versions/v0.6.4/component/button 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 --- # Marker Source: /versions/v0.6.4/component/marker `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, 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. | | 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. ## 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. | | `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`. - [`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 [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 --- # Textarea Source: /versions/v0.6.4/component/textarea `Textarea` is the styled control for ordinary multi-line text. Use [`Input`](/versions/v0.6.4/component/input) for a single line and [`Editor`](/versions/v0.6.4/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`. ## 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. --- # Select Source: /versions/v0.6.4/component/select 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 --- # Input Group Source: /versions/v0.6.4/component/input-group 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.6.4/component/input). The examples below define views for an initialized GPUI Kit application. See [Getting Started](/versions/v0.6.4/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.6.4/component/input) placed in the group, using `InputState` | | `InputGroupTextarea` | A [Textarea](/versions/v0.6.4/component/textarea) placed in the group, using `TextareaState` | | `InputGroupAddon` | Position text, icons, buttons, or custom content | | `InputGroupButton` | A [Button](/versions/v0.6.4/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.6.4/component/menu); `.dropdown_caret(true)` draws the caret after the label. For contextual help, pass an `InputGroupButton` to [Popover](/versions/v0.6.4/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.6.4/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.6.4/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.6.4/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.6.4/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. --- # Avatar Source: /versions/v0.6.4/component/avatar 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 --- # Editor Source: /versions/v0.6.4/component/editor `Editor` is the styled source-code control. Use [`Input`](/versions/v0.6.4/component/input) for single-line values and [`Textarea`](/versions/v0.6.4/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` alive while the decorations are needed. Its ranges follow subsequent text edits. ## 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`. --- # Tree Source: /versions/v0.6.4/component/tree 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 })) ``` --- # Checkbox Source: /versions/v0.6.4/component/checkbox 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 --- # OtpInput Source: /versions/v0.6.4/component/otp-input 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 ``` --- # StatusBar Source: /versions/v0.6.4/component/status-bar 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`. --- # Clipboard Source: /versions/v0.6.4/component/clipboard 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. ## 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") .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 --- # Label Source: /versions/v0.6.4/component/label 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")) ``` --- # Questionnaire Source: /versions/v0.6.4/component/questionnaire `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 answers it and makes `Next` available. `with_multiple` keeps every selected choice instead. 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 `activate_choice`, `confirm_current`, `go_previous`, `go_next`, `skip_current`, and `submit` for user intent. 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. 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 --- # Progress Source: /versions/v0.6.4/component/progress 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"), ) } } ``` --- # Sheet Source: /versions/v0.6.4/component/sheet 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 ### Setup application root view for display of sheets You need to set up your application's root view to render the sheet layer. This is typically done in your main application struct's render method. The [Root::render_sheet_layer](https://docs.rs/gpui-component/latest/gpui_component/struct.Root.html#method.render_sheet_layer) function handles rendering any active modals on top of your app content. ```rust use gpui_kit::component::TitleBar; struct MyApp { view: AnyView, } impl Render for MyApp { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let sheet_layer = Root::render_sheet_layer(window, cx); div() .size_full() .child( v_flex() .size_full() .child(TitleBar::new()) .child(div().flex_1().overflow_hidden().child(self.view.clone())), ) // Render the sheet layer on top of the app content .children(sheet_layer) } } ``` ### 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 --- # DropdownButton Source: /versions/v0.6.4/component/dropdown_button 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 --- # VirtualList Source: /versions/v0.6.4/component/virtual-list 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 --- # Kbd Source: /versions/v0.6.4/component/kbd 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. --- # Tooltip Source: /versions/v0.6.4/component/tooltip 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](/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 ``` --- # Plot Source: /versions/v0.6.4/component/plot 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. ## 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) vec![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 vec![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 vec![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()).collect(), vec![0., width], ); let y = ScaleLinear::new(vec![0., max_value], vec![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); } } ``` --- # MessageScroller Source: /versions/v0.6.4/component/message-scroller `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 --- # Components Source: /versions/v0.6.4/component ### 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 - [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 - [OtpInput](otp-input) - One-time password input - [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 ### 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 --- # Skeleton Source: /versions/v0.6.4/component/skeleton 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. --- # Chart Source: /versions/v0.6.4/component/chart 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) ``` ### 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 control how many even intervals the scale is divided into with `value_tick_count`. The count drives both the grid line spacing and the tick labels, so the two always agree. Note that `value_tick_count` is a count, whereas `tick_margin` 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) // Divide the value scale into 6 intervals instead of the default 4 BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .value_axis(true) .value_tick_count(6) ``` ### 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() ``` ### 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 is a static plot until it is given an `id`. With one, it hit-tests the cursor, shows a tooltip for the datum under it, and emphasizes that datum the way the chart's kind calls for: ```rust LineChart::new(data) .x(|d| d.date.clone()) .y(|d| d.value) .name("Desktop") // The series name in the tooltip row .id("visitors") // Unique among sibling elements ``` | 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. | | `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 label and throughput. | 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()`. ### Motion The emphasis is animated with the styled layer's motion tokens (`cx.theme().motion_tokens()`): 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. ### Caching An identified chart also keeps its heavy geometry across frames, since a chart 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. Charts without an `id` rebuild everything on each paint, as sibling charts would otherwise share one cache. ### Custom Plots A custom [`Plot`] opts in the same way: return the id from `Plot::id`, resolve the datum under the cursor in `Plot::tooltip_state`, and build the overlay in `Plot::tooltip`. To animate the emphasis, implement `Plot::hover`, which runs each frame before `tooltip` and `paint` with the [`PlotHover`] in focus — it carries the `TooltipState` and lingers after the cursor leaves while `hover.focus()` eases back to zero, so sample the motion there and keep the result on `self` for the other two methods. A `Tooltip` returned from `tooltip` fades with the hover on its own: ```rust fn hover(&mut self, hover: Option<&PlotHover>, window: &mut Window, cx: &mut App) { self.band_center = hover.map(|hover| { spring( ("my-plot", "band"), hover.state().cross_line.x, // Adopt the datum on the first hovered frame instead of travelling // from where the last hover ended. cx.theme().motion_tokens().spring_control.with_travel(!hover.is_entering()), window, cx, ) }); } fn tooltip(&self, state: &TooltipState, cursor: Point, bounds: Bounds, _: &mut Window, cx: &mut App) -> Option { let center = self.band_center.unwrap_or(state.cross_line.x); Some( Tooltip::new(cursor, bounds.size) .cross_line(CrossLine::new(point(center, state.cross_line.y)).band(px(24.))) .title("Title") .row(cx.theme().chart_1, "Series", "42") .into_any_element(), ) } ``` `Dot::halo(size)` draws the translucent ring the built-in charts put behind a hovered dot. ## 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 - 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 --- # Accordion Source: /versions/v0.6.4/component/accordion 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 --- # Calendar Source: /versions/v0.6.4/component/calendar 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 --- # Rating Source: /versions/v0.6.4/component/rating 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 --- # HoverCard Source: /versions/v0.6.4/component/hover-card 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](/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 --- # Badge Source: /versions/v0.6.4/component/badge 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 --- # Dock Source: /versions/v0.6.4/component/dock 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](/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. --- # Empty Source: /versions/v0.6.4/component/empty `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. --- # Spinner Source: /versions/v0.6.4/component/spinner 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...") ) ``` --- # Toggle Source: /versions/v0.6.4/component/toggle 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 --- # Styling Source: /versions/v0.6.4/shell/styling 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. --- # The Engine Seam Source: /versions/v0.6.4/shell/engine 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 built when application state moves and [replayed by every frame until it moves again](/versions/v0.6.4/shell/state#when-render-runs), so the cost below is paid per user action 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.6.4/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** | once per application change | | **B** | Snapshot → GPUI elements | **0.7 ms** | every frame | | **C** | a full cached repaint | **1.8 ms**, **no JavaScript at all** | every frame | 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 is the one that is an assertion rather than a timing.** Fifty repaints of an unchanged View run no JavaScript at all. If a single one of them ever does, the runtime has regressed to charging script cost per frame, and the benchmark fails rather than merely getting slower. 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 whole frame — B plus GPUI's layout and paint, which is what C measures — costs 1.3, 5.9, 12.0 and 27.0 ms. Both scale close to linearly with the node count. What does not scale is the JavaScript: no frame at any size runs a line of it. Three things that settles: - **4,203 nodes is where the Snapshot decides the outcome.** 12 ms a frame holds 60 FPS; rebuilding the description for every frame would cost 22 ms and drop them. Below that size both models have room to spare, which is worth knowing before reading too much into the ratio. - **The description cost did not vanish, it moved.** 20 ms for 8,403 nodes is paid when the user acts rather than sixty times a second, but it is still 20 ms — which is why the per-call cost remains the number a second engine would be judged on. - **Past a few thousand nodes the bill is not script at all.** 27 ms a frame at that size, with the VM untouched, is materialization, layout and paint. A View that large wants virtualizing; a faster engine would not move it. 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. At 120 FPS the old model would have spent 168 ms of every second describing an interface nobody had changed; the same panel now costs 1.4 ms when the user actually changes something, and 0.7 ms to repaint. 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.6.4/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.6.4/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. The target was 120 FPS, which gives each frame **8.33 ms**. 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.6.4/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. --- # Overlays Source: /versions/v0.6.4/shell/overlays 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.6.4/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.6.4/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. --- # Dependencies Source: /versions/v0.6.4/shell/dependencies 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.6.4/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.6.4/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.6.4/shell/capabilities) | The rest of the manifest: identity, versions, and what a script may reach | | [Getting Started](/versions/v0.6.4/shell/getting-started) | `gpui-shell types`, `check`, and the declarations a dependency joins | | [API Reference](/versions/v0.6.4/shell/api) | The built-in modules a package imports alongside yours | --- # Elements Source: /versions/v0.6.4/shell/elements 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.6.4/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.6.4/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.6.4/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.6.4/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.6.4/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.6.4/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.6.4/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. --- # Capabilities Source: /versions/v0.6.4/shell/capabilities 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.6.4/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.6.0", "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.6.4/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. --- # Performance Source: /versions/v0.6.4/shell/performance [The script is not in the frame](/versions/v0.6.4/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 frame rate. A window repainting at 120 Hz runs no more JavaScript than one repainting at 30 Hz, and a View nobody has invalidated runs none at all. Both factors are yours: the left one is where you call `cx.notify()`, the right one is how much interface sits behind a single call. 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.** Every frame in between is drawn from it — turned into GPUI elements, laid out, painted — entirely in Rust. No JavaScript runs. ```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.6.4/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. - **Notifying more often than the frame rate costs nothing extra.** See below — batching by hand buys nothing, conditioning does. - **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.6.4/shell/host-module), the description is stale and only `refresh` says so. See [Hosting](/versions/v0.6.4/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. **That puts a ceiling on what invalidation can cost: at most one script render per View per frame.** A feed ticking a thousand times a second costs at most 120 renders a second on a 120 Hz display, not a thousand. It is why an over-eager `notify` shows up as wasted work rather than as a runaway. The runtime adds no throttle of its own on top of that, and there is none to tune. The coalescing is GPUI's own scheduling, and it never defers a rebuild past the next frame — so it costs no latency, which is the other half of the pair below. ### 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()` costs no frames at all. GPUI keeps replaying the last good description at full rate, so the HUD reads a steady 120 FPS while the interface is showing something that stopped being true — and then jumps a quarter of a second later, when something unrelated invalidates the View. Every rendering measurement calls this healthy. | Symptom | Which number is wrong | Usual cause | | --- | --- | --- | | The window stutters while nothing in the application is changing | FPS | Description too large per frame, or a virtual list doing per-row work; see [the measurement](/versions/v0.6.4/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.6.4/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.6.4/shell/elements) item renderers and [dock](/versions/v0.6.4/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. --- # Examples Source: /versions/v0.6.4/shell/examples 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.6.4/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.6.4/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.6.4/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.6.4/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.6.4/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.6.4/shell/hosting) walks through the same calls. --- # Getting Started Source: /versions/v0.6.4/shell/getting-started `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.6.4/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.6.4/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.6.4/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.6.4/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 | --- # Hosting Source: /versions/v0.6.4/shell/hosting [Getting Started](/versions/v0.6.4/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.6.4/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.6.4/shell/capabilities) and [HostModule](/versions/v0.6.4/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.6.4/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.6.4/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. --- # State and Views Source: /versions/v0.6.4/shell/state 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.6.4/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 repaints for reasons your application never hears about — a pointer moving over a button, a text cursor blinking, a list scrolling, an animation advancing — and none of those are a reason to run JavaScript. 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.6.4/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.6.4/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 `render` cost follows your users, not your frame rate.** A View that changes ten times a second costs ten renders a second, whether the window is repainting at 60 FPS or 120. Describing a large panel is affordable precisely because it is not being redescribed sixty times for no reason. **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.6.4/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.6.4/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.6.4/shell/host-module [Capabilities](/versions/v0.6.4/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.6.4/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.6.4/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. --- # API Reference Source: /versions/v0.6.4/shell/api 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.6.4/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.6.4/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.6.4/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.6.4/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.6.4/shell/styling). Each name links to the component's own page in the [gpui-base documentation](/versions/v0.6.4/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.6.4/base/primitives/resizable) | A row of panes with draggable dividers; sizes live in the window under the id | | [`v_resizable(id)`](/versions/v0.6.4/base/primitives/resizable) | The same, stacked | | [`resizable_panel()`](/versions/v0.6.4/base/primitives/resizable) | One pane of a resizable group, and legal nowhere else | ### Controls | Name | What it is | | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | [`Button`](/versions/v0.6.4/base/primitives/button) | Activation, focus, disabled and selected state | | [`Link`](/versions/v0.6.4/base/primitives/link) | An external HTTP(S) resource opened through the system browser | | [`Checkbox`](/versions/v0.6.4/base/primitives/checkbox) | A controlled toggle; draw the indicator yourself | | [`Switch`](/versions/v0.6.4/base/primitives/switch) | A controlled switch | | [`Radio`](/versions/v0.6.4/base/primitives/radio) | One option in a group; reports `true` only, never a deselection | | [`Toggle`](/versions/v0.6.4/base/primitives/toggle) | A button that stays down | | [`RadioGroup`](/versions/v0.6.4/base/primitives/radio-group) | A set of radios announced as one group; holds no selection | | [`ToggleGroup`](/versions/v0.6.4/base/primitives/toggle-group) | A set of toggles announced as a toolbar | | [`Tabs`](/versions/v0.6.4/base/primitives/tabs) | A tab list that holds no selection of its own | | [`Tab`](/versions/v0.6.4/base/primitives/tabs) | One tab: `selected(...)` in, `on_click(...)` out | | [`Progress`](/versions/v0.6.4/base/primitives/progress) | The announcement, not the bar; `Progress.new(...)` alone draws nothing | | [`ProgressTrack`](/versions/v0.6.4/base/primitives/progress) | The groove: a plain element you size and color | | [`ProgressIndicator`](/versions/v0.6.4/base/primitives/progress) | The filled part; set its width from the percentage you announced | | [`Avatar`](/versions/v0.6.4/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.6.4/base/primitives/avatar) | The image slot: `AvatarImage.new(path)`, and legal nowhere else | | [`AvatarFallback`](/versions/v0.6.4/base/primitives/avatar) | The fallback slot: an ordinary box holding initials, a shape or an `svg` | | [`Pagination`](/versions/v0.6.4/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.6.4/base/primitives/accordion) | A group holding items | | [`AccordionItem`](/versions/v0.6.4/base/primitives/accordion) | One item: `open(...)` in, the trigger's `on_change(...)` out; it passes its `open` down to both halves | | [`AccordionHeader`](/versions/v0.6.4/base/primitives/accordion) | The heading: `AccordionHeader.new(trigger)`, with `aria_level(n)` announcing its level (default 3) | | [`AccordionPanel`](/versions/v0.6.4/base/primitives/accordion) | The revealed region. Out of the tree while shut, unless `keep_mounted(true)` | | [`AccordionTrigger`](/versions/v0.6.4/base/primitives/accordion) | The button: announces the expanded state, and `on_change` asks for the other one | | [`CalendarState`](/versions/v0.6.4/base/primitives/calendar) | Retained calendar state: the month grid, the month being shown, and the chosen date | | [`SliderState`](/versions/v0.6.4/base/primitives/slider) | Retained slider state, and where a drag writes | | [`Slider`](/versions/v0.6.4/base/primitives/slider) | The root: announces the value and owns the release | | [`SliderTrack`](/versions/v0.6.4/base/primitives/slider) | The press and drag surface | | [`SliderIndicator`](/versions/v0.6.4/base/primitives/slider) | The groove, and the box every pointer position is measured against | | [`SliderThumb`](/versions/v0.6.4/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.6.4/base/primitives/input) | Retained text state: `InputState.new({ placeholder, value })` | | [`Input`](/versions/v0.6.4/base/primitives/input) | The frame around retained text state | | [`NumberInput`](/versions/v0.6.4/base/primitives/number-input) | A spinbutton over the same `InputState`, with three slots that all carry weight | | [`TextareaState`](/versions/v0.6.4/base/primitives/textarea) | Retained multi-line text state; `rows` is an option | | [`Textarea`](/versions/v0.6.4/base/primitives/textarea) | The frame around retained multi-line state | | [`OtpState`](/versions/v0.6.4/base/primitives/otp-input) | Retained one-time-code state; the length is fixed when it is created | | [`OtpInput`](/versions/v0.6.4/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.6.4/base/primitives/collapsible) | Renders its `content` slot only while `open`; no role, chevron or trigger | | [`Popover`](/versions/v0.6.4/base/primitives/popover) | A surface anchored to a trigger and opened by a press | | [`HoverCard`](/versions/v0.6.4/base/primitives/hover-card) | The same, opened by resting the pointer, with its own open state | | [`Popup`](/versions/v0.6.4/base/primitives/popup) | The bare anchored surface: `Popup.new(id, trigger)`, opened by filling `content` | | [`Select`](/versions/v0.6.4/base/primitives/select) | A combobox root: the role, the announced open state, the keyboard — none of the picture | | [`Combobox`](/versions/v0.6.4/base/primitives/combobox) | The same root, announced as a combobox whose trigger is an editable field | | [`DatePicker`](/versions/v0.6.4/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.6.4/base/primitives/table) | A semantic table root, composed the way HTML composes one | | [`TableHeader`](/versions/v0.6.4/base/primitives/table) | The header row group | | [`TableBody`](/versions/v0.6.4/base/primitives/table) | The body row group | | [`TableRow`](/versions/v0.6.4/base/primitives/table) | One row: `.new(id, row_index)`, one-based | | [`TableHead`](/versions/v0.6.4/base/primitives/table) | One column header: `.new(id, column_index)`, one-based | | [`TableCell`](/versions/v0.6.4/base/primitives/table) | One data cell: `.new(id, column_index)`, one-based | | [`TableCaption`](/versions/v0.6.4/base/primitives/table) | The visual slot a caption belongs in; it carries no caption role | | [`v_virtual_list(…)`](/versions/v0.6.4/base/virtual-list) | A vertical list that describes only what is on screen | | [`h_virtual_list(…)`](/versions/v0.6.4/base/virtual-list) | The same along the other axis; `item_sizes` are widths | | [`VirtualListScrollHandle`](/versions/v0.6.4/base/virtual-list) | A virtual list's scroll position, kept across frames | | [`Scrollbar`](/versions/v0.6.4/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.6.4/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.6.4/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.6.4/shell/host-module). --- # GPUI Shell Source: /versions/v0.6.4/shell `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 never renders. It describes an interface once, and Rust replays that description into real GPUI elements on every frame after it — the same element model a Rust application on `gpui-base` builds, through the same GPU renderer. JavaScript is the application layer here, not the rendering layer, which is why a repaint costs no JavaScript at all and taking the whole runtime costs [+13.5 MiB of binary](/versions/v0.6.4/engine#what-linking-it-costs). Both goals rest on the same split. `gpui-shell` is built directly on [`gpui-base`](/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.6.4/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.6.4/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 the interface once into a Snapshot, and until the next `cx.notify()` every repaint replays that Snapshot in Rust. A pointer crossing a button, a blinking cursor, a scrolling list and a native transition or spring advancing do not run JavaScript. The runtime counts the two events separately, and the gallery's Shell story (`cargo run -- shell`) puts both counters on screen: One second of a live panel. With nothing JavaScript reads changing, 60 frames fire and the JavaScript track stays empty. With prices moving every 50 ms, 60 frames fire and JavaScript runs about 20 times. One second of a live panel. With nothing JavaScript reads changing, 60 frames fire and the JavaScript track stays empty. With prices moving every 50 ms, 60 frames fire and JavaScript runs about 20 times. | 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 | The frame count belongs to the display, the JavaScript count to the data. In the second row the other 41 frames replay a description that already exists. Cost is therefore paid per user action rather than per frame. On a 443-node panel, running `render` and recording the whole interface into a Snapshot takes 1.1 ms, paid only when state changes; each frame after it takes 1.3 ms, which is rendering itself — turning the Snapshot into elements, laying out, painting, with no JavaScript in it. | | Cost per 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 that. The [benchmark](/versions/v0.6.4/engine#the-measurement) covers sizes up to 8,403 nodes, no frame at any of them runs JavaScript, and the smallest size is asserted on every CI build. ### 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.6.4/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.6.4/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.6.4/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.6.4/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.6.4/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.6.4/getting-started) | Running the example, the smallest application, `check` and `types` | | [Examples](/versions/v0.6.4/examples) | The two applications in the repository, and what to copy from them | | [Elements](/versions/v0.6.4/elements) | Constructors, `child` / `children` / `when`, and why an element is single-use | | [Styling](/versions/v0.6.4/styling) | The fluent style surface, lengths, colour tokens and state styles | | [State and Views](/versions/v0.6.4/state) | `init` / `render`, `cx.notify()`, retained state, async | | [Overlays](/versions/v0.6.4/overlays) | Dialogs, the sheet, toasts, and the phase rule | | [Capabilities](/versions/v0.6.4/capabilities) | `gpui-shell.json`, default deny, filesystem, storage, process and network APIs | | [Dependencies](/versions/v0.6.4/dependencies) | Shell packages: what makes one, how a manifest names and pins it, and the types an editor gets | | [Hosting](/versions/v0.6.4/hosting) | The Rust side in full: mounting, refreshing, metrics, exit, hot-reload | | [HostModule](/versions/v0.6.4/host-module) | Lending the host's own Rust to a script, and the plain-data boundary | | [Dock and Panels](/versions/v0.6.4/dock) | A script View as a dockable panel, the chrome you draw for it, and what survives a restart | | [Performance](/versions/v0.6.4/performance) | What a script costs: invalidation against description size, the View as the boundary, and the counters | | [The engine seam](/versions/v0.6.4/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). --- # Dock and Panels Source: /versions/v0.6.4/shell/dock 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. --- # TextView Source: /versions/v0.6.4/base/text-view `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.6.4/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. `gpui-component::Root` also installs the window selection layer. 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) ``` `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())`. ## 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. 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.6.4/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 ``` --- # History Source: /versions/v0.6.4/base/history `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. | --- # Motion Source: /versions/v0.6.4/base/motion `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,ignore 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,ignore 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. --- # Text Selection Source: /versions/v0.6.4/base/text-selection `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. --- # Table Source: /versions/v0.6.4/base/primitives/table 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. --- # Pagination Source: /versions/v0.6.4/base/primitives/pagination 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. --- # Link Source: /versions/v0.6.4/base/primitives/link 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. --- # Scrollbar Source: /versions/v0.6.4/base/primitives/scrollbar `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. --- # Combobox Source: /versions/v0.6.4/base/primitives/combobox 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("⌄")), ); let popup = div() .w_56() .p_1() .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_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().mt_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. --- # Radio Source: /versions/v0.6.4/base/primitives/radio 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. --- # Tabs Source: /versions/v0.6.4/base/primitives/tabs 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. --- # Dialog Source: /versions/v0.6.4/base/primitives/dialog 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() .absolute() .inset_0() .flex() .items_center() .justify_center() .child( div() .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. --- # Number Input Source: /versions/v0.6.4/base/primitives/number-input 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. --- # Alert Dialog Source: /versions/v0.6.4/base/primitives/alert-dialog 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() .flex() .items_center() .justify_center() .child( div() .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. --- # Slider Source: /versions/v0.6.4/base/primitives/slider 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. --- # Color Picker Source: /versions/v0.6.4/base/primitives/color-picker 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(if open { "⌃" } else { "⌄" }); 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() .flex() .items_center() .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. --- # Popover Source: /versions/v0.6.4/base/primitives/popover 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 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. --- # Resizable Source: /versions/v0.6.4/base/primitives/resizable 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. ## 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. --- # Collapsible Source: /versions/v0.6.4/base/primitives/collapsible 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. --- # Date Picker Source: /versions/v0.6.4/base/primitives/date-picker 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("⌄"); 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. --- # Input Source: /versions/v0.6.4/base/primitives/input `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.6.4/base/primitives/textarea) for ordinary multi-line text and [Editor](/versions/v0.6.4/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.6.4/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). --- # Switch Source: /versions/v0.6.4/base/primitives/switch 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. --- # Popup Source: /versions/v0.6.4/base/primitives/popup `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.6.4/base/primitives/nav-stack 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.6.4/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. --- # Button Source: /versions/v0.6.4/base/primitives/button 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. --- # Toast Source: /versions/v0.6.4/base/primitives/toast 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. --- # Textarea Source: /versions/v0.6.4/base/primitives/textarea `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.6.4/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.6.4/component/textarea). ## Runnable example ```bash cargo run -p gpui-base-examples -- textarea ``` --- # Select Source: /versions/v0.6.4/base/primitives/select 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(if open { "⌃" } else { "⌄" }); 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. --- # Avatar Source: /versions/v0.6.4/base/primitives/avatar 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. --- # Editor Source: /versions/v0.6.4/base/primitives/editor `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.6.4/base/primitives/input) for single-line values and [Textarea](/versions/v0.6.4/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.6.4/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.6.4/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.6.4/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 collection alive for as long as its decorations should remain active. ## 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.6.4/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.6.4/component/editor). ## Runnable example ```bash cargo run -p gpui-base-examples -- editor ``` --- # Tree Source: /versions/v0.6.4/base/primitives/tree 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. --- # Checkbox Source: /versions/v0.6.4/base/primitives/checkbox 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. --- # OTP Input Source: /versions/v0.6.4/base/primitives/otp-input 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. --- # Toggle Group Source: /versions/v0.6.4/base/primitives/toggle-group 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. --- # Progress Source: /versions/v0.6.4/base/primitives/progress 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. --- # Sheet Source: /versions/v0.6.4/base/primitives/sheet 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. --- # Tooltip Source: /versions/v0.6.4/base/primitives/tooltip 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. --- # Primitives Source: /versions/v0.6.4/base/primitives 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.6.4/base/accordion) — A disclosure group composed from independently styleable header, trigger, and panel parts. - [Alert Dialog](/versions/v0.6.4/base/alert-dialog) — A modal confirmation surface for actions that need an explicit decision. - [Avatar](/versions/v0.6.4/base/avatar) — An image with composable fallback content for a person or entity. - [Button](/versions/v0.6.4/base/button) — An unstyled, accessible pressable with semantic state and keyboard activation. - [Calendar](/versions/v0.6.4/base/calendar) — A state-driven date grid with selection matchers and custom item rendering. - [Checkbox](/versions/v0.6.4/base/checkbox) — A controlled tri-state check control with a separately styled indicator. - [Collapsible](/versions/v0.6.4/base/collapsible) — A composable region that shows or hides content without prescribing its trigger styling. - [Color Picker](/versions/v0.6.4/base/color-picker) — State and interaction foundations for selecting colors in a custom picker UI. - [Combobox](/versions/v0.6.4/base/combobox) — A text input paired with keyboard-navigable suggestions and selection behavior. - [Date Picker](/versions/v0.6.4/base/date-picker) — A focus-aware date input that composes calendar behavior with a popup. - [Dialog](/versions/v0.6.4/base/dialog) — A composable modal surface with focus management, backdrop, title, and close parts. - [Hover Card](/versions/v0.6.4/base/hover-card) — A delayed floating card associated with a pointer or keyboard trigger. - [Input](/versions/v0.6.4/base/input) — A single-line text input with selection, masking, validation, and number stepping. - [Textarea](/versions/v0.6.4/base/textarea) — A multi-line text field with fixed rows, wrapping, and auto-grow behavior. - [Editor](/versions/v0.6.4/base/editor) — A source-code editor foundation with highlighting, gutter, folding, decorations, and LSP hooks. - [Link](/versions/v0.6.4/base/link) — An accessible link-like control with application-defined styling. - [Nav Stack](/versions/v0.6.4/base/nav-stack) — A navigation stack of views with push, pop, forward, and replace, and an animatable transition lifecycle. - [Number Input](/versions/v0.6.4/base/number-input) — A numeric input with reusable increment, decrement, and step behavior. - [OTP Input](/versions/v0.6.4/base/otp-input) — A multi-cell one-time-code input driven by a shared text state. - [Pagination](/versions/v0.6.4/base/pagination) — A controlled page navigator with explicit current and total page state. - [Popover](/versions/v0.6.4/base/popover) — An anchored floating surface with controlled or internally managed open state. - [Popup](/versions/v0.6.4/base/popup) — A low-level trigger and anchored floating-content host. - [Progress](/versions/v0.6.4/base/progress) — Composable track and indicator parts for reporting task completion. - [Radio](/versions/v0.6.4/base/radio) — A controlled single-choice item with selectable and disabled semantics. - [Radio Group](/versions/v0.6.4/base/radio-group) — Groups radio items and provides keyboard navigation for a single selection. - [Resizable](/versions/v0.6.4/base/resizable) — Panel groups and resize handles for user-adjustable split layouts. - [Scrollbar](/versions/v0.6.4/base/scrollbar) — An unstyled scrollbar connected to GPUI scroll or uniform-list handles. - [Select](/versions/v0.6.4/base/select) — A button-like selection control backed by an anchored, keyboard-navigable popup. - [Sheet](/versions/v0.6.4/base/sheet) — A modal surface that enters from an edge while managing dismissal and focus. - [Slider](/versions/v0.6.4/base/slider) — A state-driven range input with independently styleable track, indicator, and thumb. - [Switch](/versions/v0.6.4/base/switch) — A controlled on/off control with separately styleable track and thumb. - [Table](/versions/v0.6.4/base/table) — Semantic table primitives for composing headers, bodies, rows, and cells. - [Tabs](/versions/v0.6.4/base/tabs) — A tab list and accessible tab controls with controlled selection. - [Toast](/versions/v0.6.4/base/toast) — A managed, animated stack of temporary status messages. - [Toggle](/versions/v0.6.4/base/toggle) — A controlled two-state pressable for persistent choices such as formatting. - [Toggle Group](/versions/v0.6.4/base/toggle-group) — Coordinates a set of toggle controls as a single- or multiple-selection group. - [Tooltip](/versions/v0.6.4/base/tooltip) — A delayed, positioned description associated with a trigger element. - [Tree](/versions/v0.6.4/base/tree) — A virtualized hierarchical list with explicit expansion and selection state. --- # Radio Group Source: /versions/v0.6.4/base/primitives/radio-group 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. --- # Accordion Source: /versions/v0.6.4/base/primitives/accordion 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. --- # Calendar Source: /versions/v0.6.4/base/primitives/calendar 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. --- # Hover Card Source: /versions/v0.6.4/base/primitives/hover-card 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. --- # Toggle Source: /versions/v0.6.4/base/primitives/toggle 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. --- # Getting Started Source: /versions/v0.6.4/base/getting-started ## 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.6.4/base/primitives). The website compiles the same showcase for `wasm32-unknown-unknown` and loads it on each primitive page. --- # VirtualList Source: /versions/v0.6.4/base/virtual-list # 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. --- # GPUI Base Source: /versions/v0.6.4/base `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.6.4/getting-started), render selectable Markdown and HTML with [TextView](/versions/v0.6.4/text-view), learn how to add [window-level text selection](/versions/v0.6.4/text-selection) to custom renderers, then explore the [primitive catalog](/versions/v0.6.4/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.6.4/motion) provides typed transitions, springs, keyframes, presence, and sequencing. [Virtual List](/versions/v0.6.4/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.6.4/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.6.4/history) covers two smaller, deliberately distinct structures: `History` is a root/current/back/forward navigation trail, while `UndoHistory` records grouped undo and redo transactions. --- # Dock Source: /versions/v0.6.4/base/dock 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. --- # Entity Source: /versions/v0.6.4/zh-CN/docs/entity 当一份状态需要由多个 View、handler 或异步任务共同使用时,把它放进 GPUI 提供的 `Entity`。例如,Chat 可以用 `Entity` 保存消息;任何持有其 clone 的代码都能通过 GPUI Context 访问同一份 Chat。 使用 `cx.new` 创建 Entity,使用 `read` 读取状态,使用 `update` 修改状态。当 `Chat` 实现 `Render` 时,`Entity` 还可以直接作为 View 渲染;不需要渲染时,它就是一个共享状态 model。 ```text Entity ├── read(cx) → &Chat ├── update(cx, …) → &mut Chat + Context └── downgrade() → WeakEntity ``` clone Entity 只会复制句柄,不会复制其中的状态。Entity 只能通过 GPUI Context 访问,因此 GPUI 可以统一协调状态更新、渲染、订阅和 Entity 生命周期。 ## 创建 Entity 可以在任意 GPUI context 中使用 `cx.new`: ```rs struct Chat { messages: Vec, } let chat: Entity = cx.new(|_cx| Chat { messages: Vec::new(), }); ``` 闭包会收到 `Context`,因此初始化时也可以创建子 Entity 或注册订阅。 当子 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 和 Zed 广泛采用这种强所有权关系:父 View 持有它所渲染、协调的子 View 或 model。 ## 读取状态 直接同步访问状态时使用 `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() }); ``` ## 更新状态 使用 `update` 获得可变状态和对应的 `Context`: ```rs chat.update(cx, |chat, cx| { chat.messages.push("Hello".into()); cx.notify(); }); ``` `cx.notify()` 表示这个 Entity 发生了变化,渲染过或正在观察它的 View 随后可以更新。仅修改字段不会自动产生通知;当新状态需要反映到观察者或界面上时,应调用它。 始终使用 `update` 闭包传入的内部 `cx`。它是当前 Entity 的 `Context`。 同一个 Entity 正在 update 或 render 时,不要再次对它调用 `read` 或 `update`。GPUI 会阻止这种重入访问并 panic。应该直接使用当前回调已经提供的 `&mut T`,或者先结束本次 update,再开始下一次访问。 ## 使用 WeakEntity 表示反向引用和 callback clone `Entity` 会创建另一个强句柄,并让 Entity 继续存活。当一段关系不应该拥有目标时,应使用 `WeakEntity`,例如子 View 反向引用父 View,或长时间运行的 callback 引用某个 View。 ```rs struct ChatSidebar { workspace: WeakEntity, } let workspace = cx.weak_entity(); let sidebar = cx.new(|_cx| ChatSidebar { workspace }); ``` 弱句柄可能比目标存活得更久。可以尝试 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 可能已经释放。Zed 和 gpui-kit 会在异步任务、callback、delegate 和父级引用中使用这一模式,避免这些关系意外延长 View 的生命周期。 ## 观察变化与订阅 Event 一个 Entity 可以通过两种相关方式与另一个 Entity 协作: - `cx.observe(&entity, ...)` 在目标 Entity 调用 `cx.notify()` 时执行。只关心“状态变了”时使用。 - `cx.subscribe(&entity, ...)` 接收类型化 [Event]。需要知道变化的含义和数据时使用。 应该把返回的 `Subscription` 保存在发起订阅的 Entity 上: ```rs enum ChatEvent { MessageSent, } impl EventEmitter for Chat {} struct Workspace { chat: Entity, _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, _subscriptions, } } fn on_chat_event( &mut self, _chat: Entity, _event: &ChatEvent, cx: &mut Context, ) { // 处理类型化 Event。 cx.notify(); } } ``` gpui-kit 的 View,以及 Zed 和 Longbridge Pro 中更复杂的 View,都在使用这一模式。`Workspace` 被释放时,`_subscriptions` 也会随之释放,callback 会自动断开。局部的 `let subscription = ...` 通常是错误写法,因为它会在函数结束时立即被 drop。把 View 的订阅 detach,或保存在生命周期更长的全局 owner 中,也可能导致 View 消失后 callback 与捕获的资源仍然存活,造成内存泄漏。 `EventEmitter`、`emit` 和类型化订阅的设计请继续阅读 [Event]。 ## 生命周期 只要还有一个强引用 `Entity`,Entity 就会继续存活。最后一个强句柄被 drop 后,GPUI 会释放其状态,此时 `WeakEntity` 将无法再 upgrade。 大部分清理工作应该直接跟随所有权关系: - 使用 `Entity` 持有子 Entity; - 使用 `WeakEntity` 表示非拥有关系; - 把 View 级 Subscription 放在同一个 View 的 `_subscriptions` 字段中; - View 被 drop 时,一并释放订阅及 callback 捕获的资源。 如果集成代码必须在状态被 drop 前立即执行操作,GPUI 还提供了 `cx.on_release(...)` 来观察当前 Entity,以及 `cx.observe_release(...)` 来观察另一个 Entity。它们返回的 Subscription 也应该只保存到 release callback 所需的生命周期结束为止。 [Entity]: https://docs.rs/gpui/latest/gpui/struct.Entity.html [WeakEntity]: https://docs.rs/gpui/latest/gpui/struct.WeakEntity.html [Event]: /zh-CN/docs/event --- # 安装 Source: /versions/v0.6.4/zh-CN/docs/installation 在开始使用 `gpui-component` 构建应用之前,需要先准备对应的开发环境并安装依赖。 实验性的 iOS 支持与 Swift UIView 嵌入方式请参阅[移动端](/zh-CN/docs/mobile)。移动端使用 `gpui-pre-mobile`,应用启动方式与桌面端不同。 ## 系统要求 目前可以在 macOS、Windows 和 Linux 上进行开发。 ### macOS - macOS 15 或更高版本 - Xcode Command Line Tools ## Windows - Windows 10 或更高版本 仓库提供了一个脚本用于安装所需工具链和依赖。可以在 PowerShell 中运行: ```ps .\script\install-window.ps1 ``` ## Linux 在 Linux 上,可以运行下面的脚本安装系统依赖: ```bash ./script/bootstrap ``` ## Rust 和 Cargo `gpui-component` 使用 Rust 构建,因此请确保系统已经安装 Rust 和 Cargo。 - Rust 1.90 或更高版本 - Cargo(通常随 Rust 一起安装) 安装库时,只需要在 `Cargo.toml` 的 `[dependencies]` 中加入: ```toml gpui-kit = "0.6" ``` `gpui-kit` 会替你引入配套的 GPUI crate,应用无需再单独声明 GPUI。`use gpui_kit::*;` 就是 GPUI 本身,各层按名访问:`gpui_kit::component`(带样式的组件)、`gpui_kit::base`、`gpui_kit::assets`、`gpui_kit::platform`。 ## 加快开发构建 Debug 构建下 GPUI、组件库和文字排版相关的 crate 都不做优化,`cargo run` 出来的应用渲染会明显比 release 慢。可以只对这些依赖开启优化,你自己的代码仍然保持快速、可调试的 debug 构建。Profile 只在应用(或 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 } ``` --- # 国际化 Source: /versions/v0.6.4/zh-CN/docs/i18n GPUI Component 为组件提供了内置翻译,目前包含 `en`、`zh-CN`、`zh-HK`。应用可以新增语言或覆盖个别翻译,而不需要复制完整的内置 locale 文件。 此功能需要 `rust-i18n` 4.2 或更高版本。 ## 添加依赖 在持有 locale 文件的应用 crate 中添加 `rust-i18n`: ```toml [dependencies] gpui-component = "0.6" rust-i18n = "4.2" ``` ## 创建应用 locale 文件 在应用 crate 中创建 `locales/ui.yml`,并将组件翻译放在 `gpui_component` namespace 下: ```yaml _version: 2 gpui_component: Calendar: week.0: fr: Di month.January: fr: Janvier DatePicker: placeholder: fr: Sélectionner une date ``` namespace 必须是 `gpui_component`,它对应 Rust crate 名称 `gpui-component`,其中连字符转换为下划线。可在 [GPUI Component 内置 locale 文件](https://github.com/longbridge/gpui-kit/blob/main/crates/component/locales/ui.yml)中查看可用的翻译 key。 ## 注册扩展 首先在应用的 crate root 初始化 locales: ```rust rust_i18n::i18n!("locales", fallback = "en"); ``` 然后在初始化 GPUI Component 之前注册扩展: ```rust app.run(move |cx| { rust_i18n::extend!(gpui_component); gpui_kit::init(cx); // 打开窗口并初始化应用的其他部分。 }); ``` 应用启动期间只需调用一次 `extend!`。 ## 查找优先级 应用提供的翻译优先于组件的内置翻译: ```text 应用 locales(locales/ui.yml) │ │ 未找到 key ▼ GPUI Component 内置 locales ``` 这种 deep merge 行为意味着: - 新语言(例如 `fr`)只需提供应用需要的 key。 - locale 和 key 都相同时,应用提供的翻译会覆盖内置值。 - 应用未提供的 key 会继续使用组件内置值。 - GPUI Component 后续新增的翻译无需复制,也会自动可用。 例如,只定义 `gpui_component.Calendar.month.January.en` 会修改一月份的英文标签,其他英文日历标签仍然来自 GPUI Component。 ### 命名空间只对组件内部的查找生效 `extend!` 改变的是 GPUI Component 查找**自身** key 的方式,它不会让应用自己的 `t!` 调用能访问到内置翻译: ```rust // GPUI Component 组件内部:先查应用,再查内置。 t!("Calendar.month.February") // -> "February" // 应用代码中:只会读取应用自己的 locale 文件。 t!("gpui_component.Calendar.month.February") // -> 未定义时返回 key 本身 ``` 组件文案应当通过渲染组件来呈现,不要自己去查这些 key。 ## 切换语言 `gpui-component` 已经再导出 locale 相关方法,应用无需直接依赖 `rust-i18n` 即可切换语言: ```rust gpui_kit::component::set_locale("fr"); let current = gpui_kit::component::locale(); ``` 组件随后会按照上述优先级,使用当前 locale 查找翻译文本。 当前 locale 属于 GPUI 不感知的全局状态,修改它本身不会触发重绘。展示翻译文本的 view 需要自行通知: ```rust gpui_kit::component::set_locale("fr"); cx.notify(); ``` --- # 设计指南 Source: /versions/v0.6.4/zh-CN/docs/design-guides 请在选择组件或编写布局代码之前阅读本指南。它记录 GPUI Kit 在多年桌面应用开发中形成的产品判断:界面应当原生、克制、精确,并让用户无需猜测就能完成任务。 本文是一份规范性指南:**必须**表示正确性或生态约束,**应该**表示默认选择,偏离时需要有明确理由;**可以**表示可选方法。具体方法签名仍以组件 API 文档和当前源码为准。 这些规则建立在 `gpui-base` 的行为能力、GPUI Component 的主题与组件体系,以及桌面平台共同的交互习惯之上。Shadcn 提供了开放代码、组合能力和可靠默认值等有益方法,但不决定 GPUI 应用的外观。发生冲突时,优先服从 GPUI 生命周期和用户熟悉的桌面交互。 ## 设计主张 界面应该原生、安静、准确。由内容、层级与交互承担体验,装饰只用于支持它们。 1. **先清晰,后个性:** 在加入品牌表达前,先让主要任务与下一步行动清楚。 2. **先组合,后发明:** 从已有组件出发组成产品工作流;只有行为确实不同,才创建新 primitive。 3. **先 token,后数值:** 颜色、圆角、字体与间距必须构成系统,避免无法响应主题的孤立字面量。 4. **桌面约定优先于网页惯例:** 保留键盘操作、窗口框架、菜单、密集数据视图、可调整区域和持久导航。 5. **状态必须可见:** hover、焦点、选中、禁用、加载、校验与破坏性状态必须清楚且一致。 ## 借鉴 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。 ### 圆角、间距与密度 所有应用拥有的控件圆角都应来自主题。这样产品才能作为一个整体变得方正或圆润。圆形和胶囊使用 `radius_full()`,不要写死最大圆角。 采用紧凑、重复的间距 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 中有价值的相对 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 与 accessible name。填充或彩色图标用于表达状态,不用于让 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`。遵循熟悉的平台快捷键,在菜单或工具提示中展示,并在浮层打开或关闭后把焦点放到合理位置。 选中状态是信息模型的一部分,不是可选润色。标签页、分段选择、可选行、筛选项与导航入口必须持续显示选中状态。拥有下拉菜单的按钮在弹出层关闭前必须保持按下或打开外观;悬停无法说明触发按钮与弹出层之间的关系。 破坏性操作要区分可逆与不可逆。可逆变更优先 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:需要更多持续空间的辅助工作。 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 示例中保持同一种写法。 ### 按钮与确认对话框 按钮默认简短,通常一至两个词,并说明结果,而不是手势或控件。使用“保存”“移动”“删除”,不用“点击进行保存”“执行移动操作”“确认删除操作”。不提交并离开的操作统一使用“取消”。`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.6.4/zh-CN/docs/coding-guides),把这些设计决策落实为 GPUI 架构和代码。 --- # Icon Source: /versions/v0.6.4/zh-CN/docs/assets GPUI Component 中的 [IconName] 和 [Icon] 提供了一套可直接在 GPUI 应用中使用的图标接口。 但为了尽量减小应用体积,`gpui-component` 默认 **不会内置任何图标资源**。 因此仓库把图标资源拆分到了独立的 [gpui-kit-assets] crate 中。这样你可以自行决定: - 直接使用默认内置图标资源 - 完全不引入图标资源 - 自己维护一套 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 下载包 和构建产物也仍包含完整目录。WASM 的 `Assets::new(endpoint)` 和 `AllAssets::new(endpoint)` 沿用按需下载的 CDN 加载器,不嵌入完整资源包。 ## 共享名称与兼容性 `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)`。 `IconName::ALL` 列出完整的 1,830 个名称,`IconName::Accessibility.path()` 返回 `icons/accessibility.svg`。默认资源源只包含原来的 101 个组件图标;额外图标请使用 下文的自定义资源源,或者显式注册 `AllAssets` 使用完整资源包。 ## 使用默认内置资源 [gpui-kit-assets] 提供了一个默认的资源实现,包含 `crates/assets/default-icons.txt` 中列出的原有 101 个组件图标。 如果要使用默认资源,需要在 `Cargo.toml` 中添加: ```toml [dependencies] gpui-component = { git = "https://github.com/longbridge/gpui-kit" } gpui-kit-assets = { git = "https://github.com/longbridge/gpui-kit" } ``` 然后在创建 GPUI 应用时,通过 `with_assets` 注册资源源: ```rs use gpui_kit::*; use gpui_kit::assets::Assets; let app = gpui_kit::application().with_assets(Assets); ``` 完成后,你就可以像平常一样使用 `IconName` 和 `Icon`。这些图标会从默认打包资源中读取。 继续阅读下面的 [使用图标](#使用图标) 小节查看实际示例。 ## 自定义资源 如果你只想带上一小部分图标,或者希望使用项目自己的 SVG 资源,可以自己构建资源源。 仓库中的 [assets] 目录包含了目前支持的全部 SVG 图标文件,文件名与 [IconName] 枚举一一对应。 你可以: - 直接从 [assets] 目录拷贝需要的 SVG - 或按 [IconName] 的命名规则准备自己的 SVG 文件 在 GPUI 应用中,通常可以结合 [rust-embed] 将这些 SVG 嵌入可执行文件,并通过 `AssetSource` 提供加载能力。 ```rs use gpui_kit::*; use gpui_kit::assets::Assets as ComponentAssets; use gpui_kit::component::{v_flex, IconName, Root}; use rust_embed::RustEmbed; use std::borrow::Cow; /// An asset source that loads assets from the `./assets` folder. #[derive(RustEmbed)] #[folder = "./assets"] #[include = "icons/**/*.svg"] pub struct Assets; impl AssetSource for Assets { fn load(&self, path: &str) -> Result>> { if path.is_empty() { return Ok(None); } if let Some(file) = Self::get(path) { return Ok(Some(file.data)); } ComponentAssets.load(path) } fn list(&self, path: &str) -> Result> { let mut paths = ComponentAssets.list(path)?; paths.extend(Self::iter().filter_map(|p| p.starts_with(path).then(|| p.into()))); paths.sort(); paths.dedup(); Ok(paths) } } ``` 同样需要在创建应用时调用 `with_assets`: ```rs fn main() { // Register Assets to GPUI application. let app = gpui_kit::application().with_assets(Assets); app.run(move |cx| { // We must initialize gpui_component before using it. gpui_kit::init(cx); cx.spawn(async move |cx| { cx.open_window(WindowOptions::default(), |window, cx| { let view = cx.new(|_| Example); // The first level on the window must be Root. cx.new(|cx| Root::new(view, window, cx)) }) .expect("Failed to open window"); }) .detach(); }); } ``` ## 使用图标 完成资源注册后,就可以在应用中直接使用图标: ```rs pub struct Example; impl Render for Example { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { v_flex() .gap_2() .size_full() .items_center() .justify_center() .text_center() .child(IconName::Inbox) .child(IconName::Bot) } } ``` ## 单独嵌入 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.6.4/zh-CN/component/icon#svg-字节)。 ## 参考资源 - [Lucide Icons](https://lucide.dev/) - GPUI Component 的图标集主要基于 Lucide 开源图标库 [rust-embed]: https://docs.rs/rust-embed/latest/rust_embed/ [IconName]: https://docs.rs/gpui-kit-assets/latest/gpui_kit_assets/enum.IconName.html [Icon]: https://docs.rs/gpui_component/latest/gpui_component/icon/struct.Icon.html [assets]: https://github.com/longbridge/gpui-kit/tree/main/crates/assets/assets/ [gpui-kit-assets]: https://crates.io/crates/gpui-kit-assets --- # Event Source: /versions/v0.6.4/zh-CN/docs/event GPUI 提供 **Event**,用于在 Entity 之间发送类型明确的通知。Event 报告已经发生的事实;与 [**Action**](./action) 不同,它不经过 Focus、Key Context、KeyBinding 或 Dispatch Path。 ## Action 进入,Event 返回 Action 可以触发状态变化,但 Event 的传递从状态变化之后开始: ```text Chat 状态变化 → emit(MessageSent) → 订阅者收到 Event → Workspace 更新 ``` Chat 发出一个 MessageSent Event,分别送达 Workspace、Activity Log 与 Telemetry 三个独立订阅者 Chat 发出一个 MessageSent Event,分别送达 Workspace、Activity Log 与 Telemetry 三个独立订阅者 - [**Action**](./action) 把意图向内传递:“发送这条消息”; - **Event** 把结果向外报告:“这条消息已经发送”。 Command owner 处理 Action 并改变状态,再发出 Event,让 owner 或 service 响应结果,而不必依赖命令来自快捷键、按钮还是菜单。Focus、KeyBinding 与 Action 派发参见 [Action](./action)。 ## 定义并发出 Event 先定义 Entity 可以报告的事实,并实现 `EventEmitter`: ```rust #[derive(Clone, Debug)] enum ChatEvent { DraftChanged, MessageSent { message_id: MessageId }, } impl EventEmitter for Chat {} ``` 状态变化成功后再发出 Event: ```rust fn finish_send(&mut self, message_id: MessageId, cx: &mut Context) { self.draft.clear(); cx.emit(ChatEvent::MessageSent { message_id }); } ``` Event 应按已经发生的事实命名,例如 `MessageSent`、`Saved`、`Dismissed`。`SendMessage` 这种命令式名称属于 Action。 ## 由 owner 订阅 订阅方应把 Subscription 保存在发起订阅的同一个 View 上。GPUI Kit 的示例采用下面这种模式: ```rust struct Workspace { chat: Entity, _subscriptions: Vec, } impl Workspace { fn new(cx: &mut Context) -> Self { let chat = cx.new(Chat::new); let _subscriptions = vec![cx.subscribe(&chat, |workspace, _, event, _cx| { if matches!(event, ChatEvent::MessageSent { .. }) { workspace.refresh_conversation(); } })]; Self { chat, _subscriptions } } } ``` 不要只用局部变量保存返回的 `Subscription`:函数结束后它会被 drop,观察者随即断开。把 `_subscriptions` 放在 `Workspace` 上,两者便拥有相同生命周期;View 释放时,Subscription 也会一起释放并取消订阅。也不要把 View 级 Subscription 存到生命周期更长的全局 owner,否则 View 消失后 callback 与捕获的资源仍然存活,可能造成内存泄漏。 回调需要 `&mut Window` 时使用 `cx.subscribe_in(..., window, ...)`,返回的 `Subscription` 同样保存在这个字段中。 **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。 --- # ElementId Source: /versions/v0.6.4/zh-CN/docs/element_id [ElementId] 是 GPUI 元素的唯一标识符,用于在 GPUI 组件树中引用具体元素。 在开始使用 GPUI 和 GPUI Component 之前,最好先理解 [ElementId] 的作用。 例如: ```rs div().id("my-element").child("Hello, World!") ``` 在这个例子里,`div` 元素的 `id` 是 `"my-element"`。为元素添加 `id` 后,GPUI 才能将事件绑定到它上面,例如 `on_click` 或 `on_mouse_move`。在 GPUI 中,带有 `id` 的元素通常称为 [Stateful\]。 我们也会在某些组件内部使用 `id` 来管理状态。实际上 GPUI 会在内部使用 [GlobalElementId],并通过 `window.use_keyed_state` 这类机制保存状态,因此 `id` 保持唯一非常重要。 ## 唯一性 `id` 需要在当前布局作用域内唯一,也就是在同一个 [Stateful\] 父节点下不能重复。 例如下面这个包含多个列表项的结构: ```rs div().id("app").child( div().id("list1").child(vec![ div().id(1).child("Item 1"), div().id(2).child("Item 2"), div().id(3).child("Item 3"), ]) ).child( div().id("list2").child(vec![ div().id(1).child("Item 1"), ]) ) ``` 这里子项可以使用很简单的 `id`,因为它们已经处于带有 `id` 的父元素之下。 GPUI 内部会结合父元素的 `id` 自动生成 [GlobalElementId]。在这个例子中,`list1` 里的 `Item 1` 对应的 `global_id` 是: ```rs ["app", "list1", 1] ``` 而 `list2` 里的 `Item 1` 对应的 `global_id` 是: ```rs ["app", "list2", 1] ``` 因此,只要父级路径不同,子元素就可以复用较简单的局部 `id`。 [ElementId]: https://docs.rs/gpui/latest/gpui/enum.ElementId.html [GlobalElementId]: https://docs.rs/gpui/latest/gpui/struct.GlobalElementId.html [Stateful]: https://docs.rs/gpui/latest/gpui/struct.Stateful.html [Stateful\]: https://docs.rs/gpui/latest/gpui/struct.Stateful.html --- # 编码指南 Source: /versions/v0.6.4/zh-CN/docs/coding-guides 本指南总结 GPUI Kit 中经过长期实践验证的应用架构与代码模式,面向工程师和 coding agent。请先阅读[设计指南](/versions/v0.6.4/zh-CN/docs/design-guides):代码结构的职责是保存产品意图,而不是替代产品设计。 本文是一份规范性指南:**必须**表示生命周期、正确性或生态约束;**应该**表示默认架构,偏离时需要有具体理由。精确方法签名以当前源码和 API 文档为准。 ## 架构总览 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,而不是继续向全局`views`、`models` 或 `modals` 目录添加文件。同一能力的 model、view、command、dialog 与 workflow 应该放在一起。编辑 Workspace 的 dialog 属于 Workspace Feature;只有可复用的 Dialog 基础组件属于 UI library。 ```text crates/ ├── app/ │ └── src/main.rs # 组合窗口与 Feature ├── workspace/ │ └── src/ │ ├── lib.rs # Feature 的公开边界 │ ├── 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 # 多个 Feature 共同使用的稳定能力 ``` 不要反过来建立全局 `models/`、`views/`、`modals/` 与 `commands/` 目录。这种方式只是按实现角色给文件分类,却会把每个 Feature 拆散到整个应用中。 App Shell 只组合 Feature Crate,尽量不承载 Feature 逻辑。Feature 可以依赖稳定的共享能力与 UI 基础设施,但不能反向依赖 App Shell,也不能进入另一个 Feature 的内部实现。两个 Feature 需要协作时,优先使用明确的 command、event、数据类型或小型共享 service,而不是让彼此的 view 形成依赖。只有一项能力已有清晰名称和两个以上真实使用方时,才提取共享 crate。 crate 边界也是工程边界。它让 Cargo 只重编译和测试较小的依赖子图,让所有权直接体现在`Cargo.toml` 中,并收紧一次修改需要评审和回归验证的范围。它还让删除 Feature 成为真实的架构检验:如果删除一个 Feature 仍需在全局 view 与 modal 目录中到处搜索,它从未真正解耦。 不要为每个页面或 helper 创建 crate。只有当一项能力拥有独立状态与生命周期、稳定的公开边界,或已经大到值得独立编译和测试时才拆分。依赖必须无环,并始终指向更小、更稳定的 crate。 ## 初始化与 Root 所有权 在创建组件 view 前只初始化一次 GPUI Component,并让每个窗口的第一层是 `Root`: ```rust app.run(move |cx| { gpui_kit::init(cx); cx.spawn(async move |cx| { cx.open_window(WindowOptions::default(), |window, cx| { let workspace = cx.new(|cx| Workspace::new(window, cx)); cx.new(|cx| Root::new(workspace, window, cx)) }) .expect("failed to open window"); }) .detach(); }); ``` `Root` 不只是容器。它协调 dialog、sheet、notification、tooltip/menu layer、modal focus restore、focus trap 与窗口级文本选择。一个窗口只能有一个 `Root`;绕过它的 UI 可能静止时正常,却会在 overlay 嵌套或快速切换 focus 时失效。 ## 理解 GPUI 阶段与上下文 GPUI 使用 retained state 与 declarative rendering。`Entity` 跨 frame 存活;`render` 返回的 element tree 只描述当前 frame。必须始终区分持久状态与一次 render 的输出。 - `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` 当所有输入都由 caller 提供,且 element 不需要跨 frame 保存 application state 时,使用`RenderOnce` 或 `IntoElement`。纯 presentation wrapper 和小控件通常属于此类。 ```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` 行为需要 observation、subscription、focus、async、history、measurement 或增量更新时,使用 entity-backed `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` 决定。 ## 状态所有权 状态应放在能够保持其正确性的最窄 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(...)`。API 要求时必须保留返回的 Subscription。 读取或派生值不能触发 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` 是行为契约的一部分。它为元素提供稳定标识,并作为元素局部状态或组件状态的键。组件也可以将它用于自身的焦点、测量或动画标识;焦点与滚动通常仍由各自的 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 布局: ```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()` 仍返回它。 直接修改 GPUI Component global theme 后调用 `Theme::sync_base(cx)`,让 Base 拥有的 scrollbar 与 resize handle 获得新 projection;完整 `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 中有价值的模型:具名 type、spacing、size step 共享一个 relative base,而不是变成互不相关的 pixel constant。 通过更新 base font 并 refresh window 改变 zoom: ```rust Theme::global_mut(cx).font_size = px(18.); Theme::sync_base(cx); window.refresh(); ``` 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 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。 Focus owner 必须明确: - 拥有 keyboard interaction 的 Entity 保存 `FocusHandle`; - 在正确 focused region 注册 key context 与 Action; - overlay 打开时转移 focus,关闭后恢复; - 绘制清楚的 `focus_visible` state; - 禁止在 `render` 中无条件 request focus。 `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。 ## 异步任务与副作用 Async work 从 event、lifecycle hook 或具名 method 启动,不能作为 `render` 的无条件副作用。不应让 task 延长已关闭 view 生命周期时 capture weak entity。完成后通过正确 GPUI context 更新,并处理 entity/window 已不存在的情况;完成 coherent update 后只 notify 一次。 用 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`。这与 Zed 的 `h_flex` 一致, 也是一排控件想要的效果,所以图标加文字的一行什么都不用写。但一排等高的列不是这样:直接放进 `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** 禁止交互;**read-only** 允许导航/选择但禁止编辑;**loading** 表示操作中并应防止重复提交。 - **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.6.4/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.6.4/zh-CN/docs/getting-started),具体 API 以各组件页面为准。 --- # 测试 Source: /versions/v0.6.4/zh-CN/docs/test 本指南统一介绍 GPUI Kit 应用和 GPUI 的测试方式。根据要验证的行为选择测试层级: - 纯数据转换、校验和状态转换使用普通 Rust `#[test]`。 - Entity、action、订阅和异步任务使用 `#[gpui_kit::test]` 与 `TestAppContext`,按需创建窗口。 - UI 集成测试渲染真实应用视图,通过 `gpui_kit::test` 派发事件,再检查控件状态、布局和业务结果。 - 像素检查使用独立的离屏渲染器;原生窗口和平台集成保留相应测试。 类型和 `#[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` 定位控件,派发真实 GPUI 事件, 再用普通 Rust 断言检查结果。 本指南介绍进程内的行为与布局自动化。元素快照不会检查像素,也不会启动打包后的应用。像素验证使用下文单独介绍的 GPUI 离屏渲染器。如果需要验证原生窗口、平台集成或视觉效果,应另外保留相应测试。 ## 配置测试项目 UI 测试直接集成在 `gpui-kit` 中,通过 `test-support` feature 启用。下面示例使用包含这些辅助方法的 Kit 源码检出目录,不需要额外测试 crate、GPUI fork 或 Cargo 补丁。 先按照[安装说明](/versions/v0.6.4/zh-CN/docs/installation)准备平台依赖。无头测试仍然需要编译 GPUI 的原生依赖。可以在源码目录旁创建独立测试项目: ```text workspace/ gpui-kit/ ui-tests/ Cargo.toml tests/ui.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`。示例使用 GPUI Kit 的统一入口,初始化组件库,用 `Root` 包装视图,并像真实应用一样将输入状态保存在视图上。 测试会输入 Unicode 姓名,通过 Backspace 编辑,点击 Save,检查状态文本与布局,最后验证保存的业务值。以下代码直接引用仓库集成测试的源码,会实际编译运行。 ```rust mod common; use gpui_kit::test::{TestSupportExt, TestWindowExt}; use gpui_kit::{ AppContext, Context, Entity, 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(Input::new(&self.name).id("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").label(), Some("Not saved")); window.click("name", cx); window.input("Ada 中文", cx); let name = window.find("name"); assert_eq!(name.focused(), Some(true)); assert_eq!(name.value(), Some("Ada 中文")); 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 中")); window.click("save", cx); let status = window.find("status"); assert!(status.visible()); assert_eq!(status.label(), Some("Saved: Ada 中")); 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 中")); }); } ``` 在自己的应用中,应从 library crate 导入生产视图及其构造函数。不要在测试中另写一份视图实现,否则测试与应用可能逐渐不一致。本例内联定义视图,是为了让整个示例可以直接复制到新项目。 在 `ui-tests/` 中运行: ```sh cargo generate-lockfile cargo test --test ui --locked ``` 将 `Cargo.lock` 一起提交。在 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 按键解析器发送特殊键或快捷键。 | | `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)); // 原来的帧。 assert_eq!(window.find("agree").checked(), Some(true)); // 新的一帧。 ``` 同时断言界面状态与业务结果。验证保存的模型或发出的事件也是集成测试的一部分, 但不能取代相关控件可见状态的验证。文本输入不模拟完整的系统 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 包复用相同契约 | | `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、虚拟列表、指针、生命周期和隔离测试继续保留。 纯展示组件通过几何或像素断言验证,不虚构交互状态。自定义部件观察已有原生元素; 不支持的属性保持不可用,不提供手填测试值的覆盖入口。 通过 `WindowExt` 打开 Dialog、Sheet 或 Notification 的视图,需要像生产应用一样挂载 `Root::render_dialog_layer`、`Root::render_sheet_layer` 和 `Root::render_notification_layer` 返回的子元素。仅构造 `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 绕过这些限制。 失败时按具体情况检查注册路径、观察配置、完成帧、键盘焦点、裁剪与覆盖层、异步完成条件。 ## 独立验证绘制结果 值或勾选标志正确,不代表控件正确绘制。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 个元素缩减后的清理。大列表用例验证正确性,不是渲染性能基准。 --- # 开始使用 Source: /versions/v0.6.4/zh-CN/docs/getting-started ## 安装 在 `Cargo.toml` 中添加依赖: ```toml [dependencies] gpui-kit = "0.6" anyhow = "1.0" ``` `gpui-kit` 始终引入 GPUI 和 `gpui-base`,并默认带上 `gpui-component` 和默认图标集。如果你希望自行管理图标与资源文件,只保留需要的 feature 即可: ```toml gpui-kit = { version = "0.6", default-features = false, features = ["component"] } ``` 更多说明见 [资源与图标](/versions/v0.6.4/zh-CN/docs/assets)。 ## 快速开始 下面是一个最小可运行示例: ```rust use gpui_kit::component::button::*; use gpui_kit::component::*; use gpui_kit::*; pub struct HelloWorld; impl Render for HelloWorld { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div() .v_flex() .gap_2() .size_full() .items_center() .justify_center() .child("Hello, World!") .child( Button::new("ok") .primary() .label("Let's Go!") .on_click(|_, _, _| println!("Clicked!")), ) } } fn main() { let app = gpui_kit::application().with_assets(gpui_kit::assets::Assets); app.run(move |cx| { gpui_kit::init(cx); cx.spawn(async move |cx| { cx.open_window(WindowOptions::default(), |window, cx| { let view = cx.new(|_| HelloWorld); cx.new(|cx| Root::new(view, window, cx)) }) .expect("Failed to open window"); }) .detach(); }); } ``` 请确保在 `app.run` 闭包中尽早调用 `gpui_kit::init(cx);`。它会初始化主题和全局配置。 ## 有状态组件与完整示例 Input、List 和 DataTable 的状态由持有它们的视图保存。使用 `&mut Window` 创建 `InputState`,在 render 中通过 `Input::new(&self.input)` 渲染组件,不要每帧重新创建状态。事件订阅也必须保存在视图中,不能仅绑定到构造函数的局部变量。 每个窗口以 `Root` 包装,应用内容还需渲染所使用的 dialog、sheet 和 notification 图层。完整实现及验证命令见[可执行应用示例](https://github.com/longbridge/gpui-kit/tree/main/examples/ai_recipes)。 ```rust use gpui_kit::component::{ ActiveTheme, IconName, Root, 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, window: &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") }); }), ), ) .children(Root::render_dialog_layer(window, cx)) .children(Root::render_sheet_layer(window, cx)) .children(Root::render_notification_layer(window, cx)) } } ``` ## 后续阅读 - [组件总览](../component/index) - [资源与图标](/versions/v0.6.4/zh-CN/docs/assets) --- # RenderOnce Source: /versions/v0.6.4/zh-CN/docs/render-once GPUI 提供 `RenderOnce`,用于根据持有的数据构建可复用组件。父级每次 render 时会重新创建这些组件,因此它很适合按钮、列表行、Badge、Card 等不需要独立持久生命周期的声明式 UI。 ```rust use gpui::{App, IntoElement, RenderOnce, SharedString, Window, div}; #[derive(IntoElement)] struct MessageRow { author: SharedString, body: SharedString, } impl MessageRow { fn new(author: impl Into, body: impl Into) -> Self { Self { author: author.into(), body: body.into(), } } } impl RenderOnce for MessageRow { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { div() .flex() .gap_2() .child(div().font_semibold().child(self.author)) .child(self.body) } } ``` `#[derive(IntoElement)]` 会生成转换实现,让这个值可以直接加入 GPUI 的 Element tree: ```rust div().child(MessageRow::new("You", "Explain RenderOnce")) ``` 这个 derive 不会立即 render 组件。它会把 `RenderOnce` 值包装成 Element,GPUI 在处理外层 Element tree 时消费并 render 它。 ## 为什么 `render` 会消费 `self` 这个方法签名是它与 [`Render`](./render) 最主要的区别: ```rust fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement; ``` 因为 `self` 的所有权属于 `render`,所以可以直接把字段移动到 Element tree 和 `'static` handler 中。组件值只使用一次;父级下一次 render 时会创建一个新的值。 多个字段需要移动到不同位置时,可以先解构,让所有权关系更清楚: ```rust fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { let MessageRow { author, body } = self; div() .child(author) .child(body) } ``` 这并不表示界面显示一帧后就会消失。生成的 Element 会参与当前帧;下次 render 时,父级再提供新的 UI 描述。 ## 状态应放在组件值之外 不要把会变化的应用状态放进 `RenderOnce` 值,并期待修改能够保留。持久状态应该存放在实现了 `Render` 的 `Entity` 中,再把当前值或 `Entity` handle 传给组件。 ```rust #[derive(IntoElement)] struct SendButton { chat: Entity, } impl RenderOnce for SendButton { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { let chat = self.chat; Button::new("send") .child("Send") .on_click(move |_, _, cx| { chat.update(cx, |chat, cx| { chat.send_message(cx); }); }) } } ``` Element handler 要求 `'static`,因此应使用 `move`,捕获 `SharedString`、`Entity` 或 Action 等持有的值。如果调用方还需要某个 handle,应先 clone,再把副本移入 handler。 捕获 `Entity` 会让该 Entity 在生成的 handler 存续期间保持存活。子组件操作其 owner 时,这通常符合预期。如果 handler 不应该延长目标的生命周期,应使用 `WeakEntity`,并处理 `weak.update(...)` 已经无法访问目标的情况。 `RenderOnce::render` 得到的是 `&mut App`,而不是 `&mut Context`。因此 `RenderOnce` 组件没有自己的 Entity Context:它不能为自己使用 `cx.listener`、保存 Subscription,也不能调用 `cx.notify()`。此时应传入 handler、派发 [Action](./action),或更新真正持有状态的 `Entity`。 ## Builder 风格的组件 持有字段的方式很适合 Builder API。GPUI Kit 和 Zed 的 Button、List Item、Label、Modal Section 等组件都广泛使用这种模式: ```rust #[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() .when(self.muted, |this| this.opacity(0.6)) .child(self.label) } } ``` ## 选择合适的层级 | 使用 | 适用情况 | | --- | --- | | `RenderOnce` | 可复用组件由持有的输入构建,并且没有独立的持久状态。 | | [`Render`](./render) | 有状态的 `Entity` 持有数据、Subscription、Task、Focus 或生命周期,需要反复 render。 | | [`Element`](./element) | 需要直接控制 layout、prepaint、paint、hit testing 等底层渲染阶段。 | 常见的组合方式是:`Render` view 持有状态,由它创建 `RenderOnce` 组件来描述可复用 UI,而这些组件返回内置 Element。只有标准 Element API 无法表达所需渲染行为时,才需要直接实现 `Element`。 如果一个组件开始积累可变状态、Subscription 或后台 Task,应把这些生命周期移入 `Entity`,并为它实现 `Render`。把它们放在每次 render 都会被消费的值里,会破坏 `RenderOnce` 简单明确的所有权模型。 --- # Window Source: /versions/v0.6.4/zh-CN/docs/window GPUI 提供 `Window` 作为单个系统窗口的上下文。它把渲染后的 Element 树与平台输入、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`。 ## Window 负责什么 常见的窗口级操作包括: | 需求 | API | | --- | --- | | 读取尺寸和状态 | `bounds`、`viewport_size`、`scale_factor`、`is_window_active` | | 管理 Focus | `focused`、`focus`、`blur`、`focus_next`、`focus_prev` | | 从代码下达命令 | `dispatch_action` | | 请求下一帧 | `refresh`、`on_next_frame` | | 控制原生窗口 | `set_window_title`、`activate_window`、`remove_window` | | 稍后继续工作 | `defer`、`spawn` | `Window` 内部还承载布局、文本、hit testing、输入和绘制状态。大多数 View 不需要直接操作这些系统;Element 和 GPUI 会在渲染期间使用它们。 ## Focus 与 Action 派发 Focus 属于某个具体的 Window。`window.focus(...)` 选择一个 `FocusHandle`,`window.focused(cx)` 返回当前 handle。键盘输入随后从获得 Focus 的 Element 建立 Dispatch Path,用它匹配 KeyBinding 并派发 Action。 ```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("打开") .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。Zed 与 Longbridge Pro 都会用 `defer` 和 `defer_in` 安排不能在当前 callback 中安全完成的 Focus 变化和 UI 树修改。 只有操作明确属于下一次渲染帧时才使用 `window.on_next_frame(...)`,例如推进一帧动画。`defer` 表示“当前 effect cycle 结束后”,两者不是同一个时机。 ## 需要 Window 的异步任务 当任务属于当前 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, mut cx| { let Ok(messages) = fetch_messages().await else { return }; this.update_in(&mut 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 执行的更新可能失败,代码应正确处理这一情况。 可以用下面几条规则判断所有权: - 持久 UI 状态属于 Entity; - 窗口级工作只在 callback 执行期间获得 `&mut Window`; - 把 `Task` 和 `Subscription` 存在 View 上,让后台工作和订阅跟随 owner 生命周期; - Focus 与 Action 派发始终使用当前这个 Window 的状态。 [Entity]: ./entity --- # 移动端 Source: /versions/v0.6.4/zh-CN/docs/mobile 移动端支持基于 [gpui-mobile](https://github.com/itsbalamurali/gpui-mobile),由 [itsbalamurali](https://github.com/itsbalamurali) 创建并与社区共同开发。原始移动平台的成果归功于该项目的作者和贡献者。移动平台负责窗口、触摸输入、文本系统和 GPU 渲染表面,GPUI 与 GPUI Kit 继续管理 Rust 视图树和组件。 GPUI Kit 目前使用 `gpui-pre-mobile`,这是在 [Longbridge fork](https://github.com/longbridge/gpui-mobile) 中维护的临时兼容包。它基于原项目进行打包适配,用于配合 `gpui-pre` 发布 crate,并持续跟进最新的 GPUI 版本、保持集成兼容。待社区 `gpui-mobile` 完成接入、GPUI 也发布 crate 后,我们计划将本文及相关依赖更新为社区的 `gpui-mobile`。 目前该集成仍处于实验阶段。Swift 托管的 iOS 示例已在 iOS 模拟器中构建并运行。仓库中也有 Android 平台实现,但本文介绍的 GPUI Kit 集成尚未在 Android 或实体 iPhone 上验证。 ## 运行 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` 可以只构建。示例的最低部署版本为 iOS 16;这项配置不代表所有支持的系统版本都经过测试。 真机开发还需要安装 `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 检出,可以替换 Kit 依赖: ```toml gpui-kit = { path = "../gpui-kit/crates/kit", default-features = false, features = ["component"] } ``` 路径相对于应用的 Cargo 清单,请按实际目录调整。GPUI 核心与渲染器应使用同一版本:上述移动平台固定使用 `0.3.4` 的 `gpui-pre` 和 `gpui-pre-wgpu`。 与桌面端[快速开始](/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 和桥接调用均应在主线程执行。 ## 平台判断 `gpui_kit::is_mobile()` 是带 `#[inline]` 的 `const fn`,在 iOS 和 Android 目标上返回 `true`。它判断编译目标,不判断窗口宽度或是否连接鼠标。 ```rust if gpui_kit::is_mobile() { // 使用适合触摸的交互。 } ``` ## 移动界面设计 可以与桌面端共享组件行为和内容,但应针对触摸操作与窄屏调整界面: - 由原生容器处理导航、安全区域和键盘避让。不要在 Rust 内容中重复添加标题栏或安全区域内边距。 - 一段对话只由一个容器负责纵向滚动。位于该容器内的 `TextView` 使用 `.w_full().min_w_0().scrollable(false)`,使文字与图片适应可用宽度。 - 输入为空时保持紧凑。如果不需要多行输入,就使用单行输入框,并确保键盘不会遮挡发送操作。 - iOS 和 Android 上点击 HoverCard 的触发元素切换开关,点击外部关闭;移动手指不会打开卡片。 - 在 `Input`、`Textarea` 或可选择的 `TextView` 中长按或双击会选中手指下的单词,然后在选区两端显示拖动 handle,并弹出包含剪切、复制、粘贴、全选(按当前可用情况显示)的编辑菜单。无需额外配置;窗口文本选区的菜单由 `Root` 绘制。 - 让操作可以通过触摸发现。复制按钮与回复正文对齐,复制成功后短暂显示对勾,不依赖悬停提示解释操作。 - 使用短段落和有意义的标题。代码、表格和图片应服务于对话,不必在每条回复中罗列所有 Markdown 格式。 - 一致使用 Kit 的主题颜色、字号和间距。在真实设备宽度下检查长回复、宽代码、图片加载和中文等不同文字。 GPUI Base 在 iOS 和 Android 上禁用其 tooltip overlay。这只覆盖通过该 overlay 显示的 Kit 提示,不影响直接调用 GPUI `.tooltip()` 的代码。上述固定依赖基线尚不包含这一修改。移动视图中不要添加 GPUI 原生悬停提示。 ## 验证与当前限制 集成到应用后,应检查启动和后台恢复、键盘显示与隐藏、视口尺寸变化、文本选择与复制、滚动及触摸反馈。除了 Rust 编译检查,也应观察实际渲染界面。 在做出性能结论前,使用实体设备、Release 构建和 Xcode Instruments 测量。模拟器适合验证布局与交互,但它的结果不是设备帧耗时。 Android 使用独立的 Activity 与渲染表面生命周期。仓库包含 Android 示例,但本文不代表 Android Kit 兼容性或嵌入原生 Android `View` 的能力已经得到验证。采用这些路径前需要单独评估。 --- # FPS Monitor Source: /versions/v0.6.4/zh-CN/docs/fps `gpui-fps` 在窗口上叠加一个性能 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 用绝对定位;是否显示由调用方自己决定。 ## 主读数 那个大数字回答两个问题之一,`MAX` 标记说明当前是哪一个。**右键**切换,**左键**折叠成一个小标签。 | | 读的是 | 含义 | | --- | --- | --- | | `MAX FPS`(默认) | `1 / FRAME`,并夹在显示器刷新率上 | 这个窗口做一次完整重绘能撑住的速率 | | `FPS` | 每秒 present 的帧数 | 窗口实际在画多快 | 这是两个不同的问题,而按需绘制的应用给出的答案差得很远:一个空闲的窗口每秒画两帧,但它 其实能画一百二十帧——只有其中一个数字代表性能问题。 ### 为什么 MAX 是推导出来的,而不是数出来的 要让帧计数器读出"这个 UI 能跑多快",最直接的做法是像游戏里的帧数器那样不停地要帧。但在 这里这件事并不免费:标脏任意一个 view 排的是一次**窗口**绘制,GPUI 会重新 render 该窗口中 除 [`Entity::cached`] 边界后面之外的所有 view —— 于是 HUD 每要一帧,代价就是应用的一次 完整 layout 与 paint,而下面那行 CPU 报的正是 HUD 自己制造出来的开销。在 story gallery 的 Table 页上,这意味着没人碰窗口时也有约 62% 的 CPU。 而帧耗时本身已经回答了这个问题。`FRAME` 就是一次完整重绘的成本,取倒数就是这种重绘能撑住 的速率,一帧都不用多画。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 说明窗口被要求重绘的频率超过了它能画的频率。 | | `CPU` | 本进程,采用 `top` / 活动监视器的口径:100 表示占满一个核,占用一核半读作 140。 | | `MEM` | 常驻内存。 | `FRAME`、`P95`、`DROP` 按帧预算着色:平台报得出上面那个刷新率时,预算就是窗口所在面板的一次刷新; 报不出时是一个 60Hz 帧。`frame_budget()` 可以钉一个自己的预算,之后面板不再替换它。 ## 最初的几帧不计入统计 窗口最初的几帧是最贵的——着色器、字形图集、图标,所有缓存都是冷的——它们并不代表应用的运行成本。 其中一帧可能是 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/latest/gpui/struct.Entity.html --- # Context Source: /versions/v0.6.4/zh-CN/docs/context 在 GPUI 中,[Window]、[App]、[Context] 和 [Entity] 是最常见、也最重要的几个核心概念。 - [Window] - 当前窗口实例,负责处理 **窗口级** 行为 - [App] - 当前应用实例,负责处理 **应用级** 行为 - [Context] - 某个 Entity 的 `Context` 实例,负责处理 **Context 级** 行为 - [Entity] - 某个实体本身,负责处理 **实体级** 状态和逻辑 例如: ```rs fn new(window: &mut Window, cx: &mut App) {} impl RenderOnce for MyElement { fn render(self, window: &mut Window, cx: &mut App) {} } impl Render for MyView { fn render(&mut self, window: &mut Window, cx: &mut Context) {} } ``` 可以看到,在 GPUI 里通常都会用 `cx` 来表示 `App` 或 `Context`。 这是 GPUI 里约定俗成的命名习惯,继续沿用这个写法会让代码更统一,也更容易阅读。 [Window]: https://docs.rs/gpui/latest/gpui/struct.Window.html [App]: https://docs.rs/gpui/latest/gpui/struct.App.html [Context]: https://docs.rs/gpui/latest/gpui/struct.Context.html [Entity]: https://docs.rs/gpui/latest/gpui/struct.Entity.html --- # Element Source: /versions/v0.6.4/zh-CN/docs/element **Element** 是 GPUI 为当前一帧构建的 Element 树节点。Element 负责布局、准备命中测试,并把像素绘制到 Window。下一帧开始前,GPUI 会释放整棵 Element 树及其中注册的帧级 callback,再根据应用的最新状态重新构建。 大多数应用代码只需要组合 GPUI 和 GPUI Kit 提供的 Element: ```rust div() .flex() .items_center() .gap_2() .child(Icon::new(IconName::Search)) .child("搜索") ``` 这段代码构建了一棵 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("离线").into_any_element() } } ``` Zed 和 Longbridge Pro 会在可选 slot、表格单元格以及返回不同 UI 类型的函数中这样使用 `AnyElement`。如果调用方不需要类型擦除,通常用 `IntoElement` 作为 API 边界。 ## 三个阶段 GPUI 会依次执行 `Element` 的三个阶段: ```text request_layout → prepaint → paint ``` ### `request_layout` 通过 `window.request_layout` 注册 Element 的 `Style` 和子布局节点。GPUI 使用 Taffy 布局引擎,在所有布局请求完成后计算尺寸和位置。 这个阶段返回 `LayoutId`,以及后续阶段需要的 `RequestLayoutState`。此时不要假设最终 `Bounds` 已经确定。 ### `prepaint` 此时 GPUI 会传入布局完成后的 `Bounds`。在这里进行文字 shaping、几何计算、插入 hitbox、对子元素执行 prepaint,并准备 `paint` 所需的数据。 这些数据通过 `PrepaintState` 返回。命中测试应在这里准备,因为 GPUI 需要在绘制前建立当前帧的空间信息和派发信息。 ### `paint` 使用前面准备好的状态绘制 quad、文字、路径或图片;自定义 Element 需要输入处理时,也在这个阶段注册帧级 handler。这里应复用前面算好的几何信息,不要再次进行布局计算。 状态在一帧内单向传递: ```text RequestLayoutState ───────────────┐ │ │ ▼ ▼ prepaint ── PrepaintState ──► paint ``` 这些 associated state 只属于当前帧。长期存在的应用状态应放在 [Entity](./entity) 中;少量需要跨帧保存的 Element 状态可以通过 [ElementId](./element_id) 建立关联。 ## 什么时候实现 `Element` 只有现有 Element 无法表达需求时才实现 `Element`,例如: - 需要进行文字 shaping,并绘制选区和光标的代码编辑器或文本输入框; - 具有自定义几何图形的图表或 canvas; - 自定义布局算法; - 需要直接控制布局、hitbox 与绘制的高性能底层原语。 GPUI 的文本输入示例使用自定义 `Element`,因为它需要在 `prepaint` 中 shape 一行文字,注册输入 handler,并绘制选区和光标。GPUI 的 `Svg`、`Img`、列表和 canvas 也使用同一套生命周期。相比之下,Zed 与 Longbridge Pro 的大部分 UI 都通过组合已有 Element 完成,只在不同分支返回不同类型时转换成 `AnyElement`。 编写可复用 UI 组件时,先使用 [`RenderOnce`](./render)。编写由 Entity 持有状态的 UI 时,使用 [`Render`](./render)。只有需要直接控制渲染管线时,才下沉到 `Element`。 ## 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` 不会自动获得这些能力。 如果 `div()` 等现有 Interactive Element 已能满足需要,直接组合它。自定义底层原语需要标准交互时,可以像 GPUI 内置 Element 一样,嵌入或委托给 GPUI 的 `Interactivity`。如果自行实现 hitbox 与输入事件注册,也要自行正确处理派发、裁剪、cursor 行为和无障碍信息。 `Element::id()` 返回 `ElementId` 不只是给像素加一个标签,它会建立跨帧的稳定 identity。ID 在最近的 keyed ancestor 中必须唯一;只有 Element 或附加行为确实需要 identity 时才添加 ID。 [Element]: https://docs.rs/gpui/latest/gpui/trait.Element.html [IntoElement]: https://docs.rs/gpui/latest/gpui/trait.IntoElement.html [AnyElement]: https://docs.rs/gpui/latest/gpui/struct.AnyElement.html --- # Render Source: /versions/v0.6.4/zh-CN/docs/render GPUI 提供 `Render` trait,用于把 `Entity` 的当前状态转换成元素树。对于 Chat 面板、设置页面、Workspace 这类状态会持续变化、生命周期较长的 View,应使用 `Render`。 ```rust use gpui::{div, prelude::*, Context, IntoElement, Render, Window}; struct Chat { messages: Vec, } impl Render for Chat { fn render( &mut self, _window: &mut Window, _cx: &mut Context, ) -> impl IntoElement { div() .flex() .flex_col() .children( self.messages .iter() .cloned() .map(|message| div().child(message)), ) } } ``` `render` 会收到: - `&mut self`:保存在当前 Entity 中的状态; - `&mut Window`:窗口级状态与操作; - `&mut Context`:当前 Entity 的 GPUI Context; - 返回 `impl IntoElement`:可被 GPUI 转换成元素树的值。 使用 `impl IntoElement` 后,函数签名不需要写出通常很深的具体元素类型。`div()`、GPUI Kit 组件以及子级 `Entity` 都可以加入这棵树。 ## 更新 View 只修改 Entity 并不会告诉 GPUI 它的可见内容已经变化。应在 Entity update 中修改状态,然后调用 `cx.notify()`: ```rust impl Chat { fn push_message(&mut self, message: String, cx: &mut Context) { self.messages.push(message); cx.notify(); } } ``` 它们的关系是: ```text Entity 状态变化 ↓ cx.notify() ↓ GPUI 使展示该 Entity 的 View 失效 ↓ Render 构建下一棵元素树 ``` `cx.notify()` 还会通知该 Entity 的观察者。GPUI 会安排后续渲染,并不会在调用 `notify` 的这一行同步执行 `render`。同一个 Entity 同时显示在多个窗口或位置时,GPUI 会使正在展示它的 View 失效。 ## 让 Render 保持声明式 把 `render` 看作“根据当前状态描述 UI”。读取状态、选择子元素和绑定 handler 都是正常操作;不要仅仅因为 `render` 被调用,就启动工作或修改应用状态: - 不要发起网络请求或后台任务; - 不要创建 Subscription 或注册应用级观察者; - 不要派发命令或发送 Event; - 不要无条件调用 `cx.notify()`、`window.refresh()` 或 `window.request_animation_frame()`。 View 失效时,GPUI 可能再次调用 `render`。如果在 `render`、`prepaint`、`paint` 或 canvas callback 中无条件调用 `cx.notify()`,窗口可能被持续标记为需要重绘,形成空闲重绘循环。应在初始化阶段或明确的 handler 中启动工作;结果返回后更新 Entity,并且只在可见状态确实变化时调用 `cx.notify()`。 在渲染时绑定输入 handler 是另一回事:closure 只是作为元素树的一部分被注册,之后发生输入时才会执行。 ```rust div() .child("Clear") .on_click(cx.listener(|this, _, _, cx| { this.messages.clear(); cx.notify(); })) ``` ## Render、RenderOnce 与 Element 选择能够满足需求的最小抽象: | API | 适用场景 | 方法接收者 | Context | | --- | --- | --- | --- | | `Render` | 由 `Entity` 承载、有状态且长期存在的 View | `&mut self` | `Context` | | [`RenderOnce`](./render-once) | 根据输入数据组合出的可复用组件 | `self` | `App` | | [`Element`](./element) | 自定义布局、prepaint、hitbox 或绘制 | 各阶段的 `&mut self` | `App` | 当 `T: Render` 时,`Entity` 可以直接作为 child。Entity ID 为 View 提供 identity,`notify` 可以使对应的 View 子树失效。`RenderOnce` 组件会在构建元素树时消耗自身,没有独立的 Entity identity。只有组合已有元素无法满足需求时,才需要直接实现 `Element`。 ## 相关文档 - [`Entity`](./entity) 介绍状态的所有权、读取和更新。 - [`Context`](./context) 介绍 `App`、`Window` 和 `Context`。 - [`RenderOnce`](./render-once) 介绍如何根据 owned props 构建可复用组件。 - [`Element`](./element) 介绍 GPUI 的布局与绘制阶段。 --- # 框架对比 Source: /versions/v0.6.4/zh-CN/docs/comparison GPUI Kit 与其他桌面 UI 框架的对比。表格由人工维护,如发现任何错误或过时信息,请提交 issue 或 PR。 | 特性 | GPUI Kit | [Iced] | [egui] | [Qt 6] | | ------------------- | -------------------- | ------------------ | --------------------- | ------------------------------------------------- | | 语言 | Rust | Rust | Rust | C++/QML | | 核心 | GPUI | wgpu | wgpu | QT | | 许可证 | Apache 2.0 | MIT | MIT/Apache 2.0 | [Commercial/LGPL](https://www.qt.io/qt-licensing) | | 最小二进制大小 [^1] | 12MB | 11MB | 5M | 20MB [^2] | | 跨平台 | 是 | 是 | 是 | 是 | | 文档 | 一般 | 一般 | 一般 | 良好 | | Web 支持 | 是(WASM) | 是 | 是 | 是 | | UI 风格 | 现代 | 基础 | 基础 | 基础 | | CJK 支持 | 是 | 是 | 差 | 是 | | Chart | 是 | 否 | 否 | 是 | | Table(大数据集) | 是
(虚拟行、列) | 否 | 是
(虚拟行) | 是
(虚拟行、列) | | Table 列宽调整 | 是 | 否 | 是 | 是 | | 文本基础 | Rope | [COSMIC Text] [^3] | trait TextBuffer [^4] | [QTextDocument] | | Code Editor | 简单 | 简单 | 简单 | 基础 API | | Dock 布局 | 是 | 是 | 是 | 是 | | 语法高亮 | [Tree Sitter] | [Syntect] | [Syntect] | [QSyntaxHighlighter] | | Markdown 渲染 | 是 | 是 | 基础 | 否 | | Markdown 混合 HTML | 是 | 否 | 否 | 否 | | HTML 渲染 | 基础 | 否 | 否 | 基础 | | 文本选择 | TextView | 否 | 任意 Label | 是 | | 自定义主题 | 是 | 是 | 是 | 是 | | 内置主题 | 是 | 否 | 否 | 否 | | 国际化 | 是 | 是 | 是 | 是 | [Iced]: https://github.com/iced-rs/iced [egui]: https://github.com/emilk/egui [QT 6]: https://www.qt.io/product/qt6 [Tree Sitter]: https://tree-sitter.github.io/tree-sitter/ [Syntect]: https://github.com/trishume/syntect [QSyntaxHighlighter]: https://doc.qt.io/qt-6/qsyntaxhighlighter.html [QTextDocument]: https://doc.qt.io/qt-6/qtextdocument.html [COSMIC Text]: https://github.com/pop-os/cosmic-text [^1]: 使用简单 Hello World 示例的 Release 构建。 [^2]: [减小 Qt 应用程序的二进制大小](https://www.qt.io/blog/reducing-binary-size-of-qt-applications-part-3-more-platforms) [^3]: Iced Editor: [^4]: egui TextBuffer: --- # Action Source: /versions/v0.6.4/zh-CN/docs/action GPUI 提供 **Focus**、**Key Context**、**Action**、**KeyBinding** 与 [**Event**](./event),它们是 GPUI 的核心交互机制。应用通过这些机制,把操作命令路由到窗口中当前活跃的区域,并在 Entity 之间传递类型明确的状态变化。 这篇 Guide 说明如何配合使用这些机制: - **Focus** 表示键盘交互此刻发生在哪里; - **`track_focus`** 在 Element 上注册稳定的 `FocusHandle`,让鼠标交互与命令路由可以使用它; - **Action** 表示一条命令,可以来自快捷键、菜单、按钮或代码; - [**Event**](./event) 表示某个 Entity 已经发生了什么,并通知它的订阅者。 ## 快捷键怎样生效 GPUI 从 Focus 组成 Dispatch Path,用路径上的 Key Context 匹配 KeyBinding,再把 Action 派发给最具体的 handler GPUI 从 Focus 组成 Dispatch Path,用路径上的 Key Context 匹配 KeyBinding,再把 Action 派发给最具体的 handler 假设窗口左侧是 Sidebar,右侧是 Chat。点击 Sidebar 后,Focus Path 包含 `Sidebar`;点击聊天输入区后,Focus Path 包含 `Chat`。因此绑定到 `Chat` 的快捷键只会在右侧区域激活。 可以在同一段布局代码中明确声明两个键盘交互区域: ```rust h_flex() .size_full() .child( // 左侧:点击后激活 Sidebar Key Context。 div() .w_64() .track_focus(&self.sidebar_focus) .key_context("Sidebar") .child("Sidebar"), ) .child( // 右侧:点击后激活 Chat Key Context。 div() .flex_1() .track_focus(&self.chat_focus) .key_context("Chat") .on_action(cx.listener(Self::on_action_send_message)) .child("Chat"), ) ``` 两个区域各自拥有稳定的 `FocusHandle` 与 Key Context。点击 Chat 后,Focus 移到 `chat_focus`,`Chat` 进入当前 Dispatch Path,`SendMessage` handler 才能收到匹配后的 Action。点击 Sidebar 则会激活 `Sidebar`,此时只属于 Chat 的 binding 不会匹配。 按下一个键时,GPUI 会: 1. 从获得 Focus 的元素出发,沿祖先节点组成 Dispatch Path; 2. 收集路径上的 `key_context`,用它们匹配 `KeyBinding`; 3. 把匹配到的 Action 沿同一条路径派发,最具体的 handler 最先处理。 活跃的 Focus Path 让同一个按键可以在窗口的不同区域表达不同含义,不需要额外维护一套全局快捷键分发开关。 ## Focus 是位置 `FocusHandle` 是键盘目标的稳定身份。让拥有这段交互的 Entity 保存它: ```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() } } ``` - `handle.is_focused(window)`:Focus 正好在这个目标上; - `handle.contains_focused(window, cx)`:Focus 也可以在它的子树里; - `handle.focus(window, cx)`:主动把 Focus 移到这里。 输入光标、选中的控件通常检查 exact Focus;当子控件获得 Focus 时整个面板仍应保持激活,则检查 containment。 ## `track_focus` 到底做了什么 `track_focus` 把 handle 绑定到当前帧里的具体元素: ```rust div() .track_focus(&self.focus_handle) .key_context("Chat") .on_action(cx.listener(Self::on_action_send_message)) ``` 调用 `track_focus` 会把 `FocusHandle` 注册到这个 Element 对应的 dispatch node,并把该 Element 标记为可以接收 Focus。这会产生几项关联行为: - 在 Element 内按下鼠标时,GPUI 默认把 Focus 移到这个 handle; - `focus`、`in_focus` 与 `focus_visible` style 可以读取它的状态; - GPUI 可以计算 Focus containment 与 Focus Path; - 这条路径上的 Key Context 和 Action handler 会参与按键匹配与 Action 派发。 嵌套控件需要保留自己的 Focus 时,可以通过 `cx.prevent_default()` 阻止父元素在 mouse down 时接管 Focus。 `track_focus` **不会在 render 时立刻让 Element 获得 Focus**。打开视图或进入交互时,调用 `focus_handle.focus(window, cx)`。不要在 `render` 里无条件请求 Focus,否则每次渲染都会把 Focus 抢回来。 ### Focus 与 Tab 顺序是两件事 被 track 的 handle 不会自动成为 Tab stop。Tab 行为要声明在 handle 本身: ```rust let focus_handle = cx.focus_handle().tab_stop(true); ``` 需要明确顺序时使用 `tab_index(...)`。在元素上调用 `.tab_stop(...)` 不会改变传给 `track_focus` 的 handle。 Stateless component 可以用 keyed state 让 handle 跨 render 保持稳定: ```rust let focus_handle = window.use_keyed_state(id, cx, |_, cx| { cx.focus_handle().tab_stop(true) }); ``` ## Action 是命令协议 Action 是 GPUI 表达应用操作的核心方式:它是一个有类型的命令值,sender 无需耦合 receiver 就能 dispatch,GPUI 再沿当前 Focus Path 路由。同一个 Action 同时服务三个层次: 1. **输入映射**:`KeyBinding` 把按键映射为 Action; 2. **命令派发**:命令面板、按钮、Popup Menu 或其他 handler dispatch 这个 Action; 3. **配置**:Keymap 把命令序列化为稳定的 Action name 与可选 JSON payload,GPUI 的 Action registry 再将其反序列化为有类型的 Action;这是 Zed 风格用户 Keymap 的基础。 例如,命令面板保存 Action,而不是为每一行各存一份 callback: ```rust let commands: Vec<(&str, Box)> = vec![ ("发送消息", Box::new(SendMessage)), ("切换侧边栏", Box::new(ToggleSidebar)), ]; // 用户确认当前命令时: window.dispatch_action(commands[selected].1.boxed_clone(), cx); ``` 应用可以按自己的模型保存 owned 或 cloneable command entry;这里的关键边界是:选择命令后得到一个 Action 并 dispatch,当前 focused owner 仍然负责如何处理它。 Native application menu 也使用同一套协议。在 macOS 上,菜单命令通过 Action 接入,而不是普通 element click callback: ```rust MenuItem::action("发送消息", SendMessage) ``` 通过 `actions!` 声明的 unit Action 会按名称注册。Action 需要携带配置数据时,derive `Action` 与 `Deserialize`,并设置 namespace: ```rust #[derive(Action, Clone, PartialEq, Deserialize)] #[action(namespace = chat)] struct InsertPrompt { text: SharedString, } ``` Keymap 因此可以用稳定的 Action name 标识命令,并在需要时附带 JSON payload。`#[action(no_json)]` 会明确禁止从 JSON 构造该 Action,适用于不应该出现在用户配置中的 runtime-only command。 ### 通过共同 owner 协调并列组件 假设用户在 Sidebar 选择一条会话后,Chat 需要打开这条会话。Sidebar 只需要用 `OpenConversation` 表达这个意图,不需要持有 Chat 的 callback 或引用。二者最近的共同 owner `Workspace` 负责处理 Action,再更新 Chat: ```rust #[derive(Action, Clone, PartialEq)] #[action(namespace = workspace, no_json)] struct OpenConversation { conversation_id: ConversationId, } 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 是 Sidebar 与 Chat 的共同祖先。 h_flex() .on_action(cx.listener(Self::on_action_open_conversation)) .child(self.sidebar.clone()) .child(self.chat.clone()) // Sidebar 中的会话行派发命令。 window.dispatch_action( Box::new(OpenConversation { conversation_id }), cx, ); ``` 现在路由关系很明确:**Sidebar → Workspace → Chat**。Action 从 Sidebar 当前的 Dispatch Path 向上走,由 `Workspace` 接收;`Workspace` 再通过 Entity API 调用 Chat。Action 本身不会从 Sidebar 横向跳到 Chat。 **INFO — sibling 不在当前 Dispatch Path 上** 如果只把 `on_action_open_conversation` handler 挂在 Chat 上,当 Sidebar 拥有 Focus 时派发的 Action 无法到达它:Chat 是 sibling,不是当前 Dispatch Path 上的祖先。同一种错误也会导致快捷键看起来没有响应——`on_action` handler 位于 Focus 选中的路径之外。跨区域 handler 应放在最近的共同 owner 上;注册 `KeyBinding` 后,还要把对应的 `key_context` 与 handler 放在快捷键应该生效的路径上。 只有真正属于整个应用的命令才使用 global handler。 ## 完整实现一条键盘命令 先定义并绑定一次命令: ```rust actions!(chat, [SendMessage]); const CHAT_CONTEXT: &str = "Chat"; fn init(cx: &mut App) { cx.bind_keys([ #[cfg(target_os = "macos")] KeyBinding::new("cmd-enter", SendMessage, Some(CHAT_CONTEXT)), #[cfg(not(target_os = "macos"))] KeyBinding::new("ctrl-enter", SendMessage, Some(CHAT_CONTEXT)), ]); } ``` 把 Focus、Key Context 和 handler 放在同一个 owner region: ```rust impl Chat { fn on_action_send_message( &mut self, _: &SendMessage, _: &mut Window, cx: &mut Context, ) { self.submit_draft(); cx.notify(); } } impl Render for Chat { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .track_focus(&self.focus_handle) .key_context(CHAT_CONTEXT) .on_action(cx.listener(Self::on_action_send_message)) .child("Chat") } } ``` 所有入口都派发同一个 `SendMessage` Action:`KeyBinding` 负责快捷键,按钮在 click handler 中调用 `window.dispatch_action(...)`,Popup Menu 与 macOS native menu 保存同一个 Action。真正的消息提交逻辑只实现一次。 Action handler 默认停止冒泡。如果内层 handler 不处理,希望父级继续尝试,调用 `cx.propagate()`。使用 `cx.on_action(...)` 注册的全局 handler 在不适用时也必须 propagate。 先执行 `cx.bind_keys(...)`,再执行 `cx.set_menus(...)`。Native menu 创建时会固定当时的快捷键显示;之后只改 binding,不会自动更新已有菜单。 ## Action 与 Event 如何配合 Action 与 Event 描述同一次交互中的两个相反方向: ```text ⌘ Enter → SendMessage Action → Chat 发送 → MessageSent Event → Workspace 更新 ``` Action 把“**想做什么**”向内传给 command owner;操作改变状态后,Event 再把“**发生了什么**”向外传给感兴趣的 owner。阅读 [Event](./event),继续了解 `EventEmitter`、`emit`、订阅生命周期,以及完整的 Action/Event 选择方法。 ## 全局与上下文快捷键 编辑和导航快捷键通常都应有 Key Context。它们只在特定区域包含 Focus 时有意义,也应该让更具体的 child 优先处理。 真正的全局 fallback 或 service command 才使用 `cx.on_action(...)`。危险的全局快捷键还要在 owner 中检查运行时状态。Longbridge Pro 的交易快捷键既限制在 Workspace Key Context,又会在输入框或对话框获得 Focus 时拒绝执行。Key Context 决定“**命令在哪里有资格匹配**”,handler 决定“**它现在是否允许执行**”。 ## 排查“点击以后才生效” 如果快捷键只有点击某个区域以后才生效,通常是这次点击把 Focus 移进了包含目标 Key Context 和 handler 的路径。把它当作路由问题,沿 GPUI 使用的同一条链路检查。 按整条链路依次检查: 1. **Binding**:按键是否绑定到预期的 Action 和 Key Context? 2. **Focus**:点击前后,究竟哪个 `FocusHandle` 获得 Focus? 3. **Tracking**:同一个 handle 是否在已渲染元素上调用 `track_focus`? 4. **Context**:目标 `key_context` 是否位于 focused element 或其祖先上? 5. **Handler**:`on_action` 是否也在同一条 Dispatch Path 上? 6. **Propagation**:是否有更具体的 handler 提前吞掉 Action? 7. **Lifetime**:全局 handler 或 Event subscription 是否已释放?过期 handler 是否忘了 propagate? 最常见的修复,是让同一个 region 一起拥有稳定 handle、`track_focus`、`key_context` 与 `on_action`,然后在用户进入该区域时把 Focus 移进去。 这些模式来自 GPUI 的 dispatch 实现,并在 GPUI Kit 的 Menu、Tree、Input、Dialog、Color Picker,Zed 的 panel 与 editor,以及 Longbridge Pro 的 workspace 和交易快捷键中得到实际验证。 --- # 介绍 Source: /versions/v0.6.4/zh-CN/docs # GPUI Kit 简介 GPUI Kit 是一个基于 GPUI 的综合性 Rust 桌面应用开发框架。 它将完整 UI 系统与应用级数据、布局、内容和编辑能力整合在一起,并以三个层层递进的 crate 交付,只需依赖 `gpui-kit` 一个包即可全部获得: - **`gpui-base`**:无样式的行为、受控状态、焦点、浮层、虚拟列表、Dock 基础设施与语义化设计 token。 - **`gpui-component`**:即 GPUI Component,完整的带样式组件库,提供 60+ 控件、主题、数据表格、Dock 布局和代码编辑器。 - **`gpui-shell`**:让 Rust 宿主可以被 JavaScript 扩展,能力逐项授予。 使用 `gpui-component` 可以获得统一、成熟的视觉风格;基于 `gpui-base` 则可以复用可靠的行为与基础设施,同时创建并拥有自己的设计系统。本节文档介绍 GPUI Kit 的入门配置、公共设计与编码指南,以及应用开发。各层 API 请参阅 [GPUI Component](/zh-CN/component)、[GPUI Base](/zh-CN/base) 与 [GPUI Shell](/zh-CN/shell)。 ## 特性 - **60+ 组件**:覆盖表单、导航、浮层、反馈和布局等场景 - **生产就绪**:从第一天起用于构建 Longbridge Pro,并在公开发布的商业桌面应用中持续打磨 - **原生体验**:设计灵感来自 macOS 与 Windows 的现代桌面控件 - **120 FPS**:GPU 加速界面,在高负载下依然保持流畅 - **数据表格**:虚拟滚动、固定列、列宽调整、排序与单元格选择,可承载数十万行数据 - **虚拟列表**:只渲染可见区域,并支持不同尺寸的列表项 - **代码编辑器**:20 万行、Tree-sitter 高亮、诊断、补全和悬浮提示 - **Dock 布局**:可调整面板、可拖拽标签、嵌套分割和边缘停靠 - **丰富内容**:原生 Markdown 与 HTML、语法高亮和图表 - **设计自由**:使用完整视觉系统,或基于 `gpui-base` 构建自己的系统 - **类型化动效**:CSS 对齐的 easing、timing、keyframes、spring、presence 与测量式展开,稳定采样路径零分配 - **跨平台**:通过一份 Rust 代码交付 macOS、Windows 和 Linux ## 下一步 - 阅读 [开始使用](./getting-started) - 浏览 [组件文档](../component/index) - 阅读 [GPUI Base 动画与动效](/zh-CN/base/motion) --- # Fonts Source: /versions/v0.6.4/zh-CN/docs/fonts ## 默认字体 每个应用都从主题自带的一套 UI 字体和等宽字体开始: | 用途 | 字体 | 字号 | | --- | --- | --- | | UI 文本 | `.SystemUIFont` | 16px | | 代码/等宽 | macOS:`Menlo`,Windows:`Consolas`,Linux:`DejaVu Sans Mono` | 13px | 编辑器使用 `mono_font_family` 和 `mono_font_size` 绘制代码,详见 [Editor](/versions/v0.6.4/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` 如果名称与已安装字体不匹配,GPUI 会静默回退——请在每个目标平台上确认准确的 family 名称。 ## 通过 Theme 修改字体 在 `Theme` 全局量上设置应用级字体,然后同步到底层: ```rust Theme::global_mut(cx).font_family = "Inter".into(); Theme::global_mut(cx).mono_font_family = "JetBrains Mono".into(); Theme::global_mut(cx).font_size = px(18.); Theme::sync_base(cx); window.refresh(); ``` `font_size` 同时是应用缩放控制——`Root` 会调用 `window.set_rem_size(cx.theme().font_size)`,因此基于 `rem` 的间距会跟随缩放。详见[编码指南](/versions/v0.6.4/zh-CN/docs/coding-guides)。 ## 元素级覆盖 任何元素都可以在不改动主题的情况下覆盖字体: ```rust div() .font_family("JetBrains Mono") .text_size(px(15.)) .font_weight(FontWeight::BOLD) ``` 这些就是普通的 [`Styled`](https://docs.rs/gpui/latest/gpui/trait.Styled.html) 方法,与样式链的其余部分组合使用。 ## 打包自定义字体 用户系统中没有的字体必须打包,并在**首帧之前**注册到文本系统: ```rust cx.text_system() .add_fonts(vec![Cow::Borrowed( include_bytes!("../fonts/MyFont-Regular.ttf").as_slice(), )]) .expect("Failed to load fonts"); ``` 之后照常用 family 名称引用: ```rust Theme::global_mut(cx).font_family = "MyFont".into(); Theme::sync_base(cx); ``` Web 版画廊就是这样打包 `Inter`、`JetBrains Mono`、`Noto Sans SC` 和 `IBM Plex Sans` 的,参见 `crates/story-web/src/lib.rs`。 ## 主题 JSON 配置 字体与字号也可以来自主题文件: ```json { "font.family": "Inter", "font.size": 16, "mono_font.family": "JetBrains Mono", "mono_font.size": 13 } ``` 用 `ThemeRegistry` 加载: ```rust ThemeRegistry::watch_dir(PathBuf::from("./themes"), cx, move |cx| { if let Some(theme) = ThemeRegistry::global(cx).themes().get(&theme_name).cloned() { Theme::global_mut(cx).apply_config(&theme); } }); ``` 完整配置说明参见 [Theme](/versions/v0.6.4/zh-CN/component/theme)。 ## WebAssembly 说明 浏览器不会向 WASM 应用暴露系统字体。在 `gpui-kit.com/gallery/` 运行的 `story-web` 画廊必须打包它用到的每一种字体,并在 `Theme::change` 之后重新 声明,否则文本系统会 panic。桌面应用完全不需要这一步。 打包字体画不出来的文字仍然可以交给浏览器绘制。Web 平台会用 Canvas 2D 和访问者本机的字体渲染 emoji,应用不必再打包 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); ``` 只要打包字体里有对应字形,就仍然优先使用打包字体。回退是逐个字素独立绘制的, 所以这样渲染的 CJK 文字以可读为先,不保证精确的间距和字体特性,外观也取决于 访问者机器上安装的字体。画廊选择了 `EmojiAndCjk`:它打包的字体只包含 故事本身用到的字形,访问者在输入框里键入的其他文字否则都会显示成方块。 --- # Sidebar Source: /versions/v0.6.4/zh-CN/component/sidebar 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") ) ``` --- # Table Source: /versions/v0.6.4/zh-CN/component/table 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 --- # Root View Source: /versions/v0.6.4/zh-CN/component/root [Root] 组件是 GPUI Component 在窗口中的根提供者。要启用 GPUI Component 的功能,必须把 [Root] 作为窗口中的 **第一层子节点**。 这一点很重要。如果不把 [Root] 放在窗口的第一层,许多行为都会出现异常或不符合预期。 下面这份完整的 **Tested consumer recipe** 在隔离的 `gpui-kit` 消费者工作区中编译。它会在创建窗口前初始化 GPUI Kit,将 `Root` 作为窗口的第一层视图,并渲染全部 Root 浮层。 ```rust use gpui_kit::component::Root; 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); cx.spawn(async move |cx| { cx.open_window(WindowOptions::default(), |window, cx| { let view = cx.new(|_| BootstrapView); cx.new(|cx| Root::new(view, window, cx)) }) .expect("failed to open window"); }) .detach(); }); } struct BootstrapView; impl Render for BootstrapView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { div() .size_full() .child("My application") .children(Root::render_dialog_layer(window, cx)) .children(Root::render_sheet_layer(window, cx)) .children(Root::render_notification_layer(window, cx)) } } ``` ## 窗口边框 默认情况下,[Root] 会渲染 GPUI Component 的客户端窗口边框包装层。`layer-shell` 全屏窗口等场景不应渲染这层边框,可以使用 `bordered(false)` 关闭: ```rs cx.new(|cx| Root::new(view, window, cx).bordered(false)) ``` ## 浮层 对话框、抽屉、通知等 UI 都需要一个统一的展示层,[Root] 提供了这些浮层的渲染入口: - [Root::render_dialog_layer](https://docs.rs/gpui-component/latest/gpui_component/struct.Root.html#method.render_dialog_layer) - 渲染当前打开的对话框 - [Root::render_sheet_layer](https://docs.rs/gpui-component/latest/gpui_component/struct.Root.html#method.render_sheet_layer) - 渲染当前打开的抽屉 - [Root::render_notification_layer](https://docs.rs/gpui-component/latest/gpui_component/struct.Root.html#method.render_notification_layer) - 渲染通知列表 在 `Root` 之下的第一层视图的 `render` 方法中放置这些图层;上方经过测试的 recipe 展示了所需的 `window, cx` 参数。 这里使用的是 `children` 而不是 `child`,因为当没有打开的 dialog、sheet 或 notification 时,这些方法会返回 `None`,GPUI 就不会渲染任何内容。 [Root]: https://docs.rs/gpui-component/latest/gpui_component/root/struct.Root.html --- # TextView Source: /versions/v0.6.4/zh-CN/component/text-view `TextView` 用于在 GPUI 中渲染格式化文本。它支持 Markdown、简单 HTML、文本选择、代码块操作,以及通过 Markdown 插件解析和渲染项目自定义语法。 标准实现现在位于 `gpui-base`;本模块保留兼容重导出和组件主题适配。仅使用 Base 时的设置、完整默认样式及可选语法高亮请参阅 [GPUI Base TextView](/zh-CN/base/text-view)。 `TextView::selectable(true)` 使用 `gpui-base` 提供的窗口级文本选择引擎。如果要让普通文本或自定义 renderer 参与同一选择,请参阅 [GPUI Base Text Selection](/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") ``` ### 流式文字淡入 聊天回复是分块到达的。`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](/zh-CN/base/text-view#保留状态与动态更新)。 ## 触摸选择 在触摸屏上,长按会选中手指下的单词,手指按住不放时选区跟随手指移动。抬起手指后,选区上方会出现包含 `复制` 和 `全选` 的编辑菜单,并在选区两端各显示一个拖动 handle。拖动 handle 会移动对应的一端,另一端保持不动;`全选` 选中被按下的那个视图,其 handle 仍可继续调整结果。 handle 和菜单由 [`Root`](/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.6.4/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())) }) ``` --- # Pagination Source: /versions/v0.6.4/zh-CN/component/pagination [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 --- # Attachment Source: /versions/v0.6.4/zh-CN/component/attachment `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`。 ## 媒体和图片预览 将 `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` 填满媒体区域。没有图片源时,媒体 slot 仍然可以展示图标;如果附件状态为 `Failed`,没有图片源的媒体区域使用 destructive 语义颜色。 ### 预览上的 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` 组合。加载、处理和失败状态会降低图片本身的透明度;`Pending` 与 `Complete` 保持完整不透明。 ## 上传生命周期 `AttachmentStatus` 只表达通用生命周期,具体的文件数据和请求状态由应用持有: | 状态 | 用途 | 默认视觉提示 | | --- | --- | --- | | `Pending` | 已选择,等待上传。 | 边框使用 dashed 样式。 | | `Uploading` | 正在传输文件。 | 标题显示 shimmer;图片预览变暗。 | | `Processing` | 上传完成,服务端正在处理。 | 标题显示 shimmer;图片预览变暗。 | | `Failed` | 上传或处理失败。 | 边框与描述使用 destructive 语义色;无图片媒体也使用 destructive 色。 | | `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("演示文稿")), ) ``` 尺寸使用共享的 design scale;应根据界面密度选择语义尺寸,避免在应用里为每个附件写独立高度。 `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` 的形式提供在键盘可达的位置——点击层只是指针便利,不参与焦点。 ## 状态继承与局部覆盖 通过具名 `.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` 保存。 ## 自定义样式与主题 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 之下。 | | `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 的横向滚动附件组。 | | 两者 | `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 --- # Combobox Source: /versions/v0.6.4/zh-CN/component/combobox 支持从可搜索列表中选择一个或多个值的下拉选择组件。 ## 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` — 圆角 --- # Radio Source: /versions/v0.6.4/zh-CN/component/radio 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. 选项较少时可横向排列,较多时更适合纵向排列。 --- # Shimmer Source: /versions/v0.6.4/zh-CN/component/shimmer `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 --- # Tabs Source: /versions/v0.6.4/zh-CN/component/tabs 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` 或滚动支持提升可用性 --- # Dialog Source: /versions/v0.6.4/zh-CN/component/dialog Dialog 用于创建普通对话框、确认框和提示弹窗。它支持遮罩层、键盘快捷键以及多种自定义能力。 ## 导入 ```rust use gpui_kit::component::dialog::DialogButtonProps; use gpui_kit::component::WindowExt; ``` ## 用法 ### 在应用根视图中渲染 Dialog 图层 如果你要展示对话框,需要在应用根视图中渲染 dialog layer。通常这会放在主应用结构体的 `render` 方法里。 [Root::render_dialog_layer](https://docs.rs/gpui-component/latest/gpui_component/struct.Root.html#method.render_dialog_layer) 会把当前激活的对话框渲染在应用内容之上。 ```rust use gpui_kit::component::TitleBar; struct MyApp { view: AnyView, } impl Render for MyApp { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let dialog_layer = Root::render_dialog_layer(window, cx); div() .size_full() .child( v_flex() .size_full() .child(TitleBar::new()) .child(div().flex_1().overflow_hidden().child(self.view.clone())), ) .children(dialog_layer) } } ``` ### 基础对话框 ```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") }) ``` ### 嵌套对话框 ```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. 对内容尺寸敏感的弹窗,建议显式设置宽度 --- # NumberInput Source: /versions/v0.6.4/zh-CN/component/number-input 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. 始终为输入提供清晰的标签与描述。 --- # Form Source: /versions/v0.6.4/zh-CN/component/form 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)) ) ``` --- # Menu Source: /versions/v0.6.4/zh-CN/component/menu # 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 --- # AlertDialog Source: /versions/v0.6.4/zh-CN/component/alert-dialog 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.6.4/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::dialog::DialogButtonProps; 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.") .button_props( DialogButtonProps::default() .ok_text("Delete") .ok_variant(ButtonVariant::Danger) .cancel_text("Keep") .show_cancel(true) ) .on_ok(|_, window, cx| { window.push_notification("Account deleted", cx); true }) }) ``` ### 带图标的 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 use gpui_kit::component::dialog::DialogButtonProps; window.open_alert_dialog(cx, |alert, _, _| { alert .title("Processing") .description("A process is running. Click Continue to stop it or Cancel to keep waiting.") .button_props( DialogButtonProps::default() .ok_text("Continue") .show_cancel(true) ) .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 | | `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.") .button_props( DialogButtonProps::default() .ok_text("Delete") .ok_variant(ButtonVariant::Danger) .show_cancel(true) ) .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 --- # 主题 Source: /versions/v0.6.4/zh-CN/component/theme # Theme 所有组件都支持内置主题系统。[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::global_mut(cx).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 --- # DataTable Source: /versions/v0.6.4/zh-CN/component/data-table 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 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); }); ``` ## 列宽调整与列移动 ```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] - 表格事件 ### 常见方法 #### TableState - `new(delegate, window, cx)` - `cell_selectable(bool)` - `row_selectable(bool)` - `col_selectable(bool)` - `selected_cell()` - `set_selected_cell(row_ix, col_ix, cx)` - `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 [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 --- # Slider Source: /versions/v0.6.4/zh-CN/component/slider 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` | 焦点移动到上一个元素 | --- # Icon Source: /versions/v0.6.4/zh-CN/component/icon Icon 支持通过资源路径或内存中的字节渲染 SVG 图标,并可定制尺寸、颜色与变换。内置的 Lucide 图标使用资源包;自定义 SVG 字节可以通过 `Icon::data` 直接传入。 在开始之前,建议先阅读 [Icons & Assets](/versions/v0.6.4/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`,应用额外需要的 SVG 由自己的 `AssetSource` 提供, 并在未找到时回退到默认资源源。也可以用可选的 `icon_assets!` 声明应用额外使用的 内置 SVG,然后将其资源源与默认资源源组合。详见 [Icons & Assets](/versions/v0.6.4/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); // 使用 SVG 字节 Icon::default().data(bytes).path("icons/search.svg"); // 使用资源路径 ``` 字节图标与路径图标使用相同的 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 下效果最佳,并且在其它尺寸下也有良好缩放表现。 --- # DescriptionList Source: /versions/v0.6.4/zh-CN/component/description-list 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 列以内。 - 使用分隔线对相关信息分组。 - 标签保持简洁且语义明确。 - 使用尺寸属性统一间距和密度。 - 嵌入式场景下可考虑关闭边框。 --- # Scrollable Source: /versions/v0.6.4/zh-CN/component/scrollable 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() }, ) } } ``` --- # Stepper Source: /versions/v0.6.4/zh-CN/component/stepper 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 --- # Settings Source: /versions/v0.6.4/zh-CN/component/settings > 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![...]) ``` ## 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![...]) ``` ## 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 --- # Focus Trap Source: /versions/v0.6.4/zh-CN/component/focus-trap Focus Trap 是一个用于将键盘焦点限制在特定容器内的工具能力,可防止用户通过 Tab 键把焦点移出当前区域。它对对话框、侧边面板和自定义覆盖层的可访问性非常重要。 **注意:** [Dialog](/zh-CN/component/dialog) 和 [Sheet](/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](/zh-CN/component/root) - [Dialog](/zh-CN/component/dialog) - [Sheet](/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]: /zh-CN/component/dialog [Sheet]: /zh-CN/component/sheet --- # ColorPicker Source: /versions/v0.6.4/zh-CN/component/color-picker ColorPicker 是一个通用的颜色选择组件,提供直观的颜色选择界面。它支持颜色面板、十六进制输入、精选颜色,以及 RGB、HSL 和十六进制格式,并支持 alpha 透明通道。 ## 导入 ```rust use gpui_kit::component::color_picker::{ColorPicker, ColorPickerState, ColorPickerEvent}; ``` ## 用法 ### 基础 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) ``` ## 颜色选择界面 ### 调色板 组件内置多组颜色家族: - **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] - [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 [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 --- # GroupBox Source: /versions/v0.6.4/zh-CN/component/group-box 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")) ``` ### 自定义 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 --- # Popover Source: /versions/v0.6.4/zh-CN/component/popover 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 如何贴合触发器,使用 [`Anchor`] 类型。 可以把 Popover 想象成有一个箭头尖角(像对话气泡的小三角)。anchor 指的就是这个尖角相对于触发器落在哪个点——`Anchor::TopLeft` 把它放在触发器的左上角,`Anchor::BottomRight` 放在右下角,以此类推。Popover 就从这个点挂出来。 例如 `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("Anchored to the trigger's top-center") ``` ### 在 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.") ``` [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 --- # Resizable Source: /versions/v0.6.4/zh-CN/component/resizable 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() ) ``` ### 面板尺寸约束 ```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. 为拖拽句柄保留足够交互空间,提升体验。 --- # Collapsible Source: /versions/v0.6.4/zh-CN/component/collapsible 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 动画与动效](/zh-CN/base/motion)。 [Collapsible]: https://docs.rs/gpui-component/latest/gpui_component/collapsible/struct.Collapsible.html --- # Command Source: /versions/v0.6.4/zh-CN/component/command 命令面板是带有分组、由 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 --- # DatePicker Source: /versions/v0.6.4/zh-CN/component/date-picker DatePicker 是一个灵活的日期选择组件,内置日历界面,支持单日期选择、日期范围选择、自定义格式、禁用日期和预设范围。 ## 导入 ```rust use gpui_kit::component::{ date_picker::{DatePicker, DatePickerState, DateRangePreset, DatePickerEvent}, calendar::{Date, Matcher}, }; ``` ## 用法 ### 基础 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) ``` ### 自定义日期格式 ```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(date) => { match 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(Date::Range(Some(start), Some(end))) => { 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") ``` --- # Input Source: /versions/v0.6.4/zh-CN/component/input 多个附加元素、共享外框和文本域工具栏的组合方式,见 [Input Group](/versions/v0.6.4/zh-CN/component/input-group)。 Input 是一个单行文本输入组件,支持校验、输入掩码、前后缀元素以及多种交互状态。普通多行文本请使用 [Textarea](/versions/v0.6.4/zh-CN/component/textarea),源代码编辑请使用 [Editor](/versions/v0.6.4/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)) ``` ### 触摸选择 在触摸屏上,长按会选中手指下的单词,手指按住不放时选区跟随手指移动。抬起手指后,选区上方会出现编辑菜单,列出当前可用的命令——`剪切`、`复制`、`粘贴` 和 `全选`——并在选区两端各显示一个拖动 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)) ``` --- # Switch Source: /versions/v0.6.4/zh-CN/component/switch 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) ``` ## 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` 状态 | ### 样式 实现了 `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 - 背景色会在关闭色与激活色之间过渡 - 圆点位置会平滑移动 - 禁用状态下不会触发交互动效 --- # Message Source: /versions/v0.6.4/zh-CN/component/message `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` 不持有发送者、时间戳、送达状态、reaction 或操作状态;这些数据由应用生成对应的 child。 - `MessageContent::bubble(...)` 是专门用于 Bubble 的类型化便利入口,用于传播 Ghost surface 元信息;其他 element 使用普通 `.child(...)`。 - 应用操作使用 `Button`,URL 使用 `Link`,附件使用 `Attachment`;不创建消息专用的 Action、Link 或 Attachment wrapper。 - 需要消息列表、尾部跟随、未读定位或历史加载时,使用 `MessageScroller` 管理虚拟列表;Message 只负责单行布局。 ## 可访问性 - Avatar 是身份辅助信息,不应是唯一的发送者标识;Header 应提供可读发送者或系统来源。 - 时间、送达状态、失败状态和未读信息应作为可读文本提供,不能只用颜色、位置或 icon 表达。 - Footer 中的 icon-only Button 应提供可见的 `.label(...)` 或其他可读名称,tooltip 只作为补充提示;发送者操作应使用明确的 Button/Link 语义。 - Bubble、Attachment 和富文本 child 的键盘行为由各自组件负责;Message 不会自动为普通 `div` 增加焦点。 - 应用自定义消息动画时,应在 reduced motion 下保持静态结果;Message 自身没有额外动画。 - 长消息和代码内容应保持可读的换行、选择和滚动策略,不要依赖 hover 才能访问完整内容。 ## API 参考 ### `Message` | 方法 | 说明 | | --- | --- | | `new()` | 创建默认起始侧对齐的消息。 | | `alignment(MessageAlignment)` | 设置起始侧或结束侧对齐。 | | `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 --- # List Source: /versions/v0.6.4/zh-CN/component/list 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); }); ``` --- # Notification Source: /versions/v0.6.4/zh-CN/component/notification Notification 是一个 toast 通知系统,用于向用户显示短暂消息。通知会出现在窗口右上角,并可在超时后自动消失。它支持多种类型、标题、自定义内容和操作按钮,适合状态反馈、确认信息和异步操作提示。 ## 导入 ```rust use gpui_kit::component::{ notification::{Notification, NotificationType}, WindowExt }; ``` ## 用法 ### 在根视图中渲染通知层 如果你想显示通知,需要在应用根视图中渲染 notification layer。 [Root::render_notification_layer](https://docs.rs/gpui-component/latest/gpui_component/struct.Root.html#method.render_notification_layer) 会将当前激活的通知渲染在应用内容之上。 ```rust use gpui_kit::component::{TitleBar, Root}; struct Example {} impl Render for Example { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let notification_layer = Root::render_notification_layer(window, cx); div() .size_full() .child( v_flex() .size_full() .child(TitleBar::new()) .child(div().flex_1().child("Hello world!")), ) .children(notification_layer) } } ``` ### 基础通知 ```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::global_mut(cx).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::global_mut(cx).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, ); ``` --- # Bubble Source: /versions/v0.6.4/zh-CN/component/bubble `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"), ), ), ) ``` 较长文本仍由调用方决定换行、截断和最小宽度。可将 `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 --- # Image Source: /versions/v0.6.4/zh-CN/component/image Image 组件为图片展示提供了更稳健的封装,支持加载态、回退内容、响应式尺寸以及多种图片来源。它基于 GPUI 原生图片能力构建,可处理 URL、本地文件和 SVG 等资源,并便于结合主题与布局系统做统一样式控制。 ## 导入 ```rust use gpui_kit::{img, ImageSource, ObjectFit}; use gpui_kit::component::{v_flex, h_flex, div, Icon, IconName}; ``` ## 用法 ### 基础图片 ```rust // 来自 URL 的图片 img("https://example.com/image.jpg") // 本地图片文件 img("assets/logo.png") // SVG 图片 img("icons/star.svg") ``` ### 设置尺寸 ```rust // 固定尺寸 img("https://example.com/photo.jpg") .w(px(300.)) .h(px(200.)) // 响应式宽度并限制最大宽度 img("https://example.com/banner.jpg") .w(relative(1.)) .max_w(px(800.)) .h(px(400.)) // 正方形图片 img("https://example.com/avatar.jpg") .size(px(100.)) ``` ### Object Fit 选项 用于控制图片在容器中的缩放与定位方式: ```rust // Cover:填满容器,可能裁剪 img("https://example.com/photo.jpg") .w(px(300.)) .h(px(200.)) .object_fit(ObjectFit::Cover) // Contain:完整显示,保持比例 img("https://example.com/photo.jpg") .w(px(300.)) .h(px(200.)) .object_fit(ObjectFit::Contain) // Fill:拉伸填满,可能变形 img("https://example.com/photo.jpg") .w(px(300.)) .h(px(200.)) .object_fit(ObjectFit::Fill) // ScaleDown:类似 contain,但不会放大 img("https://example.com/photo.jpg") .w(px(300.)) .h(px(200.)) .object_fit(ObjectFit::ScaleDown) // None:保持原始尺寸 img("https://example.com/photo.jpg") .w(px(300.)) .h(px(200.)) .object_fit(ObjectFit::None) ``` ### 回退内容 ```rust fn image_with_fallback(src: &str, alt_text: &str) -> impl IntoElement { div() .w(px(300.)) .h(px(200.)) .bg(cx.theme().surface) .border_1() .border_color(cx.theme().border) .rounded(px(8.)) .overflow_hidden() .child( img(src) .w_full() .h_full() .object_fit(ObjectFit::Cover) // 实际项目中可在这里补充错误处理 ) } fn image_with_icon_fallback(src: &str) -> impl IntoElement { div() .size(px(200.)) .bg(cx.theme().surface) .border_1() .border_color(cx.theme().border) .rounded(px(8.)) .flex() .items_center() .justify_center() .child( img(src) .size_full() .object_fit(ObjectFit::Cover) // 加载失败时可改为显示图标占位 ) } ``` ### 加载状态 ```rust fn image_with_loading(src: &str, is_loading: bool) -> impl IntoElement { div() .w(px(400.)) .h(px(300.)) .rounded(px(8.)) .overflow_hidden() .map(|this| { if is_loading { this.bg(cx.theme().muted) .flex() .items_center() .justify_center() .child("Loading...") } else { this.child( img(src) .w_full() .h_full() .object_fit(ObjectFit::Cover) ) } }) } fn progressive_image(src: &str, placeholder_src: &str) -> impl IntoElement { div() .relative() .w(px(400.)) .h(px(300.)) .rounded(px(8.)) .overflow_hidden() .child( img(placeholder_src) .absolute() .inset_0() .w_full() .h_full() .object_fit(ObjectFit::Cover) .opacity(0.5) ) .child( img(src) .absolute() .inset_0() .w_full() .h_full() .object_fit(ObjectFit::Cover) ) } ``` ### 响应式图片 ```rust fn responsive_image_grid() -> impl IntoElement { div() .grid() .grid_cols(3) .gap_4() .child( img("https://example.com/photo1.jpg") .w_full() .aspect_ratio(1.0) .object_fit(ObjectFit::Cover) .rounded(px(8.)) ) .child( img("https://example.com/photo2.jpg") .w_full() .aspect_ratio(1.0) .object_fit(ObjectFit::Cover) .rounded(px(8.)) ) .child( img("https://example.com/photo3.jpg") .w_full() .aspect_ratio(1.0) .object_fit(ObjectFit::Cover) .rounded(px(8.)) ) } fn hero_image() -> impl IntoElement { div() .relative() .w_full() .h(px(500.)) .rounded(px(12.)) .overflow_hidden() .child( img("https://example.com/hero-image.jpg") .absolute() .inset_0() .w_full() .h_full() .object_fit(ObjectFit::Cover) ) .child( div() .absolute() .inset_0() .bg(rgba(0, 0, 0, 0.4)) .flex() .items_center() .justify_center() .child( v_flex() .items_center() .gap_4() .child("Hero Title") .child("Subtitle text here") ) ) } ``` ### 图片画廊 ```rust fn image_gallery(images: Vec<&str>) -> impl IntoElement { v_flex() .gap_6() .child( div() .w_full() .h(px(400.)) .rounded(px(12.)) .overflow_hidden() .child( img(images[0]) .w_full() .h_full() .object_fit(ObjectFit::Cover) ) ) .child( h_flex() .gap_3() .children( images.iter().map(|src| { div() .size(px(80.)) .rounded(px(6.)) .overflow_hidden() .border_2() .border_color(cx.theme().border) .cursor_pointer() .hover(|this| this.border_color(cx.theme().primary)) .child( img(*src) .size_full() .object_fit(ObjectFit::Cover) ) }) ) ) } ``` ### SVG 图片 ```rust img("assets/icons/logo.svg") .size(px(64.)) .text_color(cx.theme().primary) img("data:image/svg+xml;base64,...") .w(px(32.)) .h(px(32.)) img("assets/spinner.svg") .size(px(24.)) .text_color(cx.theme().primary) // 实际使用中可叠加旋转动画 ``` ## API 参考 ### 核心函数 | 函数 | 说明 | | --- | --- | | `img(source)` | 基于 `ImageSource` 创建图片元素 | ### 图片来源(ImageSource) | 类型 | 说明 | 示例 | | --- | --- | --- | | String / &str | URL 或文件路径 | `"https://example.com/image.jpg"` | | SharedUri | 共享 URI 引用 | `SharedUri::from("file://path")` | | Local Path | 本地文件系统路径 | `"assets/logo.png"` | | Data URI | Base64 编码图片 | `"data:image/png;base64,..."` | ### 尺寸方法 | 方法 | 说明 | | --- | --- | | `w(length)` | 设置宽度 | | `h(length)` | 设置高度 | | `size(length)` | 同时设置宽高 | | `w_full()` | 占满容器宽度 | | `h_full()` | 占满容器高度 | | `size_full()` | 占满容器尺寸 | | `max_w(length)` | 设置最大宽度 | | `max_h(length)` | 设置最大高度 | | `min_w(length)` | 设置最小宽度 | | `min_h(length)` | 设置最小高度 | ### Object Fit 选项 | 值 | 说明 | | --- | --- | | `ObjectFit::Cover` | 填满容器,可能裁剪 | | `ObjectFit::Contain` | 完整显示在容器内 | | `ObjectFit::Fill` | 拉伸填满容器 | | `ObjectFit::ScaleDown` | 类似 contain,但不会放大 | | `ObjectFit::None` | 保持原始尺寸 | ### 样式方法 | 方法 | 说明 | | --- | --- | | `rounded(radius)` | 设置圆角 | | `border_1()` | 1px 边框 | | `border_color(color)` | 设置边框颜色 | | `opacity(value)` | 设置透明度(0.0-1.0) | | `shadow_sm()` | 小阴影 | | `shadow_lg()` | 大阴影 | ## 最佳实践 ### 图片优化 - 根据展示尺寸提供合适分辨率的图片 - 在保证质量的前提下尽量压缩资源 - 优先考虑 WebP、AVIF 等现代格式 - 为不同屏幕尺寸准备响应式图片 ### 错误处理 - 为加载失败提供明确回退内容 - 使用骨架屏保持布局稳定 - 对临时网络错误考虑重试机制 - 对永久失败给出可理解的用户提示 ### 性能 - 对首屏外图片使用懒加载 - 配合缓存策略减少重复请求 - 加载期间可使用低质量占位图 - 图片尺寸应与实际展示上下文匹配 ### 用户体验 - 图片网格中保持一致的宽高比 - 使用平滑的加载过渡 - 根据内容类型选择合适的 `object-fit` - 细节图可考虑提供缩放能力 --- # TitleBar Source: /versions/v0.6.4/zh-CN/component/title-bar 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 才会渲染自定义窗口控制按钮 - 拖拽窗口的逻辑已内置在合适区域中 - 自定义样式时应考虑各平台对标题栏的交互习惯 --- # Carousel Source: /versions/v0.6.4/zh-CN/component/carousel 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 动画会遵循应用的减少动效设置。 --- # Alert Source: /versions/v0.6.4/zh-CN/component/alert 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 --- # Toolbar Source: /versions/v0.6.4/zh-CN/component/toolbar 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 默认没有背景和边框,由宿主表面提供。 --- # Tag Source: /versions/v0.6.4/zh-CN/component/tag 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()` - 纯展示标签不应默认承担交互职责 --- # Button Source: /versions/v0.6.4/zh-CN/component/button [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 --- # Marker Source: /versions/v0.6.4/zh-CN/component/marker `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, 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 的装饰线是内部实现,不携带语义内容。文本本身应说明它代表的日期、边界或状态。 ## 状态内容与图标 应用可以组合 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`。 | | `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] - [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 [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 --- # Textarea Source: /versions/v0.6.4/zh-CN/component/textarea `Textarea` 用于普通多行文本。单行输入请使用 [Input](/versions/v0.6.4/zh-CN/component/input),源代码编辑请使用 [Editor](/versions/v0.6.4/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) .h(px(160.)) .bordered(true) .disabled(false) .readonly(false) .aria_label("备注") ``` 与 `disabled` 不同,只读 Textarea 保持正常外观,仍然可以聚焦、选中和复制,只是拒绝用户对内容的修改。 `Textarea` 不提供只适用于单行 Input 的前后缀、密码显示切换和清除按钮;相关操作应组合在 Textarea 外部。 --- # Select Source: /versions/v0.6.4/zh-CN/component/select 在 `<= 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 --- # Input Group Source: /versions/v0.6.4/zh-CN/component/input-group 使用 `InputGroup` 可以在输入框或文本域周围添加文本、图标、按钮和工具栏, 并将它们放在同一个外框内。简单的前缀或后缀可以使用 [Input](/versions/v0.6.4/zh-CN/component/input)。 下面的示例定义了可用于 GPUI Kit 应用的视图。应用初始化方式见 [快速开始](/versions/v0.6.4/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.6.4/zh-CN/component/input),使用 `InputState` | | `InputGroupTextarea` | 放入组合中的 [Textarea](/versions/v0.6.4/zh-CN/component/textarea),使用 `TextareaState` | | `InputGroupAddon` | 放置文本、图标、按钮或自定义内容 | | `InputGroupButton` | 带有紧凑组合样式的 [Button](/versions/v0.6.4/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.6.4/zh-CN/component/menu); `.dropdown_caret(true)` 会在文字右侧绘制下拉箭头。 上下文帮助可以将 `InputGroupButton` 传给 [Popover](/versions/v0.6.4/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.6.4/zh-CN/component/textarea)。 ## 禁用、只读与校验 | 方法 | 效果 | | --- | --- | | `.disabled(true)` | 禁用输入区域及直接添加的 `InputGroupButton` 子部件 | | `.readonly(true)` | 禁止编辑,同时允许聚焦、选择、复制和附加操作 | | `.invalid(true)` | 显示错误状态,允许继续编辑 | 输入部件设置 `.disabled(true)` 时,整个组合也会禁用。 自定义交互内容和经过包装的控件需要分别传入禁用状态。 根据校验结果设置 `.invalid(...)`,并在旁边显示错误说明。 需要拒绝特定编辑内容时,使用 [`InputState::validate`](/versions/v0.6.4/zh-CN/component/input)。 即使为整个组合设置了名称,也应为输入控件单独提供 `.aria_label(...)`。 在 `InputGroupInput` 上通过 `.content_type(...)` 设置 URL、邮箱等输入提示, 通过 `InputState::masked` 配置密码遮罩。两个输入部件都支持用 `.context_menu(...)` 自定义右键菜单。 在触屏设备上,长按文本可以选择单词,拖动选择手柄可以调整范围, 编辑菜单提供剪切、复制、粘贴和全选操作。 在 Rust 中,两个输入部件还支持 `.on_paste(...)`,可在插入文本前处理剪贴板中的图片和文件。 返回 `true` 表示已处理此次粘贴,返回 `false` 则继续默认的文本插入。 输入控件处于禁用或只读状态时,不会调用该回调。 附件处理示例及 Web 端限制见 [粘贴回调](/versions/v0.6.4/zh-CN/component/input#粘贴钩子)。 ## 尺寸与样式 组合的默认尺寸为 Medium。导入 `Sizable` 后,可以使用 `.xsmall()`、`.small()`、 `.large()` 或 `.with_size(Size::Medium)`;尺寸决定外框高度、文字大小,以及附加区域与控件共用的内边距。 颜色、圆角、焦点环和错误外环遵循当前 [Theme](/versions/v0.6.4/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 <应用目录>` 可生成编辑器补全声明。 --- # Avatar Source: /versions/v0.6.4/zh-CN/component/avatar 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 --- # Editor Source: /versions/v0.6.4/zh-CN/component/editor `Editor` 用于编辑源代码。单行输入请使用 [Input](/versions/v0.6.4/zh-CN/component/input),普通多行文本请使用 [Textarea](/versions/v0.6.4/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` 保留多久;文本修改后,其 range 会自动跟随内容变化。 ## 值与事件 ```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 的工具栏和操作按钮应组合在组件外部。 --- # Tree Source: /versions/v0.6.4/zh-CN/component/tree 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` | 自定义动作 | --- # Checkbox Source: /versions/v0.6.4/zh-CN/component/checkbox 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 --- # OtpInput Source: /versions/v0.6.4/zh-CN/component/otp-input 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 ``` --- # StatusBar Source: /versions/v0.6.4/zh-CN/component/status-bar 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`。 --- # Clipboard Source: /versions/v0.6.4/zh-CN/component/clipboard Clipboard 组件提供了一个简单的复制按钮,可将文本或其它数据复制到用户剪贴板。它默认显示复制图标,在复制成功后会切换为勾选图标。组件既支持静态值,也支持通过回调动态生成复制内容。 ## 导入 ```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") .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 --- # Label Source: /versions/v0.6.4/zh-CN/component/label 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")) ``` --- # Questionnaire Source: /versions/v0.6.4/zh-CN/component/questionnaire `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 默认单选:激活某个选项后即有答案,`Next` 可以继续;`with_multiple` 则保留 所有已选项。答案 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"); }); ``` 用户意图应使用 `activate_choice`、`confirm_current`、`go_previous`、`go_next`、 `skip_current` 和 `submit`。这些路径会发出相应的 `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 确认已填写的答案。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 --- # Progress Source: /versions/v0.6.4/zh-CN/component/progress 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"), ) } } ``` --- # Sheet Source: /versions/v0.6.4/zh-CN/component/sheet Sheet 是一种从屏幕边缘滑出的面板组件,也常被用作侧栏、抽屉或临时内容面板。它适合承载导航菜单、表单、设置项和辅助信息,而不会直接占用主视图空间。 ## 导入 ```rust use gpui_kit::component::WindowExt; use gpui_kit::component::Placement; ``` ## 用法 ### 在根视图中渲染 Sheet 图层 如果应用要支持 Sheet,需要在根视图中渲染 sheet layer。 [Root::render_sheet_layer](https://docs.rs/gpui-component/latest/gpui_component/struct.Root.html#method.render_sheet_layer) 会把当前激活的 Sheet 渲染到应用内容之上。 ```rust use gpui_kit::component::TitleBar; struct MyApp { view: AnyView, } impl Render for MyApp { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let sheet_layer = Root::render_sheet_layer(window, cx); div() .size_full() .child( v_flex() .size_full() .child(TitleBar::new()) .child(div().flex_1().overflow_hidden().child(self.view.clone())), ) .children(sheet_layer) } } ``` ### 基础 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. 对于复杂内容,尽量延迟加载内部数据 --- # DropdownButton Source: /versions/v0.6.4/zh-CN/component/dropdown_button [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 --- # VirtualList Source: /versions/v0.6.4/zh-CN/component/virtual-list 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. 测试不同数据量和滚动位置下的表现 --- # Kbd Source: /versions/v0.6.4/zh-CN/component/kbd 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 的方法覆盖。 --- # Tooltip Source: /versions/v0.6.4/zh-CN/component/tooltip Tooltip 用于在鼠标悬停或元素获得焦点时显示补充信息。它支持纯文本、自定义内容、快捷键信息以及多种触发方式,适合做解释说明、状态提示和操作说明。 ## 移动端行为 iOS 和 Android 上会禁用由 GPUI Base overlay 管理的 tooltip。共享组件可以保留 tooltip 配置,但移动端操作仍应提供可见或可访问的标签。直接使用 GPUI `.tooltip()` 的调用(包括下方的基础 `div()` 示例)不经过此 overlay,因此不受该策略限制。集成方式请参阅[移动端](/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 应尽量避免复杂内容,以减少渲染开销。 --- # Plot Source: /versions/v0.6.4/zh-CN/component/plot `plot` 模块提供了构建自定义图表所需的底层能力,包括比例尺、图形和辅助工具。高层 `Chart` 组件也是基于这些原语实现的,适合需要完全控制图表绘制逻辑的场景。 ## 导入 ```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.], vec![0., 500.] ); scale.tick(&50.); ``` ### ScaleBand ```rust let scale = ScaleBand::new( vec!["A", "B", "C"], vec![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"], vec![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()).collect(), vec![0., width], ); let y = ScaleLinear::new(vec![0., max_value], vec![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); } } --- # MessageScroller Source: /versions/v0.6.4/zh-CN/component/message-scroller `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 --- # 组件 Source: /versions/v0.6.4/zh-CN/component ## 基础组件 - [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) - 日期选择器 - [OtpInput](otp-input) - 一次性验证码输入 - [ColorPicker](color-picker) - 颜色选择器 - [Form](form) - 表单容器与布局 ## 布局与高级组件 - [Root](root) - 窗口级的主题、对话框与通知的根提供者 - [Theme](theme) - 定制颜色、字体与明暗外观 - [Command](command) - 用于搜索与快捷操作的命令面板 - [Dialog](dialog) - 对话框与模态窗口 - [Popover](popover) - 浮层内容 - [Resizable](resizable) - 可调整大小的面板 - [Scrollable](scrollable) - 可滚动容器 - [Sidebar](sidebar) - 侧边栏导航 - [StatusBar](status-bar) - 底部状态栏,含左/中/右三区 - [Chart](chart) - 图表组件 - [Carousel](carousel) - 浏览一组相关内容 - [DataTable](data-table) - 高性能数据表格 - [Dock](dock) - 支持标签、分割与状态持久化的生产级 Dock 布局 - [Tree](tree) - 树形结构组件 - [VirtualList](virtual-list) - 大数据量虚拟列表 ## 翻译说明 组件页已经预置中文路由结构,尚未完成的页面会先显示中文占位说明,并回链到英文原文: - [English version](/component) --- # Skeleton Source: /versions/v0.6.4/zh-CN/component/skeleton 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% 透明度,让占位效果更柔和。 --- # Chart Source: /versions/v0.6.4/zh-CN/component/chart 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) ``` ### 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` 控制数值轴被 均分为多少个区间。该数量同时决定网格线间距和刻度标签,两者始终保持一致。 注意 `value_tick_count` 是一个数量,而 `tick_margin` 是分类轴上的步长—— `tick_margin(2)` 表示每隔一个分类保留一个标签。 ```rust // 纵向柱状图的数值标签位于左侧,横向柱状图位于下方 BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .value_axis(true) // 将数值轴均分为 6 个区间(默认为 4) BarChart::new(data) .band(|d| d.category.clone()) .value(|d| d.value) .value_axis(true) .value_tick_count(6) ``` ### 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() ``` ### 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 图表在设置 `id` 之前都是静态绘图。设置之后,它会对光标做命中测试,为光标所在的数据显示 tooltip,并按图表类型强调这条数据: ```rust LineChart::new(data) .x(|d| d.date.clone()) .y(|d| d.value) .name("Desktop") // tooltip 行中的系列名 .id("visitors") // 在同级元素中须唯一 ``` | 图表 | 悬停时 | | --- | --- | | `LineChart`、`AreaChart` | 十字线和每个系列的圆点沿折线滑到悬停的数据点,圆点外扩出一圈光晕。 | | `BarChart` | 与柱同宽的高亮条滑到悬停的柱,其余柱淡出到它后面。 | | `PieChart` | 悬停的扇区从圆环中抬起,其余扇区淡出;tooltip 显示数值与占比。 | | `RadarChart` | 每个系列的圆点沿多边形滑到悬停的辐条。 | | `CandlestickChart` | 高亮条滑到悬停的 K 线;tooltip 列出开盘、最高、最低、收盘。 | | `SankeyChart` | 悬停节点的连接保持颜色,其余淡出;tooltip 显示节点的标签与流量。 | tooltip 框跟随光标,靠近边缘时翻向绘图区中心。`AreaChart` 与 `RadarChart` 每个系列各取一个 `.name()`,在对应的 `.y()` / `.value()` 之后调用。 ### 动效 强调效果使用样式层的 motion tokens(`cx.theme().motion_tokens()`)驱动:十字线、高亮条、圆点等指示器以快速弹簧跟随悬停的数据,饼图扇区以 control 弹簧抬起,整个覆盖层在光标落到数据上时淡入、离开后淡出。动效遵循操作系统的减弱动态效果偏好,开启后所有值立即到达目标。 ### 缓存 设置了 `id` 的图表还会跨帧保留较重的几何计算,因为图表在屏幕上的每一帧都会重绘:折线与面积的描边、饼图扇区在投影点不变时保持已细分的路径,桑基图在数据、设置和尺寸不变时保留布局。没有 `id` 的图表每次绘制都重新计算,否则同级图表会共用同一份缓存。 ### 自定义 Plot 自定义 [`Plot`] 以同样的方式接入:在 `Plot::id` 返回 id,在 `Plot::tooltip_state` 解析光标所在的数据,在 `Plot::tooltip` 构建覆盖层。要为强调效果加动画,实现 `Plot::hover`——它在每帧的 `tooltip` 与 `paint` 之前运行,收到当前聚焦的 [`PlotHover`];它携带 `TooltipState`,光标离开后会保留一段时间,`hover.focus()` 逐渐回到零,因此在这里采样动效并把结果存到 `self` 供另外两个方法使用。`tooltip` 返回的 `Tooltip` 会自动随悬停淡入淡出: ```rust fn hover(&mut self, hover: Option<&PlotHover>, window: &mut Window, cx: &mut App) { self.band_center = hover.map(|hover| { spring( ("my-plot", "band"), hover.state().cross_line.x, // 悬停的第一帧直接采用该数据,而不是从上次悬停结束处滑过来。 cx.theme().motion_tokens().spring_control.with_travel(!hover.is_entering()), window, cx, ) }); } fn tooltip(&self, state: &TooltipState, cursor: Point, bounds: Bounds, _: &mut Window, cx: &mut App) -> Option { let center = self.band_center.unwrap_or(state.cross_line.x); Some( Tooltip::new(cursor, bounds.size) .cross_line(CrossLine::new(point(center, state.cross_line.y)).band(px(24.))) .title("Title") .row(cx.theme().chart_1, "Series", "42") .into_any_element(), ) } ``` `Dot::halo(size)` 绘制内置图表放在悬停圆点后面的半透明光晕。 ## 数据结构示例 ```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) ) ``` ### 默认样式 图表默认会自动包含: - 虚线网格 - 自动定位的 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 --- # Accordion Source: /versions/v0.6.4/zh-CN/component/accordion 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 --- # Calendar Source: /versions/v0.6.4/zh-CN/component/calendar 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 --- # Rating Source: /versions/v0.6.4/zh-CN/component/rating 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 --- # HoverCard Source: /versions/v0.6.4/zh-CN/component/hover-card HoverCard 用于在鼠标悬停到触发元素时显示富内容浮层,适合做用户资料预览、链接预览和上下文信息展示。它支持打开和关闭延迟,以减少鼠标快速移动时的闪烁问题。 它和 [Popover] 很像,但触发方式是 hover 而不是 click,并且提供了更细的时间控制。 iOS 和 Android 上点击触发元素即可打开或关闭卡片,点击外部关闭,点击内容区域保持打开。移动端不使用悬停延迟;Tooltip 提示仍保持禁用。参阅[移动端](/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 --- # Badge Source: /versions/v0.6.4/zh-CN/component/badge 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 --- # Dock Source: /versions/v0.6.4/zh-CN/component/dock Dock 用可拖动标签组、嵌套分割和可收起的左、右、底部 Dock 构建应用工作区。它是 Longbridge 在商业产品中长期使用的布局基础,而不是一个脱离实际项目的 UI Demo。 `gpui-base` 负责数据模型、布局计算和拖放行为,`gpui-component` 提供完整控件与统一视觉。需要直接用于真实应用的 Dock 时,请使用 `gpui_kit::component::dock`。 如果你需要了解与渲染器无关的架构或实现自定义渲染器,请阅读英文版 [Dock — gpui-base](/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)。 --- # Empty Source: /versions/v0.6.4/zh-CN/component/empty `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。 --- # Spinner Source: /versions/v0.6.4/zh-CN/component/spinner 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...") ``` --- # Toggle Source: /versions/v0.6.4/zh-CN/component/toggle 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 状态应与实际业务状态保持同步,避免视觉与数据不一致。 --- # 样式 Source: /versions/v0.6.4/zh-CN/shell/styling # Styling 呈现权在脚本,所以一个应用的大部分代码都写在这里。所有元素接受同一套样式接口,写成一条流式链——与 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` 对象就是这么做的。 --- # 引擎接缝 Source: /versions/v0.6.4/zh-CN/shell/engine # The Engine Seam 脚本引擎位于一条内部接口之后。这条分界线(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` 不再等于一帧渲染之后,一次描述在应用状态变动时构建,然后[被之后的每一帧复用,直到状态再次变动](/versions/v0.6.4/zh-CN/shell/state#render-什么时候执行)——所以下面这笔成本是按用户操作付,不是按重绘付。这让边界成本不像从前那么关键,但它并没有变成免费,而且仍然是决定要不要引入第二个引擎的那个数字。 所以这条分界线是一种“不必提前判断正确”的做法。决定交给实测,而第二个引擎会是一个新模块,不是一次重写。 JavaScript 是默认,理由只有一条,而且是产品理由不是技术理由:**应用代码用它写出来更好读。** 呈现权在脚本手上,应用的绝大部分就是组合元素、写样式、处理事件——这类代码的可读性直接决定这个运行时值不值得用。类、箭头函数、模板字符串与解构,恰好落在这类代码上。附带收益是 JavaScript 在模型训练语料里覆盖最好,这对[三种场景](/versions/v0.6.4/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** | 每一帧 | | **C** | 一整帧的缓存重绘 | **1.8 ms**,**一行 JavaScript 都没跑** | 每一帧 | 要用 release 跑,否则数字没有意义。本页所有绝对数值都来自一台 MacBook Pro(M3,8 核,24 GB)上的 release 构建,会随机器变化。 **C 是断言,不只是计时。** 对一个未变化的 View 重绘五十次,一行 JavaScript 也没有跑过。哪怕有一帧跑了,就说明运行时退回到了按帧收取脚本成本的老路上——那时基准是直接失败,而不是只变慢一点。 只测一个规模,看不出这三笔成本里哪几笔会随规模增长,所以第四个测试把同一个面板一路放大到 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。而一整帧——也就是 B 加上 GPUI 的布局与绘制,即 C 量的那件事——对应是 1.3、5.9、12.0、27.0 ms。两笔都随节点数接近线性增长;不增长的是 JavaScript——每一档的每一帧都是零行。这三件事因此说清了: - **4,203 个节点是 Snapshot 决定结果的那个规模。** 12 ms 一帧稳在 60 FPS;若每帧都重建描述则要 22 ms,直接掉帧。比这更小的规模,两种做法都有余量——在过度解读那个倍数之前,这一点值得先知道。 - **描述这笔成本没有消失,只是挪了位置。** 8,403 个节点的 20 ms 是用户操作时才付,不是每秒付六十次;但它仍然是 20 ms,所以单次调用成本依旧是评判第二个引擎的那个数字。 - **超过几千个节点之后,账单根本不在脚本一侧。** 那个规模上 27 ms 一帧、其间一行 JavaScript 都没跑,花的是 `materialize`、布局与绘制。这么大的 View 该做的是虚拟化,换个更快的引擎也推不动它。 把 A 对照设计给出的预算,答案仍是“在预算之内,但余量比预期少”:目标是一次脚本 `render` 花 1.5 ms,实测达标;但那份预算是按 800 个节点、每次记录操作约 150 ns 推出来的,而实测是 443 个节点上约 320 ns,三倍规模的面板放不进一次描述。变的是这件事有多要紧。按老模型,120 FPS 下每一秒会有 168 ms 花在重复描述一个没人改过的界面上;现在同一个面板在用户真正改动时花 1.4 ms,重绘则是 0.7 ms。设计为超大面板列出的三条手段——压低单次调用成本、缓存未变化的子树、对长列表做虚拟化——依然[还没有实现](/versions/v0.6.4/zh-CN/shell/elements#还没有的东西),但它们现在是优化项,不再是前提条件。 同一次实测还带来了两个实现选择,今天在运行时里看得到: - **元素是共用同一个原型的普通对象**,样式方法由一段 JavaScript prelude 遍历名字列表装到该原型上。不是每个元素一个 class,不是每次属性访问新建闭包,也不是 3000 个 Rust 闭包。 - **带诊断的 `Proxy` 原型不是默认。** 用 `Proxy` 包住原型以便说出拼错的方法名,代价是整个描述过程的约 30%;所以运行时保留普通原型,只在一次渲染失败后用诊断原型重跑一遍,纯粹为了产出那条信息。见 [Styling](/versions/v0.6.4/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.6.4/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;不是每个候选引擎都能,有些还会生成机器码,这在禁止可写可执行内存的平台上是一项约束。这些事实都不决定今天的引擎,但它们是“引擎是架构的一个参数,而不是架构的一部分”这句话被写下来的原因。 --- # 浮层 Source: /versions/v0.6.4/zh-CN/shell/overlays # Overlays 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.6.4/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.6.4/zh-CN/shell/getting-started#把运行时接进-rust-应用)。 ## 还没有的东西 - **Dialog 的返回值。** `open_dialog` 返回的是深度,不是一个在 dialog 关闭时 settle 的 promise。请像上面的例子那样闭包捕获一个回调,或者让 dialog 写回打开方会读取的状态。 - **Tooltip 与右键菜单。** Popover 和 HoverCard 已可作为锚定浮层使用;专用的 tooltip 与右键菜单 API 尚未公开。 - **定位选项。** Dialog 居中,sheet 贴边,两者都不能指定位置。 --- # 依赖 Source: /versions/v0.6.4/zh-CN/shell/dependencies 应用用相对路径 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.6.4/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.6.4/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.6.4/zh-CN/shell/capabilities) | manifest 的其余部分:身份、版本,以及脚本能触达什么 | | [快速开始](/versions/v0.6.4/zh-CN/shell/getting-started) | `gpui-shell types`、`check`,以及依赖加入的那份声明 | | [API 参考](/versions/v0.6.4/zh-CN/shell/api) | package 与你的代码一同 import 的内建模块 | --- # 元素 Source: /versions/v0.6.4/zh-CN/shell/elements # Elements `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.6.4/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.6.4/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.6.4/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.6.4/zh-CN/shell/state#render-什么时候执行),处理函数在这期间一直可调用。描述里只记录一个 id;Rust 装配的闭包持有对运行时的弱引用加上这个 id。 被替换掉的那份描述会多保留一代,因为事件可能针对一个已经被取代的帧派发。再晚到达的事件会被丢弃并记一条 `debug` 日志,而不是报错——作者没有做错什么,也没有什么可修。 实际后果是:渲染期注册的回调不是订阅。需要活得比本次渲染更久的东西——比如响应输入框的 `change` 事件——见 [State and Views](/versions/v0.6.4/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.6.4/zh-CN/shell/styling#未知方法)。 ## 还没有的东西 元素接口现在已经包含 Tabs、Table、Progress、表单控件、Popover/HoverCard 锚定浮层、Textarea、Scrollbar、PathBuilder、VirtualList,以及一个由脚本绘制 chrome 的 [dock area](/versions/v0.6.4/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。 --- # 能力授权 Source: /versions/v0.6.4/zh-CN/shell/capabilities # Capabilities 脚本默认**什么都拿不到**。没有文件访问、没有剪贴板、没有进程执行、没有网络。`Capabilities::default()` 就是空集,并有一条断言把它钉在那里。 唯一的例外是存储,而且只在 manifest 这一层:没有写 `storage` 的应用会拿到属于它自己的 `localStorage`,就像浏览器不问自答地给每个 origin 一个那样。这是关于作者**要写什么**的约定,不是模型上的口子——Rust 侧的 `Capabilities` 在 Host 开口之前照样拒绝,manifest 也照样可以写 `"storage": false`。见 [Storage](#storage)。 授权由 Host 决定,因为只有 Host 知道它对即将运行的这段代码信任到什么程度。至于它主动**递出去**的东西——它自己的、有意暴露的那部分 Rust——见 [HostModule](/versions/v0.6.4/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.6.0", "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.6.4/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 契约。 ## 还没有的东西 - **向用户询问授权。** 授权在应用加载之前就已决定,不会在使用的那一刻弹出询问。 --- # 性能 Source: /versions/v0.6.4/zh-CN/shell/performance # Performance 一个 View 的 `render()` 不是每帧都跑。它产出一份描述并存成 Snapshot,之后的每一帧都由 Rust 从这份 Snapshot 画出来,不再进入 JavaScript——[首页那一节](/versions/v0.6.4/zh-CN/shell#性能-脚本不在每一帧里)讲的就是这件事。 一旦重绘不进入 JavaScript,剩下要算的就只有两样: ```text JavaScript 的开销 = 一个 View 多久失效一次 × 描述这个 View 要花多少 ``` 两样都不是帧率。窗口以 120 Hz 还是 30 Hz 重绘,JavaScript 执行的次数完全一样;没有人让它失效的 View,一次都不执行。 两样也都在你手里:左边是你在哪里调用 `cx.notify()`,右边是一次 `notify` 背后压了多少界面。这一页剩下的内容讲的就是这两件事,以及出问题时怎么分辨是哪一个。 ## 每个 View 都有自己的 Snapshot GPUI Shell 给每一个 JavaScript View 一份属于它自己的 Snapshot:这个 View 的 `render` 产出的那份描述,保存在 Rust 一侧。 **只要 View 本身没有变化,它的 Snapshot 就一直被复用。** 中间的每一帧都从这份 Snapshot 画出来——转成 GPUI 元素、布局、绘制——全部在 Rust 里完成,不执行任何 JavaScript。 ```text View 变了 ──▶ render() ──▶ 新的 Snapshot ──▶ 帧 View 没变 ─────────────────▶ 已有的那份 Snapshot ──▶ 帧 ``` 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.6.4/zh-CN/shell/capabilities) 的事,而不是因为你想要第二份缓存。 ## 只为用户看得见的变化 notify `cx.notify()` 就是这里全部的依赖系统,而它只表达一件事:**我的描述过期了。** 它不是事件通知,把它当事件通知用,是让 JavaScript 变贵的最常见方式。 行情回调是典型场景: ```js onQuote(quote, cx) { this.quotes.set(quote.symbol, quote); cx.notify(); // 每一跳都通知,包括没人在看的那些 } ``` 如果这个 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 上。 - **notify 得比帧率还密,也不会更贵。** 见下——手动攒批换不来什么,加条件才有用。 - **在 Host 一侧,`cx.notify()` 与 `ScriptView::refresh` 是两个不同的请求。** 单纯的 `notify` 只是重绘已有的描述。如果 Rust 改的是脚本通过 [HostModule](/versions/v0.6.4/zh-CN/shell/host-module) 读到的状态,那描述已经陈旧,只有 `refresh` 能说明这一点。见 [Hosting](/versions/v0.6.4/zh-CN/shell/hosting#host-状态变了-怎么刷新-view)。 ### notify 到底做了什么,谁在合并它 `cx.notify()` 不重建任何东西。它只是在这个 View 上置一个标志,表示「我的描述可能过期了」,然后请求 GPUI 绘制。重建发生在之后的那一帧里,而且只在标志仍然置位时才发生。 所以两帧之间的所有 notify 都会合并成一次 `render`——无论它们来自三个事件回调、一个循环里的 task,还是 Host: ```text notify notify notify ──▶ 一帧 ──▶ 一次 render() ``` 一个标志置三次等于置一次。什么都没有被丢掉:三个回调都执行了,状态也都改了;它们共享的只是随后那一次重建。 **这就给失效的开销划了一个上限:每个 View 每帧最多一次脚本 render。** 一秒跳一千次的行情,在 120 Hz 的屏幕上最多也只有每秒 120 次 render,而不是一千次。这也是为什么滥用 `notify` 表现为白做功,而不是失控。 运行时不在这之上再加任何自己的节流,也没有可调的参数。合并来自 GPUI 自己的调度,而且它绝不会把重建推迟到下一帧之后——所以它不带来延迟,而延迟正是下面那一对里的另一半。 ### 这份缓存占多少内存 一个 View 持有**两份**描述:它已发布的那份,以及被它替换掉的那份。留着第二份,是因为事件仍可能派发到一个已经被取代的帧上,而那一帧需要的回调属于那份较旧的描述。 没有第三份。发布新描述时最旧的那份会被丢弃,丢弃它同时会退役随它注册的那些回调。所以上限就是每个存活的 View 两份描述,而且不随时间累积:一个重渲染过一百万次的 View,持有的东西和一个只渲染过两次的 View 完全一样。关掉一个面板,它的 View 就没了,两份描述一起走。 这也是「该拆大 View 而不必怕拆」的另一个理由:一百个小 View 持有的是一百对小描述,加起来仍然只是把这个界面描述了两遍,而不是一百遍。 ## 帧率与呈现延迟是两类问题 一个运行中的界面可能出两种问题,而只有一种会体现在 FPS 上: ```text 渲染帧率 画面流畅吗? 状态 → 呈现 状态变了以后,多久用户才看得到? ``` 漏掉一次 `cx.notify()` 一帧都不会掉。GPUI 会继续以满帧率重放上一份完好的描述,于是 HUD 稳稳地读出 120 FPS,而界面显示的东西早就不成立了——然后在四分之一秒后,因为某件不相干的事让这个 View 失效,画面突然跳一下。所有渲染指标都会把这种情况判为健康。 | 症状 | 哪个数字不对 | 常见原因 | | --- | --- | --- | | 应用里什么都没变,窗口却卡 | 帧率 | 每帧要物化的描述过大,或虚拟列表在按行做额外工作;见[那次实测](/versions/v0.6.4/zh-CN/shell/engine#那次实测) | | 行情在跑的时候窗口卡 | 帧率**和**失效频率 | 某个 View 重建得太频繁、太大,或两者都有 | | 画面很流畅,但数据慢半拍 | 呈现延迟 | 某次 `notify` 被漏掉、被压在 `await` 之后,或该用 `refresh` 的地方用了 Host 侧的 `cx.notify()` | 这两件事要分开诊断。FPS 从没掉过,并不能证明失效逻辑是对的。 ## 怎么读那几个计数器 运行时把这两类事件分开计数,Host 用 `runtime.read_metrics()` 读取——接口本身以及“留一个基线再相减”得到每秒速率的用法,见[观察它花了多少](/versions/v0.6.4/zh-CN/shell/hosting#观察它花了多少)。 | 读数 | 它回答什么 | | --- | --- | | `script_renders()` | JavaScript 执行了多少次。跟着 `cx.notify()`、hot-reload 与主题切换走,永远不跟帧走 | | `materializations()` | Snapshot 变成元素多少次。跟着帧走 | | `mean_script_render()` | 一次描述要花多少,包含其中的 Host 调用 | | `mean_native()` | 其中有多少是在 HostModule 函数里,而不是在描述界面 | | `slowest_script_render()` | 这一段里最慢的那一次构建 | | `frame_script_calls()` | 从帧路径进入 VM 的次数——只有[虚拟列表](/versions/v0.6.4/zh-CN/shell/elements)的 item 渲染器与 [Dock](/versions/v0.6.4/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()` 是用来核对这条线划得对不对的。它们统计一次重建产出的**结构**与被替换那份是否相同——只有其中的取值不同。如果某块面板报出来的比例很低,这件事本身就值得知道:你以为只有一个数字在变,实际上有东西在改变结构。 --- # 示例 Source: /versions/v0.6.4/zh-CN/shell/examples # Examples 仓库自带四个示例,合起来覆盖独立应用、可停靠布局、由 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.6.4/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.6.4/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.6.4/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.6.4/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.6.4/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.6.4/zh-CN/shell/hosting) 走的是同样这几个调用。 --- # 开始使用 Source: /versions/v0.6.4/zh-CN/shell/getting-started # Getting Started `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.6.4/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.6.4/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.6.4/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.6.4/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`,额外打印构建出的元素描述 | --- # Hosting Source: /versions/v0.6.4/zh-CN/shell/hosting [Getting Started](/versions/v0.6.4/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.6.4/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.6.4/zh-CN/shell/capabilities) 与 [HostModule](/versions/v0.6.4/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.6.4/zh-CN/shell/engine#那次实测)靠的正是这一点。 `structure_repeats()` 与 `structure_changes()` 回答的是另一个问题:在那些有上一份描述可比的重建里,有多少次产出的**结构**完全相同——相同的组件、相同的 builder 方法、相同的树,只有其中的取值变了。运行时不会因为这个答案而少做任何事;它存在,是为了给[Snapshot 缓存止步于哪里](/versions/v0.6.4/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。 ## 还没有的东西 - **给卡住的脚本做监管。** 解释器自己的中断会切断一次调用,但没有东西会去重启一个反复撞上中断的运行时。 --- # 状态与 View Source: /versions/v0.6.4/zh-CN/shell/state # State and Views 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.6.4/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 会因为你的应用完全不知情的原因重绘——指针划过一个按钮、文本光标闪烁、列表滚动、动画推进——这些都不构成执行 JavaScript 的理由。 所以一次 `render` 调用描述的不是*这一帧*。它把界面描述一次,写进运行时保留的一份 Snapshot: ```text cx.notify() ──▶ render() ──▶ Snapshot ──┬──▶ 帧 ├──▶ 帧 └──▶ 帧 … ``` Snapshot 只在有东西让它失效时才重建: - 事件回调或异步任务里的 `cx.notify()` - [hot-reload](/versions/v0.6.4/zh-CN/shell/getting-started) 替换了脚本 - 主题切换——因为 `bg(cx.theme().colors.surface)` 在 `render` 执行时记录真实颜色,已经烘进了 Snapshot - Host 调用 `ScriptView::refresh`——Rust 用它表示“我改了脚本会读到的状态”(通过 [HostModule](/versions/v0.6.4/zh-CN/shell/host-module))。Host 侧单纯的 `cx.notify()` 只是重绘,不会跑脚本:这是两个不同的请求 其余情况都在 Rust 里复用你已经产出的那份描述,不执行任何 JavaScript。 三条值得记住的推论: **你的 `render` 成本跟着用户走,不跟着帧率走。** 一个每秒变化十次的 View,成本就是每秒十次渲染,无论窗口是 60 FPS 还是 120 FPS 在重绘。描述一个大面板之所以负担得起,正是因为它不会为了没有变化的内容被重复描述六十次。 **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(); // 同一个 cx,仍然是对的那个 } ``` 这三处正是职责为「安排或延续比启动它的那次调用活得更久的工作」的地方。其余场合要的就是严格的那种,被告知「你留得太久了」正是它的价值所在。 `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 -> 对 after / every 返回的 Task 调用 cancel() ``` `setTimeout`、`setInterval`、`clearTimeout` 与 `clearInterval` 都是会抛错的 stub。一次性工作使用 `cx.timer.after`,重复工作使用 `cx.timer.every`;要停止其中任意一种,都对返回的 `Task` 调用 `cancel()`。全局 `fetch`,以及 [Capabilities](/versions/v0.6.4/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.6.4/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.6.4/zh-CN/shell/host-module [Capabilities](/versions/v0.6.4/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.6.4/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.6.4/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;模块只能被调用。 --- # API 参考 Source: /versions/v0.6.4/zh-CN/shell/api # API Reference 脚本接口的一份清单:有什么,以及它来自哪个模块。其余页面解释每样东西为什么是这个样子——这一页是用来查名字的。 权威不在这一页。runtime 会为自己的版本生成 `gpui-kit.d.ts`,并在应用加载时尽力刷新到源码旁;`gpui-shell types ` 执行同一次写入,并会明确报告失败。生成文件的头部带有 `gpui-shell` 版本,也包含该应用注册的 HostModule 。请忽略这个文件而不要提交,并在脚本顶部写上 `// @ts-check` 让编辑器照着它检查。manifest 声明的 Git 依赖同样不在这一页:它们由同一次刷新链接进 `node_modules`,名字、签名与文档都来自 package 自身。见[依赖](/versions/v0.6.4/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.6.4/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.6.4/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.6.4/zh-CN/shell/capabilities#storage)。 ## `gpui-base` 模块 这里的组件拥有行为、焦点,以及屏幕阅读器听到的内容,而自身几乎什么都不画。画面归脚本所有,用[样式接口](/versions/v0.6.4/zh-CN/shell/styling)写出来。每个名字都链接到它在 [gpui-base 文档](/versions/v0.6.4/base)里的页面,那里描述了它完整的 Rust 接口与行为。 ### 布局 | 名称 | 说明 | | --------------------------------------------------------- | -------------------------------------------------- | | `h_flex()` | 一行 | | `v_flex()` | 一列 | | [`h_resizable(id)`](/versions/v0.6.4/base/primitives/resizable) | 一行带可拖拽分隔条的窗格;尺寸按这个 id 存在窗口里 | | [`v_resizable(id)`](/versions/v0.6.4/base/primitives/resizable) | 同上,纵向堆叠 | | [`resizable_panel()`](/versions/v0.6.4/base/primitives/resizable) | 可调整组里的一个窗格,用在别处都不合法 | ### 控件 | 名称 | 说明 | | -------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | [`Button`](/versions/v0.6.4/base/primitives/button) | 激活、焦点、disabled 与 selected 状态 | | [`Link`](/versions/v0.6.4/base/primitives/link) | 通过系统浏览器打开的外部 HTTP(S) 资源 | | [`Checkbox`](/versions/v0.6.4/base/primitives/checkbox) | 受控的勾选;勾选标记自己画 | | [`Switch`](/versions/v0.6.4/base/primitives/switch) | 受控的 switch | | [`Radio`](/versions/v0.6.4/base/primitives/radio) | 一组中的一个选项;只报告 `true`,从不报告取消选中 | | [`Toggle`](/versions/v0.6.4/base/primitives/toggle) | 一个会保持按下的按钮 | | [`RadioGroup`](/versions/v0.6.4/base/primitives/radio-group) | 被报读为一组的一批 radio;自身不持有选中项 | | [`ToggleGroup`](/versions/v0.6.4/base/primitives/toggle-group) | 被报读为 toolbar 的一批 toggle | | [`Tabs`](/versions/v0.6.4/base/primitives/tabs) | 自身不持有选中项的 tab 列表 | | [`Tab`](/versions/v0.6.4/base/primitives/tabs) | 一个 tab:`selected(...)` 进,`on_click(...)` 出 | | [`Progress`](/versions/v0.6.4/base/primitives/progress) | 只有报读,没有进度条;单独的 `Progress.new(...)` 什么都不画 | | [`ProgressTrack`](/versions/v0.6.4/base/primitives/progress) | 凹槽:一个由你设定尺寸与颜色的普通元素 | | [`ProgressIndicator`](/versions/v0.6.4/base/primitives/progress) | 已填充的部分;按你报读的百分比设置它的宽度 | | [`Avatar`](/versions/v0.6.4/base/primitives/avatar) | 渲染它的 `image` 槽;没有图片时渲染 `fallback`。它自己不画圆形、尺寸或背景 | | [`AvatarImage`](/versions/v0.6.4/base/primitives/avatar) | 图片槽:`AvatarImage.new(path)`,用在别处无效 | | [`AvatarFallback`](/versions/v0.6.4/base/primitives/avatar) | 兜底槽:一个普通盒子,放首字母、图形或 `svg` | | [`Pagination`](/versions/v0.6.4/base/primitives/pagination) | 一个 navigation landmark,带报读的标签;页码按钮由脚本自己画 | | `pagination_items(current, total, visible?)` | 该画哪些页码、省略号落在哪。`visible` 默认 7,最小 5;总页数 ≤ 1 时返回空 | | [`Accordion`](/versions/v0.6.4/base/primitives/accordion) | 一个 group,装 item | | [`AccordionItem`](/versions/v0.6.4/base/primitives/accordion) | 一个条目:`open(...)` 进,trigger 的 `on_change(...)` 出;它把自己的 `open` 传给下面两半 | | [`AccordionHeader`](/versions/v0.6.4/base/primitives/accordion) | 标题:`AccordionHeader.new(trigger)`,`aria_level(n)` 报读层级(默认 3) | | [`AccordionPanel`](/versions/v0.6.4/base/primitives/accordion) | 展开的区域。关闭时不在树里,除非 `keep_mounted(true)` | | [`AccordionTrigger`](/versions/v0.6.4/base/primitives/accordion) | 按钮:报读展开状态,`on_change` 请求相反的那个 | | [`CalendarState`](/versions/v0.6.4/base/primitives/calendar) | 留存的日历状态:月网格、当前月份、选中的日期 | | [`SliderState`](/versions/v0.6.4/base/primitives/slider) | 留存的 slider 状态,也是一次拖拽写入的地方 | | [`Slider`](/versions/v0.6.4/base/primitives/slider) | 根:报读数值,并拥有 release | | [`SliderTrack`](/versions/v0.6.4/base/primitives/slider) | 按下与拖拽的表面 | | [`SliderIndicator`](/versions/v0.6.4/base/primitives/slider) | 凹槽,也是每个指针位置据以测量的那个盒子 | | [`SliderThumb`](/versions/v0.6.4/base/primitives/slider) | 滑块;shell 给它位置,你给它外观 | slider 的四个部件接受同一个 `SliderState`,而且四个都不能少——没有 `SliderIndicator` 的 slider 根本拖不动。 ### 文本编辑 | 名称 | 说明 | | ------------------------------------------------------ | ----------------------------------------------------------- | | [`InputState`](/versions/v0.6.4/base/primitives/input) | 留存的文本状态:`InputState.new({ placeholder, value })` | | [`Input`](/versions/v0.6.4/base/primitives/input) | 包住留存文本状态的框 | | [`NumberInput`](/versions/v0.6.4/base/primitives/number-input) | 建立在同一个 `InputState` 上的 spinbutton,三个插槽都有分量 | | [`TextareaState`](/versions/v0.6.4/base/primitives/textarea) | 留存的多行文本状态;`rows` 是一个选项 | | [`Textarea`](/versions/v0.6.4/base/primitives/textarea) | 包住留存多行状态的框 | | [`OtpState`](/versions/v0.6.4/base/primitives/otp-input) | 留存的一次性验证码状态;长度在创建时固定 | | [`OtpInput`](/versions/v0.6.4/base/primitives/otp-input) | 定长验证码,格子由 shell 画、由脚本设定样式 | 没有专门的数字状态类型:给 `InputState` 设上 `set_step`、`set_min` 与 `set_max`,它就成了数字状态。 ### 容器与浮层 | 名称 | 说明 | | ----------------------------------------------------- | ----------------------------------------------------------------- | | [`Collapsible`](/versions/v0.6.4/base/primitives/collapsible) | 仅在 `open` 时渲染它的 `content` 插槽;不带 role、箭头或触发器 | | [`Popover`](/versions/v0.6.4/base/primitives/popover) | 锚定在触发元素上、由按下打开的浮层 | | [`HoverCard`](/versions/v0.6.4/base/primitives/hover-card) | 同上,但由指针停留打开,并有自己的打开状态 | | [`Popup`](/versions/v0.6.4/base/primitives/popup) | 光秃秃的锚定浮层:`Popup.new(id, trigger)`,填入 `content` 即打开 | | [`Select`](/versions/v0.6.4/base/primitives/select) | combobox 的根:role、报读的打开状态、键盘——但不含任何画面 | | [`Combobox`](/versions/v0.6.4/base/primitives/combobox) | 同一个根,被报读为一个触发器是可编辑输入框的 combobox | | [`DatePicker`](/versions/v0.6.4/base/primitives/date-picker) | 日期选择器的根:`DatePicker.new(id, focus_handle)`;它不持有日期 | 在这些之上动手之前,有两处缺口值得先知道:打开的 `Select` 或 `Combobox` 列表的方向键导航要你自己接(零件都在,见下),而 Enter 与 Escape 到不了 `DatePicker`。两者都写在各自类型的声明里,也就是它们真正咬人的地方。 ### 表格与列表 | 名称 | 说明 | | ------------------------------------------------------- | --------------------------------------------------------------------- | | [`Table`](/versions/v0.6.4/base/primitives/table) | 语义表格的根,组合方式与 HTML 组合表格一致 | | [`TableHeader`](/versions/v0.6.4/base/primitives/table) | 表头行组 | | [`TableBody`](/versions/v0.6.4/base/primitives/table) | 表体行组 | | [`TableRow`](/versions/v0.6.4/base/primitives/table) | 一行:`.new(id, row_index)`,从 1 开始 | | [`TableHead`](/versions/v0.6.4/base/primitives/table) | 一个列头:`.new(id, column_index)`,从 1 开始 | | [`TableCell`](/versions/v0.6.4/base/primitives/table) | 一个数据单元格:`.new(id, column_index)`,从 1 开始 | | [`TableCaption`](/versions/v0.6.4/base/primitives/table) | caption 该在的视觉位置;它不带 caption role | | [`v_virtual_list(…)`](/versions/v0.6.4/base/virtual-list) | 只描述屏幕内内容的纵向列表 | | [`h_virtual_list(…)`](/versions/v0.6.4/base/virtual-list) | 另一个轴上的同一件事;`item_sizes` 是宽度 | | [`VirtualListScrollHandle`](/versions/v0.6.4/base/virtual-list) | 虚拟列表的滚动位置,跨帧保留 | | [`Scrollbar`](/versions/v0.6.4/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.6.4/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.6.4/zh-CN/shell/styling) 里,还有长度与颜色的语法,以及调色板定义的 token。 ## HostModule Host 在 Rust 侧注册的模块,按名字 import,和其它模块没有区别: ```js import { quotes } from "market"; ``` 它不属于任何内建模块。生成的类型声明里每个注册过的模块各有一段 `declare module`,所以模块名和每一个导出名都会被检查。见 [HostModule](/versions/v0.6.4/zh-CN/shell/host-module)。 --- # GPUI Shell Source: /versions/v0.6.4/zh-CN/shell `gpui-shell` 的存在,是为了让一个用 Rust 写的 GPUI 应用**能被 JavaScript 扩展**。 **首要目标是插件扩展。** Host 应用编译一次、发布一次,此后新增一块面板、一个侧边工具或一段业务逻辑,都以脚本的形式加载进同一个进程——不必重新编译,不必重新分发二进制,想加一块面板的人也不必 fork 整个 Host。 **次要目标是用 JavaScript 写完整的应用。** CLI 可以直接跑起一个应用目录。这本身就是一条能用的路径,同时也是插件的开发方式:先把脚本单独跑通,再挂进 Host。 **它不是 Electron,也不是 Tauri。** 没有 WebView,没有 DOM,没有 HTML 与 CSS,没有浏览器引擎,也没有 Node.js。脚本从不负责渲染,它只把界面**描述**一次,此后每一帧都由 Rust 把这份描述重放成真正的 GPUI 元素——和一个基于 `gpui-base` 的 Rust 应用所构建的,是同一套元素模型、同一个 GPU 渲染器。在这里 JavaScript 是应用层,不是渲染层:所以一次重绘完全不执行 JavaScript,而带上整个运行时也只多 [13.5 MiB 二进制](/versions/v0.6.4/zh-CN/engine#链接它要付多少)。 这两个目标建立在同一条分工上。`gpui-shell` 直接构建在 [`gpui-base`](/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.6.4/zh-CN/dock)。 ## 核心特点 ### 架构:脚本负责描述,Host 负责渲染 脚本从不持有 GPUI 元素,它记录的是元素的**描述**——builder 链上的每一次调用都会往一块 arena 里写入一条操作,等某一帧需要时,Rust 再把这些操作重放成真实元素。布局、绘制、命中测试、滚动、IME 与文本编辑全部留在 Rust,不会回调进脚本。[一次渲染是怎么走完的](#一次渲染是怎么走完的)完整走了一遍这个过程。 引擎是这套设计的一个参数,而不是其中一部分。今天只有 QuickJS 一种,但这条分界线之上的全部模块——arena、把描述变成真实元素的 `materialize`、CallScope、样式表、主题、能力模型、浮层 Host 、hot-reload——源码里都没有出现任何 VM 的名字。见 [The Engine Seam](/versions/v0.6.4/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` **不是**每帧跑一次。它把界面描述一次、存进一份 Snapshot;在下一次 `cx.notify()` 之前,每一次重绘都由 Rust 重放这份 Snapshot。指针划过按钮、光标闪烁、列表滚动、原生 transition 或 spring 推进,这些重绘都不执行 JavaScript。 运行时把两件事分开计数,gallery 的 Shell story(`cargo run -- shell`)把这两个数摆在界面上: 一秒内的一块实时面板。JavaScript 的数据没有变化时,60 帧全部触发,而 JavaScript 那一行始终是空的;价格每 50 ms 变动一次时,仍是 60 帧,JavaScript 触发约 20 次。 一秒内的一块实时面板。JavaScript 的数据没有变化时,60 帧全部触发,而 JavaScript 那一行始终是空的;价格每 50 ms 变动一次时,仍是 60 帧,JavaScript 触发约 20 次。 | 界面在做什么 | 每秒画的帧 | 每秒跑的 JavaScript | | ----------------------------------- | ---------- | ------------------- | | 只是重绘,JavaScript 的数据没有变化 | 60 | 0 | | 价格每 50 ms 变动一次 | 60 | 19 | 帧数取决于屏幕,JavaScript 的次数取决于数据。第二行里另外 41 帧重放的是已有的描述。 成本因此按用户操作计,而不是按帧计。443 节点的面板,跑一遍 `render`、把整个界面记进 Snapshot 要 1.1 ms,只在状态变化时付;之后每一帧 1.3 ms,那是渲染本身——把 Snapshot 变成元素、布局、绘制,其中没有 JavaScript。 | | 每帧成本 | | ------------- | ------------------------------------------------------------------- | | 没有 Snapshot | 1.1 ms (JS render) + 1.3 ms (Rust render) = **2.4 ms/frame render** | | 有 Snapshot | **1.3 ms** | 面板变大也不改变这条性质:[基准测试](/versions/v0.6.4/zh-CN/engine#那次实测)覆盖到 8,403 个节点,各档的每一帧都不执行 JavaScript,最小一档由每次 CI 运行的断言保证。 ### 体积:一个脚本运行时只要 +13.5 MiB 跑一个真实脚本应用的 Host,二进制 **26.1 MiB**、常驻内存 **81 MiB**——QuickJS 和整个标准运行时都在里面。相比同一个应用不带它的版本,取这个依赖的代价是**二进制 +13.5 MiB、内存 +14 MiB**。 这个数是个常数,不是比例:组件 gallery——体量是它的五倍——增加的同样是 13.5 MiB。[链接它要付多少](/versions/v0.6.4/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.6.4/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.6.4/zh-CN/elements)。 - **`cx` 只属于产生它的那次调用。** 它带着一个 generation 编号,每次使用都与实时的调用栈比对;一个跨过 `await` 仍在使用的 `cx` 会给出明确错误,而不是去访问一个早已失效的栈帧。见 [State and Views](/versions/v0.6.4/zh-CN/state)。 - **回调属于注册它的那次渲染。** 下一次渲染会整体替换它们,这正是脚本闭包不会在 Host 里堆积的原因。见 [Elements](/versions/v0.6.4/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 应用 main.js · views · 样式 · 业务逻辑 │ import { … } from "gpui-kit" ▼ gpui-shell 引擎分界线 · 元素描述 · CallScope 样式表 · 主题 token · 能力模型 ShellRoot(dialog / sheet / toast)· 调度器 │ ▼ gpui-base 行为 · 状态 · 基础设施(无样式) │ ▼ gpui 元素 · 样式 · 渲染 · GPU · 平台 ``` `gpui-shell` 与 `gpui-component` 是并列关系,而不是在它下游:两者都是 `gpui-base` 的使用者,都补上了 Base 不提供的那一层呈现。`gpui-component` 用 Rust 提供了一套成品且统一的呈现;`gpui-shell` 提供的是让脚本自己去提供呈现的那套机制。 ## 接着读 | 页面 | 内容 | | --------------------------------------- | ---------------------------------------------------------------------------------- | | [Getting Started](/versions/v0.6.4/zh-CN/getting-started) | 运行示例、最小应用、`check` 与 `types` | | [Examples](/versions/v0.6.4/zh-CN/examples) | 仓库里的独立应用、 Host 状态与原生动画示例 | | [Elements](/versions/v0.6.4/zh-CN/elements) | 构造器、`child` / `children` / `when`,以及元素为什么是一次性的 | | [Styling](/versions/v0.6.4/zh-CN/styling) | 流式样式接口、长度与颜色、语义 token、状态样式 | | [State and Views](/versions/v0.6.4/zh-CN/state) | `init` / `render`、`cx.notify()`、留存状态、异步 | | [Overlays](/versions/v0.6.4/zh-CN/overlays) | dialog、sheet、toast,以及 phase 规则 | | [Capabilities](/versions/v0.6.4/zh-CN/capabilities) | `gpui-shell.json`、默认拒绝、文件、存储、进程与网络 API | | [依赖](/versions/v0.6.4/zh-CN/dependencies) | shell package:什么样的仓库算一个,manifest 如何命名与钉住它,以及编辑器拿到的类型 | | [Hosting](/versions/v0.6.4/zh-CN/hosting) | Rust 这一侧的全貌:挂载、刷新、指标、退出、hot-reload | | [HostModule](/versions/v0.6.4/zh-CN/host-module) | 把 Host 自己的 Rust 借给脚本,以及那条纯数据边界 | | [Dock 与面板](/versions/v0.6.4/zh-CN/dock) | 把脚本 View 变成可停靠面板、为它绘制 chrome,以及重启后什么会留下 | | [Performance](/versions/v0.6.4/zh-CN/performance) | 脚本的成本:失效频率乘以描述规模、 View 这条边界,以及那几个计数器 | | [The Engine Seam](/versions/v0.6.4/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)。 --- # Dock 与面板 Source: /versions/v0.6.4/zh-CN/shell/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 自己写绑定时同样用得上。 --- # TextView Source: /versions/v0.6.4/zh-CN/base/text-view `gpui-base` 现在拥有完整的 `TextView` 实现,可渲染 Markdown 和常用 HTML。解析、链接、图片、列表、表格、代码块、滚动、行数限制、插件、文本选择和复制都不依赖 `gpui-component`。 上方可运行示例只依赖 `gpui-base`。其中 Rust 代码块特意没有着色,因为语法高亮默认不开启。 ## 设置窗口 应用启动时调用一次 `gpui_kit::base::init`,并在每个窗口渲染一个 `TextSelectionLayer`。它统一协调 `TextView`、[`SelectableText`](/versions/v0.6.4/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())`。 ## 语法高亮由使用者开启 `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`)只让发生变化的字形重新淡入,不会整段闪烁。每次只比较更新触及的块,并且只在还有文字在淡入时才请求下一帧。系统开启减少动态效果时跳过淡入。 通过 `SelectionFormat` 可以选择复制渲染文本或 Markdown 源码。链接路由、代码块操作、表格操作、图片和 Markdown 插件继续使用与兼容 API 相同的 builder,详见 [gpui-component TextView 文档](/versions/v0.6.4/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 ``` --- # History Source: /versions/v0.6.4/zh-CN/base/history `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 事务。 | --- # 动画与动效 Source: /versions/v0.6.4/zh-CN/base/motion `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,ignore 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,ignore 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。 --- # 文本选择 Source: /versions/v0.6.4/zh-CN/base/text-selection `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 中修改状态。 - 验证跨块拖拽、反向选择、复制、动态内容、滚动和模态隔离。 --- # Table Source: /versions/v0.6.4/zh-CN/base/primitives/table 用于组合表头、表体、行和单元格的语义表格原语。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Pagination Source: /versions/v0.6.4/zh-CN/base/primitives/pagination 显式管理当前页和总页数的受控分页导航。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Link Source: /versions/v0.6.4/zh-CN/base/primitives/link 样式由应用定义的可访问链接控件。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Scrollbar Source: /versions/v0.6.4/zh-CN/base/primitives/scrollbar `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)) } } ``` ## 可访问性与交互检查 - 保留底层视口的滚轮、触控板和键盘滚动。 - 即使绘制的滑块很窄,也保留完整轨道命中区域。 - 验证普通、悬停和活动状态对比度。 - 动画不要移动布局或命中区域。 - 在减少动态效果下分别测试三种模式以及纵向、横向和双轴溢出。 --- # Combobox Source: /versions/v0.6.4/zh-CN/base/primitives/combobox 结合文本输入、键盘导航建议和选择行为的组合框。 和所有 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("⌄")), ); let popup = div() .w_56() .p_1() .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_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().mt_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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Radio Source: /versions/v0.6.4/zh-CN/base/primitives/radio 具有选中和禁用语义的受控单选项。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Tabs Source: /versions/v0.6.4/zh-CN/base/primitives/tabs 带受控选择的标签列表和可访问标签控件。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Dialog Source: /versions/v0.6.4/zh-CN/base/primitives/dialog 带焦点管理、遮罩、标题和关闭部件的可组合模态界面。 和所有 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() .absolute() .inset_0() .flex() .items_center() .justify_center() .child( div() .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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Number Input Source: /versions/v0.6.4/zh-CN/base/primitives/number-input 带可复用递增、递减和步进行为的数字输入。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Alert Dialog Source: /versions/v0.6.4/zh-CN/base/primitives/alert-dialog 用于需要用户明确决定之操作的模态确认界面。 和所有 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() .flex() .items_center() .justify_center() .child( div() .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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Slider Source: /versions/v0.6.4/zh-CN/base/primitives/slider 轨道、已选区和滑块可独立设置样式的状态驱动范围输入。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Color Picker Source: /versions/v0.6.4/zh-CN/base/primitives/color-picker 为自定义颜色选择器提供状态与交互基础。 和所有 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(if open { "⌃" } else { "⌄" }); 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() .flex() .items_center() .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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Popover Source: /versions/v0.6.4/zh-CN/base/primitives/popover 支持受控或内部开关状态的锚定浮层。 和所有 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),原生与浏览器预览编译的是同一文件。 ## 状态与事件 触发器切换打开状态;点击外部或 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Resizable Source: /versions/v0.6.4/zh-CN/base/primitives/resizable 用于用户可调分栏布局的面板组和调整手柄。 和所有 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。 ## 完整 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Collapsible Source: /versions/v0.6.4/zh-CN/base/primitives/collapsible 不规定触发器样式的可组合显隐区域。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Date Picker Source: /versions/v0.6.4/zh-CN/base/primitives/date-picker 将日历行为与弹出层组合的焦点感知日期输入。 和所有 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("⌄"); 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Input Source: /versions/v0.6.4/zh-CN/base/primitives/input `Input` 是 `gpui-base` 的单行文本控件。它负责编辑、焦点、选择、键盘输入、IME、掩码、验证与事件,表现由应用提供。普通多行文本使用 [Textarea](/versions/v0.6.4/zh-CN/base/primitives/textarea),源代码使用 [Editor](/versions/v0.6.4/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.6.4/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)。 --- # Switch Source: /versions/v0.6.4/zh-CN/base/primitives/switch 轨道和滑块可分别设置样式的受控开关。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Popup Source: /versions/v0.6.4/zh-CN/base/primitives/popup 底层触发器与锚定浮动内容宿主。 和所有 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.6.4/zh-CN/base/primitives/nav-stack 后进先出的视图栈,同一时刻只显示一个:把新视图 push 到当前视图之上,pop 回到下面那个,或 replace 掉栈顶。它对应 SwiftUI 的 `NavigationStack`、Qt 的 `StackView` 和 WinUI 的 `Frame`。底层是一份视图的 [History](/versions/v0.6.4/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 丢弃它或栈被清空。请在消费端设计系统中验证减少动态效果时的表现。 --- # Button Source: /versions/v0.6.4/zh-CN/base/primitives/button `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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Toast Source: /versions/v0.6.4/zh-CN/base/primitives/toast 受管理、带动画的临时状态消息栈。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Textarea Source: /versions/v0.6.4/zh-CN/base/primitives/textarea `Textarea` 用于普通多行文本,接口聚焦于行数、换行、自动增高、值更新、插入、替换和光标位置。代码编辑器概念由 [`Editor`](/versions/v0.6.4/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.6.4/zh-CN/component/textarea)。 ## 可运行示例 ```bash cargo run -p gpui-base-examples -- textarea ``` --- # Select Source: /versions/v0.6.4/zh-CN/base/primitives/select 由锚定、支持键盘导航的弹层驱动的选择控件。 和所有 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(if open { "⌃" } else { "⌄" }); 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Avatar Source: /versions/v0.6.4/zh-CN/base/primitives/avatar 带可组合后备内容的人物或实体图像。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Editor Source: /versions/v0.6.4/zh-CN/base/primitives/editor `Editor` 是源代码编辑控件。它建立在共享文本引擎之上,增加语言、行号槽、折叠、空白字符显示、文本装饰、高亮、搜索基础、诊断与 LSP 扩展。单行值使用 [Input](/versions/v0.6.4/zh-CN/base/primitives/input),普通多行文本使用 [Textarea](/versions/v0.6.4/zh-CN/base/primitives/textarea)。 ## 语言编辑规则 Base 编辑器接受 `LanguageConfig` 及独立的 `auto_close` / `smart_indent` 选项。 它读取已注册的语言配置,不加载解析器。Component 在初始化时安装 `LanguageProvider`, 提供内置语言名称、默认规则及语法提供者;Base 使用者也可通过 `set_language_provider` 安装自己的服务,并用 `set_language_config` 配置语言规则。 配置字段及语言注册方式参见 [语言编辑规则](/versions/v0.6.4/zh-CN/component/editor#语言编辑规则)。直接使用 Base 时,从 `gpui_kit::base::input` 导入相同的配置类型。 ## 快捷键 Base 与样式组件共享键盘和鼠标行为。各平台快捷键、多光标编辑和矩形列选的细节请参阅 [快捷键与矩形列选](/versions/v0.6.4/zh-CN/component/editor#快捷键与矩形列选)。 ## 搜索 编辑器内置搜索面板。编辑器聚焦时按 `Ctrl-F`(Windows/Linux)或 `Cmd-F`(macOS)打开。编程式 API(`open_search`、`close_search`、`set_searchable`)与只读行为参见 [搜索](/versions/v0.6.4/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` 创建随文本编辑自动跟踪范围的装饰集合。只要装饰仍需生效,就应保留返回的 collection。 ## 高亮与语言功能 `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.6.4/zh-CN/component/editor)。 ## 可运行示例 ```bash cargo run -p gpui-base-examples -- editor ``` --- # Tree Source: /versions/v0.6.4/zh-CN/base/primitives/tree 显式管理展开与选择状态的虚拟化层级列表。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Checkbox Source: /versions/v0.6.4/zh-CN/base/primitives/checkbox 指示器可独立设置样式的受控三态复选框。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # OTP Input Source: /versions/v0.6.4/zh-CN/base/primitives/otp-input 由共享文本状态驱动的多单元格一次性验证码输入。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Toggle Group Source: /versions/v0.6.4/zh-CN/base/primitives/toggle-group 将多个 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Progress Source: /versions/v0.6.4/zh-CN/base/primitives/progress 用于报告任务完成度的可组合轨道与指示器。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Sheet Source: /versions/v0.6.4/zh-CN/base/primitives/sheet 从边缘进入并管理关闭与焦点的模态界面。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Tooltip Source: /versions/v0.6.4/zh-CN/base/primitives/tooltip 与触发元素关联、延迟显示且可定位的说明。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # 原语 Source: /versions/v0.6.4/zh-CN/base/primitives GPUI Base 原语只提供行为,不规定视觉表现。每个页面都会说明公开导入路径和最小可用组合。页面上方的在线示例由 `crates/base/examples` 构建,也可以作为原生 GPUI 应用运行。 ## 原语目录 - [Accordion](/versions/v0.6.4/zh-CN/base/accordion) — 由可独立设置样式的标题、触发器和面板组成的折叠组。 - [Alert Dialog](/versions/v0.6.4/zh-CN/base/alert-dialog) — 用于需要明确确认之操作的模态对话框。 - [Avatar](/versions/v0.6.4/zh-CN/base/avatar) — 带可组合后备内容的人物或实体图像。 - [Button](/versions/v0.6.4/zh-CN/base/button) — 无样式、可访问且支持键盘激活的按钮。 - [Calendar](/versions/v0.6.4/zh-CN/base/calendar) — 支持选择匹配器和自定义日期渲染的状态驱动日历。 - [Checkbox](/versions/v0.6.4/zh-CN/base/checkbox) — 指示器可单独设置样式的受控三态复选框。 - [Collapsible](/versions/v0.6.4/zh-CN/base/collapsible) — 不限定触发器样式的可折叠内容区域。 - [Color Picker](/versions/v0.6.4/zh-CN/base/color-picker) — 构建自定义颜色选择器所需的状态和交互基础。 - [Combobox](/versions/v0.6.4/zh-CN/base/combobox) — 结合文本输入、键盘导航建议和选择行为的组合框。 - [Date Picker](/versions/v0.6.4/zh-CN/base/date-picker) — 将日历与弹出层组合起来、可感知焦点的日期输入。 - [Dialog](/versions/v0.6.4/zh-CN/base/dialog) — 带焦点管理、遮罩、标题和关闭部件的可组合模态层。 - [Hover Card](/versions/v0.6.4/zh-CN/base/hover-card) — 与指针或键盘触发器关联的延迟浮动卡片。 - [Input](/versions/v0.6.4/zh-CN/base/input) — 支持选择、掩码、验证和数值步进的单行输入。 - [Textarea](/versions/v0.6.4/zh-CN/base/textarea) — 支持固定行数、换行和自动增高的多行文本框。 - [Editor](/versions/v0.6.4/zh-CN/base/editor) — 支持高亮、行号槽、折叠、装饰和 LSP 扩展的代码编辑器基础。 - [Link](/versions/v0.6.4/zh-CN/base/link) — 样式由应用定义的可访问链接控件。 - [Nav Stack](/versions/v0.6.4/zh-CN/base/nav-stack) — 支持 push、pop、forward 与 replace 的视图导航栈,过渡生命周期可动画。 - [Number Input](/versions/v0.6.4/zh-CN/base/number-input) — 带递增、递减和步进行为的数字输入。 - [OTP Input](/versions/v0.6.4/zh-CN/base/otp-input) — 由共享文本状态驱动的多单元格验证码输入。 - [Pagination](/versions/v0.6.4/zh-CN/base/pagination) — 显式管理当前页与总页数的受控分页器。 - [Popover](/versions/v0.6.4/zh-CN/base/popover) — 支持受控或内部开关状态的锚定浮层。 - [Popup](/versions/v0.6.4/zh-CN/base/popup) — 底层触发器与锚定浮动内容宿主。 - [Progress](/versions/v0.6.4/zh-CN/base/progress) — 用轨道和指示器报告任务完成度。 - [Radio](/versions/v0.6.4/zh-CN/base/radio) — 具有选中与禁用语义的受控单选项。 - [Radio Group](/versions/v0.6.4/zh-CN/base/radio-group) — 为单项选择提供分组与键盘导航。 - [Resizable](/versions/v0.6.4/zh-CN/base/resizable) — 用于可调整分栏布局的面板组和拖拽手柄。 - [Scrollbar](/versions/v0.6.4/zh-CN/base/scrollbar) — 连接 GPUI 滚动句柄或统一列表句柄的滚动条。 - [Select](/versions/v0.6.4/zh-CN/base/select) — 由锚定且支持键盘导航的弹层驱动的选择控件。 - [Sheet](/versions/v0.6.4/zh-CN/base/sheet) — 从边缘进入并管理关闭和焦点的模态层。 - [Slider](/versions/v0.6.4/zh-CN/base/slider) — 轨道、指示区和滑块可独立设置样式的范围输入。 - [Switch](/versions/v0.6.4/zh-CN/base/switch) — 轨道与滑块可分别设置样式的受控开关。 - [Table](/versions/v0.6.4/zh-CN/base/table) — 用于组合表头、表体、行和单元格的语义化表格原语。 - [Tabs](/versions/v0.6.4/zh-CN/base/tabs) — 带受控选择的标签列表和可访问标签控件。 - [Toast](/versions/v0.6.4/zh-CN/base/toast) — 受管理、带动画的临时状态消息栈。 - [Toggle](/versions/v0.6.4/zh-CN/base/toggle) — 用于格式等持久选择的受控双态按钮。 - [Toggle Group](/versions/v0.6.4/zh-CN/base/toggle-group) — 将多个 Toggle 协调为单选或多选组。 - [Tooltip](/versions/v0.6.4/zh-CN/base/tooltip) — 与触发元素关联、延迟显示且可定位的说明。 - [Tree](/versions/v0.6.4/zh-CN/base/tree) — 显式管理展开与选择状态的虚拟化层级列表。 --- # Radio Group Source: /versions/v0.6.4/zh-CN/base/primitives/radio-group 将单选项分组,并为单项选择提供键盘导航。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Accordion Source: /versions/v0.6.4/zh-CN/base/primitives/accordion 由可独立设置样式的标题、触发器和面板组成的折叠组。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Calendar Source: /versions/v0.6.4/zh-CN/base/primitives/calendar 支持选择匹配器和自定义日期项渲染的状态驱动日期网格。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Hover Card Source: /versions/v0.6.4/zh-CN/base/primitives/hover-card 与指针或键盘触发器关联的延迟浮动卡片。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # Toggle Source: /versions/v0.6.4/zh-CN/base/primitives/toggle 用于格式等持久选择的受控双态按钮。 和所有 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,并在消费端设计系统中验证焦点、悬停、按下、选中、禁用、减少动态效果和高对比度状态。 --- # 快速开始 Source: /versions/v0.6.4/zh-CN/base/getting-started ## 安装 使用与 `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.6.4/zh-CN/base/primitives)中的 slug。网站会把同一份展示代码编译为 `wasm32-unknown-unknown` 并加载到各原语页面。 --- # 虚拟列表 Source: /versions/v0.6.4/zh-CN/base/virtual-list 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()`。 - 保持逻辑顺序、项目数和稳定身份,让辅助技术获得连贯列表。 --- # GPUI Base Source: /versions/v0.6.4/zh-CN/base `gpui-base` 是 GPUI Kit 的无样式基础层。它提供交互行为、受控状态、焦点管理、无障碍语义、动画、虚拟列表和主题 token,同时将布局与视觉设计完整留给应用。 ## 如何选择 | 使用 | 适用场景 | | --- | --- | | `gpui-base` | 需要创建自己的设计系统,并掌控每个视觉选择 | | `gpui-component` | 需要一套具有完整视觉设计、可直接使用的组件 | 依赖始终由上层指向基础层:`gpui-component` 构建于 `gpui-base` 之上,应用也可以直接使用任意一层。 ## 基本原则 - **行为内置**:控件提供一致的指针、键盘、焦点和状态行为。 - **表现由应用决定**:直接组合 GPUI 样式方法和 children,不需要覆盖默认视觉。 - **部件可组合**:primitive 暴露有意义的子部件,而不是把结构隐藏在单体组件中。 - **状态明确**:受控输入报告变化,最终状态由 view 持有。 ## 开始使用 从[入门指南](/versions/v0.6.4/zh-CN/getting-started)开始,使用 [TextView](/versions/v0.6.4/zh-CN/text-view) 渲染可选择的 Markdown 与 HTML,或阅读[文本选择](/versions/v0.6.4/zh-CN/text-selection)为自定义 renderer 接入窗口级选择。每个页面都提供 Rust 代码和可运行的 WASM 示例。 [History](/versions/v0.6.4/zh-CN/history) 介绍两种用途明确不同的状态结构:`History` 是包含根条目、当前条目、后退与前进分支的导航轨迹,`UndoHistory` 则记录分组的 undo 与 redo 事务。 --- # Dock Source: /versions/v0.6.4/zh-CN/base/dock 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、减少动态效果和高对比度。