update dock

This commit is contained in:
2026-08-19 10:13:29 +07:00
parent 071cb9e714
commit c8bf7eb5d3
11 changed files with 236 additions and 2228 deletions
+99 -168
View File
@@ -152,8 +152,6 @@ pub struct TabPanel {
will_split_placement: Option<Placement>,
drop_placeholder_animation: Option<DropPlaceholderAnimation>,
drop_placeholder_animation_name: SharedString,
/// Is TabPanel used in Tiles.
in_tiles: bool,
/// Bounds of the title bar row (the wrapper around the tab bar), in
/// window coordinates. Measured via `on_prepaint` to position the
@@ -183,9 +181,8 @@ impl Panel for TabPanel {
return false;
}
// 1. When is the final panel in the dock, it will not able to close.
// 2. When is in the Tiles, it will always able to close (by active panel state).
if !self.draggable(cx) && !self.in_tiles {
// The final panel in the dock is not closable.
if !self.draggable(cx) {
return false;
}
@@ -314,18 +311,12 @@ impl TabPanel {
zoomed: false,
collapsed: false,
closable: true,
in_tiles: false,
title_bar_bounds: None,
title_bar_strip_bounds: None,
title_bar_suffix_bounds: None,
}
}
/// Mark the TabPanel as being used in Tiles.
pub(super) fn set_in_tiles(&mut self, in_tiles: bool) {
self.in_tiles = in_tiles;
}
pub(super) fn set_parent(&mut self, view: WeakEntity<StackPanel>) {
self.stack_panel = Some(view);
}
@@ -717,6 +708,11 @@ impl TabPanel {
_: &mut Window,
cx: &mut Context<Self>,
) -> Option<Button> {
// Only the left dock can be toggled.
if placement != DockPlacement::Left {
return None;
}
if self.zoomed {
return None;
}
@@ -733,47 +729,15 @@ impl TabPanel {
let toggle_button_panels = dock_area.toggle_button_panels;
// Check if current TabPanel's entity_id matches the one stored in DockArea for this placement
if !match placement {
DockPlacement::Left => {
dock_area.left_dock.is_some() && toggle_button_panels.left == Some(view_entity_id)
}
DockPlacement::Right => {
dock_area.right_dock.is_some() && toggle_button_panels.right == Some(view_entity_id)
}
DockPlacement::Bottom => {
dock_area.bottom_dock.is_some()
&& toggle_button_panels.bottom == Some(view_entity_id)
}
DockPlacement::Center => unreachable!(),
} {
if dock_area.left_dock.is_none() || toggle_button_panels.left != Some(view_entity_id) {
return None;
}
let is_open = dock_area.is_dock_open(placement, cx);
let icon = match placement {
DockPlacement::Left => {
if is_open {
IconName::PanelLeft
} else {
IconName::PanelLeftOpen
}
}
DockPlacement::Right => {
if is_open {
IconName::PanelRight
} else {
IconName::PanelRightOpen
}
}
DockPlacement::Bottom => {
if is_open {
IconName::PanelBottom
} else {
IconName::PanelBottomOpen
}
}
DockPlacement::Center => unreachable!(),
let icon = if is_open {
IconName::PanelLeft
} else {
IconName::PanelLeftOpen
};
Some(
@@ -810,11 +774,7 @@ impl TabPanel {
};
let left_dock_button = self.render_dock_toggle_button(DockPlacement::Left, window, cx);
let bottom_dock_button = self.render_dock_toggle_button(DockPlacement::Bottom, window, cx);
let right_dock_button = self.render_dock_toggle_button(DockPlacement::Right, window, cx);
let has_extend_dock_button = left_dock_button.is_some() || bottom_dock_button.is_some();
let is_bottom_dock = bottom_dock_button.is_some();
let has_extend_dock_button = left_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
@@ -849,7 +809,6 @@ impl TabPanel {
.pr_2()
.when(left_dock_button.is_some(), |this| this.pl_2())
.when(needs_traffic_light_padding, |this| this.pl(px(80.)))
.when(right_dock_button.is_some(), |this| this.pr_2())
.when_some(title_style, |this, theme| {
this.bg(theme.background).text_color(theme.foreground)
})
@@ -859,8 +818,7 @@ impl TabPanel {
.flex_shrink_0()
.mr_1()
.gap_1()
.children(left_dock_button)
.children(bottom_dock_button),
.children(left_dock_button),
)
})
.child(
@@ -889,8 +847,7 @@ impl TabPanel {
.flex_shrink_0()
.ml_1()
.gap_1()
.child(self.render_toolbar(state, window, cx))
.children(right_dock_button),
.child(self.render_toolbar(state, window, cx)),
),
window,
cx,
@@ -973,17 +930,13 @@ impl TabPanel {
.top_0()
// Right -1 for avoid border overlap with the first tab
.right(-px(1.))
.border_r_1()
.border_b_1()
.h_full()
.border_color(cx.theme().border)
.bg(cx.theme().tokens.tab_bar)
.px_2()
.when(needs_traffic_light_padding, |this| {
this.pl(px(80.))
})
.children(left_dock_button)
.children(bottom_dock_button),
.children(left_dock_button),
)
},
)
@@ -1017,21 +970,8 @@ impl TabPanel {
})
.selected(active)
.on_click(cx.listener({
let is_collapsed = self.collapsed;
let dock_area = self.dock_area.clone();
move |view, _, window, cx| {
view.set_active_ix(ix, window, cx);
// Open dock if clicked on the collapsed bottom dock
if is_bottom_dock && is_collapsed {
_ = dock_area.update(cx, |dock_area, cx| {
dock_area.toggle_dock(
DockPlacement::Bottom,
window,
cx,
);
});
}
}
}))
.when(!droppable, |this| {
@@ -1060,23 +1000,18 @@ impl TabPanel {
this.on_drop(drag, Some(ix), true, window, cx)
},
))
.when(
!self.in_tiles,
|this| {
this.drag_over::<AnyDrag>(|this, _, _, cx| {
this.rounded_l_none()
.border_l_2()
.border_r_0()
.border_color(cx.theme().drag_border)
})
.on_drop(cx.listener(
|this, item: &AnyDrag, _, cx| {
this.will_split_placement = None;
this.emit_drag_drop(item, None, cx);
},
))
.drag_over::<AnyDrag>(|this, _, _, cx| {
this.rounded_l_none()
.border_l_2()
.border_r_0()
.border_color(cx.theme().drag_border)
})
.on_drop(cx.listener(
|this, item: &AnyDrag, _, cx| {
this.will_split_placement = None;
this.emit_drag_drop(item, None, cx);
},
)
))
},
)
}),
@@ -1120,19 +1055,14 @@ impl TabPanel {
this.on_drop(drag, ix, false, window, cx)
},
))
.when(
!self.in_tiles,
|this| {
this.drag_over::<AnyDrag>(|this, _, _, cx| {
this.bg(cx.theme().tokens.drop_target)
})
.on_drop(cx.listener(
|this, item: &AnyDrag, _, cx| {
this.will_split_placement = None;
this.emit_drag_drop(item, None, cx);
},
))
},
.drag_over::<AnyDrag>(|this, _, _, cx| {
this.bg(cx.theme().tokens.drop_target)
})
.on_drop(
cx.listener(|this, item: &AnyDrag, _, cx| {
this.will_split_placement = None;
this.emit_drag_drop(item, None, cx);
}),
)
}),
)
@@ -1142,10 +1072,7 @@ impl TabPanel {
.items_center()
.top_0()
.right_0()
.border_l_1()
.border_b_1()
.h_full()
.border_color(cx.theme().border)
.bg(cx.theme().tokens.tab_bar)
.px_2()
.gap_1()
@@ -1165,8 +1092,7 @@ impl TabPanel {
self.active_panel(cx)
.and_then(|panel| panel.title_suffix(window, cx)),
)
.child(self.render_toolbar(state, window, cx))
.when_some(right_dock_button, |this, btn| this.child(btn)),
.child(self.render_toolbar(state, window, cx)),
)
}),
)
@@ -1214,9 +1140,7 @@ impl TabPanel {
)
.when(state.droppable, |this| {
this.on_drag_move(cx.listener(Self::on_panel_drag_move))
.when(!self.in_tiles, |this| {
this.on_drag_move(cx.listener(Self::on_item_drag_move))
})
.on_drag_move(cx.listener(Self::on_item_drag_move))
.child(
div()
.invisible()
@@ -1233,13 +1157,11 @@ impl TabPanel {
.on_drop(cx.listener(|this, drag: &DragPanel, window, cx| {
this.on_drop(drag, None, true, window, cx)
}))
.when(!self.in_tiles, |this| {
this.group_drag_over::<AnyDrag>("", |this| this.visible())
.on_drop(cx.listener(|this, item: &AnyDrag, _, cx| {
let placement = this.will_split_placement.take();
this.emit_drag_drop(item, placement, cx);
}))
})
.group_drag_over::<AnyDrag>("", |this| this.visible())
.on_drop(cx.listener(|this, item: &AnyDrag, _, cx| {
let placement = this.will_split_placement.take();
this.emit_drag_drop(item, placement, cx);
}))
.when_some(placeholder, |this, animation| {
let from = animation.from.origin - animation.to.origin;
this.child(
@@ -1601,9 +1523,8 @@ impl TabPanel {
self.remove_panel(panel, window, cx);
}
// Remove self from the parent DockArea.
// This is ensure to remove from Tiles
if self.panels.is_empty() && self.in_tiles {
// Remove self from the parent DockArea when the last panel is closed.
if self.panels.is_empty() {
let tab_panel = Arc::new(cx.entity());
window.defer(cx, {
let dock_area = self.dock_area.clone();
@@ -1681,12 +1602,12 @@ mod tests {
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use gpui::{TestAppContext, VisualTestContext, WindowHandle, WindowOptions, size};
use gpui::{Axis, TestAppContext, VisualTestContext, WindowHandle, WindowOptions, size};
use gpui_component::tab::{Tab, TabBar};
use gpui_component::{Root, Theme, v_flex};
use super::*;
use crate::{DockItem, TileMeta};
use crate::DockItem;
#[test]
fn drop_placeholder_bounds_cover_each_target_placement() {
@@ -2018,49 +1939,6 @@ mod tests {
assert!(cx.read(|cx| fixture.dock_area.read(cx).is_center_empty(cx)));
}
/// A `TabPanel` inside `Tiles` has no parent `StackPanel` to remove itself
/// from, so emptying it leaves the tile behind and the walk has to recurse.
#[gpui::test]
fn center_holding_only_empty_tiles_is_empty(cx: &mut TestAppContext) {
let fixture = setup(cx);
let mut cx = VisualTestContext::from_window(fixture.window.into(), cx);
let (tabs, tab_panel, panels) = build_tabs(&fixture, &["A"], None, &mut cx);
let weak_dock_area = fixture.dock_area.downgrade();
cx.update(|window, cx| {
let tiles = DockItem::tiles(
vec![tabs],
vec![TileMeta::default()],
&weak_dock_area,
window,
cx,
);
fixture
.dock_area
.update(cx, |area, cx| area.set_center(tiles, window, cx))
});
cx.run_until_parked();
assert!(!cx.read(|cx| fixture.dock_area.read(cx).is_center_empty(cx)));
for panel in panels {
cx.update(|window, cx| {
tab_panel.update(cx, |tab_panel, cx| {
tab_panel.remove_panel(Arc::new(panel.clone()), window, cx)
})
});
}
cx.run_until_parked();
let tiles = cx.read(|cx| {
let DockItem::Tiles { view, .. } = fixture.dock_area.read(cx).center() else {
unreachable!("the centre is a Tiles item");
};
view.read(cx).panels().len()
});
assert_eq!(tiles, 1, "the emptied TabPanel is still listed as a tile");
assert!(cx.read(|cx| fixture.dock_area.read(cx).is_center_empty(cx)));
}
#[gpui::test]
fn single_panel_group_receives_initial_active(cx: &mut TestAppContext) {
let fixture = setup(cx);
@@ -2287,6 +2165,59 @@ mod tests {
assert_eq!(drain(&fixture.log), [("A", false)]);
}
/// Adding a panel to the right of the center splits it horizontally;
/// a later bottom add wraps the split in a vertical one.
#[gpui::test]
fn add_panel_splits_the_center(cx: &mut TestAppContext) {
let fixture = setup(cx);
let mut cx = VisualTestContext::from_window(fixture.window.into(), cx);
let (item_a, _, _) = build_tabs(&fixture, &["A"], None, &mut cx);
let (_item_b, _, panels_b) = build_tabs(&fixture, &["B"], None, &mut cx);
let (_item_c, _, panels_c) = build_tabs(&fixture, &["C"], None, &mut cx);
cx.update(|window, cx| {
fixture.dock_area.update(cx, |dock_area, cx| {
dock_area.set_center(item_a, window, cx);
dock_area.add_panel(
Arc::new(panels_b[0].clone()),
DockPlacement::Right,
window,
cx,
);
});
});
let (axis, len) = cx.read(|cx| match fixture.dock_area.read(cx).center() {
DockItem::Split { axis, items, .. } => (*axis, items.len()),
other => panic!("center must be a horizontal split, got {other:?}"),
});
assert_eq!(axis, Axis::Horizontal);
assert_eq!(len, 2, "the right panel must be appended to the split");
cx.update(|window, cx| {
fixture.dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel(
Arc::new(panels_c[0].clone()),
DockPlacement::Bottom,
window,
cx,
);
});
});
cx.read(|cx| match fixture.dock_area.read(cx).center() {
DockItem::Split {
axis, items, sizes, ..
} => {
assert_eq!(*axis, Axis::Vertical);
assert_eq!(items.len(), 2);
assert_eq!(sizes.len(), 2);
}
other => panic!("center must be wrapped in a vertical split, got {other:?}"),
});
}
#[derive(Default)]
struct ProbeFlags {
empty_down: AtomicBool,