Skip to main content

gpuikit/elements/
tabs.rs

1//! Tabs component for gpuikit
2//!
3//! A tabbed interface for organizing content into multiple panels.
4
5use crate::layout::h_stack;
6use crate::theme::{ActiveTheme, ControlSize, Themeable};
7use crate::traits::control_sized::ControlSized;
8use crate::traits::disableable::Disableable;
9use gpui::{
10    Context, ElementId, EventEmitter, InteractiveElement, IntoElement, MouseButton, ParentElement,
11    Render, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, px,
12};
13
14/// Event emitted when the selected tab changes
15pub struct TabChanged {
16    /// The ID of the newly selected tab
17    pub tab_id: SharedString,
18}
19
20/// A single tab definition
21#[derive(Clone)]
22pub struct Tab {
23    /// Unique identifier for the tab
24    pub id: SharedString,
25    /// Display label for the tab
26    pub label: SharedString,
27    /// Whether this tab is disabled
28    pub disabled: bool,
29}
30
31impl Tab {
32    /// Create a new tab with an ID and label
33    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
34        Self {
35            id: id.into(),
36            label: label.into(),
37            disabled: false,
38        }
39    }
40}
41
42impl Disableable for Tab {
43    fn is_disabled(&self) -> bool {
44        self.disabled
45    }
46
47    fn disabled(mut self, disabled: bool) -> Self {
48        self.disabled = disabled;
49        self
50    }
51}
52
53/// A tabs component for organizing content into multiple panels
54pub struct Tabs {
55    id: ElementId,
56    tabs: Vec<Tab>,
57    selected: Option<SharedString>,
58    disabled: bool,
59    size: ControlSize,
60}
61
62impl EventEmitter<TabChanged> for Tabs {}
63
64impl Tabs {
65    /// Create a new tabs component with the given ID
66    pub fn new(id: impl Into<ElementId>) -> Self {
67        Self {
68            id: id.into(),
69            tabs: Vec::new(),
70            selected: None,
71            disabled: false,
72            size: ControlSize::default(),
73        }
74    }
75
76    /// Add a tab to the tabs component
77    pub fn tab(mut self, tab: Tab) -> Self {
78        // If this is the first non-disabled tab and no selection exists, select it
79        if self.selected.is_none() && !tab.disabled {
80            self.selected = Some(tab.id.clone());
81        }
82        self.tabs.push(tab);
83        self
84    }
85
86    /// Set the selected tab by ID
87    pub fn selected(mut self, tab_id: impl Into<SharedString>) -> Self {
88        self.selected = Some(tab_id.into());
89        self
90    }
91
92    /// Set the selected tab by ID (optional)
93    pub fn selected_option(mut self, tab_id: Option<SharedString>) -> Self {
94        self.selected = tab_id;
95        self
96    }
97
98    /// Disable the entire tabs component
99    pub fn disabled(mut self, disabled: bool) -> Self {
100        self.disabled = disabled;
101        self
102    }
103
104    /// Get the currently selected tab ID
105    pub fn get_selected(&self) -> Option<&SharedString> {
106        self.selected.as_ref()
107    }
108
109    /// Set the selected tab and emit a change event
110    pub fn set_selected(&mut self, tab_id: SharedString, cx: &mut Context<Self>) {
111        if self.selected.as_ref() != Some(&tab_id) {
112            self.selected = Some(tab_id.clone());
113            cx.emit(TabChanged { tab_id });
114            cx.notify();
115        }
116    }
117
118    /// Select a tab by index
119    fn select_tab(&mut self, index: usize, cx: &mut Context<Self>) {
120        if let Some(tab) = self.tabs.get(index) {
121            if !tab.disabled && !self.disabled {
122                self.set_selected(tab.id.clone(), cx);
123            }
124        }
125    }
126}
127
128impl Render for Tabs {
129    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
130        let theme = cx.theme();
131        let metrics = theme.control(self.size);
132        let group_disabled = self.disabled;
133        let selected = self.selected.clone();
134
135        // The strip's own border is the rule the tabs sit on, and the tabs
136        // overlap it by the 1px they are pulled down. A tab is therefore the
137        // rung exactly, and so is the strip — a tab bar lines up with a button
138        // beside it instead of standing 15px taller than one.
139        let tab_list = h_stack()
140            .gap(metrics.gap)
141            .border_b_1()
142            .border_color(theme.border());
143
144        div().id(self.id.clone()).flex().flex_col().child(
145            tab_list.children(
146                self.tabs
147                    .iter()
148                    .enumerate()
149                    .map(|(index, tab)| {
150                        let is_selected = selected.as_ref() == Some(&tab.id);
151                        let is_disabled = group_disabled || tab.disabled;
152                        let label = tab.label.clone();
153
154                        let text_color = if is_disabled {
155                            theme.fg_disabled()
156                        } else if is_selected {
157                            theme.accent()
158                        } else {
159                            theme.fg_muted()
160                        };
161
162                        let bg = if is_selected && !is_disabled {
163                            theme.accent_bg()
164                        } else {
165                            gpui::Hsla::transparent_black()
166                        };
167
168                        let border_color = if is_selected && !is_disabled {
169                            theme.accent()
170                        } else {
171                            gpui::Hsla::transparent_black()
172                        };
173
174                        div()
175                            .id(ElementId::NamedInteger("tab".into(), index as u64))
176                            .flex()
177                            .items_center()
178                            .justify_center()
179                            .h(metrics.height)
180                            .px(metrics.padding_x)
181                            .text_size(metrics.text_size)
182                            .line_height(metrics.line_height)
183                            .text_color(text_color)
184                            .bg(bg)
185                            .border_b_2()
186                            .border_color(border_color)
187                            .mb(px(-1.0)) // Overlap with container border
188                            .rounded_t(metrics.radius)
189                            .when(!is_disabled, |this| {
190                                this.cursor_pointer()
191                                    .on_mouse_down(MouseButton::Left, |_, window, _| {
192                                        window.prevent_default()
193                                    })
194                                    .on_click(cx.listener(move |this, _, _, cx| {
195                                        this.select_tab(index, cx);
196                                    }))
197                                    .hover(|style| {
198                                        if is_selected {
199                                            style
200                                        } else {
201                                            style
202                                                .text_color(theme.fg())
203                                                .bg(theme.surface_secondary())
204                                        }
205                                    })
206                            })
207                            .when(is_disabled, |this| this.cursor_not_allowed().opacity(0.65))
208                            .child(label)
209                    })
210                    .collect::<Vec<_>>(),
211            ),
212        )
213    }
214}
215
216/// Convenience function to create a tabs component
217pub fn tabs(id: impl Into<ElementId>) -> Tabs {
218    Tabs::new(id)
219}
220
221/// Convenience function to create a tab
222pub fn tab(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Tab {
223    Tab::new(id, label)
224}
225
226impl Disableable for Tabs {
227    fn is_disabled(&self) -> bool {
228        self.disabled
229    }
230
231    fn disabled(mut self, disabled: bool) -> Self {
232        self.disabled = disabled;
233        self
234    }
235}
236
237impl ControlSized for Tabs {
238    fn control_size(mut self, size: ControlSize) -> Self {
239        self.size = size;
240        self
241    }
242}