Another project
0

Configure Feed

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

feat(app): smart-dimension ribbon thingy

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

author
Lewis
date (May 13, 2026, 11:51 AM +0300) commit 4a463187 parent e3bb5f1c change-id ywzovwkz
+253 -20
+253 -20
crates/bone-app/src/shell.rs
··· 22 22 use uom::si::length::millimeter; 23 23 24 24 use crate::relation_tools::{Eligibility, RelationKind, eligibility}; 25 + use crate::sketch_mode::PendingDimension; 25 26 use crate::sketch_mode::{Mode, Plane, SketchTool}; 27 + use crate::smart_dimension; 26 28 use crate::strings; 27 29 28 30 const RIBBON_GROUP_PADDING_PX: f32 = 8.0; ··· 65 67 dock_host: WidgetId, 66 68 ribbon: WidgetId, 67 69 ribbon_exit: WidgetId, 70 + ribbon_smart_dimension: WidgetId, 68 71 feature_tree: WidgetId, 69 72 property_pane: WidgetId, 70 73 viewport: WidgetId, ··· 101 104 dock_host: root.child(WidgetKey::new("dock")), 102 105 ribbon, 103 106 ribbon_exit: ribbon.child(WidgetKey::new("tool.exit_sketch")), 107 + ribbon_smart_dimension: ribbon.child(WidgetKey::new("tool.smart_dimension")), 104 108 feature_tree, 105 109 property_pane: root.child(WidgetKey::new("props")), 106 110 viewport: root.child(WidgetKey::new("viewport")), ··· 181 185 pub viewport_rect: LayoutRect, 182 186 pub activated_tool: Option<SketchTool>, 183 187 pub activated_relation: Option<SketchRelation>, 188 + pub activated_dimension: Option<PendingDimension>, 184 189 pub plane_picked: Option<Plane>, 185 190 pub exit_sketch: bool, 186 191 pub menu_action: Option<MenuAction>, ··· 194 199 viewport_rect: zero_rect(), 195 200 activated_tool: None, 196 201 activated_relation: None, 202 + activated_dimension: None, 197 203 plane_picked: None, 198 204 exit_sketch: false, 199 205 menu_action: None, ··· 300 306 rect: ribbon_rect, 301 307 ribbon: self.ids.ribbon, 302 308 ribbon_exit: self.ids.ribbon_exit, 309 + ribbon_smart_dimension: self.ids.ribbon_smart_dimension, 303 310 mode, 304 311 sketch: active_sketch, 305 312 selection, ··· 337 344 active_sketch, 338 345 selection, 339 346 ); 347 + let activated_dimension = resolve_activated_dimension( 348 + activated_widget, 349 + self.ids.ribbon_smart_dimension, 350 + active_sketch, 351 + selection, 352 + ); 340 353 let plane_picked = double_activated.and_then(|id| self.ids.plane_for(id)); 341 354 let (paints, overlay_paints) = partition_overlay(paints, ctx.theme()); 342 355 ShellFrame { ··· 345 358 viewport_rect, 346 359 activated_tool, 347 360 activated_relation, 361 + activated_dimension, 348 362 plane_picked, 349 363 exit_sketch, 350 364 menu_action, ··· 409 423 } 410 424 } 411 425 426 + fn resolve_activated_dimension( 427 + activated_widget: Option<WidgetId>, 428 + smart_dimension_id: WidgetId, 429 + sketch: Option<&Sketch>, 430 + selection: &[SketchEntityId], 431 + ) -> Option<PendingDimension> { 432 + if activated_widget? != smart_dimension_id { 433 + return None; 434 + } 435 + match smart_dimension::eligibility(sketch?, selection) { 436 + smart_dimension::Eligibility::Eligible(req) => Some(req), 437 + smart_dimension::Eligibility::Disabled(_) => None, 438 + } 439 + } 440 + 441 + fn smart_dimension_tool_item( 442 + id: WidgetId, 443 + sketch: Option<&Sketch>, 444 + selection: &[SketchEntityId], 445 + sketch_disabled: bool, 446 + ) -> ToolbarItem { 447 + let item = ToolbarItem::new(id, strings::TOOL_SMART_DIMENSION); 448 + if sketch_disabled { 449 + return item.disabled(true); 450 + } 451 + let Some(sketch) = sketch else { 452 + return item.disabled(true); 453 + }; 454 + match smart_dimension::eligibility(sketch, selection) { 455 + smart_dimension::Eligibility::Eligible(_) => item, 456 + smart_dimension::Eligibility::Disabled(reason) => item.disabled(true).with_tooltip(reason), 457 + } 458 + } 459 + 412 460 fn render_menu_bar( 413 461 ctx: &mut FrameCtx<'_>, 414 462 rect: LayoutRect, ··· 522 570 rect: LayoutRect, 523 571 ribbon: WidgetId, 524 572 ribbon_exit: WidgetId, 573 + ribbon_smart_dimension: WidgetId, 525 574 mode: &'a Mode, 526 575 sketch: Option<&'a Sketch>, 527 576 selection: &'a [SketchEntityId], ··· 537 586 rect, 538 587 ribbon, 539 588 ribbon_exit, 589 + ribbon_smart_dimension, 540 590 mode, 541 591 sketch, 542 592 selection, ··· 570 620 }) 571 621 .collect(); 572 622 let dimension_items = vec![size_item( 573 - ToolbarItem::new( 574 - tool_widget_id(ribbon, SketchTool::SmartDimension), 575 - strings::TOOL_SMART_DIMENSION, 576 - ) 577 - .active(active_tool == Some(SketchTool::SmartDimension)) 578 - .disabled(tools_disabled), 623 + smart_dimension_tool_item(ribbon_smart_dimension, sketch, selection, tools_disabled), 579 624 large_min, 580 625 )]; 581 626 let relation_items: Vec<ToolbarItem> = ··· 587 632 ToolbarItem::new(ribbon_exit, strings::TOOL_EXIT_SKETCH), 588 633 large_min, 589 634 )]; 635 + let exit_preferred = group_width_for(&exit_items, large_min); 590 636 let tab_id = ribbon.child(WidgetKey::new("tab.sketch")); 591 637 let exit_group = mode.is_sketch().then(|| RibbonGroup { 592 638 id: ribbon.child(WidgetKey::new("group.exit")), 593 639 label: strings::RIBBON_GROUP_EXIT, 594 - width: group_width_for(&exit_items, large_min), 640 + min_width: exit_preferred, 641 + width: exit_preferred, 595 642 items: exit_items, 596 643 icon_size: RibbonIconSize::Large, 597 644 }); 645 + let dimensions_preferred = group_width_for(&dimension_items, large_min); 598 646 let groups: Vec<RibbonGroup> = [ 599 647 RibbonGroup { 600 648 id: ribbon.child(WidgetKey::new("group.entities")), 601 649 label: strings::RIBBON_GROUP_ENTITIES, 650 + min_width: group_min_width(large_min, entity_items.len()), 602 651 width: group_width_for(&entity_items, large_min), 603 652 items: entity_items, 604 653 icon_size: RibbonIconSize::Large, ··· 606 655 RibbonGroup { 607 656 id: ribbon.child(WidgetKey::new("group.relations")), 608 657 label: strings::RIBBON_GROUP_RELATIONS, 658 + min_width: group_min_width(small_min, relation_items.len()), 609 659 width: group_width_for(&relation_items, small_min), 610 660 items: relation_items, 611 661 icon_size: RibbonIconSize::Small, ··· 613 663 RibbonGroup { 614 664 id: ribbon.child(WidgetKey::new("group.dimensions")), 615 665 label: strings::RIBBON_GROUP_DIMENSIONS, 616 - width: group_width_for(&dimension_items, large_min), 666 + min_width: dimensions_preferred, 667 + width: dimensions_preferred, 617 668 items: dimension_items, 618 669 icon_size: RibbonIconSize::Large, 619 670 }, ··· 924 975 LayoutPx::new(total + 2.0 * RIBBON_GROUP_PADDING_PX) 925 976 } 926 977 978 + fn group_min_width(item_size: LayoutPx, item_count: usize) -> LayoutPx { 979 + let min_items_extent = match item_count { 980 + 0 => 0.0, 981 + 1 => item_size.value(), 982 + _ => 2.0 * item_size.value() + RIBBON_TOOLBAR_GAP_PX, 983 + }; 984 + LayoutPx::new(min_items_extent + 2.0 * RIBBON_GROUP_PADDING_PX) 985 + } 986 + 927 987 fn relation_tool_buttons( 928 988 ribbon: WidgetId, 929 989 sketch: Option<&Sketch>, ··· 973 1033 SketchTool::ENTITIES 974 1034 .iter() 975 1035 .copied() 976 - .chain(core::iter::once(SketchTool::SmartDimension)) 977 1036 .map(|t| (tool_widget_id(ribbon, t), t)) 978 1037 .collect() 979 1038 } ··· 996 1055 SketchTool::ThreePointCornerRectangle => "tool.three_point_corner_rectangle", 997 1056 SketchTool::ThreePointCenterRectangle => "tool.three_point_center_rectangle", 998 1057 SketchTool::Parallelogram => "tool.parallelogram", 999 - SketchTool::SmartDimension => "tool.smart_dimension", 1000 1058 } 1001 1059 } 1002 1060 ··· 1014 1072 SketchTool::ThreePointCornerRectangle => strings::TOOL_THREE_POINT_CORNER_RECTANGLE, 1015 1073 SketchTool::ThreePointCenterRectangle => strings::TOOL_THREE_POINT_CENTER_RECTANGLE, 1016 1074 SketchTool::Parallelogram => strings::TOOL_PARALLELOGRAM, 1017 - SketchTool::SmartDimension => strings::TOOL_SMART_DIMENSION, 1018 1075 } 1019 1076 } 1020 1077 ··· 1164 1221 let mut hits = HitFrame::new(); 1165 1222 let prev = HitState::new(); 1166 1223 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 1224 + let mut shaper = bone_text::Shaper::new(); 1167 1225 let mut a11y = AccessTreeBuilder::new(); 1168 1226 let mut ctx = FrameCtx::new( 1169 1227 theme, ··· 1174 1232 &mut hits, 1175 1233 &prev, 1176 1234 &mut a11y, 1235 + &mut shaper, 1177 1236 ); 1178 1237 shell.render(&mut ctx, document, mode, &[], size) 1179 1238 } ··· 1241 1300 } 1242 1301 1243 1302 #[test] 1244 - fn tool_index_round_trips_every_tool() { 1303 + fn tool_index_round_trips_every_entity_tool() { 1245 1304 let ids = ShellIds::standard(); 1246 1305 let index = build_tool_index(ids.ribbon); 1247 - SketchTool::ENTITIES 1248 - .iter() 1249 - .copied() 1250 - .chain(core::iter::once(SketchTool::SmartDimension)) 1251 - .for_each(|t| { 1252 - let id = tool_widget_id(ids.ribbon, t); 1253 - assert_eq!(index.get(&id).copied(), Some(t)); 1254 - }); 1306 + SketchTool::ENTITIES.iter().copied().for_each(|t| { 1307 + let id = tool_widget_id(ids.ribbon, t); 1308 + assert_eq!(index.get(&id).copied(), Some(t)); 1309 + }); 1310 + } 1311 + 1312 + #[test] 1313 + fn tool_index_omits_smart_dimension() { 1314 + let ids = ShellIds::standard(); 1315 + let index = build_tool_index(ids.ribbon); 1316 + assert!(!index.contains_key(&ids.ribbon_smart_dimension)); 1255 1317 } 1256 1318 1257 1319 #[test] ··· 1404 1466 let id = relation_widget_id(ids.ribbon, RelationKind::Parallel); 1405 1467 let resolved = resolve_activated_relation(Some(id), &index, Some(&sketch), &[l1, l2]); 1406 1468 assert_eq!(resolved, Some(SketchRelation::Parallel(l1, l2))); 1469 + } 1470 + 1471 + #[test] 1472 + fn feature_tree_panel_rect_is_independent_of_pending_dim() { 1473 + let document = sample_document(); 1474 + let idle = render_with( 1475 + Theme::light(), 1476 + layout_size(1600.0, 900.0), 1477 + &document, 1478 + &Mode::Idle, 1479 + ); 1480 + let in_sketch = render_with( 1481 + Theme::light(), 1482 + layout_size(1600.0, 900.0), 1483 + &document, 1484 + &Mode::enter_sketch(SketchId::default()), 1485 + ); 1486 + let tree_rect_idle = panel_surface(&idle.paints, |x| x < 300.0); 1487 + let tree_rect_sketch = panel_surface(&in_sketch.paints, |x| x < 300.0); 1488 + assert_eq!( 1489 + tree_rect_idle, tree_rect_sketch, 1490 + "feature tree panel must not change between idle and sketch mode", 1491 + ); 1492 + } 1493 + 1494 + fn panel_surface(paints: &[WidgetPaint], filter: impl Fn(f32) -> bool) -> Option<LayoutRect> { 1495 + paints.iter().find_map(|p| match p { 1496 + WidgetPaint::Surface { rect, .. } if filter(rect.min_x().value()) => Some(*rect), 1497 + _ => None, 1498 + }) 1499 + } 1500 + 1501 + #[test] 1502 + fn smart_dimension_paints_at_typical_window_with_real_strings() { 1503 + use crate::strings as app_strings; 1504 + use bone_ui::strings::Locale; 1505 + let table = app_strings::make_strings(Locale::EnUs); 1506 + let Ok(mut shell) = Shell::new() else { 1507 + panic!("shell init"); 1508 + }; 1509 + let theme = Arc::new(Theme::light()); 1510 + let hk = HotkeyTable::new(); 1511 + let mut focus = FocusManager::new(); 1512 + let mut hits = HitFrame::new(); 1513 + let prev = HitState::new(); 1514 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 1515 + let mut shaper = bone_text::Shaper::new(); 1516 + let mut a11y = AccessTreeBuilder::new(); 1517 + let mut ctx = FrameCtx::new( 1518 + theme, 1519 + &mut input, 1520 + &mut focus, 1521 + &hk, 1522 + &table, 1523 + &mut hits, 1524 + &prev, 1525 + &mut a11y, 1526 + &mut shaper, 1527 + ); 1528 + let frame = shell.render( 1529 + &mut ctx, 1530 + &sample_document(), 1531 + &Mode::enter_sketch(SketchId::default()), 1532 + &[], 1533 + layout_size(1600.0, 900.0), 1534 + ); 1535 + let any_smart_dim_label = frame.paints.iter().any(|p| { 1536 + matches!( 1537 + p, 1538 + WidgetPaint::Label { text: LabelText::Key(key), .. } 1539 + if *key == strings::TOOL_SMART_DIMENSION 1540 + ) 1541 + }); 1542 + assert!(any_smart_dim_label); 1543 + } 1544 + 1545 + #[test] 1546 + fn smart_dimension_button_paints_even_in_narrow_ribbon() { 1547 + let frame = render_with( 1548 + Theme::light(), 1549 + layout_size(800.0, 600.0), 1550 + &sample_document(), 1551 + &Mode::enter_sketch(SketchId::default()), 1552 + ); 1553 + let any_smart_dim_label = frame.paints.iter().any(|p| { 1554 + matches!( 1555 + p, 1556 + WidgetPaint::Label { text: LabelText::Key(key), .. } 1557 + if *key == strings::TOOL_SMART_DIMENSION 1558 + ) 1559 + }); 1560 + assert!( 1561 + any_smart_dim_label, 1562 + "Smart Dimension button must remain reachable on a narrow ribbon", 1563 + ); 1564 + } 1565 + 1566 + #[test] 1567 + fn smart_dimension_item_disabled_without_sketch() { 1568 + let ids = ShellIds::standard(); 1569 + let item = smart_dimension_tool_item(ids.ribbon_smart_dimension, None, &[], false); 1570 + assert!(item.disabled); 1571 + } 1572 + 1573 + #[test] 1574 + fn smart_dimension_item_disabled_when_sketch_disabled_flag_set() { 1575 + let ids = ShellIds::standard(); 1576 + let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 1577 + let item = smart_dimension_tool_item(ids.ribbon_smart_dimension, Some(&sketch), &[], true); 1578 + assert!(item.disabled); 1579 + } 1580 + 1581 + #[test] 1582 + fn smart_dimension_item_carries_reason_tooltip_when_no_selection() { 1583 + let ids = ShellIds::standard(); 1584 + let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 1585 + let item = smart_dimension_tool_item(ids.ribbon_smart_dimension, Some(&sketch), &[], false); 1586 + assert!(item.disabled); 1587 + assert_eq!(item.tooltip, Some(strings::DIM_HINT_GENERIC)); 1588 + } 1589 + 1590 + #[test] 1591 + fn smart_dimension_item_enabled_for_eligible_line() { 1592 + let ids = ShellIds::standard(); 1593 + let (sketch, line) = sample_sketch_with_line(); 1594 + let item = 1595 + smart_dimension_tool_item(ids.ribbon_smart_dimension, Some(&sketch), &[line], false); 1596 + assert!(!item.disabled); 1597 + assert!(item.tooltip.is_none()); 1598 + } 1599 + 1600 + #[test] 1601 + fn resolve_activated_dimension_returns_request_for_eligible_selection() { 1602 + let ids = ShellIds::standard(); 1603 + let (sketch, line) = sample_sketch_with_line(); 1604 + let id = ids.ribbon_smart_dimension; 1605 + let resolved = resolve_activated_dimension(Some(id), id, Some(&sketch), &[line]); 1606 + let Some(req) = resolved else { 1607 + panic!("expected eligible request"); 1608 + }; 1609 + assert!(matches!( 1610 + req.proto, 1611 + bone_document::SketchDimension::Linear { .. } 1612 + )); 1613 + } 1614 + 1615 + #[test] 1616 + fn resolve_activated_dimension_drops_when_widget_id_mismatches() { 1617 + let ids = ShellIds::standard(); 1618 + let (sketch, line) = sample_sketch_with_line(); 1619 + let other = relation_widget_id(ids.ribbon, RelationKind::Horizontal); 1620 + let resolved = resolve_activated_dimension( 1621 + Some(other), 1622 + ids.ribbon_smart_dimension, 1623 + Some(&sketch), 1624 + &[line], 1625 + ); 1626 + assert_eq!(resolved, None); 1627 + } 1628 + 1629 + #[test] 1630 + fn resolve_activated_dimension_drops_when_selection_is_invalid() { 1631 + let ids = ShellIds::standard(); 1632 + let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 1633 + let resolved = resolve_activated_dimension( 1634 + Some(ids.ribbon_smart_dimension), 1635 + ids.ribbon_smart_dimension, 1636 + Some(&sketch), 1637 + &[], 1638 + ); 1639 + assert_eq!(resolved, None); 1407 1640 } 1408 1641 1409 1642 #[test]