ui/components/pane/mod.rs
1//! Stateful, tabbed pane rendered as a leaf of [`crate::PaneGroup`]'s
2//! recursive split tree.
3//!
4//! Deliberately generic over tab content via [`TabContent`] rather than
5//! hardcoding e.g. a text editor or terminal view — `boltz-ui` is a
6//! reusable component library, so `Pane` only owns tab bookkeeping
7//! (add/close/activate/reorder) and delegates the actual per-tab content to
8//! whatever the consumer supplies.
9
10mod render;
11
12use std::cell::Cell;
13use std::rc::Rc;
14
15use gpui::{AnyElement, Bounds, EventEmitter, Pixels};
16
17use crate::prelude::*;
18
19/// Content rendered inside a single tab of a [`Pane`].
20///
21/// Object-safe by design (no generics, no `Self` return type) so `Pane` can
22/// hold a heterogeneous `Vec<Box<dyn TabContent>>`.
23///
24/// # Example
25/// ```ignore
26/// struct FileTab { path: SharedString }
27/// impl TabContent for FileTab {
28/// fn render(&self, _focused: bool, _window: &mut Window, _cx: &mut App) -> AnyElement {
29/// div().child(self.path.clone()).into_any_element()
30/// }
31/// fn title(&self) -> SharedString { self.path.clone() }
32/// }
33/// ```
34pub trait TabContent: 'static {
35 /// Renders this tab's body. `focused` is true when this tab is the
36 /// active tab of a [`Pane`] that is itself `PaneGroup`'s active pane.
37 fn render(&self, focused: bool, window: &mut Window, cx: &mut App) -> AnyElement;
38 /// The label shown on the tab strip.
39 fn title(&self) -> SharedString;
40
41 /// Fired when this tab becomes the active tab of a focused [`Pane`]:
42 /// activation ([`Pane::activate`]), being added ([`Pane::add_tab`]),
43 /// inheriting focus after the active tab is closed, or its pane regaining
44 /// focus ([`Pane::set_focused`]). A terminal implementor would e.g. resume
45 /// cursor blink / mark the PTY focused. Default: no-op, so existing
46 /// implementors need no changes.
47 ///
48 /// NOT fired for a pane's initial tab seeded via [`Pane::with_tab`] (that
49 /// builder runs before a [`Context`] exists, so no hook can fire) — an
50 /// implementor needing initial-focus state should set it at construction
51 /// or have the mounting code trigger focus explicitly after mount.
52 ///
53 /// Takes `&mut App` (not `&mut Window`) because most fire sites
54 /// (`activate`/`add_tab`/`set_focused`) run from a `Context`-only path
55 /// with no `Window` in scope; focus-driven tab behaviour (blink toggle,
56 /// PTY focus flag) needs no window geometry.
57 fn on_focus_in(&mut self, _cx: &mut App) {}
58
59 /// Fired when this tab stops being the active tab of a focused [`Pane`]
60 /// (another tab activated, or its pane losing focus). A terminal
61 /// implementor would e.g. pause cursor blink. Default: no-op.
62 fn on_focus_out(&mut self, _cx: &mut App) {}
63
64 /// Fired when the pane's tab-content area changes size (measured via a
65 /// `canvas()` in [`Pane`]'s render, one frame after the layout change).
66 /// A terminal implementor would recompute rows/cols and `resize` its PTY
67 /// (SIGWINCH). Default: no-op.
68 fn on_resize(&mut self, _bounds: Bounds<Pixels>, _cx: &mut App) {}
69
70 /// Fired exactly once, right before this tab is removed — whether via
71 /// closing the single tab or via its whole pane being removed from the
72 /// tree ([`Pane::close_all_tabs`]). A terminal implementor MUST shut its
73 /// PTY down here: `Drop` timing is not guaranteed to coincide with tree
74 /// removal (other live `Entity` handles can outlive it), so relying on
75 /// `Drop` would leak the child process. Default: no-op.
76 fn on_close(&mut self, _cx: &mut App) {}
77}
78
79/// Stable identifier for a tab within one [`Pane`] (unique per-`Pane` only —
80/// a `u64` counter is sufficient since reorder/close are always scoped to a
81/// single `Pane`, never compared across panes).
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83pub struct TabId(u64);
84
85/// Events emitted by [`Pane`] for [`crate::PaneGroup`] to react to.
86pub enum PaneEvent {
87 /// The last tab was just closed; `PaneGroup` should remove this pane
88 /// from the split tree.
89 Empty,
90 /// The pane's header close ("x") button was pressed; `PaneGroup` should
91 /// remove this whole pane (no-op if it is the last remaining pane).
92 CloseRequested,
93}
94
95/// Default content for tabs created via the "+" button when no factory was
96/// supplied through [`Pane::with_new_tab_factory`].
97struct PlaceholderTab;
98
99impl TabContent for PlaceholderTab {
100 fn render(&self, _focused: bool, _window: &mut Window, _cx: &mut App) -> AnyElement {
101 div()
102 .p_4()
103 .child(Label::new("Empty tab").color(Color::Muted))
104 .into_any_element()
105 }
106
107 fn title(&self) -> SharedString {
108 "Untitled".into()
109 }
110}
111
112/// A single pane holding an ordered list of tabs, exactly one of which is
113/// active. Renders its own [`TabBar`]/[`Tab`] strip (with close "x" and add
114/// "+" affordances) above the active tab's content.
115///
116/// Create with `cx.new(|_| Pane::new())`, mount as a [`crate::PaneGroup`]
117/// leaf.
118pub struct Pane {
119 tabs: Vec<(TabId, Box<dyn TabContent>)>,
120 active_idx: usize,
121 next_tab_id: u64,
122 new_tab_factory: Rc<dyn Fn() -> Box<dyn TabContent>>,
123 /// Whether this pane is the active pane of its [`crate::PaneGroup`]. Only
124 /// the focused pane's active tab is drawn "selected" (accent), so the
125 /// window shows exactly one active tab. Kept in sync by `PaneGroup`.
126 focused: bool,
127 /// Tab-content-area bounds, written by a `canvas()` child during paint and
128 /// read back at the START of the next render (same one-frame-lag pattern
129 /// `TerminalView`/`ResizablePanelGroup` use) to detect size changes.
130 content_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
131 /// The `(tab, bounds)` pair last delivered via `on_resize`. Keyed by
132 /// [`TabId`] — not just bounds — so a tab that becomes active without a
133 /// physical size change (e.g. `add_tab`/`activate`) still gets an initial
134 /// `on_resize` with the current bounds (each tab owns an independently
135 /// sized PTY). Plain `Cell` (not `Rc`) — only touched inside `Pane`.
136 notified_resize: Cell<Option<(TabId, Bounds<Pixels>)>>,
137}
138
139impl Pane {
140 /// Starts with zero tabs. The "+" button uses [`PlaceholderTab`] until
141 /// [`Pane::with_new_tab_factory`] is called.
142 pub fn new() -> Self {
143 Self {
144 tabs: Vec::new(),
145 active_idx: 0,
146 next_tab_id: 0,
147 new_tab_factory: Rc::new(|| Box::new(PlaceholderTab)),
148 focused: true,
149 content_bounds: Rc::new(Cell::new(None)),
150 notified_resize: Cell::new(None),
151 }
152 }
153
154 /// Sets whether this pane is its group's active pane (drives whether its
155 /// active tab is shown as selected). Called by [`crate::PaneGroup`].
156 pub fn set_focused(&mut self, focused: bool, cx: &mut Context<Self>) {
157 if self.focused != focused {
158 self.focused = focused;
159 // Fire the focus lifecycle hook on the active tab so its content
160 // (e.g. a terminal) can toggle its focused state.
161 if let Some((_, content)) = self.tabs.get_mut(self.active_idx) {
162 if focused {
163 content.on_focus_in(cx);
164 } else {
165 content.on_focus_out(cx);
166 }
167 }
168 cx.notify();
169 }
170 }
171
172 /// Sets the factory used to create content for tabs opened via the "+"
173 /// button. Additive over the phase-01 design (not explicitly listed
174 /// there) — needed because `Pane` renders its own "+" button and must
175 /// therefore know how to produce new tab content itself.
176 pub fn with_new_tab_factory(
177 mut self,
178 factory: impl Fn() -> Box<dyn TabContent> + 'static,
179 ) -> Self {
180 self.new_tab_factory = Rc::new(factory);
181 self
182 }
183
184 /// Builder that seeds an initial tab without a [`Context`] — usable
185 /// inside a [`crate::PaneGroup`] pane factory (which runs before the
186 /// pane's own context exists) or any `cx.new(|_| Pane::new().with_tab(..))`
187 /// construction. The seeded tab becomes active.
188 pub fn with_tab(mut self, content: Box<dyn TabContent>) -> Self {
189 let id = TabId(self.next_tab_id);
190 self.next_tab_id += 1;
191 self.tabs.push((id, content));
192 self.active_idx = self.tabs.len() - 1;
193 self
194 }
195
196 /// Appends a tab and activates it. Returns the new tab's stable id.
197 pub fn add_tab(&mut self, content: Box<dyn TabContent>, cx: &mut Context<Self>) -> TabId {
198 // Previous active tab (if any) loses focus to the incoming one.
199 let prev_active = if self.tabs.is_empty() {
200 None
201 } else {
202 Some(self.active_idx)
203 };
204 let id = TabId(self.next_tab_id);
205 self.next_tab_id += 1;
206 self.tabs.push((id, content));
207 self.active_idx = self.tabs.len() - 1;
208 if self.focused {
209 if let Some(prev) = prev_active {
210 self.tabs[prev].1.on_focus_out(cx);
211 }
212 let new_idx = self.active_idx;
213 self.tabs[new_idx].1.on_focus_in(cx);
214 }
215 cx.notify();
216 id
217 }
218
219 /// Removes the tab at `idx`, reassigning the active index if needed.
220 /// Returns `true` if the pane is now empty (in which case
221 /// [`PaneEvent::Empty`] is emitted for `PaneGroup` to remove this pane).
222 pub fn close_tab(&mut self, idx: usize, cx: &mut Context<Self>) -> bool {
223 if idx >= self.tabs.len() {
224 return self.tabs.is_empty();
225 }
226 // Whether the tab being closed is the currently active one — if so, a
227 // different tab inherits focus below and must be told.
228 let closing_active = idx == self.active_idx;
229 // Let the tab release resources (e.g. shut down its PTY) before it is
230 // dropped — `Drop` timing alone is not a reliable close signal.
231 self.tabs[idx].1.on_close(cx);
232 self.tabs.remove(idx);
233 if self.active_idx >= self.tabs.len() {
234 self.active_idx = self.tabs.len().saturating_sub(1);
235 } else if idx < self.active_idx {
236 self.active_idx -= 1;
237 }
238 // Closing the active tab hands focus to whichever tab took its place;
239 // that new active tab must receive `on_focus_in` (mirrors `activate`).
240 if closing_active && self.focused && !self.tabs.is_empty() {
241 let new_active = self.active_idx;
242 self.tabs[new_active].1.on_focus_in(cx);
243 }
244 let now_empty = self.tabs.is_empty();
245 if now_empty {
246 cx.emit(PaneEvent::Empty);
247 }
248 cx.notify();
249 now_empty
250 }
251
252 /// Activates the tab at `idx` (no-op if out of range).
253 pub fn activate(&mut self, idx: usize, cx: &mut Context<Self>) {
254 if idx < self.tabs.len() && idx != self.active_idx {
255 let old = self.active_idx;
256 self.active_idx = idx;
257 if self.focused {
258 self.tabs[old].1.on_focus_out(cx);
259 self.tabs[idx].1.on_focus_in(cx);
260 }
261 cx.notify();
262 }
263 }
264
265 /// Closes every tab (firing each one's [`TabContent::on_close`] in order)
266 /// and empties the pane. Called by [`crate::PaneGroup`] when a whole pane
267 /// is removed from the tree, so PTY-owning tabs shut down deterministically
268 /// instead of relying on `Entity`/`Box` drop timing.
269 pub fn close_all_tabs(&mut self, cx: &mut Context<Self>) {
270 for (_, content) in self.tabs.iter_mut() {
271 content.on_close(cx);
272 }
273 self.tabs.clear();
274 self.active_idx = 0;
275 cx.notify();
276 }
277
278 /// Moves the tab at `from` to `to`, preserving which tab is active.
279 pub fn reorder(&mut self, from: usize, to: usize, cx: &mut Context<Self>) {
280 if from == to || from >= self.tabs.len() || to >= self.tabs.len() {
281 return;
282 }
283 let active_id = self.tabs.get(self.active_idx).map(|(id, _)| *id);
284 let tab = self.tabs.remove(from);
285 self.tabs.insert(to, tab);
286 if let Some(active_id) = active_id
287 && let Some(ix) = self.tabs.iter().position(|(id, _)| *id == active_id)
288 {
289 self.active_idx = ix;
290 }
291 cx.notify();
292 }
293
294 /// Number of open tabs.
295 pub fn tab_count(&self) -> usize {
296 self.tabs.len()
297 }
298
299 /// Index of the active tab.
300 pub fn active_index(&self) -> usize {
301 self.active_idx
302 }
303
304 /// Titles of every open tab, in order — mainly useful for tests
305 /// asserting reorder results.
306 pub fn titles(&self) -> Vec<SharedString> {
307 self.tabs
308 .iter()
309 .map(|(_, content)| content.title())
310 .collect()
311 }
312}
313
314impl Default for Pane {
315 fn default() -> Self {
316 Self::new()
317 }
318}
319
320impl EventEmitter<PaneEvent> for Pane {}