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::rc::Rc;
13
14use gpui::{AnyElement, EventEmitter};
15
16use crate::prelude::*;
17
18/// Content rendered inside a single tab of a [`Pane`].
19///
20/// Object-safe by design (no generics, no `Self` return type) so `Pane` can
21/// hold a heterogeneous `Vec<Box<dyn TabContent>>`.
22///
23/// # Example
24/// ```ignore
25/// struct FileTab { path: SharedString }
26/// impl TabContent for FileTab {
27///     fn render(&self, _focused: bool, _window: &mut Window, _cx: &mut App) -> AnyElement {
28///         div().child(self.path.clone()).into_any_element()
29///     }
30///     fn title(&self) -> SharedString { self.path.clone() }
31/// }
32/// ```
33pub trait TabContent: 'static {
34    /// Renders this tab's body. `focused` is true when this tab is the
35    /// active tab of a [`Pane`] that is itself `PaneGroup`'s active pane.
36    fn render(&self, focused: bool, window: &mut Window, cx: &mut App) -> AnyElement;
37    /// The label shown on the tab strip.
38    fn title(&self) -> SharedString;
39}
40
41/// Stable identifier for a tab within one [`Pane`] (unique per-`Pane` only —
42/// a `u64` counter is sufficient since reorder/close are always scoped to a
43/// single `Pane`, never compared across panes).
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45pub struct TabId(u64);
46
47/// Events emitted by [`Pane`] for [`crate::PaneGroup`] to react to.
48pub enum PaneEvent {
49    /// The last tab was just closed; `PaneGroup` should remove this pane
50    /// from the split tree.
51    Empty,
52}
53
54/// Default content for tabs created via the "+" button when no factory was
55/// supplied through [`Pane::with_new_tab_factory`].
56struct PlaceholderTab;
57
58impl TabContent for PlaceholderTab {
59    fn render(&self, _focused: bool, _window: &mut Window, _cx: &mut App) -> AnyElement {
60        div()
61            .p_4()
62            .child(Label::new("Empty tab").color(Color::Muted))
63            .into_any_element()
64    }
65
66    fn title(&self) -> SharedString {
67        "Untitled".into()
68    }
69}
70
71/// A single pane holding an ordered list of tabs, exactly one of which is
72/// active. Renders its own [`TabBar`]/[`Tab`] strip (with close "x" and add
73/// "+" affordances) above the active tab's content.
74///
75/// Create with `cx.new(|_| Pane::new())`, mount as a [`crate::PaneGroup`]
76/// leaf.
77pub struct Pane {
78    tabs: Vec<(TabId, Box<dyn TabContent>)>,
79    active_idx: usize,
80    next_tab_id: u64,
81    new_tab_factory: Rc<dyn Fn() -> Box<dyn TabContent>>,
82}
83
84impl Pane {
85    /// Starts with zero tabs. The "+" button uses [`PlaceholderTab`] until
86    /// [`Pane::with_new_tab_factory`] is called.
87    pub fn new() -> Self {
88        Self {
89            tabs: Vec::new(),
90            active_idx: 0,
91            next_tab_id: 0,
92            new_tab_factory: Rc::new(|| Box::new(PlaceholderTab)),
93        }
94    }
95
96    /// Sets the factory used to create content for tabs opened via the "+"
97    /// button. Additive over the phase-01 design (not explicitly listed
98    /// there) — needed because `Pane` renders its own "+" button and must
99    /// therefore know how to produce new tab content itself.
100    pub fn with_new_tab_factory(
101        mut self,
102        factory: impl Fn() -> Box<dyn TabContent> + 'static,
103    ) -> Self {
104        self.new_tab_factory = Rc::new(factory);
105        self
106    }
107
108    /// Builder that seeds an initial tab without a [`Context`] — usable
109    /// inside a [`crate::PaneGroup`] pane factory (which runs before the
110    /// pane's own context exists) or any `cx.new(|_| Pane::new().with_tab(..))`
111    /// construction. The seeded tab becomes active.
112    pub fn with_tab(mut self, content: Box<dyn TabContent>) -> Self {
113        let id = TabId(self.next_tab_id);
114        self.next_tab_id += 1;
115        self.tabs.push((id, content));
116        self.active_idx = self.tabs.len() - 1;
117        self
118    }
119
120    /// Appends a tab and activates it. Returns the new tab's stable id.
121    pub fn add_tab(&mut self, content: Box<dyn TabContent>, cx: &mut Context<Self>) -> TabId {
122        let id = TabId(self.next_tab_id);
123        self.next_tab_id += 1;
124        self.tabs.push((id, content));
125        self.active_idx = self.tabs.len() - 1;
126        cx.notify();
127        id
128    }
129
130    /// Removes the tab at `idx`, reassigning the active index if needed.
131    /// Returns `true` if the pane is now empty (in which case
132    /// [`PaneEvent::Empty`] is emitted for `PaneGroup` to remove this pane).
133    pub fn close_tab(&mut self, idx: usize, cx: &mut Context<Self>) -> bool {
134        if idx >= self.tabs.len() {
135            return self.tabs.is_empty();
136        }
137        self.tabs.remove(idx);
138        if self.active_idx >= self.tabs.len() {
139            self.active_idx = self.tabs.len().saturating_sub(1);
140        } else if idx < self.active_idx {
141            self.active_idx -= 1;
142        }
143        let now_empty = self.tabs.is_empty();
144        if now_empty {
145            cx.emit(PaneEvent::Empty);
146        }
147        cx.notify();
148        now_empty
149    }
150
151    /// Activates the tab at `idx` (no-op if out of range).
152    pub fn activate(&mut self, idx: usize, cx: &mut Context<Self>) {
153        if idx < self.tabs.len() && idx != self.active_idx {
154            self.active_idx = idx;
155            cx.notify();
156        }
157    }
158
159    /// Moves the tab at `from` to `to`, preserving which tab is active.
160    pub fn reorder(&mut self, from: usize, to: usize, cx: &mut Context<Self>) {
161        if from == to || from >= self.tabs.len() || to >= self.tabs.len() {
162            return;
163        }
164        let active_id = self.tabs.get(self.active_idx).map(|(id, _)| *id);
165        let tab = self.tabs.remove(from);
166        self.tabs.insert(to, tab);
167        if let Some(active_id) = active_id
168            && let Some(ix) = self.tabs.iter().position(|(id, _)| *id == active_id)
169        {
170            self.active_idx = ix;
171        }
172        cx.notify();
173    }
174
175    /// Number of open tabs.
176    pub fn tab_count(&self) -> usize {
177        self.tabs.len()
178    }
179
180    /// Index of the active tab.
181    pub fn active_index(&self) -> usize {
182        self.active_idx
183    }
184
185    /// Titles of every open tab, in order — mainly useful for tests
186    /// asserting reorder results.
187    pub fn titles(&self) -> Vec<SharedString> {
188        self.tabs
189            .iter()
190            .map(|(_, content)| content.title())
191            .collect()
192    }
193}
194
195impl Default for Pane {
196    fn default() -> Self {
197        Self::new()
198    }
199}
200
201impl EventEmitter<PaneEvent> for Pane {}