Prologue
You’ve heard about Rust.
Maybe a colleague wouldn’t stop talking about it. Maybe you saw it top the “most loved language” survey for the eighth year running and wondered what the fuss was about. Maybe you’ve watched the systems programming world slowly tilt in its direction and filed it under “should probably look at that eventually.”
Eventually keeps getting postponed.
Rust has a reputation: steep learning curve, cryptic compiler errors, ownership rules that feel academic until they suddenly click. There’s always a more urgent deadline, a more familiar tool, a good enough solution in the language you already know.
This book is for the engineer who never quite got the activation energy.
If you’re coming from C++ — you already understand memory, you’re just tired of the preprocessor, the build system archaeology, the use-after-free that only manifests in release builds on one platform. You know the performance is worth it. You’ve quietly wondered if the ceremony has to be this expensive.
If you’re coming from TypeScript and React — you understand components, reactivity, and utility-first styling. You’ve shipped products in Electron and watched a 200MB bundle download just to open a settings window. You’ve accepted that “native feel” was someone else’s problem and explained to a non-technical founder why your desktop app needs 400MB of RAM at idle.
Both of you end up in the same place: wanting something better, not quite finding the reason to start.
GPUI is that reason. It’s the UI framework the Zed editor was built with. It thinks in components. It styles with a fluent API that maps directly to your Tailwind intuitions. It runs on a language that makes the promises JavaScript never could — no garbage collector, no IPC bridge between main and renderer, no Chromium. Real cross-platform native desktop apps, small enough to email, fast enough to feel instant.
And agentic coding changes the calculus entirely. You don’t have to hold the entire borrow checker in your head on day one. You have a collaborator that knows the rules, catches the compiler errors with you, and explains the why while you focus on building something real. The learning curve is still there — but it’s no longer something you climb alone.
This book teaches Rust through GPUI. Every language concept arrives exactly when the framework demands it, motivated by something you’ve already felt as a limitation. By the end you’ll have built and shipped a cross-platform native desktop application — and you’ll understand why so many engineers who finally made the jump say they wish they’d done it sooner.
Let’s start by opening a window.
Chapter 1: Open a Window
Let’s skip the theory. Before we explain anything, we’re going to put a native window on your screen.
Install Rust and the project generator
If you don’t already have Rust:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
This installs rustc (the compiler) and cargo (the package manager — think npm, but for Rust). One command, no separate installs, no PATH archaeology.
Now install the project generator and create your first app:
# Install a tool to create starter projects
cargo install cargo-gpui --locked
# Create new starter project in the current directory (choose backend/fork when prompted)
cargo gpui new gpui_hello
# When prompted:
# - Choose "gpui-unofficial" (or your preferred backend)
# - Choose "hello-world" starter template
cd gpui_hello
cargo run
cargo gpui new is your npm init. It puts all the GPUI libraries into Cargo.toml manifest, and writes a main.rs suitable for whichever fork you picked.
📝 Note: When you run cargo gpui new, the generator will ask you two questions:
- Which GPUI backend? → Select gpui-unofficial (or your preferred fork)
- Which example/starter? → Select hello-world
The rest of this chapter assumes you’ve made these choices. If you picked something different, your generated
main.rswill look different — that’s expected, and you should follow what was generated rather than the code examples here.
cargo install cargo-gpui --lockedinstall the binary cargo-gpui, so that it gives youcargo gpuisubcommand.cargo gpui newwill generateCargo.tomland other template files.cargo runwill install the dependencies inCargo.toml, executecargo buildand run the resulting command. The result is the selected gpui fork with a recommended version installed and a starter app running. (Curious what to expect from Cargo’s dependency model as your project grows? See Appendix B: The Cargo Expectation Gap.)
Run it
cargo run
The first build takes a minute — Rust is compiling GPUI and its dependencies from source, something that happens once and then caches. After that, a native window appears on your screen with “Hello, GPUI!” centered on a dark background. No Electron wrapper. No local web server. No Chromium. Native pixels, drawn directly by your GPU.
Here’s what cargo gpui new just wrote for you:
use gpui::*;
use gpui_platform;
struct HelloWorld;
impl Render for HelloWorld {
fn render(&mut self, _: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.flex()
.items_center()
.justify_center()
.bg(rgb(0x1e1e1e))
.text_color(rgb(0xffffff))
.child("Hello, GPUI!")
}
}
fn main() {
gpui_platform::application().run(|cx| {
let _ = cx.open_window(WindowOptions::default(), |_, cx| {
cx.new(|_| HelloWorld)
});
});
}
What just happened
Open the main.rs in the editor.
If you’re coming from the web world or done some desktop application development, some lines will feel familiar.
application().run() is your ReactDOM.render(). It initializes the application, hooks into your operating system’s event loop, and hands control to the closure — the |cx: &mut App| { ... } block — where you set up your windows before the loop starts.
Depending on the fork you selected and the version you chose, there might be some differences in API. But here’s the major difference:
gpui_platformcontainsapplication()factory that creates the app context. Earlier versions or other forks useApplication::new() or App::new().
cx.open_window() asks the operating system (compositor) for a window frame/surface. The cx here is a context object — you’ll see it everywhere in GPUI. It’s your handle into the framework’s runtime, the thing you talk to when you want GPUI to do something for you.
HelloWorld is your root component. Right now it’s an empty struct — just a named type with no data — but it’s about to become the home for your application state.
impl Render for HelloWorld is your component’s render method. Whenever the screen needs to update, GPUI calls this function and asks: what should I draw right now? You return a description of the UI — not pixels directly, but a high-level declaration — and GPUI handles the rest.
div() is where it starts feeling like Tailwind. GPUI’s layout engine is built on Flexbox, the same model powering every modern browser. The method chain reads almost like a CSS class list: make it a flex container, fill the full size, center horizontally, center vertically, set the background color, set the text color, add a child. If you’ve written .flex .h-full .w-full .justify-center .items-center before, you already know some of GPUI.
Making It Look Like an App
Static text centered on a background isn’t very interesting. Let’s make it a bit more complex. Open src/main.rs and replace the Render implementation:
#![allow(unused)]
fn main() {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
.size_full()
.bg(rgb(0x1e1e1e))
.child(
div()
.w_full()
.p_4()
.bg(rgb(0x3b82f6))
.text_color(rgb(0xffffff))
.text_xl()
.font_weight(FontWeight::BOLD)
.child("My Native App")
)
.child(
div()
.flex_1()
.p_6()
.text_color(rgb(0xa0aec0))
.child("Rendering on GPU")
)
}
}
Run it again. Try resizing the window. Notice how instantly the layout recalculates — no jank, no reflow flicker. You have a responsive flex layout with a header bar and a content area, zero CSS, zero HTML.
Linux readers, a heads-up: “try resizing the window” assumes you can find an edge to grab. On GNOME — the default desktop for Ubuntu, Fedora, and others — running under Wayland, you might not have one. GPUI’s default window decorations aren’t always honored by GNOME’s compositor, which can leave you with a frameless rectangle: no title bar, no resize borders, no close button. Nothing’s wrong with the code above — see Appendix C: The Linux Desktop Reality for why this happens and how to handle it before you ship.
The nesting model is direct: .child() takes anything that can be rendered and places it inside the current element. Layouts compose by nesting, exactly like JSX — except it’s all just Rust function calls returning descriptions that GPUI resolves into pixels.
Try it yourself
Before moving on, try changing a few things and re-running:
- Change
rgb(0x3b82f6)to a different hex color and watch the header repaint. - Add a second
.child(...)to the content area with different text. - Delete the header
div()entirely. What happens to the layout?
Don’t be afraid to break something. The point is to get a feel for how changes to the Render method map onto what gets drawn, before Chapter 2 introduces state and the ownership model that makes this interactive.
What’s missing
The app looks real but it’s completely static. Click anywhere — nothing happens. There’s no state, no interactivity, no memory of anything the user has done.
To fix that we need to give HelloWorld some data, and we need to learn how GPUI thinks about ownership. That’s Chapter 2 — and it’s where Rust starts to feel less like a foreign language and more like a better version of something you already know.
Chapter 2: Add State
Our app from Chapter 1 looks real but remembers nothing. Click anywhere — nothing happens. To fix that we need to give HelloWorld some data and learn how GPUI thinks about ownership.
Let’s build a click counter. It’s the simplest possible stateful UI, and it introduces every pattern you’ll use in every GPUI application you write.
The Data
Start by adding a field to the struct:
#![allow(unused)]
fn main() {
struct HelloWorld {
counter: usize,
}
}
Simple enough. But the moment we try to mutate this field inside a running UI, we run into something worth understanding — because how GPUI solves it is the foundation of everything else in this book.
Think of your app’s UI as a family tree of screens, panels, buttons, and text. Each piece potentially reacts to the same underlying data. A button label might change from “Start” to “Stop”. A click handler might update a counter that three other components are displaying. A panel might appear or disappear based on state that something else just changed.
Rust wants to know exactly who is allowed to change what, and only one part of the program may mutate a piece of data at a time. A UI, on the other hand, naturally wants several parts of the code to look at and react to the same thing. That tension — Rust’s strict ownership rules meeting a widget tree’s overlapping interests in shared state — is why GUI programming in Rust has a reputation for friction. Not because Rust is wrong. But because the obvious way to write UI code is exactly the kind of thing Rust is designed to prevent.
GPUI sidesteps this entirely by changing who owns the data.
Handing Ownership to the Runtime
In GPUI, the application runtime owns your state — not you. Instead of passing mutable references around your UI tree, you surrender your struct to the framework when the window opens, using cx.new():
fn main() {
App::new().run(|cx: &mut App| {
cx.open_window(WindowOptions::default(), |_, cx| {
cx.new(|_cx| HelloWorld { counter: 0 })
});
});
}
cx.new() takes your data, places it in GPUI’s managed runtime, and hands you back an Entity<HelloWorld> — a strongly-typed handle to your state. The framework now owns the actual memory. It guarantees your state stays alive exactly as long as the UI needs it and cleans it up automatically when the window closes. No manual memory management, no lifetime annotations, no dangling pointers.
Two things to hold onto here:
T— your raw struct,HelloWorld. Just plain data.Entity<T>— the handle GPUI gives back. Your reference into the runtime.
When a type implements Render, GPUI treats its entity as a view. The struct is both the data and the component — the state and the thing that knows how to draw itself.
Making It Interactive
Now update Render to draw a counter and a button. In GPUI there’s no built-in <Button> primitive — interactivity is just behavior layered onto the same div() you already know:
use gpui::*;
struct HelloWorld {
counter: usize,
}
impl Render for HelloWorld {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
.size_full()
.justify_center()
.items_center()
.bg(rgb(0x1e1e1e))
.text_color(rgb(0xffffff))
.gap_4()
.child(
div()
.text_xl()
.child(format!("Click count: {}", self.counter))
)
.child(
div()
.id("increment-button")
.p_2()
.px_4()
.bg(rgb(0x3b82f6))
.rounded_md()
.cursor_pointer()
.on_click(cx.listener(|this, _event, _window, cx| {
this.counter += 1;
cx.notify();
}))
.child("Increment")
)
}
}
fn main() {
App::new().run(|cx: &mut App| {
cx.open_window(WindowOptions::default(), |_, cx| {
cx.new(|_| HelloWorld { counter: 0 })
});
});
}
Run cargo run. Click the button. The counter increments instantly — native pixels, no virtual DOM diff, no garbage collector deciding this is a good moment to pause.
Two small things before we move on:
format!("Click count: {}", self.counter) is Rust’s string interpolation. The ! marks it as a macro rather than a regular function call — something you’ll see often in Rust. It works exactly like a template literal in JavaScript.
cx.listener() is worth a moment’s attention. When you write an event handler in GPUI, you need access to both your component’s data (this) and the framework context (cx) at the same time. cx.listener() is the bridge that wires them together safely — it produces a closure that GPUI knows how to call with both. You’ll write it often enough that it becomes automatic, but now you know what it’s doing.
Two Things Worth Understanding Now
The Hitbox Rule
Notice .id("increment-button") on the button div. In GPUI a plain div() is invisible to the mouse by default — GPUI’s hit-testing skips it entirely. Assigning a unique .id() registers the element as a target for mouse events. Without it, clicks fall silently through to the background with no error, no warning, nothing. Any element that needs to respond to mouse interaction needs an id.
The Golden Rule
Inside the click handler, this.counter += 1 mutates the data in memory. But changing data in memory does not update the screen. You must call cx.notify() explicitly.
If you’re coming from React, this is your setState. It tells GPUI: the data has changed, mark this entity dirty, schedule a re-render for the next frame. This explicit step is what lets GPUI safely coordinate updates across the entire UI tree without watching every variable in your application. Forget it once and your UI will silently show stale values — which will happen exactly once before it becomes muscle memory.
What’s Next
You built a stateful, interactive, native desktop app without writing a single lifetime annotation, without fighting the borrow checker, without thinking about memory at all. GPUI handled it.
But how? How does the runtime safely let a click handler, a render pass, and an async task all interact with your data without the compiler objecting?
That’s the question Chapter 3 answers. You’ve earned it.
Chapter 3: The Borrow Checker You Never Fight
In Chapter 2 you built a working stateful app without writing a single lifetime annotation, without wrapping anything in Arc<Mutex<T>>, without the compiler objecting once. If you’re coming from C++ or TypeScript that probably felt either suspiciously easy or genuinely surprising.
This chapter explains why it worked. Not because the theory is required reading before you can continue — you can keep building without it — but because understanding the engine makes you a better driver. When something does go wrong, you’ll know exactly where to look.
What the Runtime Owns
When you called cx.new(|_| HelloWorld { counter: 0 }) in Chapter 2, something important happened: you gave up ownership of your data. Not to another part of your code — to GPUI’s runtime itself.
In exchange, GPUI handed you back an Entity<HelloWorld>. This handle is your reference into the runtime. The actual memory lives inside the framework, not in your call stack, not in a struct field you manage manually.
This matters for two reasons.
First, lifecycle management becomes automatic. Entities are never manually freed. When all handles to an entity drop out of scope — when the window closes, when the view is removed — GPUI cleans up the memory. No destructor to write, no free to call, no use-after-free to debug.
Second, GPUI can now coordinate mutations safely. Because the runtime controls access to your data, it can ensure that a click handler and a render pass never hold conflicting references to the same state at the same time. This is the problem that makes UI code awkward in standard Rust — the widget tree’s natural desire for overlapping access to shared state runs directly against Rust’s ownership rules. GPUI resolves it by owning the data itself and mediating all access through the context object cx.
This is also why cx.notify() exists. Mutating data through cx is the framework’s signal that a change happened. Calling cx.notify() afterward says: the data has changed, schedule a re-render. The runtime batches these safely across the entire UI tree.
Async Without Fear
The real payoff of this model becomes clear the moment you need to do something asynchronous.
Imagine a button that loads a large file. You spawn a background task. The file is slow — network drive, large log, doesn’t matter. While it’s loading, the user closes the window.
In a naive implementation, the background task finishes, tries to update a UI component that no longer exists, and crashes. In standard Rust you’d reach for Arc<Mutex<T>> and hope you got the locking right. In GPUI, the solution is WeakEntity.
Before spawning an async task, you downgrade your entity to a weak reference:
#![allow(unused)]
fn main() {
let weak = entity.downgrade();
cx.spawn(|mut cx| async move {
// Heavy work happens here, off the main thread
let content = load_large_file().await;
// Attempt to update the UI when done
let result = weak.update(&mut cx, |this, cx| {
this.content = content;
cx.notify();
});
// If the window closed while we were working, result is Err.
// No crash. No panic. Just a graceful exit.
if result.is_err() {
// The view was dropped before we finished. Nothing to do.
}
}).detach();
}
A WeakEntity doesn’t keep the entity alive. If the user closes the window and all strong handles drop, the entity is cleaned up — and when your background task calls update(), it gets back an Err instead of a panic. You handle it or ignore it. Either way, nothing crashes.
This pattern — downgrade before spawning, check the result after — is the standard GPUI approach to all async work. Write it a few times and it becomes automatic.
RAII: The Patterns That Keep Things Running
GPUI uses Rust’s RAII model — Resource Acquisition Is Initialization — for two things that are easy to get wrong silently.
Tasks
When you spawn an async task with cx.spawn(), it returns a Task<T>. If you drop that value without calling .detach(), the task is immediately cancelled. Not eventually — immediately, cooperatively, on the next yield point.
This is a feature, not a bug. It means you can cancel an in-flight network request simply by dropping the task handle. It means reassigning a new task to a struct field automatically cancels the previous one. No explicit cancellation logic, no tracking running operations manually.
But it also means: if you want a task to keep running, you must store it. A fire-and-forget task that you don’t store will silently never complete.
#![allow(unused)]
fn main() {
struct MyView {
// Store the task here, or it gets cancelled immediately
_load_task: Option<Task<()>>,
}
}
Subscriptions
The same pattern applies to event subscriptions. When you subscribe to an event stream with cx.subscribe(), you get back a subscription handle. Drop it and the subscription is immediately unregistered — your handler stops being called.
Store the handle in your struct if you want the subscription to live as long as the view. This is RAII working exactly as intended: the subscription’s lifetime is tied to the handle’s lifetime, and the handle’s lifetime is tied to wherever you put it.
#![allow(unused)]
fn main() {
struct MyView {
_subscription: Option<Subscription>,
}
}
Both of these follow the same mental model: in GPUI, if you own the handle, you own the lifetime.
The Mental Shift
You don’t fight the borrow checker in GPUI because you’re not trying to manage memory yourself. The runtime owns the data. You hold handles. You request access through cx. You notify when things change.
Once that shift clicks, the rest of GPUI’s API starts to feel inevitable rather than arbitrary. The context object isn’t boilerplate — it’s the runtime’s interface. The Entity isn’t an indirection — it’s a guarantee.
Part II builds on this foundation. Now that you understand the engine, we can look at how GPUI actually turns your Render implementations into pixels — and what happens when the built-in primitives aren’t enough.
Chapter 4: Layout and Style
In the first part of this book, you built a working, stateful application. You learned how to pass data to GPUI and let the runtime borrow checker keep it safe. But so far, our UI has been brutally simple.
Now it is time to make it look like a professional desktop application.
If you are coming from web development, you might be looking around for the CSS files or a stylesheet engine. You won’t find one. GPUI completely eliminates the split between markup and styling. Instead, styling is applied directly via a fluent Rust API that maps perfectly to the mental model of Tailwind CSS.
The Styled Trait and the Fluent API
In GPUI, every base element (like div(), button(), and text()) implements a trait called Styled. Because these elements implement this trait, procedural macros inside the framework generate hundreds of chainable styling methods for them.
When you write .w_full(), .p_4(), or .bg(), you are calling these generated methods directly on the element.
#![allow(unused)]
fn main() {
div()
.flex()
.w_full() // width: 100%
.h_12() // height: 3rem (48px)
.p_4() // padding: 1rem (16px)
.bg(rgb(0x1e1e1e))
}
Because this is pure Rust, there are no string typos or missing CSS classes. If you type .w_fll() instead of .w_full(), your application simply won’t compile.
Flexbox and Spacing
GPUI’s layout engine uses Flexbox. There is no CSS Grid, no floats, and no table layouts. Flexbox is the single layout primitive you must master.
To turn any div() into a flex container, you simply call .flex(). From there, you control the layout exactly as you would in the web world:
#![allow(unused)]
fn main() {
div()
.flex()
.flex_col() // Flex direction: column
.justify_between() // Space elements apart
.items_center() // Center elements along the cross-axis
.gap_4() // Apply a 16px gap between children
}
For exact pixel measurements, GPUI provides the px() helper. You can use this for specific gaps (.gap(px(10.0))) or custom sizing when the predefined Tailwind-like steps don’t fit your needs.
Colors, Typography, and Borders
You apply colors using the rgb() or rgba() macros. These can be chained for backgrounds, text, and borders:
#![allow(unused)]
fn main() {
div()
.bg(rgb(0x2d3748))
.text_color(rgb(0xffffff))
.border_1() // 1px border
.border_color(rgb(0x4a5568))
.rounded_md() // Medium border radius
.shadow_sm() // Small drop shadow
}
Note on Text: While GPUI supports rich text formatting, the basic .text_color() and size APIs applied to a div() will cascade to the plain text children inside it. However, GPUI lacks some of the hyper-granular text styling found in CSS, such as arbitrary .line_height().
Conditional Styling with when()
One of the biggest friction points in standard React/Tailwind development is dynamically changing classes based on state (e.g., className={isActive ? "bg-blue-500" : "bg-gray-500"}).
GPUI solves this elegantly with the .when() method. It allows you to conditionally apply styles without breaking your fluent builder chain:
#![allow(unused)]
fn main() {
div()
.flex()
.p_2()
.rounded_md()
// If self.is_active is true, apply the blue background.
// Otherwise, do nothing.
.when(self.is_active, |this| this.bg(rgb(0x3b82f6)))
// You can also use it for conditional text colors or borders
.when(!self.is_active, |this| this.text_color(rgb(0xa0aec0)))
.child("Dashboard")
}
Absolute Positioning and Overlays
Flexbox handles most of your application’s structure, but some things need to step outside the normal flow entirely. Notification badges, tooltips, dropdown menus — these sit on top of other content rather than alongside it.
GPUI provides .absolute(), paired with .top(), .bottom(), .left(), and .right(). An absolute element is positioned relative to its closest ancestor marked .relative(), exactly as in CSS:
#![allow(unused)]
fn main() {
div()
.relative() // Establishes the positioning boundary
.p_2()
.bg(rgb(0x3b82f6))
.rounded_md()
.child("Inbox")
.child(
div()
.absolute() // Pulls the badge out of the flex flow
.top(px(-4.0))
.right(px(-4.0))
.w_3()
.h_3()
.bg(rgb(0xef4444))
.rounded_full()
)
}
Paint Order Instead of Z-Index
When you start using .absolute(), you’ll instinctively reach for .z_index() to control which element sits on top. There isn’t one.
GPUI renders directly to the GPU and deliberately avoids the performance cost of calculating CSS stacking contexts. Instead, elements are painted in exactly the order they appear in your code. If you need a badge to overlap the content below it, declare it after that content. The last child paints on top.
For overlays that need to break out of their parent’s clipping bounds entirely — a command palette, a context menu — GPUI provides a deferred() rendering system. That’s Chapter 8.
What GPUI Does NOT Have (The Constraints)
Because GPUI renders everything natively to the GPU for maximum performance, it does not implement the entire CSS specification. To avoid frustration, you must understand what is deliberately missing:
- No Z-Index: There is no
.z_index()method. Elements are drawn in the exact order they appear in the tree. To put a popup over a text box, you either render it later in the child list or use absolute positioning with GPUI’s.deferred()rendering system (which we will cover in Chapter 8). - No Animations or Transforms: GPUI has no
.transition(),.animation(),.rotate(), or.scale()methods. If you want something to move or fade, you must manually drive the animation state using a timer, which we will cover in Chapter 7. - Limited Overflow: There is no
.overflow_scroll(),.overflow_auto(), or.overflow_visible(). GPUI only supports.overflow_hidden()to clip children to the parent’s bounds. - No Gradients or Filters: There are no built-in linear gradients, radial gradients, or backdrop blur filters.
The Next Step
By combining div(), Flexbox, and .when(), you can build 95% of a professional application interface. But what about the other 5%? What happens when you need to draw a custom shape, a complex chart, or something GPUI simply doesn’t have a built-in method for?
In Chapter 5, we are going to step off the “fluent styling” path and drop down to the foundational Element trait, showing you how to draw your own custom primitives directly to the screen.
Chapter 5: Custom Elements
Chapter 5: Custom Elements
The fluent API from Chapter 4 will take you a long way. Most application interfaces — navigation bars, settings panels, content lists — can be built entirely from composed div() elements with Flexbox and conditional styling.
Some things genuinely can’t. A data visualization, a custom code minimap, a waveform display, a specialized input control with non-rectangular hit areas — these require drawing primitives the framework doesn’t provide. In GPUI this isn’t a dead end. Because the renderer is native, you can step below the fluent API and push drawing commands directly to the GPU.
Render vs Element
Two traits govern how GPUI turns your code into pixels.
Render is the high-level view contract you’ve been using since Chapter 1. It returns a blueprint composed of existing pieces — anything that implements IntoElement. GPUI resolves that blueprint into a tree and handles the rest.
Element is the low-level primitive. When you implement Element, you stop composing existing blocks and start defining three things directly: how much space your element needs, where it receives mouse input, and exactly what gets drawn to the screen.
Every built-in primitive — every div(), every .bg(), every .rounded_md() — is implemented using this same Element trait underneath. You’re not accessing a back door. You’re using the same interface the framework uses for itself.
The Three-Phase Lifecycle
GPUI evaluates every element in three phases: Layout, Prepaint, and Paint.
The best way to understand them is to build something real. Here’s a complete custom ProgressBar — a fixed-height fill that renders a background track and an active fill proportional to a progress value:
#![allow(unused)]
fn main() {
use gpui::*;
pub struct ProgressBar {
progress: f32, // 0.0 to 1.0
}
pub fn progress_bar(progress: f32) -> ProgressBar {
ProgressBar { progress }
}
impl IntoElement for ProgressBar {
type Element = Self;
fn into_element(self) -> Self::Element { self }
}
impl Element for ProgressBar {
type RequestLayoutState = ();
type PrepaintState = ();
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
cx: &mut WindowContext,
) -> (LayoutId, Self::RequestLayoutState) {
let mut style = Style::default();
style.size.width = relative(1.0).into();
style.size.height = px(8.0).into();
let layout_id = cx.request_layout(style, None);
(layout_id, ())
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
bounds: Bounds<Pixels>,
_layout: &mut Self::RequestLayoutState,
cx: &mut WindowContext,
) -> Self::PrepaintState {
()
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
bounds: Bounds<Pixels>,
_layout: &mut Self::RequestLayoutState,
_prepaint: &mut Self::PrepaintState,
cx: &mut WindowContext,
) {
cx.paint_quad(fill(bounds, rgb(0x374151)));
let mut fill_bounds = bounds;
fill_bounds.size.width *= self.progress;
cx.paint_quad(fill(fill_bounds, rgb(0x3b82f6)));
}
}
}
You can now drop progress_bar(0.75) into any Render method exactly like a div(). GPUI treats it identically to its own built-in primitives.
What Each Phase Does
Layout — request_layout runs first. You define how much space your element needs by constructing a Style and returning a LayoutId. The flexbox engine uses this to position everything around you. Our progress bar requests full width and a fixed 8px height.
Prepaint — Once your position and size are resolved, prepaint runs. This is where you register hitboxes — the regions that receive mouse events. Our progress bar is display-only, so prepaint does nothing. If this were a custom slider, you would push an opaque hitbox here matching the element’s bounds, so GPUI’s event router knows to deliver mouse clicks and drags to this element. Without a registered hitbox, the router treats the element as invisible to the mouse regardless of what gets painted.
This is the mechanic behind the Hitbox Rule from Chapter 2. A div() without .id() skips hitbox registration entirely. Now you can see exactly why clicks fall through.
Paint — Finally, you push drawing commands to the GPU. cx.paint_quad() draws a filled rectangle. We draw the track first, then calculate the fill width by multiplying the full bounds width by the progress value, and draw the fill on top. Paint order is depth order — later calls paint over earlier ones.
A Note on Animations
GPUI has no .transition() or .animate() modifier. If you want a progress bar to fill smoothly rather than jump, you drive the animation yourself — incrementing the progress value on a timer and calling cx.notify() each frame. Chapter 7 covers exactly this using the async Timer system.
What’s Next
You can now draw anything. The next question is how users interact with what you’ve built — specifically, how GPUI thinks about the difference between a mouse click and a keyboard shortcut, and why that distinction matters more than it might seem.
Chapter 6: Focus and Actions.
Chapter 6: Focus and Actions
Chapter 6: Focus and Actions
You can now draw anything to the screen. The next question is how your application responds to the keyboard — and this is where GPUI introduces a distinction that feels unfamiliar at first but turns out to be one of its best ideas.
In GPUI, mouse events and keyboard events are handled through entirely different systems, and deliberately so.
The Semantic Divide
Mouse events are positional. When you click, GPUI finds the hitbox registered during prepaint and triggers the corresponding .on_click() handler. The location of the cursor determines everything.
Keyboard events are contextual. When a user presses Cmd+S, the cursor position is irrelevant. What matters is which part of the application currently has the user’s attention. Is the user working in a text editor? Save the file. Are they in a settings panel? Save the preferences. The same keypress, two completely different meanings depending on context.
GPUI models this context through a focus system and a routing layer called Actions. Together they replace the pattern of attaching onKeyDown listeners to elements and checking which mode the app is currently in.
Focus Management
To receive keyboard-driven input, an element must be part of the focus tree. GPUI manages this with FocusHandle — a token representing a specific element’s position in that tree.
You create focus handles through the context and attach them to elements with .track_focus():
#![allow(unused)]
fn main() {
use gpui::*;
struct SplitPane {
left_focus: FocusHandle,
right_focus: FocusHandle,
}
impl SplitPane {
fn new(cx: &mut Context<Self>) -> Self {
Self {
left_focus: cx.focus_handle(),
right_focus: cx.focus_handle(),
}
}
}
impl Render for SplitPane {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.size_full()
.child(
div()
.track_focus(&self.left_focus)
.on_focus(cx.listener(|_, _, _, cx| println!("Left pane focused")))
.w_1_2()
.bg(rgb(0x1e1e1e))
)
.child(
div()
.track_focus(&self.right_focus)
.w_1_2()
.bg(rgb(0x2d3748))
)
}
}
}
.track_focus() registers the element with GPUI’s focus tree. To move focus programmatically — after a task completes, after a modal closes — call self.right_focus.focus(window). The rest of the routing system follows automatically.
The Action System
With focus established, the question is how keypresses get connected to behavior. GPUI’s answer is Actions — pure semantic descriptions of intent defined with the actions! macro:
#![allow(unused)]
fn main() {
actions!(workspace, [SaveFile, ClosePanel]);
}
These structs carry no logic. SaveFile just means “the user intends to save.” What saving actually does depends on which element catches the action and what context it’s in.
Keystrokes are mapped to actions in a keymap configuration separate from your application code. Cmd+S maps to workspace::SaveFile. If a user wants to remap it, they change the keymap — nothing in your Rust code changes. The action name is the stable contract between input and behavior.
Action Routing: The Bubble
When the user presses a mapped keystroke, GPUI creates the corresponding action struct and injects it at whichever element currently holds focus. From there it bubbles up the element tree. Every element in the path has a chance to catch it with .on_action():
#![allow(unused)]
fn main() {
div()
.track_focus(&self.left_focus)
.on_action(cx.listener(|this, _action: &SaveFile, _window, cx| {
// Only reached if this pane or one of its children has focus
this.save(cx);
cx.notify();
}))
}
If no element in the active focus path handles the action, it drops silently at the root.
This silent drop is a feature. Consider a Delete action mapped to Backspace. When the user is typing in a text input, the input catches Delete, removes a character, and stops the bubble. When the user clicks into a file explorer and presses Backspace, the text input is no longer in the focus path — it never sees the action. The file explorer catches it instead and moves a file to trash.
No global mode tracking. No if currentFocus === 'editor' conditionals. The structure of the focus tree is the routing logic.
What’s Next
You can draw anything and route user intent precisely where it belongs. The remaining question is time — what happens when handling an action triggers work that takes a while? Saving to a slow disk, fetching from a network, parsing a large file. Doing that work inside an action handler will freeze the UI.
Chapter 7 covers GPUI’s async engine: how to move heavy work off the UI thread, how to get results back safely, and how the WeakEntity pattern from Chapter 3 becomes essential in practice.
Chapter 7: Async and Memory Lifetimes
Chapter 7: Async and Memory Lifetimes
Routing a SaveFile action through the focus tree is clean and fast. But saving a file — actually writing bytes to disk, or fetching data over a network — can take time. Do that work directly inside an .on_action() handler and the UI thread stalls. The window freezes, the OS considers the app unresponsive, and the user reaches for force quit.
GPUI’s async engine is how you keep the interface running while real work happens in the background.
Foreground vs Background Tasks
GPUI provides two spawning contexts:
cx.spawn() runs on the main UI thread. It’s right for lightweight async control flow — waiting on a timer, chaining short UI updates, sequencing operations that touch view state. It won’t block rendering between yield points, but heavy synchronous work inside it will still stall the thread.
cx.background_spawn() moves execution onto GPUI’s background thread pool, completely off the UI thread. Disk reads, network requests, large file parsing — anything that takes real time belongs here.
Because background tasks run on a separate thread, they have no direct access to your UI state. To get results back safely, you need the WeakEntity pattern.
The WeakEntity Pattern in Practice
Chapter 3 introduced WeakEntity as theory. Here it becomes a daily pattern.
Before leaving the UI thread, downgrade your entity to a weak reference. When the background work finishes, use that reference to jump back and update state:
#![allow(unused)]
fn main() {
let weak_state = entity.downgrade();
cx.background_spawn(async move {
// Runs on the background thread pool.
// The UI keeps rendering while this executes.
let content = std::fs::read_to_string("large_file.txt")
.unwrap_or_default();
// Jump back to the UI thread to apply the result.
let result = weak_state.update(&mut cx, |this, cx| {
this.content = content;
cx.notify();
});
if result.is_err() {
// The window closed while we were loading. Nothing to do.
}
}).detach();
}
update() returns a Result. If the user closes the window while the file is loading, the entity is dropped, and update() returns Err instead of panicking. Your background task exits cleanly. No crash, no undefined behavior, no complex cancellation logic.
RAII: Lifetimes You Control
GPUI uses Rust’s RAII model to manage two things that are easy to get wrong silently.
Tasks
cx.spawn() and cx.background_spawn() both return a Task<T>. If you drop that value without calling .detach(), the task is immediately cancelled — cooperatively, on its next yield point. Not eventually. Immediately.
This is deliberate. It means you can cancel an in-flight operation simply by dropping its task handle. More usefully, it means storing a task in your struct and reassigning it automatically cancels the previous one:
#![allow(unused)]
fn main() {
struct SearchView {
search_task: Option<Task<()>>,
}
}
Every new keystroke in a search input can replace search_task with a new task, cancelling the previous network request automatically. No explicit cancellation API, no tracking in-flight operations manually. The lifetime of the task is the lifetime of the handle.
If you want a task to keep running — a background sync, an animation loop — you must store it. A task you don’t store will silently never complete.
Subscriptions
Event subscriptions from cx.subscribe() follow the same pattern. The subscription handle keeps the listener alive. Drop the handle and the listener is immediately unregistered. If your event handlers stop firing unexpectedly, the first thing to check is whether the subscription handle is still in scope.
#![allow(unused)]
fn main() {
struct MyView {
_subscription: Option<Subscription>,
}
}
Both patterns share the same mental model: in GPUI, if you own the handle, you own the lifetime.
Manual Animations
GPUI has no .transition() or .animate() modifier. Animations are driven explicitly — update state on a timer, call cx.notify(), let the renderer do its job.
Here’s a 60-frame animation that fills a progress bar over one second:
#![allow(unused)]
fn main() {
let weak_state = entity.downgrade();
// Store this in your struct — dropping it cancels the animation
self.animation_task = Some(cx.spawn(|mut cx| async move {
for frame in 0..60 {
Timer::after(Duration::from_millis(16)).await;
let _ = weak_state.update(&mut cx, |this, cx| {
this.progress = frame as f32 / 60.0;
cx.notify();
});
}
}));
}
The same WeakEntity pattern applies. If the view is removed mid-animation, update() returns Err and the loop exits. No dangling references, no orphaned timers.
What’s Next
You now have the full foundational toolkit: layouts, custom drawing, keyboard routing, and async work without memory hazards.
Part II is complete. Part III steps back from the framework itself and looks at the broader ecosystem — what the community has built on top of GPUI, what you can borrow, and what you’ll likely need to build yourself.
We start with the application that stress-tests everything: Zed.
Chapter 8: The Upstream Reality
Chapter 8: The Upstream Reality
By now GPUI feels productive. Things compile. The entity model clicks. The async patterns make sense. This chapter steps back from the framework itself and answers a question worth asking before you build anything serious: what kind of tool is this, and what are you actually betting on?
Not to scare you off. To give you a map.
Where GPUI Came From
GPUI was not designed as a general-purpose UI framework. It was extracted from Zed, the high-performance code editor, and that origin explains almost everything about its design.
The history matters in brief. Zed’s earliest prototype was an Electron app with a Rust backend passing JSON messages back and forth. The first version of GPUI was essentially a layer to feed data to that Electron shell. Over several iterations — a Pathfinder renderer, a Flutter-inspired constraint system, a split between Rust code and JSON themes — the team kept finding UI development painful. The framework was accumulating complexity without gaining clarity.
In late 2023, the Zed team made a clean break. They froze the old framework, built GPUI 2 in parallel over roughly four months, and shipped it as the foundation for Zed’s collaboration features. Only after it stabilized did they open source it.
That sequencing is significant. GPUI was opened after it solved Zed’s problems, not before. It is not a community-first framework that happens to power an editor. It is an editor framework that happens to be open.
The Editor-First Constraint
Zed is a business. The team’s primary responsibility is improving the editor. Features that benefit Zed get prioritized. Features that don’t will wait — or never arrive. This is not hostility toward the community. It’s focus.
The practical consequence: if you’re building something that looks like Zed — a code editor, an IDE, a terminal, a data tool, a dashboard — you’re aligned with upstream’s priorities. The framework was designed for exactly your use case. If you’re building a creative tool with custom shaders, a 3D application, or something with heavy animations and visual effects, you’re working against the grain. GPUI will not grow in your direction quickly, if at all.
The practical rule: build with upstream GPUI until you hit a hard wall. Then decide whether to work around the gap, use a community fork, or choose a different framework. Don’t make that decision in advance — the gaps are fewer than they appear from the outside.
What GPUI Does Well
You’ve already used most of this. Here it is gathered in one place:
| Area | Status |
|---|---|
| Layout | Production-ready. Flexbox, full style API, absolute positioning. |
| Text | Rope buffers, UTF-8 alignment, IME wiring. |
| Async | cx.spawn(), RAII cancellation, WeakEntity. Solid. |
| State | Entity model, cx.notify(), subscriptions. |
| Performance | GPU-accelerated, 120fps capable, no layout thrashing. |
| Focus and Actions | Keyboard routing, bubble-and-handle, action system. |
| Custom Elements | Element trait, three-phase lifecycle, full GPU access. |
For roughly eighty percent of what a native desktop app needs — windows, lists, forms, async data loading, keyboard-driven interfaces — GPUI is production-ready today.
What GPUI Doesn’t Have
These gaps are confirmed by the framework’s GitHub issues and the community’s own analysis. They’re stated plainly here because you’re better off knowing them now than discovering them mid-project.
| Gap | Workaround | When It Hurts |
|---|---|---|
| Custom shaders / GPU compute | raw_window_handle + manual wgpu context | Games, data viz, video, creative tools |
| Transforms on generic elements | Absolute positioning + nested views, or pre-render to image | Complex animations, drag gestures |
| Radial / conic gradients | Pre-render to image, use as background | Decorative surfaces, animated gradients |
| Headless testing | CLI harness pattern (Chapter 12) + snapshot testing | Visual regression, CI without GPU |
| Rich text editing out of the box | Wire rope, selection, IME yourself (Chapter 11) | Serious text inputs |
The honest framing: these gaps exist. For some apps they’re dealbreakers. For most they’re inconveniences with straightforward workarounds. The question isn’t whether GPUI has a feature — it’s whether you can live without it or build around it.
The Fork Landscape in Brief
If a gap is a dealbreaker, you’re not stuck. The community has produced several active forks that fill specific holes. Chapter 9 covers each in depth. Here’s the decision tree to hold in your head now:
Is your app Zed-shaped (text, data, tooling)?
├── Yes → Upstream GPUI
│
├── Need custom shaders or 3D? → Kael
│
├── Need wgpu backend + smooth scrolling? → WGPUI
│
└── Need stable versioned releases? → gpui-unofficial
Using a fork doesn’t mean abandoning upstream. Most forks rebase regularly on upstream GPUI, so you continue receiving fixes. The choice is about picking the branch that matches your constraints.
Three Questions Before You Commit
Does your app’s core value rely on a missing feature? If you’re building a 3D model viewer or a video editor, GPUI is the wrong tool. If you’re building a CRUD app, a chat client, a dashboard, or an internal tool, you’re in good shape.
Can you live with the workarounds for the near term? The gaps are real but not growing. The community is slowly filling them through forks. If your timeline is short and your app is Zed-shaped, upstream is fine today.
Are you building for the long term? If yes, expect to either contribute upstream, maintain a small fork with the features you need, or migrate to whichever community fork consolidates. The pure core pattern from Chapter 12 makes migration possible without rewriting your business logic. Put your domain logic in a crate with no GPUI dependencies, and you can swap the UI layer later.
The Final Advice
Don’t bet on GPUI. Bet on your domain logic being pure Rust. GPUI is the delivery mechanism, not the product.
That’s not pessimism — it’s good architectural hygiene regardless of which framework you choose. Build your business logic in a framework-agnostic crate, use GPUI to deliver it, and the specific state of the ecosystem matters less than it might seem.
The next chapter maps the fork landscape in detail — what each fork solves, who’s building it, and how to choose between them.
For a deeper dive into GPUI’s origins — the GPUI 1 to GPUI 2 rewrite, the frozen-crate strategy, and the open-source timing decision — see Appendix A.
Chapter 9: The Fork Landscape
Chapter 9: The Fork Landscape
Chapter 8 gave you the honest truth about upstream GPUI: it’s editor-first, gaps exist, and the team prioritizes Zed’s needs over general UI features. So what do you do when you hit a wall — when you need custom shaders, or a unified renderer, or a component library that doesn’t require building everything from scratch?
You have options. This chapter maps them.
Why do forks exist at all? By early 2026, the Zed team made it clear that GPUI development would prioritize only features directly related to the editor’s use case, pushing off anything outside that scope. PRs for features like custom shaders have been rejected upstream, with the explanation that work not directly usable in Zed isn’t being merged.
That’s the context. None of this is hostile — it’s focus. But focus creates gaps, and gaps create forks.
Here are the major ones you should know about.
WGPUI
WGPUI solves a specific technical problem: GPUI’s platform-specific backends. Upstream GPUI uses Metal on macOS, Blade on Linux, and Direct3D on Windows. This works for Zed, but it means behavior can differ across platforms and adding new rendering features requires implementing them three times.
WGPUI replaces all of that with a single wgpu backend plus winit for window management. The result is one cross-platform renderer, eliminating accidental platform divergence. Text rendering uses cosmic-text and font-kit. The fork has also added features requested by its community: controllable text gradients, blurred UI elements, blurred element content, and smooth scrolling support.
Who uses WGPUI today? Several projects, including Futureboard¹ and the maintainers’ own applications. The primary value proposition is unified rendering — if you care deeply about cross-platform consistency and wgpu’s architecture, this is your fork.
gpui-ce — Community Edition
gpui-ce is the most direct response to upstream’s narrowing focus. It’s maintained by a former early Zed employee along with several community contributors. The goal is explicitly consolidation — keeping a version of GPUI that welcomes features Zed doesn’t need.
The fork already has a custom shaders pull request in progress. The maintainers have stated their willingness to compromise on a lot in order to have a consolidated effort around GPUI, which they believe could be many times larger than it is now. People have stuck with GPUI through years of poor usability because they were compelled by its potential. With community maintenance, that potential might finally be realized.
If you want a version of GPUI that tracks upstream but accepts features rejected by Zed, gpui-ce is the most direct path.
gpui-unofficial
This fork solves a different problem: versioning. Upstream GPUI doesn’t publish versioned releases on a predictable cadence. The crates are tied to Zed’s monorepo, which makes depending on GPUI from crates.io awkward.
gpui-unofficial is the simplest solution: it publishes GPUI releases tagged to Zed’s stable releases. No new features, no backend changes, no community governance. Just a stable mirror with reliable versioning.
If your only complaint about upstream GPUI is that versioning is a headache, use this fork and move on.
Kael
Kael is the most ambitious independent bet in the GPUI ecosystem. It started as adabraka-gpui, a fork for a video editor called OpenReel. At some point the maintainer realized the framework itself was more valuable than the application, so the project pivoted. The video editor is now just one consumer of Kael.
Today Kael positions itself as a general-purpose desktop UI framework. The scope is substantial: radial and conic gradients in the core styling API, webviews, form controls, rich text, Lottie animation playback, backdrop blur, gesture recognizers, system tray, global hotkeys, notifications, media playback and capture. The built-in component library, kael-ui, ships over one hundred components with eighteen themes and custom theming.
On custom shaders, the maintainer is unequivocal: committed, and at the top of the GPU roadmap. The plan is a public render target and pass API with application-registered shaders and compute pipelines across Metal, DX11, and Vulkan. Offscreen RGBA16F targets and compute on Metal are already working in development.
The maintainer’s stance on the relationship with Zed is clear: the Zed team has made their priorities explicit, so Kael was deliberately not structured as a fork that tracks upstream.² Kael keeps the three native backends rather than migrating to wgpu, and builds what desktop apps need without waiting on Zed.
If you want a batteries-included framework with components, media, and custom shaders on the roadmap, Kael is your bet. But understand what you’re signing up for: you’re leaving upstream behind, by design.
The Practical Decision Tree
Is your app Zed-shaped (text, data, tooling)?
├── Yes → Upstream GPUI
│
├── Need custom shaders or 3D? → Kael
│
├── Need wgpu backend + smooth scrolling? → WGPUI
│
├── Need community governance + upstream tracking? → gpui-ce
│
└── Need stable versioned releases only? → gpui-unofficial
The Fragmentation Question
You might look at this landscape and see fragmentation. You’re not wrong. There are now four significant forks plus upstream, each with different priorities and backends.
But there’s also a case for optimism. The maintainers are aware of the fragmentation and some are actively working against it. The gpui-ce team has stated their goal is de-fragmentation and willingness to compromise for a consolidated effort. The Kael maintainer has expressed interest in coordinating with gpui-ce on shared pieces — the shape of a custom shader API in particular seems like something worth not designing four different ways.
The GitHub discussion that surfaced much of this also raised an idea worth watching: a headless primitives layer that could bridge the ecosystem without more fragmentation — a clean separation between rendering engine, headless primitives, styled widgets, and application. It’s a community suggestion rather than an adopted roadmap item, but it maps directly to the architecture we’ll build in Chapter 12.
The forks are a sign of life, not death. People are building things. The framework matters enough to fork.
A Note on Licensing
Some forks and components may have different licensing than upstream GPUI — see Appendix A for the full boundary.
Bottom Line
Forks exist because GPUI’s focus is Zed, not you. That’s not a failure — it’s an opportunity. Each fork solves a different problem. The decision tree above gives you the framework; your choice depends on whether you need feature freedom, stable mirrors, or batteries-included primitives.
In the next chapter we’ll look at third-party component libraries — which work across multiple forks and give you another layer of reusable UI without committing to a fork at all.
¹ Futureboard is a modern DAW being built with WGPUI. See [futureboard link to be added before publication].
² From the Kael fork GitHub discussion thread. [Link to be verified before publication.]
Chapter 10: Third-Party Components
You’ve made it through the fork landscape. You understand the upstream constraints and have chosen a path. Now you need to actually build your UI — buttons, inputs, dialogs, tables. Do you build everything from scratch using GPUI’s primitives, or do you stand on the shoulders of those who’ve gone before?
This chapter maps the third-party component landscape. Unlike the forks in Chapter 9, which change the framework itself, these libraries work on top of GPUI. You can use them with upstream or with most forks, and they range from focused component collections to full-blown UI toolkits.
To add any of these libraries to your project, the pattern is the same: cargo add gpui-component (or gpui-ui-kit, etc.) and follow the library’s specific setup instructions. Now let’s look at what each one offers.
gpui-component
The most complete component library in the ecosystem is gpui-component, built by the team at Longbridge for their trading platform. It’s not an academic exercise — it’s production code running a real financial application, which means it has been battle-tested against serious performance requirements.
The library includes over sixty cross-platform desktop UI components, drawing inspiration from native macOS and Windows controls while blending in modern design language from shadcn/ui. The feature set is extensive: virtualized tables and lists for smooth rendering of large datasets, built-in charting capabilities, a high-performance code editor supporting up to two hundred thousand lines with Language Server Protocol integration, dock layouts for panel arrangements, and native Markdown with basic HTML rendering.
The component list covers most of what a typical desktop app needs. For core UI, you get buttons with multiple variants, cards, dialogs, menus, menu bars, tabs, and toasts. For forms, there’s input with rich editing capabilities including mouse drag selection, clipboard operations, and Emacs keybindings, plus number inputs with spin buttons, checkboxes, toggles, selects, color pickers, sliders, and wizards. For data display, badges, progress indicators, spinners, avatars, and typography components. For feedback, alerts, inline alerts, and tooltips. For layout, stacks, spacers, dividers, pane dividers, accordions, and breadcrumbs.
The library has one non-negotiable requirement: your entire application must be wrapped in a Root component that provides theming and global state management. This is not optional. If you’re integrating gpui-component into an existing app, you’ll need to refactor to put Root at the top of your view tree. The licensing is Apache 2.0, matching GPUI itself, so you can use it in commercial applications without concern.
gpui-ui-kit
If gpui-component is the production workhorse, gpui-ui-kit is the more experimental sibling. It offers a similar range of components with some notable additions, particularly controls that are well-suited for audio applications — potentiometer knobs, vertical sliders, and volume controls.
The component set overlaps heavily with gpui-component: buttons, icon buttons, cards, dialogs, menus, tabs, toasts, inputs, checkboxes, toggles, selects, color pickers, sliders, badges, progress indicators, spinners, avatars, typography, alerts, tooltips, stacks, spacers, dividers, and breadcrumbs.
Where gpui-ui-kit distinguishes itself is in its audio-focused controls and its documentation presentation. The library is well-documented on docs.rs, and the API feels slightly more polished in places. That said, it has fewer components overall than gpui-component and lacks some of the advanced features like virtualized tables and built-in charting.
Use gpui-ui-kit when you need audio-style controls or prefer its API design. Use gpui-component when you need maximum component coverage and production pedigree.
gpui-kit (Vitesse)
This one is worth watching but not yet ready for prime time. gpui-kit, also called Vitesse, is an upcoming UI toolkit with an ambitious three-layer architecture: headless primitives in the vein of Radix or Headless UI, a color library with optional themes, and styled components built on top. The goal is to provide accessible, unstyled building blocks that you can customize completely, separate from opinionated design decisions.
As of this writing, the project is still in planning. The repository exists, the vision is documented, but there’s not yet a stable release you can depend on. Keep an eye on it. If the team delivers on the headless primitives layer, it could become the foundation that the ecosystem desperately needs.
kael-ui
Remember Kael from Chapter 9? The batteries-included fork ships with its own component library, kael-ui, living inside the same monorepo. This is not a separate library you can pull in independently — it’s part of the Kael framework. The component set is substantial: over one hundred components with eighteen built-in themes and full custom theming support.
Because kael-ui is tightly coupled to the Kael fork, you can’t easily use it with upstream GPUI or other forks. The components assume Kael’s extended styling API, its custom primitives, and its rendering backend. If you’ve chosen Kael as your framework, you get kael-ui for free. If you’re on upstream, this is not an option.
The Missing Headless Primitives Layer
Here’s the problem that none of these libraries fully solves.
Every component library currently available is opinionated. gpui-component has a specific look. gpui-ui-kit has another. kael-ui has a third. If you don’t like their default styling, or if your application’s design language doesn’t match, you’re left fighting the library’s theming system — or rebuilding components from scratch.
What’s missing is a headless primitives layer: unstyled, accessible components that handle behavior, focus management, keyboard interactions, and accessibility attributes, but leave styling completely to the developer. This is what Radix provides for React, what Headless UI provides for Vue, and what gpui-kit (Vitesse) is promising but hasn’t delivered.
The absence of this layer matters most for text input. As we discussed in Appendix A, GPUI itself doesn’t ship an input component. The third-party libraries do, but each comes with its own styling and behavior assumptions. If you need a text input that matches your custom design, you’re back to building it yourself from GPUI’s primitives, as we’ll cover in Chapter 11.
When to Use a Library vs. Build Your Own
Here’s the decision rule.
Use gpui-component if you want the most complete, production-tested component set and you’re willing to accept its design language and the Root wrapper requirement. This is the fastest path from zero to functional app.
Use gpui-ui-kit if you prefer its API or need controls suited for audio applications.
Use kael-ui if you’ve already committed to the Kael framework. You get components as part of the deal.
Build your own components if your application has a custom design language that doesn’t match these libraries, or if you need fine-grained control over behavior that the libraries don’t expose. The effort is higher, but the result is uniquely yours.
Wait for gpui-kit if you want headless primitives and are willing to be patient. The project hasn’t shipped yet, but its vision is the right one for the ecosystem.
The Root Problem
One more thing about gpui-component’s Root requirement. It’s not arbitrary — the Root component manages theme context, global shortcuts, dialog stacks, and notification toasts. This is a reasonable architectural choice. But it means you cannot incrementally adopt gpui-component. Your entire application must be wrapped from the start, or you refactor later.
If you’re starting a new project, this is fine. Add Root at the top of your view hierarchy and forget about it. If you’re adding gpui-component to an existing project, budget time for the refactor.
Bottom Line
The third-party component landscape is healthier than the fork landscape suggests. gpui-component gives you over sixty production-ready components. gpui-ui-kit offers a solid alternative with audio-focused extras. kael-ui is there if you’re on Kael. And gpui-kit points toward a headless future that doesn’t yet exist.
Each library makes design decisions you may want to override. That’s not a flaw — it’s the nature of opinionated code. The question is whether those opinions align with your needs.
In the next chapter, we’ll drop down to the foundations: text and data. Because whether you use a component library or build your own, you need to understand how GPUI handles text storage, selection, and input. And that’s where things get interesting.
Chapter 11: Text Foundations: The Input You Have to Build Yourself
If you want to understand why building native UI is hard, look at text.
Here’s a simple search bar. You capture a keystroke and append it to a string. In Rust, that string is UTF-8. Characters can be one to four bytes. The emoji “🦀” is four bytes. If your cursor lands anywhere inside those four bytes, Rust will panic.
#![allow(unused)]
fn main() {
let mut query = String::from("Hi 🦀");
// The user moves the cursor back one space and types "!"
query.insert(4, '!');
// PANIC: byte index 4 is not a char boundary; it is inside '🦀' (bytes 3..7)
}
String operations in Rust index by bytes. The emoji “🦀” spans bytes 3 through 7. Index 4 lands inside the emoji, and Rust refuses to split a character. This is the trap. Standard strings index by bytes. User interfaces index by characters (graphemes). Mix them, and your app crashes the moment someone types an emoji or a non-Latin character.
What GPUI Gives You (And What It Doesn’t)
GPUI is a rendering engine and a windowing system. It is not a text editor out of the box. Before you build an input field, understand exactly where the framework stops and your responsibility begins.
| Feature | Who Provides It | Notes |
|---|---|---|
| Keyboard Events | GPUI | KeyDownEvent delivers raw keystrokes |
| Focus Management | GPUI | FocusHandle routes events to your element |
| Clipboard Access | GPUI | Platform services read/write the system clipboard |
| Text Storage | You | GPUI has no built-in InputState or text buffer |
| Cursor Position | You | Track where the blinking cursor lives |
| Selection | You | Track highlighted ranges as byte offsets |
| Undo/Redo | You | Maintain a stack of Change operations |
| IME Composition | You | Handle multi-event input for Chinese, Japanese, Korean |
Look at that table. GPUI handles the low-level plumbing. You handle everything that makes text behave like text.
The Rope Solution
Because standard String requires O(n) time to insert a character in the middle of a large document, and because byte-offset math is a minefield, the Rust text-editing ecosystem relies on a different data structure: the rope.
A rope is a tree of string slices. Inserting or deleting text anywhere in the document takes O(log n) time — exponentially faster than O(n) for large documents. More importantly for UI, the standard crate — ropey — operates on character indices.
You don’t need to master ropey. You need a tiny fraction of its API:
#![allow(unused)]
fn main() {
use ropey::Rope;
let mut text = Rope::from_str("Hello");
text.insert_char(5, '!'); // Safe, O(log n), indexed by character
text.remove(0..1); // Removes the 'H'
let len = text.len_chars(); // Length in characters, not bytes
}
Version note: Check the ropey version you’re depending on. The 1.x and 2.x APIs differ on whether indices are character-based or byte-based. This book uses 1.x conventions.
The Minimal Working Input
Let’s wire ropey and GPUI together. A functioning text input needs three pieces: state, event handling, and rendering.
1. The State
Your component needs a Rope for the text buffer, a usize for the cursor position (tracked as a character offset, not a byte index), and a FocusHandle so GPUI routes keyboard events correctly.
#![allow(unused)]
fn main() {
use gpui::*;
use ropey::Rope;
pub struct TextInput {
buffer: Rope,
cursor_offset: usize, // Character index, NOT byte offset
focus_handle: FocusHandle,
}
}
Real-world inputs like gpui-component’s InputState add much more: selection ranges, undo history, display maps for line wrapping, scroll handles, IME marked ranges, validation rules, masking, and LSP integration. But this minimal state gets you typing.
2. Event Handling
When your element renders, attach the focus handle and listen for keystrokes. Intercept navigation and editing commands — Backspace, Left Arrow, Right Arrow — and pass normal characters directly into the Rope.
#![allow(unused)]
fn main() {
impl TextInput {
fn handle_key_down(&mut self, event: &KeyDownEvent, cx: &mut Context<Self>) {
let key = &event.keystroke.key;
match key.as_str() {
"backspace" => {
if self.cursor_offset > 0 {
// ropey removes the character before the cursor safely
self.buffer.remove((self.cursor_offset - 1)..self.cursor_offset);
self.cursor_offset -= 1;
}
}
"left" => {
self.cursor_offset = self.cursor_offset.saturating_sub(1);
}
"right" => {
self.cursor_offset = (self.cursor_offset + 1).min(self.buffer.len_chars());
}
_ => {
// Single character? Insert it.
if key.chars().count() == 1 {
let c = key.chars().next().unwrap();
self.buffer.insert_char(self.cursor_offset, c);
self.cursor_offset += 1;
}
}
}
cx.notify(); // Tell GPUI we changed state
}
}
}
In production inputs like gpui-component’s InputState, the event handler is far more sophisticated. It tracks selection state, handles clipboard operations, manages undo stack entries, and coordinates with the IME system.
3. Rendering
Finally, draw the component. Slice the Rope into a standard string for GPUI’s text renderer, and show a cursor.
#![allow(unused)]
fn main() {
impl Render for TextInput {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let display_text = self.buffer.to_string();
div()
.track_focus(&self.focus_handle)
.on_key_down(cx.listener(Self::handle_key_down))
.p_2()
.bg(rgb(0x1e1e1e))
.border_1()
.border_color(if self.focus_handle.is_focused(cx) {
rgb(0x3b82f6)
} else {
rgb(0x4a5568)
})
.child(display_text)
}
}
}
A production input would also compute the exact pixel position of the cursor for precise placement. gpui-component’s internal layout caching handles this, but that implementation is specific to the library.
The Missing Pieces: IME
The code above works for English text. It fails for Chinese, Japanese, and Korean.
Input Method Editors (IMEs) allow users to type characters not on their keyboard. Japanese users type “nihongo” and get a candidate window to select 「日本語」. During composition, keystrokes do not produce final characters immediately. If you insert them directly into your buffer, the IME breaks.
GPUI provides IME events, but you must handle them correctly. The key is detecting when a keystroke is part of active IME composition:
#![allow(unused)]
fn main() {
// Check if keydown event is part of IME composition
if event.is_composing() {
// IME is composing — don't insert into buffer
return;
}
}
Technical note: The code above uses event.is_composing() as the correct GPUI native event check. (If you see references to key_code == 229 in web-focused examples, that’s a browser convention, not a native desktop one.)
On Linux, pressing Enter while composing with Chinese IME should not send a message — it should commit the composition. The fix required checking the compose status before dispatching KeyDown events.
On macOS, IME handling is even more complex. When a user types a dead key (like ` on a Brazilian layout) or a special character with Cmd held (like Cmd+ç on Spanish layout), the IME consumes the event before GPUI sees it. Recent fixes in GPUI bypass the IME for Cmd+key events while preserving composition state.
A complete input implementation must track an “IME marked range” — temporary text that the IME is composing — and only commit it to the Rope when composition ends. IME composition produces multiple events per character; you must buffer the composition string and only commit to the rope when composition ends. gpui-component’s InputState does exactly this, storing ime_marked_range: Option<Selection> alongside the main text buffer.
The Missing Pieces: Selection and Undo
A usable text input also needs selection and undo.
Selection is a range of byte offsets in the buffer. A collapsed range (start == end) represents a cursor position. You need to handle:
- Shift + arrow keys to extend selection
- Double-click to select words
- Triple-click to select lines
- Drag to select with the mouse
gpui-component implements word selection by checking each character’s CharType (Word, Whitespace, Newline, Other) and expanding the range to boundaries.
Undo requires a stack of Change operations. Each text insertion or deletion pushes a Change onto the history. Ctrl+Z pops the last change and reverses it. The history stack should have a maximum size to prevent memory growth. gpui-component’s InputState includes history: History<Change> as a core field.
Which Path to Choose?
If you’re building a specialized internal tool or a command palette, the minimal rope-backed input we just built is sufficient. You need the three pieces — state, events, rendering — and you can stop there.
But if text editing is a primary feature of your application, do not build from scratch. As outlined in Appendix A, you have better options:
-
Copy from Zed: The
ui_inputcrate containsSingleLineInputandMultiLineInput. The code is there, but it’s GPL-licensed. Copying it puts your project under GPL. See Appendix A for the full licensing boundary. -
Use
gpui-component: ProvidesInputStatewith rope manipulation, selection, undo history, IME composition handling, line measurement, validation rules, and LSP integration — all under Apache 2.0.
For most commercial applications, gpui-component is the right answer. It implements everything this chapter outlined — and dozens of details we skipped — in production-tested code.
In the next chapter, we’ll move from text foundations to application structure: how to organize a real GPUI project so your business logic stays clean and testable.
Chapter 12: Structuring a Real Application
Chapter 12: Structuring a Real Application
You’ve built components, wrestled with text input, and navigated the ecosystem. Now the question is mechanical: how do you organize code so your business logic doesn’t tangle itself in GPUI’s event loop, and how do you test that logic without opening a window?
This chapter answers that with a specific structure — the concrete workspace layout and crate boundaries that make a GPUI application maintainable as it grows.
The Four-Layer Architecture
A robust GPUI application separates concerns through four distinct layers:
The Rendering Layer is the absolute bottom. GPUI manages the windowing system, the event loop, the GPU pipeline, and flexbox layout. You rarely interact with this directly beyond initializing the App.
The Primitives Layer is the declarative API layer. Elements like div(), text(), and svg() are stateless building blocks. They describe what something looks like but hold no application state.
The Widgets Layer is where primitives become reusable, interactive components. Widgets are stateful but strictly product-blind. A button knows how to animate when clicked. It does not know what that click achieves.
The Product Layer is the orchestrator. This layer catches widget interactions and binds them to your business logic.
The rule that makes this model work: your domain logic should never import GPUI.
The Pure Core Pattern
Put your business logic in a separate crate with no dependencies on gpui, winit, or any UI framework. That crate should compile to native Rust and nothing else. No div, no cx.spawn(), no focus handles.
The structure is straightforward:
myapp/
├── Cargo.toml # Workspace root
├── crates/
│ ├── domain/ # Pure business logic
│ │ ├── Cargo.toml # No GPUI dependency
│ │ └── src/
│ │ └── lib.rs
│ ├── ui/ # GPUI application
│ │ ├── Cargo.toml # Depends on domain + gpui
│ │ └── src/
│ │ └── main.rs
│ └── cli/ # Headless CLI harness
│ ├── Cargo.toml # Depends on domain only
│ └── src/
│ └── main.rs
Cargo Workspaces
A Cargo workspace manages multiple related crates in a single repository:
# Root Cargo.toml
[workspace]
members = [
"crates/domain",
"crates/ui",
"crates/cli",
]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2024"
[workspace.dependencies]
gpui = { git = "https://github.com/zed-industries/zed", package = "gpui" }
serde = { version = "1.0", features = ["derive"] }
thiserror = "1.0"
The domain crate’s Cargo.toml stays deliberately lean:
[package]
name = "myapp-domain"
version.workspace = true
edition.workspace = true
[dependencies]
serde.workspace = true
thiserror.workspace = true
# No gpui here — enforced by Cargo
Cargo enforces the boundary. You cannot accidentally import GPUI into your domain crate — it simply isn’t in the dependency graph.
Tip: As your workspace grows and you add community crates beyond
serdeandthiserror, you’ll encounter Rust’s0.xversioning conventions — which work differently than you might expect fromnpmorpip. Appendix B covers the two rules worth knowing before they bite you.
The Domain: An Example
Let’s build a markdown previewer. The domain crate:
#![allow(unused)]
fn main() {
// crates/domain/src/lib.rs
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MarkdownDoc {
pub raw_text: String,
pub word_count: usize,
}
impl MarkdownDoc {
pub fn new(text: String) -> Self {
let word_count = text.split_whitespace().count();
Self { raw_text: text, word_count }
}
pub fn update_text(&mut self, new_text: String) {
let new_word_count = new_text.split_whitespace().count();
self.raw_text = new_text;
self.word_count = new_word_count;
}
pub fn export_html(&self) -> String {
format!("<h1>Preview</h1>\n<p>{}</p>", self.raw_text)
}
}
pub struct DocumentStore {
documents: Vec<MarkdownDoc>,
current_index: usize,
}
impl DocumentStore {
pub fn new() -> Self {
Self {
documents: vec![MarkdownDoc::new(String::new())],
current_index: 0,
}
}
pub fn current_document(&self) -> &MarkdownDoc {
&self.documents[self.current_index]
}
pub fn update_current_document(&mut self, new_text: String) {
self.documents[self.current_index].update_text(new_text);
}
}
}
No gpui. No cx. Just Rust. You can run cargo test on this crate without a window server, a GPU, or an event loop.
The UI Layer: Thin and Deliberate
In GPUI, state is owned by whoever creates it via cx.new(). The resulting Entity<T> is your handle. Everything flows from where you store that handle.
The application root creates the DocumentStore entity and hands it to the view:
#![allow(unused)]
fn main() {
// crates/ui/src/main.rs
use gpui::*;
use myapp_domain::DocumentStore;
struct EditorApp {
document: Entity<DocumentStore>,
}
impl EditorApp {
fn new(cx: &mut Context<Self>) -> Self {
Self {
document: cx.new(|_cx| DocumentStore::new()),
}
}
}
}
The view reads from the entity and renders it:
#![allow(unused)]
fn main() {
// crates/ui/src/document_view.rs
use gpui::*;
use myapp_domain::DocumentStore;
pub struct DocumentView {
document: Entity<DocumentStore>,
focus_handle: FocusHandle,
}
impl Render for DocumentView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let doc = self.document.read(cx).current_document().clone();
div()
.track_focus(&self.focus_handle)
.flex()
.flex_col()
.size_full()
.child(
div()
.text_sm()
.text_color(rgb(0x6b7280))
.child(format!("Word count: {}", doc.word_count))
)
.child(
div()
.mt_4()
.p_4()
.bg(rgb(0x1e1e1e))
.child(doc.raw_text)
)
}
}
}
The component reads the current document from the entity, renders it using primitives, and knows nothing about how the document got its content.
Four Architectural Lessons
Lesson one: drop UI metadata at the boundary.
When a user clicks, GPUI generates events containing screen coordinates and pixel bounds. Your domain logic should never see these. The Product layer catches the raw event, strips the framework-specific metadata, and hands the core exactly what it needs — a clean string or a semantic command.
Lesson two: ownership boundaries are enforced by where you store the handle.
Entity<T> is GPUI’s universal handle. Whoever holds it, owns the lifetime. If you store an Entity<DocumentStore> in a component that recreates documents frequently, the old entities won’t be dropped until the component itself is dropped — which may never happen. Use WeakEntity for references that should not extend lifetimes:
#![allow(unused)]
fn main() {
// Store a weak reference when you observe but don't own
let weak_doc = self.document.downgrade();
}
The question to ask at every boundary: should this component own this state, or just observe it?
Lesson three: use subscriptions for cross-cutting concerns.
Rather than manually pushing updates to every component, broadcast changes through GPUI’s subscription system. One component updates the entity. Every subscribed view receives a notification and re-renders:
#![allow(unused)]
fn main() {
// In a component that observes but doesn't own the document
cx.subscribe(&document_entity, |this, _entity, _event, cx| {
cx.notify(); // Re-render when the document changes
}).detach();
}
The domain publishes events. The UI subscribes. Components re-render when they need to, not when you manually tell them to.
Lesson four: lifecycle discipline prevents silent leaks.
Subscriptions and tasks follow RAII — drop the handle, cancel the subscription. Store subscriptions in your struct if you want them to live as long as the component. Drop them if you want to stop listening. The same applies to async tasks. If a component is removed from the view tree while a background task is running, WeakEntity::update() returns Err and exits cleanly. No orphaned operations, no silent memory growth.
The CLI Harness
The final crate proves the pure core actually works without a UI:
# crates/cli/Cargo.toml
[package]
name = "myapp-cli"
version.workspace = true
edition.workspace = true
[dependencies]
myapp-domain = { path = "../domain" }
# No gpui
// crates/cli/src/main.rs
use myapp_domain::DocumentStore;
fn main() {
let mut store = DocumentStore::new();
store.update_current_document("# Hello\n\nThis is a test.".into());
let doc = store.current_document();
println!("Words: {}", doc.word_count);
println!("HTML: {}", doc.export_html());
}
Run with cargo run --bin myapp-cli. No window, no GPU, no event loop. The domain logic runs in complete isolation:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn word_count_updates_on_edit() {
let mut store = DocumentStore::new();
store.update_current_document("hello world".into());
assert_eq!(store.current_document().word_count, 2);
}
#[test]
fn export_html_wraps_content() {
let doc = MarkdownDoc::new("test content".into());
assert!(doc.export_html().contains("<p>test content</p>"));
}
}
}
cargo test on the domain crate runs in milliseconds. When GPUI shifts under you — a new version, a fork migration — your domain logic doesn’t feel a thing. The separation isn’t just good practice. It’s what makes your application portable.
What’s Next
The application is structured. The domain is clean. Now it needs to interact with the platform it’s running on — file dialogs, clipboard, system tray, and the places where macOS, Windows, and Linux behave differently than you’d expect.
Chapter 13: Platform Features and Cross-Platform Concerns.
Chapter 13: Platform Features and Cross-Platform Concerns
Chapter 13: Platform Features and Cross-Platform Concerns
Up until now, we have lived entirely inside the GPUI window. We have drawn pixels, managed state, and handled keyboard focus. But a real desktop application needs to copy text, save files, open external browsers, and communicate with the host operating system.
GPUI abstracts macOS, Windows, and Linux behind a unified Platform trait. However, crossing the boundary from your UI into the host operating system introduces async complexity, testing headaches, and strict component library boundaries.
File Dialogs and System Integration
If you want a user to select a file, you should not build a custom file tree in GPUI. You should use the native OS file picker. GPUI provides direct access to these native dialogs via prompt_for_paths() and prompt_for_new_path().
Because waiting for a user to navigate their hard drive could take minutes, these platform APIs return futures. You spawn a task with cx.spawn() and await the user’s selection without blocking the UI thread:
#![allow(unused)]
fn main() {
cx.spawn(|this, mut cx| async move {
let paths = this.update(&mut cx, |this, cx| {
cx.prompt_for_paths(PromptOptions::default())
}).await?;
// Handle the selected paths
}).detach();
}
Alongside file dialogs, the platform services provide cross-platform hooks to hand operations off to the OS:
reveal_path(): Opens the native file explorer (Finder on macOS, Explorer on Windows) and highlights the file.open_with_system(): Opens a file using the user’s default registered application.open_url(): Launches the system’s default web browser.
Titlebar and Window Decoration
GPUI gives you control over the window titlebar, but the level of control depends on your platform and fork choice.
On macOS, you can enable native titlebar styling with window.set_titlebar_appears_transparent() or hide it entirely with window.set_titlebar_hidden(). When hidden, you become responsible for draggable regions — use .on_drag() on your custom titlebar element to allow window movement.
On Windows and Linux, titlebar behavior is more constrained. Upstream GPUI provides basic titlebar configuration, but deep customization often requires platform-specific code or a fork. WGPUI, for example, offers more consistent titlebar handling across all three platforms due to its unified winit backend.
The practical rule: for custom titlebars on macOS, use GPUI’s native APIs. For cross-platform custom titlebars, test each target or consider a fork like WGPUI that abstracts the differences.
(Targeting Linux? Window decoration behavior on GNOME differs from macOS and Windows in ways worth knowing before you ship — see Appendix C.)
The Clipboard and The Fork Boundary
To support copying and pasting, GPUI provides clipboard read/write capabilities. The exact API shape is subject to change, but the pattern is consistent: you access clipboard services through the context object cx and interact with them asynchronously.
Integrating the clipboard highlights a critical architectural boundary if you are using third-party libraries. Recall from Chapter 10 that gpui-component provides an excellent, pre-built InputState for text editing. But gpui-component is strictly a UI library. It provides the text buffer manipulation, cursor positioning, and event handling, but clipboard APIs remain strictly at the raw GPUI framework level.
If you want to implement a “Copy to Clipboard” button inside a custom gpui-component editor, you must bridge the gap yourself. Here’s the pattern, assuming the current API shape:
#![allow(unused)]
fn main() {
// Bridge gpui-component's text state to GPUI's platform clipboard
let text = self.editor_state.value(); // Extract from gpui-component's InputState
cx.write_to_clipboard(ClipboardItem::new_text(text));
}
The same pattern applies in reverse for paste operations: read from the clipboard and insert the text into gpui-component’s InputState.
(Note: GPUI’s clipboard API is actively evolving. Consult the latest gpui documentation for the exact method names.)
System Tray and Native Menus (The Missing Pieces)
Upstream GPUI does not provide system tray access. If your application relies on background tray icons, you have two options. First, use external Rust crates that implement platform-specific tray functionality. Second, use a fork like Kael, which ships system tray support as a first-class feature built directly into the framework.
GPUI also does not natively invoke the host operating system’s context menus. Applications like Zed build their own cross-platform menus using the deferred() rendering hatch and explicit screen coordinate math to prevent edge overflow. You are responsible for drawing these menus using GPUI elements rather than relying on native OS menu widgets.
Headless Testing
When you touch the file system or native OS dialogs, your code becomes vulnerable to platform-specific divergence. Paths behave differently on Windows than on macOS, and file locking semantics vary wildly.
To test platform-dependent code without opening a window, GPUI provides the #[gpui::test] macro and TestAppContext. Here’s a minimal example:
#![allow(unused)]
fn main() {
#[gpui::test]
async fn test_clipboard_interaction(cx: &mut TestAppContext) {
let text = "Hello, world!".to_string();
cx.write_to_clipboard(ClipboardItem::new_text(text.clone()));
let read_back = cx.read_from_clipboard().await.unwrap();
assert_eq!(read_back.text(), text);
}
}
This test runs completely headless — no window server, no GPU, no user interaction. It’s fast enough to run in CI on every commit.
For file system operations, apply the “Pure Core” pattern from Chapter 12. Inject a MockFileSystem during testing rather than calling GPUI’s platform APIs directly. Decouple command execution from GPUI’s platform layer, and you can simulate file selection and dialog responses without ever touching the real file system.
The Bridge Pattern Summary
| Platform Feature | GPUI Provides | Alternative |
|---|---|---|
| File dialogs | ✓ prompt_for_paths() | — |
| Open with system | ✓ open_with_system() | — |
| Titlebar (macOS) | ✓ set_titlebar_hidden(), .on_drag() | — |
| Titlebar (cross-platform) | Basic | WGPUI fork |
| Clipboard | ✓ Platform services | — |
| System tray | — | External crates or Kael fork |
| Native context menus | — | Build with GPUI elements + deferred() |
| Headless testing | ✓ #[gpui::test] + TestAppContext | Mock filesystem |
The pattern is consistent: GPUI gives you the low-level platform hooks. For features it doesn’t provide, you either bring an external crate, switch to a fork, or build the UI yourself in GPUI. The bridge code between these layers is minimal but critical — and now you have the pattern.
In the final chapter, we’ll put it all together: distribution, binary size, and the final trade-offs of shipping a GPUI application.
Chapter 14: Distribution
Chapter 14: Distribution
You have built your application. The text input works, the platform features are wired, and the pure core is thoroughly tested. Now comes the final step: getting your binary into users’ hands.
Distribution is where GPUI’s native foundation pays its largest dividend. No runtime, no embedded browser, no second executable to manage. Just a compiled binary and the system libraries it depends on.
Binary Size: The Actual Numbers
A minimal Electron app — node_modules, Chromium, and a few hundred lines of JavaScript — typically runs 80 to 150 megabytes compressed, often over 200 megabytes unpacked. Every Electron app ships the entire Chromium browser engine, regardless of whether the app needs tabs, devtools, or a PDF viewer.
A minimal GPUI application, compiled in release mode with LTO enabled and stripped of debug symbols, typically lands in the single-digit to low double-digit megabytes — roughly a factor of six to ten times smaller, depending on platform and what your app includes.
Where does the difference come from? Electron ships a full web browser alongside your code. GPUI ships only what you use: the windowing glue, the GPU renderer, your application logic, and exactly the assets you include. No dead code, no hidden browser features.
To put a number on it: a markdown previewer with the functionality we sketched in Chapter 12, built with GPUI and optimized for size, comes in around 8 megabytes. A comparable Electron application — same features, plus Chromium — exceeds 90 megabytes. The exact figures will vary by platform and what your app includes, but the order of magnitude is the point: GPUI ships your app, not a browser.
Build Pipeline
The simplest distribution pipeline for a GPUI application is cargo build --release. This produces a binary in target/release/ that you can rename, sign, and bundle.
cargo build --release --target x86_64-pc-windows-msvc # Windows
cargo build --release --target x86_64-apple-darwin # macOS Intel
cargo build --release --target aarch64-apple-darwin # macOS Apple Silicon
cargo build --release --target x86_64-unknown-linux-gnu # Linux
For cross-compilation from a single machine, use cross, which manages toolchain and linker configuration via Docker containers.
cross build --release --target aarch64-apple-darwin
Code Signing and Notarization
Code signing is non-negotiable for distribution on macOS and Windows. Users expect applications that open without security warnings.
On macOS: You need an Apple Developer ID certificate. Sign the binary with codesign, then submit it to Apple for notarization using xcrun notarytool. GPUI’s binary is signed like any other executable — no framework-specific steps required.
codesign --force --options runtime --sign "Developer ID Application: Your Name" myapp
ditto -c -k --keepParent myapp.app myapp.zip
xcrun notarytool submit myapp.zip --apple-id your@email.com --team-id TEAMID --wait
On Windows: You need a code signing certificate from a trusted authority. Sign the executable with signtool:
signtool sign /fd SHA256 /a /f mycert.pfx /p password myapp.exe
On Linux: Code signing is less common, but you can distribute via package repositories that verify GPG signatures.
Bundling for Distribution
Each platform expects applications in a specific format.
On macOS: A .app bundle is a directory structure with a fixed layout. GPUI does not require any special entries in Info.plist beyond the standard executable name and bundle identifier. Use cargo-bundle or write a simple script to create the bundle:
MyApp.app/
└── Contents/
├── Info.plist
├── MacOS/
│ └── myapp
└── Resources/
On Windows: A standalone .exe works, but users expect installers. Tools like Inno Setup or WiX Toolset can wrap your executable into a traditional installer. MSIX packaging is also an option for distribution through the Microsoft Store.
On Linux: Package formats vary by distribution. .deb for Debian/Ubuntu, .rpm for Fedora/RHEL, and AppImage for distribution-agnostic portable binaries. AppImage is the simplest cross-distribution option: it bundles the executable and its dependencies into a single file that runs on any Linux system.
Fork-Specific Distribution Considerations
If you built your application on a fork, distribution may change.
WGPUI uses the same binary pipeline as upstream GPUI. No additional steps required.
Kael includes additional dependencies for media playback, networking, and system tray integration. These may pull in platform-specific dynamic libraries. If you distribute as a single static binary, ensure those dependencies are linked statically or bundle them alongside your executable. Kael’s documentation provides the exact library list for each platform.
gpui-ce tracks upstream closely, so distribution is identical to upstream GPUI.
gpui-unofficial is a versioned mirror of upstream. No changes to distribution.
The Update Problem
Electron has a built-in auto-updater. GPUI does not.
If your application needs automatic updates, you must build that logic yourself. The pattern is straightforward: check a remote manifest on startup, download a new binary if available, replace the current executable, and relaunch. For signed binaries, verify the signature before replacement.
Several Rust crates provide update infrastructure. self_update and update-informer are popular choices that integrate with GitHub Releases, Amazon S3, or custom servers. GPUI’s async system can drive the download and installation without blocking the UI.
The trade-off is clear: you control the update experience, but you also own the implementation.
The Electron Comparison, Finalized
| Metric | GPUI | Electron |
|---|---|---|
| Minimal binary size | 8–15 MB | 80–150 MB |
| Memory overhead | Application only | Chromium + Node.js |
| Startup time | Instant (0.1–0.3s) | Delayed (0.5–1.5s) |
| Native feel | Full | Emulated |
| Auto-updater | You build | Built-in |
| Cross-platform UI consistency | Your responsibility | High (web standards) |
GPUI wins on size, speed, and native integration. Electron wins on out-of-the-box updates and web rendering consistency. Neither is universally better. The right choice depends on your application’s priorities.
Final Checklist
Before shipping, verify:
- Release build compiles with
--releaseand LTO enabled - Binary is stripped of debug symbols (
striporsplit-debuginfo) - Code signing is applied for macOS and Windows
- Bundling matches platform expectations (
.app,.exe,.deb, or AppImage) - Update mechanism is implemented or explicitly skipped
- Fork-specific dependencies are accounted for (Kael’s media libraries, etc.)
Next Steps
Your application is now ready for users.
But before you close this book, read the Epilogue. It answers the question you may still be asking: “Was this the right investment, given the fragmentation?” The answer is more hopeful than you might think.
Epilogue: The Lingua Franca Argument
Epilogue: The Lingua Franca Argument
You have read fourteen chapters, built at least one application, and navigated a fragmented ecosystem of forks, components, and missing primitives. The natural question now is: was this worth it?
The answer depends on what you are betting on.
If you bet on GPUI the framework — the specific crate from Zed Industries — you are betting on an editor-first tool that may never add custom shaders, transforms, or a headless primitives layer. That bet pays off only if your application looks like an editor.
But if you bet on Rust the language, you have made a different bet entirely.
Where Rust Is Heading
Rust is becoming one of the primary languages for native application development. The borrow checker, once the steepest part of the learning curve, increasingly looks like a reasonable price for memory safety without a garbage collector — whether you arrived at that conclusion from C++’s manual memory management or from chasing down a reference-counting cycle in a higher-level language.
The evidence is everywhere. Tauri lets you build webview-based apps with Rust backends. Slint provides a declarative UI toolkit with its own rendering. Dioxus and Leptos bring reactive, signal-based patterns to native and web rendering alike. And GPUI proves that a GPU-accelerated, native-feeling desktop framework can exist without a garbage collector.
These frameworks will not converge on a single API. Dioxus’s signals and GPUI’s Entity<T> are not the same mechanism. But they share a mental model that’s distinctly Rust’s own: the framework owns the data, you request access through a context, and the type system enforces the boundary. Learning that mental model in GPUI means recognizing it instantly elsewhere.
Why This Investment Compounds
The specific skills you have learned in this book are not GPUI trivia. They are Rust architecture patterns expressed through GPUI’s particular syntax.
The pure core pattern (Chapter 12) is not GPUI-specific. It applies to any framework where you want to separate domain logic from presentation — and it’s good practice even outside Rust.
The ownership-mediated state model — requesting access through a context, notifying on change — is a shape you’ll recognize in other Rust UI frameworks even when the specific types differ.
Subscription-based event propagation is how you build decoupled systems in any actor-like architecture, in any language.
The WeakEntity pattern for async safety (Chapter 3) is directly transferable to any framework with similar ownership semantics.
You have learned Rust architecture, not just GPUI. That investment compounds regardless of which fork wins — or whether GPUI itself is still the dominant choice in a few years.
A Year or Two From Now
Where will the GPUI ecosystem be a year or two from now? No one knows precisely. But the forks provide a kind of insurance.
If Zed continues to prioritize editor features, the energy around custom shaders, unified rendering, and broader component libraries will likely concentrate in the forks — Kael, WGPUI, gpui-ce, or whichever combination of them proves most useful. Ideas may flow between them; a shader API design from one could inform another’s implementation. None of this is guaranteed, but it’s the normal pattern for how open ecosystems mature.
Fragmentation isn’t failure. It’s the ecosystem learning which designs work. The forks that produce the best solutions will attract the most users, and consolidation tends to follow — not by decree, but because developers choose the tools that work.
The Final Trade
You can wait for the perfect framework. You will wait forever.
Or you can build with what exists, separate your domain logic from your UI, and stay portable across frameworks. That’s what this book has taught. Your business logic is pure Rust. Your UI is a thin wrapper. When the ecosystem shifts — and it will — you rewrite the wrapper. The core remains.
Whether you came to this book tired of CMake or tired of shipping Chromium, you arrived at the same place: a language that takes your existing instincts seriously and a framework that puts native pixels on the screen without ceremony.
That’s the lingua franca argument. Rust is the constant. GPUI is one expression of it. Learn the patterns, not the incantations. Build the product, not the framework tribute.
And ship.
Appendix A: GPUI’s Origins — The Zed Interview and What It Reveals
This appendix is optional. Chapter 8 gave you the practical takeaways. Here’s the primary source material behind those claims — drawn from a conversation with Nathan Ball, co-founder of Zed Industries.
The Origin
GPUI wasn’t born as a general-purpose framework. It was extracted from Zed.
The original 2018 architecture had GPUI 1 spitting JSON at an Electron shell — Zed’s first UI was an Electron app launching a Rust binary. From there, GPUI 1 went through phases: a Pathfinder renderer, a Flutter-inspired layout system (constraints down, sizes up), and a split between Rust code and JSON/TypeScript themes. The team later admitted that split worked poorly in practice.
By late 2023, the team was paralyzed. Every time they wanted to add collaboration features, the same question came up: how do you build this? The honest answer was that they didn’t have a good workflow. Building UI had become frustrating and miserable.
The catalyst was a contract designer who couldn’t make any progress in the old framework.
The Rewrite
The rewrite took four months, from late October to December 2023. The team froze GPUI 1 and built GPUI 2 in parallel. They were never worried that the rewrite itself was a bad idea — the real risk was organizational, coordinating the switch without breaking everything.
The rewrite had three goals. First, unidirectional flow — model code should never be aware of view code. Second, a Flexbox-inspired DSL where you can just write div and child. Third, type safety — previously, pixels were just f32s with no type distinction, leading to silent rendering bugs.
The result solved the problem they set out to solve: building views went from painful to painless.
The Open Source Decision
The team followed a strict sequence: rewrite, stabilize, then open source. Doing a major rewrite after open sourcing would have created too much chaos.
On quality: Nathan describes himself as an unapologetic perfectionist. Beauty matters. The code is now public, so it’s part of what he’s expressing to the world. It needs to be good.
The License Split: Apache vs GPL
GPUI and Zed live in the same repository but under different licenses. This matters when you’re deciding what code you can legally reuse.
GPUI is Apache 2.0. The framework — the gpui crate and its core primitives — uses Apache License, Version 2.0. This is a permissive license. You can use GPUI to build commercial desktop applications and distribute them under any license you choose. The Apache 2.0 license grants you a perpetual, worldwide, royalty-free copyright license to reproduce, prepare derivative works, and distribute the work.
Zed the editor is GPL v3. The editor’s source code — features like the workspace, dock, pane, scrollbar, picker, and title bar — falls under GPL version 3. Server-side components use AGPL. The copyleft license ensures that any improvements to the editor itself must be shared back with the community.
The boundary is enforced. A developer once asked permission to extract Zed’s dock, scrollbar, picker, and title bar components for use in a commercial GPUI project. The response from the Zed team was clear: taking those crates wholesale and relicensing them would violate the GPL, and they couldn’t legally permit it. Those components are built specifically for Zed, not intended for reuse outside the editor.
What this means for you. You can freely use GPUI itself. Build your app, ship it, keep it closed source if you want. But if you copy code from Zed’s GPL-licensed crates — anything from workspace, ui, or editor-specific components — your project becomes subject to the GPL. The team has plans for a more general-purpose component library under a permissive license someday, but right now, the focus is Zed.
The practical rule: stick to the gpui crate and your own code. Admire Zed’s architecture, don’t copy its GPL’d implementation.
What This Reveals
Four truths about GPUI today:
One — it’s editor-first, not community-first. The roadmap serves Zed. That’s focus, not hostility, but it’s a constraint you must accept if you build on upstream.
Two — the rewrite was about workflow, not features. GPUI 2 fixed painful UI development. It did not add custom shaders, transforms, or general-purpose graphics. Those gaps exist because Zed doesn’t need them.
Three — open source was an afterthought. GPUI is an editor’s internal framework that happens to be open source, not an open source project that happens to have an editor. This matters when you’re betting on it.
Four — quality is real. Nathan’s perfectionism means thoughtful architecture. It also means features that don’t meet his standards — or don’t serve Zed — may never arrive.
The Missing Text Input
GPUI does not ship a text input component. This surprises everyone. Zed is a text editor. How can its own framework lack basic text input?
The answer reveals how GPUI works in practice.
GPUI provides the rendering and event primitives: keyboard routing, focus management, cursor rendering, and basic shapes. What it does not provide is the text storage, selection logic, IME handling, or undo/redo stack. Those live in Zed’s text crate — Rope-based, with sum tree architecture for performance.
Inside Zed, the boundary between “framework” and “editor” is blurry. The text stack is editor code that happens to share a repository with framework code. It was never extracted, documented, or stabilized as a public API.
As a result, you have three options:
One: Copy from Zed. The ui_input crate contains SingleLineInput and MultiLineInput. The code is there. The team won’t stop you. But it’s not versioned, not documented, and may break. And remember the license — copying from Zed’s GPL’d crates puts your project under GPL.
Two: Use gpui-component from the Longbridge trading platform. It provides Input with InputState, InputMode::PlainText, selection, history, and rendering. This is the most complete off-the-shelf solution, but it carries the rest of that library’s opinions.
Three: Build from GPUI primitives. Keyboard events, focus, and cursor rendering are in the framework. Add ropey for text storage, wire selection and IME yourself. Chapter 11 walks through exactly this. Your code stays under your own license.
The absence isn’t a technical gap. It’s a priority gap. Zed doesn’t need a standalone text input — it has an Editor that does syntax highlighting, multi-cursor, and LSP. Extracting the simple case is work with no direct benefit to the product. The editor-first constraint in action.
What This Does Not Mean
No licensing hostility. The team chose Apache for GPUI specifically so others could build commercial applications. The GPL on Zed itself is standard for open source editors.
No abandonment. Active maintenance continues — witness the February 2026 PR reimplementing the Linux renderer with wgpu.
No universal warning label. GPUI is optimized for editor-shaped problems. If your project looks like an editor — data-heavy, text-heavy, performance-sensitive — you’re in the right place. If it looks like a game or creative tool, you’re not.
How To Use This
When you hit a missing feature, you now understand why. That tells you whether to work around it, contribute upstream, or fork.
When you evaluate a long-term bet, you now understand the incentives. Upstream serves Zed first. That means faster, more reliable text and data — but no custom shaders or transforms anytime soon.
When you read GitHub discussions or contribute, you now understand the team’s perspective. Contributions that help Zed are welcome. Contributions that don’t are lower priority.
When you look at Zed’s source for inspiration, you now understand the legal boundary. Learn from the architecture. Don’t copy the GPL’d code.
Appendix B: The Cargo Expectation Gap
Appendix B: The Cargo Expectation Gap
In Chapter 1, we made a promise: cargo add gpui pulls in the framework and everything it depends on, with one command and no PATH archaeology.
If you are coming to Rust from C++, you likely read that, ran the command, and felt a profound sense of relief. You have escaped a decades-long purgatory of Makefiles, CMake lists, missing headers, and cryptic linker errors. To you, Cargo feels like magic.
If you are coming to Rust from TypeScript, Ruby, or Python, you likely read that same sentence and shrugged. Your baseline is npm, bundler, or pip. Your ecosystem solved “one command to install” years ago. To you, Cargo isn’t magic; it is the bare minimum expectation of a modern language.
This appendix is primarily for the TypeScript and high-level language engineers. Because if your baseline is npm, you are eventually going to run cargo build on a new project, watch it pull down 250 transitive dependencies, wait several minutes for a clean compile, and hit a version conflict between two crates. When that happens, you might wonder if Chapter 1 oversold the Rust experience.
We didn’t. But your experience of Cargo depends entirely on the ecosystem you left behind. Here is the honest map of why Cargo behaves the way it does, and a few essential survival rules for operating it.
The “Micro-Crate” Reality
Languages like Go, Python, and JavaScript are “batteries included.” If you want to parse JSON, generate a random number, or make an HTTP request, it is either built into the language or provided by a massive, centralized standard library.
Rust’s core team made a different philosophical choice: the standard library must remain lean, stable forever, and capable of running on embedded microcontrollers.
The result is the micro-crate ecosystem. In Rust, you have to pull in a dependency for almost everything.
JSON serialization needs serde. Random numbers need rand. Time and dates need chrono. HTTP requests need reqwest.
To see the difference, look at making a simple HTTP request. In a modern TypeScript environment, fetch is just there. In Rust, adding the reqwest crate doesn’t just add one library; it pulls in an async runtime, an HTTP parser, a TLS library, and their respective dependencies. Suddenly, your Cargo.toml requires dozens of community crates just to stitch together basic functionality.
You Are Compiling the World
When you run npm install, you are mostly downloading pre-compiled JavaScript files. When you use Java’s Maven, you download pre-built JARs.
Cargo downloads source code and compiles it all locally on your machine.
This is where the friction hits. The dependency management is clean on paper, but the build process exposes you to the harsh realities of systems programming. If one of your 200 dependencies relies on a C library that isn’t on your system, the build fails. You are trading the convenience of pre-compiled binaries for absolute control over optimization, memory layout, and target architecture.
GPUI and the Async Question
Because Rust’s standard library is so barebones, the community had to build the infrastructure for async programming themselves. Historically, this led to deep ecosystem rifts — most notably the split between the tokio and async-std runtimes. In standard Rust backend development, pulling in a library built for the “wrong” runtime can result in a cascading tangle of incompatible dependencies.
GPUI helps here, but it’s worth being precise about how much. When you use cx.spawn() or cx.background_spawn(), you’re using GPUI’s own integrated executor — you don’t need to set up a tokio runtime just to make your UI’s async code work. For the async patterns covered in this book — file loading, background tasks, animations — GPUI’s concurrency model is self-contained.
What GPUI doesn’t do is shield you from runtime requirements introduced by other dependencies. If you add reqwest for networking, it brings its own runtime expectations regardless of what GPUI uses internally. The historical rift doesn’t disappear — but for the core of your application, GPUI gives you a working concurrency model without forcing that choice on you up front.
The Ecosystem Survival Guide
Cargo is a remarkably stable tool, but it carries the baggage of decisions made in a very different era of package management. If you remember the early, fragile days of Gemfile.lock or package-lock.json, you already possess the intuition to avoid Cargo’s two biggest footguns.
Survival Rule 1: Always use --locked for binaries.
When you install a tool globally using cargo install <tool>, Cargo ignores the package’s Cargo.lock file by default. It recomputes the dependency tree on the fly. Historically, the idea was that installed binaries should automatically get compatible bug fixes and security patches. In reality, it means your installation is held hostage by whichever transitive dependency pushed a broken update in the last 24 hours. Always run cargo install <tool> --locked to ensure a reproducible build that matches what the author actually tested.
Survival Rule 2: Tightly pin volatile 0.x crates.
Cargo enforces Semantic Versioning strictly. If your Cargo.toml asks for version 0.3.1, Cargo will safely allow updates up to, but not including, 0.4.0. However, the Rust ecosystem has a culture of extreme hesitation around publishing a 1.0. Crates will sit in 0.x — which officially means “initial development” — for years. Many maintainers treat this as a perpetual beta where any patch release is fair game for breaking API changes. If a 0.x crate breaks your build, tighten your Cargo.toml to demand an exact version: =0.3.1.
The Mitigation: Why Chapter 12 Matters
Cargo’s strictness — specifically how it treats two different versions of the same crate as entirely different, incompatible types — can make large dependency trees brittle.
This is the exact reason Chapter 12’s Pure Core Pattern is not just academic architecture. It is a practical defense mechanism.
By keeping your business logic in a domain crate that knows nothing about GPUI, reqwest, or system-level dependencies, you isolate your application from Cargo’s friction. Your domain crate compiles quickly, and its tests run in milliseconds. Its Cargo.toml has only a handful of fundamental data-structuring crates.
You let the UI crate deal with the massive dependency trees, the framework updates, and the platform-specific build times. Your core logic stays fast, testable, and largely unaffected by the churn of the ecosystem.
Appendix C: The Linux Desktop Reality
Appendix C: The Linux Desktop Reality
In a clean cross-platform world, you write your UI code once, and the operating system provides the window frame — title bar, resize borders, close button — while your framework draws the pixels inside it. On macOS and Windows, this assumption holds. On Linux, it doesn’t always.
Server-Side and Client-Side Decoration
Linux windowing is currently mid-migration from the older X11 protocol to Wayland. Under Wayland, the compositor — the software responsible for drawing the screen — decides how windows are framed. Most modern compositors support an extension called xdg-decoration-unstable-v1, which lets applications request standard server-drawn decorations (Server-Side Decoration, or SSD) — the traditional model where the desktop environment draws your title bar and borders.
GNOME, the default environment for Ubuntu, Fedora, and several other major distributions, does not implement this protocol. GNOME’s design philosophy favors Client-Side Decoration (CSD) — the idea that applications should draw their own title bars, integrate their toolbars into that space, and manage their own window chrome. This is a deliberate design choice with real motivations behind it — consistency of app integration, reduced duplication between toolbar and title bar — even if the practical effect, for a framework expecting SSD, is jarring.
What This Means for Your GPUI App
GPUI is a foundational rendering layer, and by default it requests server-drawn decorations.¹ On GNOME under Wayland, a request for server decorations that the compositor doesn’t honor can result in a window with no title bar, no resize borders, and no close button — just a frameless rectangle. If your users don’t know a window-manager shortcut to close an unresponsive window, this is a genuinely bad first impression.
If you’re targeting Linux — and Ubuntu and Fedora users are a meaningful share of any Linux desktop audience — this isn’t something to discover after shipping.
Three Ways to Handle It
Build your own decorations. Because GPUI gives you full control over pixels, you can draw your own title bar, implement drag-to-move by handling pointer events and passing move requests to the window manager, and draw your own resize borders. This is real work — you’re building a small part of a window manager — but it gives you full control over your app’s chrome on every platform, not just Linux.
Use a component library. As covered in Chapter 10, libraries like gpui-component aim to provide pre-built title bar and window-frame components designed to work correctly under CSD. If you’ve already adopted one of these libraries for other reasons, check whether it solves this problem for you as well.
Don’t support GNOME Wayland natively. Some applications simply document that GNOME users should run the app under XWayland — a compatibility layer that makes GNOME behave like X11 and draw a standard frame, sometimes with minor visual quirks. This is the lowest-effort option, but it pushes a workaround onto exactly the users least likely to know about it.
None of these is universally right. The choice depends on how much of your audience runs GNOME, how much control you want over your app’s chrome anyway, and how much time you have before shipping. The point of this appendix isn’t to tell you which path to take — it’s to make sure you’re choosing deliberately, rather than discovering the frameless-rectangle problem from a confused bug report after launch.
¹ Verify WindowDecorations::Server as GPUI’s actual default and exact behavior against current source before publication.