Skip to content

Improvements to DockArea for nested Docks and styling - #2840

Open
landaire wants to merge 5 commits into
longbridge:mainfrom
landaire-contrib:upstream-dock
Open

Improvements to DockArea for nested Docks and styling#2840
landaire wants to merge 5 commits into
longbridge:mainfrom
landaire-contrib:upstream-dock

Conversation

@landaire

Copy link
Copy Markdown

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 DockArea to make managing docks, creating nested docks, and styling docks a bit easier.

Description

  • fix(input): tolerate a window whose window_handle() panics: upstream gpui panics (unreachable!) when attempting to get a TestWindow handle. 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 because macos_accessibility is disabled in tests, but it may be required for some tests to do hit detection.
  • feat(dock): expose remove_panel_id publicly on DockArea: makes DockArea::remove_panel_id public to support scenarios where you may have a panel ID but not a reference to the entity.
  • fix(dock): ignore move_panel of a panel this area does not own: nested docks have a weird issue where trying to drag tabs within these nested panels could cause ghost tabs and some other strange things. This resolves that issue.
  • feat(dock): expose per-node bounds for spatial overlays: during pre_paint, records leaf nodes render areas to allow for possibilities like e.g. vimimum style tab pickers (shown below).
  • feat(dock): customizable tabs via render_tab, with a built-in close button: adds a PanelView::render_tab callback which allows for full customization of the Tab. Changes default behavior so that closeable tabs have a "Close Tab"-icon suffix button for closing.

Media

Screenshot of the tabs

Before After
tabs_before tabs_after

The DockArea::move_panel changes 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

  • I have read the CONTRIBUTING document and followed the guidelines.
  • Reviewed the changes in this PR and confirmed AI generated code (If any) is accurate.
  • Passed cargo run for story tests related to the changes.
  • Tested macOS, Windows and Linux platforms performance (if the change is platform-specific)

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.
@huacnlee

Copy link
Copy Markdown
Member

Thanks for this PR! The tests are careful, and the move_panel ownership guard is a good catch.

I have one concern about the new close button.

In tab_panel.rs the button shows when panel.closable(cx) is true. But TabGroup::close_panel checks two more things before it closes anything:

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:

  • the layout is locked, or
  • the group is alone and has only one panel — a common start state for many apps.

This is the same trap the tab_drag comment just above already warns about:

a tab bar that forgets to ask TabGroupContext::is_draggable makes a group that has nowhere to go — a dock's last group, a locked dock — draggable anyway.

The smallest fix is to ask group.is_draggable() too. It covers both cases:

.when(group.is_draggable() && panel.closable(cx), |this| { ... })

The current tests do not catch this. drew_close_button builds a group with two panels, so is_last_panel is false, and no test uses a locked layout.

One note if you also want to cover the constraints.is_closable() gate: TabGroupContext::is_closable() is not the right call. It means "the active panel is closable", so it would hide the X on the other tabs. Base would need to expose that bit on its own.

@huacnlee huacnlee left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for putting this together. I found two issues that should be addressed before merging:

  1. DockArea::node_bounds can return stale bounds for removed nodes. Its public documentation says it returns None when a node is not a rendered tab group, but entries are never removed from node_bounds. After a leaf is removed, a caller retaining its NodeId can 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.

  2. 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 the catch_unwind scope to HasWindowHandle::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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants