dock: Improvements to DockArea for nested Docks and styling - #2840
dock: Improvements to DockArea for nested Docks and styling#2840landaire wants to merge 6 commits into
Conversation
gpui's TestWindow panics (unimplemented!) in its HasWindowHandle impl instead of returning Err, so the graceful `.ok()?` bail in the input and accessibility ns_view helpers never fires -- a downstream crate's tests that build a Root or render an Input over a test window panic. Probe the handle under catch_unwind and no-op when there is no backing AppKit window.
Lets a consumer close a panel it only holds a PanelId for (e.g. an unresolved/InvalidPanel leaf from a restored layout), where remove_panel would need a live Entity<P>.
A cross-DockArea drag -- dragging a tab between an outer dock and a nested dock hosted inside one of its own panels -- would insert a PanelId with no backing entity into the target tree while the panel stayed registered in its real owner: a ghost tab in one, a duplicate in the other. Guard move_panel to no-op when the panel is not owned here, so the panel stays in its source. Covered by a_move_of_an_unowned_panel_is_ignored.
Record each tab-group leaf's on-screen rect during render (via on_prepaint) into a node_bounds map, exposed as DockArea::node_bounds. Lets a host paint spatial overlays over panes -- e.g. a vimium-style pane picker badging each pane -- which the pure-data tree cannot express. The wrapper that captures the rect carries no sizing (that stays on the parent resizable_panel), so split layout is unchanged.
…utton Panels can override Panel::render_tab to have the final say over their own tab -- restyle it, swap the label, add a prefix icon, or add/drop a suffix -- while the tab bar keeps its layout, drag/drop, and activation. The tab group builds the fully wired Tab and routes it through the panel; the default returns it unchanged. Closable panels get a built-in close (X) button suffix. It stops click propagation so closing never also selects the tab, and closes by panel id so it targets its own tab regardless of which is active.
2223042 to
c74ada0
Compare
|
Thanks for this PR! The tests are careful, and the I have one concern about the new close button. In if !self.constraints.is_closable() { return; }
if !self.draggable(cx) { return; } // = !is_locked() && !is_last_panel()So the X button can be drawn but do nothing when:
This is the same trap the
The smallest fix is to ask .when(group.is_draggable() && panel.closable(cx), |this| { ... })The current tests do not catch this. One note if you also want to cover the |
huacnlee
left a comment
There was a problem hiding this comment.
Thanks for putting this together. I found two issues that should be addressed before merging:
-
DockArea::node_boundscan return stale bounds for removed nodes. Its public documentation says it returnsNonewhen a node is not a rendered tab group, but entries are never removed fromnode_bounds. After a leaf is removed, a caller retaining itsNodeIdcan still receive an obsolete on-screen rectangle. Please clear stale entries or otherwise make the implementation match the documented contract, and add a regression test covering node removal. -
The accessibility workaround catches panics around the entire
ns_view(window)call. This can silently hide unrelated failures in handle matching or pointer conversion. Please narrow thecatch_unwindscope toHasWindowHandle::window_handle, as the native input implementation does. A regression test for the expected test-window panic path would also be valuable.
There is also a testing gap in the new close button behavior: the current tests verify only that the button is rendered. Please add an interaction test that clicks the close button on a non-active tab and verifies that the correct PanelId is removed without selecting or otherwise triggering the tab click handler.
The targeted tests currently included in this PR pass locally.
…a11y scope - Prune node_bounds on reconcile so a removed leaf's NodeId reports None instead of a stale rect, matching the documented contract - Gate the per-tab close button on group.is_draggable(), the same check TabGroup::close_panel runs, so it never draws when a click could not close the panel (locked layout or a group's last visible panel) - Narrow the macOS accessibility catch_unwind to window_handle(), as the native Input does, so failures in handle matching or pointer conversion still surface - Add tests covering node removal and a non-active tab close-button click
huacnlee
left a comment
There was a problem hiding this comment.
Thanks for the thorough writeup and especially for the tests — the node_bounds probe-ordering regression guard is exactly the kind of test that pays for itself, and the comment explaining why the on_prepaint canvas must precede the content child is correct (it inserts an .absolute().size_full() canvas, so its static position depends on where it sits in the flow).
I verified the branch builds, cargo clippy --all-targets --deny warnings is clean, and the dock suites pass (179 in gpui-base, 23 in gpui-component). Findings below, most severe first.
Blocking: the branch is not rustfmt-clean
cargo fmt --check fails on this PR's own new code, so CI will reject it:
crates/base/src/dock/dock_area.rs:2503
crates/ui/src/dock/dock.rs:393
crates/ui/src/dock/dock.rs:416
node_bounds returns stale rects for nodes that stopped being painted
node_bounds documents "None if the node is not a rendered tab group", but reconcile prunes only nodes removed from the tree. A node can stop being painted while staying in the tree, and then the map keeps the rectangle it last had:
- Zoom.
Render for DockAreamatches onzoomed_view()and, when it isSome, rendersframe.child(view)and never callsrender_nodeat all. No probe fires for any node, so every entry goes stale at once. - An all-invisible slot.
ResizablePanel::renderreturns a barediv()and drops its children, the probe with them. - A closed dock.
Confirmed on this branch: an 800x600 window with an h-split of two leaves, then set_zoomed_in(left_id) and two full redraws. node_bounds(left_id) still reports 400x600 @ (0,0) for a pane that is now 800x600, and node_bounds(right_id) still reports 400x600 @ (400,0) for a pane that is not on screen. The stated use case is a vimium-style pane picker, so this badges a hidden pane and mis-places the badge on the zoomed one — the exact failure the feature is meant to avoid.
Simplest fix: record a frame counter alongside each rect and treat entries older than the current frame as None. Otherwise the zoomed view needs the same probe and invisible slots need explicit eviction.
The close button's gate is not the gate that closes
The comment says is_draggable is "the same gate TabGroup::close_panel checks". It is not. close_panel requires three things:
constraints.is_closable() && self.draggable(cx) && panel.closable(cx)The new gate is group.is_draggable() && panel.closable(cx) and drops the constraint term. TabGroupConstraints::closable(false) is a public builder whose own doc says "a dock's last group sets this false so the dock cannot be emptied", so any container using it gets an X on every tab that does nothing when clicked. Today DockArea always builds in_split(alone) (closable true) and never calls .closable(false), so this is latent rather than live — but the comment asserts an equivalence that does not hold, and the same file already gates the ellipsis-menu Close entry on group.is_closable() (tab_panel.rs:300), so the two Close affordances can disagree.
TabGroupContext exposes no per-panel-correct reader for this today, so the fix likely needs a new accessor rather than a one-line change.
Close buttons are drawn on a collapsed group's tab strip
render_tab_bar routes on visible-panel count only, so a collapsed group with two or more visible panels lands in render_tabs and now grows close buttons. The surrounding code deliberately gates every rearrange interaction behind .when(!collapsed, …), on the stated reasoning that "a collapsed group is a strip of tabs with no content: the strip is a way back in, not a selection". close_panel does not check collapsed either (collapsed does not imply locked), so a user reaching for a collapsed bottom dock can hit the X and destroy the panel instead of reopening it. The gate needs !collapsed.
The catch_unwind probe pays its cost every frame
sync_native_content_type is called from Input::render whenever the input is focused, so on a macOS test window this is a panic, a full unwind, and a default-panic-hook message on every rendered frame — not once. That is slow and it floods the very test output the change is meant to make usable.
The clean fix is upstream and one line: TestWindow::window_handle should return Err(HandleError::NotSupported) instead of unimplemented!(). raw-window-handle has that variant for exactly this case ("the underlying window system does not support any of the representative C window handles"), and the existing .ok()? then works with zero cost and no catch_unwind. Worth sending to Zed; it is a smaller change than this workaround.
If the workaround has to stay in the meantime, cache the answer per window (a thread-local keyed by window id, or a Once) so the panic is paid at most once, and log when the probe catches. As written it also silently swallows a genuine panic on a real window, turning a crash into a quiet loss of accessibility wiring and native autofill with no diagnostic.
Smaller points
- No
## Breaking Changessection.PanelViewis a public trait and gainsrender_tabwith no default, which breaks any downstreamimpl PanelView. CLAUDE.md requires that section withdiffblocks when the public API of the component crate changes. The default tab appearance also changes for every existing consumer (tab widths and hit targets move), which belongs in the same section. Panel::render_tabtakes&mut selfwhile its siblingsdropdown_menu,zoom_controlandinner_paddingall take&self, and the object-safePanelView::render_tabtakes&selfbut internally callsself.update(...). That puts a mutable borrow on the panel entity during the tab bar's render. The documented purpose is final styling tweaks, so&selfwould do and would remove a re-entrancy hazard for custom implementations.- The close button has no tooltip and no keyboard path. The adjacent zoom control uses
.tooltip_with_action(...), and theDock.Closei18n key already exists and is translated. An unlabeled icon-only destructive control sitting next to a labeled one is inconsistent, and.tab_stop(false)makes it keyboard-unreachable. - Scope. You flagged this yourself, and it is a real cost here: the
catch_unwindchange and the tab redesign have nothing to do with each other, and the tab redesign is the one that needs a design decision from the maintainers rather than a code review. Splitting it out would let the other four land quickly.
Reviewed and found sound
- The
move_panelownership guard.self.panelsis the authoritative registration map, populated byadd_paneland pruned byreconcile, so the guard cannot reject a legitimately owned panel, and the only caller path targets exactly the cross-area case you describe. remove_panel_idmade public. It is already whatremove_paneldelegates to, and it early-returns when the id is not found.- The
on_prepaintwrapper's layout impact.TabGroupSkin::frameis alreadyv_flex().size_full(), so the extradiv().size_full()is layout-neutral, and the split-filling tests still pass. stop_propagationon the close click correctly prevents the tab from also activating.
🤖 Review assisted by Claude Code
I'm stretching the "1 PR to solve 1 problem" request a bit, but unfortunately GitHub does not yet support stacked PRs across forks which would make trying to upstream these independently somewhat annoying.
Summary of changes: Adds some improvements to the
DockAreato make managing docks, creating nested docks, and styling docks a bit easier.Description
unreachable!) when attempting to get aTestWindowhandle. This catches the panic and gracefully recovers. I don't like the solution here, but it works for now. gpui-component does not hit this panic today becausemacos_accessibilityis disabled in tests, but it may be required for some tests to do hit detection.DockArea::remove_panel_idpublic to support scenarios where you may have a panel ID but not a reference to the entity.pre_paint, records leaf nodes render areas to allow for possibilities like e.g. vimimum style tab pickers (shown below).PanelView::render_tabcallback which allows for full customization of theTab. Changes default behavior so that closeable tabs have a "Close Tab"-icon suffix button for closing.Media
Screenshot of the tabs
The
DockArea::move_panelchanges help allow this:move_panels_compressed.mp4
And the overlay mentioned (in my application, but unblocked by these changes):
overlay_compressed.mp4
How to Test
Added new tests.
Checklist
cargo runfor story tests related to the changes.