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