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 /// Whether this pane is the active pane of its [`crate::PaneGroup`]. Only
86 /// the focused pane's active tab is drawn "selected" (accent), so the
87 /// window shows exactly one active tab. Kept in sync by `PaneGroup`.
88 focused: bool,
89}
90
91impl Pane {
92 /// Starts with zero tabs. The "+" button uses [`PlaceholderTab`] until
93 /// [`Pane::with_new_tab_factory`] is called.
94 pub fn new() -> Self {
95 Self {
96 tabs: Vec::new(),
97 active_idx: 0,
98 next_tab_id: 0,
99 new_tab_factory: Rc::new(|| Box::new(PlaceholderTab)),
100 focused: true,
101 }
102 }
103
104 /// Sets whether this pane is its group's active pane (drives whether its
105 /// active tab is shown as selected). Called by [`crate::PaneGroup`].
106 pub fn set_focused(&mut self, focused: bool, cx: &mut Context<Self>) {
107 if self.focused != focused {
108 self.focused = focused;
109 cx.notify();
110 }
111 }
112
113 /// Sets the factory used to create content for tabs opened via the "+"
114 /// button. Additive over the phase-01 design (not explicitly listed
115 /// there) — needed because `Pane` renders its own "+" button and must
116 /// therefore know how to produce new tab content itself.
117 pub fn with_new_tab_factory(
118 mut self,
119 factory: impl Fn() -> Box<dyn TabContent> + 'static,
120 ) -> Self {
121 self.new_tab_factory = Rc::new(factory);
122 self
123 }
124
125 /// Builder that seeds an initial tab without a [`Context`] — usable
126 /// inside a [`crate::PaneGroup`] pane factory (which runs before the
127 /// pane's own context exists) or any `cx.new(|_| Pane::new().with_tab(..))`
128 /// construction. The seeded tab becomes active.
129 pub fn with_tab(mut self, content: Box<dyn TabContent>) -> Self {
130 let id = TabId(self.next_tab_id);
131 self.next_tab_id += 1;
132 self.tabs.push((id, content));
133 self.active_idx = self.tabs.len() - 1;
134 self
135 }
136
137 /// Appends a tab and activates it. Returns the new tab's stable id.
138 pub fn add_tab(&mut self, content: Box<dyn TabContent>, cx: &mut Context<Self>) -> TabId {
139 let id = TabId(self.next_tab_id);
140 self.next_tab_id += 1;
141 self.tabs.push((id, content));
142 self.active_idx = self.tabs.len() - 1;
143 cx.notify();
144 id
145 }
146
147 /// Removes the tab at `idx`, reassigning the active index if needed.
148 /// Returns `true` if the pane is now empty (in which case
149 /// [`PaneEvent::Empty`] is emitted for `PaneGroup` to remove this pane).
150 pub fn close_tab(&mut self, idx: usize, cx: &mut Context<Self>) -> bool {
151 if idx >= self.tabs.len() {
152 return self.tabs.is_empty();
153 }
154 self.tabs.remove(idx);
155 if self.active_idx >= self.tabs.len() {
156 self.active_idx = self.tabs.len().saturating_sub(1);
157 } else if idx < self.active_idx {
158 self.active_idx -= 1;
159 }
160 let now_empty = self.tabs.is_empty();
161 if now_empty {
162 cx.emit(PaneEvent::Empty);
163 }
164 cx.notify();
165 now_empty
166 }
167
168 /// Activates the tab at `idx` (no-op if out of range).
169 pub fn activate(&mut self, idx: usize, cx: &mut Context<Self>) {
170 if idx < self.tabs.len() && idx != self.active_idx {
171 self.active_idx = idx;
172 cx.notify();
173 }
174 }
175
176 /// Moves the tab at `from` to `to`, preserving which tab is active.
177 pub fn reorder(&mut self, from: usize, to: usize, cx: &mut Context<Self>) {
178 if from == to || from >= self.tabs.len() || to >= self.tabs.len() {
179 return;
180 }
181 let active_id = self.tabs.get(self.active_idx).map(|(id, _)| *id);
182 let tab = self.tabs.remove(from);
183 self.tabs.insert(to, tab);
184 if let Some(active_id) = active_id
185 && let Some(ix) = self.tabs.iter().position(|(id, _)| *id == active_id)
186 {
187 self.active_idx = ix;
188 }
189 cx.notify();
190 }
191
192 /// Number of open tabs.
193 pub fn tab_count(&self) -> usize {
194 self.tabs.len()
195 }
196
197 /// Index of the active tab.
198 pub fn active_index(&self) -> usize {
199 self.active_idx
200 }
201
202 /// Titles of every open tab, in order — mainly useful for tests
203 /// asserting reorder results.
204 pub fn titles(&self) -> Vec<SharedString> {
205 self.tabs
206 .iter()
207 .map(|(_, content)| content.title())
208 .collect()
209 }
210}
211
212impl Default for Pane {
213 fn default() -> Self {
214 Self::new()
215 }
216}
217
218impl EventEmitter<PaneEvent> for Pane {}