gpui and the Mail Client Problem: A Text Field Costs 778 Lines
I spent a week on a question that turned out to be much older than the framework I was researching: what would it take to write a mail client in 2026? Not a demo — two accounts, a couple of hundred thousand archived messages, a thread list that stays smooth under a flick scroll, and a sync loop that holds an IMAP connection open all day. The hard parts of that are protocol and storage. But the part everyone argues about is the part that draws the pixels.
The fashionable answer is gpui, the GPU-accelerated Rust UI framework that Zed is built on. So I did the obvious thing: cargo add gpui, cargo check. It resolved 704 crates and spent the next six and a half minutes type-checking them. While that ran, I went looking for a text field — because a mail client is mostly text fields: the search box, To and Cc, the subject line, the composer.
There isn't one. What gpui ships is crates/gpui/examples/input.rs: 778 lines implementing EntityInputHandler, IME marked ranges, UTF-16 selection arithmetic, grapheme-boundary walking, clipboard actions and mouse-drag selection. It is offered as a demonstration of how you would build a text field, not as a text field.
That is not a documentation gap. It is the entire trade, stated with unusual honesty on the first day.
Choosing between a WebView shell and a native Rust GUI is not a performance question — both are fast enough for a mail client, and anyone who tells you Electron cannot scroll a list has not measured one recently. It is a question of where you are willing to put a boundary. Tauri and Electron sell you a mature text-and-widget layer and charge you a partition: your sync engine lives on one side, your list lives on the other, and every message that crosses is serialised. gpui sells you one address space where the IMAP task and the scroll offset are the same program, and charges you the widget layer, the design system, and a pre-1.0 API that changes underneath you. A mail client is one of the few application shapes where that trade genuinely flips — and it flips on list-and-text throughput and on who owns the sync loop, not on binary size.
TL;DR
- The boundary, not the frame rate, is the decision. A WebView app serialises every envelope it wants to show; a gpui app calls a function.
- gpui's entity model is the part worth stealing even if you never ship it.
Appowns all state,Entity<T>is an inert handle, andupdateleases state back to you — which is how a UI graph stops beingRc<RefCell<…>>. cx.notify()queues an effect, it does not call an observer. Run-to-completion updates kill the reentrancy bugs that make big desktop UIs unpredictable.- The text field is the bill. 778 lines in the official example, and you own every line of it plus the design system around it.
- Pre-1.0 is not a slogan here. The README on
maintells you to depend on agpui_platformcrate that is not on crates.io, andApplicationis constructed differently in the published 0.2.2 than in the tree. - gpui-component is the ecosystem's real answer — 13,000 stars, 60+ components, built for a production trading terminal — and depending on it is a strategic decision, not a
cargo add. - Tauri's own docs make the partition argument for me: event payloads "are always JSON strings", which the documentation itself calls unsuitable for bigger messages.
- The rule I landed on: ship on Tauri unless the list and the text are the product. For a mail client they are, which is the only reason this is a real question.
A mail client is four hard problems, and only one of them is a toolkit
It is worth being specific about why mail is a good probe, because "build a mail client" has become the "build a todo app" of framework marketing, and the marketing version skips the parts that hurt.
A list nobody can hold in memory. A working mailbox is 100,000 to a million messages. The list has to be virtualised, the scrollbar has to be honest about a total it has not fully indexed, and search results have to reorder that list without a rebuild. This is the single most demanding thing in the app and it runs during a gesture, where the frame budget is 8ms.
A sync loop that never ends. IMAP IDLE (RFC 2177) keeps a connection open per mailbox so the server can push changes. In Rust that is async-imap or a peer, running for days, reconnecting through sleep and captive portals, writing into a local store that the UI is reading from at 120 Hz.
Text, in every ugly form the internet has. Quoted-printable, seven charsets, HTML mail written by a marketing tool in 2009, right-to-left, CJK line breaking, and an editor that has to do IME correctly or it is unusable for half the planet. This is where WebView stacks are quietly excellent and where hand-rolled toolkits go to die.
The OS surface. Notifications, a tray icon, mailto: registration, the system share sheet, spellcheck, and accessibility — a mail client that a screen reader cannot drive is not a mail client.
Every framework below is scored on those four, because they are the ones that decide the project.
The WebView stacks are not slow, they are partitioned
Start by disarming the lazy criticism. Electron bundles Chromium, so you get the best text engine on the planet, a virtual-list ecosystem with a decade of tuning, and accessibility that already works. Tauri drops the bundled runtime and uses the WebView the OS already has, which makes the binary small and the surface familiar. Neither is the reason a mail client written on them feels heavy.
The reason is that the interesting state lives twice. Your sync engine owns the store in Rust; your list owns a rendering copy in JavaScript; and the road between them is a serialiser. Tauri's own documentation is refreshingly direct about it:
Events are designed for situations where small amounts of data need to be streamed… event payloads are always JSON strings making them not suitable for bigger messages.
and, on the same page:
the event system directly evaluates JavaScript code so it might not be suitable to sending a large amount of data.
Tauri v2 has an answer — Channel, "designed to be fast and deliver ordered data", used internally for download progress and child-process output — and it is a good answer. But notice what has happened: you now have an IPC design discipline in a mail client. Which envelopes go over events, which go over channels, what the batching window is when a folder sync produces 40,000 rows, whether the JS side keeps its own index or asks for one. That is not incidental complexity from a bad framework. It is the necessary cost of the boundary you chose, and it is paid in the exact place a mail client is hardest.
There is a second cost that is specific to Tauri and worth naming, because it gets sold as a benefit: the renderer is the user's WebView. WebView2 on Windows, WKWebView on macOS, WebKitGTK on Linux. Your mail client's HTML-body rendering — the least trustworthy content in your app — behaves differently on three engines you do not ship, do not version, and cannot patch. Electron's much-mocked 150MB is what buying that control costs.
What gpui actually hands you
The other side of the trade is small enough to read in an afternoon. Everything starts with an Application, a window is opened with a callback that returns a root view, and a view is any entity that implements Render:
struct MailWindow {
status: SharedString,
}
impl Render for MailWindow {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().flex().size_full().child(self.status.clone())
}
}
fn main() {
Application::new().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(900.0), px(600.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|_, cx| cx.new(|_| MailWindow { status: "Idle".into() }),
)
.unwrap();
cx.activate(true);
});
}
div() is the swiss-army element, and the builder chain is deliberately Tailwind-shaped — .flex().flex_col().gap_3().px_2().text_xl() — laid out by taffy, which is a real flexbox implementation rather than a lookalike. If you have written a React component you can read a gpui view on the first try. That familiarity is the framework's best trick and also its most misleading one, because the resemblance stops at layout: there is no CSS, no cascade, no stylesheet, and no <input>.
The message list is the piece that matters, and it is one element. From the upstream example, condensed:
uniform_list(
"entries",
50,
cx.processor(|_this, range, _window, _cx| {
let mut items = Vec::new();
for ix in range {
let item = ix + 1;
items.push(
div()
.id(ix)
.px_2()
.cursor_pointer()
.on_click(move |_event, _window, _cx| {
println!("clicked Item {item:?}");
})
.child(format!("Item {item}")),
);
}
items
}),
)
.h_full()
The callback receives a Range and returns only those rows. Swap 50 for mailbox.len() and the closure body for a thread-summary row and you have the thread list, with no library and no virtualisation config. gpui also has a list element for variable-height rows, which is what you actually need once a row can show a two-line preview or an attachment strip. This is the part of the framework where the "written for an editor" heritage pays off directly: Zed's whole existence is scrolling a very long list of text quickly.
The entity model is the part worth stealing
The interesting idea in gpui is not the GPU. It is that Rust's ownership rules and a mutable UI graph are famously incompatible, and gpui resolves that by refusing to hand you the state at all. App owns every model and view. What you hold is an Entity<T>: a reference-counted, type-tagged handle that can do nothing on its own.
Entities are owned by GPUI and are only accessible through an owned smart pointer similar to an
Rc.
The difference from an Rc is that dereferencing requires the application. To read you need an &App; to mutate you call update, which leases the state — moves it out to the stack, hands you &mut T, and puts it back when your closure returns. The borrow checker is satisfied because at no point do two paths hold the same &mut. The complete pattern is the upstream ownership_post example, and it is short enough to read in full:
struct Counter { count: usize }
struct Change { increment: usize }
impl EventEmitter<Change> for Counter {}
let counter: Entity<Counter> = cx.new(|_cx| Counter { count: 0 });
let subscriber = cx.new(|cx: &mut Context<Counter>| {
cx.subscribe(&counter, |subscriber, _emitter, event, _cx| {
subscriber.count += event.increment * 2;
})
.detach();
Counter { count: counter.read(cx).count * 2 }
});
counter.update(cx, |counter, cx| {
counter.count += 2;
cx.notify();
cx.emit(Change { increment: 2 });
});
assert_eq!(subscriber.read(cx).count, 4);
Two propagation mechanisms, and the distinction is worth internalising: notify says I changed, and anything that called observe re-reads you; emit says this specific thing happened, typed, to anything that called subscribe. In mail terms, a message being marked read is a notify on the folder — every list, badge and counter re-derives itself. A send failing is an emit, because the composer, the outbox and the toast each need to do something different about it and none of them should be diffing state to work out what happened.
The detail that makes this more than an ergonomic wrapper is that neither call runs a listener. Both queue an effect that is flushed after your update returns. You cannot re-enter an entity that is mid-update, which quietly deletes the class of bug where an observer mutates the thing being observed and you get a half-applied state three frames later. Anyone who has debugged a cascade of change events in a large desktop UI knows exactly how much that is worth.
Map a mail client onto it and the architecture writes itself: an Account entity per configured mailbox, a MailStore entity that owns the SQLite handle and the message index, a ThreadList view that observes the store, a Composer view that emits SendRequested. No Arc<Mutex<AppState>> at the root, no channel-per-widget, no global event bus. The framework's answer to "where does state live" is a real answer, and it is the piece I would carry into a project that used none of gpui's rendering.
Sync is the axis where gpui actually wins
Now put the sync loop through both stacks. gpui ships its own executor, split into a background pool and a foreground queue bound to the platform event loop. BackgroundExecutor::spawn requires Send + 'static, and the entity-aware Context::spawn hands your future a weak handle, so a long-running sync task cannot keep a closed window alive. The whole fetch path is nine lines:
impl MailStore {
fn sync(&mut self, cx: &mut Context<Self>) -> Task<()> {
let fetch = cx
.background_executor()
.spawn(async move { fetch_new_envelopes().await });
cx.spawn(async move |this: WeakEntity<MailStore>, cx: &mut AsyncApp| {
let fetched = fetch.await;
this.update(cx, |store, cx| {
store.messages.extend(fetched);
cx.notify();
})
.ok();
})
}
}
The IMAP conversation and the MIME parse run on a worker thread; cx.spawn hops back to the main thread with an AsyncApp that survives the await; update upgrades the weak handle and leases the store; notify schedules the redraw. The rows land in the same Vec the list element reads from, in the same address space, and the .ok() is the entire error-handling story for "the window closed while we were fetching". Nothing is serialised, because there is nothing to serialise to.
Two caveats stop this from being a free win. gpui's executor is not Tokio, and the mature IMAP and TLS crates in the ecosystem generally assume Tokio's reactor — so you either pick runtime-agnostic crates, or run a Tokio runtime alongside and treat it as a worker pool, which is a real integration cost and one you should budget on day one rather than discover in week six. And Task is cancel-on-drop unless you detach(), which is the correct default for UI work and a footgun for a sync loop that must survive a view being recreated.
For the OS surface, gpui is further along than its reputation: SystemNotification with tagged replacement and action buttons is in the framework, menus are there, and accessibility is wired through AccessKit with a dedicated example that exposes roles, spin buttons and toggles to assistive technology. That last one matters more than it looks, because "we'll do a11y later" is how a native toolkit turns into a lawsuit. It is still nothing like inheriting Chromium's a11y tree for free, but the hook exists and the framework's own examples use it.
The bill arrives as a 778-line text field
Here is the honest cost side, and it is not small.
You will write the widgets. Not "style the widgets" — write them. Text input, with selection, IME composition, undo, autocomplete for addresses. A table. A tree for the folder sidebar. Menus, dialogs, tooltips, a date picker for search filters. The 778-line input.rs is the framework telling you the floor. For a mail client, "the whole design system" is a multi-month line item that has nothing to do with mail.
You will absorb breaking changes. The README is blunt: "still pre-1.0. There will often be breaking changes between versions." That is not theoretical — it is visible right now in the gap between what is published and what is in the tree. The README on main instructs you to depend on two crates:
gpui = { version = "*" }
gpui_platform = { version = "*", features = ["font-kit", "wayland", "x11"] }
gpui_platform is not on crates.io. Searching for it returns third-party republications with names like gpui-platform-gpui-unofficial, which is its own kind of warning. The published gpui 0.2.2 still carries font-kit, wayland and x11 as its own default features, and starts with Application::new(), while the tree has moved to gpui_platform::application(). Both are correct; they just describe different weeks. Every gpui tutorial you find, including the good ones, is pinned to a moment.
You will pay for the build. A hello world depending on nothing but gpui resolves 704 crates and took 6 minutes 38 seconds to type-check on the two-core VM I was working on; the debug artefacts then filled a 2 GB disk and killed the linker before it produced a binary. macOS wants a full Xcode install for the Metal path, and Linux wants a windowing backend feature plus the system libraries behind it. None of that is unreasonable for a GPU toolkit — it is the same bill Chromium pays, just visible — but it is a poor fit for a project that wants five-minute contributor onboarding, and it is worth knowing before you promise one.
And you will make a decision about gpui-component. It is the serious answer to the widget problem — 60+ components including Input, Table, VirtualList, a dock layout and an editor with LSP support; Apache-2.0, 13,000 stars, at 0.5.2, built by Longbridge for a production trading terminal and pushed to on the day I write this. Adopting it turns gpui from "write everything" into something a small team can ship on. It also means your application's entire look and interaction model now depends on a community library tracking a pre-1.0 framework. That is a strategic bet on a second project's maintenance, and it deserves to be made deliberately rather than discovered in a Cargo.toml.
Against egui and Iced
Two other native Rust options deserve a paragraph each, mostly to explain why they do not change the argument.
egui is immediate mode: the entire UI is rebuilt every frame from a function of state. That is a superb model for tools, debug overlays and anything embedded in a game loop, and a poor one for a mail client, where the UI is long-lived, deeply retained, and full of widgets that own real editing state. You would spend the project fighting the paradigm.
Iced is the closest philosophical rival — Elm architecture, message-passing, a genuine widget set, more mature in the ways gpui is not. Its cost is the same bill in a different currency: you still own your design system, its text stack is not built by people who ship an editor, and it has nothing equivalent to the entity/lease model for a state graph that is genuinely a graph. If you like the Elm shape, Iced is a defensible choice. It just is not a different trade.
The comparison across the four hard problems, scored for this app and no other:
| Electron | Tauri | gpui | Iced | |
|---|---|---|---|---|
| 100k-row list | excellent, mature libs | excellent, over IPC | excellent, uniform_list |
good |
| Long-lived sync | Node ↔ native split | Rust core, serialised out | same address space | same address space |
| Text, IME, HTML mail | best in class | best in class, ×3 engines | you own it | a widget set, not an editor |
| OS surface + a11y | free, complete | good, plugin-shaped | AccessKit, hand-wired | partial |
| Widget layer | free | free | you write it, or gpui-component | included |
| API stability | stable | stable | pre-1.0, moving | pre-1.0, calmer |
The decision rule
Frameworks do not have winners, they have fits, but a post that ends on "it depends" has wasted your time. So, concretely.
Ship on Tauri if the mail client is a product with a schedule. You get the text engine, accessibility, and a hiring pool, and the IPC boundary is an engineering problem with known solutions — batch on channels, keep an index on the JS side, and never send a row you are not about to show. Most of the mail clients people actually use today are drawing their message list into a WebView, and they shipped, which is the strongest argument in software.
Pick gpui when three things are simultaneously true: the list and the text are the product rather than its chrome; one team owns both the sync engine and the interface, so the absence of a boundary is a daily benefit rather than a diagram; and you can afford to own a design system, either by writing it or by betting on gpui-component. Zed is the proof that this ends well — and also the reminder of what it costs, because Zed built the framework to build the editor, and that is the level of commitment the trade assumes.
What should not decide it: binary size, RAM screenshots, or how Rust the stack is. Those are the arguments people reach for when the real one — where does the boundary go, and who pays for the widgets — is uncomfortable.
What I'd steal
Put the state in one owner and hand out handles. The App-owns-everything, Entity<T>-is-inert design is portable to any language. It replaces an ownership graph with an access-control question, and the access-control question has an answer.
Make change propagation two-shaped, not one-shaped. notify for "I changed, re-derive", emit for "this happened, react specifically". Most codebases have only the second, implemented as an untyped event bus, and every consumer ends up diffing state to reconstruct what the first one would have told them.
Never call a listener from inside a mutation. Queue the effect and flush it after the update completes. Run-to-completion is why gpui's state model stays predictable at editor scale, and it costs about twenty lines in any language.
Cost a framework by its boundary, not its benchmark. Ask where your data has to change representation, how often, and who owns the runtime on the far side. That question predicted every real difference in this comparison; the frame-rate question predicted none of them.
None of this makes gpui the right choice for most desktop applications in 2026 — for most of them, it is plainly the wrong one, and the 778-line text field is the whole explanation. But it is the first native Rust GUI whose architecture I would defend independently of its rendering, and mail is one of the handful of app shapes where the argument gets close enough to matter. If I were starting the client tomorrow with a small team and no deadline, I would take the bet. With a deadline, I would take Tauri and spend the saved months on the sync engine, which is where the actual product is.
Read against gpui 0.2.2 (published 15 August 2026) and the zed-industries/zed tree at commit cef06d3, as of 20 August 2026. The MailWindow and MailStore::sync snippets are cargo check-clean against the published 0.2.2 on Linux — I typed them into a throwaway crate rather than trusting the docs, though its debug build then exhausted the 2 GB disk I had given it before the linker finished; the uniform_list and ownership_post extracts are condensed from that commit's crates/gpui/examples/, which Zed's CI compiles rather than I do. Line counts are wc -l on that tree; the 704-crate figure is that crate's Cargo.lock, which depends on nothing but gpui. Tauri quotes are from the v2 IPC and calling the frontend documentation.