From ad45a8d88d076c7370ce12fefba6a025af68dc72 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Thu, 19 Mar 2026 09:43:17 +0100 Subject: [PATCH] docs: comprehensive GPUI findings (API, animation patterns, gotchas, benchmarks) --- docs/architecture/gpui-findings.md | 299 +++++++++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 docs/architecture/gpui-findings.md diff --git a/docs/architecture/gpui-findings.md b/docs/architecture/gpui-findings.md new file mode 100644 index 0000000..d378fa9 --- /dev/null +++ b/docs/architecture/gpui-findings.md @@ -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) -> impl IntoElement { + div().flex().bg(rgba(0x1e1e2eff)).child("Hello") + } +} +``` + +### Entity System +- `Entity` — strong reference to a view/model (Arc-based) +- `WeakEntity` — weak reference (used in async contexts) +- `Context` — 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` 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) { + 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`) 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 = cx.new(|cx: &mut Context| T::new()); +``` + +### Entity Update (from parent) +```rust +entity.update(cx, |view: &mut T, cx: &mut Context| { + view.do_something(cx); +}); +``` + +### Async Spawn (from entity context) +```rust +cx.spawn(async move |weak: WeakEntity, 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 { 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, ...) -> () { () } + + fn paint(&mut self, ..., bounds: Bounds, ..., 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)