Skip to main content

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}