update dock

This commit is contained in:
2026-08-23 10:43:59 +07:00
parent 8da48bdc4a
commit 1956cb96bb
5 changed files with 299 additions and 25 deletions
+3
View File
@@ -9,3 +9,6 @@ publish.workspace = true
gpui.workspace = true
gpui-component.workspace = true
gpui-base.workspace = true
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
+83 -20
View File
@@ -97,15 +97,6 @@ fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
.flatten()
}
/// The payload for dragging the tab at `ix` out of its group, or `None` when
/// this group must not be rearranged.
fn tab_drag(group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
group
.is_draggable()
.then(|| group.drag_panel(ix, cx))
.flatten()
}
/// The left-most, top-most tab group in a container — where a left dock's
/// collapse affordance goes. Mirrors the old `StackPanel::left_top_tab_panel`.
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
@@ -183,6 +174,37 @@ impl SignedTabGroupSkin {
left == group.node() && group.panels().len() == 1
}
/// The bottom or right dock whose root tab group this group is, if any.
///
/// Base bars a dock's only group from being dragged or closed, so the
/// dock cannot be emptied. A bottom/right panel is supposed to be
/// closable and movable, though — the vendored dock allowed exactly that
/// — so the skin recognizes the group and routes around the bar.
fn is_dock_root_group(&self, group: &TabGroupContext, cx: &App) -> Option<DockPlacement> {
let area = self.shared.area().upgrade()?;
let area = area.read(cx);
[DockPlacement::Bottom, DockPlacement::Right]
.into_iter()
.find(|placement| {
area.layout(*placement)
.is_some_and(|tree| tree.root().id() == group.node())
})
}
/// The drag payload for the tab at `ix`, or `None` when this group must
/// not be rearranged. A locked group is never draggable; a group that is
/// a bottom/right dock's only content still is, because the center is
/// always there to land in.
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
if group.is_locked() {
return None;
}
if !group.is_draggable() && self.is_dock_root_group(group, cx).is_none() {
return None;
}
group.drag_panel(ix, cx)
}
/// Whether a dock's collapse affordance belongs in *this* group's tab
/// bar, and which way it points. `None` means this group draws none.
fn dock_toggle_button(
@@ -309,7 +331,12 @@ impl SignedTabGroupSkin {
let control = zoom_control(group, cx);
let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible());
let menu_zoom = control.is_some_and(|control| control.menu_visible());
let closable = group.can_close();
// A bottom/right dock's only panel cannot be closed through the
// group (base keeps a dock's last group), but the skin handles that
// close by removing the whole dock, so the item is offered.
let closable = group.can_close()
|| (self.is_dock_root_group(group, cx).is_some()
&& group.active_panel().is_some_and(|panel| panel.closable(cx)));
let buttons = handle.and_then(|handle| handle.toolbar_buttons(window, cx));
let panel = handle.map(|handle| handle.panel());
@@ -395,7 +422,7 @@ impl SignedTabGroupSkin {
) -> Tab {
let collapsed = group.is_collapsed();
let droppable = group.is_droppable();
let drag = tab_drag(group, ix, cx);
let drag = self.tab_drag(group, ix, cx);
let handle = PanelHandle::of(&panel);
Tab::new(ix)
@@ -546,6 +573,19 @@ impl SignedTabGroupSkin {
impl TabGroupRenderer for SignedTabGroupSkin {
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
let control = zoom_control(group, cx);
// An emptied group — its last panel was dragged away — draws nothing,
// so an emptied dock does not leave a bare tab bar behind.
if group.panels().is_empty() {
return div().id("tab-panel");
}
// Closing the only panel of a bottom/right dock would leave an empty
// dock, which base refuses through the group. The skin removes the
// whole dock instead — the vendored dock's close took its split
// group away just the same.
let dock_to_remove = (group.panels().len() <= 1)
.then(|| self.is_dock_root_group(group, cx))
.flatten();
let shared = self.shared.clone();
// `v_flex`, not `div`: gpui's default display is Block, and in block
// layout a child's `flex_grow` is ignored — the content region below
@@ -574,12 +614,23 @@ impl TabGroupRenderer for SignedTabGroupSkin {
})
.on_action({
let group = group.clone();
let shared = shared.clone();
move |_: &ClosePanel, window, cx| {
let Some(panel) = group.active_panel() else {
return;
};
if !panel.closable(cx) {
return;
}
let panel = panel.panel_id(cx);
group.close(panel, window, cx);
match dock_to_remove {
Some(placement) => {
_ = shared.area().update(cx, |area, cx| {
area.remove_dock(placement, window, cx);
});
}
None => group.close(panel, window, cx),
}
}
})
})
@@ -599,6 +650,12 @@ impl TabGroupRenderer for SignedTabGroupSkin {
window: &mut Window,
cx: &mut App,
) -> AnyElement {
// An emptied group draws no tab bar; the app prunes the emptied
// bottom/right dock a moment later.
if group.panels().is_empty() {
return Empty.into_any_element();
}
// The sidebar group draws no chrome at all, like the vendored dock's
// bare `DockItem::Panel`.
if self.is_plain_sidebar_group(group, cx) {
@@ -614,15 +671,21 @@ impl TabGroupRenderer for SignedTabGroupSkin {
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
let is_bottom_dock = bottom_dock_button.is_some();
// macOS: the traffic lights overlay the window's top-left corner. The
// left dock (sidebar) normally clears them; when it is closed or
// absent, the center tab bar must reserve the space itself.
// macOS: the traffic lights overlay the window's top-left corner. Only
// the group whose tab bar actually sits under them must reserve the
// space: the left dock (sidebar) normally clears them, and when it is
// closed or absent it is the center's left-most, top-most tab group
// that is in the corner. A bottom or right dock is never there, and
// neither is the right panel of a center split.
let needs_traffic_light_padding = cfg!(target_os = "macos")
&& self
.shared
.area()
.upgrade()
.is_none_or(|area| !area.read(cx).is_dock_open(DockPlacement::Left));
&& self.shared.area().upgrade().is_some_and(|area| {
let area = area.read(cx);
!area.is_dock_open(DockPlacement::Left)
&& area
.layout(DockPlacement::Center)
.and_then(|tree| left_top_group(tree.root()))
== Some(group.node())
});
// Bring a newly displayed tab into view. The group owns selection
// now, so the skin notices the change rather than being told about it.
+99
View File
@@ -0,0 +1,99 @@
//! Render-path smoke tests: the skin reads the dock area while rendering, and
//! GPUI panics if an entity is read while it is leased (being updated). These
//! pin that the first frame — docks, groups, tab bars — renders without
//! tripping the lease check.
use dock::{BasePanel, Panel, SignedDockSkin, panel_handle};
use gpui::{
App, AppContext, Context, Empty, EventEmitter, FocusHandle, Focusable, IntoElement, Render,
TestAppContext, Window,
};
use gpui_base::dock::{DockArea, DockLayout, DockPlacement, PanelEvent};
struct Probe {
focus_handle: FocusHandle,
}
impl Probe {
fn new(cx: &mut Context<Self>) -> Self {
Self {
focus_handle: cx.focus_handle(),
}
}
}
impl BasePanel for Probe {
fn panel_name(&self) -> &'static str {
"Probe"
}
}
impl Panel for Probe {
fn title(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
"Probe"
}
}
impl EventEmitter<PanelEvent> for Probe {}
impl Focusable for Probe {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Probe {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
#[gpui::test]
fn the_first_frame_renders_the_area_and_its_docks(cx: &mut TestAppContext) {
cx.update(|cx| {
gpui_component::init(cx);
});
let (area, cx) = cx.add_window_view(|window, cx| {
let skin = SignedDockSkin::new(cx);
DockArea::new("test", None, window, cx).with_renderer(skin)
});
let bottom = cx.update(|_, cx| cx.new(Probe::new));
cx.update(|window, cx| {
let left = cx.new(Probe::new);
let center = cx.new(Probe::new);
area.update(cx, |area, cx| {
area.set_dock(
DockPlacement::Left,
DockLayout::tabs().panel_view(panel_handle(left), cx),
window,
cx,
);
area.set_center(
DockLayout::tabs().panel_view(panel_handle(center), cx),
window,
cx,
);
area.set_dock(
DockPlacement::Bottom,
DockLayout::tabs().panel_view(panel_handle(bottom.clone()), cx),
window,
cx,
);
});
});
// The first frame walks every render hook — the dock frame, each group's
// tab bar, the toolbar — all of which read the dock area.
cx.update(|window, cx| window.draw(cx).clear(cx));
// Emptying a dock leaves an empty group behind; its render must also be
// safe (and draw nothing).
cx.update(|window, cx| {
area.update(cx, |area, cx| {
area.remove_panel(bottom, window, cx);
});
});
cx.update(|window, cx| window.draw(cx).clear(cx));
}