Another project
0

Configure Feed

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

feat(ui): ribbon widths, tree disclosure hit area

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (May 11, 2026, 11:54 PM +0300) commit 384d58a2 parent aaed4759 change-id wosqmxpx
+289 -27
+190 -8
crates/bone-ui/src/widgets/ribbon.rs
··· 33 33 pub label: StringKey, 34 34 pub items: Vec<ToolbarItem>, 35 35 pub icon_size: RibbonIconSize, 36 + pub min_width: LayoutPx, 36 37 pub width: LayoutPx, 37 38 } 38 39 ··· 310 311 } 311 312 312 313 fn group_rects(body: LayoutRect, groups: &[RibbonGroup], gap: LayoutPx) -> Vec<LayoutRect> { 313 - let body_max_x = body.origin.x.value() + body.size.width.value(); 314 - groups 315 - .iter() 316 - .scan(body.origin.x.value(), |x, group| { 317 - let remaining = (body_max_x - *x).max(0.0); 318 - let allocated = group.width.value().min(remaining); 314 + let n = groups.len(); 315 + if n == 0 { 316 + return Vec::new(); 317 + } 318 + #[allow( 319 + clippy::cast_precision_loss, 320 + reason = "ribbon group counts fit in f32 mantissa" 321 + )] 322 + let total_gap = gap.value() * n.saturating_sub(1) as f32; 323 + let body_w = body.size.width.value(); 324 + let avail = (body_w - total_gap).max(0.0); 325 + let widths = allocate_group_widths(groups, avail); 326 + widths 327 + .into_iter() 328 + .scan(body.origin.x.value(), |x, w| { 319 329 let rect = LayoutRect::new( 320 330 LayoutPos::new(LayoutPx::new(*x), body.origin.y), 321 - LayoutSize::new(LayoutPx::new(allocated), body.size.height), 331 + LayoutSize::new(LayoutPx::saturating_nonneg(w), body.size.height), 322 332 ); 323 - *x += allocated + gap.value(); 333 + *x += w + gap.value(); 324 334 Some(rect) 325 335 }) 326 336 .collect() 327 337 } 328 338 339 + fn allocate_group_widths(groups: &[RibbonGroup], avail: f32) -> Vec<f32> { 340 + let min_total: f32 = groups.iter().map(|g| g.min_width.value()).sum(); 341 + if avail <= min_total { 342 + let ratio = if min_total > 0.0 { 343 + avail / min_total 344 + } else { 345 + 0.0 346 + }; 347 + return groups.iter().map(|g| g.min_width.value() * ratio).collect(); 348 + } 349 + let extra = avail - min_total; 350 + let wants: Vec<f32> = groups 351 + .iter() 352 + .map(|g| (g.width.value() - g.min_width.value()).max(0.0)) 353 + .collect(); 354 + let want_total: f32 = wants.iter().sum(); 355 + if want_total <= 0.0 { 356 + return groups.iter().map(|g| g.min_width.value()).collect(); 357 + } 358 + groups 359 + .iter() 360 + .zip(wants.iter()) 361 + .map(|(g, want)| { 362 + let bonus = (extra * want / want_total).min(*want); 363 + g.min_width.value() + bonus 364 + }) 365 + .collect() 366 + } 367 + 329 368 fn inner_toolbar_rect(group: LayoutRect, label_height: LayoutPx, padding: LayoutPx) -> LayoutRect { 330 369 let avail_height = 331 370 (group.size.height.value() - label_height.value() - 2.0 * padding.value()).max(0.0); ··· 375 414 WidgetId::ROOT.child(WidgetKey::new("ribbon")) 376 415 } 377 416 417 + fn make_group(label: &'static str, items: usize, min: f32, preferred: f32) -> RibbonGroup { 418 + let id = ribbon_id().child(WidgetKey::new(label)); 419 + let item_keys: Vec<&'static str> = (0..items) 420 + .map(|i| match i { 421 + 0 => "i0", 422 + 1 => "i1", 423 + 2 => "i2", 424 + 3 => "i3", 425 + _ => "ix", 426 + }) 427 + .collect(); 428 + RibbonGroup { 429 + id, 430 + label: StringKey::new("ribbon.group"), 431 + items: item_keys 432 + .into_iter() 433 + .map(|k| ToolbarItem::new(id.child(WidgetKey::new(k)), StringKey::new(k))) 434 + .collect(), 435 + icon_size: RibbonIconSize::Large, 436 + min_width: LayoutPx::new(min), 437 + width: LayoutPx::new(preferred), 438 + } 439 + } 440 + 441 + fn body_rect(width: f32) -> LayoutRect { 442 + LayoutRect::new( 443 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 444 + LayoutSize::new(LayoutPx::new(width), LayoutPx::new(80.0)), 445 + ) 446 + } 447 + 448 + #[test] 449 + fn allocator_gives_every_group_at_least_min_when_avail_fits_all_min() { 450 + let groups = vec![ 451 + make_group("a", 12, 132.0, 1500.0), 452 + make_group("b", 1, 130.0, 130.0), 453 + make_group("c", 10, 76.0, 800.0), 454 + ]; 455 + let widths = super::allocate_group_widths(&groups, 600.0); 456 + assert!(widths[0] >= 132.0, "entity min"); 457 + assert!(widths[1] >= 130.0, "dim min"); 458 + assert!(widths[2] >= 76.0, "rel min"); 459 + } 460 + 461 + #[test] 462 + fn allocator_caps_at_preferred_for_small_demand_groups() { 463 + let groups = vec![ 464 + make_group("entities", 12, 132.0, 1500.0), 465 + make_group("dimensions", 1, 130.0, 130.0), 466 + make_group("relations", 10, 76.0, 800.0), 467 + ]; 468 + let widths = super::allocate_group_widths(&groups, 5000.0); 469 + assert!( 470 + (widths[1] - 130.0).abs() < 1e-3, 471 + "dimensions never exceeds preferred", 472 + ); 473 + } 474 + 475 + #[test] 476 + fn allocator_distributes_remainder_proportionally_to_demand() { 477 + let groups = vec![ 478 + make_group("a", 4, 100.0, 500.0), 479 + make_group("b", 4, 100.0, 500.0), 480 + ]; 481 + let widths = super::allocate_group_widths(&groups, 600.0); 482 + assert!( 483 + (widths[0] - widths[1]).abs() < 1e-3, 484 + "equal demand → equal share" 485 + ); 486 + assert!((widths[0] - 300.0).abs() < 1e-3); 487 + } 488 + 489 + #[test] 490 + fn allocator_scales_down_proportionally_when_avail_below_min_total() { 491 + let groups = vec![ 492 + make_group("a", 4, 200.0, 400.0), 493 + make_group("b", 4, 100.0, 200.0), 494 + ]; 495 + let widths = super::allocate_group_widths(&groups, 150.0); 496 + let ratio = 150.0 / 300.0; 497 + assert!((widths[0] - 200.0 * ratio).abs() < 1e-3); 498 + assert!((widths[1] - 100.0 * ratio).abs() < 1e-3); 499 + } 500 + 501 + #[test] 502 + fn allocator_handles_zero_avail() { 503 + let groups = vec![make_group("a", 4, 100.0, 200.0)]; 504 + let widths = super::allocate_group_widths(&groups, 0.0); 505 + assert!(widths[0].abs() < 1e-9); 506 + } 507 + 508 + #[test] 509 + fn allocator_handles_zero_min_groups() { 510 + let groups = vec![ 511 + make_group("a", 1, 0.0, 100.0), 512 + make_group("b", 1, 0.0, 100.0), 513 + ]; 514 + let widths = super::allocate_group_widths(&groups, 200.0); 515 + assert!((widths[0] - 100.0).abs() < 1e-3); 516 + assert!((widths[1] - 100.0).abs() < 1e-3); 517 + } 518 + 519 + #[test] 520 + fn group_rects_lays_out_left_to_right_with_gap() { 521 + let groups = vec![ 522 + make_group("a", 4, 100.0, 200.0), 523 + make_group("b", 4, 100.0, 200.0), 524 + ]; 525 + let body = body_rect(450.0); 526 + let rects = super::group_rects(body, &groups, LayoutPx::new(8.0)); 527 + assert_eq!(rects.len(), 2); 528 + assert!(rects[0].origin.x.value().abs() < 1e-9); 529 + let expected_b_x = rects[0].size.width.value() + 8.0; 530 + assert!((rects[1].origin.x.value() - expected_b_x).abs() < 1e-3); 531 + } 532 + 533 + #[test] 534 + fn group_rects_assigns_nonzero_width_to_every_group_when_body_is_tight() { 535 + let groups = vec![ 536 + make_group("entities", 12, 132.0, 1500.0), 537 + make_group("dimensions", 1, 130.0, 130.0), 538 + make_group("relations", 10, 76.0, 800.0), 539 + ]; 540 + let body = body_rect(900.0); 541 + let rects = super::group_rects(body, &groups, LayoutPx::new(8.0)); 542 + rects.iter().enumerate().for_each(|(i, r)| { 543 + assert!( 544 + r.size.width.value() > 0.0, 545 + "group {i} got zero width on tight ribbon", 546 + ); 547 + }); 548 + } 549 + 550 + #[test] 551 + fn group_rects_handles_empty_group_slice() { 552 + let body = body_rect(800.0); 553 + let rects = super::group_rects(body, &[], LayoutPx::new(8.0)); 554 + assert!(rects.is_empty()); 555 + } 556 + 378 557 fn make_ribbon_tab(name: &'static str) -> RibbonTab { 379 558 let tab_id = ribbon_id().child(WidgetKey::new(name)); 380 559 let tool_id = tab_id.child(WidgetKey::new("tool")); ··· 386 565 label: StringKey::new("ribbon.group"), 387 566 items: vec![ToolbarItem::new(tool_id, StringKey::new("ribbon.tool"))], 388 567 icon_size: RibbonIconSize::Large, 568 + min_width: LayoutPx::new(80.0), 389 569 width: LayoutPx::new(120.0), 390 570 }], 391 571 ) ··· 407 587 LayoutSize::new(LayoutPx::new(800.0), LayoutPx::new(120.0)), 408 588 ); 409 589 let response = { 590 + let mut shaper = bone_text::Shaper::new(); 410 591 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 411 592 let mut ctx = FrameCtx::new( 412 593 theme, ··· 417 598 &mut hits, 418 599 prev, 419 600 &mut a11y, 601 + &mut shaper, 420 602 ); 421 603 show_ribbon( 422 604 &mut ctx,
+9 -4
crates/bone-ui/src/widgets/toolbar.rs
··· 336 336 .enumerate() 337 337 .map(|(i, it)| item_extent(it, item_size) + if i == 0 { 0.0 } else { gap.value() }) 338 338 .sum(); 339 - let cap = if total_extent <= available { 340 - available 339 + let overflows = total_extent > available + FIT_EPSILON_PX; 340 + let cap = if overflows { 341 + (available - item_size.value() - gap.value()).max(0.0) 341 342 } else { 342 - (available - item_size.value() - gap.value()).max(0.0) 343 + available 343 344 }; 344 345 let mut offset = 0.0_f32; 345 346 items ··· 349 350 let extent = item_extent(item, item_size); 350 351 let lead = if i == 0 { 0.0 } else { gap.value() }; 351 352 let next = offset + lead + extent; 352 - if total_extent > available && next > cap { 353 + if overflows && next > cap + FIT_EPSILON_PX { 353 354 return None; 354 355 } 355 356 let single = single_item_rect(rect, offset + lead, extent, orientation); ··· 358 359 }) 359 360 .collect() 360 361 } 362 + 363 + const FIT_EPSILON_PX: f32 = 0.5; 361 364 362 365 fn single_item_rect( 363 366 rect: LayoutRect, ··· 445 448 let table = HotkeyTable::new(); 446 449 let mut hits = HitFrame::new(); 447 450 let response = { 451 + let mut shaper = bone_text::Shaper::new(); 448 452 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 449 453 let mut ctx = FrameCtx::new( 450 454 theme, ··· 455 459 &mut hits, 456 460 prev, 457 461 &mut a11y, 462 + &mut shaper, 458 463 ); 459 464 show_toolbar( 460 465 &mut ctx,
+90 -15
crates/bone-ui/src/widgets/tree_view.rs
··· 76 76 pub placement: DropPlacement, 77 77 } 78 78 79 - #[derive(Clone, Debug, Default, PartialEq, Eq)] 79 + #[derive(Clone, Debug, Default, PartialEq)] 80 80 pub struct TreeViewState { 81 81 pub expanded: BTreeSet<WidgetId>, 82 82 pub selection: BTreeSet<WidgetId>, ··· 298 298 } = args; 299 299 #[allow(clippy::cast_precision_loss, reason = "tree depth fits f32 mantissa")] 300 300 let indent = LayoutPx::new(row.depth as f32 * indent_step.value()); 301 - let disclosure_rect = disclosure_rect_at(row_rect, indent); 301 + let disclosure_icon_rect = disclosure_icon_rect_at(row_rect, indent); 302 + let disclosure_hit_rect = disclosure_hit_rect_at(row_rect, indent); 302 303 let label_rect = label_rect_at(row_rect, indent); 303 304 let selected = state.selection.contains(&row.id); 304 305 let expanded = state.expanded.contains(&row.id); ··· 339 340 elevation: None, 340 341 }]; 341 342 if row.has_children { 342 - paint.extend(draw_disclosure(ctx, row, disclosure_rect, state)); 343 + paint.extend(draw_disclosure( 344 + ctx, 345 + row, 346 + disclosure_icon_rect, 347 + disclosure_hit_rect, 348 + state, 349 + )); 343 350 } 344 351 if state.renaming == Some(row.id) { 345 352 paint.extend(draw_rename_editor( ··· 427 434 fn draw_disclosure( 428 435 ctx: &mut FrameCtx<'_>, 429 436 row: &VisibleRow, 430 - disclosure_rect: LayoutRect, 437 + icon_rect: LayoutRect, 438 + hit_rect: LayoutRect, 431 439 state: &mut TreeViewState, 432 440 ) -> Vec<WidgetPaint> { 433 441 let disclosure_id = row.id.child(WidgetKey::new("disclosure")); 434 442 let disclosure_interaction = ctx.interact( 435 - InteractDeclaration::new(disclosure_id, disclosure_rect, Sense::INTERACTIVE).a11y( 443 + InteractDeclaration::new(disclosure_id, hit_rect, Sense::INTERACTIVE).a11y( 436 444 AccessNode::new(Role::DisclosureTriangle) 437 445 .with_label_text(row.label.clone()) 438 446 .with_expanded(state.expanded.contains(&row.id)), ··· 442 450 toggle_expanded(state, row.id); 443 451 } 444 452 vec![WidgetPaint::Mark { 445 - rect: disclosure_rect, 453 + rect: icon_rect, 446 454 kind: if state.expanded.contains(&row.id) { 447 455 GlyphMark::DisclosureOpen 448 456 } else { ··· 537 545 } 538 546 } 539 547 540 - fn disclosure_rect_at(row: LayoutRect, indent: LayoutPx) -> LayoutRect { 541 - let size = 14.0; 542 - let pad = (row.size.height.value() - size) / 2.0; 548 + const DISCLOSURE_ICON_SIZE_PX: f32 = 14.0; 549 + const DISCLOSURE_COLUMN_WIDTH_PX: f32 = 20.0; 550 + 551 + fn disclosure_icon_rect_at(row: LayoutRect, indent: LayoutPx) -> LayoutRect { 552 + let pad_y = (row.size.height.value() - DISCLOSURE_ICON_SIZE_PX) / 2.0; 553 + let pad_x = (DISCLOSURE_COLUMN_WIDTH_PX - DISCLOSURE_ICON_SIZE_PX) / 2.0; 554 + LayoutRect::new( 555 + LayoutPos::new( 556 + LayoutPx::new(row.origin.x.value() + indent.value() + pad_x), 557 + LayoutPx::new(row.origin.y.value() + pad_y), 558 + ), 559 + LayoutSize::new( 560 + LayoutPx::new(DISCLOSURE_ICON_SIZE_PX), 561 + LayoutPx::new(DISCLOSURE_ICON_SIZE_PX), 562 + ), 563 + ) 564 + } 565 + 566 + fn disclosure_hit_rect_at(row: LayoutRect, indent: LayoutPx) -> LayoutRect { 543 567 LayoutRect::new( 544 568 LayoutPos::new( 545 - LayoutPx::new(row.origin.x.value() + indent.value() + 2.0), 546 - LayoutPx::new(row.origin.y.value() + pad), 569 + LayoutPx::new(row.origin.x.value() + indent.value()), 570 + row.origin.y, 547 571 ), 548 - LayoutSize::new(LayoutPx::new(size), LayoutPx::new(size)), 572 + LayoutSize::new(LayoutPx::new(DISCLOSURE_COLUMN_WIDTH_PX), row.size.height), 549 573 ) 550 574 } 551 575 552 576 fn label_rect_at(row: LayoutRect, indent: LayoutPx) -> LayoutRect { 553 - let chevron = 16.0 + 4.0; 554 577 LayoutRect::new( 555 578 LayoutPos::new( 556 - LayoutPx::new(row.origin.x.value() + indent.value() + chevron), 579 + LayoutPx::new(row.origin.x.value() + indent.value() + DISCLOSURE_COLUMN_WIDTH_PX), 557 580 row.origin.y, 558 581 ), 559 582 LayoutSize::new( 560 - LayoutPx::saturating_nonneg(row.size.width.value() - indent.value() - chevron), 583 + LayoutPx::saturating_nonneg( 584 + row.size.width.value() - indent.value() - DISCLOSURE_COLUMN_WIDTH_PX, 585 + ), 561 586 row.size.height, 562 587 ), 563 588 ) ··· 729 754 LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(400.0)), 730 755 ); 731 756 let response = { 757 + let mut shaper = bone_text::Shaper::new(); 732 758 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 733 759 let mut ctx = FrameCtx::new( 734 760 theme, ··· 739 765 &mut hits, 740 766 prev, 741 767 &mut a11y, 768 + &mut shaper, 742 769 ); 743 770 show_tree_view( 744 771 &mut ctx, ··· 792 819 } 793 820 794 821 #[test] 822 + fn click_indent_column_outside_old_icon_still_toggles() { 823 + let roots = sample_tree(); 824 + let mut state = TreeViewState::default(); 825 + let mut focus = FocusManager::new(); 826 + let mut prev = HitState::new(); 827 + let pos = LayoutPos::new(LayoutPx::new(18.0), LayoutPx::new(11.0)); 828 + [press(pos), release(pos), idle(pos)] 829 + .into_iter() 830 + .for_each(|mut snap| { 831 + let (_, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 832 + prev = next; 833 + }); 834 + assert!( 835 + state.expanded.contains(&roots[0].id), 836 + "indent column click toggles even outside the 14px icon", 837 + ); 838 + } 839 + 840 + #[test] 841 + fn click_disclosure_toggles_back_after_collapse() { 842 + let roots = sample_tree(); 843 + let mut state = TreeViewState::default(); 844 + state.expanded.insert(roots[0].id); 845 + let mut focus = FocusManager::new(); 846 + let mut prev = HitState::new(); 847 + let pos = LayoutPos::new(LayoutPx::new(8.0), LayoutPx::new(11.0)); 848 + [press(pos), release(pos), idle(pos)] 849 + .into_iter() 850 + .for_each(|mut snap| { 851 + let (_, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 852 + prev = next; 853 + }); 854 + assert!(!state.expanded.contains(&roots[0].id), "first click closes"); 855 + [press(pos), release(pos), idle(pos)] 856 + .into_iter() 857 + .for_each(|mut snap| { 858 + let (_, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 859 + prev = next; 860 + }); 861 + assert!( 862 + state.expanded.contains(&roots[0].id), 863 + "second click reopens", 864 + ); 865 + } 866 + 867 + #[test] 795 868 fn click_label_selects_node_and_marks_active() { 796 869 let roots = sample_tree(); 797 870 let mut state = TreeViewState::default(); ··· 872 945 snap.modifiers = ModifierMask::CTRL; 873 946 let mut hits = HitFrame::new(); 874 947 { 948 + let mut shaper = bone_text::Shaper::new(); 875 949 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 876 950 let mut ctx = FrameCtx::new( 877 951 theme.clone(), ··· 882 956 &mut hits, 883 957 &prev, 884 958 &mut a11y, 959 + &mut shaper, 885 960 ); 886 961 let _ = show_tree_view( 887 962 &mut ctx,