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