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#[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 .role(Role::Tab)
157 .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#[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}