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#[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 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 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 .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#[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}