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 PaneRef::Tiles { .. } => None,
164 }
165}
166
167fn right_top_group(node: &PaneNode) -> Option<NodeId> {
171 match node.kind() {
172 PaneRef::Tabs { .. } => Some(node.id()),
173 PaneRef::Split { axis, children, .. } => match axis {
174 gpui::Axis::Vertical => children.first(),
175 gpui::Axis::Horizontal => children.last(),
176 }
177 .and_then(right_top_group),
178 PaneRef::Tiles { .. } => None,
179 }
180}
181
182pub(crate) struct TabGroupSkin {
188 shared: Rc<SkinShared>,
189 scroll_handle: ScrollHandle,
190 last_active_ix: Cell<Option<usize>>,
194}
195
196impl TabGroupSkin {
197 pub(crate) fn new(shared: Rc<SkinShared>) -> Self {
198 Self {
199 shared,
200 scroll_handle: ScrollHandle::default(),
201 last_active_ix: Cell::new(None),
202 }
203 }
204
205 fn dock_toggle_button(
208 &self,
209 placement: DockPlacement,
210 group: &TabGroupContext,
211 cx: &mut App,
212 ) -> Option<Button> {
213 if group.is_zoomed() || !self.shared.is_toggle_button_visible() {
214 return None;
215 }
216
217 let area = self.shared.area().upgrade()?;
218 let area = area.read(cx);
219 if !area.is_dock_collapsible(placement) {
222 return None;
223 }
224
225 let designated = match placement {
226 DockPlacement::Left => area
227 .layout(DockPlacement::Center)
228 .and_then(|tree| left_top_group(tree.root())),
229 DockPlacement::Right => area
230 .layout(DockPlacement::Center)
231 .and_then(|tree| right_top_group(tree.root())),
232 DockPlacement::Bottom => area
233 .layout(DockPlacement::Bottom)
234 .and_then(|tree| left_top_group(tree.root())),
235 DockPlacement::Center => None,
236 };
237 if designated != Some(group.node()) {
238 return None;
239 }
240
241 let is_open = area.is_dock_open(placement);
242 let icon = match (placement, is_open) {
243 (DockPlacement::Left, true) => IconName::PanelLeft,
244 (DockPlacement::Left, false) => IconName::PanelLeftOpen,
245 (DockPlacement::Right, true) => IconName::PanelRight,
246 (DockPlacement::Right, false) => IconName::PanelRightOpen,
247 (DockPlacement::Bottom, true) => IconName::PanelBottom,
248 (DockPlacement::Bottom, false) => IconName::PanelBottomOpen,
249 (DockPlacement::Center, _) => return None,
250 };
251
252 let area = self.shared.area().clone();
253 Some(
254 Button::new(SharedString::from(format!("toggle-dock:{:?}", placement)))
255 .icon(icon)
256 .xsmall()
257 .ghost()
258 .tab_stop(false)
259 .tooltip(match is_open {
260 true => t!("Dock.Collapse"),
261 false => t!("Dock.Expand"),
262 })
263 .on_click(move |_, window, cx| {
264 _ = area.update(cx, |area, cx| area.toggle_dock(placement, window, cx));
265 }),
266 )
267 }
268
269 fn render_toolbar(
272 &self,
273 group: &TabGroupContext,
274 window: &mut Window,
275 cx: &mut App,
276 ) -> impl IntoElement {
277 if group.is_collapsed() {
278 return div();
279 }
280
281 let zoomed = group.is_zoomed();
282 let handle = group.active_panel().and_then(PanelHandle::of);
283 let control = zoom_control(group, cx);
284 let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible());
285 let menu_zoom = control.is_some_and(|control| control.menu_visible());
286 let closable = group.is_closable();
287 let buttons = handle.and_then(|handle| handle.toolbar_buttons(window, cx));
288 let panel = handle.map(|handle| handle.panel());
289
290 h_flex()
291 .gap_1()
292 .occlude()
293 .when_some(buttons, |this, buttons| {
294 this.children(
295 buttons
296 .into_iter()
297 .map(|button| button.xsmall().ghost().tab_stop(false)),
298 )
299 })
300 .when_some(
301 match (zoomed, toolbar_zoom) {
302 (true, _) => Some(("zoom-out", IconName::Minimize, t!("Dock.Zoom Out"))),
303 (false, true) => Some(("zoom-in", IconName::Maximize, t!("Dock.Zoom In"))),
304 (false, false) => None,
305 },
306 |this, (id, icon, tooltip)| {
307 this.child(
308 Button::new(id)
309 .icon(icon)
310 .xsmall()
311 .ghost()
312 .tab_stop(false)
313 .tooltip_with_action(tooltip, &ToggleZoom, None)
314 .selected(zoomed)
315 .debug_selector(|| ZOOM_CONTROL_SELECTOR.to_string())
320 .on_click({
321 let group = group.clone();
322 move |_, window, cx| group.toggle_zoom(window, cx)
323 }),
324 )
325 },
326 )
327 .child(
328 Button::new("menu")
329 .icon(IconName::Ellipsis)
330 .xsmall()
331 .ghost()
332 .tab_stop(false)
333 .dropdown_menu(move |menu, window, cx| {
334 menu.when_some(panel.clone(), |menu, panel| {
335 panel.dropdown_menu(menu, window, cx)
336 })
337 .separator()
338 .menu_with_disabled(
339 match zoomed {
340 true => t!("Dock.Zoom Out"),
341 false => t!("Dock.Zoom In"),
342 },
343 Box::new(ToggleZoom),
344 !menu_zoom,
345 )
346 .when(closable, |menu| {
347 menu.separator()
348 .menu(t!("Dock.Close"), Box::new(ClosePanel))
349 })
350 })
351 .anchor(Anchor::TopRight),
352 )
353 }
354
355 fn render_title(
357 &self,
358 group: &TabGroupContext,
359 ix: usize,
360 window: &mut Window,
361 cx: &mut App,
362 ) -> AnyElement {
363 let panel = &group.panels()[ix];
364 let left_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
365 let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
366 let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
367 let has_leading = left_button.is_some() || bottom_button.is_some();
368 let handle = PanelHandle::of(panel);
369 let title_style = handle.and_then(|handle| handle.title_style(cx));
370 let drag = tab_drag(group, ix, cx);
371
372 h_flex()
373 .justify_between()
374 .h(px(30.))
375 .py_2()
376 .pl_3()
377 .pr_2()
378 .when(left_button.is_some(), |this| this.pl_2())
379 .when(right_button.is_some(), |this| this.pr_2())
380 .when_some(title_style, |this, style| {
381 this.bg(style.background).text_color(style.foreground)
382 })
383 .when(has_leading, |this| {
384 this.child(
385 h_flex()
386 .flex_shrink_0()
387 .mr_1()
388 .gap_1()
389 .children(left_button)
390 .children(bottom_button),
391 )
392 })
393 .child(
394 div()
395 .id("tab")
396 .flex_1()
397 .min_w_16()
398 .overflow_hidden()
399 .text_ellipsis()
400 .whitespace_nowrap()
401 .child(panel_title(panel, window, cx))
402 .when_some(drag, |this, drag| {
403 this.on_drag(drag, {
404 let panel = panel.clone();
405 move |drag, offset, _, cx| {
406 cx.stop_propagation();
407 drag.set_drag_offset(offset);
408 drag.set_preview_size(DRAG_PREVIEW_SIZE);
409 cx.new(|_| DragPanelPreview {
410 panel: panel.clone(),
411 })
412 }
413 })
414 }),
415 )
416 .children(handle.and_then(|handle| handle.title_suffix(window, cx)))
417 .child(
418 h_flex()
419 .flex_shrink_0()
420 .ml_1()
421 .gap_1()
422 .child(self.render_toolbar(group, window, cx))
423 .children(right_button),
424 )
425 .into_any_element()
426 }
427
428 fn render_tabs(
430 &self,
431 group: &TabGroupContext,
432 window: &mut Window,
433 cx: &mut App,
434 ) -> AnyElement {
435 let left_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
436 let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
437 let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
438 let has_leading = left_button.is_some() || bottom_button.is_some();
439 let is_bottom_dock = bottom_button.is_some();
440 let collapsed = group.is_collapsed();
441
442 let droppable = group.is_droppable();
443 let tabs_count = group.panels().len();
444 let active_ix = group.active_ix();
445 let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
446 let visible: Vec<usize> = group
447 .panels()
448 .iter()
449 .enumerate()
450 .filter(|(_, panel)| panel.visible(cx))
451 .map(|(ix, _)| ix)
452 .collect();
453 let displayed_ix = displayed.and_then(|displayed| {
454 group
455 .panels()
456 .iter()
457 .position(|panel| panel.panel_id(cx) == displayed)
458 });
459
460 if self.last_active_ix.replace(Some(active_ix)) != Some(active_ix) {
463 if let Some(visible_ix) = visible.iter().position(|ix| *ix == active_ix) {
464 self.scroll_handle.scroll_to_item(visible_ix);
465 }
466 }
467
468 TabBar::new("tab-bar")
469 .track_scroll(&self.scroll_handle)
470 .when(has_leading, |this| {
471 this.prefix(
472 h_flex()
473 .items_center()
474 .top_0()
475 .right(-px(1.))
477 .border_r_1()
478 .border_b_1()
479 .h_full()
480 .border_color(cx.theme().border)
481 .bg(cx.theme().tokens.tab_bar)
482 .px_2()
483 .children(left_button)
484 .children(bottom_button),
485 )
486 })
487 .children(
488 visible
489 .into_iter()
490 .map(|ix| {
491 let panel = &group.panels()[ix];
492 let handle = PanelHandle::of(panel);
493 let drag = tab_drag(group, ix, cx);
494
495 Tab::new()
496 .ix(ix)
497 .tab_bar_prefix(has_leading)
498 .map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
499 Some(tab_name) => this.child(tab_name),
500 None => this.child(panel_title(panel, window, cx)),
501 })
502 .selected(!collapsed && Some(ix) == displayed_ix)
508 .on_click({
509 let group = group.clone();
510 let area = self.shared.area().clone();
511 move |_, window, cx| {
512 group.select_tab(ix, window, cx);
513
514 if is_bottom_dock && collapsed {
517 _ = area.update(cx, |area, cx| {
518 area.toggle_dock(DockPlacement::Bottom, window, cx)
519 });
520 }
521 }
522 })
523 .when(!collapsed, |this| {
526 this.when_some(drag, |this, drag| {
527 this.on_drag(drag, {
528 let panel = panel.clone();
529 move |drag, offset, _, cx| {
530 cx.stop_propagation();
531 drag.set_drag_offset(offset);
532 drag.set_preview_size(DRAG_PREVIEW_SIZE);
533 cx.new(|_| DragPanelPreview {
534 panel: panel.clone(),
535 })
536 }
537 })
538 })
539 .when(droppable, |this| {
540 this.drag_over::<DragPanel>(|this, _, _, cx| {
541 this.rounded_l_none()
542 .border_l_2()
543 .border_r_0()
544 .border_color(cx.theme().drag_border)
545 })
546 .on_drop({
547 let group = group.clone();
548 move |drag: &DragPanel, window, cx| {
549 group.drop_panel(
550 drag.clone(),
551 Some(ix),
552 true,
553 window,
554 cx,
555 );
556 }
557 })
558 .drag_over::<AnyDrag>(|this, _, _, cx| {
559 this.rounded_l_none()
560 .border_l_2()
561 .border_r_0()
562 .border_color(cx.theme().drag_border)
563 })
564 .on_drop({
565 let group = group.clone();
566 move |item: &AnyDrag, window, cx| {
567 group.drop_item(item.clone(), None, window, cx);
568 }
569 })
570 })
571 })
572 })
573 .collect::<Vec<_>>(),
574 )
575 .last_empty_space(
576 div()
578 .id("tab-bar-empty-space")
579 .h_full()
580 .flex_grow_1()
581 .min_w_16()
582 .when(droppable, |this| {
583 this.drag_over::<DragPanel>(|this, _, _, cx| {
584 this.bg(cx.theme().tokens.drop_target)
585 })
586 .on_drop({
587 let group = group.clone();
588 let node = group.node();
589 move |drag: &DragPanel, window, cx| {
590 let ix = (drag.source() == node).then(|| tabs_count - 1);
594 group.drop_panel(drag.clone(), ix, false, window, cx);
595 }
596 })
597 .drag_over::<AnyDrag>(|this, _, _, cx| {
598 this.bg(cx.theme().tokens.drop_target)
599 })
600 .on_drop({
601 let group = group.clone();
602 move |item: &AnyDrag, window, cx| {
603 group.drop_item(item.clone(), None, window, cx);
604 }
605 })
606 }),
607 )
608 .when(!collapsed, |this| {
609 this.suffix(
610 h_flex()
611 .items_center()
612 .top_0()
613 .right_0()
614 .border_l_1()
615 .border_b_1()
616 .h_full()
617 .border_color(cx.theme().border)
618 .bg(cx.theme().tokens.tab_bar)
619 .px_2()
620 .gap_1()
621 .children(
622 group
623 .active_panel()
624 .and_then(PanelHandle::of)
625 .and_then(|handle| handle.title_suffix(window, cx)),
626 )
627 .child(self.render_toolbar(group, window, cx))
628 .children(right_button),
629 )
630 })
631 .into_any_element()
632 }
633}
634
635impl TabGroupRenderer for TabGroupSkin {
636 fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
637 let control = zoom_control(group, cx);
638
639 div()
642 .id("tab-panel")
643 .bg(cx.theme().tokens.background)
644 .when(!group.is_collapsed(), |this| {
647 this.on_action({
648 let group = group.clone();
649 move |_: &ToggleZoom, window, cx| {
650 if !group.is_zoomed() && control.is_none() {
656 return;
657 }
658 group.toggle_zoom(window, cx);
659 }
660 })
661 .on_action({
662 let group = group.clone();
663 move |_: &ClosePanel, window, cx| {
664 let Some(panel) = group.active_panel() else {
665 return;
666 };
667 let panel = panel.panel_id(cx);
668 group.close(panel, window, cx);
669 }
670 })
671 })
672 }
673
674 fn content_frame(
675 &self,
676 group: &TabGroupContext,
677 _: &mut Window,
678 cx: &mut App,
679 ) -> Stateful<Div> {
680 let padded = group.panels().len() > 1
681 && group
682 .active_panel()
683 .and_then(PanelHandle::of)
684 .is_none_or(|handle| handle.inner_padding(cx));
685
686 div().id("active-panel").when(padded, |this| this.pt_2())
689 }
690
691 fn render_tab_bar(
692 &self,
693 group: &TabGroupContext,
694 window: &mut Window,
695 cx: &mut App,
696 ) -> AnyElement {
697 let visible: Vec<usize> = group
698 .panels()
699 .iter()
700 .enumerate()
701 .filter(|(_, panel)| panel.visible(cx))
702 .map(|(ix, _)| ix)
703 .collect();
704
705 match visible.as_slice() {
706 [] => Empty.into_any_element(),
707 [ix] if self.shared.panel_style() == PanelStyle::Auto => {
708 self.render_title(group, *ix, window, cx)
709 }
710 _ => self.render_tabs(group, window, cx),
711 }
712 }
713
714 fn render_active_panel(
715 &self,
716 panel: AnyView,
717 group: &TabGroupContext,
718 _: &mut Window,
719 _: &mut App,
720 ) -> AnyElement {
721 if group.is_collapsed() {
722 return Empty.into_any_element();
723 }
724
725 div()
726 .id("tab-content")
727 .overflow_y_scroll()
728 .overflow_x_hidden()
729 .flex_1()
730 .child(panel.cached(StyleRefinement::default().absolute().size_full()))
731 .into_any_element()
732 }
733
734 fn render_drop_indicator(
735 &self,
736 indicator: DropIndicator,
737 window: &mut Window,
738 cx: &mut App,
739 ) -> Option<AnyElement> {
740 let to = indicator.to();
741 let id = "drop-placeholder";
747 let placeholder_spring = cx.theme().motion_tokens().spring_move.with_epsilon(0.5);
748 let left = spring((id, "left"), to.origin().x, placeholder_spring, window, cx);
749 let top = spring((id, "top"), to.origin().y, placeholder_spring, window, cx);
750 let width = spring(
751 (id, "width"),
752 to.size().width,
753 placeholder_spring,
754 window,
755 cx,
756 );
757 let height = spring(
758 (id, "height"),
759 to.size().height,
760 placeholder_spring,
761 window,
762 cx,
763 );
764
765 Some(
766 div()
767 .absolute()
768 .bg(cx.theme().tokens.drop_target)
769 .left(left)
770 .top(top)
771 .w(width)
772 .h(height)
773 .into_any_element(),
774 )
775 }
776}
777
778#[cfg(test)]
779mod tests {
780 use std::cell::RefCell;
781
782 use gpui::{
783 Entity, EventEmitter, FocusHandle, Focusable, Pixels, TestAppContext, VisualTestContext,
784 };
785 use gpui_base::dock::{
786 DockArea, DockAreaRenderer, DockLayout, DockPlacement, PanelEvent, TileContext,
787 TilesRenderer,
788 };
789
790 use super::*;
791 use crate::dock::{
792 DockSkin, Panel, panel_handle,
793 test_support::{HideableProbe, MeasuredProbe},
794 };
795
796 struct Probe {
797 focus_handle: FocusHandle,
798 }
799
800 impl Probe {
801 fn new(cx: &mut App) -> Entity<Self> {
802 cx.new(|cx| Self {
803 focus_handle: cx.focus_handle(),
804 })
805 }
806 }
807
808 impl gpui_base::dock::Panel for Probe {
809 fn panel_name(&self) -> &'static str {
810 "Probe"
811 }
812 }
813
814 impl Panel for Probe {}
815 impl EventEmitter<PanelEvent> for Probe {}
816
817 impl Focusable for Probe {
818 fn focus_handle(&self, _: &App) -> FocusHandle {
819 self.focus_handle.clone()
820 }
821 }
822
823 impl Render for Probe {
824 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
825 Empty
826 }
827 }
828
829 #[derive(Default)]
832 struct Recorded {
833 draggable: Vec<bool>,
835 }
836
837 struct Recorder {
838 log: Rc<RefCell<Recorded>>,
839 }
840
841 impl TabGroupRenderer for Recorder {
842 fn render_tab_bar(
843 &self,
844 group: &TabGroupContext,
845 _: &mut Window,
846 cx: &mut App,
847 ) -> AnyElement {
848 let mut log = self.log.borrow_mut();
849 for ix in 0..group.panels().len() {
850 log.draggable.push(tab_drag(group, ix, cx).is_some());
851 }
852 Empty.into_any_element()
853 }
854 }
855
856 impl TilesRenderer for Recorder {
857 fn render_drag_bar(&self, _: &TileContext, _: &mut Window, _: &mut App) -> AnyElement {
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 fn tiles_renderer(&self) -> Rc<dyn TilesRenderer> {
874 Rc::new(Recorder {
875 log: self.log.clone(),
876 })
877 }
878 }
879
880 fn recording_area(
881 cx: &mut TestAppContext,
882 ) -> (
883 Entity<DockArea>,
884 Rc<RefCell<Recorded>>,
885 &mut VisualTestContext,
886 ) {
887 cx.update(|cx| {
888 crate::init(cx);
889 });
890 let log = Rc::new(RefCell::new(Recorded::default()));
891 let renderer = Rc::new(Recorder { log: log.clone() });
892 let (area, cx) = cx.add_window_view(|window, cx| {
893 DockArea::new("skin", None, window, cx).with_renderer(renderer)
894 });
895 (area, log, cx)
896 }
897
898 #[gpui::test]
902 fn the_last_group_in_a_dock_offers_no_drag(cx: &mut TestAppContext) {
903 let (area, log, cx) = recording_area(cx);
904
905 cx.update(|window, cx| {
906 let layout = DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx);
907 area.update(cx, |area, cx| area.set_center(layout, window, cx));
908 });
909 cx.run_until_parked();
910 log.borrow_mut().draggable.clear();
911 cx.update(|window, cx| window.draw(cx).clear(cx));
912
913 assert_eq!(
914 log.borrow().draggable,
915 vec![false],
916 "the only visible panel in the dock has nowhere to go, so its tab \
917 must not start a drag"
918 );
919 }
920
921 #[gpui::test]
922 fn a_group_beside_another_offers_a_drag(cx: &mut TestAppContext) {
923 let (area, log, cx) = recording_area(cx);
924
925 cx.update(|window, cx| {
926 let layout = DockLayout::h_split()
927 .child(
928 DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx),
929 None,
930 )
931 .child(
932 DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx),
933 None,
934 );
935 area.update(cx, |area, cx| area.set_center(layout, window, cx));
936 });
937 cx.run_until_parked();
938 log.borrow_mut().draggable.clear();
939 cx.update(|window, cx| window.draw(cx).clear(cx));
940
941 assert_eq!(
942 log.borrow().draggable,
943 vec![true, true],
944 "each group has somewhere to go, so both tabs start a drag"
945 );
946 }
947
948 struct ToolbarZoomProbe {
951 focus_handle: FocusHandle,
952 }
953
954 impl ToolbarZoomProbe {
955 fn new(cx: &mut App) -> Entity<Self> {
956 cx.new(|cx| Self {
957 focus_handle: cx.focus_handle(),
958 })
959 }
960 }
961
962 impl gpui_base::dock::Panel for ToolbarZoomProbe {
963 fn panel_name(&self) -> &'static str {
964 "ToolbarZoomProbe"
965 }
966 }
967
968 impl Panel for ToolbarZoomProbe {
969 fn zoom_control(&self, _: &App) -> Option<PanelControl> {
970 Some(PanelControl::Toolbar)
971 }
972 }
973
974 impl EventEmitter<PanelEvent> for ToolbarZoomProbe {}
975
976 impl Focusable for ToolbarZoomProbe {
977 fn focus_handle(&self, _: &App) -> FocusHandle {
978 self.focus_handle.clone()
979 }
980 }
981
982 impl Render for ToolbarZoomProbe {
983 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
984 Empty
985 }
986 }
987
988 struct NoControlProbe {
991 focus_handle: FocusHandle,
992 }
993
994 impl NoControlProbe {
995 fn new(cx: &mut App) -> Entity<Self> {
996 cx.new(|cx| Self {
997 focus_handle: cx.focus_handle(),
998 })
999 }
1000 }
1001
1002 impl gpui_base::dock::Panel for NoControlProbe {
1003 fn panel_name(&self) -> &'static str {
1004 "NoControlProbe"
1005 }
1006 }
1007
1008 impl Panel for NoControlProbe {
1009 fn zoom_control(&self, _: &App) -> Option<PanelControl> {
1010 None
1011 }
1012 }
1013
1014 impl EventEmitter<PanelEvent> for NoControlProbe {}
1015
1016 impl Focusable for NoControlProbe {
1017 fn focus_handle(&self, _: &App) -> FocusHandle {
1018 self.focus_handle.clone()
1019 }
1020 }
1021
1022 impl Render for NoControlProbe {
1023 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1024 Empty
1025 }
1026 }
1027
1028 struct UnzoomableProbe {
1031 focus_handle: FocusHandle,
1032 }
1033
1034 impl gpui_base::dock::Panel for UnzoomableProbe {
1035 fn panel_name(&self) -> &'static str {
1036 "UnzoomableProbe"
1037 }
1038
1039 fn zoomable(&self, _: &App) -> bool {
1040 false
1041 }
1042 }
1043
1044 impl Panel for UnzoomableProbe {
1045 fn zoom_control(&self, _: &App) -> Option<PanelControl> {
1046 Some(PanelControl::Toolbar)
1047 }
1048 }
1049
1050 impl EventEmitter<PanelEvent> for UnzoomableProbe {}
1051
1052 impl Focusable for UnzoomableProbe {
1053 fn focus_handle(&self, _: &App) -> FocusHandle {
1054 self.focus_handle.clone()
1055 }
1056 }
1057
1058 impl Render for UnzoomableProbe {
1059 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1060 Empty
1061 }
1062 }
1063
1064 fn drew_zoom_control(
1071 cx: &mut TestAppContext,
1072 panel: impl FnOnce(&mut App) -> Arc<dyn gpui_base::dock::PanelView>,
1073 ) -> bool {
1074 cx.update(|cx| {
1075 crate::init(cx);
1076 });
1077 let (area, cx) = cx.add_window_view(|window, cx| {
1078 let skin = DockSkin::new(cx);
1079 DockArea::new("skin", None, window, cx).with_renderer(skin)
1080 });
1081
1082 cx.update(|window, cx| {
1083 let layout = DockLayout::tabs().panel_view(panel(cx), cx);
1084 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1085 });
1086 cx.run_until_parked();
1087 cx.update(|window, cx| window.draw(cx).clear(cx));
1088 cx.debug_bounds(ZOOM_CONTROL_SELECTOR).is_some()
1089 }
1090
1091 #[gpui::test]
1095 fn a_panel_base_will_not_zoom_gets_no_zoom_control(cx: &mut TestAppContext) {
1096 let drew = drew_zoom_control(cx, |cx| {
1097 panel_handle(cx.new(|cx| UnzoomableProbe {
1098 focus_handle: cx.focus_handle(),
1099 }))
1100 });
1101
1102 assert!(
1103 !drew,
1104 "the panel names a place for the control, but base refuses the zoom"
1105 );
1106 }
1107
1108 #[gpui::test]
1112 fn a_zoomable_panel_gets_its_zoom_control(cx: &mut TestAppContext) {
1113 let drew = drew_zoom_control(cx, |cx| panel_handle(ToolbarZoomProbe::new(cx)));
1114
1115 assert!(
1116 drew,
1117 "a zoomable panel asking for a toolbar control gets one"
1118 );
1119 }
1120
1121 #[gpui::test]
1131 fn the_centre_and_the_bottom_dock_share_the_column(cx: &mut TestAppContext) {
1132 cx.update(|cx| {
1133 crate::init(cx);
1134 });
1135 let centre = Rc::new(Cell::new(px(0.)));
1136 let bottom = Rc::new(Cell::new(px(0.)));
1137 let (area, cx) = cx.add_window_view(|window, cx| {
1138 let skin = DockSkin::new(cx);
1139 DockArea::new("skin", None, window, cx).with_renderer(skin)
1140 });
1141
1142 let (centre_probe, bottom_probe) = (centre.clone(), bottom.clone());
1143 cx.update(|window, cx| {
1144 let centre_panel = MeasuredProbe::new(centre_probe, cx);
1145 let bottom_panel = MeasuredProbe::new(bottom_probe, cx);
1146 area.update(cx, |area, cx| {
1147 area.set_center(
1151 DockLayout::v_split().child(
1152 DockLayout::h_split().child(
1153 DockLayout::tabs().panel_view(panel_handle(centre_panel), cx),
1154 None,
1155 ),
1156 None,
1157 ),
1158 window,
1159 cx,
1160 );
1161 area.set_dock(
1162 DockPlacement::Bottom,
1163 DockLayout::tabs().panel_view(panel_handle(bottom_panel), cx),
1164 window,
1165 cx,
1166 );
1167 area.set_dock_size(DockPlacement::Bottom, px(200.), window, cx);
1168 });
1169 });
1170 cx.run_until_parked();
1171 cx.update(|window, cx| window.draw(cx).clear(cx));
1172
1173 let window_height = cx.update(|window, _| window.viewport_size().height);
1174 assert!(
1175 centre.get() > px(0.),
1176 "the centre panel must receive height; it got {:?}",
1177 centre.get()
1178 );
1179 assert!(
1180 bottom.get() > px(0.),
1181 "the bottom dock's panel must receive height; it got {:?}",
1182 bottom.get()
1183 );
1184 assert!(
1185 centre.get() < window_height - px(150.),
1186 "the centre must give the 200px bottom dock its share; the centre \
1187 got {:?} of {window_height:?}",
1188 centre.get()
1189 );
1190 }
1191
1192 #[gpui::test]
1207 fn a_split_fills_its_container_whichever_slots_are_hidden(cx: &mut TestAppContext) {
1208 cx.update(|cx| {
1209 crate::init(cx);
1210 });
1211 let heights: Vec<Rc<Cell<Pixels>>> = (0..3).map(|_| Rc::new(Cell::new(px(0.)))).collect();
1212 let (area, cx) = cx.add_window_view(|window, cx| {
1213 let skin = DockSkin::new(cx);
1214 DockArea::new("skin", None, window, cx).with_renderer(skin)
1215 });
1216
1217 let slots = heights.clone();
1218 let probes = cx.update(|window, cx| {
1219 let probes: Vec<_> = slots
1220 .iter()
1221 .map(|height| HideableProbe::new(height.clone(), cx))
1222 .collect();
1223 area.update(cx, |area, cx| {
1224 area.set_dock(
1225 DockPlacement::Right,
1226 probes.iter().zip([260., 320., 200.]).fold(
1227 DockLayout::v_split(),
1228 |split, (probe, size)| {
1229 split.child(
1230 DockLayout::tabs().panel_view(panel_handle(probe.clone()), cx),
1231 Some(px(size)),
1232 )
1233 },
1234 ),
1235 window,
1236 cx,
1237 );
1238 area.set_dock_size(DockPlacement::Right, px(380.), window, cx);
1239 });
1240 probes
1241 });
1242 cx.run_until_parked();
1243 let draw = |cx: &mut VisualTestContext| {
1244 cx.update(|window, _| window.refresh());
1245 cx.update(|window, cx| window.draw(cx).clear(cx));
1246 cx.update(|window, cx| window.draw(cx).clear(cx));
1247 };
1248 draw(cx);
1249
1250 let dock_height = cx.update(|window, _| window.viewport_size().height);
1251 let drawn: Pixels = heights.iter().map(|height| height.get()).sum();
1255 let bar = (dock_height - drawn) / 3.;
1256 assert!(
1257 bar > px(0.) && bar < px(60.),
1258 "the three slots fill the dock to begin with, one tab bar each; \
1259 that leaves {bar:?} per slot of {dock_height:?}"
1260 );
1261
1262 for hidden in 1..0b111u8 {
1265 let shown = (0..3).filter(|slot| hidden & (1 << slot) == 0);
1266 cx.update(|_, cx| {
1267 for (slot, probe) in probes.iter().enumerate() {
1268 heights[slot].set(px(-1.));
1271 probe.update(cx, |probe, cx| {
1272 probe.set_visible(hidden & (1 << slot) == 0, cx)
1273 });
1274 }
1275 });
1276 cx.run_until_parked();
1277 draw(cx);
1278
1279 let mut count = 0;
1280 let mut total = px(0.);
1281 for slot in shown {
1282 assert_ne!(
1283 heights[slot].get(),
1284 px(-1.),
1285 "hiding {hidden:03b}: slot {slot} is shown and must draw"
1286 );
1287 count += 1;
1288 total += heights[slot].get();
1289 }
1290 let empty = dock_height - total - bar * count as f32;
1291 assert!(
1292 empty.abs() < px(1.),
1293 "hiding {hidden:03b}: the drawn slots must take the hidden \
1294 ones' space between them; they left {empty:?} of \
1295 {dock_height:?} empty"
1296 );
1297 }
1298 }
1299
1300 #[gpui::test]
1304 fn the_zoom_action_reaches_the_group_through_the_skin(cx: &mut TestAppContext) {
1305 cx.update(|cx| {
1306 crate::init(cx);
1307 });
1308 let (area, cx) = cx.add_window_view(|window, cx| {
1309 let skin = DockSkin::new(cx);
1310 DockArea::new("skin", None, window, cx).with_renderer(skin)
1311 });
1312
1313 let panel = cx.update(|window, cx| {
1314 let panel = Probe::new(cx);
1315 let layout = DockLayout::tabs().panel_view(panel_handle(panel.clone()), cx);
1316 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1317 panel
1318 });
1319 cx.run_until_parked();
1320 cx.update(|window, cx| {
1321 panel.read(cx).focus_handle(cx).focus(window, cx);
1322 });
1323 cx.run_until_parked();
1324
1325 assert_eq!(cx.read(|cx| area.read(cx).is_zoomed()), false);
1326 cx.dispatch_action(ToggleZoom);
1327 cx.run_until_parked();
1328 assert_eq!(
1329 cx.read(|cx| area.read(cx).is_zoomed()),
1330 true,
1331 "the skin's frame is what carries the ToggleZoom handler"
1332 );
1333
1334 cx.dispatch_action(ToggleZoom);
1335 cx.run_until_parked();
1336 assert_eq!(cx.read(|cx| area.read(cx).is_zoomed()), false);
1337 }
1338
1339 #[gpui::test]
1345 fn the_zoom_action_refuses_a_panel_that_offers_no_control(cx: &mut TestAppContext) {
1346 cx.update(|cx| {
1347 crate::init(cx);
1348 });
1349 let (area, cx) = cx.add_window_view(|window, cx| {
1350 let skin = DockSkin::new(cx);
1351 DockArea::new("skin", None, window, cx).with_renderer(skin)
1352 });
1353
1354 let panel = cx.update(|window, cx| {
1355 let panel = NoControlProbe::new(cx);
1356 let layout = DockLayout::tabs().panel_view(panel_handle(panel.clone()), cx);
1357 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1358 panel
1359 });
1360 cx.run_until_parked();
1361 cx.update(|window, cx| {
1362 panel.read(cx).focus_handle(cx).focus(window, cx);
1363 });
1364 cx.run_until_parked();
1365
1366 cx.dispatch_action(ToggleZoom);
1367 cx.run_until_parked();
1368 assert_eq!(
1369 cx.read(|cx| area.read(cx).is_zoomed()),
1370 false,
1371 "no control means no zoom, however the zoom was asked for"
1372 );
1373 }
1374
1375 #[gpui::test]
1390 fn the_panel_content_region_gets_the_height_below_the_tab_bar(cx: &mut TestAppContext) {
1391 cx.update(|cx| {
1392 crate::init(cx);
1393 });
1394 let height = Rc::new(Cell::new(px(0.)));
1395 let (area, cx) = cx.add_window_view(|window, cx| {
1396 let skin = DockSkin::new(cx);
1397 DockArea::new("skin", None, window, cx).with_renderer(skin)
1398 });
1399
1400 let measured = height.clone();
1401 cx.update(|window, cx| {
1402 let panel = MeasuredProbe::new(measured, cx);
1403 let layout = DockLayout::tabs().panel_view(panel_handle(panel), cx);
1404 area.update(cx, |area, cx| area.set_center(layout, window, cx));
1405 });
1406 cx.run_until_parked();
1407 cx.update(|window, cx| window.draw(cx).clear(cx));
1408
1409 let window_height = cx.update(|window, _| window.viewport_size().height);
1410 let content = height.get();
1411 assert!(
1412 content > px(0.),
1413 "the panel must receive height; it got {content:?} in a {window_height:?} window"
1414 );
1415 assert!(
1418 content > window_height - px(60.),
1419 "the panel should fill what the tab bar leaves; it got {content:?} \
1420 of {window_height:?}"
1421 );
1422 }
1423
1424 #[gpui::test]
1431 fn a_collapsed_dock_ignores_the_zoom_action(cx: &mut TestAppContext) {
1432 cx.update(|cx| {
1433 crate::init(cx);
1434 });
1435 let (area, cx) = cx.add_window_view(|window, cx| {
1436 let skin = DockSkin::new(cx);
1437 DockArea::new("skin", None, window, cx).with_renderer(skin)
1438 });
1439
1440 let panel = cx.update(|window, cx| {
1441 let panel = Probe::new(cx);
1442 let layout = DockLayout::tabs().panel_view(panel_handle(panel.clone()), cx);
1443 area.update(cx, |area, cx| {
1444 area.set_dock(DockPlacement::Bottom, layout, window, cx);
1445 area.toggle_dock(DockPlacement::Bottom, window, cx);
1446 });
1447 panel
1448 });
1449 cx.run_until_parked();
1450 cx.update(|window, cx| {
1451 panel.read(cx).focus_handle(cx).focus(window, cx);
1452 });
1453 cx.run_until_parked();
1454
1455 cx.dispatch_action(ToggleZoom);
1456 cx.run_until_parked();
1457 assert_eq!(
1458 cx.read(|cx| area.read(cx).is_zoomed()),
1459 false,
1460 "a collapsed group installs no action handler"
1461 );
1462 }
1463}