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