Files
signed/crates/dock/src/tab_bar.rs
T
2026-08-20 15:04:36 +07:00

470 lines
17 KiB
Rust

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<Arc<dyn PanelView>>,
active_panel: Option<Arc<dyn PanelView>>,
collapsed: bool,
draggable: bool,
droppable: bool,
tab_panel: Entity<TabPanel>,
scroll_handle: ScrollHandle,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
empty_space: Option<AnyElement>,
}
impl TabBar {
pub(crate) fn new(id: impl Into<ElementId>, tab_panel: Entity<TabPanel>) -> 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<Arc<dyn PanelView>>) -> 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<Arc<dyn PanelView>>) -> 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<dyn PanelView>,
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::<DragPanel>(|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::<AnyDrag>(|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::<DragPanel>(|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::<AnyDrag>(|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<ProbeFlags>,
tab_panel: Entity<TabPanel>,
}
impl Render for ProbeView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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:?}"
);
}
}