Another project
1use crate::a11y::{AccessNode, Role};
2use crate::frame::FrameCtx;
3use crate::layout::{LayoutDirection, LayoutPos, LayoutPx, LayoutRect, LayoutSize};
4use crate::strings::StringKey;
5use crate::theme::{Border, Step12, StrokeWidth};
6use crate::widget_id::{WidgetId, WidgetKey};
7
8use bone_types::IconId;
9
10use super::paint::{IconSlot, WidgetPaint, estimate_label_width_px};
11use super::tabs::{Tab, Tabs, TabsOrientation, show_tabs};
12use super::toolbar::{Toolbar, ToolbarItem, ToolbarOverflowConfig, show_toolbar};
13
14#[derive(Copy, Clone, Debug, PartialEq, Eq)]
15pub enum RibbonIconSize {
16 Large,
17 Small,
18}
19
20impl RibbonIconSize {
21 #[must_use]
22 pub const fn item_px(self) -> LayoutPx {
23 match self {
24 Self::Large => LayoutPx::new(56.0),
25 Self::Small => LayoutPx::new(28.0),
26 }
27 }
28
29 #[must_use]
30 pub const fn icon_px(self) -> LayoutPx {
31 match self {
32 Self::Large => LayoutPx::new(24.0),
33 Self::Small => LayoutPx::new(16.0),
34 }
35 }
36
37 #[must_use]
38 pub const fn slot(self, icon: IconId) -> IconSlot {
39 IconSlot::new(icon, self.icon_px())
40 }
41
42 #[must_use]
43 pub const fn rows(self) -> usize {
44 match self {
45 Self::Large => 1,
46 Self::Small => 2,
47 }
48 }
49}
50
51#[derive(Clone, Debug, PartialEq)]
52pub struct RibbonGroup {
53 pub id: WidgetId,
54 pub label: StringKey,
55 pub items: Vec<ToolbarItem>,
56 pub icon_size: RibbonIconSize,
57 pub min_width: LayoutPx,
58 pub width: LayoutPx,
59 pub overflow_open: bool,
60 pub overflow_label: Option<StringKey>,
61}
62
63#[derive(Clone, Debug, PartialEq)]
64pub struct RibbonTab {
65 pub id: WidgetId,
66 pub label: StringKey,
67 pub disabled: bool,
68 pub closable: bool,
69 pub groups: Vec<RibbonGroup>,
70}
71
72impl RibbonTab {
73 #[must_use]
74 pub const fn new(id: WidgetId, label: StringKey, groups: Vec<RibbonGroup>) -> Self {
75 Self {
76 id,
77 label,
78 disabled: false,
79 closable: false,
80 groups,
81 }
82 }
83
84 #[must_use]
85 pub const fn closable(mut self, closable: bool) -> Self {
86 self.closable = closable;
87 self
88 }
89
90 #[must_use]
91 pub const fn disabled(mut self, disabled: bool) -> Self {
92 self.disabled = disabled;
93 self
94 }
95}
96
97#[derive(Copy, Clone, Debug, PartialEq)]
98pub struct Ribbon<'a> {
99 pub id: WidgetId,
100 pub rect: LayoutRect,
101 pub label: StringKey,
102 pub tabs: &'a [RibbonTab],
103 pub active: WidgetId,
104 pub tab_strip_height: LayoutPx,
105 pub group_gap: LayoutPx,
106 pub group_padding: LayoutPx,
107}
108
109impl<'a> Ribbon<'a> {
110 #[must_use]
111 pub const fn new(
112 id: WidgetId,
113 rect: LayoutRect,
114 label: StringKey,
115 tabs: &'a [RibbonTab],
116 active: WidgetId,
117 ) -> Self {
118 Self {
119 id,
120 rect,
121 label,
122 tabs,
123 active,
124 tab_strip_height: LayoutPx::new(28.0),
125 group_gap: LayoutPx::new(8.0),
126 group_padding: LayoutPx::new(8.0),
127 }
128 }
129}
130
131#[derive(Clone, Debug, PartialEq)]
132pub struct RibbonResponse {
133 pub activated_tab: Option<WidgetId>,
134 pub closed_tab: Option<WidgetId>,
135 pub activated_tool: Option<WidgetId>,
136 pub overflow_toggled: Vec<WidgetId>,
137 pub popup_consumed_click: bool,
138 pub paint: Vec<WidgetPaint>,
139 pub popover_paint: Vec<WidgetPaint>,
140}
141
142#[must_use]
143pub fn show_ribbon(ctx: &mut FrameCtx<'_>, ribbon: Ribbon<'_>) -> RibbonResponse {
144 let Ribbon {
145 id,
146 rect,
147 label,
148 tabs,
149 active,
150 tab_strip_height,
151 group_gap,
152 group_padding,
153 } = ribbon;
154 let body_height =
155 LayoutPx::saturating_nonneg(rect.size.height.value() - tab_strip_height.value());
156 let body_rect = LayoutRect::new(rect.origin, LayoutSize::new(rect.size.width, body_height));
157 let strip_rect = LayoutRect::new(
158 LayoutPos::new(
159 rect.origin.x,
160 LayoutPx::new(rect.origin.y.value() + body_height.value()),
161 ),
162 LayoutSize::new(rect.size.width, tab_strip_height),
163 );
164 let direction = ctx.direction();
165 let label_font_px = ctx.theme().typography.label.size.as_px_f32();
166 let tab_views: Vec<Tab> = build_tab_strip(ctx, tabs, strip_rect, label_font_px)
167 .into_iter()
168 .map(|t| Tab {
169 rect: t.rect.mirror_horizontally_within(strip_rect, direction),
170 ..t
171 })
172 .collect();
173 ctx.a11y
174 .push(id, rect, AccessNode::new(Role::TabPanel).with_label(label));
175 let mut paint = vec![WidgetPaint::Surface {
176 rect,
177 fill: ctx.theme().colors.surface(crate::theme::SurfaceLevel::L1),
178 border: Some(Border {
179 width: StrokeWidth::HAIRLINE,
180 color: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER),
181 }),
182 radius: ctx.theme().radius.none,
183 elevation: None,
184 }];
185 let tabs_response = show_tabs(
186 ctx,
187 Tabs::new(
188 id.child(WidgetKey::new("tabs")),
189 TabsOrientation::Bottom,
190 label,
191 tab_views.as_slice(),
192 active,
193 ),
194 );
195 paint.extend(tabs_response.paint);
196 let mut activated_tool: Option<WidgetId> = None;
197 let mut overflow_toggled: Vec<WidgetId> = Vec::new();
198 let mut popover_paint: Vec<WidgetPaint> = Vec::new();
199 let mut popup_consumed_click = false;
200 if let Some(active_tab) = tabs.iter().find(|t| t.id == active) {
201 let groups_paint = render_groups(
202 ctx,
203 &active_tab.groups,
204 GroupLayout {
205 body_rect,
206 group_gap,
207 group_padding,
208 direction,
209 },
210 &mut activated_tool,
211 &mut overflow_toggled,
212 &mut popover_paint,
213 &mut popup_consumed_click,
214 );
215 paint.extend(groups_paint);
216 }
217 RibbonResponse {
218 activated_tab: tabs_response.activated,
219 closed_tab: tabs_response.closed,
220 activated_tool,
221 overflow_toggled,
222 popup_consumed_click,
223 paint,
224 popover_paint,
225 }
226}
227
228const RIBBON_TAB_PADDING_PX: f32 = 14.0;
229
230fn build_tab_strip(
231 ctx: &FrameCtx<'_>,
232 tabs: &[RibbonTab],
233 strip_rect: LayoutRect,
234 label_font_px: f32,
235) -> Vec<Tab> {
236 let strip_max_x = strip_rect.origin.x.value() + strip_rect.size.width.value();
237 tabs.iter()
238 .scan(strip_rect.origin.x.value(), |x, t| {
239 if *x >= strip_max_x {
240 return None;
241 }
242 let resolved = ctx.strings.resolve(t.label);
243 let desired = estimate_label_width_px(resolved, label_font_px, RIBBON_TAB_PADDING_PX);
244 let width = desired.min(strip_max_x - *x).max(0.0);
245 let rect = LayoutRect::new(
246 LayoutPos::new(LayoutPx::new(*x), strip_rect.origin.y),
247 LayoutSize::new(LayoutPx::new(width), strip_rect.size.height),
248 );
249 *x += width;
250 Some(
251 Tab::new(t.id, rect, t.label)
252 .closable(t.closable)
253 .disabled(t.disabled),
254 )
255 })
256 .collect()
257}
258
259#[derive(Copy, Clone)]
260struct GroupLayout {
261 body_rect: LayoutRect,
262 group_gap: LayoutPx,
263 group_padding: LayoutPx,
264 direction: LayoutDirection,
265}
266
267fn render_groups(
268 ctx: &mut FrameCtx<'_>,
269 groups: &[RibbonGroup],
270 layout: GroupLayout,
271 activated_tool: &mut Option<WidgetId>,
272 overflow_toggled: &mut Vec<WidgetId>,
273 popover_paint: &mut Vec<WidgetPaint>,
274 popup_consumed_click: &mut bool,
275) -> Vec<WidgetPaint> {
276 let GroupLayout {
277 body_rect,
278 group_gap,
279 group_padding,
280 direction,
281 } = layout;
282 let mut paint = Vec::new();
283 let raw_layouts = group_rects(body_rect, groups, group_gap);
284 let layouts: Vec<LayoutRect> = raw_layouts
285 .iter()
286 .map(|r| r.mirror_horizontally_within(body_rect, direction))
287 .collect();
288 paint.extend(group_dividers(
289 &raw_layouts,
290 body_rect,
291 group_gap,
292 direction,
293 ctx,
294 ));
295 groups
296 .iter()
297 .zip(layouts.iter())
298 .for_each(|(group, group_rect)| {
299 ctx.a11y.push(
300 group.id,
301 *group_rect,
302 AccessNode::new(Role::Group).with_label(group.label),
303 );
304 let toolbar_rect = inner_toolbar_rect(*group_rect, group_padding);
305 let toolbar = Toolbar::horizontal(
306 group.id.child(WidgetKey::new("toolbar")),
307 toolbar_rect,
308 group.label,
309 &group.items,
310 group.icon_size.item_px(),
311 LayoutPx::new(4.0),
312 )
313 .with_rows(group.icon_size.rows());
314 let toolbar = match group.overflow_label {
315 Some(label) => toolbar.with_overflow(
316 ToolbarOverflowConfig::new(label).with_open(group.overflow_open),
317 ),
318 None => toolbar,
319 };
320 let response = show_toolbar(ctx, toolbar);
321 paint.extend(response.paint);
322 popover_paint.extend(response.popover_paint);
323 *popup_consumed_click |= response.popup_consumed_click;
324 if let Some(activated) = response.activated
325 && activated_tool.is_none()
326 {
327 *activated_tool = Some(activated);
328 }
329 if response.overflow_toggled {
330 overflow_toggled.push(group.id);
331 }
332 });
333 paint
334}
335
336fn group_dividers(
337 layouts: &[LayoutRect],
338 body: LayoutRect,
339 gap: LayoutPx,
340 direction: LayoutDirection,
341 ctx: &FrameCtx<'_>,
342) -> Vec<WidgetPaint> {
343 let thickness = StrokeWidth::HAIRLINE.value_px();
344 let color = ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER);
345 let inset_y = body.size.height.value() * 0.15;
346 layouts
347 .iter()
348 .take(layouts.len().saturating_sub(1))
349 .map(|rect| {
350 let x = rect.origin.x.value() + rect.size.width.value() + gap.value() * 0.5
351 - thickness * 0.5;
352 LayoutRect::new(
353 LayoutPos::new(
354 LayoutPx::new(x),
355 LayoutPx::new(body.origin.y.value() + inset_y),
356 ),
357 LayoutSize::new(
358 LayoutPx::new(thickness),
359 LayoutPx::saturating_nonneg(body.size.height.value() - 2.0 * inset_y),
360 ),
361 )
362 .mirror_horizontally_within(body, direction)
363 })
364 .map(|rect| WidgetPaint::Surface {
365 rect,
366 fill: color,
367 border: None,
368 radius: ctx.theme().radius.none,
369 elevation: None,
370 })
371 .collect()
372}
373
374fn group_rects(body: LayoutRect, groups: &[RibbonGroup], gap: LayoutPx) -> Vec<LayoutRect> {
375 let n = groups.len();
376 if n == 0 {
377 return Vec::new();
378 }
379 #[allow(
380 clippy::cast_precision_loss,
381 reason = "ribbon group counts fit in f32 mantissa"
382 )]
383 let total_gap = gap.value() * n.saturating_sub(1) as f32;
384 let body_w = body.size.width.value();
385 let avail = (body_w - total_gap).max(0.0);
386 let widths = allocate_group_widths(groups, avail);
387 widths
388 .into_iter()
389 .scan(body.origin.x.value(), |x, w| {
390 let rect = LayoutRect::new(
391 LayoutPos::new(LayoutPx::new(*x), body.origin.y),
392 LayoutSize::new(LayoutPx::saturating_nonneg(w), body.size.height),
393 );
394 *x += w + gap.value();
395 Some(rect)
396 })
397 .collect()
398}
399
400fn allocate_group_widths(groups: &[RibbonGroup], avail: f32) -> Vec<f32> {
401 let min_total: f32 = groups.iter().map(|g| g.min_width.value()).sum();
402 if avail <= min_total {
403 let ratio = if min_total > 0.0 {
404 avail / min_total
405 } else {
406 0.0
407 };
408 return groups.iter().map(|g| g.min_width.value() * ratio).collect();
409 }
410 let extra = avail - min_total;
411 let wants: Vec<f32> = groups
412 .iter()
413 .map(|g| (g.width.value() - g.min_width.value()).max(0.0))
414 .collect();
415 let want_total: f32 = wants.iter().sum();
416 if want_total <= 0.0 {
417 return groups.iter().map(|g| g.min_width.value()).collect();
418 }
419 groups
420 .iter()
421 .zip(wants.iter())
422 .map(|(g, want)| {
423 let bonus = (extra * want / want_total).min(*want);
424 g.min_width.value() + bonus
425 })
426 .collect()
427}
428
429fn inner_toolbar_rect(group: LayoutRect, padding: LayoutPx) -> LayoutRect {
430 let avail_height = (group.size.height.value() - 2.0 * padding.value()).max(0.0);
431 LayoutRect::new(
432 LayoutPos::new(
433 LayoutPx::new(group.origin.x.value() + padding.value()),
434 LayoutPx::new(group.origin.y.value() + padding.value()),
435 ),
436 LayoutSize::new(
437 LayoutPx::saturating_nonneg(group.size.width.value() - 2.0 * padding.value()),
438 LayoutPx::new(avail_height),
439 ),
440 )
441}
442
443#[cfg(test)]
444mod tests {
445 use std::sync::Arc;
446
447 use super::{Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, show_ribbon};
448 use crate::focus::FocusManager;
449 use crate::frame::FrameCtx;
450 use crate::hit_test::{HitFrame, HitState, resolve};
451 use crate::hotkey::HotkeyTable;
452 use crate::input::{
453 FrameInstant, InputSnapshot, PointerButton, PointerButtonMask, PointerSample,
454 };
455 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
456 use crate::strings::{StringKey, StringTable};
457 use crate::theme::Theme;
458 use crate::widget_id::{WidgetId, WidgetKey};
459 use crate::widgets::ToolbarItem;
460
461 fn ribbon_id() -> WidgetId {
462 WidgetId::ROOT.child(WidgetKey::new("ribbon"))
463 }
464
465 fn make_group(label: &'static str, items: usize, min: f32, preferred: f32) -> RibbonGroup {
466 let id = ribbon_id().child(WidgetKey::new(label));
467 let item_keys: Vec<&'static str> = (0..items)
468 .map(|i| match i {
469 0 => "i0",
470 1 => "i1",
471 2 => "i2",
472 3 => "i3",
473 _ => "ix",
474 })
475 .collect();
476 RibbonGroup {
477 id,
478 label: StringKey::new("ribbon.group"),
479 items: item_keys
480 .into_iter()
481 .map(|k| ToolbarItem::new(id.child(WidgetKey::new(k)), StringKey::new(k)))
482 .collect(),
483 icon_size: RibbonIconSize::Large,
484 min_width: LayoutPx::new(min),
485 width: LayoutPx::new(preferred),
486 overflow_open: false,
487 overflow_label: None,
488 }
489 }
490
491 fn body_rect(width: f32) -> LayoutRect {
492 LayoutRect::new(
493 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
494 LayoutSize::new(LayoutPx::new(width), LayoutPx::new(80.0)),
495 )
496 }
497
498 #[test]
499 fn allocator_gives_every_group_at_least_min_when_avail_fits_all_min() {
500 let groups = vec![
501 make_group("a", 12, 132.0, 1500.0),
502 make_group("b", 1, 130.0, 130.0),
503 make_group("c", 10, 76.0, 800.0),
504 ];
505 let widths = super::allocate_group_widths(&groups, 600.0);
506 assert!(widths[0] >= 132.0, "entity min");
507 assert!(widths[1] >= 130.0, "dim min");
508 assert!(widths[2] >= 76.0, "rel min");
509 }
510
511 #[test]
512 fn allocator_caps_at_preferred_for_small_demand_groups() {
513 let groups = vec![
514 make_group("entities", 12, 132.0, 1500.0),
515 make_group("dimensions", 1, 130.0, 130.0),
516 make_group("relations", 10, 76.0, 800.0),
517 ];
518 let widths = super::allocate_group_widths(&groups, 5000.0);
519 assert!(
520 (widths[1] - 130.0).abs() < 1e-3,
521 "dimensions never exceeds preferred",
522 );
523 }
524
525 #[test]
526 fn allocator_distributes_remainder_proportionally_to_demand() {
527 let groups = vec![
528 make_group("a", 4, 100.0, 500.0),
529 make_group("b", 4, 100.0, 500.0),
530 ];
531 let widths = super::allocate_group_widths(&groups, 600.0);
532 assert!(
533 (widths[0] - widths[1]).abs() < 1e-3,
534 "equal demand → equal share"
535 );
536 assert!((widths[0] - 300.0).abs() < 1e-3);
537 }
538
539 #[test]
540 fn allocator_scales_down_proportionally_when_avail_below_min_total() {
541 let groups = vec![
542 make_group("a", 4, 200.0, 400.0),
543 make_group("b", 4, 100.0, 200.0),
544 ];
545 let widths = super::allocate_group_widths(&groups, 150.0);
546 let ratio = 150.0 / 300.0;
547 assert!((widths[0] - 200.0 * ratio).abs() < 1e-3);
548 assert!((widths[1] - 100.0 * ratio).abs() < 1e-3);
549 }
550
551 #[test]
552 fn allocator_handles_zero_avail() {
553 let groups = vec![make_group("a", 4, 100.0, 200.0)];
554 let widths = super::allocate_group_widths(&groups, 0.0);
555 assert!(widths[0].abs() < 1e-9);
556 }
557
558 #[test]
559 fn allocator_handles_zero_min_groups() {
560 let groups = vec![
561 make_group("a", 1, 0.0, 100.0),
562 make_group("b", 1, 0.0, 100.0),
563 ];
564 let widths = super::allocate_group_widths(&groups, 200.0);
565 assert!((widths[0] - 100.0).abs() < 1e-3);
566 assert!((widths[1] - 100.0).abs() < 1e-3);
567 }
568
569 #[test]
570 fn group_rects_lays_out_left_to_right_with_gap() {
571 let groups = vec![
572 make_group("a", 4, 100.0, 200.0),
573 make_group("b", 4, 100.0, 200.0),
574 ];
575 let body = body_rect(450.0);
576 let rects = super::group_rects(body, &groups, LayoutPx::new(8.0));
577 assert_eq!(rects.len(), 2);
578 assert!(rects[0].origin.x.value().abs() < 1e-9);
579 let expected_b_x = rects[0].size.width.value() + 8.0;
580 assert!((rects[1].origin.x.value() - expected_b_x).abs() < 1e-3);
581 }
582
583 #[test]
584 fn group_rects_assigns_nonzero_width_to_every_group_when_body_is_tight() {
585 let groups = vec![
586 make_group("entities", 12, 132.0, 1500.0),
587 make_group("dimensions", 1, 130.0, 130.0),
588 make_group("relations", 10, 76.0, 800.0),
589 ];
590 let body = body_rect(900.0);
591 let rects = super::group_rects(body, &groups, LayoutPx::new(8.0));
592 rects.iter().enumerate().for_each(|(i, r)| {
593 assert!(
594 r.size.width.value() > 0.0,
595 "group {i} got zero width on tight ribbon",
596 );
597 });
598 }
599
600 #[test]
601 fn group_rects_handles_empty_group_slice() {
602 let body = body_rect(800.0);
603 let rects = super::group_rects(body, &[], LayoutPx::new(8.0));
604 assert!(rects.is_empty());
605 }
606
607 fn make_ribbon_tab(name: &'static str) -> RibbonTab {
608 let tab_id = ribbon_id().child(WidgetKey::new(name));
609 let tool_id = tab_id.child(WidgetKey::new("tool"));
610 RibbonTab::new(
611 tab_id,
612 StringKey::new("ribbon.tab"),
613 vec![RibbonGroup {
614 id: tab_id.child(WidgetKey::new("group")),
615 label: StringKey::new("ribbon.group"),
616 items: vec![ToolbarItem::new(tool_id, StringKey::new("ribbon.tool"))],
617 icon_size: RibbonIconSize::Large,
618 min_width: LayoutPx::new(80.0),
619 width: LayoutPx::new(120.0),
620 overflow_open: false,
621 overflow_label: None,
622 }],
623 )
624 }
625
626 fn render(
627 tabs: &[RibbonTab],
628 active: WidgetId,
629 focus: &mut FocusManager,
630 snap: &mut InputSnapshot,
631 prev: &HitState,
632 ) -> (super::RibbonResponse, HitState) {
633 let theme = Arc::new(Theme::light());
634 let table = HotkeyTable::new();
635 let mut hits = HitFrame::new();
636 let rect = LayoutRect::new(
637 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
638 LayoutSize::new(LayoutPx::new(800.0), LayoutPx::new(120.0)),
639 );
640 let response = {
641 let mut shaper = bone_text::Shaper::new();
642 let mut a11y = crate::a11y::AccessTreeBuilder::new();
643 let mut ctx = FrameCtx::new(
644 theme,
645 snap,
646 focus,
647 &table,
648 StringTable::empty(),
649 &mut hits,
650 prev,
651 &mut a11y,
652 &mut shaper,
653 );
654 show_ribbon(
655 &mut ctx,
656 Ribbon::new(
657 ribbon_id(),
658 rect,
659 StringKey::new("test.ribbon"),
660 tabs,
661 active,
662 ),
663 )
664 };
665 let next = resolve(prev, &hits, snap, focus.focused());
666 (response, next)
667 }
668
669 #[test]
670 fn switching_tabs_emits_activated_tab() {
671 let tabs = vec![make_ribbon_tab("home"), make_ribbon_tab("sketch")];
672 let mut focus = FocusManager::new();
673 let mut prev = HitState::new();
674 let click_pos = LayoutPos::new(LayoutPx::new(150.0), LayoutPx::new(105.0));
675 let mut last: Option<super::RibbonResponse> = None;
676 [press(click_pos), release(click_pos), idle(click_pos)]
677 .into_iter()
678 .for_each(|mut snap| {
679 let (response, next) = render(&tabs, tabs[0].id, &mut focus, &mut snap, &prev);
680 last = Some(response);
681 prev = next;
682 });
683 let Some(response) = last else {
684 panic!("response missing")
685 };
686 assert_eq!(response.activated_tab, Some(tabs[1].id));
687 }
688
689 #[test]
690 fn click_tool_in_active_tab_emits_activated_tool() {
691 let tabs = vec![make_ribbon_tab("home")];
692 let mut focus = FocusManager::new();
693 let mut prev = HitState::new();
694 let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(60.0));
695 let mut last: Option<super::RibbonResponse> = None;
696 [press(click_pos), release(click_pos), idle(click_pos)]
697 .into_iter()
698 .for_each(|mut snap| {
699 let (response, next) = render(&tabs, tabs[0].id, &mut focus, &mut snap, &prev);
700 last = Some(response);
701 prev = next;
702 });
703 let Some(response) = last else {
704 panic!("response missing")
705 };
706 assert_eq!(response.activated_tool, Some(tabs[0].groups[0].items[0].id),);
707 }
708
709 #[test]
710 fn closing_a_closable_ribbon_tab_propagates_closed_tab() {
711 let make_closable =
712 |name: &'static str| -> RibbonTab { make_ribbon_tab(name).closable(true) };
713 let tabs = vec![make_closable("home"), make_closable("sketch")];
714 let mut focus = FocusManager::new();
715 let mut prev = HitState::new();
716 let label_font_px = 12.0_f32;
717 let tab_width = super::estimate_label_width_px(
718 "ribbon.tab",
719 label_font_px,
720 super::RIBBON_TAB_PADDING_PX,
721 );
722 let close_pad = (28.0 - 14.0) / 2.0;
723 let close_x = tab_width + tab_width - 14.0 - close_pad;
724 let strip_top = 120.0 - 28.0;
725 let close_y = strip_top + 28.0 / 2.0;
726 let close_pos = LayoutPos::new(LayoutPx::new(close_x), LayoutPx::new(close_y));
727 let mut last: Option<super::RibbonResponse> = None;
728 [press(close_pos), release(close_pos), idle(close_pos)]
729 .into_iter()
730 .for_each(|mut snap| {
731 let (response, next) = render(&tabs, tabs[0].id, &mut focus, &mut snap, &prev);
732 last = Some(response);
733 prev = next;
734 });
735 let Some(response) = last else {
736 panic!("response missing")
737 };
738 assert_eq!(response.closed_tab, Some(tabs[1].id));
739 assert!(response.activated_tab.is_none());
740 }
741
742 #[test]
743 fn arrow_then_enter_switches_active_tab_via_keyboard() {
744 use crate::input::{KeyCode, KeyEvent, ModifierMask, NamedKey};
745
746 let tabs = vec![make_ribbon_tab("home"), make_ribbon_tab("sketch")];
747 let mut focus = FocusManager::new();
748 let prev = HitState::new();
749 focus.request_focus(tabs[0].id);
750 let mut warm = InputSnapshot::idle(FrameInstant::ZERO);
751 let _ = render(&tabs, tabs[0].id, &mut focus, &mut warm, &prev);
752 assert_eq!(focus.focused(), Some(tabs[0].id));
753
754 let mut arrow = InputSnapshot::idle(FrameInstant::ZERO);
755 arrow.keys_pressed.push(KeyEvent::new(
756 KeyCode::Named(NamedKey::ArrowRight),
757 ModifierMask::NONE,
758 ));
759 let _ = render(&tabs, tabs[0].id, &mut focus, &mut arrow, &prev);
760 assert_eq!(focus.focused(), Some(tabs[1].id));
761
762 let mut enter = InputSnapshot::idle(FrameInstant::ZERO);
763 enter.keys_pressed.push(KeyEvent::new(
764 KeyCode::Named(NamedKey::Enter),
765 ModifierMask::NONE,
766 ));
767 let (response, _) = render(&tabs, tabs[0].id, &mut focus, &mut enter, &prev);
768 assert_eq!(response.activated_tab, Some(tabs[1].id));
769 }
770
771 fn press(pos: LayoutPos) -> InputSnapshot {
772 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
773 s.pointer = Some(PointerSample::new(pos));
774 s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
775 s
776 }
777
778 fn release(pos: LayoutPos) -> InputSnapshot {
779 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
780 s.pointer = Some(PointerSample::new(pos));
781 s.buttons_released = PointerButtonMask::just(PointerButton::Primary);
782 s
783 }
784
785 fn idle(pos: LayoutPos) -> InputSnapshot {
786 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
787 s.pointer = Some(PointerSample::new(pos));
788 s
789 }
790}