ccf_gpui_widgets/widgets/tab_bar.rs
1//! Generic tab bar widget for switching between views
2//!
3//! A tab bar that can display any type implementing the `SelectionItem` trait.
4//! Supports left-click tab switching, right-click context menus, and keyboard navigation.
5//! Use `register_keybindings()` at app startup to enable keyboard shortcuts.
6//!
7//! # Example
8//!
9//! ```ignore
10//! use ccf_gpui_widgets::widgets::{TabBar, TabBarEvent, SelectionItem};
11//! use gpui::*;
12//!
13//! // Register keybindings at app startup
14//! ccf_gpui_widgets::widgets::tab_bar::register_keybindings(cx);
15//!
16//! #[derive(Debug, Clone, Copy, PartialEq, Eq)]
17//! pub enum MyTab {
18//! Overview,
19//! Details,
20//! Settings,
21//! }
22//!
23//! impl SelectionItem for MyTab {
24//! fn label(&self) -> SharedString {
25//! match self {
26//! MyTab::Overview => "Overview".into(),
27//! MyTab::Details => "Details".into(),
28//! MyTab::Settings => "Settings".into(),
29//! }
30//! }
31//!
32//! fn id(&self) -> ElementId {
33//! match self {
34//! MyTab::Overview => "tab_overview".into(),
35//! MyTab::Details => "tab_details".into(),
36//! MyTab::Settings => "tab_settings".into(),
37//! }
38//! }
39//! }
40//!
41//! let tab_bar = cx.new(|cx| {
42//! TabBar::new(
43//! vec![MyTab::Overview, MyTab::Details, MyTab::Settings],
44//! MyTab::Overview,
45//! cx,
46//! )
47//! });
48//!
49//! cx.subscribe(&tab_bar, |this, _, event: &TabBarEvent<MyTab>, cx| {
50//! match event {
51//! TabBarEvent::Change(tab) => this.switch_to(*tab, cx),
52//! TabBarEvent::ContextMenu { tab, position } => {
53//! this.show_context_menu(*tab, *position, cx);
54//! }
55//! }
56//! }).detach();
57//! ```
58//!
59//! # API Changes (2025-02)
60//!
61//! - Replaced `TabItem` trait with `SelectionItem` (unified trait across all selection widgets)
62//! - Renamed `active` field/methods to `selected` for consistency:
63//! - `active_tab()` → `selected()`
64//! - `set_active_tab()` → `set_selected()`
65//! - Added index-based selection: `selected_index()`, `set_selected_index()`
66//! - Renamed event: `TabSelected(T)` → `Change(T)`
67//! - Note: Navigation widgets (TabBar, SidebarNav) do NOT emit events from set_* methods
68
69use gpui::prelude::*;
70use gpui::*;
71use crate::theme::{get_theme_or, Theme};
72use super::focus_navigation::{with_focus_actions, EnabledCursorExt};
73use super::selection::SelectionItem;
74
75// Actions for keyboard navigation
76actions!(ccf_tab_bar, [SelectPreviousTab, SelectNextTab]);
77
78/// Register key bindings for tab bar components
79///
80/// Call this once at application startup:
81/// ```ignore
82/// ccf_gpui_widgets::widgets::tab_bar::register_keybindings(cx);
83/// ```
84pub fn register_keybindings(cx: &mut App) {
85 cx.bind_keys([
86 KeyBinding::new("left", SelectPreviousTab, Some("CcfTabBar")),
87 KeyBinding::new("right", SelectNextTab, Some("CcfTabBar")),
88 ]);
89}
90
91/// Events emitted by TabBar
92///
93/// Note: `set_selected()` and `set_selected_index()` do NOT emit events.
94/// Navigation widgets represent UI navigation state where the consumer typically
95/// controls transitions and doesn't need redundant event notifications.
96#[derive(Debug, Clone)]
97pub enum TabBarEvent<T> {
98 /// A tab was selected (left-click or keyboard navigation)
99 ///
100 /// Previously named `TabSelected(T)`.
101 Change(T),
102 /// Context menu was requested (right-click)
103 ContextMenu {
104 tab: T,
105 /// Mouse position for context menu placement
106 position: Point<Pixels>,
107 },
108}
109
110/// Generic tab bar widget
111pub struct TabBar<T: SelectionItem> {
112 tabs: Vec<T>,
113 selected: T,
114 focus_handle: FocusHandle,
115 custom_theme: Option<Theme>,
116 /// Whether the widget is enabled (interactive)
117 enabled: bool,
118 /// Stores the previously focused element when mouse down occurs,
119 /// so we can restore focus after a tab click (preventing focus stealing)
120 previous_focus: Option<FocusHandle>,
121 /// Horizontal padding for tabs (border extends full width)
122 tab_row_padding: Pixels,
123}
124
125impl<T: SelectionItem> TabBar<T> {
126 /// Create a new tab bar with the given tabs
127 ///
128 /// # Arguments
129 ///
130 /// * `tabs` - List of tabs to display
131 /// * `selected` - The initially selected tab
132 /// * `cx` - Context for creating the focus handle
133 pub fn new(tabs: Vec<T>, selected: T, cx: &mut Context<Self>) -> Self {
134 Self {
135 tabs,
136 selected,
137 focus_handle: cx.focus_handle().tab_stop(true),
138 custom_theme: None,
139 enabled: true,
140 previous_focus: None,
141 tab_row_padding: px(0.0),
142 }
143 }
144
145 /// Set a custom theme for this widget
146 #[must_use]
147 pub fn theme(mut self, theme: Theme) -> Self {
148 self.custom_theme = Some(theme);
149 self
150 }
151
152 /// Set enabled state (builder pattern)
153 #[must_use]
154 pub fn with_enabled(mut self, enabled: bool) -> Self {
155 self.enabled = enabled;
156 self
157 }
158
159 /// Set horizontal padding for tabs (border spans full width)
160 #[must_use]
161 pub fn tab_row_padding(mut self, padding: Pixels) -> Self {
162 self.tab_row_padding = padding;
163 self
164 }
165
166 /// Get the currently selected tab
167 pub fn selected(&self) -> &T {
168 &self.selected
169 }
170
171 /// Get the currently selected index
172 pub fn selected_index(&self) -> usize {
173 self.tabs.iter().position(|t| *t == self.selected).unwrap_or(0)
174 }
175
176 /// Set the selected tab
177 ///
178 /// Note: Does NOT emit Change event. Navigation widgets represent UI state
179 /// where the consumer controls transitions.
180 pub fn set_selected(&mut self, tab: T, cx: &mut Context<Self>) {
181 self.selected = tab;
182 cx.notify();
183 }
184
185 /// Set selected by index
186 ///
187 /// Note: Does NOT emit Change event.
188 pub fn set_selected_index(&mut self, index: usize, cx: &mut Context<Self>) {
189 if let Some(tab) = self.tabs.get(index).cloned() {
190 self.selected = tab;
191 cx.notify();
192 }
193 }
194
195 /// Get the focus handle
196 pub fn focus_handle(&self) -> &FocusHandle {
197 &self.focus_handle
198 }
199
200 /// Check if the tab bar is enabled
201 pub fn is_enabled(&self) -> bool {
202 self.enabled
203 }
204
205 /// Set enabled state programmatically
206 pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
207 if self.enabled != enabled {
208 self.enabled = enabled;
209 cx.notify();
210 }
211 }
212
213 /// Select the previous tab (wraps around)
214 fn select_previous(&mut self, cx: &mut Context<Self>) {
215 if self.tabs.is_empty() {
216 return;
217 }
218 let current_index = self.tabs.iter().position(|t| *t == self.selected).unwrap_or(0);
219 let new_index = if current_index == 0 {
220 self.tabs.len() - 1
221 } else {
222 current_index - 1
223 };
224 if let Some(tab) = self.tabs.get(new_index) {
225 self.selected = tab.clone();
226 cx.emit(TabBarEvent::Change(self.selected.clone()));
227 cx.notify();
228 }
229 }
230
231 /// Select the next tab (wraps around)
232 fn select_next(&mut self, cx: &mut Context<Self>) {
233 if self.tabs.is_empty() {
234 return;
235 }
236 let current_index = self.tabs.iter().position(|t| *t == self.selected).unwrap_or(0);
237 let new_index = if current_index >= self.tabs.len() - 1 {
238 0
239 } else {
240 current_index + 1
241 };
242 if let Some(tab) = self.tabs.get(new_index) {
243 self.selected = tab.clone();
244 cx.emit(TabBarEvent::Change(self.selected.clone()));
245 cx.notify();
246 }
247 }
248}
249
250impl<T: SelectionItem> EventEmitter<TabBarEvent<T>> for TabBar<T> {}
251
252impl<T: SelectionItem> Focusable for TabBar<T> {
253 fn focus_handle(&self, _cx: &App) -> FocusHandle {
254 self.focus_handle.clone()
255 }
256}
257
258impl<T: SelectionItem> Render for TabBar<T> {
259 fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
260 let theme = get_theme_or(cx, self.custom_theme.as_ref());
261 let selected_tab = self.selected.clone();
262 let is_focused = self.focus_handle.is_focused(window);
263 let enabled = self.enabled;
264
265 with_focus_actions(
266 div()
267 .id("ccf_tab_bar")
268 .key_context("CcfTabBar")
269 .track_focus(&self.focus_handle)
270 .tab_stop(enabled),
271 cx,
272 )
273 .flex()
274 .flex_row()
275 .when(enabled, |d| d.bg(rgb(theme.bg_secondary)))
276 .when(!enabled, |d| d.bg(rgb(theme.disabled_bg)))
277 // Tab navigation (Left / Right arrows)
278 .on_action(cx.listener(|this, _: &SelectPreviousTab, _window, cx| {
279 if this.enabled {
280 this.select_previous(cx);
281 }
282 }))
283 .on_action(cx.listener(|this, _: &SelectNextTab, _window, cx| {
284 if this.enabled {
285 this.select_next(cx);
286 }
287 }))
288 // Left filler area (draws bottom border for left padding area)
289 .when(self.tab_row_padding > px(0.0), |d| {
290 d.child(
291 div()
292 .w(self.tab_row_padding)
293 .when(enabled, |d| {
294 d.bg(rgb(theme.bg_secondary))
295 .border_b_1()
296 .border_color(rgb(theme.border_default))
297 })
298 .when(!enabled, |d| {
299 d.bg(rgb(theme.disabled_bg))
300 .border_b_1()
301 .border_color(rgb(theme.disabled_bg))
302 })
303 )
304 })
305 .children(self.tabs.iter().map(|tab| {
306 let tab = tab.clone();
307 let is_selected = tab == selected_tab;
308 let show_focus = is_selected && is_focused && enabled;
309
310 // Tab container - handles clicks and identification
311 div()
312 .id(tab.id())
313 .cursor_for_enabled(enabled)
314 .when(enabled, |d| {
315 let tab_clone = tab.clone();
316 d.on_mouse_down(MouseButton::Left, {
317 cx.listener(move |this, _event: &MouseDownEvent, window, cx| {
318 this.previous_focus = window.focused(cx);
319 cx.notify();
320 })
321 })
322 .on_click({
323 let tab = tab.clone();
324 cx.listener(move |this, _event: &ClickEvent, window, cx| {
325 this.selected = tab.clone();
326 cx.emit(TabBarEvent::Change(tab.clone()));
327 if let Some(focus_handle) = this.previous_focus.take() {
328 focus_handle.focus(window);
329 } else {
330 window.blur();
331 }
332 cx.notify();
333 })
334 })
335 .on_mouse_down(MouseButton::Right, {
336 cx.listener(move |_this, event: &MouseDownEvent, _window, cx| {
337 cx.emit(TabBarEvent::ContextMenu {
338 tab: tab_clone.clone(),
339 position: event.position,
340 });
341 })
342 })
343 })
344 // Tab content
345 .child(
346 div()
347 .px_4()
348 .pb_2()
349 // Active tab: py_2 top + border_t_2 (always accent), no other borders
350 .when(is_selected, |d| {
351 d.pt_2() // Standard top padding
352 .border_t_2()
353 })
354 // Inactive tabs: pt = py_2 + 2px to match active height
355 .when(!is_selected, |d| {
356 d.pt(px(10.0)) // 8px (py_2) + 2px (border_t_2)
357 .border_r_1()
358 .border_b_1()
359 })
360 // Colors based on active/enabled state
361 .when(is_selected && enabled, |d| {
362 d.bg(rgb(theme.bg_primary))
363 .text_color(rgb(theme.text_primary))
364 .border_color(rgb(theme.border_focus)) // Always accent for active tab
365 })
366 .when(is_selected && !enabled, |d| {
367 d.bg(rgb(theme.disabled_bg))
368 .text_color(rgb(theme.disabled_text))
369 .border_color(rgb(theme.disabled_bg))
370 })
371 .when(!is_selected && enabled, |d| {
372 d.bg(rgb(theme.bg_input))
373 .text_color(rgb(theme.text_dimmed))
374 .border_color(rgb(theme.border_default))
375 .hover(|d| {
376 d.bg(rgb(theme.bg_tab_hover))
377 .text_color(rgb(theme.text_muted))
378 })
379 })
380 .when(!is_selected && !enabled, |d| {
381 d.bg(rgb(theme.disabled_bg))
382 .text_color(rgb(theme.disabled_text))
383 .border_color(rgb(theme.disabled_bg))
384 })
385 // Text with focus ring (border always present to prevent layout shift)
386 .child(
387 div()
388 .px_1()
389 .border_1()
390 .rounded_sm()
391 .when(show_focus, |d| d.border_color(rgb(theme.border_focus)))
392 .when(!show_focus, |d| d.border_color(rgba(0x00000000)))
393 .child(tab.label())
394 )
395 )
396 }))
397 // Filler area to the right of tabs (draws its own bottom border)
398 .child(
399 div()
400 .flex_1()
401 .when(enabled, |d| {
402 d.bg(rgb(theme.bg_secondary))
403 .border_b_1()
404 .border_color(rgb(theme.border_default))
405 })
406 .when(!enabled, |d| {
407 d.bg(rgb(theme.disabled_bg))
408 .border_b_1()
409 .border_color(rgb(theme.disabled_bg))
410 })
411 )
412 // Right filler area (draws bottom border for right padding area)
413 .when(self.tab_row_padding > px(0.0), |d| {
414 d.child(
415 div()
416 .w(self.tab_row_padding)
417 .when(enabled, |d| {
418 d.bg(rgb(theme.bg_secondary))
419 .border_b_1()
420 .border_color(rgb(theme.border_default))
421 })
422 .when(!enabled, |d| {
423 d.bg(rgb(theme.disabled_bg))
424 .border_b_1()
425 .border_color(rgb(theme.disabled_bg))
426 })
427 )
428 })
429 }
430}