← All posts

Restructuring a GPUI app in Zed's shape

Cydonia is a desktop app written in Rust on GPUI, the UI framework Zed is built on. GPUI gives you entities, views and a notify that redraws them. It does not tell you how to arrange a large app out of them. Zed's own source does, and Cydonia's view layer was rebuilt in that shape: Workspace, Pane, Item, ModalLayer. The goal was that each piece of the window redraws only when what it draws changes.

The model is entities

What more than one view draws is an entity in the model. A board is an Entity<Board> held by its project, a session an Entity<ChatSession>, an article's text an Entity<Document> on the article. A write updates that entity and notifies it.

The workspace is not notified for every write. It is notified only for what the sidebar and the library list: an entry made, deleted, renamed, archived or labelled. Moving a card notifies the board, and only the views observing that board draw again.

A session's agent pump, the task reading an ACP agent's stream, runs in the session's own context and writes only to the session. A streaming agent redraws its transcript and nothing else.

Panes, tabs and items

The window holds panes. Each Pane is an entity that owns its tab bar and its tabs, and each Tab keeps one item view per entry it has shown: a BoardView, ArticleView, SessionView or TableView, made the first time the tab shows the entry and kept after.

What belongs to one pane lives on the item: its scroll, its selection, the open card, its find. The content is the model entity it observes. Open one board in two panes and there are two BoardViews over one Entity<Board>: two scroll positions, one board.

An article open in two panes is one document with one editor per pane. Undo history and anchors belong to the document; the selection and the typing run belong to the editor.

Drawn cached

Pane, Tab, the sidebar and the library are drawn .cached(...). A cached view draws again only when it, or a view inside it, notifies. This is where most of the redraw savings come from, and also where most of the bugs hide, because three things that look like they should redraw a cached view do not:

  • notifying an entity the view read during render, but does not observe;
  • window.request_animation_frame(), which redraws only the view that calls it;
  • window.refresh().

So a view observes every entity it draws from: cx.observe when it is made, or a small Watch helper where which entities it draws can change. A piece drawn inside another view, from an entity that view does not observe, becomes a view of its own, kept with window.use_keyed_state and observing its source. A card embedded in an article is one of those.

Modals on a layer

Anything over the whole window is a modal: its own entity, focusable, emitting DismissEvent, drawing its own scrim, shown through one ModalLayer. A modal is never an Option field on the window drawn by the window's render: that would redraw the whole window to open a dialog. Opening one redraws the layer.

Reaching back into the window

Updating an entity while it is already being updated panics in GPUI. A view handling a click is mid-update, and the window it wants to act on may be the thing updating it. So a view that acts on the window implements InWindow, and its defer_window runs the window's part with window.defer, after the view's own update has returned.

Each view owns its context menu, its rename field and its label picker. The window keeps two menus of its own, the band's and the launch screen's, and the rows of an entry's menu are built in one place for whichever view asks.

Rules, written down

None of this holds by itself. The shape is easy to erode one shortcut at a time, so the rules live in the repository's CLAUDE.md, where the agents working on Cydonia read them before every change: what is an entity, who notifies, what is drawn cached, what a modal is.

The restructure is PR #110.