Another project
1use crate::a11y::{AccessNode, Role};
2use crate::frame::{FrameCtx, InteractDeclaration};
3use crate::hit_test::{Interaction, Sense};
4use crate::input::{KeyCode, NamedKey};
5use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
6use crate::strings::StringKey;
7use crate::theme::{Border, Color, Step12, StrokeWidth, SurfaceLevel};
8use crate::widget_id::{WidgetId, WidgetKey};
9
10use bone_types::IconId;
11
12use super::keys::{TakeKey, take_key};
13use super::paint::{IconSlot, IconTint, LabelText, WidgetPaint};
14use super::visuals::push_focus_ring;
15
16const TAB_ICON_PX: LayoutPx = LayoutPx::new(16.0);
17
18#[derive(Copy, Clone, Debug, PartialEq, Eq)]
19pub enum TabsOrientation {
20 Top,
21 Bottom,
22 Side,
23}
24
25#[derive(Copy, Clone, Debug, PartialEq)]
26pub struct Tab {
27 pub id: WidgetId,
28 pub rect: LayoutRect,
29 pub label: StringKey,
30 pub disabled: bool,
31 pub closable: bool,
32 pub icon: Option<IconId>,
33}
34
35impl Tab {
36 #[must_use]
37 pub const fn new(id: WidgetId, rect: LayoutRect, label: StringKey) -> Self {
38 Self {
39 id,
40 rect,
41 label,
42 disabled: false,
43 closable: false,
44 icon: None,
45 }
46 }
47
48 #[must_use]
49 pub const fn disabled(self, disabled: bool) -> Self {
50 Self { disabled, ..self }
51 }
52
53 #[must_use]
54 pub const fn closable(self, closable: bool) -> Self {
55 Self { closable, ..self }
56 }
57
58 #[must_use]
59 pub const fn with_icon(self, icon: IconId) -> Self {
60 Self {
61 icon: Some(icon),
62 ..self
63 }
64 }
65}
66
67#[derive(Copy, Clone, Debug, PartialEq)]
68pub struct Tabs<'a> {
69 pub id: WidgetId,
70 pub orientation: TabsOrientation,
71 pub label: StringKey,
72 pub tabs: &'a [Tab],
73 pub active: WidgetId,
74}
75
76impl<'a> Tabs<'a> {
77 #[must_use]
78 pub const fn new(
79 id: WidgetId,
80 orientation: TabsOrientation,
81 label: StringKey,
82 tabs: &'a [Tab],
83 active: WidgetId,
84 ) -> Self {
85 Self {
86 id,
87 orientation,
88 label,
89 tabs,
90 active,
91 }
92 }
93}
94
95#[derive(Clone, Debug, PartialEq)]
96pub struct TabsResponse {
97 pub activated: Option<WidgetId>,
98 pub closed: Option<WidgetId>,
99 pub paint: Vec<WidgetPaint>,
100}
101
102const CLOSE_BUTTON_PX: f32 = 14.0;
103const CLOSE_BUTTON_GAP: f32 = 6.0;
104
105#[must_use]
106pub fn show_tabs(ctx: &mut FrameCtx<'_>, tabs: Tabs<'_>) -> TabsResponse {
107 let Tabs {
108 id: tabs_id,
109 orientation,
110 label,
111 tabs: items,
112 active,
113 } = tabs;
114 let active_present = items.iter().any(|t| t.id == active && !t.disabled);
115 let tab_stop = items
116 .iter()
117 .find(|t| t.id == active && !t.disabled)
118 .or_else(|| items.iter().find(|t| !t.disabled))
119 .map(|t| t.id);
120 if let Some(stop) = tab_stop {
121 ctx.focus.register_tab_stop(stop);
122 }
123 if let Some(strip_rect) = items.iter().map(|t| t.rect).reduce(LayoutRect::union) {
124 ctx.a11y.push(
125 tabs_id,
126 strip_rect,
127 AccessNode::new(Role::TabList).with_label(label),
128 );
129 }
130 let mut paint = Vec::new();
131 let folded = items
132 .iter()
133 .map(|tab| draw_tab(ctx, tabs_id, tab, tab.id == active && active_present))
134 .fold(
135 (None::<WidgetId>, None::<WidgetId>),
136 |(activated, closed), per_tab| {
137 let new_activated = activated.or(per_tab.activated);
138 let new_closed = closed.or(per_tab.closed);
139 paint.extend(per_tab.paint);
140 (new_activated, new_closed)
141 },
142 );
143 let in_strip_focus = ctx
144 .focus
145 .focused()
146 .is_some_and(|f| items.iter().any(|t| t.id == f));
147 let activated_via_keys = if in_strip_focus {
148 handle_keyboard(ctx, items, orientation)
149 } else {
150 None
151 };
152 let activated = folded.0.or(activated_via_keys);
153 TabsResponse {
154 activated,
155 closed: folded.1,
156 paint,
157 }
158}
159
160struct PerTab {
161 activated: Option<WidgetId>,
162 closed: Option<WidgetId>,
163 paint: Vec<WidgetPaint>,
164}
165
166fn draw_tab(ctx: &mut FrameCtx<'_>, tabs_id: WidgetId, tab: &Tab, is_active: bool) -> PerTab {
167 let interactive = !tab.disabled;
168 let interaction = ctx.interact(
169 InteractDeclaration::new(tab.id, tab.rect, Sense::INTERACTIVE)
170 .focusable(false)
171 .disabled(!interactive)
172 .active(is_active)
173 .a11y(
174 AccessNode::new(Role::Tab)
175 .with_label(tab.label)
176 .with_disabled(!interactive)
177 .with_selected(is_active),
178 ),
179 );
180 if interactive && interaction.click() {
181 ctx.focus.request_focus(tab.id);
182 }
183 let live_focused = ctx.is_focused(tab.id);
184 let mut paint = Vec::new();
185 paint.extend(tab_surface_paint(ctx, tab.rect, is_active, interaction));
186 let label_color = tab_label_color(ctx, is_active, tab.disabled);
187 if let Some(icon) = tab.icon {
188 paint.push(IconSlot::new(icon, TAB_ICON_PX).paint_in(
189 label_rect(tab.rect, tab.closable),
190 IconTint::from_disabled(tab.disabled),
191 ));
192 } else {
193 paint.push(WidgetPaint::Label {
194 rect: label_rect(tab.rect, tab.closable),
195 text: LabelText::Key(tab.label),
196 color: label_color,
197 role: ctx.theme().typography.label,
198 });
199 }
200 let mut closed = None;
201 let close_id = tabs_id.child_indexed(WidgetKey::new("close"), tab_close_index(tab.id));
202 if tab.closable {
203 let close_rect = close_button_rect(tab.rect);
204 let close_interaction = ctx.interact(
205 InteractDeclaration::new(close_id, close_rect, Sense::INTERACTIVE)
206 .focusable(false)
207 .disabled(!interactive)
208 .a11y(
209 AccessNode::new(Role::Button)
210 .with_label(StringKey::new("tabs.close"))
211 .with_disabled(!interactive),
212 ),
213 );
214 if interactive && close_interaction.click() {
215 closed = Some(tab.id);
216 }
217 paint.push(WidgetPaint::Surface {
218 rect: close_rect,
219 fill: if close_interaction.hover() && interactive {
220 ctx.theme().colors.neutral.step(Step12::HOVER_BG)
221 } else {
222 Color::TRANSPARENT
223 },
224 border: None,
225 radius: ctx.theme().radius.sm,
226 elevation: None,
227 });
228 paint.push(WidgetPaint::Icon {
229 rect: close_rect,
230 icon: IconId::Cross,
231 tint: IconTint::Solid(tab_label_color(ctx, is_active, tab.disabled)),
232 });
233 }
234 push_focus_ring(
235 ctx,
236 &mut paint,
237 tab.rect,
238 ctx.theme().radius.sm,
239 live_focused,
240 );
241 let activated = (interactive && !is_active && interaction.click()).then_some(tab.id);
242 PerTab {
243 activated,
244 closed,
245 paint,
246 }
247}
248
249fn tab_close_index(tab_id: WidgetId) -> u64 {
250 tab_id.raw().get()
251}
252
253fn label_rect(tab: LayoutRect, closable: bool) -> LayoutRect {
254 if !closable {
255 return tab;
256 }
257 let trim = LayoutPx::new(CLOSE_BUTTON_PX + CLOSE_BUTTON_GAP);
258 let width = (tab.size.width.value() - trim.value()).max(0.0);
259 LayoutRect::new(
260 tab.origin,
261 LayoutSize::new(LayoutPx::new(width), tab.size.height),
262 )
263}
264
265fn close_button_rect(tab: LayoutRect) -> LayoutRect {
266 let pad = (tab.size.height.value() - CLOSE_BUTTON_PX).max(0.0) / 2.0;
267 let x = tab.origin.x.value() + tab.size.width.value() - CLOSE_BUTTON_PX - pad;
268 let y = tab.origin.y.value() + pad;
269 LayoutRect::new(
270 LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)),
271 LayoutSize::new(
272 LayoutPx::new(CLOSE_BUTTON_PX),
273 LayoutPx::new(CLOSE_BUTTON_PX),
274 ),
275 )
276}
277
278fn tab_surface_paint(
279 ctx: &FrameCtx<'_>,
280 rect: LayoutRect,
281 active: bool,
282 interaction: Interaction,
283) -> Vec<WidgetPaint> {
284 let neutral = ctx.theme().colors.neutral;
285 if active && !interaction.disabled() {
286 return vec![WidgetPaint::Surface {
287 rect,
288 fill: ctx.theme().colors.surface(SurfaceLevel::L0),
289 border: Some(Border {
290 width: StrokeWidth::HAIRLINE,
291 color: neutral.step(Step12::SUBTLE_BORDER),
292 }),
293 radius: ctx.theme().radius.none,
294 elevation: None,
295 }];
296 }
297 let fill = if interaction.disabled() {
298 Color::TRANSPARENT
299 } else if interaction.pressed() {
300 neutral.step(Step12::SELECTED_BG)
301 } else if interaction.hover() {
302 neutral.step(Step12::HOVER_BG)
303 } else {
304 Color::TRANSPARENT
305 };
306 vec![WidgetPaint::Surface {
307 rect,
308 fill,
309 border: None,
310 radius: ctx.theme().radius.none,
311 elevation: None,
312 }]
313}
314
315fn tab_label_color(ctx: &FrameCtx<'_>, active: bool, disabled: bool) -> Color {
316 if disabled {
317 ctx.theme().colors.text_disabled()
318 } else if active {
319 ctx.theme().colors.text_primary()
320 } else {
321 ctx.theme().colors.text_secondary()
322 }
323}
324
325fn handle_keyboard(
326 ctx: &mut FrameCtx<'_>,
327 items: &[Tab],
328 orientation: TabsOrientation,
329) -> Option<WidgetId> {
330 let (prev, next) = match orientation {
331 TabsOrientation::Top | TabsOrientation::Bottom => {
332 (NamedKey::ArrowLeft, NamedKey::ArrowRight)
333 }
334 TabsOrientation::Side => (NamedKey::ArrowUp, NamedKey::ArrowDown),
335 };
336 let event = take_key(
337 ctx.input,
338 &[
339 TakeKey::named(prev),
340 TakeKey::named(next),
341 TakeKey::named(NamedKey::Home),
342 TakeKey::named(NamedKey::End),
343 TakeKey::named(NamedKey::Enter),
344 TakeKey::named(NamedKey::Space),
345 ],
346 )?;
347 let focused = ctx.focus.focused()?;
348 let current = items.iter().position(|t| t.id == focused)?;
349 match event.code {
350 KeyCode::Named(NamedKey::Enter | NamedKey::Space) => {
351 items.get(current).filter(|t| !t.disabled).map(|t| t.id)
352 }
353 KeyCode::Named(key) => {
354 let target = step_to(items, current, key, prev, next)?;
355 ctx.focus.request_focus(items[target].id);
356 None
357 }
358 KeyCode::Char(_) => None,
359 }
360}
361
362fn step_to(
363 items: &[Tab],
364 current: usize,
365 key: NamedKey,
366 prev: NamedKey,
367 next: NamedKey,
368) -> Option<usize> {
369 let len = items.len();
370 if len == 0 {
371 return None;
372 }
373 let candidates: Vec<usize> = if key == prev {
374 (1..=len)
375 .map(|delta| (current + len - delta) % len)
376 .collect()
377 } else if key == next {
378 (1..=len).map(|delta| (current + delta) % len).collect()
379 } else if matches!(key, NamedKey::Home) {
380 (0..len).collect()
381 } else if matches!(key, NamedKey::End) {
382 (0..len).rev().collect()
383 } else {
384 return None;
385 };
386 candidates.into_iter().find(|&idx| !items[idx].disabled)
387}
388
389#[cfg(test)]
390mod tests {
391 use std::sync::Arc;
392
393 use super::{Tab, Tabs, TabsOrientation, show_tabs};
394 use crate::focus::FocusManager;
395 use crate::frame::FrameCtx;
396 use crate::hit_test::{HitFrame, HitState, resolve};
397 use crate::hotkey::HotkeyTable;
398 use crate::input::{
399 FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton,
400 PointerButtonMask, PointerSample,
401 };
402 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
403 use crate::strings::{StringKey, StringTable};
404 use crate::theme::Theme;
405 use crate::widget_id::{WidgetId, WidgetKey};
406
407 fn tabs_id() -> WidgetId {
408 WidgetId::ROOT.child(WidgetKey::new("tabs"))
409 }
410
411 fn make_tabs() -> Vec<Tab> {
412 let label_keys = ["tabs.first", "tabs.second", "tabs.third"];
413 label_keys
414 .iter()
415 .enumerate()
416 .map(|(idx, key)| {
417 #[allow(clippy::cast_precision_loss, reason = "small index fits f32 mantissa")]
418 let i_f32 = idx as f32;
419 let id = tabs_id().child_indexed(WidgetKey::new("t"), idx as u64);
420 let rect = LayoutRect::new(
421 LayoutPos::new(LayoutPx::new(i_f32 * 80.0), LayoutPx::ZERO),
422 LayoutSize::new(LayoutPx::new(80.0), LayoutPx::new(28.0)),
423 );
424 Tab::new(id, rect, StringKey::new(key)).closable(true)
425 })
426 .collect()
427 }
428
429 fn render_with(
430 items: &[Tab],
431 active: WidgetId,
432 focus: &mut FocusManager,
433 snap: &mut InputSnapshot,
434 prev: &HitState,
435 ) -> (super::TabsResponse, HitState) {
436 let theme = Arc::new(Theme::light());
437 let table = HotkeyTable::new();
438 let mut hits = HitFrame::new();
439 let response = {
440 let mut shaper = bone_text::Shaper::new();
441 let mut a11y = crate::a11y::AccessTreeBuilder::new();
442 let mut ctx = FrameCtx::new(
443 theme,
444 snap,
445 focus,
446 &table,
447 StringTable::empty(),
448 &mut hits,
449 prev,
450 &mut a11y,
451 &mut shaper,
452 );
453 show_tabs(
454 &mut ctx,
455 Tabs::new(
456 tabs_id(),
457 TabsOrientation::Top,
458 StringKey::new("test.tabs"),
459 items,
460 active,
461 ),
462 )
463 };
464 let next_state = resolve(prev, &hits, snap, focus.focused());
465 (response, next_state)
466 }
467
468 fn pointer_press(pos: LayoutPos) -> InputSnapshot {
469 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
470 s.pointer = Some(PointerSample::new(pos));
471 s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
472 s
473 }
474
475 fn pointer_release(pos: LayoutPos) -> InputSnapshot {
476 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
477 s.pointer = Some(PointerSample::new(pos));
478 s.buttons_released = PointerButtonMask::just(PointerButton::Primary);
479 s
480 }
481
482 fn click_at(
483 items: &[Tab],
484 active: WidgetId,
485 focus: &mut FocusManager,
486 prev: &mut HitState,
487 pos: LayoutPos,
488 ) -> super::TabsResponse {
489 let mut last_response: Option<super::TabsResponse> = None;
490 [pointer_press(pos), pointer_release(pos), pointer_idle(pos)]
491 .into_iter()
492 .for_each(|mut snap| {
493 let (response, next) = render_with(items, active, focus, &mut snap, prev);
494 last_response = Some(response);
495 *prev = next;
496 });
497 let Some(response) = last_response else {
498 panic!("three snapshots produced a response");
499 };
500 response
501 }
502
503 fn pointer_idle(pos: LayoutPos) -> InputSnapshot {
504 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
505 s.pointer = Some(PointerSample::new(pos));
506 s
507 }
508
509 #[test]
510 fn click_on_inactive_tab_activates_it() {
511 let items = make_tabs();
512 let mut focus = FocusManager::new();
513 let mut prev = HitState::new();
514 let response = click_at(
515 &items,
516 items[0].id,
517 &mut focus,
518 &mut prev,
519 LayoutPos::new(LayoutPx::new(100.0), LayoutPx::new(10.0)),
520 );
521 assert_eq!(response.activated, Some(items[1].id));
522 assert!(response.closed.is_none());
523 }
524
525 #[test]
526 fn click_on_close_button_emits_closed_not_activated() {
527 let items = make_tabs();
528 let mut focus = FocusManager::new();
529 let mut prev = HitState::new();
530 let close_pos = LayoutPos::new(LayoutPx::new(160.0 + 64.0), LayoutPx::new(14.0));
531 let response = click_at(&items, items[0].id, &mut focus, &mut prev, close_pos);
532 assert_eq!(response.closed, Some(items[2].id));
533 assert!(response.activated.is_none());
534 }
535
536 #[test]
537 fn click_on_active_tab_does_not_re_activate() {
538 let items = make_tabs();
539 let mut focus = FocusManager::new();
540 let mut prev = HitState::new();
541 let response = click_at(
542 &items,
543 items[1].id,
544 &mut focus,
545 &mut prev,
546 LayoutPos::new(LayoutPx::new(100.0), LayoutPx::new(10.0)),
547 );
548 assert!(response.activated.is_none());
549 }
550
551 fn focused_setup(target: WidgetId) -> FocusManager {
552 let mut focus = FocusManager::new();
553 focus.register_focusable(target);
554 focus.request_focus(target);
555 focus.end_frame();
556 focus
557 }
558
559 #[test]
560 fn arrow_right_roves_focus_skipping_disabled() {
561 let mut items = make_tabs();
562 items[1] = items[1].disabled(true);
563 let first_id = items[0].id;
564 let third_id = items[2].id;
565 let mut focus = focused_setup(first_id);
566 let prev = HitState::new();
567 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
568 snap.keys_pressed.push(KeyEvent::new(
569 KeyCode::Named(NamedKey::ArrowRight),
570 ModifierMask::NONE,
571 ));
572 let _ = render_with(&items, first_id, &mut focus, &mut snap, &prev);
573 assert_eq!(focus.focused(), Some(third_id));
574 }
575
576 #[test]
577 fn enter_on_focused_tab_activates_it() {
578 let items = make_tabs();
579 let second_id = items[1].id;
580 let mut focus = focused_setup(second_id);
581 let prev = HitState::new();
582 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
583 snap.keys_pressed.push(KeyEvent::new(
584 KeyCode::Named(NamedKey::Enter),
585 ModifierMask::NONE,
586 ));
587 let (response, _) = render_with(&items, items[0].id, &mut focus, &mut snap, &prev);
588 assert_eq!(response.activated, Some(second_id));
589 }
590
591 #[test]
592 fn arrow_wraps_at_end() {
593 let items = make_tabs();
594 let last_id = items[2].id;
595 let first_id = items[0].id;
596 let mut focus = focused_setup(last_id);
597 let prev = HitState::new();
598 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
599 snap.keys_pressed.push(KeyEvent::new(
600 KeyCode::Named(NamedKey::ArrowRight),
601 ModifierMask::NONE,
602 ));
603 let _ = render_with(&items, items[0].id, &mut focus, &mut snap, &prev);
604 assert_eq!(focus.focused(), Some(first_id));
605 }
606
607 #[test]
608 fn home_jumps_to_first_enabled() {
609 let mut items = make_tabs();
610 items[0] = items[0].disabled(true);
611 let last_id = items[2].id;
612 let second_id = items[1].id;
613 let mut focus = focused_setup(last_id);
614 let prev = HitState::new();
615 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
616 snap.keys_pressed.push(KeyEvent::new(
617 KeyCode::Named(NamedKey::Home),
618 ModifierMask::NONE,
619 ));
620 let _ = render_with(&items, last_id, &mut focus, &mut snap, &prev);
621 assert_eq!(focus.focused(), Some(second_id));
622 }
623
624 #[test]
625 fn click_on_inactive_tab_moves_focus_to_clicked_tab() {
626 let items = make_tabs();
627 let mut focus = FocusManager::new();
628 let mut prev = HitState::new();
629 let _ = click_at(
630 &items,
631 items[0].id,
632 &mut focus,
633 &mut prev,
634 LayoutPos::new(LayoutPx::new(100.0), LayoutPx::new(10.0)),
635 );
636 assert_eq!(focus.focused(), Some(items[1].id));
637 }
638
639 #[test]
640 fn only_active_tab_is_in_tab_order() {
641 let items = make_tabs();
642 let mut focus = FocusManager::new();
643 let prev = HitState::new();
644 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
645 let _ = render_with(&items, items[1].id, &mut focus, &mut snap, &prev);
646 let stops: Vec<WidgetId> = focus.tab_stops().iter().map(|(id, _)| *id).collect();
647 assert_eq!(stops, vec![items[1].id]);
648 }
649}