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