Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 26 kB View raw
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}