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