From 1956cb96bbca7d1661a9f2210c080bf32fed146d Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 23 Aug 2026 10:43:59 +0700 Subject: [PATCH] update dock --- Cargo.lock | 91 ++++++++++++++++++++++++-- crates/dock/Cargo.toml | 3 + crates/dock/src/tab_panel.rs | 103 ++++++++++++++++++++++++------ crates/dock/tests/render_smoke.rs | 99 ++++++++++++++++++++++++++++ crates/workspace/src/workspace.rs | 28 +++++++- 5 files changed, 299 insertions(+), 25 deletions(-) create mode 100644 crates/dock/tests/render_smoke.rs diff --git a/Cargo.lock b/Cargo.lock index 450a341..ec41b5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1363,6 +1363,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1714,7 +1723,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", @@ -3486,6 +3495,7 @@ dependencies = [ "anyhow", "async-channel", "async-task", + "backtrace", "bindgen", "bitflags 2.13.1", "chrono", @@ -3516,6 +3526,7 @@ dependencies = [ "pollster 0.4.0", "postage", "profiling", + "proptest", "rand 0.9.5", "raw-window-handle", "refineable", @@ -4498,7 +4509,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ "byteorder-lite", - "quick-error", + "quick-error 2.0.1", ] [[package]] @@ -6613,6 +6624,36 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "proptest" +version = "1.10.0" +source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.1", + "num-traits", + "proptest-macro", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "proptest-macro" +version = "0.5.0" +source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" +dependencies = [ + "convert_case 0.11.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "psm" version = "0.1.32" @@ -6660,6 +6701,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -6838,6 +6885,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "range-alloc" version = "0.1.5" @@ -6894,7 +6950,7 @@ dependencies = [ "avif-serialize", "imgref", "loop9", - "quick-error", + "quick-error 2.0.1", "rav1e", "rayon", "rgb", @@ -7446,6 +7502,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + [[package]] name = "rustybuzz" version = "0.20.1" @@ -8592,7 +8660,7 @@ dependencies = [ "fax", "flate2", "half", - "quick-error", + "quick-error 2.0.1", "weezl", "zune-jpeg 0.5.15", ] @@ -9398,6 +9466,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.9.0" @@ -9701,6 +9775,15 @@ dependencies = [ "libc", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "waker-fn" version = "1.2.0" diff --git a/crates/dock/Cargo.toml b/crates/dock/Cargo.toml index 2218da5..6009a56 100644 --- a/crates/dock/Cargo.toml +++ b/crates/dock/Cargo.toml @@ -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"] } diff --git a/crates/dock/src/tab_panel.rs b/crates/dock/src/tab_panel.rs index 4e77d0b..8bcb529 100644 --- a/crates/dock/src/tab_panel.rs +++ b/crates/dock/src/tab_panel.rs @@ -97,15 +97,6 @@ fn zoom_control(group: &TabGroupContext, cx: &App) -> Option { .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 { - 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 { @@ -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 { + 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 { + 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
{ 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. diff --git a/crates/dock/tests/render_smoke.rs b/crates/dock/tests/render_smoke.rs new file mode 100644 index 0000000..02eed70 --- /dev/null +++ b/crates/dock/tests/render_smoke.rs @@ -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 { + 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) -> impl IntoElement { + "Probe" + } +} + +impl EventEmitter 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) -> 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)); +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 99a4a7c..0475a86 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1,4 +1,4 @@ -use dock::{DockArea, DockLayout, DockPlacement, SignedDockSkin, panel_handle}; +use dock::{DockArea, DockEvent, DockLayout, DockPlacement, SignedDockSkin, panel_handle}; use gpui::prelude::*; use gpui::{Context, Entity, Render, Subscription, Window, div, px}; use gpui_component::{Root, StyledExt, Theme}; @@ -40,6 +40,32 @@ impl Workspace { let mut subscriptions = vec![]; + // A bottom/right dock whose last panel was dragged away is removed + // entirely: base keeps the emptied region, which would otherwise + // linger as a bare strip. Deferred, because the event arrives while + // the area is mid-update. + let dock_for_pruning = dock.clone(); + subscriptions.push(cx.subscribe_in( + &dock, + window, + move |_, _, event: &DockEvent, window, cx| { + if !matches!(event, DockEvent::LayoutChanged) { + return; + } + let dock = dock_for_pruning.clone(); + cx.spawn_in(window, async move |_, window| { + _ = dock.update_in(window, |area, window, cx| { + for placement in [DockPlacement::Bottom, DockPlacement::Right] { + if area.is_empty(placement, cx) { + area.remove_dock(placement, window, cx); + } + } + }); + }) + .detach(); + }, + )); + subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| { Theme::sync_system_appearance(Some(window), cx); }));