322 lines
13 KiB
Markdown
322 lines
13 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
|
|
|
|
## 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)
|