Skip to main content

gpui_component/dock/
panel.rs

1//! The presentation half of a dockable panel, and the concrete handle that
2//! carries it back across the renderer seam.
3//!
4//! `gpui-base` owns panel *behavior*: [`gpui_base::dock::Panel`] and its
5//! object-safe mirror answer for the name, the id, closability, visibility,
6//! and the activation callbacks. Everything a tab bar actually draws — the
7//! title, the tab name, the toolbar, the ellipsis menu — is presentation and
8//! lives here.
9//!
10//! A sub-trait alone cannot join the two. Base hands a skin
11//! `Arc<dyn gpui_base::dock::PanelView>`; `Arc<dyn PanelView>` coerces *to*
12//! that, and Rust has no coercion back, so a renderer holding base's handle
13//! can never reach a presentation method through it. What does work is a
14//! *concrete* type: [`PanelHandle`] wraps `Arc<dyn PanelView>` and implements
15//! base's trait by delegation, so base holds a `PanelHandle` and the skin
16//! recovers it with [`PanelHandle::of`], which downcasts to a single known
17//! type.
18
19use std::{any::Any, sync::Arc};
20
21use gpui::{
22    AnyElement, AnyView, App, Context, Entity, FocusHandle, Hsla, IntoElement, SharedString,
23    WeakEntity, Window,
24};
25use gpui_base::dock::{PanelId, PanelState, TabGroup};
26use rust_i18n::t;
27
28use crate::{button::Button, menu::PopupMenu};
29
30#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
31pub enum PanelStyle {
32    /// Display the TabBar when there are multiple tabs, otherwise display the simple title.
33    #[default]
34    Auto,
35    /// Always display the tab bar.
36    TabBar,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct TitleStyle {
41    pub background: Hsla,
42    pub foreground: Hsla,
43}
44
45#[derive(Clone, Copy, Default)]
46pub enum PanelControl {
47    Both,
48    #[default]
49    Menu,
50    Toolbar,
51}
52
53impl PanelControl {
54    #[inline]
55    pub fn toolbar_visible(&self) -> bool {
56        matches!(self, PanelControl::Both | PanelControl::Toolbar)
57    }
58
59    #[inline]
60    pub fn menu_visible(&self) -> bool {
61        matches!(self, PanelControl::Both | PanelControl::Menu)
62    }
63}
64
65/// What a panel draws, on top of the behavior [`gpui_base::dock::Panel`]
66/// defines.
67///
68/// Everything here has a default, so a panel that only implements base's trait
69/// plus this one gets an unnamed title and no chrome.
70#[allow(unused_variables)]
71pub trait Panel: gpui_base::dock::Panel {
72    /// The short name shown when a tab bar has no room for the full title.
73    ///
74    /// Used by an already-collapsed tab group, where only the strip of tabs is
75    /// on screen.
76    fn tab_name(&self, cx: &App) -> Option<SharedString> {
77        None
78    }
79
80    /// The panel's title, as an element rather than a string so a panel can
81    /// draw an icon, a badge, or a styled fragment in the tab.
82    fn title(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
83        t!("Dock.Unnamed")
84    }
85
86    /// Colors for the title, for a panel that wants its tab to stand out.
87    fn title_style(&self, cx: &App) -> Option<TitleStyle> {
88        None
89    }
90
91    /// An element pinned to the trailing end of the title bar.
92    fn title_suffix(
93        &mut self,
94        window: &mut Window,
95        cx: &mut Context<Self>,
96    ) -> Option<impl IntoElement> {
97        None::<gpui::Div>
98    }
99
100    /// Buttons for the title bar's toolbar.
101    fn toolbar_buttons(
102        &mut self,
103        window: &mut Window,
104        cx: &mut Context<Self>,
105    ) -> Option<Vec<Button>> {
106        None
107    }
108
109    /// Entries the panel adds to the title bar's ellipsis menu.
110    fn dropdown_menu(
111        &mut self,
112        menu: PopupMenu,
113        window: &mut Window,
114        cx: &mut Context<Self>,
115    ) -> PopupMenu {
116        menu
117    }
118
119    /// Where the zoom affordance appears, or `None` for nowhere.
120    ///
121    /// `None` withholds the whole affordance, not just the button: the
122    /// [`ToggleZoom`](super::ToggleZoom) action refuses to zoom a panel that
123    /// offers no control, so either answer alone is enough to mean "never
124    /// zoom". Zooming *out* is never refused — a panel that stops offering
125    /// the control while zoomed would otherwise strand the user with no way
126    /// back.
127    ///
128    /// [`gpui_base::dock::Panel::zoomable`] is the other half: it decides
129    /// whether zooming happens at all, and base refuses a zoom that fails it
130    /// however the zoom was asked for.
131    fn zoom_control(&self, cx: &App) -> Option<PanelControl> {
132        Some(PanelControl::Menu)
133    }
134
135    /// Whether the tab group pads the panel's content when it draws it inside
136    /// a tab bar.
137    fn inner_padding(&self, cx: &App) -> bool {
138        true
139    }
140}
141
142/// Object-safe counterpart of [`Panel`], and the presentation half of the
143/// handle a skin holds.
144pub trait PanelView: gpui_base::dock::PanelView {
145    fn tab_name(&self, cx: &App) -> Option<SharedString>;
146    fn title(&self, window: &mut Window, cx: &mut App) -> AnyElement;
147    fn title_style(&self, cx: &App) -> Option<TitleStyle>;
148    fn title_suffix(&self, window: &mut Window, cx: &mut App) -> Option<AnyElement>;
149    fn toolbar_buttons(&self, window: &mut Window, cx: &mut App) -> Option<Vec<Button>>;
150    fn dropdown_menu(&self, menu: PopupMenu, window: &mut Window, cx: &mut App) -> PopupMenu;
151    fn zoom_control(&self, cx: &App) -> Option<PanelControl>;
152    fn inner_padding(&self, cx: &App) -> bool;
153}
154
155impl<T: Panel> PanelView for Entity<T> {
156    fn tab_name(&self, cx: &App) -> Option<SharedString> {
157        self.read(cx).tab_name(cx)
158    }
159
160    fn title(&self, window: &mut Window, cx: &mut App) -> AnyElement {
161        self.update(cx, |this, cx| this.title(window, cx).into_any_element())
162    }
163
164    fn title_style(&self, cx: &App) -> Option<TitleStyle> {
165        self.read(cx).title_style(cx)
166    }
167
168    fn title_suffix(&self, window: &mut Window, cx: &mut App) -> Option<AnyElement> {
169        self.update(cx, |this, cx| {
170            this.title_suffix(window, cx)
171                .map(|element| element.into_any_element())
172        })
173    }
174
175    fn toolbar_buttons(&self, window: &mut Window, cx: &mut App) -> Option<Vec<Button>> {
176        self.update(cx, |this, cx| this.toolbar_buttons(window, cx))
177    }
178
179    fn dropdown_menu(&self, menu: PopupMenu, window: &mut Window, cx: &mut App) -> PopupMenu {
180        self.update(cx, |this, cx| this.dropdown_menu(menu, window, cx))
181    }
182
183    fn zoom_control(&self, cx: &App) -> Option<PanelControl> {
184        self.read(cx).zoom_control(cx)
185    }
186
187    fn inner_padding(&self, cx: &App) -> bool {
188        self.read(cx).inner_padding(cx)
189    }
190}
191
192/// The panel handle `gpui-base` holds on this crate's behalf.
193///
194/// Concrete on purpose. Base stores it as
195/// `Arc<dyn gpui_base::dock::PanelView>`, and every renderer hook — the tab
196/// bar, the tile drag bar — gets it back with [`Self::of`], which is an `Any`
197/// downcast to this one type. That is the only recovery Rust offers: the
198/// sub-trait object `Arc<dyn PanelView>` cannot be reconstructed from base's
199/// handle, but a concrete wrapper around it can be.
200#[derive(Clone)]
201pub struct PanelHandle(Arc<dyn PanelView>);
202
203impl PanelHandle {
204    pub fn new<P: Panel>(panel: Entity<P>) -> Self {
205        Self(Arc::new(panel))
206    }
207
208    /// Wrap a presentation handle that is already erased.
209    pub fn from_view(panel: Arc<dyn PanelView>) -> Self {
210        Self(panel)
211    }
212
213    /// Recover the handle behind one of base's, or `None` when base is
214    /// holding a panel this crate did not wrap — a bare `Entity<P>` handed
215    /// straight to [`gpui_base::dock::DockLayout::panel`], say. A skin must
216    /// draw something for that case; base's own
217    /// [`gpui_base::dock::PanelView::panel_name`] is the fallback with no
218    /// obligations on the panel author.
219    pub fn of(panel: &Arc<dyn gpui_base::dock::PanelView>) -> Option<&Self> {
220        panel.as_any().downcast_ref::<Self>()
221    }
222
223    /// The presentation handle, cloned out.
224    ///
225    /// Owned rather than borrowed because the title bar's ellipsis menu is
226    /// built inside a `'static` callback: the menu closure outlives the
227    /// `render_tab_bar` call that created it, so it cannot borrow from the
228    /// render context it was made in.
229    pub fn panel(&self) -> Arc<dyn PanelView> {
230        self.0.clone()
231    }
232}
233
234/// So a recovered handle answers the presentation trait directly:
235/// `PanelHandle::of(panel)?.title(window, cx)`. Only [`Self::panel`] hands out
236/// an owned clone, which is what a `'static` callback needs.
237impl std::ops::Deref for PanelHandle {
238    type Target = dyn PanelView;
239
240    fn deref(&self) -> &Self::Target {
241        &*self.0
242    }
243}
244
245impl gpui_base::dock::PanelView for PanelHandle {
246    fn panel_name(&self, cx: &App) -> &'static str {
247        self.0.panel_name(cx)
248    }
249
250    fn panel_id(&self, cx: &App) -> PanelId {
251        self.0.panel_id(cx)
252    }
253
254    fn closable(&self, cx: &App) -> bool {
255        self.0.closable(cx)
256    }
257
258    fn zoomable(&self, cx: &App) -> bool {
259        self.0.zoomable(cx)
260    }
261
262    fn visible(&self, cx: &App) -> bool {
263        self.0.visible(cx)
264    }
265
266    fn set_active(&self, active: bool, window: &mut Window, cx: &mut App) {
267        self.0.set_active(active, window, cx);
268    }
269
270    fn set_zoomed(&self, zoomed: bool, window: &mut Window, cx: &mut App) {
271        self.0.set_zoomed(zoomed, window, cx);
272    }
273
274    fn on_added_to(&self, group: WeakEntity<TabGroup>, window: &mut Window, cx: &mut App) {
275        self.0.on_added_to(group, window, cx);
276    }
277
278    fn on_removed(&self, window: &mut Window, cx: &mut App) {
279        self.0.on_removed(window, cx);
280    }
281
282    fn view(&self) -> AnyView {
283        self.0.view()
284    }
285
286    fn focus_handle(&self, cx: &App) -> FocusHandle {
287        self.0.focus_handle(cx)
288    }
289
290    fn dump(&self, cx: &App) -> PanelState {
291        self.0.dump(cx)
292    }
293
294    fn as_any(&self) -> &dyn Any {
295        self
296    }
297}
298
299/// Wrap `panel` so base carries its presentation across the renderer seam.
300///
301/// This is what every entry point into the dock wants:
302/// `DockLayout::tabs().panel_view(panel_handle(story), cx)`,
303/// `DockLayout::tiles().tile_view(panel_handle(story), bounds, cx)`,
304/// `DockArea::add_panel_view(panel_handle(story), ..)`,
305/// `DockArea::add_tile_view(panel_handle(story), ..)`, and the closure a
306/// [`register_panel`](gpui_base::dock::register_panel) builder returns.
307///
308/// Base's own `DockLayout::panel` / `tile` and `DockArea::add_panel` /
309/// `add_tile` also accept a panel — a `gpui_component::dock::Panel` is a
310/// `gpui_base::dock::Panel` — but they store the bare entity, and a skin
311/// cannot recover presentation from one. Such a panel still docks, drags and
312/// persists; it just draws its `panel_name` where its title would be.
313pub fn panel_handle<P: Panel>(panel: Entity<P>) -> Arc<dyn gpui_base::dock::PanelView> {
314    Arc::new(PanelHandle::new(panel))
315}
316
317#[cfg(test)]
318mod tests {
319    use std::{cell::RefCell, rc::Rc};
320
321    use gpui::{
322        AppContext as _, Bounds, Div, Empty, EventEmitter, Focusable, InteractiveElement as _,
323        ParentElement as _, Render, Stateful, Styled as _, TestAppContext, div, point, px, size,
324    };
325    use gpui_base::dock::{
326        DockArea, DockAreaRenderer, DockLayout, PanelEvent, TabGroupContext, TabGroupRenderer,
327        TileContext, TilesRenderer,
328    };
329
330    use super::*;
331
332    struct Probe {
333        focus_handle: FocusHandle,
334        tab_name: SharedString,
335    }
336
337    impl Probe {
338        fn new(tab_name: &str, cx: &mut App) -> Entity<Self> {
339            let tab_name = SharedString::from(tab_name.to_string());
340            cx.new(|cx| Self {
341                focus_handle: cx.focus_handle(),
342                tab_name,
343            })
344        }
345    }
346
347    impl gpui_base::dock::Panel for Probe {
348        fn panel_name(&self) -> &'static str {
349            "Probe"
350        }
351    }
352
353    impl Panel for Probe {
354        fn tab_name(&self, _: &App) -> Option<SharedString> {
355            Some(self.tab_name.clone())
356        }
357    }
358
359    impl EventEmitter<PanelEvent> for Probe {}
360
361    impl Focusable for Probe {
362        fn focus_handle(&self, _: &App) -> FocusHandle {
363            self.focus_handle.clone()
364        }
365    }
366
367    impl Render for Probe {
368        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
369            Empty
370        }
371    }
372
373    /// A read the skin took later, out of a handle it kept.
374    type DeferredRead = Box<dyn Fn(&mut Window, &mut App) -> Option<SharedString>>;
375
376    #[derive(Default)]
377    struct Recovered {
378        /// What the tab bar read off each panel while it drew, or `None` for a
379        /// panel it could not recover.
380        tab_names: Vec<Option<SharedString>>,
381        /// The same, read by the tiles drag bar rather than the tab bar.
382        drag_bar_names: Vec<Option<SharedString>>,
383        /// A read the tab bar deferred, the way an ellipsis menu defers
384        /// building its items.
385        deferred: Option<DeferredRead>,
386    }
387
388    /// The skin: a tab bar that recovers this crate's handle out of base's and
389    /// reads presentation off it.
390    struct Skin {
391        recovered: Rc<RefCell<Recovered>>,
392    }
393
394    impl TabGroupRenderer for Skin {
395        fn render_tab_bar(
396            &self,
397            group: &TabGroupContext,
398            window: &mut Window,
399            cx: &mut App,
400        ) -> AnyElement {
401            let mut recovered = self.recovered.borrow_mut();
402            let mut tabs = Vec::new();
403            for panel in group.panels() {
404                let Some(handle) = PanelHandle::of(panel) else {
405                    recovered.tab_names.push(None);
406                    continue;
407                };
408                recovered.tab_names.push(handle.tab_name(cx));
409
410                // `title` takes the window and a mutable app while `group` is
411                // still borrowed, which is the shape every tab in a real tab
412                // bar is built from.
413                tabs.push(handle.title(window, cx));
414
415                // And the shape an ellipsis menu needs: an owned handle,
416                // called back with a window and an app long after this borrow
417                // of `group` is gone.
418                let panel = handle.panel();
419                recovered.deferred = Some(Box::new(move |window, cx| {
420                    let _ = panel.title(window, cx);
421                    panel.tab_name(cx)
422                }));
423            }
424
425            div().children(tabs).into_any_element()
426        }
427    }
428
429    impl TilesRenderer for Skin {
430        fn render_drag_bar(
431            &self,
432            tile: &TileContext,
433            window: &mut Window,
434            cx: &mut App,
435        ) -> AnyElement {
436            let handle = PanelHandle::of(tile.panel());
437            self.recovered
438                .borrow_mut()
439                .drag_bar_names
440                .push(handle.and_then(|handle| handle.tab_name(cx)));
441
442            match handle {
443                Some(handle) => div().child(handle.title(window, cx)).into_any_element(),
444                None => Empty.into_any_element(),
445            }
446        }
447    }
448
449    impl DockAreaRenderer for Skin {
450        fn frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
451            div().id("skin-dock-area").size_full()
452        }
453
454        fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
455            Rc::new(Skin {
456                recovered: self.recovered.clone(),
457            })
458        }
459
460        fn tiles_renderer(&self) -> Rc<dyn TilesRenderer> {
461            Rc::new(Skin {
462                recovered: self.recovered.clone(),
463            })
464        }
465    }
466
467    /// The seam this module exists for: a panel wrapped in a [`PanelHandle`],
468    /// installed through base's `DockLayout`, comes back to the skin's tab bar
469    /// as a handle it can read presentation off.
470    ///
471    /// Nothing in `TabGroupContext` is typed to this crate — the renderer
472    /// holds `Arc<dyn gpui_base::dock::PanelView>` — so if the `Any` downcast
473    /// stopped working the recorded name would be `None` and this fails.
474    #[gpui::test]
475    fn the_tab_bar_recovers_presentation_from_a_base_panel_handle(cx: &mut TestAppContext) {
476        cx.update(|cx| {
477            let _ = gpui_base::Theme::global_mut(cx);
478        });
479        let recovered = Rc::new(RefCell::new(Recovered::default()));
480        let skin = Rc::new(Skin {
481            recovered: recovered.clone(),
482        });
483
484        let (area, cx) = cx.add_window_view(|window, cx| {
485            DockArea::new("seam", None, window, cx).with_renderer(skin)
486        });
487
488        cx.update(|window, cx| {
489            let panel = PanelHandle::new(Probe::new("Probe Tab", cx));
490            let layout = DockLayout::tabs().panel_view(Arc::new(panel), cx);
491            area.update(cx, |area, cx| area.set_center(layout, window, cx));
492        });
493        cx.run_until_parked();
494        recovered.borrow_mut().tab_names.clear();
495        cx.update(|window, cx| window.draw(cx).clear(cx));
496
497        assert_eq!(
498            recovered.borrow().tab_names,
499            vec![Some(SharedString::from("Probe Tab"))],
500            "the skin read its own panel trait off base's handle"
501        );
502
503        // And the handle it kept still answers once the frame that recovered
504        // it is over, which is what an ellipsis menu's callback needs.
505        let deferred = recovered.borrow_mut().deferred.take().expect("kept");
506        let later = cx.update(|window, cx| deferred(window, cx));
507        assert_eq!(later, Some(SharedString::from("Probe Tab")));
508    }
509
510    /// A panel rebuilt from persisted state is recoverable too. This needs no
511    /// new entry point: [`gpui_base::dock::register_panel`] already takes a
512    /// builder returning `Arc<dyn gpui_base::dock::PanelView>`, so the skin's
513    /// builder returns a [`PanelHandle`] and base stores that handle as-is.
514    #[gpui::test]
515    fn a_panel_rebuilt_from_persisted_state_is_recoverable(cx: &mut TestAppContext) {
516        cx.update(|cx| {
517            let _ = gpui_base::Theme::global_mut(cx);
518        });
519        let recovered = Rc::new(RefCell::new(Recovered::default()));
520        let skin = Rc::new(Skin {
521            recovered: recovered.clone(),
522        });
523
524        let (area, cx) = cx.add_window_view(|window, cx| {
525            DockArea::new("seam", None, window, cx).with_renderer(skin)
526        });
527
528        cx.update(|window, cx| {
529            gpui_base::dock::register_panel(cx, "Probe", |_, _, cx| {
530                Arc::new(PanelHandle::new(Probe::new("Restored Tab", cx)))
531                    as Arc<dyn gpui_base::dock::PanelView>
532            });
533            let panel = PanelHandle::new(Probe::new("Probe Tab", cx));
534            let layout = DockLayout::tabs().panel_view(Arc::new(panel), cx);
535            area.update(cx, |area, cx| area.set_center(layout, window, cx));
536        });
537        cx.run_until_parked();
538
539        let state = cx.read(|cx| area.read(cx).dump(cx));
540        cx.update(|window, cx| area.update(cx, |area, cx| area.load(state, window, cx).unwrap()));
541        cx.run_until_parked();
542        recovered.borrow_mut().tab_names.clear();
543        cx.update(|window, cx| window.draw(cx).clear(cx));
544
545        assert_eq!(
546            recovered.borrow().tab_names,
547            vec![Some(SharedString::from("Restored Tab"))],
548            "the rebuilt panel reached the tab bar as a handle, not a bare entity"
549        );
550    }
551
552    /// A tile's drag bar is its title bar, so it has to reach the same
553    /// presentation the tab bar does. It does: [`TileContext::panel`] hands
554    /// over the same base handle, and the same downcast recovers it.
555    ///
556    /// This also exercises `DockLayout::tile_view`, the tiles half of the new
557    /// entry points.
558    #[gpui::test]
559    fn a_tile_drag_bar_reaches_the_same_presentation(cx: &mut TestAppContext) {
560        cx.update(|cx| {
561            let _ = gpui_base::Theme::global_mut(cx);
562        });
563        let recovered = Rc::new(RefCell::new(Recovered::default()));
564        let skin = Rc::new(Skin {
565            recovered: recovered.clone(),
566        });
567
568        let (area, cx) = cx.add_window_view(|window, cx| {
569            DockArea::new("seam", None, window, cx).with_renderer(skin)
570        });
571
572        cx.update(|window, cx| {
573            let panel = PanelHandle::new(Probe::new("Tile Tab", cx));
574            let bounds = Bounds {
575                origin: point(px(0.), px(0.)),
576                size: size(px(200.), px(150.)),
577            };
578            let layout = DockLayout::tiles().tile_view(Arc::new(panel), bounds, cx);
579            area.update(cx, |area, cx| area.set_center(layout, window, cx));
580        });
581        cx.run_until_parked();
582        recovered.borrow_mut().drag_bar_names.clear();
583        cx.update(|window, cx| window.draw(cx).clear(cx));
584
585        assert_eq!(
586            recovered.borrow().drag_bar_names,
587            vec![Some(SharedString::from("Tile Tab"))],
588            "the drag bar can draw a title and a menu off the panel"
589        );
590    }
591
592    /// The other half of [`PanelHandle::of`]'s contract: a panel base was
593    /// handed directly is not this crate's handle, and the skin is told so
594    /// rather than being handed something wrong.
595    #[gpui::test]
596    fn a_panel_base_was_handed_directly_is_not_recoverable(cx: &mut TestAppContext) {
597        cx.update(|cx| {
598            let _ = gpui_base::Theme::global_mut(cx);
599        });
600        let recovered = Rc::new(RefCell::new(Recovered::default()));
601        let skin = Rc::new(Skin {
602            recovered: recovered.clone(),
603        });
604
605        let (area, cx) = cx.add_window_view(|window, cx| {
606            DockArea::new("seam", None, window, cx).with_renderer(skin)
607        });
608
609        cx.update(|window, cx| {
610            let layout = DockLayout::tabs().panel(Probe::new("Probe Tab", cx));
611            area.update(cx, |area, cx| area.set_center(layout, window, cx));
612        });
613        cx.run_until_parked();
614        recovered.borrow_mut().tab_names.clear();
615        cx.update(|window, cx| window.draw(cx).clear(cx));
616
617        assert_eq!(
618            recovered.borrow().tab_names,
619            vec![None],
620            "an unwrapped panel is reported as unrecoverable, not wrongly recovered"
621        );
622    }
623}