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