Skip to main content

gpui_base/
tabs.rs

1use std::rc::Rc;
2
3use gpui::{
4    AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, Interactivity, IntoElement,
5    MouseButton, ParentElement, Refineable as _, RenderOnce, Role, SharedString,
6    StatefulInteractiveElement, StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _,
7    relative,
8};
9use smallvec::SmallVec;
10
11use crate::{StateStyle, StyledExt as _};
12
13type ClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
14
15/// An unstyled tab that owns pointer activation and accessibility behavior.
16///
17/// Tabs do not participate in keyboard focus by themselves. A compound tab
18/// list may add keyboard navigation when that behavior is introduced.
19// TODO: Add compound keyboard navigation with roving focus, arrow keys,
20// Home/End, and Enter/Space activation before treating Tabs as a complete
21// desktop tab-list primitive.
22#[derive(IntoElement)]
23pub struct Tab {
24    id: ElementId,
25    base: Div,
26    style: StyleRefinement,
27    semantic_styles: TabStyles,
28    selected: bool,
29    disabled: bool,
30    children: SmallVec<[AnyElement; 2]>,
31    on_click: Option<ClickHandler>,
32    accessibility_label: Option<SharedString>,
33    position_in_set: Option<usize>,
34    size_of_set: Option<usize>,
35}
36
37impl Tab {
38    pub fn new(id: impl Into<ElementId>) -> Self {
39        Self {
40            id: id.into(),
41            base: div(),
42            style: StyleRefinement::default(),
43            semantic_styles: TabStyles::default(),
44            selected: false,
45            disabled: false,
46            children: SmallVec::new(),
47            on_click: None,
48            accessibility_label: None,
49            position_in_set: None,
50            size_of_set: None,
51        }
52    }
53
54    /// Updates the element identity used when the tab is rendered.
55    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
56        self.id = id.into();
57        self
58    }
59
60    pub fn selected(mut self, selected: bool) -> Self {
61        self.selected = selected;
62        self
63    }
64
65    pub fn disabled(mut self, disabled: bool) -> Self {
66        self.disabled = disabled;
67        self
68    }
69
70    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
71        self.accessibility_label = Some(label.into());
72        self
73    }
74
75    /// Sets this tab's one-based position and the tab list's total size, so
76    /// assistive technology can announce "tab 2 of 5".
77    pub fn set_position(mut self, position: usize, size: usize) -> Self {
78        self.position_in_set = Some(position);
79        self.size_of_set = Some(size);
80        self
81    }
82
83    pub fn on_click(
84        mut self,
85        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
86    ) -> Self {
87        self.on_click = Some(Rc::new(handler));
88        self
89    }
90
91    pub fn styles(mut self, build: impl FnOnce(TabStyles) -> TabStyles) -> Self {
92        self.semantic_styles = build(self.semantic_styles);
93        self
94    }
95
96    fn resolved_style(&self) -> StyleRefinement {
97        crate::state_style::resolve_style(
98            &self.style,
99            [
100                self.selected.then_some(&self.semantic_styles.selected),
101                self.disabled.then_some(&self.semantic_styles.disabled),
102            ]
103            .into_iter()
104            .flatten(),
105        )
106    }
107}
108
109#[derive(Default)]
110pub struct TabStyles {
111    selected: StyleRefinement,
112    disabled: StyleRefinement,
113}
114
115impl TabStyles {
116    pub fn selected(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
117        self.selected
118            .refine(&build(StateStyle::default()).into_refinement());
119        self
120    }
121
122    pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
123        self.disabled
124            .refine(&build(StateStyle::default()).into_refinement());
125        self
126    }
127}
128
129impl Styled for Tab {
130    fn style(&mut self) -> &mut StyleRefinement {
131        &mut self.style
132    }
133}
134
135impl ParentElement for Tab {
136    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
137        self.children.extend(elements);
138    }
139}
140
141impl InteractiveElement for Tab {
142    fn interactivity(&mut self) -> &mut Interactivity {
143        self.base.interactivity()
144    }
145}
146
147impl StatefulInteractiveElement for Tab {}
148
149impl RenderOnce for Tab {
150    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
151        let disabled = self.disabled;
152        let style = self.resolved_style();
153
154        self.base
155            .id(self.id)
156            .role(Role::Tab)
157            // Match Button's neutral control geometry: a fixed-size tab
158            // centers ordinary content, while callers still own its size,
159            // spacing and visual treatment.
160            .flex()
161            .items_center()
162            .justify_center()
163            .line_height(relative(1.))
164            .when_some(self.accessibility_label, |this, label| {
165                this.aria_label(label)
166            })
167            .aria_selected(self.selected)
168            .when_some(self.position_in_set, |this, position| {
169                this.aria_position_in_set(position)
170            })
171            .when_some(self.size_of_set, |this, size| this.aria_size_of_set(size))
172            .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
173            .when_some(
174                (!disabled).then_some(self.on_click).flatten(),
175                |this, on_click| {
176                    this.on_click(move |event, window, cx| on_click(event, window, cx))
177                },
178            )
179            .children(self.children)
180            .refine_style(&style)
181    }
182}
183
184/// An unstyled collection root for [`Tab`] elements.
185///
186/// Controlled selection and activation are expressed directly by its child
187/// tabs. The root supplies collection accessibility without introducing a
188/// context hierarchy or new keyboard behavior.
189#[derive(IntoElement)]
190pub struct Tabs {
191    base: gpui::Stateful<Div>,
192    style: StyleRefinement,
193    children: SmallVec<[AnyElement; 2]>,
194}
195
196impl Tabs {
197    pub fn new(id: impl Into<ElementId>) -> Self {
198        Self {
199            base: div().id(id),
200            style: StyleRefinement::default(),
201            children: SmallVec::new(),
202        }
203    }
204}
205
206impl Styled for Tabs {
207    fn style(&mut self) -> &mut StyleRefinement {
208        &mut self.style
209    }
210}
211
212impl ParentElement for Tabs {
213    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
214        self.children.extend(elements);
215    }
216}
217
218impl InteractiveElement for Tabs {
219    fn interactivity(&mut self) -> &mut Interactivity {
220        self.base.interactivity()
221    }
222}
223
224impl StatefulInteractiveElement for Tabs {}
225
226impl RenderOnce for Tabs {
227    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
228        self.base
229            .role(Role::TabList)
230            .children(self.children)
231            .refine_style(&self.style)
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::ElementExt as _;
239    use std::{
240        cell::Cell,
241        rc::Rc,
242        sync::{Arc, Mutex},
243    };
244
245    use gpui::{
246        Context, Element as _, Modifiers, Render, Role, VisualTestContext, accesskit, canvas, hsla,
247        point, px,
248    };
249
250    struct TabHarness {
251        disabled: bool,
252        clicks: Rc<Cell<usize>>,
253    }
254
255    impl Render for TabHarness {
256        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
257            let clicks = self.clicks.clone();
258            Tab::new("tab")
259                .disabled(self.disabled)
260                .size(px(100.))
261                .on_click(move |_, _, _| clicks.set(clicks.get() + 1))
262        }
263    }
264
265    fn harness(
266        cx: &mut gpui::TestAppContext,
267        disabled: bool,
268    ) -> (&mut VisualTestContext, Rc<Cell<usize>>) {
269        let clicks = Rc::new(Cell::new(0));
270        let (_, cx) = cx.add_window_view({
271            let clicks = clicks.clone();
272            move |_, _| TabHarness { disabled, clicks }
273        });
274        cx.update(|window, cx| window.draw(cx).clear(cx));
275        (cx, clicks)
276    }
277
278    #[gpui::test]
279    fn pointer_activation_and_disabled_gating_match_tabs(cx: &mut gpui::TestAppContext) {
280        let (cx, clicks) = harness(cx, false);
281        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
282        assert_eq!(clicks.get(), 1);
283
284        let (cx, clicks) = harness(cx, true);
285        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
286        assert_eq!(clicks.get(), 0);
287    }
288
289    #[gpui::test]
290    fn fixed_height_tab_centers_ordinary_child_geometry(cx: &mut gpui::TestAppContext) {
291        type Captured = Arc<
292            Mutex<(
293                Option<gpui::Bounds<gpui::Pixels>>,
294                Option<gpui::Bounds<gpui::Pixels>>,
295            )>,
296        >;
297
298        struct AlignmentProbe(Captured);
299
300        impl Render for AlignmentProbe {
301            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
302                let root_capture = self.0.clone();
303                let child_capture = self.0.clone();
304                Tab::new("alignment-tab")
305                    .w(px(120.))
306                    .h(px(40.))
307                    .child(
308                        div()
309                            .w(px(48.))
310                            .h(px(12.))
311                            .on_prepaint(move |bounds, _, _| {
312                                child_capture.lock().unwrap().1 = Some(bounds);
313                            }),
314                    )
315                    .on_prepaint(move |bounds, _, _| {
316                        root_capture.lock().unwrap().0 = Some(bounds);
317                    })
318            }
319        }
320
321        let captured = Arc::new(Mutex::new((None, None)));
322        let (_, context) = cx.add_window_view({
323            let captured = captured.clone();
324            move |_, _| AlignmentProbe(captured)
325        });
326        context.update(|window, cx| window.draw(cx).clear(cx));
327
328        let (root, child) = *captured.lock().unwrap();
329        assert_eq!(
330            child.expect("child bounds").center(),
331            root.expect("tab bounds").center()
332        );
333    }
334
335    #[gpui::test]
336    fn exposes_tab_accessibility_state(cx: &mut gpui::TestAppContext) {
337        type Captured = Arc<Mutex<Option<(accesskit::Node, accesskit::Node)>>>;
338
339        struct Probe(Captured);
340
341        impl Render for Probe {
342            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
343                let captured = self.0.clone();
344                canvas(
345                    move |_, window, cx| {
346                        let mut info = |tab: Tab| {
347                            let mut node = accesskit::Node::new(Role::Tab);
348                            tab.render(window, cx)
349                                .into_element()
350                                .write_a11y_info(&mut node);
351                            node
352                        };
353                        let selected = info(
354                            Tab::new("selected")
355                                .selected(true)
356                                .accessibility_label("Account")
357                                .on_click(|_, _, _| {}),
358                        );
359                        let disabled =
360                            info(Tab::new("disabled").disabled(true).on_click(|_, _, _| {}));
361                        *captured.lock().unwrap() = Some((selected, disabled));
362                    },
363                    |_, _, _, _| {},
364                )
365            }
366        }
367
368        let captured: Captured = Arc::new(Mutex::new(None));
369        let result = captured.clone();
370        let (_, cx) = cx.add_window_view(move |_, _| Probe(captured));
371        cx.update(|window, cx| window.draw(cx).clear(cx));
372        let (selected, disabled) = result.lock().unwrap().take().unwrap();
373
374        assert_eq!(selected.role(), Role::Tab);
375        assert_eq!(selected.label(), Some("Account"));
376        assert_eq!(selected.is_selected(), Some(true));
377        assert!(selected.supports_action(accesskit::Action::Click));
378        assert!(!disabled.supports_action(accesskit::Action::Click));
379    }
380
381    #[gpui::test]
382    fn semantic_styles_preserve_the_legacy_state_priority(_cx: &mut gpui::TestAppContext) {
383        let expected = hsla(0.3, 0.4, 0.5, 1.0);
384        let mut tab = Tab::new("tab")
385            .selected(true)
386            .disabled(true)
387            .styles(|styles| {
388                styles
389                    .selected(|style| style.opacity(0.8))
390                    .disabled(|style| style.opacity(0.5))
391            })
392            .opacity(0.9);
393
394        assert_eq!(tab.resolved_style().opacity, Some(0.5));
395        tab.style().background = Some(expected.into());
396        assert_eq!(tab.resolved_style().background, Some(expected.into()));
397    }
398
399    #[gpui::test]
400    fn tabs_exposes_tab_list_role(cx: &mut gpui::TestAppContext) {
401        type Captured = Arc<Mutex<Option<accesskit::Node>>>;
402        struct Probe(Captured);
403
404        impl Render for Probe {
405            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
406                let captured = self.0.clone();
407                canvas(
408                    move |_, window, cx| {
409                        let mut node = accesskit::Node::new(Role::TabList);
410                        Tabs::new("tabs")
411                            .render(window, cx)
412                            .into_element()
413                            .write_a11y_info(&mut node);
414                        *captured.lock().unwrap() = Some(node);
415                    },
416                    |_, _, _, _| {},
417                )
418            }
419        }
420
421        let captured: Captured = Arc::new(Mutex::new(None));
422        let result = captured.clone();
423        let (_, cx) = cx.add_window_view(move |_, _| Probe(captured));
424        cx.update(|window, cx| window.draw(cx).clear(cx));
425        assert_eq!(result.lock().unwrap().take().unwrap().role(), Role::TabList);
426    }
427}