docs: comprehensive GPUI findings (API, animation patterns, gotchas, benchmarks)
This commit is contained in:
parent
5dbf5bd43c
commit
ad45a8d88d
1 changed files with 299 additions and 0 deletions
299
docs/architecture/gpui-findings.md
Normal file
299
docs/architecture/gpui-findings.md
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
# GPUI Framework — Detailed Findings
|
||||
|
||||
**Date:** 2026-03-19
|
||||
**Source:** Hands-on prototyping (ui-gpui/, 2,490 lines) + source code analysis of GPUI 0.2.2 + Zed editor
|
||||
|
||||
## Overview
|
||||
|
||||
GPUI is the GPU-accelerated UI framework powering the Zed editor. Apache-2.0 licensed (the crate itself; Zed app is GPL-3.0). Pre-1.0 with breaking changes every 2-3 months. 76.7k stars (Zed repo), $32M Sequoia funding.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Rendering Model
|
||||
- Hybrid immediate + retained mode
|
||||
- Target: 120 FPS (Metal on macOS, Vulkan via wgpu on Linux/Windows)
|
||||
- No CSS — all styling via Rust method chains: `.bg()`, `.text_color()`, `.border()`, `.rounded()`, `.px()`, `.py()`, `.flex()`
|
||||
- Text rendering via GPU glyph atlas
|
||||
- Binary size: ~12MB (bare GPUI app)
|
||||
- RAM baseline: ~73-200MB (depending on complexity)
|
||||
|
||||
### Component Model
|
||||
```rust
|
||||
struct MyView { /* state */ }
|
||||
|
||||
impl Render for MyView {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().flex().bg(rgba(0x1e1e2eff)).child("Hello")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Entity System
|
||||
- `Entity<T>` — strong reference to a view/model (Arc-based)
|
||||
- `WeakEntity<T>` — weak reference (used in async contexts)
|
||||
- `Context<T>` — mutable access to entity + app state during render/update
|
||||
- `cx.new(|cx| T::new())` — create child entity
|
||||
- `entity.update(cx, |view, cx| { ... })` — mutate entity from parent
|
||||
- `cx.notify()` — mark entity as dirty for re-render
|
||||
|
||||
### State Management
|
||||
- `Entity<T>` for shared mutable state (like Svelte stores)
|
||||
- `cx.observe(&entity, callback)` — react to entity changes
|
||||
- `entity.read(cx)` — read entity state (SUBSCRIBES to changes in render context!)
|
||||
- `cx.notify()` — trigger re-render of current entity
|
||||
|
||||
## Event Loop
|
||||
|
||||
### X11 (Linux)
|
||||
- Uses `calloop` event loop with periodic timer at monitor refresh rate (from xrandr CRTC info, fallback 16.6ms = 60Hz)
|
||||
- Each tick: check `invalidator.is_dirty()` → if false, skip frame (near-zero cost)
|
||||
- If dirty: full window draw + present
|
||||
- When window hidden: timer removed entirely (0% CPU)
|
||||
- File: `crates/gpui_linux/src/linux/x11/client.rs`
|
||||
|
||||
### Wayland
|
||||
- Entirely compositor-driven: `wl_surface.frame()` callback
|
||||
- No fixed timer — compositor tells GPUI when to render
|
||||
- Even more efficient than X11 (no polling)
|
||||
- File: `crates/gpui_linux/src/linux/wayland/client.rs`
|
||||
|
||||
### Window Repaint Flow
|
||||
```
|
||||
cx.notify(entity_id)
|
||||
→ App::notify() → push Effect::Notify
|
||||
→ Window::invalidate() → set dirty=true
|
||||
→ calloop tick → is_dirty()=true → Window::draw()
|
||||
→ Window::draw() → render all dirty views → paint to GPU → present
|
||||
→ is_dirty()=false → sleep until next tick or next notify
|
||||
```
|
||||
|
||||
## Animation Patterns
|
||||
|
||||
### What We Tested (chronological)
|
||||
|
||||
| Approach | Result | CPU |
|
||||
|----------|--------|-----|
|
||||
| `request_animation_frame()` in render | Continuous vsync loop, full window repaint every frame | **90%** |
|
||||
| `cx.spawn()` + `tokio::time::sleep()` | spawn didn't fire from `cx.new()` closure | N/A |
|
||||
| Custom `Element` with `paint_quad()` + `request_animation_frame()` | Works but same vsync loop | **90%** |
|
||||
| `cx.spawn()` + `background_executor().timer(200ms)` + `cx.notify()` | Works but timer spawns don't fire from `cx.new()` | **10-15%** |
|
||||
| Zed BlinkManager pattern: `cx.spawn()` from `entity.update()` after registration + `timer(500ms)` + `cx.notify()` | **Works correctly** | **5%** |
|
||||
| Same + cached SharedStrings + removed diagnostics | Same pattern, cheaper render tree | **4.5%** |
|
||||
|
||||
### The Correct Pattern (Zed BlinkManager)
|
||||
|
||||
From Zed's `crates/editor/src/blink_manager.rs`:
|
||||
|
||||
```rust
|
||||
fn blink_cursors(&mut self, epoch: usize, cx: &mut Context<Self>) {
|
||||
if epoch == self.blink_epoch && self.enabled && !self.blinking_paused {
|
||||
self.visible = !self.visible;
|
||||
cx.notify(); // marks view dirty, does NOT repaint immediately
|
||||
|
||||
let epoch = self.next_blink_epoch();
|
||||
let interval = self.blink_interval;
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(interval).await;
|
||||
this.update(cx, |this, cx| this.blink_cursors(epoch, cx));
|
||||
}).detach();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key elements:
|
||||
1. **Epoch counter** — each `next_blink_epoch()` increments a counter. Stale timers check `epoch == self.blink_epoch` and bail out. No timer accumulation.
|
||||
2. **Recursive spawn** — each blink schedules the next one. No continuous loop.
|
||||
3. **`background_executor().timer()`** — sleeps the async task for the interval. NOT `tokio::time::sleep` (GPUI has its own executor).
|
||||
4. **`cx.notify()`** — marks ONLY this view dirty. Window draws on next calloop tick.
|
||||
|
||||
### Critical Gotcha: `cx.spawn()` Inside `cx.new()`
|
||||
|
||||
**`cx.spawn()` called inside a `cx.new(|cx| { ... })` closure does NOT execute.** The entity is not yet registered with the window at that point. The spawn is created but the async task never runs.
|
||||
|
||||
**Fix:** Call `start_blinking()` via `entity.update(cx, |dot, cx| dot.start_blinking(cx))` AFTER `cx.new()` returns, from the parent's `init_subviews()`.
|
||||
|
||||
### Why 4.5% CPU Instead of ~1% (Zed)
|
||||
|
||||
`cx.notify()` on PulsingDot propagates to the window level. GPUI redraws ALL dirty views in the window — including parent views (Workspace, ProjectGrid, ProjectBox). Our prototype rebuilds ~200 div elements per window redraw.
|
||||
|
||||
Zed achieves ~1% because:
|
||||
1. Its render tree is heavily optimized with caching
|
||||
2. Static parts are pre-computed, not rebuilt per frame
|
||||
3. Entity children (`Entity<T>`) passed via `.child(entity)` are cached by GPUI
|
||||
4. Zed's element tree is much deeper but uses IDs for efficient diffing
|
||||
|
||||
Our remaining 4.5% optimization path:
|
||||
- Move header, tab bar, content area into separate Entity views (GPUI caches them independently)
|
||||
- Avoid re-building tab buttons and static text on every render
|
||||
- Use `SharedString` everywhere (done — reduced from 5% to 4.5%)
|
||||
|
||||
## API Reference (GPUI 0.2.2)
|
||||
|
||||
### Entity Creation
|
||||
```rust
|
||||
let entity: Entity<T> = cx.new(|cx: &mut Context<T>| T::new());
|
||||
```
|
||||
|
||||
### Entity Update (from parent)
|
||||
```rust
|
||||
entity.update(cx, |view: &mut T, cx: &mut Context<T>| {
|
||||
view.do_something(cx);
|
||||
});
|
||||
```
|
||||
|
||||
### Async Spawn (from entity context)
|
||||
```rust
|
||||
cx.spawn(async move |weak: WeakEntity<Self>, cx: &mut AsyncApp| {
|
||||
cx.background_executor().timer(Duration::from_millis(500)).await;
|
||||
weak.update(cx, |view, cx| {
|
||||
view.mutate();
|
||||
cx.notify();
|
||||
}).ok();
|
||||
}).detach();
|
||||
```
|
||||
|
||||
### Timer
|
||||
```rust
|
||||
cx.background_executor().timer(Duration::from_millis(500)).await;
|
||||
```
|
||||
|
||||
### Color
|
||||
```rust
|
||||
// rgba(0xRRGGBBAA) — u32 hex
|
||||
let green = rgba(0xa6e3a1ff);
|
||||
let semi_transparent = rgba(0xa6e3a180);
|
||||
// Note: alpha on bg() may not work on small elements; use color interpolation instead
|
||||
```
|
||||
|
||||
### Layout
|
||||
```rust
|
||||
div()
|
||||
.flex() // display: flex
|
||||
.flex_col() // flex-direction: column
|
||||
.flex_row() // flex-direction: row
|
||||
.flex_1() // flex: 1
|
||||
.w_full() // width: 100%
|
||||
.h(px(36.0)) // height: 36px
|
||||
.min_w(px(400.0))
|
||||
.px(px(12.0)) // padding-left + padding-right
|
||||
.py(px(8.0)) // padding-top + padding-bottom
|
||||
.gap(px(8.0)) // gap
|
||||
.rounded(px(8.0)) // border-radius
|
||||
.border_1() // border-width: 1px
|
||||
.border_color(color)
|
||||
.bg(color)
|
||||
.text_color(color)
|
||||
.text_size(px(13.0))
|
||||
.overflow_hidden()
|
||||
.items_center() // align-items: center
|
||||
.justify_center() // justify-content: center
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.bg(hover_color))
|
||||
.id("unique-id") // for diffing + hit testing
|
||||
.child(element_or_entity)
|
||||
.children(option_entity) // renders Some, skips None
|
||||
```
|
||||
|
||||
### Custom Element (for direct GPU painting)
|
||||
```rust
|
||||
impl Element for MyElement {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = ();
|
||||
|
||||
fn id(&self) -> Option<ElementId> { None }
|
||||
fn source_location(&self) -> Option<&'static Location<'static>> { None }
|
||||
|
||||
fn request_layout(&mut self, ..., window: &mut Window, cx: &mut App)
|
||||
-> (LayoutId, ()) {
|
||||
let layout_id = window.request_layout(Style { size: ..., .. }, [], cx);
|
||||
(layout_id, ())
|
||||
}
|
||||
|
||||
fn prepaint(&mut self, ..., bounds: Bounds<Pixels>, ...) -> () { () }
|
||||
|
||||
fn paint(&mut self, ..., bounds: Bounds<Pixels>, ..., window: &mut Window, ...) {
|
||||
window.paint_quad(fill(bounds, color).corner_radii(radius));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Animation Frame Scheduling
|
||||
```rust
|
||||
// From render() — schedules next vsync frame (CAUTION: 60fps = 90% CPU)
|
||||
window.request_animation_frame();
|
||||
|
||||
// From anywhere — schedule callback on next frame
|
||||
window.on_next_frame(|window, cx| { /* ... */ });
|
||||
```
|
||||
|
||||
### Window
|
||||
```rust
|
||||
window.set_window_title("Title");
|
||||
window.request_animation_frame(); // schedule next frame
|
||||
window.on_next_frame(callback); // callback on next frame
|
||||
window.paint_quad(quad); // direct GPU paint in Element::paint()
|
||||
```
|
||||
|
||||
## Known Limitations (2026-03)
|
||||
|
||||
1. **Pre-1.0 API** — breaking changes every 2-3 months
|
||||
2. **No per-view-only repaint** — `cx.notify()` propagates to window level, redraws all dirty views
|
||||
3. **`cx.spawn()` in `cx.new()` doesn't fire** — must call after entity registration
|
||||
4. **`rgba()` alpha on `.bg()` unreliable** for small elements — use color interpolation
|
||||
5. **No CSS** — every style must be expressed via Rust methods
|
||||
6. **No WebDriver** — can't use existing E2E test infrastructure
|
||||
7. **No plugin host API** — must build your own (WASM/wasmtime or subprocess)
|
||||
8. **Sparse documentation** — "read Zed source" is the primary reference
|
||||
9. **macOS-first** — Linux (X11/Wayland) added 2025, Windows added late 2025
|
||||
10. **X11 calloop polls at monitor Hz** — non-zero baseline CPU even when idle (~0.5%)
|
||||
|
||||
## Comparison with Dioxus Blitz
|
||||
|
||||
| Aspect | GPUI | Dioxus Blitz |
|
||||
|--------|------|-------------|
|
||||
| Styling | Rust methods (`.bg()`, `.flex()`) | CSS (same as browser) |
|
||||
| Animation | Spawn + timer + notify (~4.5% CPU) | Class toggle + no CSS transition (~5% CPU) |
|
||||
| Animation limit | cx.notify propagates to window | CSS transition = full scene repaint |
|
||||
| Custom paint | Yes (Element trait + paint_quad) | No (CSS only, no shader/canvas API) |
|
||||
| Render model | Retained views + element diff | HTML/CSS via Vello compute shaders |
|
||||
| Terminal | alacritty_terminal + GPUI rendering | xterm.js in WebView (or custom build) |
|
||||
| Migration cost | Full rewrite (no web tech) | Low (same wry webview as Tauri) |
|
||||
| Ecosystem | 60+ components (gpui-component) | CSS ecosystem (any web component) |
|
||||
| Text rendering | GPU glyph atlas | Vello compute shader text |
|
||||
|
||||
## Files in Our Prototype
|
||||
|
||||
```
|
||||
ui-gpui/
|
||||
├── Cargo.toml
|
||||
├── src/
|
||||
│ ├── main.rs — App entry, window creation
|
||||
│ ├── theme.rs — Catppuccin Mocha as const Rgba values
|
||||
│ ├── state.rs — AppState, Project, AgentSession types
|
||||
│ ├── backend.rs — GpuiEventSink + Backend (PtyManager bridge)
|
||||
│ ├── workspace.rs — Root view (sidebar + grid + statusbar)
|
||||
│ ├── components/
|
||||
│ │ ├── sidebar.rs — Icon rail
|
||||
│ │ ├── status_bar.rs — Bottom bar (agent counts, cost)
|
||||
│ │ ├── project_grid.rs — Grid of ProjectBox entities
|
||||
│ │ ├── project_box.rs — Project card (header, tabs, content, dot)
|
||||
│ │ ├── agent_pane.rs — Message list + prompt
|
||||
│ │ ├── pulsing_dot.rs — Animated status dot (BlinkManager pattern)
|
||||
│ │ ├── settings.rs — Settings drawer
|
||||
│ │ └── command_palette.rs — Ctrl+K overlay
|
||||
│ └── terminal/
|
||||
│ ├── renderer.rs — GPU terminal (alacritty_terminal cells)
|
||||
│ └── pty_bridge.rs — PTY via agor-core
|
||||
```
|
||||
|
||||
## Sources
|
||||
|
||||
- [GPUI crate (crates.io)](https://crates.io/crates/gpui)
|
||||
- [GPUI README](https://github.com/zed-industries/zed/blob/main/crates/gpui/README.md)
|
||||
- [Zed BlinkManager source](https://github.com/zed-industries/zed/blob/main/crates/editor/src/blink_manager.rs)
|
||||
- [GPUI X11 client source](https://github.com/zed-industries/zed/blob/main/crates/gpui_linux/src/linux/x11/client.rs)
|
||||
- [GPUI Wayland client source](https://github.com/zed-industries/zed/blob/main/crates/gpui_linux/src/linux/wayland/client.rs)
|
||||
- [GPUI window.rs source](https://github.com/zed-industries/zed/blob/main/crates/gpui/src/window.rs)
|
||||
- [gpui-component library](https://github.com/4t145/gpui-component)
|
||||
- [awesome-gpui list](https://github.com/zed-industries/awesome-gpui)
|
||||
- [Zed GPU rendering blog](https://zed.dev/blog/videogame)
|
||||
Loading…
Add table
Add a link
Reference in a new issue