gpui and the Mail Client Problem: A Text Field Costs 778 Lines

· 27 min read

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. App owns all state, Entity<T> is an inert handle, and update leases state back to you — which is how a UI graph stops being Rc<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 main tells you to depend on a gpui_platform crate that is not on crates.io, and Application is 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.

The same event, two boundaries Two columns on a shared five-row grid, tracing the same event — a message landing in the mailbox while the user scrolls the thread list. Left column, Tauri or Electron: a sync task in the Rust core owns the store and the socket; the envelope is serialised to JSON, which is the only wire format the event system offers; the payload crosses into the WebView as evaluated JavaScript; a listener rebuilds the row, producing a second copy of the same data on the JavaScript heap; and a virtual list finally writes it into the DOM of WebView2, WKWebView or WebKitGTK depending on the user's operating system. Right column, gpui: the sync task runs on the background executor inside the same crate and the same address space; cx.spawn hops back to the main thread with an AsyncApp held across the await; store.update leases the state out, mutates it and returns it; cx.notify queues an effect that is flushed after the update completes; and the uniform list paints only the visible range on the next GPU frame. The left path buys a serialiser and a runtime you do not own, but gets a text field, a table and accessibility for free. The right path is a function call, and a design system you now maintain yourself. The same event, two boundaries a message lands in the mailbox while the user is scrolling the thread list Tauri / Electron sync task, Rust core owns the store and the socket serialise the envelope JSON — the only wire formatevents "are always JSON strings" cross into the WebView payload arrives as evaluated JS listener rebuilds the row a second copy on the JS heap virtual list, then DOM WebView2 / WKWebView / WebKitGTK gpui sync task, background executor same crate, same address space cx.spawn back to the main thread AsyncApp held across the await store.update(cx, ..) state leased out, mutated, returned cx.notify() effect queued, flushed after update uniform_list, next frame visible range only, painted by the GPU left: a serialiser, and a runtime you do not own right: a function call, and a design system you now maintain the left column ships a text field today; the right one charges 778 lines

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.

One arriving message, socket to pixel A five-stage vertical flow describing what happens in a gpui mail client when a new message arrives. Stage one: the IMAP IDLE loop runs on gpui's background executor, spawned through cx.background_executor().spawn, holding a future that is Send and static and touching no UI types. Stage two: cx.spawn hops back to the main thread, taking an async closure that receives a weak handle to the entity and an AsyncApp that can be held across await points. Stage three: store.update leases the mail store out of the application, moving it to the stack where it is mutated and then returned. Stage four: cx.notify queues an effect rather than calling any observer, so listeners run only after the update finishes. Stage five: the render method runs on the next frame, and the uniform list element asks for only the rows the viewport can actually show. Nothing along this path is serialised, because nothing crosses a process boundary: one address space, one crate graph, one borrow checker. The bill for that is that every widget in the render call is one you wrote yourself. One arriving message, socket to pixel nothing is serialised, because nothing crosses a process boundary background executor — the IMAP IDLE loop cx.background_executor().spawn(..) · Send + ’static · no UI types cx.spawn(..) — hop back to the main thread AsyncFnOnce(WeakEntity<T>, &mut AsyncApp) -> R store.update(cx, ..) — lease the state the MailStore moves to the stack, mutates, and is returned cx.notify() — queue an effect, call nobody observers run when the update finishes, never inside it render(&mut self, window, cx) — next frame uniform_list asks only for the rows the viewport can see one address space · one crate graph · one borrow checker the bill for that: every widget in the render call is one you wrote

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.