493 lines
32 KiB
Markdown
493 lines
32 KiB
Markdown
# Migrating `crates/ui` to `gpui-base`
|
||
|
||
`crates/ui` is a fork of an early version of `gpui-component`: 71 files and roughly
|
||
21.9k lines that mix behavior, presentation, and application shell. This document is
|
||
the plan for moving its behavior half onto the upstream `gpui-base` crate while the
|
||
application keeps the design system it has today.
|
||
|
||
The facts below were checked against `gpui-base 0.6.1` (crates.io), the `gpui-kit`
|
||
repository at `main`, and this workspace's `Cargo.lock` (zed at `4b47ceb`,
|
||
2026-09-17). Line counts come from `wc -l` under `crates/ui/src`.
|
||
|
||
## Status
|
||
|
||
- **Phase 0: landed.** Manifest only; no Rust changed. The API drift across the
|
||
three days between the snapshot and the old pin turned out to be purely
|
||
additive, so nothing had to be fixed.
|
||
- **Phase 1: landed.** Base is wired in, `sync_base` is in place, and 1,945 lines
|
||
of dead weight are gone. `history.rs` moved to phase 2 once it turned out its
|
||
only consumer is `input/state.rs`. No dependency became unused, so the pruning
|
||
step is a no-op (four dependencies were already unused before this work).
|
||
- **Phase 2: landed.** `input/` runs on base's editing engine. 6,715 lines of
|
||
engine and history are gone and 306 are written, taking `crates/ui/src/input`
|
||
from 6,573 lines to 321. Six call sites changed, all named in phase 2 below.
|
||
- **Phase 3: landed.** `tooltip`, `popover`, `modal`, and `notification` run on base's
|
||
overlay and feedback primitives. Those four modules are 1,434 lines where they
|
||
were 1,592, and nothing outside `crates/ui` changed. The behavioural differences
|
||
are named in phase 3 below; the largest is that base's toast stack replaces the
|
||
fork's notification list.
|
||
- One pre-existing, unrelated breakage was found; see
|
||
[A pre-existing wasm blocker](#a-pre-existing-wasm-blocker).
|
||
|
||
## The two facts that shape the work
|
||
|
||
**GPUI still comes from upstream — addressed as the `gpui-pre` package.** `gpui-base`
|
||
declares its GPUI dependency as `gpui = { package = "gpui-pre", version = "0.3.1" }`:
|
||
the crate in the graph is the published package `gpui-pre`, and `gpui` is only the name
|
||
used in code. That package is upstream zed's gpui (a snapshot of `zed@d89e9c2`,
|
||
published 2026-09-14) republished unchanged, so nothing is forked and there is no
|
||
source to align. Coop previously pinned zed's git repository at `4b47ceb`
|
||
(2026-09-17), a few days ahead of that snapshot.
|
||
|
||
The two cannot be mixed. Zed's git `gpui` and the `gpui-pre` package are different
|
||
crates, so `App`, `Window`, `Entity`, and elements from one are not the other's types,
|
||
and a dependency graph that contains both does not compile. Zed's crates.io `gpui`
|
||
(0.2.2, October 2025) is also far behind the APIs coop already uses. The workspace's
|
||
`gpui` entry therefore has to resolve to the `gpui-pre` package; with the `package =`
|
||
alias, every `use gpui::…` site stays as it is.
|
||
|
||
**The fork's external contract is small.** Outside `crates/ui`, the crate is consumed
|
||
as 38 imported items plus a single `ui::init(cx)` call, across 13 modules, and never
|
||
deeper than `ui::<module>::<Item>`:
|
||
|
||
| Module | Items | Consumer files |
|
||
| --- | --- | --- |
|
||
| crate root (`Icon`, `IconName`, `h_flex`, `v_flex`, `divider`, `Root`, `TitleBar`, `Sizable`, `Selectable`, `Disableable`, `StyledExt`, `WindowExtension`, `InteractiveElementExt`) | 13 | 17 |
|
||
| `input` (`InputState`, `Input`, `InputEvent`; plus `TextareaState` and `Textarea` after phase 2) | 3, then 5 | 10 |
|
||
| `button` (`Button`, `ButtonVariants`) | 2 | 14 |
|
||
| `dock` (`Panel`, `PanelView`, `DockArea`, `DockItem`, `DockPlacement`, `PanelEvent`, `ClosePanel`) | 7 | 10 |
|
||
| `notification`, `avatar`, `menu`, `scroll`, `group_box`, `indicator`, `switch`, `modal`, `tooltip` | 12 | 16 |
|
||
| `list`, `checkbox`, `popover`, `resizable`, `skeleton`, `tab`, `divider` (module), `history`, `animation`, `actions` | 0 references | 0 |
|
||
|
||
`ui::list` and `ui::checkbox` had no consumers at all — the message list in
|
||
`crates/chat_ui` uses GPUI's own `list::ListState` — so phase 1 deleted both. The other
|
||
modules with zero external references still serve as internal machinery for `dock`,
|
||
`menu`, `modal`, and `input`.
|
||
|
||
The consequence: this is not a rewrite of an app-facing library. Most of the work is
|
||
deleting internals and re-expressing a few thousand lines of presentation over base
|
||
primitives.
|
||
|
||
## What must not change
|
||
|
||
- `crates/theme` stays the source of truth: `ThemeColors`, `ThemeFamily`, the registry,
|
||
scrollbar mode, platform, font size and radii.
|
||
- Behavior comes from `gpui-base`; presentation comes from `theme` plus the `ui` styled
|
||
layer. Every migrated component keeps reading `cx.theme()` and keeps its current
|
||
spacing, radius, and shadow math, so the rendered result does not move.
|
||
- Application code keeps importing `theme::ActiveTheme` and `ui::*` under its current
|
||
names. Module paths are part of the contract; internals are not.
|
||
- `gpui-component` is not adopted. It is a complete, styled visual language, and taking
|
||
it would replace the design system rather than preserve it.
|
||
|
||
Two `Theme` types exist — `theme::Theme` and `gpui_base::Theme` — as separate GPUI
|
||
globals. Coop's stays the application-facing one. Base's is touched in exactly one
|
||
place: `theme::sync_base(cx)`, called from `ui::init` and from `Theme::change` so that
|
||
it re-runs on every theme change. It is a no-op before coop's theme global exists,
|
||
which is the case when `ui::init` runs ahead of `theme::init`; `Theme::change` is the
|
||
hook that actually keeps the projection current.
|
||
|
||
It projects the color roles base can act on — the focus ring, the wash under selected
|
||
text, scrollbars, and overlay backdrops — and nothing else:
|
||
|
||
| `gpui_base::ColorTokens` | coop `ThemeColors` |
|
||
| --- | --- |
|
||
| `background` / `foreground` | `background` / `text` |
|
||
| `surface` / `surface_foreground` | `surface_background` / `text` |
|
||
| `primary` / `primary_foreground` | `element_background` / `element_foreground` |
|
||
| `secondary` / `secondary_foreground` | `secondary_background` / `secondary_foreground` |
|
||
| `muted` / `muted_foreground` | `ghost_element_background_alt` / `text_muted` |
|
||
| `accent` / `accent_foreground` | `ghost_element_hover` / `text` |
|
||
| `destructive` / `destructive_foreground` | `danger_background` / `danger_foreground` |
|
||
| `border` / `input` | `border` |
|
||
| `ring` | `ring` |
|
||
| `selection` | `selection` |
|
||
|
||
It also sets `ThemeAppearance` from coop's mode, `ScrollbarTheme`'s mode from coop's
|
||
`scrollbar_mode`, and `TypographyTokens::sans` from coop's `font_family`. Radius,
|
||
spacing, typography sizes, shadows, and scrollbar geometry keep their base defaults:
|
||
coop has a single `radius`/`radius_lg`/`font_size` where base has six-point scales, so
|
||
any mapping would be invented rather than derived. `ResizableTheme` needs nothing —
|
||
base's documented `None` fallback already resolves to `border` at rest and `ring` while
|
||
dragging, both of which are projected.
|
||
|
||
## What each module becomes
|
||
|
||
LOC is the count before the work; a module whose phase has landed reads
|
||
`before → after`.
|
||
|
||
| `ui` module | LOC | Plan | `gpui-base` counterpart |
|
||
| --- | --- | --- | --- |
|
||
| `input/` (input, clear_button) | 6,573 | Replace; keep `ui::input::{Input, InputEvent, InputState}` as the import path. 321 lines remain, and the engine paints itself through `InputEditorStyle` | `InputState`/`TextareaState` (`InputBaseState` in two modes) plus the `InputBase` frame |
|
||
| `list/` | 1,477 | Delete | GPUI's own `list` (already in use) |
|
||
| `checkbox.rs` | 312 | Delete | `Checkbox` |
|
||
| `scroll/` (scrollbar, scrollable, scrollable_mask) | 1,332 | Replace; keep the `ScrollableElement` and `Scrollbar` names | `Scrollbar`, `ScrollableMask` |
|
||
| `resizable/` | 927 | Replace; base exports the same names (`h_resizable`, `v_resizable`, `resizable_panel`, `PANEL_MIN_SIZE`, `resize_handle`) | `Resizable` + `ResizeHandleRenderer` for the coop hairline |
|
||
| `modal.rs` | 540 → 500 | Port onto base parts; `Modal`, `ModalButtonProps`, and `window.open_modal` unchanged. `Root` still owns the stack | `Dialog` — focus trap, Escape/Enter/backdrop dispatch, layer priority, deferred host |
|
||
| `notification.rs` | 584 → 663 | Port; `Notification`, `NotificationKind`, and `window.push_notification` unchanged | `ToastManager` (storage, ids, timers, exit), `ToastStack` (geometry, motion), `Toast` (`Role::Alert`) |
|
||
| `popover.rs` | 432 → 234 | Coop's builder over base's element; `PopoverState` is base's, re-exported | `Popover`, `Popup`, `Positioner` |
|
||
| `tooltip.rs` | 36 → 37 | Coop's view rooted at base's element | `Tooltip` (`Role::Tooltip`) |
|
||
| `button.rs` | 626 | Skin: base behavior plus coop's existing variant tables | `Button`, `StateStyle` |
|
||
| `switch.rs` | 287 | Skin | `Switch`, `SwitchTrack`, `SwitchThumb` |
|
||
| `avatar.rs` | 141 | Skin | `Avatar`, `AvatarImage`, `AvatarFallback` |
|
||
| `history.rs` | 184 | Delete. Base's input keeps its own `UndoManager`, and `UndoHistory` is a separate public utility the input never touches, so nothing has to be re-based. Its only consumer was `input/state.rs` | — |
|
||
| `index_path.rs`, `element_ext.rs`, `event.rs`, `focusable.rs` | 156 | Delete | `IndexPath`, `ElementExt`, `InteractiveElementExt`. `FocusableCycle` has no counterpart — base's `FocusableExt` is a different concept (whether a component draws a focus ring) — so it is dropped rather than re-based |
|
||
| `styled.rs`, `actions.rs`, `animation.rs` | 305 | Keep `ui::StyledExt`, `Size`, and `Sizable` as the app's import. `Selectable`, `Disableable`, and `Collapsible` now come from `gpui_base::component_traits`; the local three-line `h_flex`/`v_flex` wrappers stay rather than delegating to base's identical ones | `styled`, `StateStyle` |
|
||
| `icon.rs`, `kbd.rs`, `divider.rs`, `skeleton.rs`, `group_box.rs`, `indicator.rs` | 1,023 | Keep; no base equivalent, these are the design system | — |
|
||
| `menu/` | 2,208 | Keep; base has no menu. Optional later: re-base anchoring and dismissal on `Popup`/`Positioner` | `Popup` (optional) |
|
||
| `dock/` + `tab/` | 3,356 | Keep for now; see phase 5 | base dock (different contract) |
|
||
| `root.rs`, `window_ext.rs`, `title_bar.rs` | 965 | Keep; app shell. `Root` continues to host the dialog and toast layers. Its `focused_input` field and the two `WindowExtension` methods that read it are gone — the only thing that ever set them was the deleted input paint hook, and no crate consumed them | — |
|
||
|
||
Roughly 10k lines are removed, 3k are re-expressed as thin skins, and 8k are kept.
|
||
|
||
## Dependency change
|
||
|
||
The workspace manifest's GPUI entries become:
|
||
|
||
```toml
|
||
[workspace.dependencies]
|
||
gpui = { package = "gpui-pre", version = "0.3.5" }
|
||
gpui_platform = { package = "gpui-pre-platform", version = "0.3.5", features = ["font-kit", "x11", "wayland"] }
|
||
gpui_linux = { package = "gpui-pre-linux", version = "0.3.5" }
|
||
gpui_windows = { package = "gpui-pre-windows", version = "0.3.5" }
|
||
gpui_macos = { package = "gpui-pre-macos", version = "0.3.5" }
|
||
gpui_web = { package = "gpui-pre-web", version = "0.3.5" }
|
||
gpui_util = { package = "gpui-pre-util", version = "0.3.5" }
|
||
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.5" }
|
||
sum_tree = { package = "gpui-pre-sum-tree", version = "0.3.5" }
|
||
gpui_tokio = { path = "crates/gpui_tokio" }
|
||
gpui-base = "0.6.1"
|
||
```
|
||
|
||
Because of the `package =` alias, `use gpui::…` and `use gpui_platform::…` keep
|
||
compiling unchanged. The aliases match the ones `gpui-pre` uses internally, and
|
||
`gpui_web` moved out of `web/Cargo.toml` into this table with the rest.
|
||
|
||
The only alternative — leaving the workspace on zed's git `gpui` and redirecting
|
||
`gpui-base`'s dependency to it — means vendoring `gpui-base` and owning its source.
|
||
That is a fork, and this plan deliberately avoids it.
|
||
|
||
`gpui_tokio` is the one crate in the family longbridge does not republish. `crates/state`
|
||
uses it to run `browser-signer-proxy` and `nostr-blossom` work, and the nostr client's
|
||
reqwest backend needs a Tokio reactor, so the runtime cannot be dropped for
|
||
`cx.background_spawn`. It is vendored verbatim from zed at `4b47ceb` into
|
||
`crates/gpui_tokio` (Apache-2.0, ~100 lines), which is the smallest change that keeps
|
||
the existing behaviour.
|
||
|
||
`gpui-base` and `gpui-pre` move together on minor versions (`0.6.x` requires `0.3.x`);
|
||
bump both in the same change.
|
||
|
||
## Phases
|
||
|
||
### Phase 0 — move `gpui` onto the `gpui-pre` package (manifest only) — landed
|
||
|
||
Point the workspace's GPUI entries at the published `gpui-pre` crates. There is no GPUI
|
||
source to align, patch, or vendor.
|
||
|
||
**No drift had to be fixed.** The gap between the snapshot (`zed@d89e9c2`) and the old
|
||
pin (`4b47ceb`) is 67 commits, but only 16 touch the GPUI crates, and the public surface
|
||
only gained names: `ShapedLineCursor`, `MissingGlyphSink`, `MissingGlyph`,
|
||
`FallbackFontClass`, `MEASUREMENT_VERSION`, dynamic font installation, and inspector
|
||
registration. Nothing coop used was removed or changed shape, so every `use gpui::…`
|
||
compiled unchanged. The three entry points coop calls —
|
||
`gpui_platform::application()`, `gpui_platform::web_init()`, and
|
||
`gpui_platform::single_threaded_web()` — all exist in 0.3.5.
|
||
|
||
Exit criteria: `cargo check` passes for `desktop`, and the wasm criterion is blocked by
|
||
a pre-existing bug unrelated to GPUI — see
|
||
[A pre-existing wasm blocker](#a-pre-existing-wasm-blocker). `cargo check -p theme -p ui
|
||
--target wasm32-unknown-unknown`, which covers everything this migration touches,
|
||
passes. The change rewrites the dependency graph, so it stays in a pull request of its
|
||
own.
|
||
|
||
### Phase 1 — Wire base, delete dead weight (no visual change) — landed
|
||
|
||
What changed:
|
||
|
||
- `crates/ui` and `crates/theme` take `gpui-base`.
|
||
- `ui::init` calls `gpui_base::init(cx)` then `theme::sync_base(cx)`; the `list::init(cx)`
|
||
call went with `list/`.
|
||
- `ui`'s crate root re-exports `ElementExt`, `IndexPath`, and `InteractiveElementExt`
|
||
from `gpui_base`, so existing `use ui::{…}` sites are unchanged. In particular
|
||
`chat_ui`'s `.on_double_click(…)` is served by base's `InteractiveElementExt`, which
|
||
is the same implementation as the fork's.
|
||
- `ui::styled` no longer defines `Selectable`, `Disableable`, or `Collapsible`; it
|
||
re-exports them from `gpui_base::component_traits`. All three are signature-identical
|
||
to the fork's, so the `impl` blocks in `avatar`, `button`, `input`, and the rest
|
||
compile untouched. The path is `component_traits` rather than the crate root because
|
||
`gpui_base::Collapsible` is base's *component* of that name, not the trait.
|
||
- Deleted: `checkbox.rs` (312), `list/` (1,477), `index_path.rs` (69),
|
||
`element_ext.rs` (27), `event.rs` (21), `focusable.rs` (39) — 1,945 lines, with no
|
||
external consumers and a base counterpart for everything except `FocusableCycle`.
|
||
|
||
Two corrections this phase produced:
|
||
|
||
- **`history.rs` moved to phase 2.** It maps to base's `UndoHistory`, not `History`:
|
||
base's `History` is navigation (back/forward), while `UndoHistory` is the grouped
|
||
undo/redo. Swapping it means editing `input/state.rs` — six `ignore` writes become
|
||
`set_ignoring`, and `Change` loses its `HistoryItem` impl — which is phase 2's file.
|
||
- **No dependency became unused.** `ropey`, `sum_tree`, `lsp-types`, `tree-sitter`,
|
||
`regex`, `unicode-segmentation`, `uuid`, and `instant` are all still used by `input/`
|
||
and `history.rs`, and the deleted files used none of the others, so pruning happens in
|
||
phase 2. Separately, four dependencies — `common`, `anyhow`, `itertools`, and `smol` —
|
||
were already unreferenced anywhere in `crates/ui/src` *before* this change. They are
|
||
left alone here because removing them is unrelated to the migration.
|
||
|
||
Exit criteria: no diff outside `crates/ui` and `crates/theme` — met; the only files
|
||
touched are the two manifests, `ui/src/lib.rs`, `ui/src/styled.rs`, and
|
||
`theme/src/lib.rs`. `cargo check` and `cargo build` both pass with no warnings, and
|
||
`theme` and `ui` still compile for `wasm32-unknown-unknown`. The remaining part of the
|
||
acceptance — launching the app and walking the settings dialog and chat panel — has to
|
||
be done by hand and has not been run.
|
||
|
||
### Phase 2 — `input/` (the largest single win) — landed
|
||
|
||
`crates/ui/src/input` is three files and 321 lines: a rewritten 299-line `input.rs`, a
|
||
7-line `mod.rs` that re-exports base, and the untouched 15-line `clear_button.rs`. Deleted:
|
||
`state.rs`, `element.rs`, `display_map/`, `rope_ext.rs`, `mask_pattern.rs`, `movement.rs`,
|
||
`selection.rs`, `indent.rs`, `mode.rs`, `change.rs`, `cursor.rs`, `blink_cursor.rs`, and
|
||
`history.rs` — 6,715 lines.
|
||
|
||
The names the application imports are unchanged, but two of them are base's now:
|
||
|
||
| Coop before | `ui::input` now |
|
||
| --- | --- |
|
||
| `InputState`, one struct that became multi-line through `auto_grow`/`multi_line` | `InputState` = `InputBaseState<InputMode>` and `TextareaState` = `InputBaseState<TextareaMode>`; multi-line is a property of the state's kind |
|
||
| `Input`, one element that rendered whatever kind of state it was given | `Input` for `InputState` and `Textarea` for `TextareaState` — one generic element, two names |
|
||
| `InputEvent::{Change, PressEnter, Focus, Blur}` | identical |
|
||
| `history::History` and `HistoryItem` | gone; `Change` keeps no trait impl |
|
||
|
||
The styled element is a frame around base's engine rather than the engine itself. Base's
|
||
`InputBaseState::render` registers the key context, the focus handle, every editing
|
||
action, the text element and the editor scrollbar, so the coop element no longer carries
|
||
any of it. What is left is chrome — background, radius, font size, prefix and suffix
|
||
slots, clear button, mask toggle, loading indicator — plus three projections onto the
|
||
state:
|
||
|
||
- `set_editor_style(InputEditorStyle)`, filling `foreground`, `muted_foreground`,
|
||
`selection` and `caret` from `text`, `text_muted`, `selection` and `cursor`. Base
|
||
resolves any color left transparent from its own palette, and that palette is only a
|
||
projection of coop's, so every color coop paints with is named rather than left to
|
||
resolve. The remaining fields stay at base's defaults: coop configures no highlighter,
|
||
no diagnostics and no gutter.
|
||
- `set_editor_paddings(Edges<Pixels>)`, for multi-line only, resolved from the same `Size`
|
||
table the single-line frame applies, through the window's rem size. Base puts that
|
||
padding on the text element itself so the text, the gutter and the scrollbar share one
|
||
inset; putting it on the frame *and* passing it here would double it. Passing the
|
||
frame's own value is also what keeps the scrollbar where the fork drew it.
|
||
- `set_disabled` and `set_text_align`, replacing the fork's direct writes to `state.size`,
|
||
`state.disabled` and `state.text_align`.
|
||
|
||
`history.rs` folded in as predicted, with one correction: it did not need re-basing at
|
||
all. Base's engine owns an `UndoManager`, and `gpui_base::UndoHistory` — the grouped
|
||
undo/redo, not the back/forward `History` — is a separate utility the engine never
|
||
reaches for. Removing `pub mod history` is safe because nothing outside `crates/ui`
|
||
referenced it.
|
||
|
||
The gaps named before the phase started all resolved in base's favor: `clean_on_escape()`
|
||
and `set_loading()` both exist in 0.6.1, and `InputEditorStyle` is the third piece.
|
||
|
||
**Six call sites changed, and none of them is churn:**
|
||
|
||
| Call site | Change | Why |
|
||
| --- | --- | --- |
|
||
| `chat_ui` composer | `InputState` → `TextareaState`, `Input::new` → `Textarea::new` | multi-line is the state's kind, not a layout flag |
|
||
| `workspace`, profile bio | the same, and `.multi_line(true)` is dropped | the same; `auto_grow(3, 8)` is unchanged |
|
||
| `workspace`, sidebar | `set_loading(status, cx)` → `set_loading(status, window, cx)` | base's signature takes the window |
|
||
| `workspace`, sidebar | the `.loading` field read → `.presentation().is_loading()` | `loading` is private; `InputPresentation` is the facade for reading it |
|
||
| `ui::window_ext` | `focused_input` and `has_focused_input` are removed | their only implementation was the deleted paint hook, and no crate consumed them |
|
||
| `ui::init` | `input::init(cx)` is removed | `gpui_base::init` binds the same keys, and its set is a strict superset |
|
||
|
||
Three visible differences survive, all of them base's, none of them a color, radius or
|
||
spacing value:
|
||
|
||
- **The mask character is `•`, not `*`.** Base's `MASK_CHAR` is a private constant, so the
|
||
fork's `*` cannot be restored. It shows only in masked inputs.
|
||
- **The caret is `0.85 × line_height` at every size.** The fork scaled it by `Size` (0.75
|
||
at small, 1.0 at large) from a `size` field base does not have.
|
||
- **Inputs are tab stops.** Base builds the state's focus handle with `tab_stop(true)`;
|
||
the fork's frame was not a tab stop, so Tab skipped text fields and now lands on them.
|
||
|
||
Two things came along with `InputBase` that the fork's plain `div` did not do: the frame
|
||
carries the `TextInput` accessibility role, and a left click anywhere in the frame —
|
||
including the padding outside the text element — focuses the input. The second has to be
|
||
restated on the frame because base handles its own mouse events on the inner element only.
|
||
|
||
Surfaces to re-verify by hand: the chat composer (auto-grow, Enter to send, IME), the
|
||
profile bio, the subject line, the settings dialog, the relay and messaging lists, the
|
||
import/restore/backup dialogs, and the sidebar search field.
|
||
|
||
### Phase 3 — overlays and feedback — landed
|
||
|
||
All four modules keep their names, builders, and call sites. The four files go from
|
||
1,592 lines to 1,434, and no file outside `crates/ui` changed.
|
||
|
||
| `ui` module | What stayed coop's | What is base's now |
|
||
| --- | --- | --- |
|
||
| `tooltip` | the whole look, `Tooltip::new(text, window, cx)` and the `Render` view | the element and `Role::Tooltip` |
|
||
| `popover` | every builder, the content styling, the anchor | open lifecycle, dismissal, focus capture and restore, deferred registration, trigger measurement and anchor math |
|
||
| `modal` | `Modal`, `ModalButtonProps`, `Root`'s stack, `window.open_modal`, the card, buttons, shadows and animations | focus trap, Escape/Enter/backdrop dispatch with a cancel veto, layer priority, the deferred host, `Role::Dialog` |
|
||
| `notification` | `Notification`, `NotificationKind`, `window.push_notification`, the card and the placement from `theme.notification` | id-replacing storage, auto-hide and exit timers, stack geometry and motion, `Role::Alert` |
|
||
|
||
**`tooltip`.** The view and its `new` are unchanged; the styled box inside is
|
||
`gpui_base::Tooltip` instead of a bare `div`. That is what carries the role. Base's
|
||
window-level `TooltipOverlay` is deliberately not adopted — gpui's own `.tooltip()`
|
||
layer already provides the delay and the placement, and taking the overlay would mean
|
||
rewriting every `.tooltip(..)` call site onto `Popup` plus hover state.
|
||
|
||
**`popover`.** `PopoverState` is `gpui_base::PopoverState`, re-exported so
|
||
`ui::popover::PopoverState` still resolves, and the hand-rolled `anchored`/`deferred`
|
||
layer, `resolved_corner` and `render_popover` are gone — base's `Popup` measures the
|
||
trigger, resolves the anchor and snaps to the window edge. The rest of the file is the
|
||
fork's builder, unchanged, including `trigger_style`, which the fork already stored
|
||
without ever reading. Two bindings changed hands: `popover::init` (escape → coop's
|
||
`Cancel` in the `Popover` context) is deleted, because `gpui_base::init` binds
|
||
escape/enter/space in that same context and coop's lone escape binding would have
|
||
shadowed base's `Confirm` — the one that opens a popover from its trigger.
|
||
|
||
**`modal`.** `Modal` still assembles the card, title, close button, footer buttons,
|
||
the two shadows and the `fade-in`/`slide-down` animations; `Root` still owns the stack,
|
||
the focus restore, and the one-visible-overlay rule, now expressed as base's
|
||
`layer(index, topmost)`. What changed underneath:
|
||
|
||
- Escape, Enter and the backdrop now run through base's `Dialog` decisions, so
|
||
`on_cancel`/`on_ok` returning `false` vetoes all three. The fork honored the veto on
|
||
the buttons and the backdrop but ignored it on Escape.
|
||
- Enter on a modal that has a footer but no `on_ok` now calls `on_close` before closing;
|
||
the fork closed silently. No caller combines the two, and `on_close` defaults to a
|
||
no-op.
|
||
- Tab is trapped inside the modal, and the dialog surface carries `Role::Dialog`.
|
||
- `modal::init` (escape/enter in the `Modal` context) is deleted; base binds them in
|
||
its own `Dialog` context, which the `Dialog` host installs when `keyboard` is on.
|
||
- The dim does not move: coop's backdrop element keeps the `window_paddings` inset and
|
||
the `view_size` that the fork used. Its hit area does move — base's host covers the
|
||
whole viewport, so a click in the client-side-decoration shadow band now dismisses
|
||
the modal instead of starting a window resize.
|
||
|
||
`AlertDialog` turned out to be unnecessary. Coop's `alert()` and `confirm()` select a
|
||
button set, not an ARIA role, and they already opt out of backdrop dismissal, which is
|
||
the whole of what `AlertDialog` adds over `Dialog`.
|
||
|
||
**`notification`.** `Notification` keeps its builder and its card. `closing: bool`
|
||
becomes base's `ToastTransitionStatus`, `dismiss` now emits a `DismissRequest` the list
|
||
turns into a `ToastManager::dismiss`, and the exit delay is base's 200 ms rather than
|
||
the fork's fixed 150 ms. `NotificationList` holds
|
||
`ToastManager<NotificationId, Entity<Notification>>` plus one `ToastStackState`; its
|
||
`expanded` field and hover handler are gone, and a 50 ms lifecycle tick runs only while
|
||
something is mounted. The stack is base's:
|
||
|
||
- It collapses to three layers with a 14 px peek and a 5% width step per layer, expands
|
||
on hover or focus, and pauses auto-hide while expanded.
|
||
- The newest notification sits nearest the window edge; the fork's list grew downwards
|
||
with the oldest first.
|
||
- Motion is `ToastMotion::default()`, base's shadcn/Sonner figures. Coop contributes the
|
||
width the fork's card had, the placement and the margins from `theme.notification`.
|
||
|
||
That stack is the one visible change of the phase, and it is the one to judge by hand.
|
||
If it is not wanted, the smaller step is to keep the list's own `v_flex` and use only
|
||
`ToastManager` together with `Toast` — base separates the lifecycle from the geometry,
|
||
so nothing else has to come back.
|
||
|
||
Surfaces to re-verify by hand: the settings dialog (its Escape and Enter paths), the
|
||
import, restore and screening modals (a modal with a textarea, and one with
|
||
`keyboard(false)`), the dropdown menus that ride the popover, and every
|
||
`push_notification` site — sending an empty message, a failed upload with its retry
|
||
action, and the device-approval notification that never auto-hides.
|
||
|
||
### Phase 4 — leaf controls, scroll, and resizable (one module per pull request)
|
||
|
||
Order: `avatar`, `switch`, `button`, `scroll/`, `resizable/`. `button` is the
|
||
largest skin: the `ButtonVariants` and `ButtonCustomVariant` tables, the `compact`,
|
||
`loading`, and `caret` builders, and the variant names stay as they are, with styling
|
||
supplied through base's semantic-state styles. `scroll/` keeps the `ScrollableElement`
|
||
trait name so `.vertical_scrollbar(..)` call sites keep compiling, and `resizable/`
|
||
becomes a thin re-export of base's identically named API plus a `ResizeHandleRenderer`
|
||
for the coop hairline.
|
||
|
||
Each of these is independently shippable. Acceptance for each: no change outside
|
||
`crates/ui`, and the surfaces that use the module are pixel-identical before and after.
|
||
|
||
### Phase 5 — dock, tab, and menu (deliberately later)
|
||
|
||
Base has a full dock, but its contract is "layout is data, and the application
|
||
implements the renderer traits", while coop's `Panel`/`PanelView`/`DockArea`/`DockItem`
|
||
is an app-specific shell already consumed by `crates/workspace` and `crates/chat_ui`.
|
||
Moving it is a project of its own, and it would also retire `tab/` and touch `menu/`.
|
||
Keep them local until phases 1–4 have landed, then plan dock separately. Re-basing
|
||
menu positioning and dismissal on base `Popup`/`Positioner` is optional and later still.
|
||
|
||
## Verification
|
||
|
||
There is no UI test suite to lean on, so each phase gets the same treatment:
|
||
|
||
- `cargo check --workspace` and `cargo build` (default members build `desktop`).
|
||
`cargo build --workspace` cannot link the web crate's host dylib: `coop_web` is
|
||
`crate-type = ["cdylib", "rlib"]` and depends on `wasm-bindgen`, `web-sys`,
|
||
`console_log` and `tracing-wasm` unconditionally, so its dylib is a wasm artifact.
|
||
That is a property of the manifest rather than of any migrated crate —
|
||
`cargo check -p coop_web` passes, and the desktop binary links the same crates.
|
||
- `cargo check -p theme -p ui --target wasm32-unknown-unknown`. The web target cannot
|
||
be checked end to end until the pre-existing blocker below is fixed, so the migrated
|
||
crates are checked directly.
|
||
- Launch the app and walk the surfaces the phase touched. The settings dialog is the
|
||
densest single smoke surface (Button, GroupBox, Switch, Input, DropdownMenu,
|
||
PopupMenuItem), followed by the chat panel and the sidebar.
|
||
- For phase 4, record before/after screenshots per module.
|
||
- Keep the call-site diff at zero where the phase claims it — phases 1 and 3–4 do; if a
|
||
call site has to change because base has no equivalent, list it in the pull request.
|
||
Phase 2 needed six, tabulated above, and the list is the record of what "no equivalent"
|
||
turned out to mean in practice.
|
||
|
||
### A pre-existing wasm blocker
|
||
|
||
`cargo check -p coop_web --target wasm32-unknown-unknown` fails while compiling
|
||
`errno 0.3.14`, which refuses `wasm32-unknown-unknown`. The path is
|
||
`coop_web → workspace → browser-signer-proxy → smol → async-io → rustix → errno`, none
|
||
of which involves GPUI. `crates/workspace/Cargo.toml` declares `browser-signer-proxy`,
|
||
but nothing under `crates/workspace/src` references it; the crate is only used by
|
||
`crates/state`, where it is already gated `#[cfg(not(target_arch = "wasm32"))]`.
|
||
|
||
Every version on that path (`errno 0.3.14`, `rustix 1.1.5`, `async-io 2.6.0`,
|
||
`smol 2.0.2`) is identical before and after phase 0, and no file on it is part of this
|
||
work, so the web build was already broken. The remedy is deleting that one stale
|
||
dependency line, but that is unrelated to the migration and is deliberately left out.
|
||
Until it is done, read the wasm exit criterion for phases 1-4 as "`theme` and `ui`
|
||
compile for `wasm32-unknown-unknown`".
|
||
|
||
## Risks and non-goals
|
||
|
||
- **Snapshot lag.** The `gpui-pre` package is a republished snapshot, so it trails zed
|
||
`main` by however long it takes longbridge to cut the next release (a few days). A new
|
||
GPUI API is therefore unavailable until then. That is the price of not maintaining a
|
||
fork; the escape hatch — vendoring `gpui-base` and patching it onto zed's git
|
||
repository — should stay unused.
|
||
- **The `gpui` dependency line is load-bearing.** Depending on zed's git `gpui`
|
||
alongside `gpui-base` looks harmless and is not: it puts two GPUI crates in the graph
|
||
and every window, context, and element crossing between them becomes a type error.
|
||
- **Two `Theme` globals.** Confine `gpui_base::Theme` to `theme::sync_base` and
|
||
`crates/ui` internals; application code keeps using `theme::ActiveTheme`. Avoid
|
||
importing both `Theme` types into one file.
|
||
- **`gpui_tokio` is vendored, not ours.** `crates/gpui_tokio` is zed's crate kept
|
||
verbatim at `crates/gpui_tokio/src/lib.rs` because the `gpui-pre` family does not
|
||
publish it and the nostr client needs a Tokio reactor. Re-sync or delete it if
|
||
longbridge ever ships an equivalent.
|
||
- **Non-goals:** adopting `gpui-component`, migrating dock/tab/menu, rewriting the
|
||
self-contained pieces (`icon`, `kbd`, `divider`, `skeleton`, `group_box`,
|
||
`indicator`), and changing any color, radius, or spacing value.
|
||
|
||
## Pull request sequence
|
||
|
||
| PR | Content | Touches outside `crates/ui` | Status |
|
||
| --- | --- | --- | --- |
|
||
| 1 | Phase 0: `gpui` moves to the `gpui-pre` package, `gpui_tokio` vendored | root `Cargo.toml`, `Cargo.lock`, `web/Cargo.toml`, new `crates/gpui_tokio`; `crates/state` needed no edit | landed |
|
||
| 2 | Phase 1: base wiring, `sync_base`, deletions | `crates/theme` | landed |
|
||
| 3 | Phase 2: input, plus `history.rs` and the `ropey`/`sum_tree`/`lsp-types`/`regex`/`unicode-segmentation`/`tree-sitter` pruning | `crates/workspace`, `crates/chat_ui` (six call sites); no manifest outside `crates/ui` | landed |
|
||
| 4 | Phase 3: popover, modal, notification, tooltip | none | landed |
|
||
| 5–10 | Phase 4: one leaf module each | none | not started |
|
||
| later | Phase 5: dock, as its own plan | `crates/workspace`, `crates/chat_ui` | not started |
|
||
|
||
The end state: the application keeps its design system and its call sites, `crates/ui`
|
||
shrinks by roughly half, and the parts that are genuinely hard — text editing,
|
||
focus and IME, drag-resize arithmetic, overlay lifecycle, accessibility semantics — are
|
||
maintained upstream instead of in a fork.
|