gpui_ui_kit/
tabs.rs

1//! Tabs component for tabbed navigation
2//!
3//! Provides a horizontal tab bar with content panels.
4
5use gpui::prelude::*;
6use gpui::*;
7
8/// Tab visual variant
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum TabVariant {
11    /// Underline indicator (default)
12    #[default]
13    Underline,
14    /// Enclosed tabs with background
15    Enclosed,
16    /// Pill-shaped tabs
17    Pills,
18}
19
20/// A single tab item
21#[derive(Clone)]
22pub struct TabItem {
23    id: SharedString,
24    label: SharedString,
25    icon: Option<SharedString>,
26    badge: Option<SharedString>,
27    disabled: bool,
28    closeable: bool,
29}
30
31impl TabItem {
32    /// Create a new tab item
33    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
34        Self {
35            id: id.into(),
36            label: label.into(),
37            icon: None,
38            badge: None,
39            disabled: false,
40            closeable: false,
41        }
42    }
43
44    /// Add an icon
45    pub fn icon(mut self, icon: impl Into<SharedString>) -> Self {
46        self.icon = Some(icon.into());
47        self
48    }
49
50    /// Add a badge
51    pub fn badge(mut self, badge: impl Into<SharedString>) -> Self {
52        self.badge = Some(badge.into());
53        self
54    }
55
56    /// Disable the tab
57    pub fn disabled(mut self, disabled: bool) -> Self {
58        self.disabled = disabled;
59        self
60    }
61
62    /// Make the tab closeable
63    pub fn closeable(mut self, closeable: bool) -> Self {
64        self.closeable = closeable;
65        self
66    }
67
68    /// Get the tab ID
69    pub fn id(&self) -> &SharedString {
70        &self.id
71    }
72}
73
74/// A tabs component
75pub struct Tabs {
76    tabs: Vec<TabItem>,
77    selected_index: usize,
78    variant: TabVariant,
79    on_change: Option<Box<dyn Fn(usize, &mut Window, &mut App) + 'static>>,
80    on_close: Option<Box<dyn Fn(&SharedString, &mut Window, &mut App) + 'static>>,
81}
82
83impl Tabs {
84    /// Create a new tabs component
85    pub fn new() -> Self {
86        Self {
87            tabs: Vec::new(),
88            selected_index: 0,
89            variant: TabVariant::default(),
90            on_change: None,
91            on_close: None,
92        }
93    }
94
95    /// Set the tab items
96    pub fn tabs(mut self, tabs: Vec<TabItem>) -> Self {
97        self.tabs = tabs;
98        self
99    }
100
101    /// Set the selected tab index
102    pub fn selected_index(mut self, index: usize) -> Self {
103        self.selected_index = index;
104        self
105    }
106
107    /// Set the visual variant
108    pub fn variant(mut self, variant: TabVariant) -> Self {
109        self.variant = variant;
110        self
111    }
112
113    /// Set the tab change handler
114    pub fn on_change(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
115        self.on_change = Some(Box::new(handler));
116        self
117    }
118
119    /// Set the tab close handler
120    pub fn on_close(
121        mut self,
122        handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
123    ) -> Self {
124        self.on_close = Some(Box::new(handler));
125        self
126    }
127
128    /// Build into element
129    pub fn build(self) -> Div {
130        let mut container = div().flex().items_center();
131
132        // Apply variant-specific container styling
133        match self.variant {
134            TabVariant::Underline => {
135                container = container.border_b_1().border_color(rgb(0x3a3a3a));
136            }
137            TabVariant::Enclosed => {
138                container = container.gap_1();
139            }
140            TabVariant::Pills => {
141                container = container.gap_2().p_1().bg(rgb(0x2a2a2a)).rounded_lg();
142            }
143        }
144
145        for (index, tab) in self.tabs.iter().enumerate() {
146            let is_selected = index == self.selected_index;
147            let tab_id = tab.id.clone();
148            let label = tab.label.clone();
149            let icon = tab.icon.clone();
150            let badge = tab.badge.clone();
151            let disabled = tab.disabled;
152            let closeable = tab.closeable;
153
154            let on_change: Option<*const dyn Fn(usize, &mut Window, &mut App)> =
155                self.on_change.as_ref().map(|f| f.as_ref() as *const _);
156            let on_close: Option<*const dyn Fn(&SharedString, &mut Window, &mut App)> =
157                self.on_close.as_ref().map(|f| f.as_ref() as *const _);
158
159            let mut tab_el = div()
160                .id(SharedString::from(format!("tab-{}", tab_id)))
161                .flex()
162                .items_center()
163                .gap_2()
164                .px_4()
165                .py_2();
166
167            // Apply variant-specific tab styling
168            match self.variant {
169                TabVariant::Underline => {
170                    if is_selected {
171                        tab_el = tab_el
172                            .border_b_2()
173                            .border_color(rgb(0x007acc))
174                            .text_color(rgb(0xffffff))
175                            .font_weight(FontWeight::SEMIBOLD);
176                    } else {
177                        tab_el = tab_el
178                            .text_color(rgb(0x888888))
179                            .hover(|s| s.text_color(rgb(0xcccccc)));
180                    }
181                }
182                TabVariant::Enclosed => {
183                    if is_selected {
184                        tab_el = tab_el
185                            .bg(rgb(0x3a3a3a))
186                            .rounded_t_md()
187                            .text_color(rgb(0xffffff));
188                    } else {
189                        tab_el = tab_el
190                            .text_color(rgb(0x888888))
191                            .hover(|s| s.bg(rgb(0x2a2a2a)).text_color(rgb(0xcccccc)));
192                    }
193                }
194                TabVariant::Pills => {
195                    if is_selected {
196                        tab_el = tab_el
197                            .bg(rgb(0x007acc))
198                            .rounded_md()
199                            .text_color(rgb(0xffffff));
200                    } else {
201                        tab_el = tab_el
202                            .rounded_md()
203                            .text_color(rgb(0x888888))
204                            .hover(|s| s.bg(rgb(0x3a3a3a)).text_color(rgb(0xcccccc)));
205                    }
206                }
207            }
208
209            if disabled {
210                tab_el = tab_el.opacity(0.5).cursor_not_allowed();
211            } else {
212                tab_el = tab_el.cursor_pointer();
213
214                // Handle click
215                if let Some(handler_ptr) = on_change {
216                    let idx = index;
217                    tab_el =
218                        tab_el.on_mouse_up(MouseButton::Left, move |_event, window, cx| unsafe {
219                            (*handler_ptr)(idx, window, cx);
220                        });
221                }
222            }
223
224            // Icon
225            if let Some(icon) = icon {
226                tab_el = tab_el.child(div().text_sm().child(icon));
227            }
228
229            // Label
230            tab_el = tab_el.child(div().text_sm().child(label));
231
232            // Badge
233            if let Some(badge) = badge {
234                tab_el = tab_el.child(
235                    div()
236                        .text_xs()
237                        .px_1()
238                        .py(px(1.0))
239                        .bg(rgb(0x555555))
240                        .rounded(px(3.0))
241                        .child(badge),
242                );
243            }
244
245            // Close button
246            if closeable {
247                let id = tab_id.clone();
248                let mut close_btn = div()
249                    .id(SharedString::from(format!("tab-close-{}", tab_id)))
250                    .text_xs()
251                    .text_color(rgb(0x888888))
252                    .hover(|s| s.text_color(rgb(0xffffff)));
253
254                if let Some(handler_ptr) = on_close {
255                    close_btn = close_btn.on_mouse_up(
256                        MouseButton::Left,
257                        move |_event, window, cx| unsafe {
258                            (*handler_ptr)(&id, window, cx);
259                        },
260                    );
261                }
262
263                tab_el = tab_el.child(close_btn.child("×"));
264            }
265
266            container = container.child(tab_el);
267        }
268
269        container
270    }
271}
272
273impl Default for Tabs {
274    fn default() -> Self {
275        Self::new()
276    }
277}
278
279impl IntoElement for Tabs {
280    type Element = Div;
281
282    fn into_element(self) -> Self::Element {
283        self.build()
284    }
285}