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 _, TestSupportExt 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            .test_support()
157            .role(Role::Tab)
158            // Match Button's neutral control geometry: a fixed-size tab
159            // centers ordinary content, while callers still own its size,
160            // spacing and visual treatment.
161            .flex()
162            .items_center()
163            .justify_center()
164            .line_height(relative(1.))
165            .when_some(self.accessibility_label, |this, label| {
166                this.aria_label(label)
167            })
168            .aria_selected(self.selected)
169            .when_some(self.position_in_set, |this, position| {
170                this.aria_position_in_set(position)
171            })
172            .when_some(self.size_of_set, |this, size| this.aria_size_of_set(size))
173            .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
174            .when_some(
175                (!disabled).then_some(self.on_click).flatten(),
176                |this, on_click| {
177                    this.on_click(move |event, window, cx| on_click(event, window, cx))
178                },
179            )
180            .children(self.children)
181            .refine_style(&style)
182    }
183}
184
185/// An unstyled collection root for [`Tab`] elements.
186///
187/// Controlled selection and activation are expressed directly by its child
188/// tabs. The root supplies collection accessibility without introducing a
189/// context hierarchy or new keyboard behavior.
190#[derive(IntoElement)]
191pub struct Tabs {
192    base: gpui::Stateful<Div>,
193    style: StyleRefinement,
194    children: SmallVec<[AnyElement; 2]>,
195}
196
197impl Tabs {
198    pub fn new(id: impl Into<ElementId>) -> Self {
199        Self {
200            base: div().id(id),
201            style: StyleRefinement::default(),
202            children: SmallVec::new(),
203        }
204    }
205}
206
207impl Styled for Tabs {
208    fn style(&mut self) -> &mut StyleRefinement {
209        &mut self.style
210    }
211}
212
213impl ParentElement for Tabs {
214    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
215        self.children.extend(elements);
216    }
217}
218
219impl InteractiveElement for Tabs {
220    fn interactivity(&mut self) -> &mut Interactivity {
221        self.base.interactivity()
222    }
223}
224
225impl StatefulInteractiveElement for Tabs {}
226
227impl RenderOnce for Tabs {
228    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
229        self.base
230            .role(Role::TabList)
231            .children(self.children)
232            .refine_style(&self.style)
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::ElementExt as _;
240    use std::{
241        cell::Cell,
242        rc::Rc,
243        sync::{Arc, Mutex},
244    };
245
246    use gpui::{
247        Context, Element as _, Modifiers, Render, Role, VisualTestContext, accesskit, canvas, hsla,
248        point, px,
249    };
250
251    struct TabHarness {
252        disabled: bool,
253        clicks: Rc<Cell<usize>>,
254    }
255
256    impl Render for TabHarness {
257        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
258            let clicks = self.clicks.clone();
259            Tab::new("tab")
260                .disabled(self.disabled)
261                .size(px(100.))
262                .on_click(move |_, _, _| clicks.set(clicks.get() + 1))
263        }
264    }
265
266    fn harness(
267        cx: &mut gpui::TestAppContext,
268        disabled: bool,
269    ) -> (&mut VisualTestContext, Rc<Cell<usize>>) {
270        let clicks = Rc::new(Cell::new(0));
271        let (_, cx) = cx.add_window_view({
272            let clicks = clicks.clone();
273            move |_, _| TabHarness { disabled, clicks }
274        });
275        cx.update(|window, cx| window.draw(cx).clear(cx));
276        (cx, clicks)
277    }
278
279    #[gpui::test]
280    fn pointer_activation_and_disabled_gating_match_tabs(cx: &mut gpui::TestAppContext) {
281        let (cx, clicks) = harness(cx, false);
282        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
283        assert_eq!(clicks.get(), 1);
284
285        let (cx, clicks) = harness(cx, true);
286        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
287        assert_eq!(clicks.get(), 0);
288    }
289
290    #[gpui::test]
291    fn fixed_height_tab_centers_ordinary_child_geometry(cx: &mut gpui::TestAppContext) {
292        type Captured = Arc<
293            Mutex<(
294                Option<gpui::Bounds<gpui::Pixels>>,
295                Option<gpui::Bounds<gpui::Pixels>>,
296            )>,
297        >;
298
299        struct AlignmentProbe(Captured);
300
301        impl Render for AlignmentProbe {
302            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
303                let root_capture = self.0.clone();
304                let child_capture = self.0.clone();
305                Tab::new("alignment-tab")
306                    .w(px(120.))
307                    .h(px(40.))
308                    .child(
309                        div()
310                            .w(px(48.))
311                            .h(px(12.))
312                            .on_prepaint(move |bounds, _, _| {
313                                child_capture.lock().unwrap().1 = Some(bounds);
314                            }),
315                    )
316                    .on_prepaint(move |bounds, _, _| {
317                        root_capture.lock().unwrap().0 = Some(bounds);
318                    })
319            }
320        }
321
322        let captured = Arc::new(Mutex::new((None, None)));
323        let (_, context) = cx.add_window_view({
324            let captured = captured.clone();
325            move |_, _| AlignmentProbe(captured)
326        });
327        context.update(|window, cx| window.draw(cx).clear(cx));
328
329        let (root, child) = *captured.lock().unwrap();
330        assert_eq!(
331            child.expect("child bounds").center(),
332            root.expect("tab bounds").center()
333        );
334    }
335
336    #[gpui::test]
337    fn exposes_tab_accessibility_state(cx: &mut gpui::TestAppContext) {
338        type Captured = Arc<Mutex<Option<(accesskit::Node, accesskit::Node)>>>;
339
340        struct Probe(Captured);
341
342        impl Render for Probe {
343            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
344                let captured = self.0.clone();
345                canvas(
346                    move |_, window, cx| {
347                        let mut info = |tab: Tab| {
348                            let mut node = accesskit::Node::new(Role::Tab);
349                            tab.render(window, cx)
350                                .into_element()
351                                .write_a11y_info(&mut node);
352                            node
353                        };
354                        let selected = info(
355                            Tab::new("selected")
356                                .selected(true)
357                                .accessibility_label("Account")
358                                .on_click(|_, _, _| {}),
359                        );
360                        let disabled =
361                            info(Tab::new("disabled").disabled(true).on_click(|_, _, _| {}));
362                        *captured.lock().unwrap() = Some((selected, disabled));
363                    },
364                    |_, _, _, _| {},
365                )
366            }
367        }
368
369        let captured: Captured = Arc::new(Mutex::new(None));
370        let result = captured.clone();
371        let (_, cx) = cx.add_window_view(move |_, _| Probe(captured));
372        cx.update(|window, cx| window.draw(cx).clear(cx));
373        let (selected, disabled) = result.lock().unwrap().take().unwrap();
374
375        assert_eq!(selected.role(), Role::Tab);
376        assert_eq!(selected.label(), Some("Account"));
377        assert_eq!(selected.is_selected(), Some(true));
378        assert!(selected.supports_action(accesskit::Action::Click));
379        assert!(!disabled.supports_action(accesskit::Action::Click));
380    }
381
382    #[gpui::test]
383    fn semantic_styles_preserve_the_legacy_state_priority(_cx: &mut gpui::TestAppContext) {
384        let expected = hsla(0.3, 0.4, 0.5, 1.0);
385        let mut tab = Tab::new("tab")
386            .selected(true)
387            .disabled(true)
388            .styles(|styles| {
389                styles
390                    .selected(|style| style.opacity(0.8))
391                    .disabled(|style| style.opacity(0.5))
392            })
393            .opacity(0.9);
394
395        assert_eq!(tab.resolved_style().opacity, Some(0.5));
396        tab.style().background = Some(expected.into());
397        assert_eq!(tab.resolved_style().background, Some(expected.into()));
398    }
399
400    #[gpui::test]
401    fn tabs_exposes_tab_list_role(cx: &mut gpui::TestAppContext) {
402        type Captured = Arc<Mutex<Option<accesskit::Node>>>;
403        struct Probe(Captured);
404
405        impl Render for Probe {
406            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
407                let captured = self.0.clone();
408                canvas(
409                    move |_, window, cx| {
410                        let mut node = accesskit::Node::new(Role::TabList);
411                        Tabs::new("tabs")
412                            .render(window, cx)
413                            .into_element()
414                            .write_a11y_info(&mut node);
415                        *captured.lock().unwrap() = Some(node);
416                    },
417                    |_, _, _, _| {},
418                )
419            }
420        }
421
422        let captured: Captured = Arc::new(Mutex::new(None));
423        let result = captured.clone();
424        let (_, cx) = cx.add_window_view(move |_, _| Probe(captured));
425        cx.update(|window, cx| window.draw(cx).clear(cx));
426        assert_eq!(result.lock().unwrap().take().unwrap().role(), Role::TabList);
427    }
428}