use std::sync::Arc; use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, ElementId, Entity, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, ScrollHandle, StatefulInteractiveElement, Styled as _, Window, div, px, }; use gpui_base::{InteractiveElementExt, Tab, Tabs}; use gpui_component::{ActiveTheme, ElementExt, h_flex}; use super::{AnyDrag, DragPanel, PanelView, TabPanel}; use crate::TAB_BAR_HEIGHT; /// The dock's custom tab bar, built on gpui-base's unstyled [`Tab`]/[`Tabs`]. /// /// The dock owns the pill presentation; the layout mirrors gpui-component's /// `TabBar`: a prefix (dock toggle / tab navigation), a scrollable tab strip /// with a trailing drop target, then a suffix (panel toolbar). /// /// Tabs are draggable to move panels between groups and double as drop /// targets, so panel-level events are forwarded to the owning [`TabPanel`]. #[derive(IntoElement)] pub(crate) struct TabBar { id: ElementId, panels: Vec>, active_panel: Option>, collapsed: bool, draggable: bool, droppable: bool, tab_panel: Entity, scroll_handle: ScrollHandle, prefix: Option, suffix: Option, empty_space: Option, } impl TabBar { pub(crate) fn new(id: impl Into, tab_panel: Entity) -> Self { Self { id: id.into(), panels: Vec::new(), active_panel: None, collapsed: false, draggable: false, droppable: false, tab_panel, scroll_handle: ScrollHandle::new(), prefix: None, suffix: None, empty_space: None, } } /// The panels to show as tabs, in strip order. pub(crate) fn panels(mut self, panels: Vec>) -> Self { self.panels = panels; self } /// The currently active panel; its tab is rendered as the filled pill. pub(crate) fn active_panel(mut self, active_panel: Option>) -> Self { self.active_panel = active_panel; self } /// Collapsed tab panels render no suffix or trailing drop target, and /// their tabs lose the active style and all interactions. pub(crate) fn collapsed(mut self, collapsed: bool) -> Self { self.collapsed = collapsed; self } /// Whether the tabs can start a panel drag. pub(crate) fn draggable(mut self, draggable: bool) -> Self { self.draggable = draggable; self } /// Whether the tabs and trailing space accept drops. pub(crate) fn droppable(mut self, droppable: bool) -> Self { self.droppable = droppable; self } /// Track the strip's scroll state with the given handle, so callers can /// scroll a tab into view with [`ScrollHandle::scroll_to_item`]. pub(crate) fn scroll_handle(mut self, scroll_handle: &ScrollHandle) -> Self { self.scroll_handle = scroll_handle.clone(); self } /// Element shown before the tab strip. pub(crate) fn prefix(mut self, prefix: impl IntoElement) -> Self { self.prefix = Some(prefix.into_any_element()); self } /// Element shown after the tab strip. pub(crate) fn suffix(mut self, suffix: impl IntoElement) -> Self { self.suffix = Some(suffix.into_any_element()); self } /// Replace the trailing empty space (the drop target after the last tab). #[cfg(test)] pub(crate) fn empty_space(mut self, empty_space: impl IntoElement) -> Self { self.empty_space = Some(empty_space.into_any_element()); self } fn render_tab( &self, ix: usize, panel: Arc, active: bool, window: &mut Window, cx: &mut App, ) -> Tab { // While collapsed, tabs lose the active style and all interactions. let droppable = self.collapsed; let tab_panel = self.tab_panel.clone(); // The `ix` element id keeps each tab's identity stable across renders. Tab::new(ix) .h_6() .px_3() .text_sm() .whitespace_nowrap() .flex() .items_center() .justify_center() .gap_1() .flex_shrink_0() .overflow_hidden() .rounded(cx.theme().radius) .text_color(cx.theme().foreground) .map(|this| { if let Some(tab_name) = panel.tab_name(cx) { this.child(tab_name) } else { this.child(panel.title(window, cx)) } }) // Pill presentation: the selected tab is the filled pill, the // rest are transparent until hovered. .styles(|styles| { styles.selected(|style| { style .text_color(cx.theme().tab_active_foreground) .bg(cx.theme().tab_active) }) }) .hover(|this| { if active { this } else { this.text_color(cx.theme().secondary_foreground) .bg(cx.theme().secondary_hover) } }) .selected(active) .on_click(move |_, window, cx| { tab_panel.update(cx, |view, cx| view.set_active_ix(ix, window, cx)); }) .when(!droppable, |this| { this.when(self.draggable, |this| { this.on_drag( DragPanel::new(panel.clone(), self.tab_panel.clone()), |drag, offset, _, cx| { cx.stop_propagation(); drag.drag_offset.set(offset); cx.new(|_| drag.clone()) }, ) }) .when(self.droppable, |this| { this.drag_over::(|this, _, _, cx| { this.rounded_l_none() .border_l_2() .border_r_0() .border_color(cx.theme().drag_border) }) .on_drop({ let tab_panel = self.tab_panel.clone(); move |drag: &DragPanel, window, cx| { tab_panel.update(cx, |view, cx| { view.will_split_placement = None; view.on_drop(drag, Some(ix), true, window, cx); }); } }) .drag_over::(|this, _, _, cx| { this.rounded_l_none() .border_l_2() .border_r_0() .border_color(cx.theme().drag_border) }) .on_drop({ let tab_panel = self.tab_panel.clone(); move |item: &AnyDrag, _, cx| { tab_panel.update(cx, |view, cx| { view.will_split_placement = None; view.emit_drag_drop(item, None, cx); }); } }) }) }) } fn render_empty_space(&self) -> AnyElement { let tabs_count = self.panels.len(); // The strip after the last tab is a drop target for panels and // host-owned drag items. Its left edge (right after the last tab) // marks the start of the title-bar drag overlay. let mut empty = div() .id("tab-bar-empty-space") .h_full() .flex_grow_1() .min_w_16() .on_prepaint({ let view = self.tab_panel.clone(); move |bounds, _, cx| { view.update(cx, |this, cx| { if this.title_bar_strip_bounds != Some(bounds) { this.title_bar_strip_bounds = Some(bounds); cx.notify(); } }); } }); if self.droppable { empty = empty .drag_over::(|this, _, _, cx| this.bg(cx.theme().tokens.drop_target)) .on_drop({ let view = self.tab_panel.clone(); move |drag: &DragPanel, window, cx| { view.update(cx, |this, cx| { this.will_split_placement = None; // Dropping a panel from this same tab group onto // the strip moves it after the last tab. let ix = if drag.tab_panel == cx.entity() { Some(tabs_count - 1) } else { None }; this.on_drop(drag, ix, false, window, cx); }); } }) .drag_over::(|this, _, _, cx| this.bg(cx.theme().tokens.drop_target)) .on_drop({ let view = self.tab_panel.clone(); move |item: &AnyDrag, _, cx| { view.update(cx, |this, cx| { this.will_split_placement = None; this.emit_drag_drop(item, None, cx); }); } }); } empty.into_any_element() } } impl RenderOnce for TabBar { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let tabs: Vec<_> = self .panels .iter() .enumerate() .filter_map(|(ix, panel)| { let mut active = self.active_panel.as_ref() == Some(panel); if !panel.visible(cx) { return None; } // Always not show active tab style, if the panel is collapsed if self.collapsed { active = false; } Some(self.render_tab(ix, panel.clone(), active, window, cx)) }) .collect(); let empty_space = match self.empty_space { Some(empty_space) => empty_space, None => self.render_empty_space(), }; Tabs::new(self.id) .px(px(-1.)) .h(TAB_BAR_HEIGHT) .flex() .items_center() .text_color(cx.theme().tab_foreground) .when_some(self.prefix, |this, prefix| this.child(prefix)) .child( h_flex().id("tabs").flex_1().overflow_x_hidden().child( h_flex() .id("tabs-inner") .relative() .gap(px(4.)) .overflow_x_scroll() .lock_scroll_axis() .track_scroll(&self.scroll_handle) .children(tabs) .when(!self.collapsed, |this| this.child(empty_space)), ), ) .when_some(self.suffix, |this, suffix| { this.when(!self.collapsed, |this| this.child(suffix)) }) } } #[cfg(test)] mod tests { use std::sync::atomic::{AtomicBool, Ordering}; use gpui::{ Context, Entity, MouseButton, Render, TestAppContext, VisualTestContext, WindowOptions, div, px, size, }; use gpui_component::{Root, Theme, v_flex}; use super::*; use crate::DockArea; use crate::tab_panel::title_bar_drag_handlers; #[derive(Default)] struct ProbeFlags { empty_down: AtomicBool, empty_click: AtomicBool, control_down: AtomicBool, control_click: AtomicBool, } struct ProbeView { flags: Entity, tab_panel: Entity, } impl Render for ProbeView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let flags = self.flags.clone(); let empty = title_bar_drag_handlers( div() .id("empty-space") .h(TAB_BAR_HEIGHT) .flex_grow_1() .min_w_16(), window, cx, ) .debug_selector(|| "empty-space".into()) .on_mouse_down(MouseButton::Left, { let flags = flags.clone(); move |_, _, cx| flags.update(cx, |f, _| f.empty_down.store(true, Ordering::SeqCst)) }) .on_click({ let flags = flags.clone(); move |_, _, cx| flags.update(cx, |f, _| f.empty_click.store(true, Ordering::SeqCst)) }); let control = title_bar_drag_handlers(div().id("control-space").h_8().flex_grow_1(), window, cx) .debug_selector(|| "control-space".into()) .on_mouse_down(MouseButton::Left, { let flags = flags.clone(); move |_, _, cx| { flags.update(cx, |f, _| f.control_down.store(true, Ordering::SeqCst)) } }) .on_click({ let flags = flags.clone(); move |_, _, cx| { flags.update(cx, |f, _| f.control_click.store(true, Ordering::SeqCst)) } }); v_flex() .size_full() .child(TabBar::new("probe-bar", self.tab_panel.clone()).empty_space(empty)) .child(control) } } /// Diagnostic: verify that the tab bar's trailing empty space receives /// mouse events when wrapped by `title_bar_drag_handlers`, i.e. the /// strip's scroll containers do not swallow them. #[gpui::test] fn tab_bar_empty_space_receives_events(cx: &mut TestAppContext) { let (flags, handle) = cx.update(|cx| { cx.set_global(Theme::default()); let flags = cx.new(|_| ProbeFlags::default()); let handle = cx.open_window( WindowOptions { window_bounds: Some(gpui::WindowBounds::Windowed(gpui::Bounds { origin: gpui::Point::default(), size: size(px(800.), px(100.)), })), ..Default::default() }, |window, cx| { let flags = flags.clone(); let dock_area = cx.new(|cx| DockArea::new("probe-dock", None, window, cx)); let tab_panel = cx.new(|cx| TabPanel::new(None, dock_area.downgrade(), window, cx)); let content = cx.new(|_| ProbeView { flags, tab_panel }); cx.new(|cx| Root::new(content, window, cx)) }, ); (flags, handle.unwrap()) }); let mut cx = VisualTestContext::from_window(handle.into(), cx); cx.run_until_parked(); cx.update(|window, cx| { _ = window.draw(cx); }); let empty_bounds = cx .debug_bounds("empty-space") .expect("empty-space must be laid out"); let control_bounds = cx .debug_bounds("control-space") .expect("control-space must be laid out"); let empty_center = empty_bounds.center(); let control_center = control_bounds.center(); // Control: a plain div with the same handlers, outside the tab bar. cx.simulate_click(control_center, Default::default()); // Target: the tab bar's empty-space strip. cx.simulate_click(empty_center, Default::default()); let (empty_down, empty_click, control_down, control_click) = cx.read(|cx| { let flags = flags.read(cx); ( flags.empty_down.load(Ordering::SeqCst), flags.empty_click.load(Ordering::SeqCst), flags.control_down.load(Ordering::SeqCst), flags.control_click.load(Ordering::SeqCst), ) }); assert!( control_down, "control mouse_down must fire at {control_center:?}" ); assert!( control_click, "control click must fire at {control_center:?}" ); assert!( empty_down, "empty-space mouse_down must fire at {empty_center:?}" ); assert!( empty_click, "empty-space click must fire at {empty_center:?}" ); } }