377 lines
16 KiB
Markdown
377 lines
16 KiB
Markdown
# GPUI Framework — Detailed Findings
|
||
|
||
**Date:** 2026-03-19 (updated 2026-03-19 22:30)
|
||
**Source:** Hands-on prototyping (ui-gpui/, 2,490 lines) + source code analysis of GPUI 0.2.2 + Zed editor + 4 parallel research agents
|
||
|
||
## BREAKTHROUGH: 4.5% → 0.83% CPU
|
||
|
||
**The #1 optimization rule in GPUI:** Never make animated state an Entity CHILD of the view that renders it. Make it a PEER Entity that the view READS.
|
||
|
||
```
|
||
WRONG (4.5% CPU):
|
||
Workspace → ProjectGrid → ProjectBox → PulsingDot (child Entity)
|
||
cx.notify() on PulsingDot → mark_view_dirty walks ancestors
|
||
→ Workspace dirty → refreshing=true → ALL .cached() children miss
|
||
|
||
RIGHT (0.83% CPU):
|
||
Workspace → ProjectGrid → ProjectBox (reads BlinkState)
|
||
BlinkState (peer Entity, not child)
|
||
cx.notify() on BlinkState → only dirties views that .read(cx) it
|
||
→ ProjectBox dirty, Workspace/ProjectGrid NOT dirty → siblings cached
|
||
```
|
||
|
||
Key mechanisms:
|
||
1. **`.cached(StyleRefinement::default())`** on Entity children → GPU scene replay (zero Rust code)
|
||
2. **Shared state Entity** instead of child Entity → isolates dirty propagation
|
||
3. **`mark_view_dirty()` walks ancestors** — this is GPUI's fundamental constraint
|
||
4. **`refreshing=true` on cache miss** cascades down → children can't cache if parent is dirty
|
||
|
||
## DEEP DIVE: Why 3% is the floor for div-based views (2026-03-19 late)
|
||
|
||
### The full dirty propagation chain (confirmed by source analysis)
|
||
|
||
```
|
||
cx.notify(entity_id)
|
||
→ App::notify → WindowInvalidator::invalidate_view(entity_id)
|
||
→ dirty_views.insert(entity_id)
|
||
→ draw() → invalidate_entities() → mark_view_dirty(entity_id)
|
||
→ dispatch_tree.view_path_reversed(entity_id) → walks parent chain
|
||
→ inserts entity + ALL render-time ancestors into dirty_views
|
||
→ AnyView::prepaint checks: dirty_views.contains(MY entity_id)
|
||
→ if true: cache MISS, re-render
|
||
→ if false: cache HIT, replay GPU commands
|
||
```
|
||
|
||
### Why Zed achieves <1% with the SAME mechanism
|
||
|
||
Zed's BlinkManager → observer → cx.notify(Editor) → Editor + Pane + Workspace ALL in dirty_views.
|
||
ALL re-render. But:
|
||
- Workspace::render() = ~5 div builders = <0.1ms
|
||
- Pane::render() = ~10 div builders = <0.2ms
|
||
- Editor::render() = returns EditorElement struct = <0.01ms
|
||
- EditorElement::prepaint/paint = real work, bounded to visible rows
|
||
|
||
Total: <1ms per blink frame. Our ProjectBox::render() = ~9 divs + 3 tab buttons = ~15ms.
|
||
The 15ms includes Taffy layout + element prepaint + paint pipeline overhead per div.
|
||
|
||
### SharedBlink pattern (Arc<AtomicBool>)
|
||
|
||
Replaced Entity<BlinkState> with `Arc<AtomicBool>`:
|
||
- Background timer toggles atomic bool every 500ms
|
||
- Timer calls cx.notify() on ProjectBox directly (no intermediate entity)
|
||
- ProjectBox::render() reads bool atomically — no Entity subscription overhead
|
||
- Same 3% CPU — confirms cost is in render() itself, not entity machinery
|
||
|
||
### Hierarchy flattening (4 levels → 2 levels)
|
||
|
||
Removed ProjectGrid entity level. ProjectBoxes render directly from Workspace.
|
||
Dispatch tree: Workspace → ProjectBox (2 levels, same as Zed's Workspace → Pane).
|
||
CPU unchanged at 3% — confirms cost is per-frame render(), not ancestor walk count.
|
||
|
||
### Path to <1%: Custom Element for ProjectBox header
|
||
|
||
Convert header (accent stripe + dot + name + CWD + tab bar) from div tree to
|
||
custom `impl Element` that paints directly via `window.paint_quad()` +
|
||
`window.text_system().shape_line().paint()`. This eliminates:
|
||
- 9 div() constructor calls
|
||
- 9 Taffy layout nodes
|
||
- 9 prepaint traversals
|
||
- 9 paint traversals
|
||
|
||
Replaced by ~6 direct GPU primitive insertions (paint_quad × 4 + shape_line × 2).
|
||
Expected reduction: 15ms → <1ms per frame.
|
||
|
||
## 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)
|