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::{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,
10};
11use gpui_base::{
12    HandleEdge, ResizeHandleContext,
13    dock::{
14        DockAreaRenderer, DockContext, DockEvent, DockPlacement, NodeId, PanelState, PanelView,
15        TabGroupRenderer,
16    },
17};
18
19use crate::{
20    ActiveTheme as _,
21    dock::{
22        DockSkin, SkinShared, invalid_panel::InvalidPanel, panel_handle, report_user_toggle,
23        tab_panel::TabGroupSkin,
24    },
25    resizable::render_resize_handle,
26    resize_handle,
27};
28
29/// The payload a dock's resize handle drags. It draws nothing: the handle
30/// itself is the affordance.
31#[derive(Clone)]
32struct ResizePanel;
33
34impl Render for ResizePanel {
35    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
36        Empty
37    }
38}
39
40impl DockAreaRenderer for DockSkin {
41    // The row, the fill and the clip are base's now -- applied around whatever
42    // these return -- so a skin that has no appearance to add returns a bare
43    // frame and still gets a dock area the right shape.
44    fn frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
45        div().id("dock-area")
46    }
47
48    fn center_frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
49        div().id("dock-area-center")
50    }
51
52    fn render_split_handle(
53        &self,
54        handle: &ResizeHandleContext,
55        window: &mut Window,
56        cx: &mut App,
57    ) -> Option<AnyElement> {
58        Some(render_resize_handle(handle, window, cx))
59    }
60
61    fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, cx: &mut App) -> Stateful<Div> {
62        // The size is base's; the background is this skin's, and is the only
63        // reason this hook is implemented at all.
64        div()
65            .id(("dock-split-frame", node.as_u64()))
66            .bg(cx.theme().tokens.tab_bar)
67    }
68
69    fn render_dock(
70        &self,
71        dock: &DockContext,
72        content: AnyElement,
73        window: &mut Window,
74        cx: &mut App,
75    ) -> AnyElement {
76        // No box here any more. A dock's extent is structural, so
77        // `DockArea::render_dock` applies it around whatever this returns --
78        // which also means a renderer that draws no chrome still gets a dock
79        // the right shape. This adds the edge you drag and nothing else.
80        div()
81            .flex()
82            .size_full()
83            .relative()
84            .child(content)
85            .child(self.render_resize_handle(dock, window, cx))
86            .child(DockResizeTracker {
87                dock: dock.clone(),
88                shared: self.shared().clone(),
89            })
90            .into_any_element()
91    }
92
93    /// The "unknown panel" message the old `InvalidPanel` drew.
94    ///
95    /// It answers `dump` with the state it was handed, so a layout written by
96    /// a build that knows the panel survives a load and save here.
97    fn build_placeholder(
98        &self,
99        state: &PanelState,
100        _: &mut Window,
101        cx: &mut App,
102    ) -> Option<Arc<dyn PanelView>> {
103        let state = state.clone();
104        Some(panel_handle(cx.new(|cx| InvalidPanel::new(state, cx))))
105    }
106
107    fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
108        Rc::new(TabGroupSkin::new(self.shared().clone()))
109    }
110}
111
112impl DockSkin {
113    fn render_resize_handle(
114        &self,
115        dock: &DockContext,
116        _: &mut Window,
117        _: &mut App,
118    ) -> impl IntoElement {
119        let placement = dock.placement();
120        let open = dock.is_open();
121        let shared = self.shared().clone();
122
123        // One id per placement: the docks all render under the same stateful
124        // ancestor, so a shared literal would collapse the handles into one
125        // GlobalElementId and GPUI would silently share their element state —
126        // a press on the left handle then starts the right handle's drag.
127        let id = match placement {
128            DockPlacement::Left => "resize-handle-left",
129            DockPlacement::Right => "resize-handle-right",
130            DockPlacement::Bottom => "resize-handle-bottom",
131            DockPlacement::Center => "resize-handle-center",
132        };
133
134        // Every dock's handle lives inside the dock, because `dock_frame` clips
135        // to the dock's box. The left dock hugs its trailing edge; the right
136        // and bottom docks hug their leading one.
137        let edge = match placement {
138            DockPlacement::Left => HandleEdge::Trailing,
139            _ => HandleEdge::Leading,
140        };
141
142        resize_handle(id, placement.axis()).inside(edge).on_drag(
143            ResizePanel,
144            move |info, _, _, cx| {
145                cx.stop_propagation();
146                shared.resizing_dock().set(Some(placement));
147                shared.resize_started_open().set(open);
148                cx.new(|_| info.clone())
149            },
150        )
151    }
152}
153
154/// Turns the window's mouse stream into dock resizing.
155///
156/// A resize is driven by pointer moves that land anywhere in the window, not
157/// only on the handle, so it cannot be expressed as a listener on the handle
158/// itself. This element paints nothing and exists for its `paint` hook, which
159/// is the only place a window-level mouse listener can be registered — which
160/// is why it stays in the skin rather than moving into base: it is a
161/// paint-order concern of this appearance.
162struct DockResizeTracker {
163    dock: DockContext,
164    shared: Rc<SkinShared>,
165}
166
167impl IntoElement for DockResizeTracker {
168    type Element = Self;
169
170    fn into_element(self) -> Self::Element {
171        self
172    }
173}
174
175impl Element for DockResizeTracker {
176    type RequestLayoutState = ();
177    type PrepaintState = ();
178
179    fn id(&self) -> Option<gpui::ElementId> {
180        None
181    }
182
183    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
184        None
185    }
186
187    fn request_layout(
188        &mut self,
189        _: Option<&gpui::GlobalElementId>,
190        _: Option<&gpui::InspectorElementId>,
191        window: &mut Window,
192        cx: &mut App,
193    ) -> (gpui::LayoutId, Self::RequestLayoutState) {
194        (window.request_layout(Style::default(), None, cx), ())
195    }
196
197    fn prepaint(
198        &mut self,
199        _: Option<&gpui::GlobalElementId>,
200        _: Option<&gpui::InspectorElementId>,
201        _: gpui::Bounds<Pixels>,
202        _: &mut Self::RequestLayoutState,
203        _: &mut Window,
204        _: &mut App,
205    ) -> Self::PrepaintState {
206    }
207
208    fn paint(
209        &mut self,
210        _: Option<&gpui::GlobalElementId>,
211        _: Option<&gpui::InspectorElementId>,
212        _: gpui::Bounds<Pixels>,
213        _: &mut Self::RequestLayoutState,
214        _: &mut Self::PrepaintState,
215        window: &mut Window,
216        _: &mut App,
217    ) {
218        let placement = self.dock.placement();
219
220        window.on_mouse_event({
221            let dock = self.dock.clone();
222            let shared = self.shared.clone();
223            move |event: &MouseMoveEvent, phase, window, cx| {
224                if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
225                    return;
226                }
227                // Base opens, closes and sizes the dock from the pointer, so a
228                // drag that started on a closed dock reopens it too.
229                dock.resize_to(event.position, window, cx);
230            }
231        });
232
233        window.on_mouse_event({
234            let dock = self.dock.clone();
235            let shared = self.shared.clone();
236            move |_: &MouseUpEvent, phase, window, cx| {
237                if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
238                    return;
239                }
240                shared.resizing_dock().set(None);
241                dock.end_resize(window, cx);
242                let was_open = shared.resize_started_open().get();
243                // The size lives on the dock, not in the layout tree, so
244                // nothing else tells a subscriber to persist it.
245                _ = shared.area().update(cx, |area, cx| {
246                    cx.emit(DockEvent::LayoutChanged);
247                    report_user_toggle(area, placement, was_open, cx);
248                });
249            }
250        });
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use std::rc::Rc;
257
258    use gpui::{
259        App, Entity, IntoElement as _, Modifiers, MouseButton, TestAppContext, VisualTestContext,
260        Window, point, px, size,
261    };
262
263    use std::cell::Cell;
264
265    use gpui_base::dock::DockAreaRenderer;
266
267    use gpui_base::PANEL_MIN_SIZE;
268
269    use crate::dock::{
270        DockArea, DockLayout, DockPlacement, DockSkin,
271        test_support::{MeasuredProbe, SizedProbe},
272    };
273
274    /// A renderer that draws no chrome at all: every hook at its trait default.
275    ///
276    /// This is the position every renderer that is not `DockSkin` starts from,
277    /// including the one gpui-shell installs so a script can draw the chrome
278    /// itself, and the shape of what it gets is base's promise.
279    struct ChromelessDockSkin;
280
281    impl DockAreaRenderer for ChromelessDockSkin {
282        fn tab_group_renderer(&self) -> Rc<dyn gpui_base::dock::TabGroupRenderer> {
283            Rc::new(ChromelessTabs)
284        }
285    }
286
287    struct ChromelessTabs;
288    impl gpui_base::dock::TabGroupRenderer for ChromelessTabs {
289        // The one hook with no default, because a group with no tab bar has no
290        // way to choose between its panels. Drawn as nothing, so the height it
291        // leaves the content is the whole group.
292        fn render_tab_bar(
293            &self,
294            _: &gpui_base::dock::TabGroupContext,
295            _: &mut Window,
296            _: &mut App,
297        ) -> gpui::AnyElement {
298            gpui::Empty.into_any_element()
299        }
300    }
301
302    /// A dock's box is base's, not its renderer's.
303    ///
304    /// This is the regression. The extent lived in `DockSkin::render_dock`, so
305    /// it was reachable only through that one renderer, and `render_dock`'s
306    /// trait default hands the content straight back. A dock that never states
307    /// its extent is not a column beside the centre: it takes whatever the row
308    /// gives it and the panes inside shrink to their content. Nothing failed,
309    /// and nothing said why.
310    #[gpui::test]
311    fn a_dock_is_its_own_width_under_a_renderer_that_draws_no_chrome(cx: &mut TestAppContext) {
312        cx.update(|cx| crate::init(cx));
313        let measured = Rc::new(Cell::new(gpui::Size::default()));
314        let probe = measured.clone();
315        let centre = Rc::new(Cell::new(gpui::Size::default()));
316        let centre_probe = centre.clone();
317        let (area, cx) = cx.add_window_view(|window, cx| {
318            DockArea::new("test", None, window, cx).with_renderer(Rc::new(ChromelessDockSkin))
319        });
320        cx.simulate_resize(size(px(800.), px(600.)));
321        cx.update(|window, cx| {
322            area.update(cx, |area, cx| {
323                area.set_center(
324                    DockLayout::tabs().panel(SizedProbe::new(centre_probe, cx)),
325                    window,
326                    cx,
327                );
328                area.set_dock(
329                    DockPlacement::Right,
330                    DockLayout::tabs().panel(SizedProbe::new(probe, cx)),
331                    window,
332                    cx,
333                );
334                area.set_dock_size(DockPlacement::Right, px(200.), window, cx);
335            });
336        });
337        cx.run_until_parked();
338        cx.update(|window, cx| {
339            area.update(cx, |area, cx| {
340                if !area.is_dock_open(DockPlacement::Right) {
341                    area.toggle_dock(DockPlacement::Right, window, cx);
342                }
343            });
344        });
345        cx.run_until_parked();
346        cx.update(|window, cx| window.draw(cx).clear(cx));
347
348        let dock = measured.get().width;
349        assert_eq!(
350            dock,
351            px(200.),
352            "the right dock has to be its own width, not the area's: got {dock:?}"
353        );
354        // The other half of the same fault: a dock area that is not a row puts
355        // the centre above the dock at the area's full width instead of beside
356        // it at what the dock leaves.
357        let middle = centre.get().width;
358        assert_eq!(
359            middle,
360            px(600.),
361            "the centre has to be what the docks leave: got {middle:?}"
362        );
363        // This renderer draws no tab bar, so a group that fills its slot leaves
364        // its panel the whole 600. A tab group frame without a column and a
365        // fill gives it nothing: the panel is positioned absolutely inside the
366        // content region and contributes no height of its own, so the region
367        // resolves to zero and the group is a strip of tabs.
368        let tall = centre.get().height;
369        assert_eq!(
370            tall,
371            px(600.),
372            "a group has to fill its slot, or its panel gets no height: got {tall:?}"
373        );
374    }
375
376    fn area_with_side_docks(cx: &mut TestAppContext) -> (Entity<DockArea>, &mut VisualTestContext) {
377        cx.update(|cx| crate::init(cx));
378        let (area, cx) = cx.add_window_view(|window, cx| {
379            DockArea::new("test", None, window, cx).with_renderer(DockSkin::new(cx))
380        });
381        cx.simulate_resize(size(px(800.), px(600.)));
382        cx.update(|window, cx| {
383            area.update(cx, |area, cx| {
384                area.set_center(
385                    DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
386                    window,
387                    cx,
388                );
389                area.set_dock(
390                    DockPlacement::Left,
391                    DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
392                    window,
393                    cx,
394                );
395                area.set_dock(
396                    DockPlacement::Right,
397                    DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
398                    window,
399                    cx,
400                );
401            });
402        });
403        cx.run_until_parked();
404        (area, cx)
405    }
406
407    /// Every dock's handle once shared the literal element id
408    /// `"resize-handle"`, so GPUI silently handed them one element state —
409    /// including the pending-mouse-down that starts a drag. Pressing the left
410    /// handle then let the right dock's drag listener (painted later, so
411    /// dispatched first) claim the drag, and one pixel of movement threw the
412    /// right dock to nearly the full area width.
413    #[gpui::test]
414    fn dragging_the_left_handle_resizes_only_the_left_dock(cx: &mut TestAppContext) {
415        let (area, cx) = area_with_side_docks(cx);
416        cx.update(|window, cx| window.draw(cx).clear(cx));
417
418        // The left dock is 200px wide, so its handle sits at x ∈ [198, 199).
419        cx.simulate_mouse_down(
420            point(px(198.5), px(300.)),
421            MouseButton::Left,
422            Modifiers::none(),
423        );
424        // Past the drag threshold: the drag starts and claims a dock.
425        cx.simulate_mouse_move(
426            point(px(204.), px(300.)),
427            MouseButton::Left,
428            Modifiers::none(),
429        );
430        // The move the claimed dock resizes to.
431        cx.simulate_mouse_move(
432            point(px(240.), px(300.)),
433            MouseButton::Left,
434            Modifiers::none(),
435        );
436        cx.simulate_mouse_up(
437            point(px(240.), px(300.)),
438            MouseButton::Left,
439            Modifiers::none(),
440        );
441        cx.run_until_parked();
442
443        let (left, right) = cx.update(|_, cx| {
444            let area = area.read(cx);
445            (
446                area.dock_size(DockPlacement::Left),
447                area.dock_size(DockPlacement::Right),
448            )
449        });
450        assert_eq!(
451            right,
452            Some(px(200.)),
453            "the right dock must not move when the left handle is dragged"
454        );
455        assert_eq!(left, Some(px(240.)), "the left dock follows the pointer");
456    }
457    #[gpui::test]
458    fn dragging_the_bottom_dock_closed_and_open_reports_each_toggle(cx: &mut TestAppContext) {
459        cx.update(|cx| crate::init(cx));
460        let (area, cx) = cx.add_window_view(|window, cx| {
461            DockArea::new("test", None, window, cx).with_renderer(DockSkin::new(cx))
462        });
463        cx.simulate_resize(size(px(800.), px(600.)));
464        let events = Rc::new(std::cell::RefCell::new(Vec::new()));
465        cx.update(|window, cx| {
466            area.update(cx, |area, cx| {
467                area.set_center(
468                    DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
469                    window,
470                    cx,
471                );
472                area.set_dock(
473                    DockPlacement::Bottom,
474                    DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
475                    window,
476                    cx,
477                );
478                area.set_dock_size(DockPlacement::Bottom, px(200.), window, cx);
479            });
480            let events = events.clone();
481            cx.subscribe(&area, move |_, event: &crate::dock::DockToggleEvent, _| {
482                events
483                    .borrow_mut()
484                    .push((event.placement(), event.is_open()));
485            })
486            .detach();
487        });
488        cx.run_until_parked();
489
490        let drag = |cx: &mut VisualTestContext, ys: &[f32]| {
491            cx.update(|window, cx| window.draw(cx).clear(cx));
492            let (first, rest) = ys.split_first().unwrap();
493            cx.simulate_mouse_down(
494                point(px(400.), px(*first)),
495                MouseButton::Left,
496                Modifiers::none(),
497            );
498            for y in rest {
499                cx.simulate_mouse_move(
500                    point(px(400.), px(*y)),
501                    MouseButton::Left,
502                    Modifiers::none(),
503                );
504            }
505            cx.simulate_mouse_up(
506                point(px(400.), px(*ys.last().unwrap())),
507                MouseButton::Left,
508                Modifiers::none(),
509            );
510            cx.run_until_parked();
511        };
512
513        // A resize that keeps the dock open is not a toggle.
514        drag(cx, &[400.5, 380., 350.]);
515        assert!(events.borrow().is_empty(), "a plain resize is not reported");
516
517        // Same handle, same strip geometry as the snapping test below.
518        drag(cx, &[350.5, 356., 580., 540.]);
519        drag(cx, &[571.5, 565., 520.]);
520        assert_eq!(
521            *events.borrow(),
522            vec![
523                (DockPlacement::Bottom, false),
524                (DockPlacement::Bottom, true)
525            ]
526        );
527    }
528
529    #[gpui::test]
530    fn dragging_the_bottom_handle_below_the_minimum_snaps_to_the_nearer_end(
531        cx: &mut TestAppContext,
532    ) {
533        cx.update(|cx| crate::init(cx));
534        let (area, cx) = cx.add_window_view(|window, cx| {
535            DockArea::new("test", None, window, cx).with_renderer(DockSkin::new(cx))
536        });
537        cx.simulate_resize(size(px(800.), px(600.)));
538        cx.update(|window, cx| {
539            area.update(cx, |area, cx| {
540                area.set_center(
541                    DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
542                    window,
543                    cx,
544                );
545                area.set_dock(
546                    DockPlacement::Bottom,
547                    DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)),
548                    window,
549                    cx,
550                );
551                area.set_dock_size(DockPlacement::Bottom, px(200.), window, cx);
552            });
553        });
554        cx.run_until_parked();
555
556        let drag = |cx: &mut VisualTestContext, ys: &[f32]| {
557            cx.update(|window, cx| window.draw(cx).clear(cx));
558            let (first, rest) = ys.split_first().unwrap();
559            cx.simulate_mouse_down(
560                point(px(400.), px(*first)),
561                MouseButton::Left,
562                Modifiers::none(),
563            );
564            for y in rest {
565                cx.simulate_mouse_move(
566                    point(px(400.), px(*y)),
567                    MouseButton::Left,
568                    Modifiers::none(),
569                );
570            }
571            cx.simulate_mouse_up(
572                point(px(400.), px(*ys.last().unwrap())),
573                MouseButton::Left,
574                Modifiers::none(),
575            );
576            cx.run_until_parked();
577        };
578        let bottom = |cx: &mut VisualTestContext| {
579            cx.update(|_, cx| {
580                let area = area.read(cx);
581                (
582                    area.is_dock_open(DockPlacement::Bottom),
583                    area.dock_size(DockPlacement::Bottom),
584                )
585            })
586        };
587
588        // From the 200px dock's handle at y ∈ [400, 401), through the closed
589        // strip and back, released 60px tall: nearer the strip than the
590        // minimum, so it closes.
591        drag(cx, &[400.5, 406., 580., 540.]);
592        assert!(!bottom(cx).0, "released nearer the strip, the dock closes");
593
594        // From the closed strip's handle at y ∈ [571, 572), released 80px
595        // tall: nearer the minimum, so it opens at the minimum.
596        drag(cx, &[571.5, 565., 520.]);
597        assert_eq!(
598            bottom(cx),
599            (true, Some(PANEL_MIN_SIZE)),
600            "released nearer the minimum, the dock opens at the minimum"
601        );
602    }
603}