gpui_ui_kit/
tabs.rs

1//! Tabs component for tabbed navigation
2//!
3//! Provides a horizontal tab bar with content panels and theming support.
4
5use gpui::prelude::*;
6use gpui::*;
7
8/// Theme colors for tabs styling
9#[derive(Debug, Clone)]
10pub struct TabsTheme {
11    /// Background color for the container (Pills variant)
12    pub container_bg: Rgba,
13    /// Border color for the container (Underline variant)
14    pub container_border: Rgba,
15    /// Background color for selected tab
16    pub selected_bg: Rgba,
17    /// Background color for selected tab on hover
18    pub selected_hover_bg: Rgba,
19    /// Background color for unselected tab on hover
20    pub hover_bg: Rgba,
21    /// Accent color (underline, selected pill)
22    pub accent: Rgba,
23    /// Text color for selected tab
24    pub text_selected: Rgba,
25    /// Text color for unselected tab
26    pub text_unselected: Rgba,
27    /// Text color on hover
28    pub text_hover: Rgba,
29    /// Badge background color
30    pub badge_bg: Rgba,
31    /// Close button color
32    pub close_color: Rgba,
33    /// Close button hover color
34    pub close_hover_color: Rgba,
35}
36
37impl Default for TabsTheme {
38    fn default() -> Self {
39        Self {
40            container_bg: rgba(0x2a2a2aff),
41            container_border: rgba(0x3a3a3aff),
42            selected_bg: rgba(0x3a3a3aff),
43            selected_hover_bg: rgba(0x4a4a4aff),
44            hover_bg: rgba(0x2a2a2aff),
45            accent: rgba(0x007accff),
46            text_selected: rgba(0xffffffff),
47            text_unselected: rgba(0x888888ff),
48            text_hover: rgba(0xccccccff),
49            badge_bg: rgba(0x555555ff),
50            close_color: rgba(0x888888ff),
51            close_hover_color: rgba(0xffffffff),
52        }
53    }
54}
55
56/// Tab visual variant
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum TabVariant {
59    /// Underline indicator (default)
60    #[default]
61    Underline,
62    /// Enclosed tabs with background
63    Enclosed,
64    /// Pill-shaped tabs
65    Pills,
66}
67
68/// A single tab item
69pub struct TabItem {
70    id: SharedString,
71    label: SharedString,
72    icon: Option<SharedString>,
73    custom_icon: Option<AnyElement>,
74    badge: Option<SharedString>,
75    disabled: bool,
76    closeable: bool,
77}
78
79impl TabItem {
80    /// Create a new tab item
81    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
82        Self {
83            id: id.into(),
84            label: label.into(),
85            icon: None,
86            custom_icon: None,
87            badge: None,
88            disabled: false,
89            closeable: false,
90        }
91    }
92
93    /// Add a text/emoji icon
94    pub fn icon(mut self, icon: impl Into<SharedString>) -> Self {
95        self.icon = Some(icon.into());
96        self
97    }
98
99    /// Add a custom icon element (e.g., SVG)
100    pub fn custom_icon(mut self, icon: impl IntoElement) -> Self {
101        self.custom_icon = Some(icon.into_any_element());
102        self
103    }
104
105    /// Add a badge
106    pub fn badge(mut self, badge: impl Into<SharedString>) -> Self {
107        self.badge = Some(badge.into());
108        self
109    }
110
111    /// Disable the tab
112    pub fn disabled(mut self, disabled: bool) -> Self {
113        self.disabled = disabled;
114        self
115    }
116
117    /// Make the tab closeable
118    pub fn closeable(mut self, closeable: bool) -> Self {
119        self.closeable = closeable;
120        self
121    }
122
123    /// Get the tab ID
124    pub fn id(&self) -> &SharedString {
125        &self.id
126    }
127}
128
129/// A tabs component with theming support
130pub struct Tabs {
131    tabs: Vec<TabItem>,
132    selected_index: usize,
133    variant: TabVariant,
134    theme: Option<TabsTheme>,
135    on_change: Option<Box<dyn Fn(usize, &mut Window, &mut App) + 'static>>,
136    on_close: Option<Box<dyn Fn(&SharedString, &mut Window, &mut App) + 'static>>,
137}
138
139impl Tabs {
140    /// Create a new tabs component
141    pub fn new() -> Self {
142        Self {
143            tabs: Vec::new(),
144            selected_index: 0,
145            variant: TabVariant::default(),
146            theme: None,
147            on_change: None,
148            on_close: None,
149        }
150    }
151
152    /// Set the tab items
153    pub fn tabs(mut self, tabs: Vec<TabItem>) -> Self {
154        self.tabs = tabs;
155        self
156    }
157
158    /// Set the selected tab index
159    pub fn selected_index(mut self, index: usize) -> Self {
160        self.selected_index = index;
161        self
162    }
163
164    /// Set the visual variant
165    pub fn variant(mut self, variant: TabVariant) -> Self {
166        self.variant = variant;
167        self
168    }
169
170    /// Set the theme
171    pub fn theme(mut self, theme: TabsTheme) -> Self {
172        self.theme = Some(theme);
173        self
174    }
175
176    /// Set the tab change handler
177    pub fn on_change(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
178        self.on_change = Some(Box::new(handler));
179        self
180    }
181
182    /// Set the tab close handler
183    pub fn on_close(
184        mut self,
185        handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
186    ) -> Self {
187        self.on_close = Some(Box::new(handler));
188        self
189    }
190
191    /// Build into element
192    pub fn build(self) -> Div {
193        let default_theme = TabsTheme::default();
194        let theme = self.theme.as_ref().unwrap_or(&default_theme);
195
196        let mut container = div().flex().items_center();
197
198        // Apply variant-specific container styling
199        match self.variant {
200            TabVariant::Underline => {
201                container = container.border_b_1().border_color(theme.container_border);
202            }
203            TabVariant::Enclosed => {
204                container = container.gap_1();
205            }
206            TabVariant::Pills => {
207                container = container.gap_2().p_1().bg(theme.container_bg).rounded_lg();
208            }
209        }
210
211        for (index, tab) in self.tabs.into_iter().enumerate() {
212            let is_selected = index == self.selected_index;
213            let tab_id = tab.id.clone();
214            let label = tab.label;
215            let icon = tab.icon;
216            let custom_icon = tab.custom_icon;
217            let badge = tab.badge;
218            let disabled = tab.disabled;
219            let closeable = tab.closeable;
220
221            let on_change: Option<*const dyn Fn(usize, &mut Window, &mut App)> =
222                self.on_change.as_ref().map(|f| f.as_ref() as *const _);
223            let on_close: Option<*const dyn Fn(&SharedString, &mut Window, &mut App)> =
224                self.on_close.as_ref().map(|f| f.as_ref() as *const _);
225
226            let mut tab_el = div()
227                .id(SharedString::from(format!("tab-{}", tab_id)))
228                .flex()
229                .items_center()
230                .gap_2()
231                .px_4()
232                .py_2();
233
234            // Apply variant-specific tab styling with theme colors
235            match self.variant {
236                TabVariant::Underline => {
237                    if is_selected {
238                        tab_el = tab_el
239                            .border_b_2()
240                            .border_color(theme.accent)
241                            .text_color(theme.text_selected)
242                            .font_weight(FontWeight::SEMIBOLD);
243                    } else {
244                        let hover_color = theme.text_hover;
245                        tab_el = tab_el
246                            .text_color(theme.text_unselected)
247                            .hover(move |s| s.text_color(hover_color));
248                    }
249                }
250                TabVariant::Enclosed => {
251                    if is_selected {
252                        tab_el = tab_el
253                            .bg(theme.selected_bg)
254                            .rounded_t_md()
255                            .text_color(theme.text_selected);
256                    } else {
257                        let hover_bg = theme.hover_bg;
258                        let hover_text = theme.text_hover;
259                        tab_el = tab_el
260                            .text_color(theme.text_unselected)
261                            .hover(move |s| s.bg(hover_bg).text_color(hover_text));
262                    }
263                }
264                TabVariant::Pills => {
265                    if is_selected {
266                        tab_el = tab_el
267                            .bg(theme.accent)
268                            .rounded_md()
269                            .text_color(theme.text_selected);
270                    } else {
271                        let hover_bg = theme.selected_bg;
272                        let hover_text = theme.text_hover;
273                        tab_el = tab_el
274                            .rounded_md()
275                            .text_color(theme.text_unselected)
276                            .hover(move |s| s.bg(hover_bg).text_color(hover_text));
277                    }
278                }
279            }
280
281            if disabled {
282                tab_el = tab_el.opacity(0.5).cursor_not_allowed();
283            } else {
284                tab_el = tab_el.cursor_pointer();
285
286                // Handle click
287                if let Some(handler_ptr) = on_change {
288                    let idx = index;
289                    tab_el =
290                        tab_el.on_mouse_up(MouseButton::Left, move |_event, window, cx| unsafe {
291                            (*handler_ptr)(idx, window, cx);
292                        });
293                }
294            }
295
296            // Custom icon (takes precedence)
297            if let Some(custom_icon) = custom_icon {
298                tab_el = tab_el.child(custom_icon);
299            } else if let Some(icon) = icon {
300                // Text/emoji icon
301                tab_el = tab_el.child(div().text_sm().child(icon));
302            }
303
304            // Label
305            tab_el = tab_el.child(div().text_sm().child(label));
306
307            // Badge
308            if let Some(badge) = badge {
309                tab_el = tab_el.child(
310                    div()
311                        .text_xs()
312                        .px_1()
313                        .py(px(1.0))
314                        .bg(theme.badge_bg)
315                        .rounded(px(3.0))
316                        .child(badge),
317                );
318            }
319
320            // Close button
321            if closeable {
322                let id = tab_id.clone();
323                let close_color = theme.close_color;
324                let close_hover = theme.close_hover_color;
325                let mut close_btn = div()
326                    .id(SharedString::from(format!("tab-close-{}", tab_id)))
327                    .text_xs()
328                    .text_color(close_color)
329                    .hover(move |s| s.text_color(close_hover));
330
331                if let Some(handler_ptr) = on_close {
332                    close_btn = close_btn.on_mouse_up(
333                        MouseButton::Left,
334                        move |_event, window, cx| unsafe {
335                            (*handler_ptr)(&id, window, cx);
336                        },
337                    );
338                }
339
340                tab_el = tab_el.child(close_btn.child("×"));
341            }
342
343            container = container.child(tab_el);
344        }
345
346        container
347    }
348}
349
350impl Default for Tabs {
351    fn default() -> Self {
352        Self::new()
353    }
354}
355
356impl IntoElement for Tabs {
357    type Element = Div;
358
359    fn into_element(self) -> Self::Element {
360        self.build()
361    }
362}