ui/components/pane/render.rs
1//! [`Render`] impl for [`super::Pane`] — the tab strip (with close/add
2//! affordances and drag-to-reorder) plus the active tab's content.
3
4use gpui::{Empty, canvas};
5
6use super::{Pane, PaneEvent};
7use crate::{IconButton, IconName, IconSize, Tab, TabBar, prelude::*};
8
9/// Bare `IconButton::new`'s own default `debug_selector` (`"ICON-{icon:?}"`)
10/// collides across every tab sharing the same icon within a `Pane` — this
11/// wraps it in a `div` carrying a per-instance selector instead. The wrapper
12/// does not intercept clicks; the inner `IconButton` still owns the only
13/// click handler (mirrors `ActionPanel`'s Save/Cancel wrapper precedent).
14fn debug_wrap(id: impl Into<ElementId>, selector: String, child: IconButton) -> impl IntoElement {
15 div().id(id).debug_selector(move || selector).child(child)
16}
17
18/// Drag payload used for reordering tabs within a [`Pane`]'s tab strip.
19#[derive(Clone, Copy)]
20struct TabDragPayload {
21 from_idx: usize,
22}
23
24impl Render for Pane {
25 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
26 let active_idx = self.active_idx;
27
28 // One-frame-lagged resize hook: the `canvas()` below wrote the
29 // content-area bounds during the previous paint. Deliver `on_resize`
30 // to the active tab when EITHER the bounds changed OR the active tab
31 // changed (keyed by `TabId`) — so a freshly activated/added tab gets an
32 // initial size even if the pane itself never physically resized.
33 let active_id = self.tabs.get(active_idx).map(|(id, _)| *id);
34 if let (Some(bounds), Some(id)) = (self.content_bounds.get(), active_id) {
35 if self.notified_resize.get() != Some((id, bounds)) {
36 self.notified_resize.set(Some((id, bounds)));
37 self.tabs[active_idx].1.on_resize(bounds, cx);
38 }
39 }
40
41 let mut tab_bar = TabBar::new("pane-tab-bar");
42
43 for ix in 0..self.tabs.len() {
44 let tab_id = self.tabs[ix].0;
45 let title = self.tabs[ix].1.title();
46 // Only the focused pane's active tab is drawn selected, so the
47 // whole window shows a single active tab.
48 let selected = ix == active_idx && self.focused;
49
50 // Per-tab hover group: the close button is hidden until the mouse
51 // hovers this specific tab (each tab is its own `group` scope, so
52 // hovering one tab reveals only its own close button).
53 let hover_group = SharedString::from(format!("pane-tab-{}", tab_id.0));
54 // VSCode behavior: the active tab always shows its close button;
55 // inactive tabs reveal it only on hover.
56 let mut close_ib = IconButton::new(("pane-tab-close", tab_id.0), IconName::Close)
57 .icon_size(IconSize::XSmall)
58 .on_click(cx.listener(move |this, _, _, cx| {
59 this.close_tab(ix, cx);
60 }));
61 if !selected {
62 close_ib = close_ib.visible_on_hover(hover_group.clone());
63 }
64 let close_button = debug_wrap(
65 ("pane-tab-close-wrap", tab_id.0),
66 format!("PANE-TAB-CLOSE-{}", tab_id.0),
67 close_ib,
68 );
69
70 let tab = Tab::new(("pane-tab", tab_id.0))
71 .group(hover_group)
72 .toggle_state(selected)
73 .end_slot(close_button)
74 // Wrap in `Label` so the tab title uses the UI font family/size
75 // consistently (a bare string child inherits the window's
76 // default text size, which mismatches other UI text and can
77 // overflow the tab strip's height).
78 .child(Label::new(title).size(LabelSize::Small))
79 .on_click(cx.listener(move |this, _, _, cx| {
80 this.activate(ix, cx);
81 }))
82 .on_drag(TabDragPayload { from_idx: ix }, |_, _, _, cx| {
83 cx.new(|_| Empty)
84 })
85 .drag_over::<TabDragPayload>(|style, _, _, _| style.opacity(0.5))
86 .on_drop(cx.listener(move |this, payload: &TabDragPayload, _, cx| {
87 this.reorder(payload.from_idx, ix, cx);
88 }));
89
90 tab_bar = tab_bar.child(tab);
91 }
92
93 tab_bar = tab_bar.end_child(debug_wrap(
94 "pane-add-tab-wrap",
95 "PANE-ADD-TAB".to_string(),
96 IconButton::new("pane-add-tab", IconName::Plus)
97 .icon_size(IconSize::XSmall)
98 .on_click(cx.listener(|this, _, _, cx| {
99 let content = (this.new_tab_factory)();
100 this.add_tab(content, cx);
101 })),
102 ));
103
104 // Close-pane "x" at the far right of the header — asks the parent
105 // PaneGroup to remove this whole pane (ignored if it is the last one).
106 tab_bar = tab_bar.end_child(debug_wrap(
107 "pane-close-wrap",
108 "PANE-CLOSE".to_string(),
109 IconButton::new("pane-close", IconName::Close)
110 .icon_size(IconSize::XSmall)
111 .on_click(cx.listener(|_, _, _, cx| {
112 cx.emit(PaneEvent::CloseRequested);
113 })),
114 ));
115
116 let content = self
117 .tabs
118 .get(active_idx)
119 .map(|(_, content)| content.render(true, window, cx));
120
121 // Measure the tab-content area so `on_resize` can fire next frame. The
122 // canvas paints nothing; it only records its own bounds (mirrors
123 // `TerminalView`'s container measurement).
124 let measure = self.content_bounds.clone();
125
126 v_flex().id("pane").size_full().child(tab_bar).child(
127 div()
128 .relative()
129 .flex_1()
130 .min_h_0()
131 .overflow_hidden()
132 .child(
133 canvas(
134 move |bounds, _, _| measure.set(Some(bounds)),
135 |_, _, _, _| {},
136 )
137 .absolute()
138 .size_full(),
139 )
140 .children(content),
141 )
142 }
143}