Skip to main content

gpui_kit/layout/
dock.rs

1//! Panels arranged in regions around a centre, the way a desktop tool is laid
2//! out.
3//!
4//! Every fact the dock draws belongs to the caller: which panels a region
5//! holds, which one is on top, whether a region is collapsed, and how much
6//! room it takes. The dock reports what the typist asked for as a
7//! [`DockEvent`] and moves nothing, so a host that refuses a move keeps the
8//! arrangement that still holds.
9//!
10//! # One resize implementation
11//!
12//! Region sizes go through [`SplitTree`]: the dock builds a [`SplitLayout`]
13//! from the regions that hold panels and hands it over, so a divider between
14//! two regions is the same divider a caller would get from a plain split, with
15//! the same minimums and the same published range.
16//!
17//! # Dragging a panel between regions
18//!
19//! A region's header is a [`Tabs`] strip, so dragging a panel is the drag
20//! system in [`crate::interaction::dnd`] and nothing new. Dropping on another
21//! region's tab reports [`DockEvent::PanelMoved`] naming the panel the moved
22//! one should sit in front of; dropping on a region's body reports the same
23//! move with `before: None`, which appends.
24//!
25//! # What the dock cannot do
26//!
27//! - A region holding no panels is not drawn, so it cannot be dropped onto.
28//!   The last panel cannot be dragged out of a region and then back into it.
29//! - A panel is drawn in exactly one region. There is no split within a
30//!   region, and no floating panel.
31//! - A collapsed region shows a rail. Picking a panel from the rail reports
32//!   the selection and the request to expand, and applies neither.
33//! - The header is a tab strip whatever the count: with one panel it reads as
34//!   a title, which is what gives a lone panel something to drag by.
35
36use std::cell::RefCell;
37use std::rc::Rc;
38
39use gpui::{
40    AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
41    StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px,
42};
43use gpui_kit_assets::{Icon, icon};
44use gpui_kit_semantics::{NodeSpec, Role, Semantic};
45use gpui_kit_theme::{ActiveTheme, ControlSize, Radius, Space, Theme, TypeScale};
46
47use crate::foundation::{Disableable, FocusRing, Ident, Pressable, Sizable, StyledExt};
48use crate::interaction::dnd::{self, DragItem, DropAxis, DropIntent, DropPosition, RowTarget};
49use crate::layout::tree::{SplitChange, SplitLayout, SplitPaneSpec, SplitTree};
50use crate::motion::{Flipping, flip};
51use crate::navigation::tabs::{TabItem, Tabs};
52use crate::overlay::Tooltipped;
53use crate::strings::{ActiveStrings, StringKey};
54
55/// How wide a collapsed region's rail is, and how deep a collapsed bottom
56/// region's is. The value occurs only here.
57const RAIL: f32 = 44.0;
58
59/// The share of its own split a region takes when the caller says nothing.
60const DEFAULT_SHARE: f32 = 0.22;
61
62/// The identities of the splits the dock builds, so a reported ratio can be
63/// read back as the region it belongs to.
64const BODY_SPLIT: &str = "dock.body";
65const COLUMNS_SPLIT: &str = "dock.columns";
66const ROOT_SPLIT: &str = "dock.root";
67
68type EventHandler = Rc<dyn Fn(DockEvent, &mut Window, &mut App)>;
69
70/// Where a panel sits.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
72pub enum DockRegion {
73    Left,
74    Centre,
75    Right,
76    Bottom,
77}
78
79impl DockRegion {
80    pub const ALL: [DockRegion; 4] = [Self::Left, Self::Centre, Self::Right, Self::Bottom];
81
82    pub fn name(self) -> &'static str {
83        match self {
84            Self::Left => "left",
85            Self::Centre => "centre",
86            Self::Right => "right",
87            Self::Bottom => "bottom",
88        }
89    }
90
91    /// Whether the region's rail runs down the side rather than across.
92    fn upright(self) -> bool {
93        !matches!(self, Self::Bottom)
94    }
95
96    fn index(self) -> usize {
97        match self {
98            Self::Left => 0,
99            Self::Centre => 1,
100            Self::Right => 2,
101            Self::Bottom => 3,
102        }
103    }
104}
105
106/// One panel: a name, a glyph, a count, and whatever the caller puts inside.
107pub struct DockPanel {
108    id: SharedString,
109    title: SharedString,
110    icon: Option<Icon>,
111    badge: Option<SharedString>,
112    unavailable: Option<SharedString>,
113    /// A region draws its active panel once, and an `AnyElement` can be built
114    /// exactly once, so the content is handed over rather than copied.
115    content: RefCell<Option<AnyElement>>,
116}
117
118impl std::fmt::Debug for DockPanel {
119    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        formatter
121            .debug_struct("DockPanel")
122            .field("id", &self.id)
123            .field("title", &self.title)
124            .field("unavailable", &self.unavailable)
125            .finish()
126    }
127}
128
129impl DockPanel {
130    pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
131        Self {
132            id: id.into(),
133            title: title.into(),
134            icon: None,
135            badge: None,
136            unavailable: None,
137            content: RefCell::new(None),
138        }
139    }
140
141    pub fn icon(mut self, glyph: Icon) -> Self {
142        self.icon = Some(glyph);
143        self
144    }
145
146    /// A count shown beside the title, such as how many problems a panel holds.
147    pub fn badge(mut self, badge: impl Into<SharedString>) -> Self {
148        self.badge = Some(badge.into());
149        self
150    }
151
152    /// Why the host cannot show this panel right now.
153    ///
154    /// An unavailable panel keeps its tab and states the reason where its
155    /// content would be. It does not disappear, because a panel that vanished
156    /// would read as one the workspace never had.
157    pub fn unavailable(mut self, reason: impl Into<SharedString>) -> Self {
158        self.unavailable = Some(reason.into());
159        self
160    }
161
162    pub fn content(self, content: impl IntoElement) -> Self {
163        *self.content.borrow_mut() = Some(content.into_any_element());
164        self
165    }
166
167    pub fn id(&self) -> &SharedString {
168        &self.id
169    }
170
171    pub fn title(&self) -> &SharedString {
172        &self.title
173    }
174
175    pub fn is_unavailable(&self) -> bool {
176        self.unavailable.is_some()
177    }
178}
179
180/// What the dock reports. The caller decides what any of it means.
181#[derive(Debug, Clone, PartialEq)]
182pub enum DockEvent {
183    /// A tab or a rail glyph was picked.
184    PanelSelected {
185        region: DockRegion,
186        panel: SharedString,
187    },
188    /// A panel was dragged somewhere. Nothing has moved.
189    PanelMoved {
190        panel: SharedString,
191        to_region: DockRegion,
192        /// The panel the moved one should sit in front of, or `None` to put it
193        /// last. Never an index: an index stops meaning anything the moment
194        /// the host applies the move.
195        before: Option<SharedString>,
196    },
197    /// A region was asked to collapse to its rail, or to come back.
198    RegionCollapsed { region: DockRegion, collapsed: bool },
199    /// A divider beside a region was moved. `ratio` is the share of its own
200    /// split the region asked for.
201    RegionResized { region: DockRegion, ratio: f32 },
202}
203
204#[derive(Default)]
205struct Region {
206    panels: Vec<DockPanel>,
207    active: Option<SharedString>,
208    collapsed: bool,
209    share: Option<f32>,
210    min: Option<f32>,
211}
212
213impl Region {
214    fn active_panel(&self) -> Option<&DockPanel> {
215        match &self.active {
216            Some(id) => self.panels.iter().find(|panel| &panel.id == id),
217            None => self.panels.first(),
218        }
219    }
220
221    fn share(&self) -> f32 {
222        self.share.unwrap_or(DEFAULT_SHARE).clamp(0.0, 1.0)
223    }
224}
225
226/// Panels arranged in regions around a centre.
227#[derive(IntoElement)]
228pub struct Dock {
229    ident: Ident,
230    regions: [Region; 4],
231    disabled: bool,
232    on_event: Option<EventHandler>,
233}
234
235impl std::fmt::Debug for Dock {
236    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237        formatter
238            .debug_struct("Dock")
239            .field("ident", &self.ident)
240            .field(
241                "panels",
242                &self
243                    .regions
244                    .iter()
245                    .map(|region| region.panels.len())
246                    .collect::<Vec<_>>(),
247            )
248            .field("disabled", &self.disabled)
249            .field("has_handler", &self.on_event.is_some())
250            .finish()
251    }
252}
253
254impl Dock {
255    pub fn new(ident: impl Into<Ident>) -> Self {
256        Self {
257            ident: ident.into(),
258            regions: Default::default(),
259            disabled: false,
260            on_event: None,
261        }
262    }
263
264    pub fn panel(mut self, region: DockRegion, panel: DockPanel) -> Self {
265        self.regions[region.index()].panels.push(panel);
266        self
267    }
268
269    pub fn panels(
270        mut self,
271        region: DockRegion,
272        panels: impl IntoIterator<Item = DockPanel>,
273    ) -> Self {
274        self.regions[region.index()].panels.extend(panels);
275        self
276    }
277
278    /// Which panel the caller says is on top. Without one, the first is.
279    pub fn active(mut self, region: DockRegion, panel: impl Into<SharedString>) -> Self {
280        self.regions[region.index()].active = Some(panel.into());
281        self
282    }
283
284    /// Draws the region as a rail of its panels rather than as a panel.
285    pub fn collapsed(mut self, region: DockRegion, collapsed: bool) -> Self {
286        self.regions[region.index()].collapsed = collapsed;
287        self
288    }
289
290    /// How much of its own split the region takes, from 0 to 1.
291    pub fn share(mut self, region: DockRegion, share: f32) -> Self {
292        self.regions[region.index()].share = Some(share);
293        self
294    }
295
296    /// The smallest the region may be dragged to, in pixels.
297    pub fn min_size(mut self, region: DockRegion, min: f32) -> Self {
298        self.regions[region.index()].min = Some(min);
299        self
300    }
301
302    pub fn on_event(
303        mut self,
304        handler: impl Fn(DockEvent, &mut Window, &mut App) + 'static,
305    ) -> Self {
306        self.on_event = Some(Rc::new(handler));
307        self
308    }
309
310    fn region(&self, region: DockRegion) -> &Region {
311        &self.regions[region.index()]
312    }
313
314    fn occupied(&self, region: DockRegion) -> bool {
315        !self.region(region).panels.is_empty()
316    }
317
318    /// A region as a leaf of the tree.
319    ///
320    /// The minimum is stated on the axis the region is resized along and
321    /// nowhere else: a left panel that needs 176px of width does not thereby
322    /// need 176px of height, and saying so would stop the bottom divider.
323    fn leaf(&self, region: DockRegion) -> SplitLayout {
324        let state = self.region(region);
325        let min = state.min.unwrap_or(RAIL * 4.0);
326        let spec = SplitPaneSpec::new(region.name())
327            .rail(RAIL)
328            .collapsed(state.collapsed);
329        SplitLayout::leaf(match region {
330            DockRegion::Left | DockRegion::Right => spec.min_width(min),
331            DockRegion::Bottom => spec.min_height(min),
332            // The centre is squeezed from both sides and from below.
333            DockRegion::Centre => spec.min(min),
334        })
335    }
336
337    /// The tree the regions that hold panels make up.
338    ///
339    /// Left and right sit either side of the centre, and the bottom runs under
340    /// all three. A region with no panels is left out of the tree entirely
341    /// rather than drawn as an empty box.
342    fn layout(&self) -> SplitLayout {
343        let mut tree = self.leaf(DockRegion::Centre);
344        if self.occupied(DockRegion::Right) {
345            tree = SplitLayout::horizontal(
346                COLUMNS_SPLIT,
347                1.0 - self.region(DockRegion::Right).share(),
348                tree,
349                self.leaf(DockRegion::Right),
350            );
351        }
352        if self.occupied(DockRegion::Left) {
353            tree = SplitLayout::horizontal(
354                BODY_SPLIT,
355                self.region(DockRegion::Left).share(),
356                self.leaf(DockRegion::Left),
357                tree,
358            );
359        }
360        if self.occupied(DockRegion::Bottom) {
361            tree = SplitLayout::vertical(
362                ROOT_SPLIT,
363                1.0 - self.region(DockRegion::Bottom).share(),
364                tree,
365                self.leaf(DockRegion::Bottom),
366            );
367        }
368        tree
369    }
370
371    fn surface(&self, region: DockRegion) -> SharedString {
372        self.ident.child(region.name()).child("tabs").semantic_id()
373    }
374
375    /// Whether a payload came from this dock, so a row dragged out of a list
376    /// somewhere else is refused rather than accepted as a panel.
377    fn owns_prefix(&self) -> String {
378        format!("{}.", self.ident.as_str())
379    }
380
381    fn header(
382        &self,
383        region: DockRegion,
384        theme: &Theme,
385        window: &mut Window,
386        cx: &mut App,
387    ) -> AnyElement {
388        let state = self.region(region);
389        let ident = self.ident.child(region.name());
390        let active = state.active_panel().map(|panel| panel.id.clone());
391
392        let mut tabs = Tabs::new(ident.child("tabs"))
393            .small()
394            .tabs(state.panels.iter().map(|panel| {
395                let mut tab = TabItem::new(panel.id.clone(), panel.title.clone());
396                if let Some(glyph) = panel.icon {
397                    tab = tab.icon(glyph);
398                }
399                if let Some(badge) = panel.badge.clone() {
400                    tab = tab.badge(badge);
401                }
402                tab
403            }))
404            .disabled(self.disabled);
405        if let Some(active) = active {
406            tabs = tabs.selected(active);
407        }
408
409        if let (false, Some(handler)) = (self.disabled, self.on_event.clone()) {
410            let selected = Rc::clone(&handler);
411            let prefix = self.owns_prefix();
412            let panels: Vec<SharedString> =
413                state.panels.iter().map(|panel| panel.id.clone()).collect();
414            tabs = tabs
415                .on_select(move |panel, window, cx| {
416                    selected(DockEvent::PanelSelected { region, panel }, window, cx);
417                })
418                .reorderable(true)
419                .accepts(move |item: &DragItem, _: &DropPosition| item.source.starts_with(&prefix))
420                .on_reorder(move |intent, window, cx| {
421                    handler(
422                        DockEvent::PanelMoved {
423                            panel: intent.item.id.clone(),
424                            to_region: region,
425                            before: before_in(&panels, &intent.position),
426                        },
427                        window,
428                        cx,
429                    );
430                });
431        }
432
433        let collapse = self
434            .on_event
435            .clone()
436            .filter(|_| !self.disabled)
437            .map(|handler| {
438                let button = ident.child("collapse");
439                let name = cx.strings().text(StringKey::DockCollapseRegion);
440                div()
441                    .id(button.element_id())
442                    .flex_none()
443                    .flex()
444                    .items_center()
445                    .justify_center()
446                    .mb(px(theme.borders.thick))
447                    .size(px(theme.control.get(ControlSize::Sm).height))
448                    .radius(theme, Radius::Control)
449                    .cursor_pointer()
450                    .tab_index(0)
451                    .pressable(cx)
452                    .hover(|style| style.bg(theme.colors.hover))
453                    .focus_ring(theme)
454                    .child(
455                        icon(Icon::Sidebar)
456                            .size(px(theme.control.get(ControlSize::Sm).icon_size))
457                            .text_color(theme.colors.text_muted),
458                    )
459                    .on_click(move |_, window, cx| {
460                        handler(
461                            DockEvent::RegionCollapsed {
462                                region,
463                                collapsed: true,
464                            },
465                            window,
466                            cx,
467                        );
468                    })
469                    .tip(button.clone(), name.clone())
470                    .semantic_in(
471                        cx,
472                        NodeSpec::new(button.semantic_id(), Role::Button)
473                            .parent(ident.semantic_id())
474                            .text(name),
475                    )
476            });
477
478        let _ = window;
479        div()
480            .row()
481            .w_full()
482            .flex_none()
483            .items_end()
484            .gap_token(theme, Space::Xs)
485            .px_token(theme, Space::Xs)
486            .bg(theme.colors.panel)
487            .child(div().flex_1().min_w(px(0.0)).overflow_hidden().child(tabs))
488            .children(collapse)
489            .into_any_element()
490    }
491
492    /// The body of the region: the active panel, or why it cannot be shown.
493    fn body(
494        &self,
495        region: DockRegion,
496        theme: &Theme,
497        window: &mut Window,
498        cx: &mut App,
499    ) -> AnyElement {
500        let Some(panel) = self.region(region).active_panel() else {
501            return div().flex_1().into_any_element();
502        };
503        let ident = self.ident.child(region.name()).child(panel.id.as_ref());
504
505        let content = match &panel.unavailable {
506            // A refusal is drawn in place of the panel, not instead of it.
507            Some(reason) => div()
508                .column()
509                .flex_1()
510                .items_center()
511                .justify_center()
512                .gap_token(theme, Space::Sm)
513                .p_token(theme, Space::Lg)
514                .child(
515                    icon(Icon::CloseCircle)
516                        .size(px(20.0))
517                        .text_color(theme.colors.warning),
518                )
519                .child(
520                    div()
521                        .type_scale(theme, TypeScale::Body)
522                        .text_color(theme.colors.text)
523                        .child(panel.title.clone()),
524                )
525                .child(
526                    div()
527                        .max_w(px(320.0))
528                        .text_align(gpui::TextAlign::Center)
529                        .type_scale(theme, TypeScale::Caption)
530                        .text_color(theme.colors.text_muted)
531                        .child(reason.clone()),
532                )
533                .into_any_element(),
534            None => div()
535                .flex_1()
536                .min_h(px(0.0))
537                .overflow_hidden()
538                .children(panel.content.borrow_mut().take())
539                .into_any_element(),
540        };
541
542        let mut frame = div()
543            .id(ident.element_id())
544            .column()
545            .flex_1()
546            .min_h(px(0.0))
547            .overflow_hidden()
548            .child(content);
549
550        if let (false, Some(handler)) = (self.disabled, self.on_event.clone()) {
551            let surface = self.surface(region);
552            let name = SharedString::from(region.name());
553            let landing =
554                dnd::surface_drag(&surface, window, cx).and_then(|drag| drag.indicator_for(&name));
555            frame = frame.children(landing.map(|(position, accepted)| {
556                dnd::indicator(&position, accepted, DropAxis::Vertical, cx)
557            }));
558            let prefix = self.owns_prefix();
559            frame = dnd::drop_target(
560                frame,
561                RowTarget {
562                    surface,
563                    id: name,
564                    index: 0,
565                    allow_into: true,
566                    axis: DropAxis::Vertical,
567                    accepts: Rc::new(move |item: &DragItem, _: &DropPosition| {
568                        item.source.starts_with(&prefix)
569                    }),
570                    on_drop: Rc::new(move |intent: &DropIntent, window, cx| {
571                        handler(
572                            DockEvent::PanelMoved {
573                                panel: intent.item.id.clone(),
574                                to_region: region,
575                                before: None,
576                            },
577                            window,
578                            cx,
579                        );
580                    }),
581                },
582            );
583        }
584
585        let mut spec = NodeSpec::new(ident.semantic_id(), Role::TabPanel)
586            .parent(self.ident.child(region.name()).semantic_id())
587            .text(panel.title.clone())
588            .invalid(panel.unavailable.is_some());
589        if let Some(reason) = panel.unavailable.clone() {
590            spec = spec.value(reason);
591        }
592
593        // A panel that changed regions lands in its new slot on the frame the
594        // host applies the move; only the pixels take their time.
595        let slide = flip(self.ident.child(panel.id.as_ref()).semantic_id(), cx);
596        frame
597            .semantic_in(cx, spec)
598            .flip(&slide, window, cx)
599            .into_any_element()
600    }
601
602    /// The rail a collapsed region shows.
603    ///
604    /// Collapsing is a change of drawing, never of substance: every panel the
605    /// region holds is still published by name, so nothing becomes
606    /// unaddressable by being made narrow.
607    fn rail(&self, region: DockRegion, theme: &Theme, cx: &mut App) -> AnyElement {
608        let state = self.region(region);
609        let ident = self.ident.child(region.name()).child("rail");
610        let metrics = theme.control.get(ControlSize::Sm);
611        let active = state.active_panel().map(|panel| panel.id.clone());
612        let actionable = !self.disabled && self.on_event.is_some();
613
614        let items: Vec<_> = state
615            .panels
616            .iter()
617            .map(|panel| {
618                let item = ident.child(panel.id.as_ref());
619                let current = active.as_ref() == Some(&panel.id);
620                let color = if current {
621                    theme.colors.text
622                } else {
623                    theme.colors.text_muted
624                };
625                let mut glyph = div()
626                    .id(item.element_id())
627                    .flex()
628                    .flex_none()
629                    .items_center()
630                    .justify_center()
631                    .size(px(metrics.height))
632                    .radius(theme, Radius::Control)
633                    .when(current, |element| element.bg(theme.colors.selected))
634                    .child(match panel.icon {
635                        Some(glyph) => icon(glyph)
636                            .size(px(metrics.icon_size))
637                            .text_color(color)
638                            .into_any_element(),
639                        None => div()
640                            .type_scale(theme, TypeScale::Caption)
641                            .text_color(color)
642                            .child(initial(&panel.title))
643                            .into_any_element(),
644                    })
645                    .when(actionable, |element| {
646                        element
647                            .cursor_pointer()
648                            .tab_index(0)
649                            .pressable(cx)
650                            .hover(|style| style.bg(theme.colors.hover))
651                            .focus_ring(theme)
652                    })
653                    .tip(item.clone(), panel.title.clone());
654
655                if let (true, Some(handler)) = (actionable, self.on_event.clone()) {
656                    let id = panel.id.clone();
657                    glyph = glyph.on_click(move |_, window, cx| {
658                        // Picking from a rail is two requests, and the host
659                        // judges each: show this panel, and give the region
660                        // its room back.
661                        handler(
662                            DockEvent::PanelSelected {
663                                region,
664                                panel: id.clone(),
665                            },
666                            window,
667                            cx,
668                        );
669                        handler(
670                            DockEvent::RegionCollapsed {
671                                region,
672                                collapsed: false,
673                            },
674                            window,
675                            cx,
676                        );
677                    });
678                }
679
680                glyph.semantic_in(
681                    cx,
682                    NodeSpec::new(item.semantic_id(), Role::Button)
683                        .parent(ident.semantic_id())
684                        .selected(current)
685                        .disabled(!actionable)
686                        .text(panel.title.clone()),
687                )
688            })
689            .collect();
690
691        let upright = region.upright();
692        div()
693            .flex()
694            .when(upright, |rail| rail.flex_col().size_full())
695            .when(!upright, |rail| rail.flex_row().size_full())
696            .items_center()
697            .gap_token(theme, Space::Xs)
698            .p_token(theme, Space::Xs)
699            .bg(theme.colors.panel)
700            .children(items)
701            .semantic_in(
702                cx,
703                NodeSpec::new(ident.semantic_id(), Role::List)
704                    .parent(self.ident.child(region.name()).semantic_id())
705                    .expanded(false)
706                    .value(state.panels.len().to_string()),
707            )
708            .into_any_element()
709    }
710
711    fn region_element(
712        &self,
713        region: DockRegion,
714        theme: &Theme,
715        window: &mut Window,
716        cx: &mut App,
717    ) -> AnyElement {
718        let state = self.region(region);
719        let ident = self.ident.child(region.name());
720        let inner = if state.collapsed {
721            self.rail(region, theme, cx)
722        } else {
723            div()
724                .column()
725                .size_full()
726                .overflow_hidden()
727                .child(self.header(region, theme, window, cx))
728                .child(self.body(region, theme, window, cx))
729                .into_any_element()
730        };
731
732        div()
733            .column()
734            .size_full()
735            .overflow_hidden()
736            .bg(if region == DockRegion::Centre {
737                theme.colors.canvas
738            } else {
739                theme.colors.panel
740            })
741            .child(inner)
742            .semantic_in(
743                cx,
744                NodeSpec::new(ident.semantic_id(), Role::Region)
745                    .parent(self.ident.semantic_id())
746                    .expanded(!state.collapsed)
747                    .value(state.panels.len().to_string()),
748            )
749            .into_any_element()
750    }
751}
752
753/// The panel a drop lands in front of, given where it landed among `panels`.
754fn before_in(panels: &[SharedString], position: &DropPosition) -> Option<SharedString> {
755    let anchor = position.anchor();
756    match position {
757        DropPosition::Before(_) => Some(anchor.clone()),
758        DropPosition::After(_) => panels
759            .iter()
760            .position(|id| id == anchor)
761            .and_then(|at| panels.get(at + 1))
762            .cloned(),
763        // A drop on the region itself names no neighbour, so it appends.
764        DropPosition::Into(_) => None,
765    }
766}
767
768/// Which region a divider the tree reported belongs to.
769fn region_change(change: &SplitChange) -> Option<DockEvent> {
770    match change {
771        SplitChange::Ratio { split, ratio } => {
772            let (region, share) = match split.as_ref() {
773                BODY_SPLIT => (DockRegion::Left, *ratio),
774                COLUMNS_SPLIT => (DockRegion::Right, 1.0 - *ratio),
775                ROOT_SPLIT => (DockRegion::Bottom, 1.0 - *ratio),
776                _ => return None,
777            };
778            Some(DockEvent::RegionResized {
779                region,
780                ratio: share,
781            })
782        }
783        SplitChange::Collapsed { pane, .. } => DockRegion::ALL
784            .into_iter()
785            .find(|region| region.name() == pane.as_ref())
786            .map(|region| DockEvent::RegionCollapsed {
787                region,
788                collapsed: true,
789            }),
790    }
791}
792
793fn initial(title: &SharedString) -> SharedString {
794    SharedString::from(
795        title
796            .chars()
797            .next()
798            .map(|first| first.to_uppercase().to_string())
799            .unwrap_or_default(),
800    )
801}
802
803impl Disableable for Dock {
804    /// Freezes every panel header and rail. A frozen dock installs no handler.
805    fn disabled(mut self, disabled: bool) -> Self {
806        self.disabled = disabled;
807        self
808    }
809}
810
811impl RenderOnce for Dock {
812    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
813        let theme = cx.theme().clone();
814        let mut tree = SplitTree::new(self.ident.child("layout")).layout(self.layout());
815
816        for region in DockRegion::ALL {
817            if self.occupied(region) {
818                tree = tree.pane(
819                    region.name(),
820                    self.region_element(region, &theme, window, cx),
821                );
822            }
823        }
824
825        if let (false, Some(handler)) = (self.disabled, self.on_event.clone()) {
826            tree = tree.on_change(move |change, window, cx| {
827                if let Some(event) = region_change(&change) {
828                    handler(event, window, cx);
829                }
830            });
831        }
832
833        div()
834            .id(self.ident.element_id())
835            .size_full()
836            .overflow_hidden()
837            .bg(theme.colors.canvas)
838            .child(tree)
839            .semantic_in(
840                cx,
841                NodeSpec::new(self.ident.semantic_id(), Role::Group).value(
842                    self.regions
843                        .iter()
844                        .map(|region| region.panels.len())
845                        .sum::<usize>()
846                        .to_string(),
847                ),
848            )
849    }
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855    use crate::layout::SplitSide;
856
857    #[test]
858    fn a_region_with_no_panels_is_left_out_of_the_tree() {
859        let dock = Dock::new("dock").panel(DockRegion::Centre, DockPanel::new("editor", "Editor"));
860        let layout = dock.layout();
861        let names: Vec<&str> = layout
862            .panes()
863            .iter()
864            .map(|pane| pane.id().as_ref())
865            .collect();
866        assert_eq!(names, vec!["centre"]);
867    }
868
869    #[test]
870    fn every_occupied_region_becomes_a_leaf() {
871        let dock = Dock::new("dock")
872            .panel(DockRegion::Left, DockPanel::new("files", "Files"))
873            .panel(DockRegion::Centre, DockPanel::new("editor", "Editor"))
874            .panel(DockRegion::Right, DockPanel::new("outline", "Outline"))
875            .panel(DockRegion::Bottom, DockPanel::new("terminal", "Terminal"));
876        let layout = dock.layout();
877        let names: Vec<&str> = layout
878            .panes()
879            .iter()
880            .map(|pane| pane.id().as_ref())
881            .collect();
882        assert_eq!(names, vec!["left", "centre", "right", "bottom"]);
883    }
884
885    #[test]
886    fn a_ratio_reads_back_as_the_share_the_region_asked_for() {
887        assert_eq!(
888            region_change(&SplitChange::Ratio {
889                split: COLUMNS_SPLIT.into(),
890                ratio: 0.7,
891            }),
892            Some(DockEvent::RegionResized {
893                region: DockRegion::Right,
894                ratio: 0.3
895            })
896        );
897        assert_eq!(
898            region_change(&SplitChange::Ratio {
899                split: BODY_SPLIT.into(),
900                ratio: 0.3,
901            }),
902            Some(DockEvent::RegionResized {
903                region: DockRegion::Left,
904                ratio: 0.3
905            })
906        );
907    }
908
909    #[test]
910    fn a_drop_after_the_last_tab_names_no_neighbour() {
911        let panels: Vec<SharedString> = vec!["files".into(), "search".into()];
912        assert_eq!(
913            before_in(&panels, &DropPosition::Before("search".into())),
914            Some(SharedString::from("search"))
915        );
916        assert_eq!(
917            before_in(&panels, &DropPosition::After("files".into())),
918            Some(SharedString::from("search"))
919        );
920        assert_eq!(
921            before_in(&panels, &DropPosition::After("search".into())),
922            None
923        );
924        assert_eq!(before_in(&panels, &DropPosition::Into("left".into())), None);
925    }
926
927    #[test]
928    fn a_payload_from_somewhere_else_is_not_a_panel() {
929        let prefix = Dock::new("dock").owns_prefix();
930        assert!(
931            DragItem::new("dock.left.tabs", "files", "Files")
932                .source
933                .starts_with(&prefix)
934        );
935        assert!(
936            !DragItem::new("queue", "step-build", "Build")
937                .source
938                .starts_with(&prefix)
939        );
940    }
941
942    #[test]
943    fn collapsing_names_the_region_the_divider_stood_beside() {
944        assert_eq!(
945            region_change(&SplitChange::Collapsed {
946                split: BODY_SPLIT.into(),
947                side: SplitSide::Start,
948                pane: "left".into(),
949            }),
950            Some(DockEvent::RegionCollapsed {
951                region: DockRegion::Left,
952                collapsed: true
953            })
954        );
955    }
956}