GPUI / Zed (Rust, GPU-rendered desktop)
GPUI solves the whole overlay problem — menus, submenus, popovers, hover cards, tooltips and modals — inside a single GPU surface with no top layer, no OS popup and no pointer grab: "on top" is a flat per-frame vector of deferred paint records, stable-sorted by an integer priority.
| Field | Value |
|---|---|
| Language | Rust |
| License | Apache-2.0 (crates/gpui); GPL-3.0 for the Zed application crates |
| Repository | zed-industries/zed |
| Documentation | crates/gpui/README.md plus rustdoc on the source; no separate overlay guide exists |
| Category | Native desktop (Rust GPU, in-canvas) |
| Surface model | in-canvas — one OS window, one GPU surface; no OS popup window appears anywhere in the overlay path |
| Revision read | d71f1461045c098dc6ca6b1b5adcf1b8949722e8 (Zed 1.16.0, crates/zed/Cargo.toml:5) |
NOTE
This is an implementation reading, not a docs reading. Every claim below comes from the source at the pinned SHA. Zed was not built or run, and no behaviour was observed at runtime; the regression tests cited are read, not executed.
Overview
What it solves
An editor needs several overlay families at once: a completion menu anchored to the caret, LSP hover popovers anchored to a text range, a code-action documentation aside beside the menu, right-click context menus with submenus, toolbar popover menus, and tooltips. GPUI supplies three shared mechanisms for all of them — a deferred-paint ordering primitive, a small Anchored placement element, and a dismissal protocol — and leaves every richer geometry policy to the call site. The framework never opens an OS window for an overlay, never asks a compositor to place one, and never takes a pointer grab.
Design philosophy
The kernel owns ordering, clipping escape, hit-test order and lifetime; components own geometry policy. The clearest statement is the doc comment on the deferred element, which splits layout from paint (crates/gpui/src/elements/deferred.rs:14-15):
/// An element which delays the painting of its child until after all of /// its ancestors, while keeping its layout as part of the current element tree.
Keeping layout in the tree is load-bearing: the overlay inherits the parent's Taffy context, text style, rem size and element-id namespace, and the parent can measure it before deciding where to put it.
The second philosophical commitment is that overlay lifetime is a message, not a call (crates/gpui/src/window.rs:691, quoted verbatim):
pub trait ManagedView: Focusable + EventEmitter<DismissEvent> + Render {}An overlay body is anything focusable, renderable and able to emit one unit event. The overlay never removes itself; the host subscribes to DismissEvent and drops its handle.
The third is that even a sub-pixel-capable GPU backend snaps the final anchored offset to whole device-independent pixels (crates/gpui/src/elements/anchored.rs:207-208):
let offset = desired.origin - bounds.origin; let offset = point(offset.x.round(), offset.y.round());
How it works
One frame is request_layout (Taffy) → prepaint → paint, and an overlay is a subtree whose paint is relocated out of the normal recursion. Window::defer_draw (window.rs:4012) appends a DeferredDraw record (window.rs:930) to a flat per-frame vector, capturing the ambient context the subtree will need when it is re-run out of place: the element-id stack, the text-style stack, the dispatch-tree parent node, the rem size, an absolute offset, an optional content mask, the current view, and the usize priority.
Window::draw
root.request_layout() Taffy lays out everything, overlays included
root.prepaint_as_root() pushes hitboxes + DeferredDraw records
prepaint_deferred_draws() rounds; each round stable-sorted by priority
prepaint prompt | active drag | tooltip exactly one of the three (else-if chain)
mouse_hit_test = hit_test(mouse_position)
root.paint()
paint_deferred_draws() ALL indices stable-sorted by priority, globally
paint prompt | active drag | tooltipNesting is handled by rounds rather than by a tree. prepaint_deferred_draws (window.rs:3360) snapshots the vector length, sorts that slice by priority (window.rs:3384), prepaints each entry, and lets entries appended during the round form the next round, guarded by assert!(depth < 10, "Exceeded maximum (10) deferred depth"). The draws are processed in place — a hard-won invariant recorded in the source:
// The draws are processed in place rather than being moved out of //
next_frame.deferred_draws:prepaint_indexsnapshots that vector's // length, so any prepaint range recorded during a round (view caches, // nested deferred draws) must index the same vectorreuse_prepaint// slices on the next frame.
Because hitboxes and mouse listeners are appended to flat per-frame vectors in prepaint/paint order, "later" mechanically means "in front": Frame::hit_test (window.rs:1059) walks hitboxes in reverse and intersects hitbox.bounds ∩ hitbox.content_mask.bounds.
Placement is a separate, much smaller concern. Anchored (anchored.rs:122) measures the union of its children's laid-out bounds, computes a rect from an Anchor corner plus a point, tries one axis-wise corner flip, unconditionally clamps to the viewport, and rounds the offset. Everything richer — text-range anchoring, stacked popovers, gap bridging, submenu safe zones, aside placement — is hand-written in crates/editor and crates/ui.
The analysis spine
1. Anchor model
Two anchor representations coexist. The framework's is (Point, Anchor): Anchored stores which corner or edge-centre of the popup is pinned (Anchor, geometry.rs:2165 — a Copy + Eq enum of four corners and four edge centres), an optional anchor_position: Option<Point<Pixels>>, and a position_mode selecting window-absolute or parent-local coordinates (anchored.rs:254). With no explicit position the pin point is the anchored element's own laid-out bounds.origin — its slot in the parent flow. There is no anchor rect in the framework at all.
Call sites rebuild a rect anchor themselves. PopoverMenu records the trigger's laid-out Bounds during prepaint and, on the next frame's request_layout, converts it with child_bounds.corner(self.resolved_attach()) (popover_menu.rs:382) — an attach corner on the trigger plus an anchor corner on the popup, which is the classic side/align pair encoded as two corners. RightClickMenu uses a raw cursor Point captured at mouse-down (a virtual anchor in all but name).
Text-range anchoring belongs entirely to the editor. InfoPopover.symbol_range is a RangeInEditor over document Anchors that survives edits and scrolling, resolved to pixels only at layout time (element.rs:4377). Multi-row ranges are collapsed to one point: HoverState::render (hover_popover.rs:947) picks the diagnostic range start, else the first text range start, else the inlay position, and then walks the anchor's row into the visible range with movement::up_by_rows / down_by_rows (hover_popover.rs:984-994), giving up only with log::error!("Hover popover point out of bounds after moving") at :1006.
The sharpest statement that an anchor is a policy choice rather than a coordinate is MenuPosition (mouse_context_menu.rs:21), a two-variant sum with the semantics spelled out in its own doc comments: PinnedToScreen(Point) stays put and never disappears; PinnedToEditor { source, offset } follows the text and disappears when the row scrolls out.
Algorithm. resolve_anchor(): take the explicit position if given, else the element's own laid-out origin; in local mode add the parent origin; add offset; then Bounds::from_anchor_and_size(anchor, point, measured_size) (geometry.rs:837) subtracts width for right-anchors, half width for centre-anchors, height for bottom-anchors. Editor path: document Anchor → DisplayPoint → row clamped into the visible range → x_for_index(column) - scroll.x, row * line_height - scroll.y, plus content_origin.
Where it lives. Framework: anchored.rs and geometry.rs. Rect reconstruction: crates/ui/src/components/{popover_menu.rs,right_click_menu.rs}. Text-range anchoring: crates/editor/src/{hover_popover.rs,element.rs}.
Degradation. No OS window: nothing is lost — every coordinate in the overlay path is window-local and no screen conversion exists anywhere (multi-monitor is simply not modelled). No sub-cell precision: the model is unchanged; Anchor is an eight-value enum and the resulting offset is rounded to integers regardless (anchored.rs:208). The one casualty is centre anchors on odd extents, where Size::half() must pick a side — a cell port needs a documented floor/ceil rule. No hover: untouched; a click or caret position produces the same point. No script: the pair is emit-time computable only if the popup's size is known at emit time, and GPUI obtains that from a real measure pass.
2. Placement model
The framework model is deliberately impoverished: sides are not named at all. There is no side or placement enum, no preferred list, no fallback ordering, no custom clipping boundary — the boundary is always Bounds { origin: (0,0), size: window.viewport_size() } (anchored.rs:150-153) — no work area, no multi-monitor, no RTL or writing-mode plumbing, and no IME/virtual-keyboard avoidance (the IME position is reported outward by update_ime_position, never consumed as a placement input).
What exists is three fit modes (AnchoredFitMode, anchored.rs:243): SwitchAnchor (the default) tries a corner flip; SnapToWindow and SnapToWindowWithMargin(Edges) skip the flip. The clamp at anchored.rs:189-205 then runs unconditionally for every fit mode, so flip and shift are not alternatives but a pipeline: flip if it fully fits, then always clamp. Margins are additively combined with window.client_inset (the Linux CSD shadow inset) at anchored.rs:181-187 — the framework's single safe-area concept, and structurally the right hook for any other inset.
Note what the flip actually is: it recomputes the rect from the mirrored corner about the same pin point (Anchor::other_side_along, geometry.rs:2217, feeding Bounds::from_anchor_and_size, geometry.rs:837, called from anchored.rs:158-176). With no anchor rect in the model there is no anchor edge to mirror and no gravity to mirror with it, so the flip is a reflection of the placed region about a point.
Richer placement lives in the editor. layout_popovers_above_or_below_line (element.rs:4069) is a real preferred/fallback ladder:
bottom_y_when_flipped = target.y - line_height
available_above = bottom_y_when_flipped - text_hitbox.top()
available_below = text_hitbox.bottom() - target.y
y_flipped = placement_override ?? (max_height > available_below
&& available_above > available_below)
height = min(max_height, chosen side's available space)
if height < min_height: # re-evaluate against the VIEWPORT
prefer below if it fits min_height, else above, else the larger side,
with height clamped to that side's space
x = min(target.x, max(viewport.right - max_width, 0)) # right edge onlyTwo details are worth naming. The flip decision uses max_height — a caller-supplied bound consulted before make_sized_popovers runs — not a measured height, so the side is chosen frame-invariantly. And the horizontal adjustment is one-sided: it snaps the right edge in and never the left, with an explicit admission at element.rs:4143 (// TODO: Use viewport_bounds.width as a max width so that it doesn't get clipped on the left).
Algorithm. Anchored::prepaint: size = union(children_bounds).size; (origin, desired) = position_mode.get_position_and_bounds(...); limits = (0, 0, viewport). Under SwitchAnchor: if desired overflows on X, build the horizontally mirrored placement and accept it only if it fully fits on X (updating the working anchor); repeat on Y with the possibly-updated anchor. Then, always: if desired.right > limits.right subtract the overflow plus the right margin; if desired.left < limits.left hard-set the left edge to limits.left + edges.left; same for the vertical axis. edges = (margin or zero) + client_inset. Finally offset = round(desired.origin - bounds.origin).
Where it lives. anchored.rs:150-215; corner algebra in geometry.rs; the editor ladders at element.rs:4069 (above/below the line) and element.rs:4195 (the aside).
Degradation. No OS window: nothing is lost; the boundary is already a plain rect and the whole pipeline is arithmetic. No sub-cell precision: only the centre-anchor rounding rule needs deciding. No hover: unaffected. No script: the flip is undecidable at emit time because it needs a measured popup size — the honest static answer is to emit the preferred side only, or to pre-measure (which a cell grid can do and a browser cannot). Android's soft-keyboard inset has no analogue here; client_inset is the shape such an inset would take, folded into the clamp margin as an input.
3. Collision & geometry engine
Overflow detection is pure rect arithmetic against one boundary. There is no clipping-ancestor discovery, no observers, no polling — and no constraint adjustment machinery beyond the flip-then-clamp above.
Escaping scroll containers is structural rather than API-level. defer_draw records an absolute offset, deferred prepaint runs under with_absolute_element_offset, and — the load-bearing detail — prepaint_deferred_draws re-establishes no content mask, so the mask stack is empty and Window::content_mask() falls back to the full viewport (window.rs:3811-3822). Every hitbox inserted during a deferred prepaint therefore carries a viewport-sized mask (insert_hitbox, window.rs:4820), and since hit_test intersects bounds with that mask, overlays remain hit-testable outside their scrolling ancestor. test_anchored_position_when_scrolled (anchored.rs:349) scrolls a container by 1000px and asserts the menu's bounds are unchanged.
Paint-time clipping is opt-in via defer_draw(..., Some(mask)), applied in paint_deferred_draws (window.rs:3430). Exactly one call site in the tree uses it — edit_prediction.rs:2128, to keep the edit-prediction popover inside the editor's text bounds — which makes the default ("escape everything") explicit by exception.
IMPORTANT
Window::defer_draw's doc comment says the supplied content mask clips during both prepaint and paint, but the mask is only observed being applied in paint_deferred_draws; no corresponding application was found in prepaint_deferred_draws (window.rs:3360-3425). Whether the doc is stale or the prepaint application lives elsewhere was not resolved.
Cost is O(n log n) for the two priority sorts (into SmallVec<[usize; 8]>) plus O(hitboxes) per hit test, per frame, unconditionally — read from the code, not measured. There is no transform stack in the overlay path and scale_factor enters only when converting Taffy output and snapping to the pixel grid.
Algorithm. Per frame: Taffy lays out the whole tree including anchored subtrees; root prepaint pushes hitboxes with the ambient mask and pushes DeferredDraw records; rounds of deferred prepaint run with an empty mask stack and an absolute offset, each recomputing flip and clamp from the current viewport and the current measured child size; the hit test runs once after all prepaint; paint runs in priority order. No caching, no invalidation, no observers.
Degradation. This dimension generalises best. No OS window: unchanged — the viewport is a Size. No sub-cell precision: unchanged in kind, since GPUI already rounds. No script: recompute-every-frame has no static analogue; a scriptless target must accept a single emit-time placement and no collision response. The transferable discipline is the mask rule: an overlay's hit rects must be built with the root clip, not the clip in force where the overlay was declared.
4. Arrow / caret geometry
Not applicable, and the absence is the finding. Grepping the overlay surface (anchored.rs, deferred.rs, popover.rs, popover_menu.rs, tooltip.rs, context_menu.rs, hover_popover.rs) yields no arrow, caret, beak, tail or pointer triangle, and no transform origin concept exists anywhere in GPUI. Zed's popovers are rounded rectangles with an elevation_2 shadow token and nothing pointing at the anchor. The only directional glyph near a menu is IconName::ChevronRight used as a submenu affordance inside a row — semantics, not geometry.
Consequently no arrow size feeds the offset. The trigger-to-popup gap is a scalar, signed by anchor corner: PopoverMenu::resolved_offset (popover_menu.rs:259-271) uses rems_from_px(5.0) * rem_size with the comment // Default offset = 4px padding + 1px border and flips its sign for right-anchors versus left-anchors, zero for centre anchors. The editor uses HOVER_POPOVER_GAP = px(10.) (hover_popover.rs:39) and MENU_GAP = px(4.) (code_context_menus.rs:48). The tooltip's gap is cruder still: tooltip_container adds pl_2().pt_2p5() inside the tooltip element, commented // padding to avoid tooltip appearing right below the mouse cursor (tooltip.rs:223) — the offset is baked into the content's box model rather than the placement.
Where it lives. Nowhere as geometry; only the gap scalars named above, plus POPOVER_Y_PADDING = px(8.) (popover.rs:9).
Degradation. Trivially, because it does not exist. The evidence a cell-grid design can take from this is negative but useful: a production editor ships zero arrows. If arrows are wanted, the cheapest honest representation on a grid is (side, index along that side) — two integers — attached to the resolved placement as presentation, never fed back into the offset as an input.
5. Trigger semantics
Triggers are per-component and never unified.
- Tooltip — hover only, wired by
register_tooltip_mouse_handlers(div.rs:3573), which registers three window listeners:MouseMove(both phases),MouseDown,ScrollWheel. No focus trigger, no touch trigger, no long-press. - Popover menu — click on the trigger, plus a programmatic
PopoverMenuHandle::{show, hide, toggle}(popover_menu.rs:41-126) reachable from keybindings. - Right-click menu — a bubble-phase
MouseDownEventlistener filtered toMouseButton::Righton the hovered hitbox, which then calls bothcx.stop_propagation()andwindow.prevent_default()(right_click_menu.rs:245-252). - Editor hover — mouse-move over text, or the bindable
Hoveraction viashow_keyboard_hover(hover_popover.rs:98), a genuine keyboard trigger for the same surface. - Submenu — hover, click and Enter all funnel through
open_submenu(ix, builder, reason: SubmenuOpenTrigger::{Pointer, Keyboard})(context_menu.rs:1358).
Race avoidance is explicit and multi-mechanism: open_submenu is idempotent per item index; a keyboard-opened submenu sets ignore_blur_until = now + 150ms so the parent's blur handler cannot dismiss during the focus handoff (context_menu.rs:1393); a capture-phase mouse-down/up latch (submenu_trigger_mouse_down) stops on_hover(false) closing mid-click; and suppress_focus_selection prevents the focus-in auto-select from fighting the hover highlight (context_menu.rs:236-239).
Pointer-type distinction exists but is coarse and global. InputModality::{Mouse, Keyboard, Touch} is assigned from the last platform event (window.rs:5107-5109), and HitboxId::is_hovered returns false outright while the modality is Keyboard (window.rs:750-757) — so keyboard navigation globally suppresses hover styling and hover tooltips. Touch sets InputModality::Touch, but nothing in the overlay path branches on it, and LongPressEvent (gestures.rs:169, 500 ms with an 8 px slop, gestures.rs:115-118) has zero consumers anywhere in crates/ — there is no touch-to-context-menu path. No overlay registers an on_a11y_action to open itself.
Algorithm. Tooltip trigger: on every MouseMove, in the bubble phase, only when the state is None and the element is hovered, spawn a timer task. Concurrency is contained by computing an Action enum from an immutable borrow before mutating — // Separates logic for what mutation should occur from applying it, to avoid overlapping RefCell borrows (div.rs:3642-3643).
Degradation. No key release: every overlay-opening trigger read here fires on a press, a move or an action; the release-driven path in the API is on_mouse_up_out (div.rs:279), used for dismissal. No hover (Android): the tooltip becomes unreachable — hover is its only trigger — and submenu opening loses its primary path, while click and the keyboard Hover action survive. GPUI itself offers no substitute, so a port must invent one; the long-press event exists and is unused. No script: click can be faked with :checked or a disclosure element and hover with :hover, but none of the idempotence, grace windows or latches has a scriptless representation — the honest degradation is one trigger per surface and no races because there is no state.
6. Timing
Two independent timing systems.
GPUI tooltips. DEFAULT_TOOLTIP_SHOW_DELAY = 500ms, overridable per element via tooltip_show_delay; HOVERABLE_TOOLTIP_HIDE_DELAY = 500ms, not overridable (div.rs:49-50). There is no warm-up or cool-down — no "instant subsequent tooltip", no shared provider or group, no singleton registry, no max display duration. The window nonetheless enforces a de-facto singleton at render time: prepaint_tooltip (window.rs:3291) walks tooltip_requests in reverse and returns the first that reports visible, so at most one tooltip is painted and the last-requested wins.
Editor hover. hover_popover_delay: 300, hover_popover_hiding_delay: 300, hover_popover_sticky: true (assets/settings/default.json:146-153). The show delay is split around the LSP request: wait delay - delay/2, issue the request, then wait the remaining delay/2 (hover_popover.rs:316-335) — a slow server costs nothing extra up to half the intent delay. Re-entry is suppressed by an inclusive range test with its rationale in the source (hover_popover.rs:628-633):
// LSP returns a hover result for the end index of ranges that should be hovered, so we need to
// use an inclusive range here to check if we should dismiss the popover
(hover_range.start..=hover_range.end).contains(&offset)The hide timer is armed, not restarted, while the pointer recedes: // If we are moving away and a timer is already running, just let it count down. (hover_popover.rs:79).
Algorithm. ActiveTooltip (div.rs:3516) has three variants — WaitingForShow, Visible { tooltip, is_hoverable }, WaitingForHide — held in an Rc<RefCell<Option<ActiveTooltip>>>, so None is the fourth (idle) state:
None --(MouseMove bubble && hovered)--> WaitingForShow(timer = show_delay)
WaitingForShow --(MouseMove && !hovered)-------> None (Task is cancel-on-drop)
WaitingForShow --(timer fires)-----------------> Visible (view + mouse position
captured AT FIRE TIME)
Visible(!hoverable) --(not hovered at prepaint)-> None
Visible(hoverable) --(not hovered at prepaint)-> WaitingForHide(timer = 500ms)
WaitingForHide --(hovered again)---------------> Visible
WaitingForHide --(timer fires)-----------------> None
any --(MouseDown | ScrollWheel)-----> None, unless hoverable and hoveredThe structurally decisive choice is that the hide decision is made in window prepaint, not in the mouse handler, because // the mouse move handler won't get called when the element is not painted (e.g. via use of visible_on_hover) (div.rs:3757-3759); the window invokes check_visible_and_update from prepaint_tooltip before prepainting the tooltip.
Degradation. Static HTML: no timers, so the dimension collapses to :hover plus a CSS transition-delay, which can fake the show delay but not the hide delay, the hoverable grace, or re-entry suppression. Recording canvas: every state is assertable because the timers are BackgroundExecutor timers the test dispatcher can advance_clock — Zed's own tooltip tests do exactly this (div.rs:4681, :4718, :4746). No hover: show and hide delays become meaningless; only "build content at fire time, not at arm time" still matters. No key release: unaffected — no timing transition depends on one.
7. Interactive hover (trigger → content travel)
Three different algorithms coexist and none is a safe polygon.
Tooltip travel.
hoverable_tooltipmakes the tooltip itself a hover target;TooltipId::is_hovered(window.rs:908) is a plain containment test against the tooltip's bounds, and the hide is delayed 500 ms. The gap is unguarded, buttooltip_container's internal padding means the tooltip's hit bounds start at the cursor while its visible box is offset — the padding is the bridge.Stacked hover popovers (editor). A real painted rectangle is deferred into every gap between consecutive stacked popovers (
element.rs:4404-4417):rustlet mut occlusion = div() .size_full() .occlude() .on_mouse_move(|_, _, cx| cx.stop_propagation()) .into_any_element(); occlusion.layout_as_root(size(width, HOVER_POPOVER_GAP).into(), window, cx); window.defer_draw(occlusion, origin, 2, None);It is both a hit-test shield (
occlude()setsHitboxBehavior::BlockMouse, so the hit test stops there) and an event sink (the move handler stops the editor beneath from seeing the move and callinghover_at(None), which would start the hide timer). It is installed between every consecutive pair (element.rs:4436,:4458,:4554). This is a pointer bridge expressed as ordinary display-list geometry.Submenu "menu-aim". One scalar plus one inflated rect. On pointer entry to a submenu trigger,
submenu_safety_threshold_x = mouse.x - 50px(context_menu.rs:1665); on further moves over the trigger while the submenu is open,= mouse.x - 100px(:1628). When a submenu item reports hover-lost, the parent closes only if the pointer is left of the threshold (context_menu.rs:2043-2049):rust// Only close if mouse is to the left of the safety threshold // (prevents accidental close when moving diagonally toward submenu) let should_close = parent .submenu_safety_threshold_x .map(|threshold_x| mouse_pos.x < threshold_x) .unwrap_or(true);Independently,
padded_submenu_bounds()(context_menu.rs:1750-1762) inflates the observed bounds cell by 50 px on every side, and both the trigger's hover-lost path andon_mouse_down_out(:2313-2318) consult it.Editor hover recede.
is_mouse_getting_closer(hover_popover.rs:893-929) computes the axis-clamped Euclidean distance from the pointer to the nearest edge of each popover's last painted bounds (0 inside), takes the minimum, and returnsfalse— arming the hide — only when the new distance exceeds a running minimum by more than 4 px (:921). It is a monotone-approach heuristic with a hysteresis band, not a trajectory predictor.
The parent/child hover relationship is an explicit HoverTarget::{None, MainMenu, Submenu} that each side writes into the other entity from its own hover listener.
Degradation. In whole cells the occluder bridge is a one-row by N-column rect appended to the hit list with a "block" flag and no glyphs. The 50 px / 100 px submenu slack and the 4 px recede band are pixel constants that a cell port must restate as cell counts; at typical cell sizes the 4 px band is smaller than one cell, so it cannot be expressed at cell granularity and must be widened or dropped. No hover (Android): all four algorithms are dead code. No key release: unaffected — all four are pointer-only. Static HTML: the occluder bridge is the one technique that survives, because a wrapper spanning the gap is pure CSS; the threshold and the hysteresis are not.
8. Dismissal
There is no unified dismissal service, but there is a unified dismissal protocol: DismissEvent on a ManagedView (window.rs:691-696). The host subscribes and drops its Entity<M> handle; the overlay never removes itself. Observed causes:
| Cause | Mechanism |
|---|---|
| Escape | the menu::Cancel action → ContextMenu::cancel (context_menu.rs:1057), which for a submenu refocuses the parent and grants it a 200 ms blur grace (:1066) |
| Focus out | cx.on_blur → cancel, with two carve-outs: the grace window, and "my open submenu currently holds focus" (context_menu.rs:282-297) |
| Pointer down outside | on_mouse_down_out (div.rs:259-271), a capture-phase listener gated on !window.has_active_prompt() and raw !hitbox.contains(mouse_position) |
| Trigger re-activation | PopoverMenu::paint installs a bubble-phase MouseDown listener over the trigger that emits DismissEvent and stops propagation (popover_menu.rs:483-496) |
| Child closing parent | a submenu's DismissEvent also emits the parent's when the item was clicked, so confirming a leaf collapses the chain |
| Anchor scrolled away | MenuPosition::PinnedToEditor menus stop being laid out when neither the source row nor the position is visible (element/mouse.rs:311-322) — suppression, not dismissal |
| Selection changed | MouseContextMenu subscribes to the editor's own SelectionsChanged { local: true } and removes itself when the range moved |
| Scroll | hide_hover on editor scroll; a tooltip is cleared by any ScrollWheelEvent unless hoverable and hovered |
Not present: nothing dismisses on window or application deactivation (on_active_status_change only refreshes and runs activation observers, and Window::focused does not filter by active state), nothing dismisses on resize, and there is no touch-outside or navigation hook.
The notable composite is Editor::dismiss_menus_and_popups (editor.rs:3349-3382): one Escape runs a fixed ordered cascade of about eleven dismissals OR-combined into a single bool — rename, blame popover, hover, signature help, completion/code-action menu, mouse context menu, edit prediction, snippet stack, diff-review drag, diff-review overlays, active diagnostic group — and returns whether anything closed so propagation stays correct. This is a deliberate departure from "Escape peels the topmost".
WARNING
on_mouse_down_out uses raw hitbox.contains(...) while on_mouse_up_out uses the occlusion-aware hitbox.is_hovered(...) (div.rs:266 vs div.rs:288). The asymmetry is undocumented; the down variant therefore treats a press that landed on something painted in front of the overlay as an outside press.
Algorithm. The host's subscription closure: if the overlay's focus handle contains_focused, restore the previous_focus_handle captured at open time; then *menu.borrow_mut() = None; window.refresh(). Outside-press: capture-phase listener over every mouse-down; guard on no active system prompt; guard on bounds containment; for menus with an open submenu, additionally guard on the inflated submenu rect and on the parent's recorded trigger bounds.
Degradation. No OS window, no grab: this is precisely the situation GPUI is in — every outside-press check is a capture-phase listener over an in-surface rect and needs no grab. But GPUI still receives moves that leave a hitbox, because the OS delivers them to the window; where that is not guaranteed, the hover-lost submenu close and the tooltip's "not hovered" transition can both stall. GPUI's own defence for the analogous case is the prepaint-time check_visible_and_update, which re-derives hover from geometry every frame rather than trusting an event. No key release: every dismissal above is triggered by a press, an action, a focus change or a frame. An Android back key maps naturally onto menu::Cancel. Static HTML: only trigger-toggle survives.
9. Focus
Focus is a single Option<FocusId> per frame plus a dispatch tree. There is no focus trap and no focus scope primitive; overlays create no nested tab scopes, and although a TabStopMap exists per frame, nothing in the overlay path touches it.
The four surface kinds differ cleanly in what they do with focus:
Tooltip — never focusable, no focus handle at all; the tooltip view is rendered from
AnyTooltipoutside the dispatch tree entirely (window.rs:3291).Menu — takes real focus on its container (
track_focus,context_menu.rs:2281-2292) and keeps it there; items are never focused, they are aselected_indexplusaria_active_descendant(:1637,:1978).Popover content — focused via a doubly-nested
on_next_frame, because deferring paint defers dispatch-tree linkage (popover_menu.rs:299-310):// Since menus are rendered in a deferred fashion, their focus handles are // not linked in the dispatch tree until after the deferred draw callback // runs. We need to wait for that to happen before focusing it
Modal — the workspace
ModalLayeris a full-surfaceocclude()scrim withtrack_focus(modal_layer.rs:285-325).
Restoration is explicit and captured at open time: window.focused(cx) is read before building the menu and restored inside the DismissEvent subscription only if the menu still contains focus (popover_menu.rs:281, :288-293; the same block appears in right_click_menu.rs:258-268).
Pointer- and keyboard-opened submenus differ materially: a keyboard open immediately focuses the submenu and selects the first item, while a pointer open selects nothing and sets suppress_focus_selection so focus-in does not fight the hover highlight. ARIA conformance is deliberate and commented (context_menu.rs:304-310):
// Per the ARIA menu button // pattern, opening a menu places focus on a menu item; for select-style // menus we prefer the currently-checked item.
Algorithm. open(): prev = window.focused(); build the entity; subscribe to DismissEvent; on_next_frame(on_next_frame(focus(handle))). Selection inside a menu is index-based: select_next / select_previous (context_menu.rs:1145, :1164) skip non-selectable entries and wrap, pinned by can_navigate_back_over_headers (context_menu.rs:2441).
Degradation. No OS window: unaffected — focus is an internal id. No key release: the focus moves observed here are driven by press-phase actions, blur and frame callbacks; no release-driven focus transition was found in the overlay path. Cell grid: the "container holds focus, selection is an index" model is directly expressible, since a grid has no per-item focus ring to move. Static HTML: :focus-within can express "menu is open", but there is no restoration and no selection model. The two-frame focus dance is a direct consequence of deferring; a design that resolves overlays in the same pass avoids it.
10. Layering & portals
There is no portal, no top layer, no z-index, no stacking context, no compositor layer and no native child window. The complete model is:
paint order: root → inspector → deferred draws (stable sort by usize priority)
→ exactly one of { prompt | active drag | tooltip }Priority is a bare usize defaulting to 0 (deferred.rs:10), with two conventions in the codebase: 1 for popovers, popover menus and the completion menu; 2 for hover popovers, occluders, asides and the blame popover. sort_by_key is stable, so equal priorities preserve declaration order — "later in the list is in front" holds exactly. Nesting is handled by rounds, not by a tree, capped at depth 10. A DeferredDraw is a flat record: current view, dispatch parent node, element-id stack, text-style stack, content mask, rem size, priority, element, absolute offset, prepaint range, paint range.
WARNING
The priority sort is per round during prepaint (window.rs:3384) but global during paint: deferred_draw_traversal_order (window.rs:3469) sorts all indices. Nested deferred draws inherit no priority (the default is 0, deferred.rs:10), so a nested child can be painted behind an unrelated higher-priority sibling and even behind its own parent unless the author sets a higher priority by hand — which is exactly what the nesting test does (deferred.rs:125 sets 2 inside a 1). A two-level order key (owner entry, then child stamp) removes the class of bug at no cost.
The public API is deferred(child).with_priority(n) and anchored(); Window::defer_draw is also public and raw, and the editor uses it directly to bypass Anchored entirely. Implementation details are DeferredDraw, the rounds loop, the index-stability invariant and the prepaint/paint ranges used for frame-to-frame subtree reuse (whose violation was a real crash — the comment at window.rs:3366-3374 and the regression test test_nested_deferred_draws_with_reused_views, deferred.rs:156).
The tooltip layer is not part of this system: it is a fourth hard-coded slot in Window::draw fed by tooltip_requests, guaranteed above every deferred draw and mutually exclusive with the prompt and the active drag via an else-if chain (window.rs:3218-3257).
Degradation. Native to a world with no top layer; nothing degrades. The transferable shape is: keep the overlay record flat, sort it stably by an integer, and make each record carry the ambient context the overlay needs to be re-laid-out out of place — offset, clip, style scale, and (separately) the logical parent for event routing.
11. Modality
Modality is not a framework concept; it is an emergent property of one flag on a hit rect. HitboxBehavior (window.rs:845-892) has three values: Normal, BlockMouse (set by .occlude()) and BlockMouseExceptScroll (set by .block_mouse_except_scroll()). Frame::hit_test walks hitboxes in reverse, breaks on the first BlockMouse, and records hover_hitbox_count at the first BlockMouseExceptScroll so everything behind is not-hovered but still scroll-eligible. The doc is explicit that the mechanism is broader than event handling (window.rs:866-868):
/// This has effects beyond event handling - any use of hitbox checking, such as hover /// styles and tooltips. These other behaviors are the main point of this mechanism.
So "modal" means "paint an occluding rect". Every menu and popover body is wrapped in div().occlude() or built from WithRemSize::new(..).occlude(). The full modal case, ModalLayer, is an absolutely positioned size_full().inset_0().occlude() div with an optional faded scrim and a left-mouse-down handler that hides the modal (modal_layer.rs:294-325) — scrim plus light dismiss in a handful of lines.
Keyboard blocking is separate and weaker: nothing blocks keys geometrically. Keys route through the dispatch tree from the focused node, so a non-focused overlay blocks no keys at all, and "modal" is only ever pointer-modal unless the component also takes focus. There is no accessibility modal bit anywhere in the overlay path. Passthrough is the default: a Normal hitbox leaves everything behind hoverable, and making a surface non-passthrough is one call. has_active_prompt() is a separate global veto consulted only by on_mouse_down_out (div.rs:266), so a native system prompt does not collapse the UI behind it — regression-tested at div.rs:4789.
Algorithm.
hit_test(pos):
ids = []; blocked = false
for hitbox in hitboxes.rev():
if (hitbox.bounds ∩ hitbox.content_mask.bounds).contains(pos):
ids.push(hitbox.id)
if !blocked && behavior == BlockMouseExceptScroll:
hover_hitbox_count = ids.len(); blocked = true
if behavior == BlockMouse: break
if !blocked: hover_hitbox_count = ids.len()
is_hovered(id) = id ∈ ids[..hover_hitbox_count] (and false in keyboard modality)
should_handle_scroll(id) = id ∈ idsDegradation. This maps directly onto a single-surface, reverse-order hit list with one extra enum per rect. No OS window: unaffected. No hover: BlockMouse still blocks taps. No script: a scrim can be painted but light dismiss cannot fire. The BlockMouseExceptScroll distinction is worth carrying over on its own merits: an overlay should stop pointing interactions behind it without stealing the scroll that belongs to the nearest scrollable container.
12. Adaptive presentation
Adaptation is decided by the component, at render time, from the window size — never by the framework and never from a device class. The clearest case is ContextMenu::render (context_menu.rs:2199):
let is_wide_window = window_size.width / rem_size > rems_from_px(800_f32).0;which branches the documentation aside between a horizontal sibling (absolutely positioned right_full().mr_1() or left_full().ml_1(), capped max_w_96) and a stacked column above the menu capped max_w_48 (:2251-2252). The same idea appears in the editor's aside ladder (element.rs:4195), which falls back from "to the right of the menu" to "above or below the menu" when available_within_viewport.right < MENU_ASIDE_MIN_WIDTH (260 px, :4209) or when the caller forces it. That ladder chooses its direction from maximum sizes before falling back to the measured one (element.rs:4263-4272):
// Prefer choosing a direction using max sizes rather than actual size for stability.
with a three-tier fit_within cascade: max size within the text bounds → max size within the viewport → actual size within the viewport.
Hover-to-touch adaptation is absent: InputModality::Touch is recorded and never branched on, and LongPressEvent has no consumers. Keyboard-driven relocation exists in the editor as show_keyboard_hover (hover_popover.rs:98-130), which re-shows the hover popover anchored to the selection head and sets a keyboard_grace flag so a subsequent mouse-down clears the grace rather than dismissing. hover_popover_sticky is a user setting switching the hover popover between "vanish on leave" and "grace period plus recede detection". There are no teaching tips and no popover-to-sheet transformation.
Degradation. The finding for a cell-grid port is a hole rather than a design: GPUI has no touch adaptation at all, so nothing here can be inherited for a hover-less target. What is inheritable is the shape of the decision — a pure read of the viewport (and, for a port, the keyboard inset) inline at render, with the inset an input rather than something discovered. Static HTML can express the wide/narrow branch with a media query and nothing else.
13. Accessibility
GPUI ships a real AccessKit integration (crates/gpui/src/window/a11y.rs, 888 lines) that builds a per-frame TreeUpdate during prepaint from a node stack, with role(), aria_label, aria_description, aria_keyshortcuts, accessibility_id, aria_active_descendant, synthetic children and on_a11y_action as the author API (div.rs:1250-1340).
What the overlays use is narrow. ContextMenu sets Role::Menu on its scrolling container (context_menu.rs:2281) and Role::MenuItem plus a label on each entry, and marks the selected entry aria_active_descendant — with a good doc note that GPUI's active-descendant is set on the descendant (unlike ARIA's container-side attribute) and is honoured only while a focused ancestor exists, so it is safe to set unconditionally (div.rs:1298-1312). Tooltips expose nothing: rendered outside the dispatch tree, no focus handle, no role, no described-by linkage. There is no Role::Tooltip, no dialog role on modals, and no modal bit.
NOTE
INFERENCE, not an observed tree dump: DeferredDraw saves and restores the element-id and text-style stacks but no accessibility node stack (window.rs:930-941), and deferred prepaint runs after the root subtree has fully unwound — so the structure suggests an overlay's accessibility nodes attach to the window root rather than to their logical trigger. That would be coincidentally right for a menu and wrong for a described-by tooltip. The node builder's stack depth across deferred rounds was not traced.
Against WCAG 1.4.13, hoverable_tooltip supplies hoverable and persistent (a 500 ms grace, and it stays while hovered); dismissible is satisfied only incidentally, since a mouse-down clears it and there is no Escape-dismisses-tooltip path. GPUI also permits interactive tooltip content — hoverable_tooltip exists precisely for that — with no guard in the API.
Degradation. What plausibly belongs to a backend-neutral primitive is only: that an overlay is a top-level surface in whatever tree exists, a stable identity so an assistive technology can track it across frames, and an active-descendant channel so a container-focus widget can name its selected row. Role, label, description linkage and the modal bit belong to the semantic component. A terminal cell grid can honestly expose none of the structure: its channels are the painted characters in reading order and the caret, so the primitive-level contract there is to paint the overlay's text readable in order at its final position and to make the selected row's text change when the selection changes. See ../platform-ui-guidelines/index.md for the surrounding platform expectations.
14. Animation
Not applicable, and the absence is total. No overlay in the tree animates: the with_animation helper is used for loading labels, spinners, two AI list items and edit prediction — not for any tooltip, popover, menu, context menu or hover popover. There is no transform-origin concept, no enter/exit transition, no spring, and no reduced-motion handling in the overlay path.
The consequence that matters is not the missing motion but the missing metadata. Anchored::prepaint computes the final rect, possibly flips anchor, and then discards the result: the flipped corner is a local let mut anchor (anchored.rs:156) that is never written back to self, never stored in element state and never observable by the child. A child cannot learn which side it ended up on. Both real consumers therefore re-derive it by hand — ContextMenu computes flip_left from its own observed bounds (context_menu.rs:1377-1380) and uses it to switch between right_full().mr_neg_0p5() and left_full().ml_neg_0p5() and between Anchor::TopRight and Anchor::TopLeft (:1790-1800), while the editor threads a y_flipped: bool through ContextMenuLayout into the aside placer.
The nearest thing to reposition-during-animation is an anti-jitter latch: an open submenu's vertical offset is computed once, on the first frame where both the parent menu bounds and the trigger bounds are known, and never recomputed (context_menu.rs:2205-2229).
Degradation. Nothing to degrade. The transferable lesson is negative and cheap to act on: the resolved side, the resolved anchor and the clamp deltas should be part of the value the placement pass returns. Not for animation — a cell grid has none, and shadows and radii are dropped — but because border joins, a submenu chevron's direction, the aside's side and any future arrow all need it, and every one of those is a grid concern.
15. State architecture
Three surfaces, three architectures, which is itself the finding.
- Tooltip — an explicit state machine (
ActiveTooltip,div.rs:3516) in anRc<RefCell<Option<ActiveTooltip>>>with cancel-on-dropTasks as timers and a strict compute-then-apply discipline. Uncontrolled; the element owns it; the window sees only aTooltipRequestpublished each prepaint. - Menus — an imperative entity plus events.
ContextMenu(context_menu.rs:211-240) is 23 fields of ad-hoc state —selected_index,delayed,clicked,hover_target,submenu_state,submenu_safety_threshold_x,submenu_trigger_mouse_down,ignore_blur_until,suppress_focus_selection, and severalRc<Cell<Option<Bounds<Pixels>>>>/Rc<RefCell<HashMap<usize, Bounds<Pixels>>>>cells written fromcanvas()callbacks during paint and read on the next render. The only sum types areSubmenuState::{Closed, Open(OpenSubmenu)}andHoverTarget::{None, MainMenu, Submenu}. - Editor hover — a plain struct (
HoverState,hover_popover.rs:880-886) whose state is which options areSomeand which tasks are alive; dropping aTaskcancels it, so "cancel the pending show" is literallyself.info_task = None.
PopoverMenuHandle<M> (popover_menu.rs:41) is the single controlled escape hatch: an Rc<RefCell<...>> the parent holds to show/hide/toggle/is_deployed/refresh_menu from outside.
The architectural smell is measurement feedback. A zero-size canvas() probe writing Bounds into a shared Cell is used repeatedly in context_menu.rs — the menu's own container measures itself at :2257-2268, the submenu container at :1772-1784 — and the values are consumed a frame later, gated by // Only render the aside once we have trigger bounds to avoid flicker. (:2390). PopoverMenu has the same lag: child_bounds is written in prepaint and read in the next frame's request_layout. Note also that both probes above write the samemain_menu_observed_bounds cell, so what padded_submenu_bounds() and the flip_left test read depends on which probe wrote most recently.
Degradation. The tooltip machine survives a value-semantics, @nogc-leaning port almost verbatim: three variants plus idle, one optional timer, and a compute-then-apply discipline that is exactly what one does when aliasing is unavailable — replace the Task with a deadline value the frame loop compares against an injected clock and the whole thing becomes plain data with a tick() a recording canvas can drive. The menu state bag does not survive: its correctness rests on interior mutability and on two-way parent-child writes through Rc<RefCell>, which must be inverted into events a parent reduces. The measurement-into-Cell pattern should not be ported at all — a cell grid measures its own text exactly at layout time, so anchor, size and clamp can be resolved in one pass and the whole class of one-frame-stale bugs need not exist.
16. Shared infrastructure
The shared floor is thin: (1) deferred() plus Window::defer_draw — ordering, clipping escape and context capture; (2) anchored() — measure, corner flip, clamp, round; (3) ManagedView/DismissEvent plus .occlude() — lifetime and outside-press. Everything above that is duplicated per surface.
PopoverMenu and RightClickMenu are two separate Element implementations with near-identical structure: element state holding Rc<RefCell<Option<Entity<M>>>>, anchored().snap_to_window_with_margin(px(8.)), deferred(...).with_priority(1), the same block that subscribes to DismissEvent and restores the previously focused handle, and the same doubly-nested on_next_frame focus comment copied verbatim (popover_menu.rs:299 and right_click_menu.rs:258-268). They differ only in the trigger button and in whether the anchor is the child rect or the cursor point. DropdownMenu is a third wrapper over PopoverMenu (inferred from the component set and the surrounding pattern; that file was not read line by line).
The editor reuses none of it: completion menus, signature help, hover popovers, the code-action aside, the inline blame popover and edit predictions each call window.defer_draw directly with hand-written placement, because they need text-range anchoring, stacking, gap occluders and mutual avoidance that Anchored cannot express. Tooltips share nothing with menus: a fourth hard-coded window slot, their own request list, their own machine in div.rs, and their own placement (window.rs:3291) that offsets one pixel from the cursor, mirrors about the cursor on overflow, and only then clamps.
Algorithm. The observable shared call sequence for every popover-like surface is: build deferred(anchored().anchor(A).offset(O).snap_to_window_with_margin(8px).position(P).child(div().occlude().child(entity))).with_priority(1); request layout alongside the trigger; prepaint both; paint both; install a MouseDown listener over the trigger hitbox that suppresses re-open.
Degradation. The factoring is substrate-independent. On this evidence a single anchored overlay primitive owning ordering, context capture, one-anchor placement and dismissal covers PopoverMenu, RightClickMenu and DropdownMenu — Zed's own duplication is the argument that those three are one thing. It does not cover a stacked hover group, which needs multi-body placement, gap bridging and mutual avoidance with a third surface; that belongs above the primitive, exactly as layout_hover_popovers (element.rs:4315) is built on raw defer_draw. The pairs that merely look common and should stay apart are tooltip timing versus menu keyboard navigation, and the three focus contracts (tooltip: none; menu: container focus plus selection index; modal: scrim plus tracked focus).
Named algorithms
Deferred-draw ordering (rounds plus a stable priority sort). defer_draw snapshots element, absolute offset, priority, optional mask, id/text-style stacks, rem size, current view and dispatch parent into a flat vector. After the root prepaints, run rounds: stable sort the current slice by priority, prepaint each entry with the saved stacks and an empty clip stack under an absolute offset; entries appended during a round form the next round; assert depth < 10. Paint runs once over all entries sorted globally. Hitboxes and mouse listeners land in flat vectors in this order, so reverse-scan hit testing and the reverse bubble phase get front-to-back ordering for free.
Anchored fit: flip if it fits, then always clamp, then round. Described in dimension 2. Directly expressible in integer cells, since GPUI's last act is already a round to integers; the two gaps a port should close are a documented rounding rule for centre anchors and returning the resolved anchor plus the clamp deltas.
Painted occluder as a pointer bridge. For each consecutive pair of stacked popovers, defer an element of size (popover width, gap) at the gap's origin, at the same priority, built as div().size_full().occlude().on_mouse_move(stop_propagation). It is simultaneously a hit-test shield and an event sink, and it is ordinary display-list geometry — in cells, a one-row rect with a block flag and no glyphs.
Scalar submenu safe zone (menu-aim without a triangle). Record threshold_x = mouse.x - 50px on entry to the trigger and - 100px on subsequent motion; close the parent's submenu on hover-lost only when mouse.x < threshold_x; independently suppress closing while the pointer is inside the submenu bounds inflated by 50 px, or while a capture-phase mouse-down latch is set on the trigger. It is one comparable number and one inflated rect, needing no pointer history — but it is directional and is not mirrored when flip_left puts the submenu on the left, where the correct guard is the opposite inequality, and it expresses no vertical intent at all.
Monotone-approach hide with hysteresis. dx = max(0, |px - cx| - w/2), dy = max(0, |py - cy| - h/2), d = hypot(dx, dy), minimised over all popover bounds; maintain a running minimum; if d > closest + 4px declare the pointer receding and arm (never restart) the hide timer, else update the running minimum.
Latency-hiding split intent delay. Wait delay - delay/2, issue the request, wait delay/2, then render whatever arrived — the perceived intent delay is unchanged while up to half of it overlaps request latency. Paired with the inclusive start..=end "same hover target" test so intra-symbol motion never re-issues.
Multi-body placement with mutual avoidance (element.rs:4315-4560). Measure every popover at natural size; per popover compute horizontal_offset = min(0, hitbox.right - POPOVER_RIGHT_OFFSET - (anchor.x + width)) — a left-only shift (:4386). Then test the whole stack as a unit: can_place_above holds only if every popover, laid out upward with the gap between, is contained in the text hitbox and does not intersect the completion menu's bounds; can_place_below likewise. If neither, try four candidate origins around the completion menu in order — left of it, right of it, above it, below it (:4521) — aligned to the menu's top, or to its bottom minus the total height when the menu is itself flipped. If nothing fits, fall back and accept overlap.
Escape as an ordered dismissal cascade (editor.rs:3349). One handler runs a fixed ordered list of dismissals, OR-combining each, and returns whether anything closed so the caller can decide whether to propagate.
Strengths
- The top-layer problem is solved without a top layer, and the solution is smaller than the problem: one
Vec, one stable sort byusize, and reverse-order hit testing that falls out of paint order for free. - Escaping clipping ancestors needs no portal, no reparenting and no API — it is a consequence of prepainting deferred draws with an empty clip stack, and it is regression-tested against a real scroll container.
- The painted
occlude()d gap bridge turns "safe travel between surfaces" into ordinary display-list geometry that the hit test already understands: exact, cheap, and expressible on a cell grid. - The tooltip is an explicit state machine with cancel-on-drop timers and a compute-then-apply discipline, and — decisively — it re-evaluates its own visibility during window prepaint rather than trusting mouse events, so it behaves correctly even when its owning element stops being painted.
ManagedViewplusDismissEventis a minimal, non-aliasing lifetime contract: overlays never remove themselves, hosts own the handle, and focus restoration is captured at open time and guarded by a containment test.HitboxBehavior::BlockMouseExceptScrollseparates "do not interact with what is behind me" from "let the scroll reach the nearest scrollable container".- The editor's multi-popover placement is genuinely sophisticated: whole-stack containment tests, mutual avoidance with the completion menu, four ordered fallback origins, and a side chosen from maximum sizes explicitly for stability.
- Splitting the hover intent delay around the LSP request hides up to half the request latency at no cost to perceived responsiveness, and the inclusive
start..=endre-hover test handles the real server behaviour of reporting a hover at a range's end index. - Timers are
BackgroundExecutortasks the test dispatcher canadvance_clock, so the tooltip machine is fully assertable headlessly. - A menu keeps real focus on its container and expresses selection as an index plus
aria_active_descendant, with the ARIA menu-button initial-selection rule implemented and commented. MenuPosition::{PinnedToScreen, PinnedToEditor}makes "does this overlay follow the document or the screen" an explicit policy value rather than an accident.
Weaknesses
Anchoreddiscards the placement result. The flipped corner is a local binding never written back, so no child learns which side it landed on;flip_leftandy_flippedare hand-reimplemented at two other call sites as a direct consequence.- No arrow, caret or transform-origin concept exists, and no placement metadata is emitted that would enable one.
- No RTL, no writing modes, no multi-monitor, no work area, no safe-area insets beyond the Linux CSD
client_inset, and no IME or virtual-keyboard avoidance. The boundary is always the whole viewport. - Nothing dismisses an overlay on window or application deactivation, so a context menu stays open when the app loses focus.
- No touch story:
InputModality::Touchand a fully specifiedLongPressEventexist with zero consumers, and the tooltip's only trigger is hover. - Tooltips are invisible to assistive technology — no role, no focus handle, no described-by linkage, not in the dispatch tree — and
hoverable_tooltippermits interactive tooltip content with no guard. - The submenu safe-zone threshold is directional and is not mirrored when the submenu flips left, which is exactly the case the flip exists to handle.
- Placement inputs are one frame stale by construction: trigger rects, menu bounds and aside offsets come from paint-time
canvas()probes read on the next render, and a submenu's vertical offset is latched on first success and never recomputed. - Deferred paint defers dispatch-tree linkage, so focusing a newly opened overlay needs two nested
on_next_framehops — a workaround copy-pasted with its comment into more than one file. - Paint order sorts all deferred draws globally, ignoring nesting, and nested draws inherit no priority; the
1/2convention is unwritten and enforced only by grep. - The editor's horizontal shift is
min(0, ...)— left-only — so an anchor near the left edge lets a wide popover clip off-screen; the code carries a// TODOadmitting the same for the completion menu. - Three surfaces, three unrelated state architectures, and
ContextMenuin particular is a 23-field mutable bag with two-way parent-child writes throughRc<RefCell>and three ad-hoc anti-race latches. on_mouse_down_outuses raw bounds containment whileon_mouse_up_outuses the occlusion-awareis_hovered.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
| Defer paint only; keep the overlay's layout inside the parent tree | The overlay inherits the parent's layout context, text style, rem size and id namespace for free, and the parent can measure it before placing it — stated in the doc comment at deferred.rs:14-15 | Deferring paint also defers dispatch-tree linkage, so focusing a freshly opened menu needs two nested on_next_frame hops — a comment-documented wart copied into more than one file |
Order overlays by a bare usize priority with a stable sort, not by a tree | A flat vector plus a stable sort gives deterministic front-to-back with no ownership graph and no z-index resolution, and it matches the flat hitbox/listener vectors so hit order and paint order cannot diverge | Nesting is not respected at paint time (window.rs:3469 sorts all indices; nested draws default to priority 0), so a nested child can paint behind an unrelated sibling or its own parent; the 1/2 convention is unwritten |
| Do not re-establish a clip when prepainting deferred draws | Overlays escape scroll containers and clipped ancestors with no portal concept: the hitbox records the ambient mask at insert time, and at deferred-prepaint time that mask is the whole viewport | Paint-time clipping must be opted into explicitly, and exactly one call site in the tree does so; everything else can and does paint over the window chrome |
| Express modality as one flag on a hit rect, not as a modal mode | Three values cover scrim modals, menu bodies, occluding bridges and scroll-transparent overlays uniformly, and the semantics reach hover styles and tooltips too — "the main point of this mechanism" | Keyboard is not blocked geometrically at all, so "modal" is only ever pointer-modal unless the component also takes focus; and with no accessibility modal bit, screen readers are not told a modal is up |
| Keep tooltips out of the deferred system as a fourth hard-coded window slot | Tooltips must sit above everything including menus, must be singleton, and must be re-evaluated for visibility even when their owning element is not painted; a reverse-scanned request list gives all three | Tooltips then share nothing with the rest of the stack — their own placement, their own machine, no dispatch-tree presence, no accessibility node — and the else-if chain means a tooltip never appears while a drag is active |
| Reduce menu-aim to a scalar x-threshold plus an inflated rect instead of a safe polygon | One comparable number, no pointer history, trivially auditable; combined with the inflated bounds it covers the common diagonal path from a trigger to a right-opening submenu | It is directional and unmirrored — the same mouse.x < threshold_x test is used when flip_left puts the submenu on the left — and it cannot express vertical intent |
Measure via zero-size canvas() probes writing into shared Cells, consumed a frame later | It gives components access to final laid-out rects the element API otherwise hides, without adding a second layout pass | Every consumer is one frame stale and must handle the None case by not rendering; submenu offsets are latched on first success and never recomputed; and two probes write the same cell, so its meaning depends on which wrote last |
Make dismissal a message (DismissEvent on ManagedView) the host acts on | The overlay needs no reference to its host, its parent, or a window-level registry; the host owns the Option<Entity<M>> and drops it, and focus restoration is a host concern captured at open time | Every host re-implements the same subscription block (restore previous focus if still contained, clear the option, refresh), duplicated verbatim across the component crate and again per editor surface |
| Choose the aside's direction from maximum sizes, not measured sizes | Explicitly "for stability" (element.rs:4265): a surface whose content streams in must not flip as it grows, so the decision is frame-invariant and only the extent is clamped to available space | A surface that ends up much smaller than its maximum can be placed on the side with less room, and the three-tier fallback is more code than a single fit test |
Could not verify
- Zed, GPUI and their test suites were not built or run; all behaviour is read from source.
- The doc/source disagreement about whether
defer_draw's content mask applies during prepaint (see dimension 3) was not resolved. - The accessibility-attachment claim in dimension 13 is an inference from the
DeferredDrawfield list and the prepaint ordering; no tree was dumped. - How touch input reaches overlays on any mobile target was not established; only that no overlay code consumes
LongPressEvent. - Only the in-canvas overlay path was examined; native platform menus elsewhere in Zed (for example an application menu bar) were not surveyed.
crates/ui/src/components/dropdown_menu.rswas not read line by line.- No performance characteristic was measured; cost statements come from reading sort sizes and vector scans.
Sources
All links below are pinned to d71f1461045c098dc6ca6b1b5adcf1b8949722e8.
crates/gpui/src/elements/deferred.rs— the deferred element's doc comment, its default priority of0, and the nested-reuse regression test.crates/gpui/src/elements/anchored.rs— the whole framework placement engine:prepaint, the viewport boundary, the corner flip, theclient_insetfold, the unconditional clamp, the rounding,AnchoredFitMode,AnchoredPositionModeand the scrolled-container test.crates/gpui/src/window.rs—ManagedView/DismissEvent, keyboard modality suppressing hover,HitboxBehaviorand its doc rationale,TooltipId::is_hovered,DeferredDraw,Frame::hit_test, the paint-slot chain,prepaint_tooltip,prepaint_deferred_draws, the index-stability invariant, the per-round sort,paint_deferred_draws, the global paint sort, the content-mask fallback,defer_draw,insert_hitboxand input modality.crates/gpui/src/geometry.rs—Bounds::from_anchor_and_size,Bounds::corner,Anchor,Anchor::other_side_along.crates/gpui/src/elements/div.rs— the tooltip delays,on_mouse_down_out/on_mouse_up_out, the active-descendant semantics,ActiveTooltip,register_tooltip_mouse_handlers, the compute-then-apply discipline, the prepaint-time visibility re-check, and the tooltip and system-prompt regression tests.crates/gpui/src/gestures.rs—LongPressEventand its thresholds (unused by overlays).crates/gpui/src/window/a11y.rs— the AccessKit tree builder.crates/ui/src/components/context_menu.rs— the state bag, blur dismissal, the ARIA focus-in rule,canceland its grace window, selection navigation,open_submenu,flip_left, the keyboard blur grace, the safe-zone thresholds (100 px, 50 px),padded_submenu_bounds, the submenu measurement probe and submenu anchor, the close test,is_wide_window, the submenu offset latch, the menu measurement probe,Role::Menu, outside-press, the anti-flicker gate and the navigation test.crates/ui/src/components/popover_menu.rs—PopoverMenuHandle, the signed gap offset, focus capture, the two-frame focus comment, the anchored/deferred sequence, the corner conversion and the re-click suppression; the same scaffolding duplicated inright_click_menu.rs(trigger, focus restore).crates/ui/src/components/{tooltip.rs,popover.rs}— the padding-as-offset comment andPOPOVER_Y_PADDING.crates/editor/src/element.rs—layout_popovers_above_or_below_line, the flip decision, the min-height re-evaluation, the left-clip TODO, the right-edge snap,layout_context_menu_asideand its min-width gate, the max-sizes-for-stability comment,layout_hover_popovers, the left-only horizontal shift,draw_occluder, the whole-stack containment tests and the candidate origins around the menu.crates/editor/src/hover_popover.rs—HOVER_POPOVER_GAP, the do-not-restart note,show_keyboard_hover, the split intent delay, the inclusive range test,HoverState,is_mouse_getting_closer, its hysteresis band and the row clamp.crates/editor/src/{mouse_context_menu.rs,editor.rs,code_context_menus.rs,edit_prediction.rs}andcrates/editor/src/element/mouse.rs—MenuPosition, the dismissal cascade, the menu gap constants, the only masked deferred draw and the pinned-to-editor visibility test.crates/workspace/src/modal_layer.rs— the scrim plus light-dismiss modal.assets/settings/default.json— the user-facing hover timings.
Related reading in this catalog: ./index.md, ./concepts.md, ./comparison.md, ./features-people-forget.md, ./sparkles-baseline.md, ./proposal.md. The closest siblings by surface model are ./imgui.md, ./flutter.md, ./textual.md and ./notcurses.md; for the OS-surface contrast see ./xdg-positioner.md and ./gtk4.md. Toolkit context lives in ../../specs/ui/index.md, ../../specs/ui/input.md, ../../specs/ui/containers.md, ../../specs/ui/state-machines.md, ../../specs/ui/backends.md and ../../specs/ui/widgets.md; adjacent research in ../window-system-integration/index.md, ../ui-layout/index.md and ../sean-parent/index.md.