Skip to main content

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 {}