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    /// The pane's header close ("x") button was pressed; `PaneGroup` should
53    /// remove this whole pane (no-op if it is the last remaining pane).
54    CloseRequested,
55}
56
57/// Default content for tabs created via the "+" button when no factory was
58/// supplied through [`Pane::with_new_tab_factory`].
59struct PlaceholderTab;
60
61impl TabContent for PlaceholderTab {
62    fn render(&self, _focused: bool, _window: &mut Window, _cx: &mut App) -> AnyElement {
63        div()
64            .p_4()
65            .child(Label::new("Empty tab").color(Color::Muted))
66            .into_any_element()
67    }
68
69    fn title(&self) -> SharedString {
70        "Untitled".into()
71    }
72}
73
74/// A single pane holding an ordered list of tabs, exactly one of which is
75/// active. Renders its own [`TabBar`]/[`Tab`] strip (with close "x" and add
76/// "+" affordances) above the active tab's content.
77///
78/// Create with `cx.new(|_| Pane::new())`, mount as a [`crate::PaneGroup`]
79/// leaf.
80pub struct Pane {
81    tabs: Vec<(TabId, Box<dyn TabContent>)>,
82    active_idx: usize,
83    next_tab_id: u64,
84    new_tab_factory: Rc<dyn Fn() -> Box<dyn TabContent>>,
85}
86
87impl Pane {
88    /// Starts with zero tabs. The "+" button uses [`PlaceholderTab`] until
89    /// [`Pane::with_new_tab_factory`] is called.
90    pub fn new() -> Self {
91        Self {
92            tabs: Vec::new(),
93            active_idx: 0,
94            next_tab_id: 0,
95            new_tab_factory: Rc::new(|| Box::new(PlaceholderTab)),
96        }
97    }
98
99    /// Sets the factory used to create content for tabs opened via the "+"
100    /// button. Additive over the phase-01 design (not explicitly listed
101    /// there) — needed because `Pane` renders its own "+" button and must
102    /// therefore know how to produce new tab content itself.
103    pub fn with_new_tab_factory(
104        mut self,
105        factory: impl Fn() -> Box<dyn TabContent> + 'static,
106    ) -> Self {
107        self.new_tab_factory = Rc::new(factory);
108        self
109    }
110
111    /// Builder that seeds an initial tab without a [`Context`] — usable
112    /// inside a [`crate::PaneGroup`] pane factory (which runs before the
113    /// pane's own context exists) or any `cx.new(|_| Pane::new().with_tab(..))`
114    /// construction. The seeded tab becomes active.
115    pub fn with_tab(mut self, content: Box<dyn TabContent>) -> Self {
116        let id = TabId(self.next_tab_id);
117        self.next_tab_id += 1;
118        self.tabs.push((id, content));
119        self.active_idx = self.tabs.len() - 1;
120        self
121    }
122
123    /// Appends a tab and activates it. Returns the new tab's stable id.
124    pub fn add_tab(&mut self, content: Box<dyn TabContent>, cx: &mut Context<Self>) -> TabId {
125        let id = TabId(self.next_tab_id);
126        self.next_tab_id += 1;
127        self.tabs.push((id, content));
128        self.active_idx = self.tabs.len() - 1;
129        cx.notify();
130        id
131    }
132
133    /// Removes the tab at `idx`, reassigning the active index if needed.
134    /// Returns `true` if the pane is now empty (in which case
135    /// [`PaneEvent::Empty`] is emitted for `PaneGroup` to remove this pane).
136    pub fn close_tab(&mut self, idx: usize, cx: &mut Context<Self>) -> bool {
137        if idx >= self.tabs.len() {
138            return self.tabs.is_empty();
139        }
140        self.tabs.remove(idx);
141        if self.active_idx >= self.tabs.len() {
142            self.active_idx = self.tabs.len().saturating_sub(1);
143        } else if idx < self.active_idx {
144            self.active_idx -= 1;
145        }
146        let now_empty = self.tabs.is_empty();
147        if now_empty {
148            cx.emit(PaneEvent::Empty);
149        }
150        cx.notify();
151        now_empty
152    }
153
154    /// Activates the tab at `idx` (no-op if out of range).
155    pub fn activate(&mut self, idx: usize, cx: &mut Context<Self>) {
156        if idx < self.tabs.len() && idx != self.active_idx {
157            self.active_idx = idx;
158            cx.notify();
159        }
160    }
161
162    /// Moves the tab at `from` to `to`, preserving which tab is active.
163    pub fn reorder(&mut self, from: usize, to: usize, cx: &mut Context<Self>) {
164        if from == to || from >= self.tabs.len() || to >= self.tabs.len() {
165            return;
166        }
167        let active_id = self.tabs.get(self.active_idx).map(|(id, _)| *id);
168        let tab = self.tabs.remove(from);
169        self.tabs.insert(to, tab);
170        if let Some(active_id) = active_id
171            && let Some(ix) = self.tabs.iter().position(|(id, _)| *id == active_id)
172        {
173            self.active_idx = ix;
174        }
175        cx.notify();
176    }
177
178    /// Number of open tabs.
179    pub fn tab_count(&self) -> usize {
180        self.tabs.len()
181    }
182
183    /// Index of the active tab.
184    pub fn active_index(&self) -> usize {
185        self.active_idx
186    }
187
188    /// Titles of every open tab, in order — mainly useful for tests
189    /// asserting reorder results.
190    pub fn titles(&self) -> Vec<SharedString> {
191        self.tabs
192            .iter()
193            .map(|(_, content)| content.title())
194            .collect()
195    }
196}
197
198impl Default for Pane {
199    fn default() -> Self {
200        Self::new()
201    }
202}
203
204impl EventEmitter<PaneEvent> for Pane {}