Skip to main content

gpui_component/dock/
dock.rs

1//! The gpui-component appearance for the dock area: the outer frame, the
2//! split frames, and one dock's chrome.
3
4use std::{ops::Deref as _, rc::Rc, sync::Arc};
5
6use gpui::{
7    AnyElement, App, AppContext as _, Axis, Context, Div, Element, Empty, InteractiveElement as _,
8    IntoElement, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, Stateful, Style,
9    Styled as _, Window, div, prelude::FluentBuilder as _,
10};
11use gpui_base::dock::{
12    DockAreaRenderer, DockContext, DockEvent, DockPlacement, NodeId, PanelState, PanelView,
13    TabGroupRenderer,
14};
15
16use crate::{
17    ActiveTheme as _, Side,
18    dock::{
19        DockSkin, SkinShared, invalid_panel::InvalidPanel, panel_handle, tab_panel::TabGroupSkin,
20    },
21    resize_handle,
22};
23
24/// The payload a dock's resize handle drags. It draws nothing: the handle
25/// itself is the affordance.
26#[derive(Clone)]
27struct ResizePanel;
28
29impl Render for ResizePanel {
30    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
31        Empty
32    }
33}
34
35impl DockAreaRenderer for DockSkin {
36    // The row, the fill and the clip are base's now -- applied around whatever
37    // these return -- so a skin that has no appearance to add returns a bare
38    // frame and still gets a dock area the right shape.
39    fn frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
40        div().id("dock-area")
41    }
42
43    fn center_frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
44        div().id("dock-area-center")
45    }
46
47    fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, cx: &mut App) -> Stateful<Div> {
48        // The size is base's; the background is this skin's, and is the only
49        // reason this hook is implemented at all.
50        div()
51            .id(("dock-split-frame", node.as_u64()))
52            .bg(cx.theme().tokens.tab_bar)
53    }
54
55    fn render_dock(
56        &self,
57        dock: &DockContext,
58        content: AnyElement,
59        window: &mut Window,
60        cx: &mut App,
61    ) -> AnyElement {
62        // No box here any more. A dock's extent is structural, so
63        // `DockArea::render_dock` applies it around whatever this returns --
64        // which also means a renderer that draws no chrome still gets a dock
65        // the right shape. This adds the edge you drag and nothing else.
66        div()
67            .flex()
68            .size_full()
69            .relative()
70            .child(content)
71            .child(self.render_resize_handle(dock, window, cx))
72            .child(DockResizeTracker {
73                dock: dock.clone(),
74                shared: self.shared().clone(),
75            })
76            .into_any_element()
77    }
78
79    /// The "unknown panel" message the old `InvalidPanel` drew.
80    ///
81    /// It answers `dump` with the state it was handed, so a layout written by
82    /// a build that knows the panel survives a load and save here.
83    fn build_placeholder(
84        &self,
85        state: &PanelState,
86        _: &mut Window,
87        cx: &mut App,
88    ) -> Option<Arc<dyn PanelView>> {
89        let state = state.clone();
90        Some(panel_handle(cx.new(|cx| {
91            InvalidPanel::new(state.panel_name.clone(), state, cx)
92        })))
93    }
94
95    fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
96        Rc::new(TabGroupSkin::new(self.shared().clone()))
97    }
98}
99
100impl DockSkin {
101    fn render_resize_handle(
102        &self,
103        dock: &DockContext,
104        _: &mut Window,
105        _: &mut App,
106    ) -> impl IntoElement {
107        let placement = dock.placement();
108        let shared = self.shared().clone();
109
110        // One id per placement: the docks all render under the same stateful
111        // ancestor, so a shared literal would collapse the handles into one
112        // GlobalElementId and GPUI would silently share their element state —
113        // a press on the left handle then starts the right handle's drag.
114        let id = match placement {
115            DockPlacement::Left => "resize-handle-left",
116            DockPlacement::Right => "resize-handle-right",
117            DockPlacement::Bottom => "resize-handle-bottom",
118            DockPlacement::Center => "resize-handle-center",
119        };
120
121        resize_handle(id, placement.axis())
122            .when(placement.is_left(), |this| this.placement(Side::Left))
123            .on_drag(ResizePanel, move |info, _, _, cx| {
124                cx.stop_propagation();
125                shared.resizing_dock().set(Some(placement));
126                cx.new(|_| info.deref().clone())
127            })
128    }
129}
130
131/// Turns the window's mouse stream into dock resizing.
132///
133/// A resize is driven by pointer moves that land anywhere in the window, not
134/// only on the handle, so it cannot be expressed as a listener on the handle
135/// itself. This element paints nothing and exists for its `paint` hook, which
136/// is the only place a window-level mouse listener can be registered — which
137/// is why it stays in the skin rather than moving into base: it is a
138/// paint-order concern of this appearance.
139struct DockResizeTracker {
140    dock: DockContext,
141    shared: Rc<SkinShared>,
142}
143
144impl IntoElement for DockResizeTracker {
145    type Element = Self;
146
147    fn into_element(self) -> Self::Element {
148        self
149    }
150}
151
152impl Element for DockResizeTracker {
153    type RequestLayoutState = ();
154    type PrepaintState = ();
155
156    fn id(&self) -> Option<gpui::ElementId> {
157        None
158    }
159
160    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
161        None
162    }
163
164    fn request_layout(
165        &mut self,
166        _: Option<&gpui::GlobalElementId>,
167        _: Option<&gpui::InspectorElementId>,
168        window: &mut Window,
169        cx: &mut App,
170    ) -> (gpui::LayoutId, Self::RequestLayoutState) {
171        (window.request_layout(Style::default(), None, cx), ())
172    }
173
174    fn prepaint(
175        &mut self,
176        _: Option<&gpui::GlobalElementId>,
177        _: Option<&gpui::InspectorElementId>,
178        _: gpui::Bounds<Pixels>,
179        _: &mut Self::RequestLayoutState,
180        _: &mut Window,
181        _: &mut App,
182    ) -> Self::PrepaintState {
183    }
184
185    fn paint(
186        &mut self,
187        _: Option<&gpui::GlobalElementId>,
188        _: Option<&gpui::InspectorElementId>,
189        _: gpui::Bounds<Pixels>,
190        _: &mut Self::RequestLayoutState,
191        _: &mut Self::PrepaintState,
192        window: &mut Window,
193        _: &mut App,
194    ) {
195        let placement = self.dock.placement();
196
197        window.on_mouse_event({
198            let dock = self.dock.clone();
199            let shared = self.shared.clone();
200            move |event: &MouseMoveEvent, phase, window, cx| {
201                if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
202                    return;
203                }
204                // Dragging a closed dock's handle reopens it, as the old dock
205                // did. The live state is read rather than the render-time
206                // snapshot in `dock`, which would still say closed for the
207                // rest of the frame and toggle it shut again on the next move.
208                let open = shared
209                    .area()
210                    .upgrade()
211                    .is_some_and(|area| area.read(cx).is_dock_open(placement));
212                if !open {
213                    dock.toggle(window, cx);
214                }
215                dock.resize_to(event.position, window, cx);
216            }
217        });
218
219        window.on_mouse_event({
220            let shared = self.shared.clone();
221            move |_: &MouseUpEvent, phase, _, cx| {
222                if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
223                    return;
224                }
225                shared.resizing_dock().set(None);
226                // The size lives on the dock, not in the layout tree, so
227                // nothing else tells a subscriber to persist it.
228                _ = shared
229                    .area()
230                    .update(cx, |_, cx| cx.emit(DockEvent::LayoutChanged));
231            }
232        });
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use std::rc::Rc;
239
240    use gpui::{
241        App, Entity, IntoElement as _, Modifiers, MouseButton, TestAppContext, VisualTestContext,
242        Window, point, px, size,
243    };
244
245    use std::cell::Cell;
246
247    use gpui_base::dock::DockAreaRenderer;
248
249    use crate::dock::{
250        DockArea, DockLayout, DockPlacement, DockSkin,
251        test_support::{MeasuredProbe, SizedProbe},
252    };
253
254    /// A renderer that draws no chrome at all: every hook at its trait default.
255    ///
256    /// This is the position every renderer that is not `DockSkin` starts from,
257    /// including the one gpui-shell installs so a script can draw the chrome
258    /// itself, and the shape of what it gets is base's promise.
259    struct ChromelessDockSkin;
260
261    impl DockAreaRenderer for ChromelessDockSkin {
262        fn tab_group_renderer(&self) -> Rc<dyn gpui_base::dock::TabGroupRenderer> {
263            Rc::new(ChromelessTabs)
264        }
265    }
266
267    struct ChromelessTabs;
268    impl gpui_base::dock::TabGroupRenderer for ChromelessTabs {
269        // The one hook with no default, because a group with no tab bar has no
270        // way to choose between its panels. Drawn as nothing, so the height it
271        // leaves the content is the whole group.
272        fn render_tab_bar(
273            &self,
274            _: &gpui_base::dock::TabGroupContext,
275            _: &mut Window,
276            _: &mut App,
277        ) -> gpui::AnyElement {
278            gpui::Empty.into_any_element()
279        }
280    }
281
282    /// A dock's box is base's, not its renderer's.
283    ///
284    /// This is the regression. The extent lived in `DockSkin::render_dock`, so
285    /// it was reachable only through that one renderer, and `render_dock`'s
286    /// trait default hands the content straight back. A dock that never states
287    /// its extent is not a column beside the centre: it takes whatever the row
288    /// gives it and the panes inside shrink to their content. Nothing failed,
289    /// and nothing said why.
290    #[gpui::test]
291    fn a_dock_is_its_own_width_under_a_renderer_that_draws_no_chrome(cx: &mut TestAppContext) {
292        cx.update(|cx| crate::init(cx));
293        let measured = Rc::new(Cell::new(gpui::Size::default()));
294        let probe = measured.clone();
295        let centre = Rc::new(Cell::new(gpui::Size::default()));
296        let centre_probe = centre.clone();
297        let (area, cx) = cx.add_window_view(|window, cx| {
298            DockArea::new("test", None, window, cx).with_renderer(Rc::new(ChromelessDockSkin))
299        });
300        cx.simulate_resize(size(px(800.), px(600.)));
301        cx.update(|window, cx| {
302            area.update(cx, |area, cx| {
303                area.set_center(
304                    DockLayout::tabs().panel(SizedProbe::new(centre_probe, cx)),
305                    window,
306                    cx,
307                );
308                area.set_dock(
309                    DockPlacement::Right,
310                    DockLayout::tabs().panel(SizedProbe::new(probe, cx)),
311                    window,
312                    cx,
313                );
314                area.set_dock_size(DockPlacement::Right, px(200.), window, cx);
315            });
316        });
317        cx.run_until_parked();
318        cx.update(|window, cx| {
319            area.update(cx, |area, cx| {
320                if !area.is_dock_open(DockPlacement::Right) {
321                    area.toggle_dock(DockPlacement::Right, window, cx);
322                }
323            });
324        });
325        cx.run_until_parked();
326        cx.update(|window, cx| window.draw(cx).clear(cx));
327
328        let dock = measured.get().width;
329        assert_eq!(
330            dock,
331            px(200.),
332            "the right dock has to be its own width, not the area's: got {dock:?}"
333        );
334        // The other half of the same fault: a dock area that is not a row puts
335        // the centre above the dock at the area's full width instead of beside
336        // it at what the dock leaves.
337        let middle = centre.get().width;
338        assert_eq!(
339            middle,
340            px(600.),
341            "the centre has to be what the docks leave: got {middle:?}"
342        );
343        // This renderer draws no tab bar, so a group that fills its slot leaves
344        // its panel the whole 600. A tab group frame without a column and a
345        // fill gives it nothing: the panel is positioned absolutely inside the
346        // content region and contributes no height of its own, so the region
347        // resolves to zero and the group is a strip of tabs.
348        let tall = centre.get().height;
349        assert_eq!(
350            tall,
351            px(600.),
352            "a group has to fill its slot, or its panel gets no height: got {tall:?}"
353        );
354    }
355
356    fn area_with_side_docks(cx: &mut TestAppContext) -> (Entity<DockArea>, &mut VisualTestContext) {
357        cx.update(|cx| crate::init(cx));
358        let (area, cx) = cx.add_window_view(|window, cx| {
359            DockArea::new("test", None, window, cx).with_renderer(DockSkin::new(cx))
360        });
361        cx.simulate_resize(size(px(800.), px(600.)));
362        cx.update(|window, cx| {
363            area.update(cx, |area, cx| {
364                area.set_center(
365                    DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
366                    window,
367                    cx,
368                );
369                area.set_dock(
370                    DockPlacement::Left,
371                    DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
372                    window,
373                    cx,
374                );
375                area.set_dock(
376                    DockPlacement::Right,
377                    DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
378                    window,
379                    cx,
380                );
381            });
382        });
383        cx.run_until_parked();
384        (area, cx)
385    }
386
387    /// Every dock's handle once shared the literal element id
388    /// `"resize-handle"`, so GPUI silently handed them one element state —
389    /// including the pending-mouse-down that starts a drag. Pressing the left
390    /// handle then let the right dock's drag listener (painted later, so
391    /// dispatched first) claim the drag, and one pixel of movement threw the
392    /// right dock to nearly the full area width.
393    #[gpui::test]
394    fn dragging_the_left_handle_resizes_only_the_left_dock(cx: &mut TestAppContext) {
395        let (area, cx) = area_with_side_docks(cx);
396        cx.update(|window, cx| window.draw(cx).clear(cx));
397
398        // The left dock is 200px wide, so its handle sits at x ∈ [198, 199).
399        cx.simulate_mouse_down(
400            point(px(198.5), px(300.)),
401            MouseButton::Left,
402            Modifiers::none(),
403        );
404        // Past the drag threshold: the drag starts and claims a dock.
405        cx.simulate_mouse_move(
406            point(px(204.), px(300.)),
407            MouseButton::Left,
408            Modifiers::none(),
409        );
410        // The move the claimed dock resizes to.
411        cx.simulate_mouse_move(
412            point(px(240.), px(300.)),
413            MouseButton::Left,
414            Modifiers::none(),
415        );
416        cx.simulate_mouse_up(
417            point(px(240.), px(300.)),
418            MouseButton::Left,
419            Modifiers::none(),
420        );
421        cx.run_until_parked();
422
423        let (left, right) = cx.update(|_, cx| {
424            let area = area.read(cx);
425            (
426                area.dock_size(DockPlacement::Left),
427                area.dock_size(DockPlacement::Right),
428            )
429        });
430        assert_eq!(
431            right,
432            Some(px(200.)),
433            "the right dock must not move when the left handle is dragged"
434        );
435        assert_eq!(left, Some(px(240.)), "the left dock follows the pointer");
436    }
437}