1use std::{
9 cell::{Cell, RefCell},
10 collections::HashSet,
11 rc::Rc,
12 sync::Arc,
13};
14
15use gpui::{
16 Anchor, AnyElement, AnyView, App, AppContext as _, Context, Div, Empty,
17 InteractiveElement as _, IntoElement, ParentElement as _, Render, ScrollHandle, SharedString,
18 Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window, div,
19 prelude::FluentBuilder as _, px,
20};
21use gpui_base::{
22 dock::{
23 AnyDrag, DockPlacement, DragPanel, DropIndicator, NodeId, PaneNode, PaneRef, PanelId,
24 TabGroupContext, TabGroupRenderer,
25 },
26 spring,
27};
28use rust_i18n::t;
29
30use crate::{
31 ActiveTheme as _, IconName, Selectable as _, Sizable as _,
32 button::{Button, ButtonVariants as _},
33 dock::{ClosePanel, PanelControl, PanelHandle, PanelStyle, SkinShared, ToggleZoom},
34 h_flex,
35 menu::DropdownMenu as _,
36 tab::{Tab, TabBar},
37};
38
39const ZOOM_CONTROL_SELECTOR: &str = "dock-tab-bar-zoom-control";
42
43const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = gpui::size(px(96.), px(30.));
46
47pub struct DragPanelPreview {
52 panel: Arc<dyn gpui_base::dock::PanelView>,
53}
54
55impl Render for DragPanelPreview {
56 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
57 div()
58 .id("drag-panel")
59 .cursor_grab()
60 .py_1()
61 .px_3()
62 .w_24()
63 .overflow_hidden()
64 .whitespace_nowrap()
65 .border_1()
66 .border_color(cx.theme().border)
67 .rounded(cx.theme().radius)
68 .text_color(cx.theme().tab_foreground)
69 .bg(cx.theme().tokens.tab_active)
70 .opacity(0.75)
71 .child(panel_title(&self.panel, window, cx))
72 }
73}
74
75pub(crate) fn panel_title(
78 panel: &Arc<dyn gpui_base::dock::PanelView>,
79 window: &mut Window,
80 cx: &mut App,
81) -> AnyElement {
82 let Some(handle) = PanelHandle::of(panel) else {
83 let name = panel.panel_name(cx);
84 warn_unwrapped_once(panel.panel_id(cx), name);
85 return SharedString::from(name).into_any_element();
86 };
87 handle.title(window, cx)
88}
89
90thread_local! {
91 static WARNED_UNWRAPPED: RefCell<HashSet<PanelId>> = RefCell::new(HashSet::new());
102}
103
104fn warn_unwrapped_once(panel: PanelId, name: &'static str) {
111 if !WARNED_UNWRAPPED.with(|warned| warned.borrow_mut().insert(panel)) {
112 return;
113 }
114 tracing::warn!(
115 panel = name,
116 "dock panel reached the skin without its presentation handle, so it \
117 draws its panel name instead of its title; install it with \
118 `gpui_component::dock::panel_handle(..)` and `DockLayout::panel_view` \
119 / `DockArea::add_panel_view` rather than `DockLayout::panel` / \
120 `DockArea::add_panel`"
121 );
122}
123
124fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
135 let panel = group.active_panel()?;
136 panel
137 .zoomable(cx)
138 .then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx)))
139 .flatten()
140}
141
142fn tab_drag(group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
151 group
152 .is_draggable()
153 .then(|| group.drag_panel(ix, cx))
154 .flatten()
155}
156
157fn left_top_group(node: &PaneNode) -> Option<NodeId> {
160 match node.kind() {
161 PaneRef::Tabs { .. } => Some(node.id()),
162 PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
163 }
164}
165
166fn right_top_group(node: &PaneNode) -> Option<NodeId> {
170 match node.kind() {
171 PaneRef::Tabs { .. } => Some(node.id()),
172 PaneRef::Split { axis, children, .. } => match axis {
173 gpui::Axis::Vertical => children.first(),
174 gpui::Axis::Horizontal => children.last(),
175 }
176 .and_then(right_top_group),
177 }
178}
179
180pub(crate) struct TabGroupSkin {
186 shared: Rc<SkinShared>,
187 scroll_handle: ScrollHandle,
188 last_active_ix: Cell<Option<usize>>,
192}
193
194impl TabGroupSkin {
195 pub(crate) fn new(shared: Rc<SkinShared>) -> Self {
196 Self {
197 shared,
198 scroll_handle: ScrollHandle::default(),
199 last_active_ix: Cell::new(None),
200 }
201 }
202
203 fn dock_toggle_button(
206 &self,
207 placement: DockPlacement,
208 group: &TabGroupContext,
209 cx: &mut App,
210 ) -> Option<Button> {
211 if group.is_zoomed() || !self.shared.is_toggle_button_visible() {
212 return None;
213 }
214
215 let area = self.shared.area().upgrade()?;
216 let area = area.read(cx);
217 if !area.is_dock_collapsible(placement) {
220 return None;
221 }
222
223 let designated = match placement {
224 DockPlacement::Left => area
225 .layout(DockPlacement::Center)
226 .and_then(|tree| left_top_group(tree.root())),
227 DockPlacement::Right => area
228 .layout(DockPlacement::Center)
229 .and_then(|tree| right_top_group(tree.root())),
230 DockPlacement::Bottom => area
231 .layout(DockPlacement::Bottom)
232 .and_then(|tree| left_top_group(tree.root())),
233 DockPlacement::Center => None,
234 };
235 if designated != Some(group.node()) {
236 return None;
237 }
238
239 let is_open = area.is_dock_open(placement);
240 let icon = match (placement, is_open) {
241 (DockPlacement::Left, true) => IconName::PanelLeft,
242 (DockPlacement::Left, false) => IconName::PanelLeftOpen,
243 (DockPlacement::Right, true) => IconName::PanelRight,
244 (DockPlacement::Right, false) => IconName::PanelRightOpen,
245 (DockPlacement::Bottom, true) => IconName::PanelBottom,
246 (DockPlacement::Bottom, false) => IconName::PanelBottomOpen,
247 (DockPlacement::Center, _) => return None,
248 };
249
250 let area = self.shared.area().clone();
251 Some(
252 Button::new(SharedString::from(format!("toggle-dock:{:?}", placement)))
253 .icon(icon)
254 .xsmall()
255 .ghost()
256 .tab_stop(false)
257 .tooltip(match is_open {
258 true => t!("Dock.Collapse"),
259 false => t!("Dock.Expand"),
260 })
261 .on_click(move |_, window, cx| {
262 _ = area.update(cx, |area, cx| area.toggle_dock(placement, window, cx));
263 }),
264 )
265 }
266
267 fn render_toolbar(
270 &self,
271 group: &TabGroupContext,
272 window: &mut Window,
273 cx: &mut App,
274 ) -> impl IntoElement {
275 if group.is_collapsed() {
276 return div();
277 }
278
279 let zoomed = group.is_zoomed();
280 let handle = group.active_panel().and_then(PanelHandle::of);
281 let control = zoom_control(group, cx);
282 let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible());
283 let menu_zoom = control.is_some_and(|control| control.menu_visible());
284 let closable = group.is_closable();
285 let buttons = handle.and_then(|handle| handle.toolbar_buttons(window, cx));
286 let panel = handle.map(|handle| handle.panel());
287
288 h_flex()
289 .gap_1()
290 .occlude()
291 .when_some(buttons, |this, buttons| {
292 this.children(
293 buttons
294 .into_iter()
295 .map(|button| button.xsmall().ghost().tab_stop(false)),
296 )
297 })
298 .when_some(
299 match (zoomed, toolbar_zoom) {
300 (true, _) => Some(("zoom-out", IconName::Minimize, t!("Dock.Zoom Out"))),
301 (false, true) => Some(("zoom-in", IconName::Maximize, t!("Dock.Zoom In"))),
302 (false, false) => None,
303 },
304 |this, (id, icon, tooltip)| {
305 this.child(
306 Button::new(id)
307 .icon(icon)
308 .xsmall()
309 .ghost()
310 .tab_stop(false)
311 .tooltip_with_action(tooltip, &ToggleZoom, None)
312 .selected(zoomed)
313 .debug_selector(|| ZOOM_CONTROL_SELECTOR.to_string())
318 .on_click({
319 let group = group.clone();
320 move |_, window, cx| group.toggle_zoom(window, cx)
321 }),
322 )
323 },
324 )
325 .child(
326 Button::new("menu")
327 .icon(IconName::Ellipsis)
328 .xsmall()
329 .ghost()
330 .tab_stop(false)
331 .dropdown_menu(move |menu, window, cx| {
332 menu.when_some(panel.clone(), |menu, panel| {
333 panel.dropdown_menu(menu, window, cx)
334 })
335 .separator()
336 .menu_with_disabled(
337 match zoomed {
338 true => t!("Dock.Zoom Out"),
339 false => t!("Dock.Zoom In"),
340 },
341 Box::new(ToggleZoom),
342 !menu_zoom,
343 )
344 .when(closable, |menu| {
345 menu.separator()
346 .menu(t!("Dock.Close"), Box::new(ClosePanel))
347 })
348 })
349 .anchor(Anchor::TopRight),
350 )
351 }
352
353 fn render_title(
355 &self,
356 group: &TabGroupContext,
357 ix: usize,
358 window: &mut Window,
359 cx: &mut App,
360 ) -> AnyElement {
361 let panel = &group.panels()[ix];
362 let left_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
363 let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
364 let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
365 let has_leading = left_button.is_some() || bottom_button.is_some();
366 let handle = PanelHandle::of(panel);
367 let title_style = handle.and_then(|handle| handle.title_style(cx));
368 let drag = tab_drag(group, ix, cx);
369
370 h_flex()
371 .justify_between()
372 .h(px(30.))
373 .py_2()
374 .pl_3()
375 .pr_2()
376 .when(left_button.is_some(), |this| this.pl_2())
377 .when(right_button.is_some(), |this| this.pr_2())
378 .when_some(title_style, |this, style| {
379 this.bg(style.background).text_color(style.foreground)
380 })
381 .when(has_leading, |this| {
382 this.child(
383 h_flex()
384 .flex_shrink_0()
385 .mr_1()
386 .gap_1()
387 .children(left_button)
388 .children(bottom_button),
389 )
390 })
391 .child(
392 div()
393 .id("tab")
394 .flex_1()
395 .min_w_16()
396 .overflow_hidden()
397 .text_ellipsis()
398 .whitespace_nowrap()
399 .child(panel_title(panel, window, cx))
400 .when_some(drag, |this, drag| {
401 this.on_drag(drag, {
402 let panel = panel.clone();
403 move |drag, offset, _, cx| {
404 cx.stop_propagation();
405 drag.set_drag_offset(offset);
406 drag.set_preview_size(DRAG_PREVIEW_SIZE);
407 cx.new(|_| DragPanelPreview {
408 panel: panel.clone(),
409 })
410 }
411 })
412 }),
413 )
414 .children(handle.and_then(|handle| handle.title_suffix(window, cx)))
415 .child(
416 h_flex()
417 .flex_shrink_0()
418 .ml_1()
419 .gap_1()
420 .child(self.render_toolbar(group, window, cx))
421 .children(right_button),
422 )
423 .into_any_element()
424 }
425
426 fn render_tabs(
428 &self,
429 group: &TabGroupContext,
430 window: &mut Window,
431 cx: &mut App,
432 ) -> AnyElement {
433 let left_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
434 let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
435 let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
436 let has_leading = left_button.is_some() || bottom_button.is_some();
437 let is_bottom_dock = bottom_button.is_some();
438 let collapsed = group.is_collapsed();
439
440 let droppable = group.is_droppable();
441 let tabs_count = group.panels().len();
442 let active_ix = group.active_ix();
443 let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
444 let visible: Vec<usize> = group
445 .panels()
446 .iter()
447 .enumerate()
448 .filter(|(_, panel)| panel.visible(cx))
449 .map(|(ix, _)| ix)
450 .collect();
451 let displayed_ix = displayed.and_then(|displayed| {
452 group
453 .panels()
454 .iter()
455 .position(|panel| panel.panel_id(cx) == displayed)
456 });
457
458 if self.last_active_ix.replace(Some(active_ix)) != Some(active_ix) {
461 if let Some(visible_ix) = visible.iter().position(|ix| *ix == active_ix) {
462 self.scroll_handle.scroll_to_item(visible_ix);
463 }
464 }
465
466 TabBar::new("tab-bar")
467 .track_scroll(&self.scroll_handle)
468 .when(has_leading, |this| {
469 this.prefix(
470 h_flex()
471 .items_center()
472 .top_0()
473 .right(-px(1.))
475 .border_r_1()
476 .border_b_1()
477 .h_full()
478 .border_color(cx.theme().border)
479 .bg(cx.theme().tokens.tab_bar)
480 .px_2()
481 .children(left_button)
482 .children(bottom_button),
483 )
484 })
485 .children(
486 visible
487 .into_iter()
488 .map(|ix| {
489 let panel = &group.panels()[ix];
490 let handle = PanelHandle::of(panel);
491 let drag = tab_drag(group, ix, cx);
492
493 Tab::new()
494 .ix(ix)
495 .tab_bar_prefix(has_leading)
496 .map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
497 Some(tab_name) => this.child(tab_name),
498 None => this.child(panel_title(panel, window, cx)),
499 })
500 .selected(!collapsed && Some(ix) == displayed_ix)
506 .on_click({
507 let group = group.clone();
508 let area = self.shared.area().clone();
509 move |_, window, cx| {
510 group.select_tab(ix, window, cx);
511
512 if is_bottom_dock && collapsed {
515 _ = area.update(cx, |area, cx| {
516 area.toggle_dock(DockPlacement::Bottom, window, cx)
517 });
518 }
519 }
520 })
521 .when(!collapsed, |this| {
524 this.when_some(drag, |this, drag| {
525 this.on_drag(drag, {
526 let panel = panel.clone();
527 move |drag, offset, _, cx| {
528 cx.stop_propagation();
529 drag.set_drag_offset(offset);
530 drag.set_preview_size(DRAG_PREVIEW_SIZE);
531 cx.new(|_| DragPanelPreview {
532 panel: panel.clone(),
533 })
534 }
535 })
536 })
537 .when(droppable, |this| {
538 this.drag_over::<DragPanel>(|this, _, _, cx| {
539 this.rounded_l_none()
540 .border_l_2()
541 .border_r_0()
542 .border_color(cx.theme().drag_border)
543 })
544 .on_drop({
545 let group = group.clone();
546 move |drag: &DragPanel, window, cx| {
547 group.drop_panel(
548 drag.clone(),
549 Some(ix),
550 true,
551 window,
552 cx,
553 );
554 }
555 })
556 .drag_over::<AnyDrag>(|this, _, _, cx| {
557 this.rounded_l_none()
558 .border_l_2()
559 .border_r_0()
560 .border_color(cx.theme().drag_border)
561 })
562 .on_drop({
563 let group = group.clone();
564 move |item: &AnyDrag, window, cx| {
565 group.drop_item(item.clone(), None, window, cx);
566 }
567 })
568 })
569 })
570 })
571 .collect::<Vec<_>>(),
572 )
573 .last_empty_space(
574 div()
576 .id("tab-bar-empty-space")
577 .h_full()
578 .flex_grow_1()
579 .min_w_16()
580 .when(droppable, |this| {
581 this.drag_over::<DragPanel>(|this, _, _, cx| {
582 this.bg(cx.theme().tokens.drop_target)
583 })
584 .on_drop({
585 let group = group.clone();
586 let node = group.node();
587 move |drag: &DragPanel, window, cx| {
588 let ix = (drag.source() == node).then(|| tabs_count - 1);
592 group.drop_panel(drag.clone(), ix, false, window, cx);
593 }
594 })
595 .drag_over::<AnyDrag>(|this, _, _, cx| {
596 this.bg(cx.theme().tokens.drop_target)
597 })
598 .on_drop({
599 let group = group.clone();
600 move |item: &AnyDrag, window, cx| {
601 group.drop_item(item.clone(), None, window, cx);
602 }
603 })
604 }),
605 )
606 .when(!collapsed, |this| {
607 this.suffix(
608 h_flex()
609 .items_center()
610 .top_0()
611 .right_0()
612 .border_l_1()
613 .border_b_1()
614 .h_full()
615 .border_color(cx.theme().border)
616 .bg(cx.theme().tokens.tab_bar)
617 .px_2()
618 .gap_1()
619 .children(
620 group
621 .active_panel()
622 .and_then(PanelHandle::of)
623 .and_then(|handle| handle.title_suffix(window, cx)),
624 )
625 .child(self.render_toolbar(group, window, cx))
626 .children(right_button),
627 )
628 })
629 .into_any_element()
630 }
631}
632
633impl TabGroupRenderer for TabGroupSkin {
634 fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
635 let control = zoom_control(group, cx);
636
637 div()
640 .id("tab-panel")
641 .bg(cx.theme().tokens.background)
642 .when(!group.is_collapsed(), |this| {
645 this.on_action({
646 let group = group.clone();
647 move |_: &ToggleZoom, window, cx| {
648 if !group.is_zoomed() && control.is_none() {
654 return;
655 }
656 group.toggle_zoom(window, cx);
657 }
658 })
659 .on_action({
660 let group = group.clone();
661 move |_: &ClosePanel, window, cx| {
662 let Some(panel) = group.active_panel() else {
663 return;
664 };
665 let panel = panel.panel_id(cx);
666 group.close(panel, window, cx);
667 }
668 })
669 })
670 }
671
672 fn content_frame(
673 &self,
674 group: &TabGroupContext,
675 _: &mut Window,
676 cx: &mut App,
677 ) -> Stateful<Div> {
678 let padded = group.panels().len() > 1
679 && group
680 .active_panel()
681 .and_then(PanelHandle::of)
682 .is_none_or(|handle| handle.inner_padding(cx));
683
684 div().id("active-panel").when(padded, |this| this.pt_2())
687 }
688
689 fn render_tab_bar(
690 &self,
691 group: &TabGroupContext,
692 window: &mut Window,
693 cx: &mut App,
694 ) -> AnyElement {
695 let visible: Vec<usize> = group
696 .panels()
697 .iter()
698 .enumerate()
699 .filter(|(_, panel)| panel.visible(cx))
700 .map(|(ix, _)| ix)
701 .collect();
702
703 match visible.as_slice() {
704 [] => Empty.into_any_element(),
705 [ix] if self.shared.panel_style() == PanelStyle::Auto => {
706 let panel = &group.panels()[*ix];
708 if PanelHandle::of(panel).is_some_and(|handle| !handle.title_bar(cx)) {
709 return Empty.into_any_element();
710 }
711 self.render_title(group, *ix, window, cx)
712 }
713 _ => self.render_tabs(group, window, cx),
714 }
715 }
716
717 fn render_active_panel(
718 &self,
719 panel: AnyView,
720 group: &TabGroupContext,
721 _: &mut Window,
722 _: &mut App,
723 ) -> AnyElement {
724 if group.is_collapsed() {
725 return Empty.into_any_element();
726 }
727
728 div()
729 .id("tab-content")
730 .overflow_y_scroll()
731 .overflow_x_hidden()
732 .flex_1()
733 .child(panel.cached(StyleRefinement::default().absolute().size_full()))
734 .into_any_element()
735 }
736
737 fn render_drop_indicator(
738 &self,
739 indicator: DropIndicator,
740 window: &mut Window,
741 cx: &mut App,
742 ) -> Option<AnyElement> {
743 let to = indicator.to();
744 let id = "drop-placeholder";
750 let placeholder_spring = cx.theme().motion_tokens().spring_move.with_epsilon(0.5);
751 let left = spring((id, "left"), to.origin().x, placeholder_spring, window, cx);
752 let top = spring((id, "top"), to.origin().y, placeholder_spring, window, cx);
753 let width = spring(
754 (id, "width"),
755 to.size().width,
756 placeholder_spring,
757 window,
758 cx,
759 );
760 let height = spring(
761 (id, "height"),
762 to.size().height,
763 placeholder_spring,
764 window,
765 cx,
766 );
767
768 Some(
769 div()
770 .absolute()
771 .bg(cx.theme().tokens.drop_target)
772 .left(left)
773 .top(top)
774 .w(width)
775 .h(height)
776 .into_any_element(),
777 )
778 }
779}
780
781#[cfg(test)]
782mod tests {
783 use std::{
784 cell::{Cell, RefCell},
785 sync::Arc,
786 };
787
788 use gpui::{
789 Entity, EventEmitter, FocusHandle, Focusable, Pixels, TestAppContext, VisualTestContext,
790 };
791 use gpui_base::dock::{DockArea, DockAreaRenderer, DockLayout, DockPlacement, PanelEvent};
792
793 use super::*;
794 use crate::{
795 ElementExt as _,
796 dock::{
797 DockSkin, Panel, panel_handle,
798 test_support::{HideableProbe, MeasuredProbe},
799 },
800 };
801
802 struct Probe {
803 focus_handle: FocusHandle,
804 }
805
806 impl Probe {
807 fn new(cx: &mut App) -> Entity<Self> {
808 cx.new(|cx| Self {
809 focus_handle: cx.focus_handle(),
810 })
811 }
812 }
813
814 impl gpui_base::dock::Panel for Probe {
815 fn panel_name(&self) -> &'static str {
816 "Probe"
817 }
818 }
819
820 impl Panel for Probe {}
821 impl EventEmitter<PanelEvent> for Probe {}
822
823 impl Focusable for Probe {
824 fn focus_handle(&self, _: &App) -> FocusHandle {
825 self.focus_handle.clone()
826 }
827 }
828
829 impl Render for Probe {
830 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
831 Empty
832 }
833 }
834
835 #[derive(Default)]
838 struct Recorded {
839 draggable: Vec<bool>,
841 }
842
843 struct Recorder {
844 log: Rc<RefCell<Recorded>>,
845 }
846
847 impl TabGroupRenderer for Recorder {
848 fn render_tab_bar(
849 &self,
850 group: &TabGroupContext,
851 _: &mut Window,
852 cx: &mut App,
853 ) -> AnyElement {
854 let mut log = self.log.borrow_mut();
855 for ix in 0..group.panels().len() {
856 log.draggable.push(tab_drag(group, ix, cx).is_some());
857 }
858 Empty.into_any_element()
859 }
860 }
861
862 impl DockAreaRenderer for Recorder {
863 fn frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
864 div().id("recorder").size_full()
865 }
866
867 fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
868 Rc::new(Recorder {
869 log: self.log.clone(),
870 })
871 }
872 }
873
874 fn recording_area(
875 cx: &mut TestAppContext,
876 ) -> (
877 Entity<DockArea>,
878 Rc<RefCell<Recorded>>,
879 &mut VisualTestContext,
880 ) {
881 cx.update(|cx| {
882 crate::init(cx);
883 });
884 let log = Rc::new(RefCell::new(Recorded::default()));
885 let renderer = Rc::new(Recorder { log: log.clone() });
886 let (area, cx) = cx.add_window_view(|window, cx| {
887 DockArea::new("skin", None, window, cx).with_renderer(renderer)
888 });
889 (area, log, cx)
890 }
891
892 #[gpui::test]
896 fn the_last_group_in_a_dock_offers_no_drag(cx: &mut TestAppContext) {
897 let (area, log, cx) = recording_area(cx);
898
899 cx.update(|window, cx| {
900 let layout = DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx);
901 area.update(cx, |area, cx| area.set_center(layout, window, cx));
902 });
903 cx.run_until_parked();
904 log.borrow_mut().draggable.clear();
905 cx.update(|window, cx| window.draw(cx).clear(cx));
906
907 assert_eq!(
908 log.borrow().draggable,
909 vec![false],
910 "the only visible panel in the dock has nowhere to go, so its tab \
911 must not start a drag"
912 );
913 }
914
915 #[gpui::test]
916 fn a_group_beside_another_offers_a_drag(cx: &mut TestAppContext) {
917 let (area, log, cx) = recording_area(cx);
918
919 cx.update(|window, cx| {
920 let layout = DockLayout::h_split()
921 .child(
922 DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx),
923 None,
924 )
925 .child(
926 DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx),
927 None,
928 );
929 area.update(cx, |area, cx| area.set_center(layout, window, cx));
930 });
931 cx.run_until_parked();
932 log.borrow_mut().draggable.clear();
933 cx.update(|window, cx| window.draw(cx).clear(cx));
934
935 assert_eq!(
936 log.borrow().draggable,
937 vec![true, true],
938 "each group has somewhere to go, so both tabs start a drag"
939 );
940 }
941
942 struct ToolbarZoomProbe {
945 focus_handle: FocusHandle,
946 }
947
948 impl ToolbarZoomProbe {
949 fn new(cx: &mut App) -> Entity<Self> {
950 cx.new(|cx| Self {
951 focus_handle: cx.focus_handle(),
952 })
953 }
954 }
955
956 impl gpui_base::dock::Panel for ToolbarZoomProbe {
957 fn panel_name(&self) -> &'static str {
958 "ToolbarZoomProbe"
959 }
960 }
961
962 impl Panel for ToolbarZoomProbe {
963 fn zoom_control(&self, _: &App) -> Option<PanelControl> {
964 Some(PanelControl::Toolbar)
965 }
966 }
967
968 impl EventEmitter<PanelEvent> for ToolbarZoomProbe {}
969
970 impl Focusable for ToolbarZoomProbe {
971 fn focus_handle(&self, _: &App) -> FocusHandle {
972 self.focus_handle.clone()
973 }
974 }
975
976 impl Render for ToolbarZoomProbe {
977 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
978 Empty
979 }
980 }
981
982 struct NoControlProbe {
985 focus_handle: FocusHandle,
986 }
987
988 impl NoControlProbe {
989 fn new(cx: &mut App) -> Entity<Self> {
990 cx.new(|cx| Self {
991 focus_handle: cx.focus_handle(),
992 })
993 }
994 }
995
996 impl gpui_base::dock::Panel for NoControlProbe {
997 fn panel_name(&self) -> &'static str {
998 "NoControlProbe"
999 }
1000 }
1001
1002 impl Panel for NoControlProbe {
1003 fn zoom_control(&self, _: &App) -> Option<PanelControl> {
1004 None
1005 }
1006 }
1007
1008 impl EventEmitter<PanelEvent> for NoControlProbe {}
1009
1010 impl Focusable for NoControlProbe {
1011 fn focus_handle(&self, _: &App) -> FocusHandle {
1012 self.focus_handle.clone()
1013 }
1014 }
1015
1016 impl Render for NoControlProbe {
1017 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1018 Empty
1019 }
1020 }
1021
1022 struct UnzoomableProbe {
1025 focus_handle: FocusHandle,
1026 }
1027
1028 impl gpui_base::dock::Panel for UnzoomableProbe {
1029 fn panel_name(&self) -> &'static str {
1030 "UnzoomableProbe"
1031 }
1032
1033 fn zoomable(&self, _: &App) -> bool {
1034 false
1035 }
1036 }
1037
1038 impl Panel for UnzoomableProbe {
1039 fn zoom_control(&self, _: &App) -> Option<PanelControl> {
1040 Some(PanelControl::Toolbar)
1041 }
1042 }
1043
1044 impl EventEmitter<PanelEvent> for UnzoomableProbe {}
1045
1046 impl Focusable for UnzoomableProbe {
1047 fn focus_handle(&self, _: &App) -> FocusHandle {
1048 self.focus_handle.clone()
1049 }
1050 }
1051
1052 impl Render for UnzoomableProbe {
1053 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1054 Empty
1055 }
1056 }
1057
1058 fn drew_zoom_control(
1065 cx: &mut TestAppContext,
1066 panel: impl FnOnce(&mut App) -> Arc<dyn gpui_base::dock::PanelView>,
1067 ) -> bool {
1068 cx.update(|cx| {
1069 crate::init(cx);
1070 });
1071 let (area, cx) = cx.add_window_view(|window, cx| {
1072 let skin = DockSkin::new(cx);
1073 DockArea::new("skin", None, window, cx).with_renderer(skin)
1074 });
1075
1076 cx.update(|window, cx| {
1077 let layout = DockLayout::tabs().panel_view(panel(cx), cx);
1078 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1079 });
1080 cx.run_until_parked();
1081 cx.update(|window, cx| window.draw(cx).clear(cx));
1082 cx.debug_bounds(ZOOM_CONTROL_SELECTOR).is_some()
1083 }
1084
1085 #[gpui::test]
1089 fn a_panel_base_will_not_zoom_gets_no_zoom_control(cx: &mut TestAppContext) {
1090 let drew = drew_zoom_control(cx, |cx| {
1091 panel_handle(cx.new(|cx| UnzoomableProbe {
1092 focus_handle: cx.focus_handle(),
1093 }))
1094 });
1095
1096 assert!(
1097 !drew,
1098 "the panel names a place for the control, but base refuses the zoom"
1099 );
1100 }
1101
1102 #[gpui::test]
1106 fn a_zoomable_panel_gets_its_zoom_control(cx: &mut TestAppContext) {
1107 let drew = drew_zoom_control(cx, |cx| panel_handle(ToolbarZoomProbe::new(cx)));
1108
1109 assert!(
1110 drew,
1111 "a zoomable panel asking for a toolbar control gets one"
1112 );
1113 }
1114
1115 #[gpui::test]
1125 fn the_centre_and_the_bottom_dock_share_the_column(cx: &mut TestAppContext) {
1126 cx.update(|cx| {
1127 crate::init(cx);
1128 });
1129 let centre = Rc::new(Cell::new(px(0.)));
1130 let bottom = Rc::new(Cell::new(px(0.)));
1131 let (area, cx) = cx.add_window_view(|window, cx| {
1132 let skin = DockSkin::new(cx);
1133 DockArea::new("skin", None, window, cx).with_renderer(skin)
1134 });
1135
1136 let (centre_probe, bottom_probe) = (centre.clone(), bottom.clone());
1137 cx.update(|window, cx| {
1138 let centre_panel = MeasuredProbe::new(centre_probe, cx);
1139 let bottom_panel = MeasuredProbe::new(bottom_probe, cx);
1140 area.update(cx, |area, cx| {
1141 area.set_center(
1145 DockLayout::v_split().child(
1146 DockLayout::h_split().child(
1147 DockLayout::tabs().panel_view(panel_handle(centre_panel), cx),
1148 None,
1149 ),
1150 None,
1151 ),
1152 window,
1153 cx,
1154 );
1155 area.set_dock(
1156 DockPlacement::Bottom,
1157 DockLayout::tabs().panel_view(panel_handle(bottom_panel), cx),
1158 window,
1159 cx,
1160 );
1161 area.set_dock_size(DockPlacement::Bottom, px(200.), window, cx);
1162 });
1163 });
1164 cx.run_until_parked();
1165 cx.update(|window, cx| window.draw(cx).clear(cx));
1166
1167 let window_height = cx.update(|window, _| window.viewport_size().height);
1168 assert!(
1169 centre.get() > px(0.),
1170 "the centre panel must receive height; it got {:?}",
1171 centre.get()
1172 );
1173 assert!(
1174 bottom.get() > px(0.),
1175 "the bottom dock's panel must receive height; it got {:?}",
1176 bottom.get()
1177 );
1178 assert!(
1179 centre.get() < window_height - px(150.),
1180 "the centre must give the 200px bottom dock its share; the centre \
1181 got {:?} of {window_height:?}",
1182 centre.get()
1183 );
1184 }
1185
1186 #[gpui::test]
1201 fn a_split_fills_its_container_whichever_slots_are_hidden(cx: &mut TestAppContext) {
1202 cx.update(|cx| {
1203 crate::init(cx);
1204 });
1205 let heights: Vec<Rc<Cell<Pixels>>> = (0..3).map(|_| Rc::new(Cell::new(px(0.)))).collect();
1206 let (area, cx) = cx.add_window_view(|window, cx| {
1207 let skin = DockSkin::new(cx);
1208 DockArea::new("skin", None, window, cx).with_renderer(skin)
1209 });
1210
1211 let slots = heights.clone();
1212 let probes = cx.update(|window, cx| {
1213 let probes: Vec<_> = slots
1214 .iter()
1215 .map(|height| HideableProbe::new(height.clone(), cx))
1216 .collect();
1217 area.update(cx, |area, cx| {
1218 area.set_dock(
1219 DockPlacement::Right,
1220 probes.iter().zip([260., 320., 200.]).fold(
1221 DockLayout::v_split(),
1222 |split, (probe, size)| {
1223 split.child(
1224 DockLayout::tabs().panel_view(panel_handle(probe.clone()), cx),
1225 Some(px(size)),
1226 )
1227 },
1228 ),
1229 window,
1230 cx,
1231 );
1232 area.set_dock_size(DockPlacement::Right, px(380.), window, cx);
1233 });
1234 probes
1235 });
1236 cx.run_until_parked();
1237 let draw = |cx: &mut VisualTestContext| {
1238 cx.update(|window, _| window.refresh());
1239 cx.update(|window, cx| window.draw(cx).clear(cx));
1240 cx.update(|window, cx| window.draw(cx).clear(cx));
1241 };
1242 draw(cx);
1243
1244 let dock_height = cx.update(|window, _| window.viewport_size().height);
1245 let drawn: Pixels = heights.iter().map(|height| height.get()).sum();
1249 let bar = (dock_height - drawn) / 3.;
1250 assert!(
1251 bar > px(0.) && bar < px(60.),
1252 "the three slots fill the dock to begin with, one tab bar each; \
1253 that leaves {bar:?} per slot of {dock_height:?}"
1254 );
1255
1256 for hidden in 1..0b111u8 {
1259 let shown = (0..3).filter(|slot| hidden & (1 << slot) == 0);
1260 cx.update(|_, cx| {
1261 for (slot, probe) in probes.iter().enumerate() {
1262 heights[slot].set(px(-1.));
1265 probe.update(cx, |probe, cx| {
1266 probe.set_visible(hidden & (1 << slot) == 0, cx)
1267 });
1268 }
1269 });
1270 cx.run_until_parked();
1271 draw(cx);
1272
1273 let mut count = 0;
1274 let mut total = px(0.);
1275 for slot in shown {
1276 assert_ne!(
1277 heights[slot].get(),
1278 px(-1.),
1279 "hiding {hidden:03b}: slot {slot} is shown and must draw"
1280 );
1281 count += 1;
1282 total += heights[slot].get();
1283 }
1284 let empty = dock_height - total - bar * count as f32;
1285 assert!(
1286 empty.abs() < px(1.),
1287 "hiding {hidden:03b}: the drawn slots must take the hidden \
1288 ones' space between them; they left {empty:?} of \
1289 {dock_height:?} empty"
1290 );
1291 }
1292 }
1293
1294 #[gpui::test]
1298 fn the_zoom_action_reaches_the_group_through_the_skin(cx: &mut TestAppContext) {
1299 cx.update(|cx| {
1300 crate::init(cx);
1301 });
1302 let (area, cx) = cx.add_window_view(|window, cx| {
1303 let skin = DockSkin::new(cx);
1304 DockArea::new("skin", None, window, cx).with_renderer(skin)
1305 });
1306
1307 let panel = cx.update(|window, cx| {
1308 let panel = Probe::new(cx);
1309 let layout = DockLayout::tabs().panel_view(panel_handle(panel.clone()), cx);
1310 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1311 panel
1312 });
1313 cx.run_until_parked();
1314 cx.update(|window, cx| {
1315 panel.read(cx).focus_handle(cx).focus(window, cx);
1316 });
1317 cx.run_until_parked();
1318
1319 assert_eq!(cx.read(|cx| area.read(cx).is_zoomed()), false);
1320 cx.dispatch_action(ToggleZoom);
1321 cx.run_until_parked();
1322 assert_eq!(
1323 cx.read(|cx| area.read(cx).is_zoomed()),
1324 true,
1325 "the skin's frame is what carries the ToggleZoom handler"
1326 );
1327
1328 cx.dispatch_action(ToggleZoom);
1329 cx.run_until_parked();
1330 assert_eq!(cx.read(|cx| area.read(cx).is_zoomed()), false);
1331 }
1332
1333 #[gpui::test]
1339 fn the_zoom_action_refuses_a_panel_that_offers_no_control(cx: &mut TestAppContext) {
1340 cx.update(|cx| {
1341 crate::init(cx);
1342 });
1343 let (area, cx) = cx.add_window_view(|window, cx| {
1344 let skin = DockSkin::new(cx);
1345 DockArea::new("skin", None, window, cx).with_renderer(skin)
1346 });
1347
1348 let panel = cx.update(|window, cx| {
1349 let panel = NoControlProbe::new(cx);
1350 let layout = DockLayout::tabs().panel_view(panel_handle(panel.clone()), cx);
1351 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1352 panel
1353 });
1354 cx.run_until_parked();
1355 cx.update(|window, cx| {
1356 panel.read(cx).focus_handle(cx).focus(window, cx);
1357 });
1358 cx.run_until_parked();
1359
1360 cx.dispatch_action(ToggleZoom);
1361 cx.run_until_parked();
1362 assert_eq!(
1363 cx.read(|cx| area.read(cx).is_zoomed()),
1364 false,
1365 "no control means no zoom, however the zoom was asked for"
1366 );
1367 }
1368
1369 #[gpui::test]
1384 fn the_panel_content_region_gets_the_height_below_the_tab_bar(cx: &mut TestAppContext) {
1385 cx.update(|cx| {
1386 crate::init(cx);
1387 });
1388 let height = Rc::new(Cell::new(px(0.)));
1389 let (area, cx) = cx.add_window_view(|window, cx| {
1390 let skin = DockSkin::new(cx);
1391 DockArea::new("skin", None, window, cx).with_renderer(skin)
1392 });
1393
1394 let measured = height.clone();
1395 cx.update(|window, cx| {
1396 let panel = MeasuredProbe::new(measured, cx);
1397 let layout = DockLayout::tabs().panel_view(panel_handle(panel), cx);
1398 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1399 });
1400 cx.run_until_parked();
1401 cx.update(|window, cx| window.draw(cx).clear(cx));
1402
1403 let window_height = cx.update(|window, _| window.viewport_size().height);
1404 let content = height.get();
1405 assert!(
1406 content > px(0.),
1407 "the panel must receive height; it got {content:?} in a {window_height:?} window"
1408 );
1409 assert!(
1412 content > window_height - px(60.),
1413 "the panel should fill what the tab bar leaves; it got {content:?} \
1414 of {window_height:?}"
1415 );
1416 }
1417
1418 #[gpui::test]
1425 fn a_collapsed_dock_ignores_the_zoom_action(cx: &mut TestAppContext) {
1426 cx.update(|cx| {
1427 crate::init(cx);
1428 });
1429 let (area, cx) = cx.add_window_view(|window, cx| {
1430 let skin = DockSkin::new(cx);
1431 DockArea::new("skin", None, window, cx).with_renderer(skin)
1432 });
1433
1434 let panel = cx.update(|window, cx| {
1435 let panel = Probe::new(cx);
1436 let layout = DockLayout::tabs().panel_view(panel_handle(panel.clone()), cx);
1437 area.update(cx, |area, cx| {
1438 area.set_dock(DockPlacement::Bottom, layout, window, cx);
1439 area.toggle_dock(DockPlacement::Bottom, window, cx);
1440 });
1441 panel
1442 });
1443 cx.run_until_parked();
1444 cx.update(|window, cx| {
1445 panel.read(cx).focus_handle(cx).focus(window, cx);
1446 });
1447 cx.run_until_parked();
1448
1449 cx.dispatch_action(ToggleZoom);
1450 cx.run_until_parked();
1451 assert_eq!(
1452 cx.read(|cx| area.read(cx).is_zoomed()),
1453 false,
1454 "a collapsed group installs no action handler"
1455 );
1456 }
1457
1458 #[gpui::test]
1461 fn a_panel_without_a_title_bar_gets_the_whole_group(cx: &mut TestAppContext) {
1462 struct Chromeless {
1463 focus_handle: FocusHandle,
1464 height: Rc<Cell<Pixels>>,
1465 }
1466
1467 impl gpui_base::dock::Panel for Chromeless {
1468 fn panel_name(&self) -> &'static str {
1469 "Chromeless"
1470 }
1471 }
1472
1473 impl Panel for Chromeless {
1474 fn title_bar(&self, _: &App) -> bool {
1475 false
1476 }
1477 }
1478
1479 impl EventEmitter<PanelEvent> for Chromeless {}
1480
1481 impl Focusable for Chromeless {
1482 fn focus_handle(&self, _: &App) -> FocusHandle {
1483 self.focus_handle.clone()
1484 }
1485 }
1486
1487 impl Render for Chromeless {
1488 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1489 let height = self.height.clone();
1490 div()
1491 .size_full()
1492 .on_prepaint(move |bounds, _, _| height.set(bounds.size.height))
1493 }
1494 }
1495
1496 cx.update(|cx| crate::init(cx));
1497
1498 let measure = |cx: &mut TestAppContext, chromeless: bool| -> Pixels {
1499 let height = Rc::new(Cell::new(px(0.)));
1500 let (area, cx) = cx.add_window_view(|window, cx| {
1501 let skin = DockSkin::new(cx);
1502 DockArea::new("skin", None, window, cx).with_renderer(skin)
1503 });
1504 let measured = height.clone();
1505 cx.update(|window, cx| {
1506 let panel: Arc<dyn gpui_base::dock::PanelView> = if chromeless {
1507 panel_handle(cx.new(|cx| Chromeless {
1508 focus_handle: cx.focus_handle(),
1509 height: measured,
1510 }))
1511 } else {
1512 panel_handle(MeasuredProbe::new(measured, cx))
1513 };
1514 let layout = DockLayout::tabs().panel_view(panel, cx);
1515 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1516 });
1517 cx.run_until_parked();
1518 cx.update(|window, cx| window.draw(cx).clear(cx));
1519 height.get()
1520 };
1521
1522 let with_title = measure(cx, false);
1523 let without = measure(cx, true);
1524 assert!(with_title > px(0.), "the probe must have been drawn");
1525 assert_eq!(
1526 without,
1527 with_title + px(30.),
1528 "the 30px the title bar took go to the panel instead"
1529 );
1530 }
1531}