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    /// Whether the tab group draws a title bar above this panel when it is
142    /// the only panel in its group.
143    ///
144    /// `false` for a panel that carries its own chrome. A group holding
145    /// several panels still draws its tabs, so every panel stays reachable.
146    fn title_bar(&self, cx: &App) -> bool {
147        true
148    }
149}
150
151/// Object-safe counterpart of [`Panel`], and the presentation half of the
152/// handle a skin holds.
153pub trait PanelView: gpui_base::dock::PanelView {
154    fn tab_name(&self, cx: &App) -> Option<SharedString>;
155    fn title(&self, window: &mut Window, cx: &mut App) -> AnyElement;
156    fn title_style(&self, cx: &App) -> Option<TitleStyle>;
157    fn title_suffix(&self, window: &mut Window, cx: &mut App) -> Option<AnyElement>;
158    fn toolbar_buttons(&self, window: &mut Window, cx: &mut App) -> Option<Vec<Button>>;
159    fn dropdown_menu(&self, menu: PopupMenu, window: &mut Window, cx: &mut App) -> PopupMenu;
160    fn zoom_control(&self, cx: &App) -> Option<PanelControl>;
161    fn inner_padding(&self, cx: &App) -> bool;
162    fn title_bar(&self, cx: &App) -> bool;
163}
164
165impl<T: Panel> PanelView for Entity<T> {
166    fn tab_name(&self, cx: &App) -> Option<SharedString> {
167        self.read(cx).tab_name(cx)
168    }
169
170    fn title(&self, window: &mut Window, cx: &mut App) -> AnyElement {
171        self.update(cx, |this, cx| this.title(window, cx).into_any_element())
172    }
173
174    fn title_style(&self, cx: &App) -> Option<TitleStyle> {
175        self.read(cx).title_style(cx)
176    }
177
178    fn title_suffix(&self, window: &mut Window, cx: &mut App) -> Option<AnyElement> {
179        self.update(cx, |this, cx| {
180            this.title_suffix(window, cx)
181                .map(|element| element.into_any_element())
182        })
183    }
184
185    fn toolbar_buttons(&self, window: &mut Window, cx: &mut App) -> Option<Vec<Button>> {
186        self.update(cx, |this, cx| this.toolbar_buttons(window, cx))
187    }
188
189    fn dropdown_menu(&self, menu: PopupMenu, window: &mut Window, cx: &mut App) -> PopupMenu {
190        self.update(cx, |this, cx| this.dropdown_menu(menu, window, cx))
191    }
192
193    fn zoom_control(&self, cx: &App) -> Option<PanelControl> {
194        self.read(cx).zoom_control(cx)
195    }
196
197    fn inner_padding(&self, cx: &App) -> bool {
198        self.read(cx).inner_padding(cx)
199    }
200
201    fn title_bar(&self, cx: &App) -> bool {
202        self.read(cx).title_bar(cx)
203    }
204}
205
206/// The panel handle `gpui-base` holds on this crate's behalf.
207///
208/// Concrete on purpose. Base stores it as
209/// `Arc<dyn gpui_base::dock::PanelView>`, and every renderer hook — the tab
210/// bar most of all — gets it back with [`Self::of`], which is an `Any`
211/// downcast to this one type. That is the only recovery Rust offers: the
212/// sub-trait object `Arc<dyn PanelView>` cannot be reconstructed from base's
213/// handle, but a concrete wrapper around it can be.
214#[derive(Clone)]
215pub struct PanelHandle(Arc<dyn PanelView>);
216
217impl PanelHandle {
218    pub fn new<P: Panel>(panel: Entity<P>) -> Self {
219        Self(Arc::new(panel))
220    }
221
222    /// Wrap a presentation handle that is already erased.
223    pub fn from_view(panel: Arc<dyn PanelView>) -> Self {
224        Self(panel)
225    }
226
227    /// Recover the handle behind one of base's, or `None` when base is
228    /// holding a panel this crate did not wrap — a bare `Entity<P>` handed
229    /// straight to [`gpui_base::dock::DockLayout::panel`], say. A skin must
230    /// draw something for that case; base's own
231    /// [`gpui_base::dock::PanelView::panel_name`] is the fallback with no
232    /// obligations on the panel author.
233    pub fn of(panel: &Arc<dyn gpui_base::dock::PanelView>) -> Option<&Self> {
234        panel.as_any().downcast_ref::<Self>()
235    }
236
237    /// The presentation handle, cloned out.
238    ///
239    /// Owned rather than borrowed because the title bar's ellipsis menu is
240    /// built inside a `'static` callback: the menu closure outlives the
241    /// `render_tab_bar` call that created it, so it cannot borrow from the
242    /// render context it was made in.
243    pub fn panel(&self) -> Arc<dyn PanelView> {
244        self.0.clone()
245    }
246}
247
248/// So a recovered handle answers the presentation trait directly:
249/// `PanelHandle::of(panel)?.title(window, cx)`. Only [`Self::panel`] hands out
250/// an owned clone, which is what a `'static` callback needs.
251impl std::ops::Deref for PanelHandle {
252    type Target = dyn PanelView;
253
254    fn deref(&self) -> &Self::Target {
255        &*self.0
256    }
257}
258
259impl gpui_base::dock::PanelView for PanelHandle {
260    fn panel_name(&self, cx: &App) -> &'static str {
261        self.0.panel_name(cx)
262    }
263
264    fn panel_id(&self, cx: &App) -> PanelId {
265        self.0.panel_id(cx)
266    }
267
268    fn closable(&self, cx: &App) -> bool {
269        self.0.closable(cx)
270    }
271
272    fn zoomable(&self, cx: &App) -> bool {
273        self.0.zoomable(cx)
274    }
275
276    fn visible(&self, cx: &App) -> bool {
277        self.0.visible(cx)
278    }
279
280    fn set_active(&self, active: bool, window: &mut Window, cx: &mut App) {
281        self.0.set_active(active, window, cx);
282    }
283
284    fn set_zoomed(&self, zoomed: bool, window: &mut Window, cx: &mut App) {
285        self.0.set_zoomed(zoomed, window, cx);
286    }
287
288    fn on_added_to(&self, group: WeakEntity<TabGroup>, window: &mut Window, cx: &mut App) {
289        self.0.on_added_to(group, window, cx);
290    }
291
292    fn on_removed(&self, window: &mut Window, cx: &mut App) {
293        self.0.on_removed(window, cx);
294    }
295
296    fn view(&self) -> AnyView {
297        self.0.view()
298    }
299
300    fn focus_handle(&self, cx: &App) -> FocusHandle {
301        self.0.focus_handle(cx)
302    }
303
304    fn dump(&self, cx: &App) -> PanelState {
305        self.0.dump(cx)
306    }
307
308    fn as_any(&self) -> &dyn Any {
309        self
310    }
311}
312
313/// Wrap `panel` so base carries its presentation across the renderer seam.
314///
315/// This is what every entry point into the dock wants:
316/// `DockLayout::tabs().panel_view(panel_handle(story), cx)`,
317/// `DockArea::add_panel_view(panel_handle(story), ..)`, and the closure a
318/// [`register_panel`](gpui_base::dock::register_panel) builder returns.
319///
320/// Base's own `DockLayout::panel` and `DockArea::add_panel` also accept a
321/// panel — a `gpui_component::dock::Panel` is a
322/// `gpui_base::dock::Panel` — but they store the bare entity, and a skin
323/// cannot recover presentation from one. Such a panel still docks, drags and
324/// persists; it just draws its `panel_name` where its title would be.
325pub fn panel_handle<P: Panel>(panel: Entity<P>) -> Arc<dyn gpui_base::dock::PanelView> {
326    Arc::new(PanelHandle::new(panel))
327}
328
329#[cfg(test)]
330mod tests {
331    use std::{cell::RefCell, rc::Rc};
332
333    use gpui::{
334        AppContext as _, Div, Empty, EventEmitter, Focusable, InteractiveElement as _,
335        ParentElement as _, Render, Stateful, Styled as _, TestAppContext, div,
336    };
337    use gpui_base::dock::{
338        DockArea, DockAreaRenderer, DockLayout, PanelEvent, TabGroupContext, TabGroupRenderer,
339    };
340
341    use super::*;
342
343    struct Probe {
344        focus_handle: FocusHandle,
345        tab_name: SharedString,
346    }
347
348    impl Probe {
349        fn new(tab_name: &str, cx: &mut App) -> Entity<Self> {
350            let tab_name = SharedString::from(tab_name.to_string());
351            cx.new(|cx| Self {
352                focus_handle: cx.focus_handle(),
353                tab_name,
354            })
355        }
356    }
357
358    impl gpui_base::dock::Panel for Probe {
359        fn panel_name(&self) -> &'static str {
360            "Probe"
361        }
362    }
363
364    impl Panel for Probe {
365        fn tab_name(&self, _: &App) -> Option<SharedString> {
366            Some(self.tab_name.clone())
367        }
368    }
369
370    impl EventEmitter<PanelEvent> for Probe {}
371
372    impl Focusable for Probe {
373        fn focus_handle(&self, _: &App) -> FocusHandle {
374            self.focus_handle.clone()
375        }
376    }
377
378    impl Render for Probe {
379        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
380            Empty
381        }
382    }
383
384    /// A read the skin took later, out of a handle it kept.
385    type DeferredRead = Box<dyn Fn(&mut Window, &mut App) -> Option<SharedString>>;
386
387    #[derive(Default)]
388    struct Recovered {
389        /// What the tab bar read off each panel while it drew, or `None` for a
390        /// panel it could not recover.
391        tab_names: Vec<Option<SharedString>>,
392        /// A read the tab bar deferred, the way an ellipsis menu defers
393        /// building its items.
394        deferred: Option<DeferredRead>,
395    }
396
397    /// The skin: a tab bar that recovers this crate's handle out of base's and
398    /// reads presentation off it.
399    struct Skin {
400        recovered: Rc<RefCell<Recovered>>,
401    }
402
403    impl TabGroupRenderer for Skin {
404        fn render_tab_bar(
405            &self,
406            group: &TabGroupContext,
407            window: &mut Window,
408            cx: &mut App,
409        ) -> AnyElement {
410            let mut recovered = self.recovered.borrow_mut();
411            let mut tabs = Vec::new();
412            for panel in group.panels() {
413                let Some(handle) = PanelHandle::of(panel) else {
414                    recovered.tab_names.push(None);
415                    continue;
416                };
417                recovered.tab_names.push(handle.tab_name(cx));
418
419                // `title` takes the window and a mutable app while `group` is
420                // still borrowed, which is the shape every tab in a real tab
421                // bar is built from.
422                tabs.push(handle.title(window, cx));
423
424                // And the shape an ellipsis menu needs: an owned handle,
425                // called back with a window and an app long after this borrow
426                // of `group` is gone.
427                let panel = handle.panel();
428                recovered.deferred = Some(Box::new(move |window, cx| {
429                    let _ = panel.title(window, cx);
430                    panel.tab_name(cx)
431                }));
432            }
433
434            div().children(tabs).into_any_element()
435        }
436    }
437
438    impl DockAreaRenderer for Skin {
439        fn frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
440            div().id("skin-dock-area").size_full()
441        }
442
443        fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
444            Rc::new(Skin {
445                recovered: self.recovered.clone(),
446            })
447        }
448    }
449
450    /// The seam this module exists for: a panel wrapped in a [`PanelHandle`],
451    /// installed through base's `DockLayout`, comes back to the skin's tab bar
452    /// as a handle it can read presentation off.
453    ///
454    /// Nothing in `TabGroupContext` is typed to this crate — the renderer
455    /// holds `Arc<dyn gpui_base::dock::PanelView>` — so if the `Any` downcast
456    /// stopped working the recorded name would be `None` and this fails.
457    #[gpui::test]
458    fn the_tab_bar_recovers_presentation_from_a_base_panel_handle(cx: &mut TestAppContext) {
459        cx.update(|cx| {
460            let _ = gpui_base::Theme::global_mut(cx);
461        });
462        let recovered = Rc::new(RefCell::new(Recovered::default()));
463        let skin = Rc::new(Skin {
464            recovered: recovered.clone(),
465        });
466
467        let (area, cx) = cx.add_window_view(|window, cx| {
468            DockArea::new("seam", None, window, cx).with_renderer(skin)
469        });
470
471        cx.update(|window, cx| {
472            let panel = PanelHandle::new(Probe::new("Probe Tab", cx));
473            let layout = DockLayout::tabs().panel_view(Arc::new(panel), cx);
474            area.update(cx, |area, cx| area.set_center(layout, window, cx));
475        });
476        cx.run_until_parked();
477        recovered.borrow_mut().tab_names.clear();
478        cx.update(|window, cx| window.draw(cx).clear(cx));
479
480        assert_eq!(
481            recovered.borrow().tab_names,
482            vec![Some(SharedString::from("Probe Tab"))],
483            "the skin read its own panel trait off base's handle"
484        );
485
486        // And the handle it kept still answers once the frame that recovered
487        // it is over, which is what an ellipsis menu's callback needs.
488        let deferred = recovered.borrow_mut().deferred.take().expect("kept");
489        let later = cx.update(|window, cx| deferred(window, cx));
490        assert_eq!(later, Some(SharedString::from("Probe Tab")));
491    }
492
493    /// A panel rebuilt from persisted state is recoverable too. This needs no
494    /// new entry point: [`gpui_base::dock::register_panel`] already takes a
495    /// builder returning `Arc<dyn gpui_base::dock::PanelView>`, so the skin's
496    /// builder returns a [`PanelHandle`] and base stores that handle as-is.
497    #[gpui::test]
498    fn a_panel_rebuilt_from_persisted_state_is_recoverable(cx: &mut TestAppContext) {
499        cx.update(|cx| {
500            let _ = gpui_base::Theme::global_mut(cx);
501        });
502        let recovered = Rc::new(RefCell::new(Recovered::default()));
503        let skin = Rc::new(Skin {
504            recovered: recovered.clone(),
505        });
506
507        let (area, cx) = cx.add_window_view(|window, cx| {
508            DockArea::new("seam", None, window, cx).with_renderer(skin)
509        });
510
511        cx.update(|window, cx| {
512            gpui_base::dock::register_panel(cx, "Probe", |_, _, cx| {
513                Arc::new(PanelHandle::new(Probe::new("Restored Tab", cx)))
514                    as Arc<dyn gpui_base::dock::PanelView>
515            });
516            let panel = PanelHandle::new(Probe::new("Probe Tab", cx));
517            let layout = DockLayout::tabs().panel_view(Arc::new(panel), cx);
518            area.update(cx, |area, cx| area.set_center(layout, window, cx));
519        });
520        cx.run_until_parked();
521
522        let state = cx.read(|cx| area.read(cx).dump(cx));
523        cx.update(|window, cx| area.update(cx, |area, cx| area.load(state, window, cx).unwrap()));
524        cx.run_until_parked();
525        recovered.borrow_mut().tab_names.clear();
526        cx.update(|window, cx| window.draw(cx).clear(cx));
527
528        assert_eq!(
529            recovered.borrow().tab_names,
530            vec![Some(SharedString::from("Restored Tab"))],
531            "the rebuilt panel reached the tab bar as a handle, not a bare entity"
532        );
533    }
534
535    /// The other half of [`PanelHandle::of`]'s contract: a panel base was
536    /// handed directly is not this crate's handle, and the skin is told so
537    /// rather than being handed something wrong.
538    #[gpui::test]
539    fn a_panel_base_was_handed_directly_is_not_recoverable(cx: &mut TestAppContext) {
540        cx.update(|cx| {
541            let _ = gpui_base::Theme::global_mut(cx);
542        });
543        let recovered = Rc::new(RefCell::new(Recovered::default()));
544        let skin = Rc::new(Skin {
545            recovered: recovered.clone(),
546        });
547
548        let (area, cx) = cx.add_window_view(|window, cx| {
549            DockArea::new("seam", None, window, cx).with_renderer(skin)
550        });
551
552        cx.update(|window, cx| {
553            let layout = DockLayout::tabs().panel(Probe::new("Probe Tab", cx));
554            area.update(cx, |area, cx| area.set_center(layout, window, cx));
555        });
556        cx.run_until_parked();
557        recovered.borrow_mut().tab_names.clear();
558        cx.update(|window, cx| window.draw(cx).clear(cx));
559
560        assert_eq!(
561            recovered.borrow().tab_names,
562            vec![None],
563            "an unwrapped panel is reported as unrecoverable, not wrongly recovered"
564        );
565    }
566}