Another project
0

Configure Feed

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

at main 256 kB View raw
1use core::num::NonZeroU32; 2use std::collections::{BTreeMap, BTreeSet}; 3use std::sync::Arc; 4 5use bone_document::{ 6 DimensionKind, DimensionValue, Document, ExtrudeEndCondition, ExtrudeFeature, FeatureEdge, 7 FeatureNode, MergeResult, Sketch, SketchDimension, SketchEntity, SketchRelation, 8 SketchStatusReport, SketchVersion, 9}; 10use bone_types::{ 11 Angle, Camera3, ExtrudeId, FeatureId, IconId, Length, Point2, PositiveLength, RollbackMarker, 12 SketchDimensionId, SketchEntityId, SketchId, 13}; 14use bone_ui::a11y::{AccessNode, Role}; 15use bone_ui::frame::{FrameCtx, InteractDeclaration}; 16use bone_ui::hit_test::Sense; 17use bone_ui::input::PointerButton; 18use bone_ui::layout::{ 19 Axis, DockNode, DockPanel, DockState, GridChild, GridLine, GridSpan, GridTrack, Layout, 20 LayoutPos, LayoutPx, LayoutRect, LayoutSize, NodeKind, PanelId, RetainedLayout, SolvedLayout, 21 SolvedNode, SplitFraction, TrackSize, measure, 22}; 23use bone_ui::strings::{StringKey, StringTable}; 24use bone_ui::theme::{Border, ElevationLevel, Radius, Spacing, Step12, StrokeWidth, Theme}; 25use bone_ui::widgets::IconTint; 26use bone_ui::widgets::{ 27 AngleEditor, BoolEditor, Checkbox, CheckboxState, Clipboard, ContextMenu, ContextMenuRequest, 28 Dialog, DialogButton, DropPlacement, DropTarget, HorizontalAlign, HotkeyCapture, 29 HotkeyCaptureState, LabelText, LengthEditor, MemoryClipboard, MenuBar, MenuBarEntry, 30 MenuBarState, MenuItem, MenuState, Panel, PanelState, PanelTitlebar, PanelVariant, 31 PropertyCell, PropertyEditor, PropertyGrid, PropertyOption, PropertyPaneAction, 32 PropertyPaneHeader, PropertyRow, RenameCommit, Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, 33 RollbackBar, RollbackTarget, SelectionEditor, Slider, SliderRange, SliderStep, StatusAlign, 34 StatusBar, StatusItem, Tab, Tabs, TabsOrientation, ToolbarItem, TreeBadge, TreeNode, TreeView, 35 TreeViewState, WidgetPaint, show_checkbox, show_context_menu, show_dialog, show_hotkey_capture, 36 show_menu_bar, show_panel, show_property_grid, show_property_pane_header, show_ribbon, 37 show_slider, show_status_bar, show_tabs, show_tree_view, 38}; 39use bone_ui::{WidgetId, WidgetKey}; 40use uom::si::angle::degree; 41use uom::si::length::millimeter; 42 43use bone_render::PickAperture; 44 45use crate::relation_tools::{Eligibility, RelationKind, eligibility}; 46use crate::selection::Selection; 47use crate::settings::Settings; 48use crate::sketch_mode::PendingDimension; 49use crate::sketch_mode::{ 50 EndConditionKind, ExtrudeArming, FeatureTool, Mode, Plane, SketchTool, default_extrude_depth, 51 end_condition_depth, 52}; 53use crate::smart_dimension; 54use crate::status_badge::{ 55 ExtrudeStatus, extrude_badge_style, extrude_badge_widget_id, extrude_panel_widget_id, 56 render_extrude_panel, render_status_panel, status_badge_widget_id, status_color, 57 status_label_key, status_panel_widget_id, 58}; 59use crate::strings; 60use crate::view_cube::{ 61 ViewCubeInputs, ViewCubeMenuAction, ViewPick, ViewUi, render_view_cube, render_view_selector, 62}; 63 64const RIBBON_GROUP_PADDING_PX: f32 = 8.0; 65const RIBBON_TOOLBAR_GAP_PX: f32 = 4.0; 66const RIBBON_LABEL_HORIZONTAL_PADDING_PX: f32 = 12.0; 67const RIBBON_LABEL_AVG_ADVANCE_RATIO: f32 = 0.6; 68const STATUS_MODE_WIDTH: LayoutPx = LayoutPx::new(220.0); 69const STATUS_UNITS_WIDTH: LayoutPx = LayoutPx::new(80.0); 70const STATUS_COORDS_WIDTH: LayoutPx = LayoutPx::new(180.0); 71const STATUS_STATUS_WIDTH: LayoutPx = LayoutPx::new(180.0); 72 73#[derive(Copy, Clone, Debug, PartialEq, Eq)] 74struct ShellPanels { 75 left_pane: PanelId, 76 viewport: PanelId, 77} 78 79impl ShellPanels { 80 fn standard() -> Self { 81 Self { 82 left_pane: panel(3), 83 viewport: panel(5), 84 } 85 } 86} 87 88#[derive(Copy, Clone, Debug, PartialEq, Eq)] 89struct ShellIds { 90 dock_host: WidgetId, 91 ribbon: WidgetId, 92 ribbon_smart_dimension: WidgetId, 93 left_pane: WidgetId, 94 left_pane_tab_tree: WidgetId, 95 left_pane_tab_properties: WidgetId, 96 left_pane_tab_configuration: WidgetId, 97 left_pane_tab_dimension_expert: WidgetId, 98 left_pane_tab_display: WidgetId, 99 feature_tree: WidgetId, 100 property_pane: WidgetId, 101 viewport: WidgetId, 102 confirm_accept: WidgetId, 103 confirm_cancel: WidgetId, 104 view_cube: WidgetId, 105 view_cube_menu: WidgetId, 106 view_selector: WidgetId, 107 heads_up: WidgetId, 108 status_bar: WidgetId, 109 doc_tabs: WidgetId, 110 doc_tab_model: WidgetId, 111 feature_part: WidgetId, 112 plane_xy: WidgetId, 113 plane_yz: WidgetId, 114 plane_zx: WidgetId, 115 menu_bar: WidgetId, 116 menu_file: WidgetId, 117 menu_edit: WidgetId, 118 menu_view: WidgetId, 119 menu_insert: WidgetId, 120 menu_tools: WidgetId, 121 menu_sketch: WidgetId, 122 menu_window: WidgetId, 123 menu_help: WidgetId, 124 menu_file_new: WidgetId, 125 menu_file_open: WidgetId, 126 menu_file_save: WidgetId, 127 menu_file_save_as: WidgetId, 128 menu_file_import: WidgetId, 129 menu_file_export: WidgetId, 130 menu_file_export_step: WidgetId, 131 menu_file_quit: WidgetId, 132 menu_edit_undo: WidgetId, 133 menu_edit_redo: WidgetId, 134 menu_view_zoom_fit: WidgetId, 135 menu_tools_options: WidgetId, 136 menu_tools_keyboard: WidgetId, 137 menu_sketch_exit: WidgetId, 138 settings_dialog: WidgetId, 139 settings_aperture_slider: WidgetId, 140 settings_reduce_motion: WidgetId, 141 settings_reset: WidgetId, 142 settings_close: WidgetId, 143 keyboard_dialog: WidgetId, 144 keyboard_dialog_reset: WidgetId, 145 keyboard_dialog_close: WidgetId, 146} 147 148impl ShellIds { 149 fn standard() -> Self { 150 let root = WidgetId::ROOT.child(WidgetKey::new("shell")); 151 let left_pane = root.child(WidgetKey::new("left")); 152 let feature_tree = left_pane.child(WidgetKey::new("tree")); 153 let feature_part = feature_tree.child(WidgetKey::new("part")); 154 let ribbon = root.child(WidgetKey::new("ribbon")); 155 let menu_bar = root.child(WidgetKey::new("menu")); 156 let menu_file = menu_bar.child(WidgetKey::new("file")); 157 let menu_edit = menu_bar.child(WidgetKey::new("edit")); 158 let menu_view = menu_bar.child(WidgetKey::new("view")); 159 let menu_tools = menu_bar.child(WidgetKey::new("tools")); 160 let menu_sketch = menu_bar.child(WidgetKey::new("sketch")); 161 let settings_dialog = root.child(WidgetKey::new("settings.dialog")); 162 let keyboard_dialog = root.child(WidgetKey::new("keyboard.dialog")); 163 let viewport = root.child(WidgetKey::new("viewport")); 164 Self { 165 dock_host: root.child(WidgetKey::new("dock")), 166 ribbon, 167 ribbon_smart_dimension: ribbon.child(WidgetKey::new("tool.smart_dimension")), 168 left_pane, 169 left_pane_tab_tree: left_pane.child(WidgetKey::new("tab.tree")), 170 left_pane_tab_properties: left_pane.child(WidgetKey::new("tab.props")), 171 left_pane_tab_configuration: left_pane.child(WidgetKey::new("tab.config")), 172 left_pane_tab_dimension_expert: left_pane.child(WidgetKey::new("tab.dimxpert")), 173 left_pane_tab_display: left_pane.child(WidgetKey::new("tab.display")), 174 feature_tree, 175 property_pane: left_pane.child(WidgetKey::new("props")), 176 viewport, 177 confirm_accept: viewport.child(WidgetKey::new("confirm.accept")), 178 confirm_cancel: viewport.child(WidgetKey::new("confirm.cancel")), 179 view_cube: viewport.child(WidgetKey::new("view_cube")), 180 view_cube_menu: viewport.child(WidgetKey::new("view_cube.menu")), 181 view_selector: viewport.child(WidgetKey::new("view_selector")), 182 heads_up: viewport.child(WidgetKey::new("heads_up")), 183 status_bar: root.child(WidgetKey::new("status")), 184 doc_tabs: root.child(WidgetKey::new("doc_tabs")), 185 doc_tab_model: root.child(WidgetKey::new("doc_tabs.model")), 186 feature_part, 187 plane_xy: feature_part.child(WidgetKey::new("plane.xy")), 188 plane_yz: feature_part.child(WidgetKey::new("plane.yz")), 189 plane_zx: feature_part.child(WidgetKey::new("plane.zx")), 190 menu_bar, 191 menu_file, 192 menu_edit, 193 menu_view, 194 menu_insert: menu_bar.child(WidgetKey::new("insert")), 195 menu_tools, 196 menu_sketch, 197 menu_window: menu_bar.child(WidgetKey::new("window")), 198 menu_help: menu_bar.child(WidgetKey::new("help")), 199 menu_file_new: menu_file.child(WidgetKey::new("new")), 200 menu_file_open: menu_file.child(WidgetKey::new("open")), 201 menu_file_save: menu_file.child(WidgetKey::new("save")), 202 menu_file_save_as: menu_file.child(WidgetKey::new("save_as")), 203 menu_file_import: menu_file.child(WidgetKey::new("import")), 204 menu_file_export: menu_file.child(WidgetKey::new("export")), 205 menu_file_export_step: menu_file 206 .child(WidgetKey::new("export")) 207 .child(WidgetKey::new("step")), 208 menu_file_quit: menu_file.child(WidgetKey::new("quit")), 209 menu_edit_undo: menu_edit.child(WidgetKey::new("undo")), 210 menu_edit_redo: menu_edit.child(WidgetKey::new("redo")), 211 menu_view_zoom_fit: menu_view.child(WidgetKey::new("zoom_fit")), 212 menu_tools_options: menu_tools.child(WidgetKey::new("options")), 213 menu_tools_keyboard: menu_tools.child(WidgetKey::new("keyboard")), 214 menu_sketch_exit: menu_sketch.child(WidgetKey::new("exit")), 215 settings_dialog, 216 settings_aperture_slider: settings_dialog.child(WidgetKey::new("aperture.slider")), 217 settings_reduce_motion: settings_dialog.child(WidgetKey::new("reduce_motion.checkbox")), 218 settings_reset: settings_dialog.child(WidgetKey::new("button.reset")), 219 settings_close: settings_dialog.child(WidgetKey::new("button.close")), 220 keyboard_dialog, 221 keyboard_dialog_reset: keyboard_dialog.child(WidgetKey::new("button.reset")), 222 keyboard_dialog_close: keyboard_dialog.child(WidgetKey::new("button.close")), 223 } 224 } 225 226 fn plane_for(&self, id: WidgetId) -> Option<Plane> { 227 [ 228 (self.plane_xy, Plane::Xy), 229 (self.plane_yz, Plane::Yz), 230 (self.plane_zx, Plane::Zx), 231 ] 232 .iter() 233 .copied() 234 .find_map(|(plane_id, plane)| (plane_id == id).then_some(plane)) 235 } 236 237 fn menu_action_for(&self, id: WidgetId) -> Option<MenuAction> { 238 [ 239 (self.menu_file_new, MenuAction::NewDocument), 240 (self.menu_file_open, MenuAction::OpenDocument), 241 (self.menu_file_save, MenuAction::SaveDocument), 242 (self.menu_file_save_as, MenuAction::SaveDocumentAs), 243 (self.menu_file_import, MenuAction::ImportStep), 244 (self.menu_file_export_step, MenuAction::ExportStep), 245 (self.menu_file_quit, MenuAction::Quit), 246 (self.menu_edit_undo, MenuAction::Undo), 247 (self.menu_edit_redo, MenuAction::Redo), 248 (self.menu_view_zoom_fit, MenuAction::ZoomFit), 249 (self.menu_tools_options, MenuAction::OpenSettings), 250 (self.menu_tools_keyboard, MenuAction::OpenKeyboardCustomize), 251 (self.menu_sketch_exit, MenuAction::ExitSketch), 252 ] 253 .iter() 254 .copied() 255 .find_map(|(menu_id, action)| (menu_id == id).then_some(action)) 256 } 257} 258 259#[derive(Copy, Clone, Debug, PartialEq, Eq)] 260pub enum MenuAction { 261 NewDocument, 262 OpenDocument, 263 SaveDocument, 264 SaveDocumentAs, 265 ImportStep, 266 ExportStep, 267 Quit, 268 Undo, 269 Redo, 270 ZoomFit, 271 OpenSettings, 272 OpenKeyboardCustomize, 273 ExitSketch, 274} 275 276#[derive(Copy, Clone, Debug, PartialEq, Eq)] 277pub enum FeatureTarget { 278 Sketch(SketchId), 279 Extrude(ExtrudeId), 280} 281 282#[derive(Copy, Clone, Debug, PartialEq, Eq)] 283pub enum FeatureCommand { 284 Suppress(FeatureId), 285 Unsuppress(FeatureId), 286 RollbackToHere(FeatureId), 287 Delete(FeatureTarget), 288} 289 290#[derive(Copy, Clone, Debug, PartialEq, Eq)] 291pub struct FeatureReorder { 292 pub moved: FeatureId, 293 pub anchor: FeatureId, 294 pub before: bool, 295} 296 297#[derive(Copy, Clone, Debug, PartialEq, Eq)] 298pub enum RollbackChange { 299 ToEnd, 300 ToFeature(FeatureId), 301} 302 303#[derive(Clone, Debug, PartialEq)] 304pub struct WhatsWrong { 305 pub label: String, 306 pub message: StringKey, 307 pub is_error: bool, 308 pub reattach: Option<SketchId>, 309} 310 311#[derive(Default)] 312pub struct FeatureMenu { 313 open: Option<FeatureMenuAnchor>, 314 menu: MenuState, 315} 316 317#[derive(Copy, Clone)] 318struct FeatureMenuAnchor { 319 target: FeatureTarget, 320 anchor: LayoutPos, 321} 322 323pub struct Shell { 324 panels: ShellPanels, 325 ids: ShellIds, 326 retained_layout: RetainedLayout, 327 dock_state: Arc<DockState>, 328 tool_index: BTreeMap<WidgetId, SketchTool>, 329 feature_tool_index: BTreeMap<WidgetId, FeatureTool>, 330 relation_index: BTreeMap<WidgetId, RelationKind>, 331 pub state: ShellState, 332} 333 334#[derive(Default)] 335#[allow( 336 clippy::struct_excessive_bools, 337 reason = "shell aggregates independent dialog and panel toggles" 338)] 339pub struct ShellState { 340 pub feature_tree: TreeViewState, 341 pub feature_menu: FeatureMenu, 342 pub relationships: Option<FeatureTarget>, 343 pub whats_wrong_open: bool, 344 pub clipboard: MemoryClipboard, 345 pub menu_bar: MenuBarState, 346 pub dim_property: Option<DimPropertyEditor>, 347 pub extrude_property: Option<ExtrudePropertyEditor>, 348 pub settings_dialog_open: bool, 349 pub keyboard_dialog_open: bool, 350 pub hotkey_capture: BTreeMap<bone_ui::hotkey::ActionId, HotkeyCaptureState>, 351 pub left_pane: LeftPane, 352 last_left_pane_interesting: bool, 353 last_mode_was_sketch: bool, 354 pub status_panel_open: bool, 355 pub status_panel: PanelState, 356 pub extrude_panel_open: bool, 357 pub extrude_panel: PanelState, 358 pub property_groups: BTreeMap<WidgetId, PanelState>, 359 status_cache: Option<(SketchVersion, SketchStatusReport)>, 360 pub ribbon_overflow_open: BTreeMap<WidgetId, bool>, 361 pub ribbon_active_tab: Option<WidgetId>, 362} 363 364#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] 365pub enum LeftPane { 366 #[default] 367 Tree, 368 Properties, 369} 370 371pub enum DimPropertyEditor { 372 Length { 373 sketch_id: SketchId, 374 id: SketchDimensionId, 375 editor: LengthEditor, 376 }, 377 Angle { 378 sketch_id: SketchId, 379 id: SketchDimensionId, 380 editor: AngleEditor, 381 }, 382} 383 384#[derive(Copy, Clone, Debug, PartialEq)] 385pub enum ExtrudeEdit { 386 EndCondition(EndConditionKind), 387 Depth(PositiveLength), 388 Merge(MergeResult), 389} 390 391impl ExtrudeEdit { 392 #[must_use] 393 pub fn apply(self, feature: ExtrudeFeature) -> ExtrudeFeature { 394 match self { 395 Self::EndCondition(kind) => match end_condition_depth(&feature.end_condition) { 396 Some(depth) => ExtrudeFeature { 397 end_condition: kind.with_depth(depth), 398 ..feature 399 }, 400 None => feature, 401 }, 402 Self::Depth(depth) => match EndConditionKind::of(&feature.end_condition) { 403 Some(kind) => ExtrudeFeature { 404 end_condition: kind.with_depth(depth), 405 ..feature 406 }, 407 None => feature, 408 }, 409 Self::Merge(merge_result) => ExtrudeFeature { 410 merge_result, 411 ..feature 412 }, 413 } 414 } 415} 416 417pub struct ExtrudePropertyEditor { 418 sketch: SketchId, 419 end_condition: SelectionEditor, 420 depth: LengthEditor, 421 draft_enabled: BoolEditor, 422 draft_angle: AngleEditor, 423 direction_two: BoolEditor, 424 thin: BoolEditor, 425 merge: BoolEditor, 426} 427 428impl ExtrudePropertyEditor { 429 fn new(feature: ExtrudeFeature) -> Self { 430 Self { 431 sketch: feature.sketch, 432 end_condition: SelectionEditor::new( 433 end_condition_options(), 434 Some(kind_index(&feature.end_condition)), 435 ), 436 depth: LengthEditor::new(current_depth(&feature)), 437 draft_enabled: BoolEditor::new(feature.draft.is_some()), 438 draft_angle: AngleEditor::new(current_draft_angle(&feature)), 439 direction_two: BoolEditor::new(false), 440 thin: BoolEditor::new(feature.thin_wall.is_some()), 441 merge: BoolEditor::new(matches!(feature.merge_result, MergeResult::Merge)), 442 } 443 } 444 445 fn synced(mut self, feature: ExtrudeFeature) -> Self { 446 self.end_condition.current = Some(kind_index(&feature.end_condition)); 447 self.depth.value = current_depth(&feature); 448 self.draft_enabled.value = feature.draft.is_some(); 449 self.draft_angle.value = current_draft_angle(&feature); 450 self.direction_two.value = false; 451 self.thin.value = feature.thin_wall.is_some(); 452 self.merge.value = matches!(feature.merge_result, MergeResult::Merge); 453 self 454 } 455} 456 457fn end_condition_options() -> Vec<PropertyOption> { 458 EndConditionKind::SUPPORTED 459 .iter() 460 .map(|kind| PropertyOption { 461 label: end_condition_label(*kind), 462 }) 463 .collect() 464} 465 466fn end_condition_label(kind: EndConditionKind) -> StringKey { 467 match kind { 468 EndConditionKind::Blind => strings::EXTRUDE_END_BLIND, 469 EndConditionKind::MidPlane => strings::EXTRUDE_END_MIDPLANE, 470 } 471} 472 473fn kind_index(condition: &ExtrudeEndCondition) -> usize { 474 let kind = EndConditionKind::of(condition).unwrap_or(EndConditionKind::Blind); 475 EndConditionKind::SUPPORTED 476 .iter() 477 .position(|candidate| *candidate == kind) 478 .unwrap_or(0) 479} 480 481fn kind_from_index(index: Option<usize>) -> Option<EndConditionKind> { 482 index.and_then(|i| EndConditionKind::SUPPORTED.get(i).copied()) 483} 484 485fn current_depth(feature: &ExtrudeFeature) -> Length { 486 end_condition_depth(&feature.end_condition).map_or_else( 487 || { 488 debug_assert!( 489 false, 490 "an armed extrude always carries a Blind or MidPlane depth" 491 ); 492 default_extrude_depth().get() 493 }, 494 PositiveLength::get, 495 ) 496} 497 498fn current_draft_angle(feature: &ExtrudeFeature) -> Angle { 499 feature 500 .draft 501 .map_or_else(|| Angle::new::<degree>(0.0), |draft| draft.angle().get()) 502} 503 504#[derive(Clone, Debug, PartialEq)] 505pub struct ShellFrame { 506 pub paints: Vec<WidgetPaint>, 507 pub overlay_paints: Vec<WidgetPaint>, 508 pub viewport_rect: LayoutRect, 509 pub activated_tool: Option<SketchTool>, 510 pub activated_feature_tool: Option<FeatureTool>, 511 pub activated_relation: Option<SketchRelation>, 512 pub activated_dimension: Option<PendingDimension>, 513 pub dimension_edit: Option<DimensionEdit>, 514 pub extrude_edit: Option<ExtrudeEdit>, 515 pub plane_picked: Option<Plane>, 516 pub sketch_activated: Option<SketchId>, 517 pub sketch_rename: Option<SketchRenameRequest>, 518 pub extrude_activated: Option<ExtrudeId>, 519 pub extrude_rename: Option<ExtrudeRenameRequest>, 520 pub feature_command: Option<FeatureCommand>, 521 pub feature_reorder: Option<FeatureReorder>, 522 pub rollback_change: Option<RollbackChange>, 523 pub reattach_request: Option<SketchId>, 524 pub exit_sketch: bool, 525 pub confirm_action: Option<ConfirmAction>, 526 pub menu_action: Option<MenuAction>, 527 pub settings_change: Option<crate::settings::Settings>, 528 pub view_pick: Option<ViewPick>, 529 pub view_menu: Option<ViewCubeMenuAction>, 530} 531 532#[derive(Copy, Clone, Debug, PartialEq)] 533pub struct DimensionEdit { 534 pub id: SketchDimensionId, 535 pub value: DimensionValue, 536} 537 538impl ShellFrame { 539 fn empty() -> Self { 540 Self { 541 paints: Vec::new(), 542 overlay_paints: Vec::new(), 543 viewport_rect: zero_rect(), 544 activated_tool: None, 545 activated_feature_tool: None, 546 activated_relation: None, 547 activated_dimension: None, 548 dimension_edit: None, 549 extrude_edit: None, 550 plane_picked: None, 551 sketch_activated: None, 552 sketch_rename: None, 553 extrude_activated: None, 554 extrude_rename: None, 555 feature_command: None, 556 feature_reorder: None, 557 rollback_change: None, 558 reattach_request: None, 559 exit_sketch: false, 560 confirm_action: None, 561 menu_action: None, 562 settings_change: None, 563 view_pick: None, 564 view_menu: None, 565 } 566 } 567} 568 569impl Shell { 570 fn build_layout(&self, gap: Spacing) -> Layout { 571 let center = Layout::dock_host( 572 self.ids.dock_host, 573 Arc::clone(&self.dock_state), 574 vec![ 575 DockPanel { 576 id: self.panels.left_pane, 577 child: Layout::leaf(self.ids.left_pane), 578 }, 579 DockPanel { 580 id: self.panels.viewport, 581 child: Layout::leaf(self.ids.viewport), 582 }, 583 ], 584 gap, 585 ); 586 chrome_grid(ChromeRows { 587 menu: Layout::leaf(self.ids.menu_bar), 588 ribbon: Layout::leaf(self.ids.ribbon), 589 center, 590 doc_tabs: Layout::leaf(self.ids.doc_tabs), 591 status: Layout::leaf(self.ids.status_bar), 592 }) 593 } 594 595 #[must_use] 596 pub fn new() -> Self { 597 let panels = ShellPanels::standard(); 598 let ids = ShellIds::standard(); 599 let dock_state = Arc::new(DockState::new(build_dock_main(panels))); 600 let tool_index = build_tool_index(ids.ribbon); 601 let feature_tool_index = build_feature_tool_index(ids.ribbon); 602 let relation_index = build_relation_index(ids.ribbon); 603 let mut state = ShellState::default(); 604 state.feature_tree.expanded.insert(ids.feature_part); 605 Self { 606 panels, 607 ids, 608 retained_layout: RetainedLayout::default(), 609 dock_state, 610 tool_index, 611 feature_tool_index, 612 relation_index, 613 state, 614 } 615 } 616 617 #[allow( 618 clippy::too_many_lines, 619 clippy::too_many_arguments, 620 reason = "shell.render orchestrates the chrome layout pipeline" 621 )] 622 pub fn render( 623 &mut self, 624 ctx: &mut FrameCtx<'_>, 625 document: &Document, 626 mode: &Mode, 627 selection: &Selection, 628 settings: &Settings, 629 viewport_size: LayoutSize, 630 cursor_world: Option<Point2>, 631 camera3: Option<Camera3>, 632 extrude_status: Option<ExtrudeStatus<'_>>, 633 view: &mut ViewUi, 634 feature_badges: &BTreeMap<FeatureId, TreeBadge>, 635 needs_rebuild: bool, 636 whats_wrong: &[WhatsWrong], 637 ) -> ShellFrame { 638 let theme = ctx.theme(); 639 let direction = ctx.direction(); 640 let layout = self.build_layout(theme.spacing.md); 641 let Ok(solved) = measure(&layout, viewport_size, &self.retained_layout, direction) else { 642 return ShellFrame::empty(); 643 }; 644 let inset_px = theme.spacing.sm.value_px(); 645 let mut paints = paint_walk(&solved, solved.root_node(), theme, self.panels.viewport); 646 let viewport_rect = panel_rect(&solved, self.panels.viewport).unwrap_or_else(zero_rect); 647 let ribbon_rect = leaf_rect(&solved, self.ids.ribbon).unwrap_or_else(zero_rect); 648 let menu_bar_rect = leaf_rect(&solved, self.ids.menu_bar).unwrap_or_else(zero_rect); 649 let left_pane_rect = panel_rect(&solved, self.panels.left_pane) 650 .map_or_else(zero_rect, |r| inset_rect(r, inset_px)); 651 let LeftPaneSplit { 652 tab_strip_rect, 653 content_rect, 654 } = split_left_pane(left_pane_rect); 655 let status_rect = leaf_rect(&solved, self.ids.status_bar).unwrap_or_else(zero_rect); 656 let doc_tabs_rect = leaf_rect(&solved, self.ids.doc_tabs).unwrap_or_else(zero_rect); 657 let mut popover_paints: Vec<WidgetPaint> = Vec::new(); 658 let menu_action = render_menu_bar( 659 ctx, 660 menu_bar_rect, 661 &self.ids, 662 &mut self.state.menu_bar, 663 document, 664 mode.is_sketch(), 665 &settings.hotkey_overrides, 666 &mut paints, 667 &mut popover_paints, 668 ); 669 let active_sketch = active_sketch(document, mode); 670 let entity_ids = selection.entity_ids(); 671 sync_ribbon_tab_to_mode(&mut self.state, self.ids.ribbon, mode); 672 let activated_widget = render_ribbon( 673 ctx, 674 RibbonInputs { 675 rect: ribbon_rect, 676 ribbon: self.ids.ribbon, 677 ribbon_smart_dimension: self.ids.ribbon_smart_dimension, 678 mode, 679 sketch: active_sketch, 680 selection: entity_ids, 681 }, 682 &mut paints, 683 &mut popover_paints, 684 &mut self.state.ribbon_overflow_open, 685 &mut self.state.ribbon_active_tab, 686 ); 687 update_left_pane_auto( 688 &mut self.state, 689 selection, 690 mode.active_tool(), 691 mode.is_extrude(), 692 ); 693 let tab_clicked = render_left_pane_tabs( 694 ctx, 695 tab_strip_rect, 696 &self.ids, 697 self.state.left_pane, 698 &mut paints, 699 ); 700 if let Some(target) = tab_clicked { 701 self.state.left_pane = target; 702 } 703 let active_pane = self.state.left_pane; 704 let (tree_rect, property_rect) = match active_pane { 705 LeftPane::Tree => (content_rect, zero_rect()), 706 LeftPane::Properties => (zero_rect(), content_rect), 707 }; 708 let feature_tree = render_feature_tree( 709 ctx, 710 &mut self.state.feature_tree, 711 FeatureTreeInputs { 712 rect: tree_rect, 713 tree_id: self.ids.feature_tree, 714 part_id: self.ids.feature_part, 715 document, 716 badges: feature_badges, 717 needs_rebuild, 718 }, 719 &mut paints, 720 ); 721 if let Some((target, anchor)) = feature_tree.context_menu { 722 self.state.feature_menu.open = Some(FeatureMenuAnchor { target, anchor }); 723 self.state.feature_menu.menu = MenuState::default(); 724 } 725 let feature_menu_base = self.ids.feature_tree; 726 let feature_menu_outcome = render_feature_context_menu( 727 ctx, 728 feature_menu_base, 729 &mut self.state, 730 document, 731 &mut popover_paints, 732 ); 733 let pane = render_property_pane( 734 ctx, 735 property_rect, 736 self.ids.property_pane, 737 &mut self.state.clipboard, 738 &mut PaneEditors { 739 dim: &mut self.state.dim_property, 740 extrude: &mut self.state.extrude_property, 741 groups: &mut self.state.property_groups, 742 }, 743 PropertyState { 744 mode, 745 sketch: active_sketch, 746 selection, 747 }, 748 &mut paints, 749 ); 750 let dimension_edit = pane.dimension_edit; 751 let extrude_edit = pane.extrude_edit; 752 render_doc_tabs(ctx, doc_tabs_rect, &self.ids, &mut paints); 753 let status_report: Option<&SketchStatusReport> = if let Some(s) = active_sketch { 754 let v = s.version(); 755 if self 756 .state 757 .status_cache 758 .as_ref() 759 .is_none_or(|(cv, _)| *cv != v) 760 { 761 self.state.status_cache = Some((v, s.status())); 762 } 763 self.state.status_cache.as_ref().map(|(_, r)| r) 764 } else { 765 self.state.status_cache = None; 766 None 767 }; 768 let status_badge_id = status_badge_widget_id(self.ids.status_bar); 769 let extrude_badge_id = extrude_badge_widget_id(self.ids.status_bar); 770 let badge_activated = render_status_bar( 771 ctx, 772 status_rect, 773 self.ids.status_bar, 774 mode, 775 document, 776 cursor_world, 777 status_report, 778 status_badge_id, 779 extrude_status, 780 extrude_badge_id, 781 &mut paints, 782 ); 783 if badge_activated == Some(status_badge_id) { 784 self.state.status_panel_open = !self.state.status_panel_open; 785 } 786 if status_report.is_none_or(|r| r.offending().is_empty()) { 787 self.state.status_panel_open = false; 788 } 789 if self.state.status_panel_open { 790 if let (Some(report), Some(sketch)) = (status_report, active_sketch) { 791 render_status_panel( 792 ctx, 793 status_panel_widget_id(self.ids.status_bar), 794 &mut self.state.status_panel, 795 status_rect, 796 report, 797 sketch, 798 &mut popover_paints, 799 ); 800 } else { 801 self.state.status_panel_open = false; 802 } 803 } 804 if badge_activated == Some(extrude_badge_id) { 805 self.state.extrude_panel_open = !self.state.extrude_panel_open; 806 } 807 let extrude_error = extrude_status.and_then(ExtrudeStatus::error); 808 if self.state.extrude_panel_open { 809 match extrude_error { 810 Some(error) => render_extrude_panel( 811 ctx, 812 extrude_panel_widget_id(self.ids.status_bar), 813 &mut self.state.extrude_panel, 814 status_rect, 815 error, 816 &mut popover_paints, 817 ), 818 None => self.state.extrude_panel_open = false, 819 } 820 } 821 let confirm_visible = 822 mode.is_sketch() || matches!(mode, Mode::Extrude(ExtrudeArming::Profile { .. })); 823 let confirm = 824 render_confirm_corner(ctx, viewport_rect, &self.ids, confirm_visible, &mut paints); 825 let confirm_action = confirm.or(pane.confirm); 826 let normal_to_available = active_sketch.is_some(); 827 let (view_pick, view_menu) = render_view_controls( 828 ctx, 829 ViewControlInputs { 830 viewport: viewport_rect, 831 camera3, 832 ids: &self.ids, 833 view, 834 normal_to_available, 835 confirm_visible, 836 }, 837 &mut paints, 838 &mut popover_paints, 839 ); 840 let heads_up_action = crate::heads_up::render_heads_up_toolbar( 841 ctx, 842 viewport_rect, 843 self.ids.heads_up, 844 &mut paints, 845 ); 846 let menu_action = menu_action.or(heads_up_action); 847 let exit_sketch = confirm_action.is_some() || menu_action == Some(MenuAction::ExitSketch); 848 let activated_tool = activated_widget.and_then(|id| self.tool_index.get(&id).copied()); 849 let activated_feature_tool = 850 activated_widget.and_then(|id| self.feature_tool_index.get(&id).copied()); 851 let activated_relation = resolve_activated_relation( 852 activated_widget, 853 &self.relation_index, 854 active_sketch, 855 entity_ids, 856 ); 857 let activated_dimension = resolve_activated_dimension( 858 activated_widget, 859 self.ids.ribbon_smart_dimension, 860 active_sketch, 861 entity_ids, 862 ); 863 let plane_picked = feature_tree 864 .double_activated 865 .and_then(|id| self.ids.plane_for(id)); 866 let mut sketch_activated = feature_tree.sketch_activated; 867 let sketch_rename = feature_tree.sketch_rename; 868 let mut extrude_activated = feature_tree.extrude_activated; 869 let extrude_rename = feature_tree.extrude_rename; 870 let feature_reorder = feature_tree.reorder; 871 let rollback_change = feature_tree.rollback; 872 let mut feature_command = None; 873 match feature_menu_outcome { 874 Some(FeatureMenuOutcome::EditSketch(sketch_id)) => { 875 sketch_activated = sketch_activated.or(Some(sketch_id)); 876 } 877 Some(FeatureMenuOutcome::EditExtrude(extrude_id)) => { 878 extrude_activated = extrude_activated.or(Some(extrude_id)); 879 } 880 Some(FeatureMenuOutcome::ShowRelationships(target)) => { 881 self.state.relationships = Some(target); 882 } 883 Some(FeatureMenuOutcome::Command(command)) => feature_command = Some(command), 884 None => {} 885 } 886 if let Some(target) = self.state.relationships { 887 let relationships_base = self.ids.feature_tree; 888 let close = render_relationships_panel( 889 ctx, 890 relationships_base, 891 viewport_rect, 892 target, 893 document, 894 &mut popover_paints, 895 ); 896 if close { 897 self.state.relationships = None; 898 } 899 } 900 if feature_tree.part_activated && !whats_wrong.is_empty() { 901 self.state.whats_wrong_open = !self.state.whats_wrong_open; 902 } 903 if whats_wrong.is_empty() { 904 self.state.whats_wrong_open = false; 905 } 906 let mut reattach_request = None; 907 if self.state.whats_wrong_open { 908 let whats_wrong_base = self.ids.feature_tree; 909 let outcome = render_whats_wrong_panel( 910 ctx, 911 whats_wrong_base, 912 viewport_rect, 913 whats_wrong, 914 &mut popover_paints, 915 ); 916 reattach_request = outcome.reattach; 917 if outcome.close || reattach_request.is_some() { 918 self.state.whats_wrong_open = false; 919 } 920 } 921 let mut dialog_paints: Vec<WidgetPaint> = Vec::new(); 922 let settings_change = render_settings_dialog( 923 ctx, 924 viewport_size, 925 &self.ids, 926 &mut self.state, 927 settings, 928 &mut dialog_paints, 929 ); 930 let keyboard_change = render_keyboard_dialog( 931 ctx, 932 viewport_size, 933 &self.ids, 934 &mut self.state, 935 settings, 936 &mut dialog_paints, 937 ); 938 let settings_change = keyboard_change.or(settings_change); 939 let (paints, mut overlay_paints) = partition_overlay(paints, ctx.theme()); 940 overlay_paints.extend(popover_paints); 941 overlay_paints.extend(dialog_paints); 942 ShellFrame { 943 paints, 944 overlay_paints, 945 viewport_rect, 946 activated_tool, 947 activated_feature_tool, 948 activated_relation, 949 activated_dimension, 950 dimension_edit, 951 extrude_edit, 952 plane_picked, 953 sketch_activated, 954 sketch_rename, 955 extrude_activated, 956 extrude_rename, 957 feature_command, 958 feature_reorder, 959 rollback_change, 960 reattach_request, 961 exit_sketch, 962 confirm_action, 963 menu_action, 964 settings_change, 965 view_pick, 966 view_menu, 967 } 968 } 969} 970 971struct ViewControlInputs<'a> { 972 viewport: LayoutRect, 973 camera3: Option<Camera3>, 974 ids: &'a ShellIds, 975 view: &'a mut ViewUi, 976 normal_to_available: bool, 977 confirm_visible: bool, 978} 979 980fn render_view_controls( 981 ctx: &mut FrameCtx<'_>, 982 inputs: ViewControlInputs<'_>, 983 paints: &mut Vec<WidgetPaint>, 984 popover_paints: &mut Vec<WidgetPaint>, 985) -> (Option<ViewPick>, Option<ViewCubeMenuAction>) { 986 let ViewControlInputs { 987 viewport, 988 camera3, 989 ids, 990 view, 991 normal_to_available, 992 confirm_visible, 993 } = inputs; 994 let Some(camera) = camera3 else { 995 return (None, None); 996 }; 997 let outcome = render_view_cube( 998 ctx, 999 ViewCubeInputs { 1000 viewport, 1001 camera, 1002 base: ids.view_cube, 1003 menu_id: ids.view_cube_menu, 1004 view, 1005 normal_to_available, 1006 confirm_visible, 1007 }, 1008 paints, 1009 popover_paints, 1010 ); 1011 let selected = render_view_selector( 1012 ctx, 1013 viewport, 1014 ids.view_selector, 1015 view, 1016 normal_to_available, 1017 popover_paints, 1018 ); 1019 let pick = outcome.pick.or_else(|| selected.map(ViewPick::Standard)); 1020 (pick, outcome.menu) 1021} 1022 1023const SETTINGS_DIALOG_WIDTH: f32 = 420.0; 1024const SETTINGS_DIALOG_HEIGHT: f32 = 256.0; 1025const SETTINGS_DIALOG_GUTTER: f32 = 16.0; 1026const SETTINGS_LABEL_HEIGHT: f32 = 20.0; 1027const SETTINGS_HINT_HEIGHT: f32 = 36.0; 1028const SETTINGS_SLIDER_HEIGHT: f32 = 28.0; 1029const SETTINGS_CHECKBOX_HEIGHT: f32 = 24.0; 1030const SETTINGS_LABEL_TO_HINT_GAP: f32 = 6.0; 1031const SETTINGS_HINT_TO_SLIDER_GAP: f32 = 12.0; 1032const SETTINGS_SLIDER_TO_CHECKBOX_GAP: f32 = 12.0; 1033const PICK_APERTURE_MIN_PX: i32 = 1; 1034const PICK_APERTURE_MAX_PX: i32 = 30; 1035 1036fn render_settings_dialog( 1037 ctx: &mut FrameCtx<'_>, 1038 viewport_size: LayoutSize, 1039 ids: &ShellIds, 1040 state: &mut ShellState, 1041 settings: &Settings, 1042 paints: &mut Vec<WidgetPaint>, 1043) -> Option<Settings> { 1044 if !state.settings_dialog_open { 1045 return None; 1046 } 1047 let viewport = LayoutRect::new( 1048 LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)), 1049 viewport_size, 1050 ); 1051 let buttons = [ 1052 DialogButton::secondary(ids.settings_reset, strings::SETTINGS_RESET), 1053 DialogButton::primary(ids.settings_close, strings::SETTINGS_CLOSE), 1054 ]; 1055 let dialog_size = LayoutSize::new( 1056 LayoutPx::new(SETTINGS_DIALOG_WIDTH), 1057 LayoutPx::new(SETTINGS_DIALOG_HEIGHT), 1058 ); 1059 let aperture_label_text = format!( 1060 "{}: {} px", 1061 ctx.strings.resolve(strings::SETTINGS_PICK_APERTURE_LABEL), 1062 settings.pick_aperture.radius_px(), 1063 ); 1064 let aperture_slider_id = ids.settings_aperture_slider; 1065 let reduce_motion_id = ids.settings_reduce_motion; 1066 let (response, body_change) = show_dialog( 1067 ctx, 1068 Dialog::new( 1069 ids.settings_dialog, 1070 viewport, 1071 dialog_size, 1072 strings::SETTINGS_DIALOG_TITLE, 1073 &buttons, 1074 ), 1075 |ctx, body_rect, paint| { 1076 settings_dialog_body( 1077 ctx, 1078 body_rect, 1079 aperture_slider_id, 1080 reduce_motion_id, 1081 settings, 1082 aperture_label_text, 1083 paint, 1084 ) 1085 }, 1086 ); 1087 paints.extend(response.paint); 1088 if response.dismissed || response.activated == Some(ids.settings_close) { 1089 state.settings_dialog_open = false; 1090 } 1091 if response.activated == Some(ids.settings_reset) { 1092 return Some(Settings { 1093 hotkey_overrides: settings.hotkey_overrides.clone(), 1094 ..Settings::default() 1095 }); 1096 } 1097 body_change 1098} 1099 1100fn settings_dialog_body( 1101 ctx: &mut FrameCtx<'_>, 1102 body_rect: LayoutRect, 1103 aperture_slider_id: WidgetId, 1104 reduce_motion_id: WidgetId, 1105 settings: &Settings, 1106 aperture_label_text: String, 1107 paint: &mut Vec<WidgetPaint>, 1108) -> Option<Settings> { 1109 let label_rect = settings_label_rect(body_rect); 1110 paint.push(WidgetPaint::Label { 1111 rect: label_rect, 1112 text: LabelText::Owned(aperture_label_text), 1113 color: ctx.theme().colors.text_primary(), 1114 role: ctx.theme().typography.label, 1115 }); 1116 let hint_rect = settings_hint_rect(body_rect); 1117 paint.push(WidgetPaint::Label { 1118 rect: hint_rect, 1119 text: LabelText::Key(strings::SETTINGS_PICK_APERTURE_HINT), 1120 color: ctx.theme().colors.text_secondary(), 1121 role: ctx.theme().typography.caption, 1122 }); 1123 let Ok(range) = SliderRange::try_new(PICK_APERTURE_MIN_PX, PICK_APERTURE_MAX_PX) else { 1124 unreachable!("PICK_APERTURE_MIN_PX < PICK_APERTURE_MAX_PX is statically guaranteed"); 1125 }; 1126 let Ok(step) = SliderStep::try_new(1i32) else { 1127 unreachable!("integer step of 1 is positive"); 1128 }; 1129 let initial = i32::try_from(settings.pick_aperture.radius_px()).unwrap_or(PICK_APERTURE_MAX_PX); 1130 let response = show_slider( 1131 ctx, 1132 Slider::new( 1133 aperture_slider_id, 1134 settings_slider_rect(body_rect), 1135 strings::SETTINGS_PICK_APERTURE_LABEL, 1136 initial, 1137 range, 1138 step, 1139 ), 1140 ); 1141 paint.extend(response.paint); 1142 let slider_change = response.changed.then(|| { 1143 let clamped = response 1144 .value 1145 .clamp(PICK_APERTURE_MIN_PX, PICK_APERTURE_MAX_PX); 1146 #[allow( 1147 clippy::cast_sign_loss, 1148 reason = "value clamped to [PICK_APERTURE_MIN_PX, PICK_APERTURE_MAX_PX] which is non-negative" 1149 )] 1150 let radius = clamped as u32; 1151 Settings { 1152 pick_aperture: PickAperture::new(radius), 1153 ..settings.clone() 1154 } 1155 }); 1156 let checkbox_state = if settings.reduce_motion { 1157 CheckboxState::Checked 1158 } else { 1159 CheckboxState::Unchecked 1160 }; 1161 let checkbox = show_checkbox( 1162 ctx, 1163 Checkbox::new( 1164 reduce_motion_id, 1165 settings_checkbox_rect(body_rect), 1166 strings::SETTINGS_REDUCE_MOTION_LABEL, 1167 checkbox_state, 1168 ), 1169 ); 1170 paint.extend(checkbox.paint); 1171 let checkbox_change = checkbox.toggled.then(|| Settings { 1172 reduce_motion: !settings.reduce_motion, 1173 ..settings.clone() 1174 }); 1175 slider_change.or(checkbox_change) 1176} 1177 1178fn settings_row_rect(body: LayoutRect, top_offset: f32, height: f32) -> LayoutRect { 1179 LayoutRect::new( 1180 LayoutPos::new( 1181 LayoutPx::new(body.origin.x.value() + SETTINGS_DIALOG_GUTTER), 1182 LayoutPx::new(body.origin.y.value() + SETTINGS_DIALOG_GUTTER + top_offset), 1183 ), 1184 LayoutSize::new( 1185 LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * SETTINGS_DIALOG_GUTTER), 1186 LayoutPx::new(height), 1187 ), 1188 ) 1189} 1190 1191const SETTINGS_HINT_TOP: f32 = SETTINGS_LABEL_HEIGHT + SETTINGS_LABEL_TO_HINT_GAP; 1192const SETTINGS_SLIDER_TOP: f32 = 1193 SETTINGS_HINT_TOP + SETTINGS_HINT_HEIGHT + SETTINGS_HINT_TO_SLIDER_GAP; 1194const SETTINGS_CHECKBOX_TOP: f32 = 1195 SETTINGS_SLIDER_TOP + SETTINGS_SLIDER_HEIGHT + SETTINGS_SLIDER_TO_CHECKBOX_GAP; 1196 1197fn settings_label_rect(body: LayoutRect) -> LayoutRect { 1198 settings_row_rect(body, 0.0, SETTINGS_LABEL_HEIGHT) 1199} 1200 1201fn settings_hint_rect(body: LayoutRect) -> LayoutRect { 1202 settings_row_rect(body, SETTINGS_HINT_TOP, SETTINGS_HINT_HEIGHT) 1203} 1204 1205fn settings_slider_rect(body: LayoutRect) -> LayoutRect { 1206 settings_row_rect(body, SETTINGS_SLIDER_TOP, SETTINGS_SLIDER_HEIGHT) 1207} 1208 1209fn settings_checkbox_rect(body: LayoutRect) -> LayoutRect { 1210 settings_row_rect(body, SETTINGS_CHECKBOX_TOP, SETTINGS_CHECKBOX_HEIGHT) 1211} 1212 1213const KEYBOARD_DIALOG_WIDTH: f32 = 460.0; 1214const KEYBOARD_DIALOG_HEIGHT: f32 = 420.0; 1215const KEYBOARD_ROW_HEIGHT: f32 = 32.0; 1216const KEYBOARD_ROW_GAP: f32 = 6.0; 1217const KEYBOARD_CAPTURE_WIDTH: f32 = 180.0; 1218const KEYBOARD_HINT_HEIGHT: f32 = 20.0; 1219const KEYBOARD_HINT_TO_ROWS_GAP: f32 = 12.0; 1220 1221fn render_keyboard_dialog( 1222 ctx: &mut FrameCtx<'_>, 1223 viewport_size: LayoutSize, 1224 ids: &ShellIds, 1225 state: &mut ShellState, 1226 settings: &Settings, 1227 paints: &mut Vec<WidgetPaint>, 1228) -> Option<Settings> { 1229 if !state.keyboard_dialog_open { 1230 return None; 1231 } 1232 let viewport = LayoutRect::new( 1233 LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)), 1234 viewport_size, 1235 ); 1236 let buttons = [ 1237 DialogButton::secondary(ids.keyboard_dialog_reset, strings::SETTINGS_RESET), 1238 DialogButton::primary(ids.keyboard_dialog_close, strings::SETTINGS_CLOSE), 1239 ]; 1240 let dialog_size = LayoutSize::new( 1241 LayoutPx::new(KEYBOARD_DIALOG_WIDTH), 1242 LayoutPx::new(KEYBOARD_DIALOG_HEIGHT), 1243 ); 1244 let mut next_overrides: Option<crate::hotkeys::HotkeyOverrides> = None; 1245 let (response, _) = show_dialog( 1246 ctx, 1247 Dialog::new( 1248 ids.keyboard_dialog, 1249 viewport, 1250 dialog_size, 1251 strings::KEYBOARD_DIALOG_TITLE, 1252 &buttons, 1253 ), 1254 |ctx, body_rect, paint| { 1255 next_overrides = keyboard_dialog_body( 1256 ctx, 1257 body_rect, 1258 ids.keyboard_dialog, 1259 state, 1260 &settings.hotkey_overrides, 1261 paint, 1262 ); 1263 Some(()) 1264 }, 1265 ); 1266 paints.extend(response.paint); 1267 if response.dismissed || response.activated == Some(ids.keyboard_dialog_close) { 1268 state.keyboard_dialog_open = false; 1269 state.hotkey_capture.clear(); 1270 } 1271 if response.activated == Some(ids.keyboard_dialog_reset) { 1272 return Some(Settings { 1273 hotkey_overrides: crate::hotkeys::HotkeyOverrides::default(), 1274 ..settings.clone() 1275 }); 1276 } 1277 next_overrides.map(|overrides| Settings { 1278 hotkey_overrides: overrides, 1279 ..settings.clone() 1280 }) 1281} 1282 1283fn keyboard_dialog_header_rects(body_rect: LayoutRect) -> (LayoutRect, LayoutRect, f32) { 1284 let gutter = SETTINGS_DIALOG_GUTTER; 1285 let heading_rect = LayoutRect::new( 1286 LayoutPos::new( 1287 LayoutPx::new(body_rect.origin.x.value() + gutter), 1288 LayoutPx::new(body_rect.origin.y.value() + gutter), 1289 ), 1290 LayoutSize::new( 1291 LayoutPx::saturating_nonneg(body_rect.size.width.value() - 2.0 * gutter), 1292 LayoutPx::new(KEYBOARD_HINT_HEIGHT), 1293 ), 1294 ); 1295 let hint_rect = LayoutRect::new( 1296 LayoutPos::new( 1297 LayoutPx::new(body_rect.origin.x.value() + gutter), 1298 LayoutPx::new( 1299 body_rect.origin.y.value() + gutter + KEYBOARD_HINT_HEIGHT + KEYBOARD_ROW_GAP, 1300 ), 1301 ), 1302 LayoutSize::new( 1303 LayoutPx::saturating_nonneg(body_rect.size.width.value() - 2.0 * gutter), 1304 LayoutPx::new(KEYBOARD_HINT_HEIGHT), 1305 ), 1306 ); 1307 let rows_origin_y = body_rect.origin.y.value() 1308 + gutter 1309 + 2.0 * KEYBOARD_HINT_HEIGHT 1310 + KEYBOARD_ROW_GAP 1311 + KEYBOARD_HINT_TO_ROWS_GAP; 1312 (heading_rect, hint_rect, rows_origin_y) 1313} 1314 1315fn keyboard_row_rects(body_rect: LayoutRect, row_y: f32) -> (LayoutRect, LayoutRect) { 1316 let gutter = SETTINGS_DIALOG_GUTTER; 1317 let label_rect = LayoutRect::new( 1318 LayoutPos::new( 1319 LayoutPx::new(body_rect.origin.x.value() + gutter), 1320 LayoutPx::new(row_y + 4.0), 1321 ), 1322 LayoutSize::new( 1323 LayoutPx::saturating_nonneg( 1324 body_rect.size.width.value() - 3.0 * gutter - KEYBOARD_CAPTURE_WIDTH, 1325 ), 1326 LayoutPx::new(KEYBOARD_ROW_HEIGHT), 1327 ), 1328 ); 1329 let capture_rect = LayoutRect::new( 1330 LayoutPos::new( 1331 LayoutPx::new( 1332 body_rect.origin.x.value() + body_rect.size.width.value() 1333 - gutter 1334 - KEYBOARD_CAPTURE_WIDTH, 1335 ), 1336 LayoutPx::new(row_y), 1337 ), 1338 LayoutSize::new( 1339 LayoutPx::new(KEYBOARD_CAPTURE_WIDTH), 1340 LayoutPx::new(KEYBOARD_ROW_HEIGHT), 1341 ), 1342 ); 1343 (label_rect, capture_rect) 1344} 1345 1346fn keyboard_dialog_body( 1347 ctx: &mut FrameCtx<'_>, 1348 body_rect: LayoutRect, 1349 dialog_id: WidgetId, 1350 state: &mut ShellState, 1351 overrides: &crate::hotkeys::HotkeyOverrides, 1352 paint: &mut Vec<WidgetPaint>, 1353) -> Option<crate::hotkeys::HotkeyOverrides> { 1354 let (heading_rect, hint_rect, rows_origin_y) = keyboard_dialog_header_rects(body_rect); 1355 paint.push(WidgetPaint::Label { 1356 rect: heading_rect, 1357 text: LabelText::Key(strings::HOTKEY_SECTION_HEADING), 1358 color: ctx.theme().colors.text_primary(), 1359 role: ctx.theme().typography.label, 1360 }); 1361 paint.push(WidgetPaint::Label { 1362 rect: hint_rect, 1363 text: LabelText::Key(strings::HOTKEY_RECORDING_HINT), 1364 color: ctx.theme().colors.text_secondary(), 1365 role: ctx.theme().typography.caption, 1366 }); 1367 let entries = crate::hotkeys::remap_entries(); 1368 let row_advance = KEYBOARD_ROW_HEIGHT + KEYBOARD_ROW_GAP; 1369 let captures_changed = entries 1370 .iter() 1371 .fold( 1372 ( 1373 rows_origin_y, 1374 Vec::<(bone_ui::hotkey::ActionId, bone_ui::hotkey::KeyChord)>::new(), 1375 ), 1376 |(row_y, mut acc), entry| { 1377 let (label_rect, capture_rect) = keyboard_row_rects(body_rect, row_y); 1378 paint.push(WidgetPaint::Label { 1379 rect: label_rect, 1380 text: LabelText::Key(entry.label), 1381 color: ctx.theme().colors.text_primary(), 1382 role: ctx.theme().typography.label, 1383 }); 1384 let chord_now = current_chord(overrides, entry); 1385 let placeholder = chord_now.map_or(strings::HOTKEY_UNBOUND_LABEL, |_| entry.label); 1386 let capture_state = state.hotkey_capture.entry(entry.action).or_insert_with(|| { 1387 HotkeyCaptureState { 1388 recording: false, 1389 chord: chord_now, 1390 } 1391 }); 1392 if capture_state.chord.is_none() { 1393 capture_state.chord = chord_now; 1394 } 1395 let response = show_hotkey_capture( 1396 ctx, 1397 HotkeyCapture::new( 1398 capture_widget_id(dialog_id, entry.action), 1399 capture_rect, 1400 placeholder, 1401 strings::HOTKEY_RECORDING_PROMPT, 1402 capture_state, 1403 ), 1404 ); 1405 paint.extend(response.paint); 1406 if let Some(chord) = response.captured { 1407 acc.push((entry.action, chord)); 1408 } 1409 (row_y + row_advance, acc) 1410 }, 1411 ) 1412 .1; 1413 if captures_changed.is_empty() { 1414 return None; 1415 } 1416 let next = captures_changed 1417 .into_iter() 1418 .fold(overrides.clone(), |mut acc, (action, chord)| { 1419 acc.set(action, chord); 1420 acc 1421 }); 1422 Some(next) 1423} 1424 1425fn current_chord( 1426 overrides: &crate::hotkeys::HotkeyOverrides, 1427 entry: &crate::hotkeys::RemapEntry, 1428) -> Option<bone_ui::hotkey::KeyChord> { 1429 overrides.lookup(entry.action).or(entry.default_chord) 1430} 1431 1432fn capture_widget_id(dialog_id: WidgetId, action: bone_ui::hotkey::ActionId) -> WidgetId { 1433 dialog_id.child_indexed(WidgetKey::new("capture"), u64::from(action.get().get())) 1434} 1435 1436fn partition_overlay( 1437 paints: Vec<WidgetPaint>, 1438 theme: &Theme, 1439) -> (Vec<WidgetPaint>, Vec<WidgetPaint>) { 1440 paints.into_iter().fold( 1441 (Vec::new(), Vec::new()), 1442 |(mut main, mut overlay), paint| { 1443 match paint { 1444 WidgetPaint::Tooltip { 1445 rect, 1446 text, 1447 elevation, 1448 .. 1449 } => { 1450 overlay.push(WidgetPaint::Surface { 1451 rect, 1452 fill: theme.colors.surface(elevation.surface), 1453 border: elevation.border, 1454 radius: theme.radius.sm, 1455 elevation: Some(elevation), 1456 }); 1457 overlay.push(WidgetPaint::Label { 1458 rect, 1459 text, 1460 color: theme.colors.text_primary(), 1461 role: theme.typography.caption, 1462 }); 1463 } 1464 WidgetPaint::Popup { paints } => { 1465 let (inner_main, inner_overlay) = partition_overlay(paints, theme); 1466 overlay.extend(inner_main); 1467 overlay.extend(inner_overlay); 1468 } 1469 other => main.push(other), 1470 } 1471 (main, overlay) 1472 }, 1473 ) 1474} 1475 1476fn active_sketch<'a>(document: &'a Document, mode: &Mode) -> Option<&'a Sketch> { 1477 mode.sketch_id().and_then(|id| document.sketch(id)) 1478} 1479 1480fn resolve_activated_relation( 1481 activated_widget: Option<WidgetId>, 1482 relation_index: &BTreeMap<WidgetId, RelationKind>, 1483 sketch: Option<&Sketch>, 1484 selection: &[SketchEntityId], 1485) -> Option<SketchRelation> { 1486 let id = activated_widget?; 1487 let kind = relation_index.get(&id).copied()?; 1488 let sketch = sketch?; 1489 match eligibility(kind, sketch, selection) { 1490 Eligibility::Eligible(rel) => Some(rel), 1491 Eligibility::Disabled(_) => None, 1492 } 1493} 1494 1495fn resolve_activated_dimension( 1496 activated_widget: Option<WidgetId>, 1497 smart_dimension_id: WidgetId, 1498 sketch: Option<&Sketch>, 1499 selection: &[SketchEntityId], 1500) -> Option<PendingDimension> { 1501 if activated_widget? != smart_dimension_id { 1502 return None; 1503 } 1504 match smart_dimension::eligibility(sketch?, selection) { 1505 smart_dimension::Eligibility::Eligible(req) => Some(req), 1506 smart_dimension::Eligibility::Disabled(_) => None, 1507 } 1508} 1509 1510fn smart_dimension_tool_item( 1511 id: WidgetId, 1512 sketch: Option<&Sketch>, 1513 selection: &[SketchEntityId], 1514 sketch_disabled: bool, 1515) -> ToolbarItem { 1516 let item = ToolbarItem::new(id, strings::TOOL_SMART_DIMENSION) 1517 .with_icon(RibbonIconSize::Large.slot(IconId::SmartDimension)); 1518 if sketch_disabled { 1519 return item.disabled(true); 1520 } 1521 let Some(sketch) = sketch else { 1522 return item.disabled(true); 1523 }; 1524 match smart_dimension::eligibility(sketch, selection) { 1525 smart_dimension::Eligibility::Eligible(_) => item, 1526 smart_dimension::Eligibility::Disabled(reason) => item.disabled(true).with_tooltip(reason), 1527 } 1528} 1529 1530#[allow( 1531 clippy::too_many_arguments, 1532 reason = "menu bar render bundles a handful of shell-owned references" 1533)] 1534fn render_menu_bar( 1535 ctx: &mut FrameCtx<'_>, 1536 rect: LayoutRect, 1537 ids: &ShellIds, 1538 state: &mut MenuBarState, 1539 document: &Document, 1540 is_sketch: bool, 1541 overrides: &crate::hotkeys::HotkeyOverrides, 1542 paints: &mut Vec<WidgetPaint>, 1543 popover_paints: &mut Vec<WidgetPaint>, 1544) -> Option<MenuAction> { 1545 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 1546 return None; 1547 } 1548 let entries = build_menu_entries(ids, is_sketch, overrides); 1549 let response = show_menu_bar( 1550 ctx, 1551 MenuBar::new(ids.menu_bar, rect, strings::MENU_BAR_LABEL, &entries, state) 1552 .with_document_label(LabelText::Owned(document.name().to_owned())), 1553 ); 1554 paints.extend(response.paint); 1555 popover_paints.extend(response.popover_paint); 1556 response.activated.and_then(|id| ids.menu_action_for(id)) 1557} 1558 1559#[allow( 1560 clippy::too_many_lines, 1561 reason = "menu entries are flat data; splitting would scatter related strings" 1562)] 1563fn build_menu_entries( 1564 ids: &ShellIds, 1565 is_sketch: bool, 1566 overrides: &crate::hotkeys::HotkeyOverrides, 1567) -> Vec<MenuBarEntry> { 1568 let placeholder = |menu_id: WidgetId, key: &'static str| MenuItem::Action { 1569 id: menu_id.child(WidgetKey::new(key)), 1570 label: strings::MENU_PLACEHOLDER_COMING_SOON, 1571 shortcut: None, 1572 disabled: true, 1573 }; 1574 let action_with_accel = 1575 |id: WidgetId, label: StringKey, accel: Option<bone_ui::hotkey::ActionId>| { 1576 let shortcut = accel 1577 .and_then(|a| crate::hotkeys::accelerator_label(a, overrides)) 1578 .map(LabelText::Owned); 1579 MenuItem::Action { 1580 id, 1581 label, 1582 shortcut, 1583 disabled: false, 1584 } 1585 }; 1586 let file = ids.menu_file; 1587 let mut entries = vec![ 1588 MenuBarEntry { 1589 id: file, 1590 label: strings::MENU_FILE, 1591 items: vec![ 1592 action_with_accel( 1593 ids.menu_file_new, 1594 strings::MENU_FILE_NEW, 1595 Some(crate::hotkeys::NEW_DOCUMENT_ACTION), 1596 ), 1597 action_with_accel( 1598 ids.menu_file_open, 1599 strings::MENU_FILE_OPEN, 1600 Some(crate::hotkeys::OPEN_DOCUMENT_ACTION), 1601 ), 1602 action_with_accel( 1603 ids.menu_file_save, 1604 strings::MENU_FILE_SAVE, 1605 Some(crate::hotkeys::SAVE_DOCUMENT_ACTION), 1606 ), 1607 action_with_accel(ids.menu_file_save_as, strings::MENU_FILE_SAVE_AS, None), 1608 MenuItem::Separator, 1609 action_with_accel( 1610 ids.menu_file_import, 1611 strings::MENU_FILE_IMPORT, 1612 Some(crate::hotkeys::IMPORT_STEP_ACTION), 1613 ), 1614 MenuItem::Submenu { 1615 id: ids.menu_file_export, 1616 label: strings::MENU_FILE_EXPORT, 1617 items: vec![action_with_accel( 1618 ids.menu_file_export_step, 1619 strings::MENU_FILE_EXPORT_STEP, 1620 Some(crate::hotkeys::EXPORT_STEP_ACTION), 1621 )], 1622 }, 1623 MenuItem::Separator, 1624 action_with_accel( 1625 ids.menu_file_quit, 1626 strings::MENU_FILE_QUIT, 1627 Some(crate::hotkeys::QUIT_ACTION), 1628 ), 1629 ], 1630 }, 1631 MenuBarEntry { 1632 id: ids.menu_edit, 1633 label: strings::MENU_EDIT, 1634 items: vec![ 1635 action_with_accel( 1636 ids.menu_edit_undo, 1637 strings::MENU_EDIT_UNDO, 1638 Some(crate::sketch_mode::UNDO_ACTION), 1639 ), 1640 action_with_accel( 1641 ids.menu_edit_redo, 1642 strings::MENU_EDIT_REDO, 1643 Some(crate::sketch_mode::REDO_ACTION), 1644 ), 1645 ], 1646 }, 1647 MenuBarEntry { 1648 id: ids.menu_view, 1649 label: strings::MENU_VIEW, 1650 items: vec![action_with_accel( 1651 ids.menu_view_zoom_fit, 1652 strings::MENU_VIEW_ZOOM_FIT, 1653 Some(crate::hotkeys::ZOOM_FIT_ACTION), 1654 )], 1655 }, 1656 MenuBarEntry { 1657 id: ids.menu_insert, 1658 label: strings::MENU_INSERT, 1659 items: vec![placeholder(ids.menu_insert, "soon")], 1660 }, 1661 MenuBarEntry { 1662 id: ids.menu_tools, 1663 label: strings::MENU_TOOLS, 1664 items: vec![ 1665 action_with_accel(ids.menu_tools_options, strings::MENU_TOOLS_OPTIONS, None), 1666 action_with_accel(ids.menu_tools_keyboard, strings::MENU_TOOLS_KEYBOARD, None), 1667 ], 1668 }, 1669 ]; 1670 if is_sketch { 1671 entries.push(MenuBarEntry { 1672 id: ids.menu_sketch, 1673 label: strings::MENU_SKETCH, 1674 items: vec![action_with_accel( 1675 ids.menu_sketch_exit, 1676 strings::MENU_SKETCH_EXIT, 1677 Some(crate::sketch_mode::ESCAPE_ACTION), 1678 )], 1679 }); 1680 } 1681 entries.extend([ 1682 MenuBarEntry { 1683 id: ids.menu_window, 1684 label: strings::MENU_WINDOW, 1685 items: vec![placeholder(ids.menu_window, "soon")], 1686 }, 1687 MenuBarEntry { 1688 id: ids.menu_help, 1689 label: strings::MENU_HELP, 1690 items: vec![placeholder(ids.menu_help, "soon")], 1691 }, 1692 ]); 1693 entries 1694} 1695 1696#[derive(Copy, Clone)] 1697struct RibbonInputs<'a> { 1698 rect: LayoutRect, 1699 ribbon: WidgetId, 1700 ribbon_smart_dimension: WidgetId, 1701 mode: &'a Mode, 1702 sketch: Option<&'a Sketch>, 1703 selection: &'a [SketchEntityId], 1704} 1705 1706fn sketch_tab_id(ribbon: WidgetId) -> WidgetId { 1707 ribbon.child(WidgetKey::new("tab.sketch")) 1708} 1709 1710fn features_tab_id(ribbon: WidgetId) -> WidgetId { 1711 ribbon.child(WidgetKey::new("tab.features")) 1712} 1713 1714fn sync_ribbon_tab_to_mode(state: &mut ShellState, ribbon: WidgetId, mode: &Mode) { 1715 let in_sketch = mode.is_sketch(); 1716 if in_sketch && !state.last_mode_was_sketch { 1717 state.ribbon_active_tab = Some(sketch_tab_id(ribbon)); 1718 } 1719 state.last_mode_was_sketch = in_sketch; 1720} 1721 1722fn render_ribbon( 1723 ctx: &mut FrameCtx<'_>, 1724 inputs: RibbonInputs<'_>, 1725 paints: &mut Vec<WidgetPaint>, 1726 popover_paints: &mut Vec<WidgetPaint>, 1727 overflow_open: &mut BTreeMap<WidgetId, bool>, 1728 active_tab: &mut Option<WidgetId>, 1729) -> Option<WidgetId> { 1730 let RibbonInputs { 1731 rect, 1732 ribbon, 1733 ribbon_smart_dimension, 1734 mode, 1735 sketch, 1736 selection, 1737 } = inputs; 1738 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 1739 return None; 1740 } 1741 let active_tool = mode.active_tool(); 1742 let tools_disabled = !mode.is_sketch(); 1743 let label_font_size_px = ctx.theme().typography.caption.size.as_px_f32(); 1744 let size_item = |item: ToolbarItem, min_width: LayoutPx| -> ToolbarItem { 1745 let resolved = ctx.strings.resolve(item.label); 1746 let width = estimate_label_width(resolved, label_font_size_px, min_width); 1747 item.with_width(width) 1748 }; 1749 let large_min = RibbonIconSize::Large.item_px(); 1750 let small_min = RibbonIconSize::Small.item_px(); 1751 let entity_items: Vec<ToolbarItem> = SketchTool::ENTITIES 1752 .iter() 1753 .copied() 1754 .map(|t| { 1755 size_item( 1756 ToolbarItem::new(tool_widget_id(ribbon, t), tool_label(t)) 1757 .with_icon(RibbonIconSize::Large.slot(tool_icon(t))) 1758 .active(active_tool == Some(t)) 1759 .disabled(tools_disabled), 1760 large_min, 1761 ) 1762 }) 1763 .collect(); 1764 let dimension_items = vec![size_item( 1765 smart_dimension_tool_item(ribbon_smart_dimension, sketch, selection, tools_disabled), 1766 large_min, 1767 )]; 1768 let relation_items: Vec<ToolbarItem> = 1769 relation_tool_buttons(ribbon, sketch, selection, tools_disabled) 1770 .into_iter() 1771 .map(|item| size_item(item, small_min)) 1772 .collect(); 1773 let feature_items = feature_tool_items(ctx, ribbon, mode, large_min); 1774 let sketch_tab_id = sketch_tab_id(ribbon); 1775 let features_tab_id = features_tab_id(ribbon); 1776 let groups = build_sketch_groups( 1777 ribbon, 1778 entity_items, 1779 relation_items, 1780 dimension_items, 1781 large_min, 1782 small_min, 1783 overflow_open, 1784 ); 1785 let feature_groups = build_feature_groups(ribbon, feature_items, large_min, overflow_open); 1786 let placeholder_tab = |key: &'static str, label: StringKey| { 1787 RibbonTab::new(ribbon.child(WidgetKey::new(key)), label, Vec::new()).disabled(true) 1788 }; 1789 let tabs = [ 1790 RibbonTab::new( 1791 features_tab_id, 1792 strings::RIBBON_TAB_FEATURES, 1793 feature_groups, 1794 ), 1795 RibbonTab::new(sketch_tab_id, strings::RIBBON_TAB_SKETCH, groups), 1796 placeholder_tab("tab.surfaces", strings::RIBBON_TAB_SURFACES), 1797 placeholder_tab("tab.evaluate", strings::RIBBON_TAB_EVALUATE), 1798 ]; 1799 let selected_tab = active_tab.unwrap_or(sketch_tab_id); 1800 let pointer_pressed = !ctx.input.buttons_pressed.is_empty(); 1801 let response = show_ribbon( 1802 ctx, 1803 Ribbon::new(ribbon, rect, strings::RIBBON_LABEL, &tabs, selected_tab), 1804 ); 1805 if let Some(tab) = response.activated_tab { 1806 *active_tab = Some(tab); 1807 } 1808 process_ribbon_response( 1809 response, 1810 paints, 1811 popover_paints, 1812 overflow_open, 1813 pointer_pressed, 1814 ) 1815} 1816 1817fn build_sketch_groups( 1818 ribbon: WidgetId, 1819 entity_items: Vec<ToolbarItem>, 1820 relation_items: Vec<ToolbarItem>, 1821 dimension_items: Vec<ToolbarItem>, 1822 large_min: LayoutPx, 1823 small_min: LayoutPx, 1824 overflow_open: &BTreeMap<WidgetId, bool>, 1825) -> Vec<RibbonGroup> { 1826 let large_rows = RibbonIconSize::Large.rows(); 1827 let small_rows = RibbonIconSize::Small.rows(); 1828 let dimensions_preferred = group_width_for(&dimension_items, large_min, large_rows); 1829 let relations_width = group_width_for(&relation_items, small_min, small_rows); 1830 let entities_id = ribbon.child(WidgetKey::new("group.entities")); 1831 let relations_id = ribbon.child(WidgetKey::new("group.relations")); 1832 let dimensions_id = ribbon.child(WidgetKey::new("group.dimensions")); 1833 let open_of = |id: WidgetId| overflow_open.get(&id).copied().unwrap_or(false); 1834 vec![ 1835 RibbonGroup { 1836 id: entities_id, 1837 label: strings::RIBBON_GROUP_ENTITIES, 1838 min_width: group_min_width(large_min, entity_items.len()), 1839 width: group_width_for(&entity_items, large_min, large_rows), 1840 items: entity_items, 1841 icon_size: RibbonIconSize::Large, 1842 overflow_open: open_of(entities_id), 1843 overflow_label: Some(strings::TOOLBAR_OVERFLOW), 1844 }, 1845 RibbonGroup { 1846 id: relations_id, 1847 label: strings::RIBBON_GROUP_RELATIONS, 1848 min_width: relations_width, 1849 width: relations_width, 1850 items: relation_items, 1851 icon_size: RibbonIconSize::Small, 1852 overflow_open: open_of(relations_id), 1853 overflow_label: Some(strings::TOOLBAR_OVERFLOW), 1854 }, 1855 RibbonGroup { 1856 id: dimensions_id, 1857 label: strings::RIBBON_GROUP_DIMENSIONS, 1858 min_width: dimensions_preferred, 1859 width: dimensions_preferred, 1860 items: dimension_items, 1861 icon_size: RibbonIconSize::Large, 1862 overflow_open: open_of(dimensions_id), 1863 overflow_label: Some(strings::TOOLBAR_OVERFLOW), 1864 }, 1865 ] 1866} 1867 1868fn build_feature_groups( 1869 ribbon: WidgetId, 1870 feature_items: Vec<ToolbarItem>, 1871 large_min: LayoutPx, 1872 overflow_open: &BTreeMap<WidgetId, bool>, 1873) -> Vec<RibbonGroup> { 1874 let extrude_id = ribbon.child(WidgetKey::new("group.extrude")); 1875 vec![RibbonGroup { 1876 id: extrude_id, 1877 label: strings::RIBBON_GROUP_EXTRUDE, 1878 min_width: group_min_width(large_min, feature_items.len()), 1879 width: group_width_for(&feature_items, large_min, RibbonIconSize::Large.rows()), 1880 items: feature_items, 1881 icon_size: RibbonIconSize::Large, 1882 overflow_open: overflow_open.get(&extrude_id).copied().unwrap_or(false), 1883 overflow_label: Some(strings::TOOLBAR_OVERFLOW), 1884 }] 1885} 1886 1887fn process_ribbon_response( 1888 response: bone_ui::widgets::RibbonResponse, 1889 paints: &mut Vec<WidgetPaint>, 1890 popover_paints: &mut Vec<WidgetPaint>, 1891 overflow_open: &mut BTreeMap<WidgetId, bool>, 1892 pointer_pressed: bool, 1893) -> Option<WidgetId> { 1894 paints.extend(response.paint); 1895 popover_paints.extend(response.popover_paint); 1896 response.overflow_toggled.iter().for_each(|id| { 1897 let entry = overflow_open.entry(*id).or_insert(false); 1898 *entry = !*entry; 1899 }); 1900 if let Some(toggled_id) = response.overflow_toggled.first().copied() 1901 && overflow_open.get(&toggled_id).copied().unwrap_or(false) 1902 { 1903 overflow_open 1904 .iter_mut() 1905 .filter(|(k, _)| **k != toggled_id) 1906 .for_each(|(_, v)| *v = false); 1907 } 1908 let any_open = overflow_open.values().any(|v| *v); 1909 let activated_anything = response.activated_tool.is_some(); 1910 let outside_click = pointer_pressed 1911 && any_open 1912 && response.overflow_toggled.is_empty() 1913 && !response.popup_consumed_click; 1914 if activated_anything || outside_click { 1915 overflow_open.values_mut().for_each(|v| *v = false); 1916 } 1917 response.activated_tool 1918} 1919 1920#[derive(Clone, Debug, PartialEq)] 1921pub struct SketchRenameRequest { 1922 pub id: SketchId, 1923 pub label: String, 1924} 1925 1926#[derive(Clone, Debug, PartialEq)] 1927pub struct ExtrudeRenameRequest { 1928 pub id: ExtrudeId, 1929 pub label: String, 1930} 1931 1932struct FeatureTreeOutcome { 1933 double_activated: Option<WidgetId>, 1934 sketch_activated: Option<SketchId>, 1935 sketch_rename: Option<SketchRenameRequest>, 1936 extrude_activated: Option<ExtrudeId>, 1937 extrude_rename: Option<ExtrudeRenameRequest>, 1938 context_menu: Option<(FeatureTarget, LayoutPos)>, 1939 reorder: Option<FeatureReorder>, 1940 rollback: Option<RollbackChange>, 1941 part_activated: bool, 1942} 1943 1944fn sketch_widget_id(part_id: WidgetId, sketch_id: SketchId) -> WidgetId { 1945 part_id.child_indexed(WidgetKey::new("sketch"), sketch_id.as_u64()) 1946} 1947 1948fn extrude_widget_id(part_id: WidgetId, extrude_id: ExtrudeId) -> WidgetId { 1949 part_id.child_indexed(WidgetKey::new("extrude"), extrude_id.as_u64()) 1950} 1951 1952fn sketch_leaf( 1953 document: &Document, 1954 part_id: WidgetId, 1955 sketch_id: SketchId, 1956 badges: &BTreeMap<FeatureId, TreeBadge>, 1957) -> TreeNode { 1958 let widget_id = sketch_widget_id(part_id, sketch_id); 1959 let label = document.sketch_label(sketch_id).unwrap_or("").to_owned(); 1960 let feature = document.feature_tree().feature_of_sketch(sketch_id); 1961 let badge = feature.and_then(|feature| badges.get(&feature).copied()); 1962 let rolled_back = feature.is_some_and(|feature| document.is_rolled_back(feature)); 1963 TreeNode::leaf_owned(widget_id, label) 1964 .with_icon(IconId::TreeSketch) 1965 .with_badge(badge) 1966 .disabled(rolled_back) 1967} 1968 1969fn source_sketch_of_extrude(document: &Document, extrude_id: ExtrudeId) -> Option<SketchId> { 1970 let tree = document.feature_tree(); 1971 let extrude_feature = tree.feature_of_extrude(extrude_id)?; 1972 tree.parents(extrude_feature) 1973 .into_iter() 1974 .find_map(|parent| match tree.node(parent) { 1975 Some(FeatureNode::Sketch(sketch_id)) => Some(sketch_id), 1976 _ => None, 1977 }) 1978} 1979 1980fn extrude_feature_node( 1981 document: &Document, 1982 part_id: WidgetId, 1983 extrude_id: ExtrudeId, 1984 badges: &BTreeMap<FeatureId, TreeBadge>, 1985) -> TreeNode { 1986 let widget_id = extrude_widget_id(part_id, extrude_id); 1987 let label = document.extrude_label(extrude_id).unwrap_or("").to_owned(); 1988 let nested: Vec<TreeNode> = source_sketch_of_extrude(document, extrude_id) 1989 .map(|sketch_id| sketch_leaf(document, part_id, sketch_id, badges)) 1990 .into_iter() 1991 .collect(); 1992 let feature = document.feature_tree().feature_of_extrude(extrude_id); 1993 let own = feature.and_then(|feature| badges.get(&feature).copied()); 1994 let child = nested.iter().filter_map(|node| node.badge).max(); 1995 let badge = [own, child].into_iter().flatten().max(); 1996 let rolled_back = feature.is_some_and(|feature| document.is_rolled_back(feature)); 1997 TreeNode::parent_owned(widget_id, label, nested) 1998 .with_icon(IconId::TreeFeature) 1999 .with_badge(badge) 2000 .disabled(rolled_back) 2001} 2002 2003fn consumed_sketches(document: &Document) -> BTreeSet<SketchId> { 2004 let tree = document.feature_tree(); 2005 tree.edges() 2006 .iter() 2007 .filter_map(|edge| match edge { 2008 FeatureEdge::SketchToExtrude { sketch, .. } => match tree.node(*sketch) { 2009 Some(FeatureNode::Sketch(sketch_id)) => Some(sketch_id), 2010 _ => None, 2011 }, 2012 FeatureEdge::FaceToSketch { .. } => None, 2013 }) 2014 .collect() 2015} 2016 2017fn feature_tree_children( 2018 document: &Document, 2019 part_id: WidgetId, 2020 badges: &BTreeMap<FeatureId, TreeBadge>, 2021) -> Vec<TreeNode> { 2022 let leaf = |key: &'static str, label: StringKey| { 2023 TreeNode::leaf(part_id.child(WidgetKey::new(key)), label) 2024 }; 2025 let feature_leaf = 2026 |key: &'static str, label: StringKey| leaf(key, label).with_icon(IconId::TreeFeature); 2027 let placeholder = |key: &'static str, label: StringKey| feature_leaf(key, label).disabled(true); 2028 let plane_leaf = 2029 |key: &'static str, label: StringKey| leaf(key, label).with_icon(IconId::TreePlane); 2030 [ 2031 placeholder("history", strings::FEATURE_HISTORY), 2032 placeholder("sensors", strings::FEATURE_SENSORS), 2033 placeholder("annotations", strings::FEATURE_ANNOTATIONS), 2034 placeholder("solid_bodies", strings::FEATURE_SOLID_BODIES), 2035 placeholder("material", strings::FEATURE_MATERIAL), 2036 plane_leaf("plane.xy", strings::FEATURE_PLANE_XY), 2037 plane_leaf("plane.yz", strings::FEATURE_PLANE_YZ), 2038 plane_leaf("plane.zx", strings::FEATURE_PLANE_ZX), 2039 leaf("origin", strings::FEATURE_ORIGIN).with_icon(IconId::TreeOrigin), 2040 ] 2041 .into_iter() 2042 .chain(feature_rows(document, part_id, badges)) 2043 .collect() 2044} 2045 2046fn tree_illegal_drop( 2047 document: &Document, 2048 state: &TreeViewState, 2049 widget_to_sketch: &BTreeMap<WidgetId, SketchId>, 2050 widget_to_extrude: &BTreeMap<WidgetId, ExtrudeId>, 2051) -> bool { 2052 state 2053 .drag_source 2054 .zip(state.drop_target) 2055 .is_some_and(|(src, target)| { 2056 drop_to_reorder(document, widget_to_sketch, widget_to_extrude, src, target).is_none() 2057 }) 2058} 2059 2060fn feature_rows( 2061 document: &Document, 2062 part_id: WidgetId, 2063 badges: &BTreeMap<FeatureId, TreeBadge>, 2064) -> Vec<TreeNode> { 2065 let consumed = consumed_sketches(document); 2066 document 2067 .feature_tree() 2068 .iter() 2069 .filter_map(|(_, node)| match node { 2070 FeatureNode::Extrude(extrude_id) => { 2071 Some(extrude_feature_node(document, part_id, extrude_id, badges)) 2072 } 2073 FeatureNode::Sketch(sketch_id) if !consumed.contains(&sketch_id) => { 2074 Some(sketch_leaf(document, part_id, sketch_id, badges)) 2075 } 2076 _ => None, 2077 }) 2078 .collect() 2079} 2080 2081fn sketch_widget_ids(document: &Document, part_id: WidgetId) -> Vec<(SketchId, WidgetId)> { 2082 document 2083 .sketches() 2084 .map(|(sketch_id, _)| (sketch_id, sketch_widget_id(part_id, sketch_id))) 2085 .collect() 2086} 2087 2088fn extrude_widget_ids(document: &Document, part_id: WidgetId) -> Vec<(ExtrudeId, WidgetId)> { 2089 document 2090 .feature_tree() 2091 .iter() 2092 .filter_map(|(_, node)| match node { 2093 FeatureNode::Extrude(extrude_id) => Some(extrude_id), 2094 _ => None, 2095 }) 2096 .map(|extrude_id| (extrude_id, extrude_widget_id(part_id, extrude_id))) 2097 .collect() 2098} 2099 2100#[derive(Copy, Clone)] 2101struct FeatureTreeInputs<'a> { 2102 rect: LayoutRect, 2103 tree_id: WidgetId, 2104 part_id: WidgetId, 2105 document: &'a Document, 2106 badges: &'a BTreeMap<FeatureId, TreeBadge>, 2107 needs_rebuild: bool, 2108} 2109 2110impl FeatureTreeOutcome { 2111 fn empty() -> Self { 2112 Self { 2113 double_activated: None, 2114 sketch_activated: None, 2115 sketch_rename: None, 2116 extrude_activated: None, 2117 extrude_rename: None, 2118 context_menu: None, 2119 reorder: None, 2120 rollback: None, 2121 part_activated: false, 2122 } 2123 } 2124} 2125 2126fn resolve_renames( 2127 commit: Option<&RenameCommit>, 2128 widget_to_sketch: &BTreeMap<WidgetId, SketchId>, 2129 widget_to_extrude: &BTreeMap<WidgetId, ExtrudeId>, 2130) -> (Option<SketchRenameRequest>, Option<ExtrudeRenameRequest>) { 2131 let sketch = commit.and_then(|RenameCommit { id, text }| { 2132 widget_to_sketch 2133 .get(id) 2134 .copied() 2135 .map(|sketch_id| SketchRenameRequest { 2136 id: sketch_id, 2137 label: text.clone(), 2138 }) 2139 }); 2140 let extrude = commit.and_then(|RenameCommit { id, text }| { 2141 widget_to_extrude 2142 .get(id) 2143 .copied() 2144 .map(|extrude_id| ExtrudeRenameRequest { 2145 id: extrude_id, 2146 label: text.clone(), 2147 }) 2148 }); 2149 (sketch, extrude) 2150} 2151 2152fn render_feature_tree( 2153 ctx: &mut FrameCtx<'_>, 2154 state: &mut TreeViewState, 2155 inputs: FeatureTreeInputs<'_>, 2156 paints: &mut Vec<WidgetPaint>, 2157) -> FeatureTreeOutcome { 2158 let FeatureTreeInputs { 2159 rect, 2160 tree_id, 2161 part_id, 2162 document, 2163 badges, 2164 needs_rebuild, 2165 } = inputs; 2166 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 2167 return FeatureTreeOutcome::empty(); 2168 } 2169 let sketch_ids = sketch_widget_ids(document, part_id); 2170 let extrude_ids = extrude_widget_ids(document, part_id); 2171 let renamable: Vec<WidgetId> = sketch_ids 2172 .iter() 2173 .map(|(_, w)| *w) 2174 .chain(extrude_ids.iter().map(|(_, w)| *w)) 2175 .collect(); 2176 let widget_to_sketch: BTreeMap<WidgetId, SketchId> = 2177 sketch_ids.iter().map(|(s, w)| (*w, *s)).collect(); 2178 let widget_to_extrude: BTreeMap<WidgetId, ExtrudeId> = 2179 extrude_ids.iter().map(|(e, w)| (*w, *e)).collect(); 2180 let children = feature_tree_children(document, part_id, badges); 2181 let part_badge = [ 2182 badges.values().copied().max(), 2183 needs_rebuild.then_some(TreeBadge::RebuildNeeded), 2184 ] 2185 .into_iter() 2186 .flatten() 2187 .max(); 2188 let part = TreeNode::parent_owned(part_id, document.name().to_owned(), children) 2189 .with_badge(part_badge); 2190 let roots = [part]; 2191 let illegal_drop = tree_illegal_drop(document, state, &widget_to_sketch, &widget_to_extrude); 2192 let stops = rollback_stops(document, part_id); 2193 let rollback_bar = (!stops.is_empty()).then(|| RollbackBar { 2194 stops: &stops, 2195 marker: current_rollback_target(document, part_id), 2196 }); 2197 let response = show_tree_view( 2198 ctx, 2199 TreeView::new(tree_id, rect, strings::FEATURE_TREE_LABEL, &roots, state) 2200 .renamable(&renamable) 2201 .illegal_drop(illegal_drop) 2202 .rollback(rollback_bar), 2203 ); 2204 paints.extend(response.paint); 2205 let reorder = response.drop_committed.and_then(|(src, target)| { 2206 drop_to_reorder(document, &widget_to_sketch, &widget_to_extrude, src, target) 2207 }); 2208 let rollback = response.rollback_moved.and_then(|target| { 2209 resolve_rollback_change(document, &widget_to_sketch, &widget_to_extrude, target) 2210 }); 2211 let sketch_activated = response 2212 .double_activated 2213 .and_then(|id| widget_to_sketch.get(&id).copied()); 2214 let extrude_activated = response 2215 .double_activated 2216 .and_then(|id| widget_to_extrude.get(&id).copied()); 2217 let (sketch_rename, extrude_rename) = resolve_renames( 2218 response.rename_committed.as_ref(), 2219 &widget_to_sketch, 2220 &widget_to_extrude, 2221 ); 2222 let context_menu = 2223 resolve_context_menu_target(response.context_menu, &widget_to_sketch, &widget_to_extrude); 2224 FeatureTreeOutcome { 2225 double_activated: response.double_activated, 2226 sketch_activated, 2227 sketch_rename, 2228 extrude_activated, 2229 extrude_rename, 2230 context_menu, 2231 reorder, 2232 rollback, 2233 part_activated: response.activated == Some(part_id), 2234 } 2235} 2236 2237fn widget_feature( 2238 document: &Document, 2239 widget: WidgetId, 2240 widget_to_sketch: &BTreeMap<WidgetId, SketchId>, 2241 widget_to_extrude: &BTreeMap<WidgetId, ExtrudeId>, 2242) -> Option<FeatureId> { 2243 let tree = document.feature_tree(); 2244 widget_to_sketch 2245 .get(&widget) 2246 .and_then(|sketch_id| tree.feature_of_sketch(*sketch_id)) 2247 .or_else(|| { 2248 widget_to_extrude 2249 .get(&widget) 2250 .and_then(|extrude_id| tree.feature_of_extrude(*extrude_id)) 2251 }) 2252} 2253 2254fn drop_to_reorder( 2255 document: &Document, 2256 widget_to_sketch: &BTreeMap<WidgetId, SketchId>, 2257 widget_to_extrude: &BTreeMap<WidgetId, ExtrudeId>, 2258 source: WidgetId, 2259 target: DropTarget, 2260) -> Option<FeatureReorder> { 2261 let moved = widget_feature(document, source, widget_to_sketch, widget_to_extrude)?; 2262 let anchor = widget_feature(document, target.anchor, widget_to_sketch, widget_to_extrude)?; 2263 let before = match target.placement { 2264 DropPlacement::Before => true, 2265 DropPlacement::After => false, 2266 }; 2267 document 2268 .reorder_is_legal(moved, anchor, before) 2269 .then_some(FeatureReorder { 2270 moved, 2271 anchor, 2272 before, 2273 }) 2274} 2275 2276fn feature_widget_id( 2277 document: &Document, 2278 part_id: WidgetId, 2279 feature: FeatureId, 2280) -> Option<WidgetId> { 2281 match document.feature_tree().node(feature)? { 2282 FeatureNode::Sketch(sketch_id) => Some(sketch_widget_id(part_id, sketch_id)), 2283 FeatureNode::Extrude(extrude_id) => Some(extrude_widget_id(part_id, extrude_id)), 2284 _ => None, 2285 } 2286} 2287 2288fn rollback_stops(document: &Document, part_id: WidgetId) -> Vec<WidgetId> { 2289 let consumed = consumed_sketches(document); 2290 document 2291 .feature_tree() 2292 .iter() 2293 .filter_map(|(_, node)| match node { 2294 FeatureNode::Extrude(extrude_id) => Some(extrude_widget_id(part_id, extrude_id)), 2295 FeatureNode::Sketch(sketch_id) if !consumed.contains(&sketch_id) => { 2296 Some(sketch_widget_id(part_id, sketch_id)) 2297 } 2298 _ => None, 2299 }) 2300 .collect() 2301} 2302 2303fn current_rollback_target(document: &Document, part_id: WidgetId) -> RollbackTarget { 2304 match document.rollback() { 2305 RollbackMarker::AtEnd => RollbackTarget::AtEnd, 2306 RollbackMarker::Above(feature) => feature_widget_id(document, part_id, feature) 2307 .map_or(RollbackTarget::AtEnd, RollbackTarget::Above), 2308 } 2309} 2310 2311fn resolve_rollback_change( 2312 document: &Document, 2313 widget_to_sketch: &BTreeMap<WidgetId, SketchId>, 2314 widget_to_extrude: &BTreeMap<WidgetId, ExtrudeId>, 2315 target: RollbackTarget, 2316) -> Option<RollbackChange> { 2317 match target { 2318 RollbackTarget::AtEnd => Some(RollbackChange::ToEnd), 2319 RollbackTarget::Above(widget) => { 2320 widget_feature(document, widget, widget_to_sketch, widget_to_extrude) 2321 .map(RollbackChange::ToFeature) 2322 } 2323 } 2324} 2325 2326fn resolve_context_menu_target( 2327 request: Option<ContextMenuRequest>, 2328 widget_to_sketch: &BTreeMap<WidgetId, SketchId>, 2329 widget_to_extrude: &BTreeMap<WidgetId, ExtrudeId>, 2330) -> Option<(FeatureTarget, LayoutPos)> { 2331 let ContextMenuRequest { target, at } = request?; 2332 widget_to_sketch 2333 .get(&target) 2334 .map(|sketch_id| FeatureTarget::Sketch(*sketch_id)) 2335 .or_else(|| { 2336 widget_to_extrude 2337 .get(&target) 2338 .map(|extrude_id| FeatureTarget::Extrude(*extrude_id)) 2339 }) 2340 .map(|feature_target| (feature_target, at)) 2341} 2342 2343struct FeatureMenuIds { 2344 root: WidgetId, 2345 edit_feature: WidgetId, 2346 edit_sketch: WidgetId, 2347 suppress: WidgetId, 2348 unsuppress: WidgetId, 2349 rollback: WidgetId, 2350 delete: WidgetId, 2351 relationships: WidgetId, 2352} 2353 2354impl FeatureMenuIds { 2355 fn new(base: WidgetId) -> Self { 2356 let root = base.child(WidgetKey::new("ctxmenu")); 2357 Self { 2358 edit_feature: root.child(WidgetKey::new("edit_feature")), 2359 edit_sketch: root.child(WidgetKey::new("edit_sketch")), 2360 suppress: root.child(WidgetKey::new("suppress")), 2361 unsuppress: root.child(WidgetKey::new("unsuppress")), 2362 rollback: root.child(WidgetKey::new("rollback")), 2363 delete: root.child(WidgetKey::new("delete")), 2364 relationships: root.child(WidgetKey::new("relationships")), 2365 root, 2366 } 2367 } 2368} 2369 2370#[derive(Copy, Clone, Debug, PartialEq, Eq)] 2371enum FeatureMenuOutcome { 2372 EditSketch(SketchId), 2373 EditExtrude(ExtrudeId), 2374 ShowRelationships(FeatureTarget), 2375 Command(FeatureCommand), 2376} 2377 2378fn feature_id_of(document: &Document, target: FeatureTarget) -> Option<FeatureId> { 2379 let tree = document.feature_tree(); 2380 match target { 2381 FeatureTarget::Sketch(sketch_id) => tree.feature_of_sketch(sketch_id), 2382 FeatureTarget::Extrude(extrude_id) => tree.feature_of_extrude(extrude_id), 2383 } 2384} 2385 2386fn feature_target_sketch(document: &Document, target: FeatureTarget) -> Option<SketchId> { 2387 match target { 2388 FeatureTarget::Sketch(sketch_id) => Some(sketch_id), 2389 FeatureTarget::Extrude(extrude_id) => source_sketch_of_extrude(document, extrude_id), 2390 } 2391} 2392 2393fn feature_menu_items( 2394 ids: &FeatureMenuIds, 2395 target: FeatureTarget, 2396 suppressed: bool, 2397 has_sketch: bool, 2398) -> Vec<MenuItem> { 2399 let action = |id, label| MenuItem::Action { 2400 id, 2401 label, 2402 shortcut: None, 2403 disabled: false, 2404 }; 2405 let edit_feature = matches!(target, FeatureTarget::Extrude(_)) 2406 .then(|| action(ids.edit_feature, strings::FEATURE_CTX_EDIT_FEATURE)); 2407 let edit_sketch = has_sketch.then(|| action(ids.edit_sketch, strings::FEATURE_CTX_EDIT_SKETCH)); 2408 let suppression = if suppressed { 2409 action(ids.unsuppress, strings::FEATURE_CTX_UNSUPPRESS) 2410 } else { 2411 action(ids.suppress, strings::FEATURE_CTX_SUPPRESS) 2412 }; 2413 edit_feature 2414 .into_iter() 2415 .chain(edit_sketch) 2416 .chain([ 2417 suppression, 2418 action(ids.rollback, strings::FEATURE_CTX_ROLLBACK), 2419 MenuItem::Separator, 2420 action(ids.delete, strings::FEATURE_CTX_DELETE), 2421 MenuItem::Separator, 2422 action(ids.relationships, strings::FEATURE_CTX_RELATIONSHIPS), 2423 ]) 2424 .collect() 2425} 2426 2427fn feature_menu_outcome_for( 2428 ids: &FeatureMenuIds, 2429 activated: WidgetId, 2430 target: FeatureTarget, 2431 document: &Document, 2432) -> Option<FeatureMenuOutcome> { 2433 let feature = feature_id_of(document, target)?; 2434 if activated == ids.edit_feature { 2435 match target { 2436 FeatureTarget::Extrude(extrude_id) => Some(FeatureMenuOutcome::EditExtrude(extrude_id)), 2437 FeatureTarget::Sketch(_) => None, 2438 } 2439 } else if activated == ids.edit_sketch { 2440 feature_target_sketch(document, target).map(FeatureMenuOutcome::EditSketch) 2441 } else if activated == ids.suppress { 2442 Some(FeatureMenuOutcome::Command(FeatureCommand::Suppress( 2443 feature, 2444 ))) 2445 } else if activated == ids.unsuppress { 2446 Some(FeatureMenuOutcome::Command(FeatureCommand::Unsuppress( 2447 feature, 2448 ))) 2449 } else if activated == ids.rollback { 2450 Some(FeatureMenuOutcome::Command(FeatureCommand::RollbackToHere( 2451 feature, 2452 ))) 2453 } else if activated == ids.delete { 2454 Some(FeatureMenuOutcome::Command(FeatureCommand::Delete(target))) 2455 } else if activated == ids.relationships { 2456 Some(FeatureMenuOutcome::ShowRelationships(target)) 2457 } else { 2458 None 2459 } 2460} 2461 2462fn render_feature_context_menu( 2463 ctx: &mut FrameCtx<'_>, 2464 base_id: WidgetId, 2465 state: &mut ShellState, 2466 document: &Document, 2467 paints: &mut Vec<WidgetPaint>, 2468) -> Option<FeatureMenuOutcome> { 2469 let open = state.feature_menu.open?; 2470 let target = open.target; 2471 let ids = FeatureMenuIds::new(base_id); 2472 let suppressed = feature_id_of(document, target) 2473 .is_some_and(|feature| document.suppression_state(feature).is_suppressed()); 2474 let has_sketch = feature_target_sketch(document, target).is_some(); 2475 let items = feature_menu_items(&ids, target, suppressed, has_sketch); 2476 let response = show_context_menu( 2477 ctx, 2478 ContextMenu::at_cursor( 2479 ids.root, 2480 open.anchor, 2481 strings::FEATURE_TREE_LABEL, 2482 &items, 2483 &mut state.feature_menu.menu, 2484 ), 2485 ); 2486 paints.extend(response.paint); 2487 let outcome = response 2488 .activated 2489 .and_then(|id| feature_menu_outcome_for(&ids, id, target, document)); 2490 let pressed_outside = ctx.input.buttons_pressed.contains(PointerButton::Primary) 2491 && ctx 2492 .input 2493 .pointer 2494 .is_some_and(|sample| !response.rect.contains(sample.position)); 2495 if outcome.is_some() || response.close || pressed_outside { 2496 state.feature_menu.open = None; 2497 state.feature_menu.menu = MenuState::default(); 2498 } 2499 outcome 2500} 2501 2502const REL_ROW_PX: f32 = 22.0; 2503const REL_PANEL_WIDTH_PX: f32 = 280.0; 2504const REL_PAD_PX: f32 = 10.0; 2505 2506#[derive(Copy, Clone)] 2507enum RelTone { 2508 Heading, 2509 Item, 2510 Empty, 2511} 2512 2513#[derive(Clone)] 2514struct RelRow { 2515 text: LabelText, 2516 indent: f32, 2517 tone: RelTone, 2518} 2519 2520fn feature_label(document: &Document, feature: FeatureId) -> Option<String> { 2521 match document.feature_tree().node(feature)? { 2522 FeatureNode::Sketch(sketch_id) => { 2523 Some(document.sketch_label(sketch_id).unwrap_or("").to_owned()) 2524 } 2525 FeatureNode::Extrude(extrude_id) => { 2526 Some(document.extrude_label(extrude_id).unwrap_or("").to_owned()) 2527 } 2528 _ => None, 2529 } 2530} 2531 2532fn relationship_section( 2533 document: &Document, 2534 header: StringKey, 2535 features: &[FeatureId], 2536) -> Vec<RelRow> { 2537 let head = RelRow { 2538 text: LabelText::Key(header), 2539 indent: REL_PAD_PX, 2540 tone: RelTone::Heading, 2541 }; 2542 let items: Vec<RelRow> = features 2543 .iter() 2544 .filter_map(|feature| feature_label(document, *feature)) 2545 .map(|label| RelRow { 2546 text: LabelText::Owned(label), 2547 indent: REL_PAD_PX + 12.0, 2548 tone: RelTone::Item, 2549 }) 2550 .collect(); 2551 let body = if items.is_empty() { 2552 vec![RelRow { 2553 text: LabelText::Key(strings::FEATURE_REL_NONE), 2554 indent: REL_PAD_PX + 12.0, 2555 tone: RelTone::Empty, 2556 }] 2557 } else { 2558 items 2559 }; 2560 std::iter::once(head).chain(body).collect() 2561} 2562 2563fn relationship_rows(document: &Document, feature: FeatureId) -> Vec<RelRow> { 2564 let tree = document.feature_tree(); 2565 let parents = tree.parents(feature); 2566 let children = tree.children(feature); 2567 relationship_section(document, strings::FEATURE_REL_PARENTS, &parents) 2568 .into_iter() 2569 .chain(relationship_section( 2570 document, 2571 strings::FEATURE_REL_CHILDREN, 2572 &children, 2573 )) 2574 .collect() 2575} 2576 2577fn relationships_panel_rect(viewport: LayoutRect, content_height: f32) -> LayoutRect { 2578 let height = REL_PAD_PX * 2.0 + REL_ROW_PX + content_height; 2579 let x = viewport.origin.x.value() + (viewport.size.width.value() - REL_PANEL_WIDTH_PX) / 2.0; 2580 let y = viewport.origin.y.value() + (viewport.size.height.value() - height) / 2.0; 2581 LayoutRect::new( 2582 LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), 2583 LayoutSize::new(LayoutPx::new(REL_PANEL_WIDTH_PX), LayoutPx::new(height)), 2584 ) 2585} 2586 2587fn relationships_close_rect(panel: LayoutRect) -> LayoutRect { 2588 let width = 56.0; 2589 LayoutRect::new( 2590 LayoutPos::new( 2591 LayoutPx::new(panel.origin.x.value() + panel.size.width.value() - width - REL_PAD_PX), 2592 LayoutPx::new(panel.origin.y.value() + REL_PAD_PX), 2593 ), 2594 LayoutSize::new(LayoutPx::new(width), LayoutPx::new(REL_ROW_PX)), 2595 ) 2596} 2597 2598fn rel_row_paint(ctx: &FrameCtx<'_>, body: LayoutRect, row: &RelRow, top: f32) -> WidgetPaint { 2599 let rect = LayoutRect::new( 2600 LayoutPos::new( 2601 LayoutPx::new(body.origin.x.value() + row.indent), 2602 LayoutPx::new(top), 2603 ), 2604 LayoutSize::new( 2605 LayoutPx::saturating_nonneg(body.size.width.value() - row.indent - REL_PAD_PX), 2606 LayoutPx::new(REL_ROW_PX), 2607 ), 2608 ); 2609 let (color, role) = match row.tone { 2610 RelTone::Heading => ( 2611 ctx.theme().colors.text_secondary(), 2612 ctx.theme().typography.label, 2613 ), 2614 RelTone::Item => ( 2615 ctx.theme().colors.text_primary(), 2616 ctx.theme().typography.body, 2617 ), 2618 RelTone::Empty => ( 2619 ctx.theme().colors.text_disabled(), 2620 ctx.theme().typography.body, 2621 ), 2622 }; 2623 WidgetPaint::AlignedLabel { 2624 rect, 2625 text: row.text.clone(), 2626 color, 2627 role, 2628 align: HorizontalAlign::Start, 2629 } 2630} 2631 2632fn render_relationships_panel( 2633 ctx: &mut FrameCtx<'_>, 2634 base_id: WidgetId, 2635 viewport: LayoutRect, 2636 target: FeatureTarget, 2637 document: &Document, 2638 paints: &mut Vec<WidgetPaint>, 2639) -> bool { 2640 let Some(feature) = feature_id_of(document, target) else { 2641 return true; 2642 }; 2643 let rows = relationship_rows(document, feature); 2644 let content_height = rows.iter().fold(0.0_f32, |acc, _| acc + REL_ROW_PX); 2645 let rect = relationships_panel_rect(viewport, content_height); 2646 let panel_id = base_id.child(WidgetKey::new("relationships")); 2647 let mut panel_state = PanelState::open(); 2648 let response = show_panel( 2649 ctx, 2650 Panel::new(panel_id, rect, &mut panel_state).variant(PanelVariant::Card), 2651 ); 2652 paints.extend(response.paint); 2653 let Some(body) = response.body_rect else { 2654 return false; 2655 }; 2656 paints.push(WidgetPaint::AlignedLabel { 2657 rect: LayoutRect::new( 2658 LayoutPos::new( 2659 LayoutPx::new(body.origin.x.value() + REL_PAD_PX), 2660 LayoutPx::new(body.origin.y.value() + REL_PAD_PX), 2661 ), 2662 LayoutSize::new( 2663 LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * REL_PAD_PX), 2664 LayoutPx::new(REL_ROW_PX), 2665 ), 2666 ), 2667 text: LabelText::Key(strings::FEATURE_CTX_RELATIONSHIPS), 2668 color: ctx.theme().colors.text_primary(), 2669 role: ctx.theme().typography.title, 2670 align: HorizontalAlign::Start, 2671 }); 2672 let start_y = body.origin.y.value() + REL_PAD_PX + REL_ROW_PX; 2673 paints.extend(rows.iter().scan(start_y, |top, row| { 2674 let current = *top; 2675 *top += REL_ROW_PX; 2676 Some(rel_row_paint(ctx, body, row, current)) 2677 })); 2678 let close_id = panel_id.child(WidgetKey::new("close")); 2679 let close_rect = relationships_close_rect(rect); 2680 let close = ctx.interact( 2681 InteractDeclaration::new(close_id, close_rect, Sense::INTERACTIVE) 2682 .a11y(AccessNode::new(Role::Button).with_label(strings::FEATURE_REL_CLOSE)), 2683 ); 2684 paints.push(WidgetPaint::AlignedLabel { 2685 rect: close_rect, 2686 text: LabelText::Key(strings::FEATURE_REL_CLOSE), 2687 color: if close.hover() { 2688 ctx.theme().colors.text_primary() 2689 } else { 2690 ctx.theme().colors.text_secondary() 2691 }, 2692 role: ctx.theme().typography.label, 2693 align: HorizontalAlign::End, 2694 }); 2695 let pressed_outside = ctx.input.buttons_pressed.contains(PointerButton::Primary) 2696 && ctx 2697 .input 2698 .pointer 2699 .is_some_and(|sample| !rect.contains(sample.position)); 2700 close.click() || pressed_outside 2701} 2702 2703struct WhatsWrongOutcome { 2704 close: bool, 2705 reattach: Option<SketchId>, 2706} 2707 2708fn whats_wrong_row_paint( 2709 ctx: &mut FrameCtx<'_>, 2710 panel_id: WidgetId, 2711 body: LayoutRect, 2712 entry: &WhatsWrong, 2713 top: f32, 2714 paints: &mut Vec<WidgetPaint>, 2715) -> Option<SketchId> { 2716 let message_color = if entry.is_error { 2717 ctx.theme().colors.danger.step(Step12::SOLID) 2718 } else { 2719 ctx.theme().colors.warning.step(Step12::SOLID) 2720 }; 2721 let message_rect = LayoutRect::new( 2722 LayoutPos::new( 2723 LayoutPx::new(body.origin.x.value() + REL_PAD_PX), 2724 LayoutPx::new(top), 2725 ), 2726 LayoutSize::new( 2727 LayoutPx::saturating_nonneg(body.size.width.value() * 0.55 - REL_PAD_PX), 2728 LayoutPx::new(REL_ROW_PX), 2729 ), 2730 ); 2731 paints.push(WidgetPaint::AlignedLabel { 2732 rect: message_rect, 2733 text: LabelText::Key(entry.message), 2734 color: message_color, 2735 role: ctx.theme().typography.body, 2736 align: HorizontalAlign::Start, 2737 }); 2738 let right_rect = LayoutRect::new( 2739 LayoutPos::new( 2740 LayoutPx::new(body.origin.x.value() + body.size.width.value() * 0.55), 2741 LayoutPx::new(top), 2742 ), 2743 LayoutSize::new( 2744 LayoutPx::saturating_nonneg(body.size.width.value() * 0.45 - REL_PAD_PX), 2745 LayoutPx::new(REL_ROW_PX), 2746 ), 2747 ); 2748 let Some(sketch_id) = entry.reattach else { 2749 paints.push(WidgetPaint::AlignedLabel { 2750 rect: right_rect, 2751 text: LabelText::Owned(entry.label.clone()), 2752 color: ctx.theme().colors.text_secondary(), 2753 role: ctx.theme().typography.caption, 2754 align: HorizontalAlign::End, 2755 }); 2756 return None; 2757 }; 2758 let reattach_id = panel_id.child_indexed(WidgetKey::new("reattach"), sketch_id.as_u64()); 2759 let interaction = ctx.interact( 2760 InteractDeclaration::new(reattach_id, right_rect, Sense::INTERACTIVE) 2761 .a11y(AccessNode::new(Role::Button).with_label(strings::WHATS_WRONG_REATTACH)), 2762 ); 2763 paints.push(WidgetPaint::AlignedLabel { 2764 rect: right_rect, 2765 text: LabelText::Key(strings::WHATS_WRONG_REATTACH), 2766 color: ctx.theme().colors.accent.step(Step12::SOLID), 2767 role: ctx.theme().typography.label, 2768 align: HorizontalAlign::End, 2769 }); 2770 interaction.click().then_some(sketch_id) 2771} 2772 2773fn render_whats_wrong_panel( 2774 ctx: &mut FrameCtx<'_>, 2775 base_id: WidgetId, 2776 viewport: LayoutRect, 2777 entries: &[WhatsWrong], 2778 paints: &mut Vec<WidgetPaint>, 2779) -> WhatsWrongOutcome { 2780 let line_count = entries.len().max(1); 2781 let content_height = (0..line_count).fold(0.0_f32, |height, _| height + REL_ROW_PX); 2782 let rect = relationships_panel_rect(viewport, content_height); 2783 let panel_id = base_id.child(WidgetKey::new("whats_wrong")); 2784 let mut panel_state = PanelState::open(); 2785 let response = show_panel( 2786 ctx, 2787 Panel::new(panel_id, rect, &mut panel_state).variant(PanelVariant::Card), 2788 ); 2789 paints.extend(response.paint); 2790 let Some(body) = response.body_rect else { 2791 return WhatsWrongOutcome { 2792 close: false, 2793 reattach: None, 2794 }; 2795 }; 2796 paints.push(WidgetPaint::AlignedLabel { 2797 rect: LayoutRect::new( 2798 LayoutPos::new( 2799 LayoutPx::new(body.origin.x.value() + REL_PAD_PX), 2800 LayoutPx::new(body.origin.y.value() + REL_PAD_PX), 2801 ), 2802 LayoutSize::new( 2803 LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * REL_PAD_PX), 2804 LayoutPx::new(REL_ROW_PX), 2805 ), 2806 ), 2807 text: LabelText::Key(strings::WHATS_WRONG_TITLE), 2808 color: ctx.theme().colors.text_primary(), 2809 role: ctx.theme().typography.title, 2810 align: HorizontalAlign::Start, 2811 }); 2812 let start_y = body.origin.y.value() + REL_PAD_PX + REL_ROW_PX; 2813 let reattach = if entries.is_empty() { 2814 paints.push(WidgetPaint::AlignedLabel { 2815 rect: LayoutRect::new( 2816 LayoutPos::new( 2817 LayoutPx::new(body.origin.x.value() + REL_PAD_PX), 2818 LayoutPx::new(start_y), 2819 ), 2820 LayoutSize::new( 2821 LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * REL_PAD_PX), 2822 LayoutPx::new(REL_ROW_PX), 2823 ), 2824 ), 2825 text: LabelText::Key(strings::WHATS_WRONG_NONE), 2826 color: ctx.theme().colors.text_disabled(), 2827 role: ctx.theme().typography.body, 2828 align: HorizontalAlign::Start, 2829 }); 2830 None 2831 } else { 2832 entries 2833 .iter() 2834 .enumerate() 2835 .fold(None, |acc, (index, entry)| { 2836 let top = start_y + index_offset(index); 2837 acc.or(whats_wrong_row_paint( 2838 ctx, panel_id, body, entry, top, paints, 2839 )) 2840 }) 2841 }; 2842 let close_id = panel_id.child(WidgetKey::new("close")); 2843 let close_rect = relationships_close_rect(rect); 2844 let close = ctx.interact( 2845 InteractDeclaration::new(close_id, close_rect, Sense::INTERACTIVE) 2846 .a11y(AccessNode::new(Role::Button).with_label(strings::FEATURE_REL_CLOSE)), 2847 ); 2848 paints.push(WidgetPaint::AlignedLabel { 2849 rect: close_rect, 2850 text: LabelText::Key(strings::FEATURE_REL_CLOSE), 2851 color: if close.hover() { 2852 ctx.theme().colors.text_primary() 2853 } else { 2854 ctx.theme().colors.text_secondary() 2855 }, 2856 role: ctx.theme().typography.label, 2857 align: HorizontalAlign::End, 2858 }); 2859 let pressed_outside = ctx.input.buttons_pressed.contains(PointerButton::Primary) 2860 && ctx 2861 .input 2862 .pointer 2863 .is_some_and(|sample| !rect.contains(sample.position)); 2864 WhatsWrongOutcome { 2865 close: close.click() || pressed_outside, 2866 reattach, 2867 } 2868} 2869 2870fn index_offset(index: usize) -> f32 { 2871 f32::from(u16::try_from(index).unwrap_or(u16::MAX)) * REL_ROW_PX 2872} 2873 2874#[derive(Copy, Clone)] 2875struct PropertyState<'a> { 2876 mode: &'a Mode, 2877 sketch: Option<&'a Sketch>, 2878 selection: &'a Selection, 2879} 2880 2881#[derive(Default)] 2882struct PropertyPaneOutcome { 2883 dimension_edit: Option<DimensionEdit>, 2884 extrude_edit: Option<ExtrudeEdit>, 2885 confirm: Option<ConfirmAction>, 2886} 2887 2888struct PaneEditors<'a> { 2889 dim: &'a mut Option<DimPropertyEditor>, 2890 extrude: &'a mut Option<ExtrudePropertyEditor>, 2891 groups: &'a mut BTreeMap<WidgetId, PanelState>, 2892} 2893 2894fn render_property_pane( 2895 ctx: &mut FrameCtx<'_>, 2896 rect: LayoutRect, 2897 id: WidgetId, 2898 clipboard: &mut MemoryClipboard, 2899 editors: &mut PaneEditors<'_>, 2900 state: PropertyState<'_>, 2901 paints: &mut Vec<WidgetPaint>, 2902) -> PropertyPaneOutcome { 2903 let in_sketch = matches!(state.mode, Mode::Sketch { .. }); 2904 let active_sketch_id = state.mode.sketch_id(); 2905 let resolved = state 2906 .sketch 2907 .filter(|_| in_sketch) 2908 .and_then(|s| resolve_selection_target(s, state.selection).map(|t| (s, t))); 2909 if !matches!(resolved, Some((_, SelectionTarget::Dimension(_, _)))) { 2910 *editors.dim = None; 2911 } 2912 if !matches!(state.mode, Mode::Extrude(ExtrudeArming::Profile { .. })) { 2913 *editors.extrude = None; 2914 } 2915 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 2916 return PropertyPaneOutcome::default(); 2917 } 2918 if let Mode::Extrude(arming) = state.mode { 2919 return match arming { 2920 ExtrudeArming::Profile { feature, .. } => { 2921 let outcome = 2922 render_extrude_rows(ctx, rect, id, clipboard, editors, *feature, paints); 2923 PropertyPaneOutcome { 2924 dimension_edit: None, 2925 extrude_edit: outcome.edit, 2926 confirm: outcome.confirm, 2927 } 2928 } 2929 ExtrudeArming::AwaitingSketch => { 2930 let mut editors = vec![row_editor(strings::EXTRUDE_PROMPT_SELECT_SKETCH, "")]; 2931 render_static_rows(ctx, rect, id, clipboard, &mut editors, paints); 2932 PropertyPaneOutcome::default() 2933 } 2934 }; 2935 } 2936 match resolved { 2937 Some((sketch, SelectionTarget::Entity(entity))) => { 2938 let mut editors = entity_editors(ctx.strings, entity, sketch); 2939 render_static_rows(ctx, rect, id, clipboard, &mut editors, paints); 2940 PropertyPaneOutcome::default() 2941 } 2942 Some((sketch, SelectionTarget::Relation(rel))) => { 2943 let mut editors = relation_editors(ctx.strings, rel, sketch); 2944 render_static_rows(ctx, rect, id, clipboard, &mut editors, paints); 2945 PropertyPaneOutcome::default() 2946 } 2947 Some((sketch, SelectionTarget::Dimension(dim_id, dim))) => { 2948 let Some(sketch_id) = active_sketch_id else { 2949 return PropertyPaneOutcome::default(); 2950 }; 2951 PropertyPaneOutcome { 2952 dimension_edit: render_dimension_rows( 2953 ctx, 2954 rect, 2955 id, 2956 clipboard, 2957 editors.dim, 2958 sketch_id, 2959 dim_id, 2960 dim, 2961 sketch, 2962 paints, 2963 ), 2964 extrude_edit: None, 2965 confirm: None, 2966 } 2967 } 2968 None => { 2969 let mut editors = vec![row_editor(strings::PROPERTY_PANE_NO_SELECTION, "")]; 2970 render_static_rows(ctx, rect, id, clipboard, &mut editors, paints); 2971 PropertyPaneOutcome::default() 2972 } 2973 } 2974} 2975 2976fn sync_extrude_editor( 2977 slot: &mut Option<ExtrudePropertyEditor>, 2978 feature: ExtrudeFeature, 2979) -> &mut ExtrudePropertyEditor { 2980 let editor = match slot.take() { 2981 Some(editor) if editor.sketch == feature.sketch => editor.synced(feature), 2982 _ => ExtrudePropertyEditor::new(feature), 2983 }; 2984 slot.insert(editor) 2985} 2986 2987const PM_HEADER_HEIGHT: f32 = 44.0; 2988const PM_GROUP_TITLE_HEIGHT: f32 = 22.0; 2989const PM_GROUP_ROW_HEIGHT: f32 = 22.0; 2990const PM_GROUP_GAP: f32 = 6.0; 2991 2992struct ExtrudeRowsOutcome { 2993 edit: Option<ExtrudeEdit>, 2994 confirm: Option<ConfirmAction>, 2995} 2996 2997#[derive(Copy, Clone)] 2998struct PropertyGroupSpec { 2999 id: WidgetId, 3000 title: StringKey, 3001 top_left: LayoutPos, 3002 width: LayoutPx, 3003} 3004 3005fn extrude_row_id(key: &'static str) -> WidgetId { 3006 WidgetId::ROOT 3007 .child(WidgetKey::new("props.extrude")) 3008 .child(WidgetKey::new(key)) 3009} 3010 3011fn direction_group_rows(editor: &mut ExtrudePropertyEditor) -> Vec<PropertyRow<'_>> { 3012 vec![ 3013 PropertyRow { 3014 id: extrude_row_id("end"), 3015 label: strings::PROPERTY_ROW_EXTRUDE_END, 3016 editor: &mut editor.end_condition, 3017 read_only: false, 3018 }, 3019 PropertyRow { 3020 id: extrude_row_id("depth"), 3021 label: strings::PROPERTY_ROW_EXTRUDE_DEPTH, 3022 editor: &mut editor.depth, 3023 read_only: false, 3024 }, 3025 PropertyRow { 3026 id: extrude_row_id("draft"), 3027 label: strings::PROPERTY_ROW_EXTRUDE_DRAFT, 3028 editor: &mut editor.draft_enabled, 3029 read_only: true, 3030 }, 3031 PropertyRow { 3032 id: extrude_row_id("draft_angle"), 3033 label: strings::PROPERTY_ROW_EXTRUDE_DRAFT_ANGLE, 3034 editor: &mut editor.draft_angle, 3035 read_only: true, 3036 }, 3037 PropertyRow { 3038 id: extrude_row_id("direction_two"), 3039 label: strings::PROPERTY_ROW_EXTRUDE_DIRECTION_TWO, 3040 editor: &mut editor.direction_two, 3041 read_only: true, 3042 }, 3043 ] 3044} 3045 3046fn scope_group_rows(editor: &mut ExtrudePropertyEditor) -> Vec<PropertyRow<'_>> { 3047 vec![ 3048 PropertyRow { 3049 id: extrude_row_id("thin"), 3050 label: strings::PROPERTY_ROW_EXTRUDE_THIN, 3051 editor: &mut editor.thin, 3052 read_only: true, 3053 }, 3054 PropertyRow { 3055 id: extrude_row_id("merge"), 3056 label: strings::PROPERTY_ROW_EXTRUDE_MERGE, 3057 editor: &mut editor.merge, 3058 read_only: false, 3059 }, 3060 ] 3061} 3062 3063fn render_property_group( 3064 ctx: &mut FrameCtx<'_>, 3065 clipboard: &mut MemoryClipboard, 3066 groups: &mut BTreeMap<WidgetId, PanelState>, 3067 spec: PropertyGroupSpec, 3068 rows: &mut Vec<PropertyRow<'_>>, 3069 paints: &mut Vec<WidgetPaint>, 3070) -> (LayoutPx, Vec<WidgetId>) { 3071 let collapsed = groups.get(&spec.id).is_some_and(|s| s.collapsed); 3072 #[allow( 3073 clippy::cast_precision_loss, 3074 reason = "property row counts fit the f32 mantissa" 3075 )] 3076 let body_height = if collapsed { 3077 0.0 3078 } else { 3079 rows.len() as f32 * PM_GROUP_ROW_HEIGHT 3080 }; 3081 let group_rect = LayoutRect::new( 3082 spec.top_left, 3083 LayoutSize::new( 3084 spec.width, 3085 LayoutPx::new(PM_GROUP_TITLE_HEIGHT + body_height), 3086 ), 3087 ); 3088 let state = groups.entry(spec.id).or_default(); 3089 let response = show_panel( 3090 ctx, 3091 Panel::new(spec.id, group_rect, state) 3092 .variant(PanelVariant::Card) 3093 .titlebar(PanelTitlebar { 3094 label: spec.title, 3095 height: LayoutPx::new(PM_GROUP_TITLE_HEIGHT), 3096 collapsible: true, 3097 }), 3098 ); 3099 paints.extend(response.paint); 3100 let changed = match response.body_rect { 3101 Some(body) => { 3102 let grid = show_property_grid( 3103 ctx, 3104 PropertyGrid::new( 3105 spec.id.child(WidgetKey::new("grid")), 3106 body, 3107 spec.title, 3108 rows, 3109 ), 3110 clipboard, 3111 ); 3112 paints.extend(grid.paint); 3113 grid.changed_rows 3114 } 3115 None => Vec::new(), 3116 }; 3117 let next_y = 3118 LayoutPx::new(group_rect.origin.y.value() + group_rect.size.height.value() + PM_GROUP_GAP); 3119 (next_y, changed) 3120} 3121 3122fn extrude_pane_header( 3123 ctx: &mut FrameCtx<'_>, 3124 id: WidgetId, 3125 rect: LayoutRect, 3126) -> (Option<ConfirmAction>, Vec<WidgetPaint>) { 3127 let header_id = id.child(WidgetKey::new("header")); 3128 let header = show_property_pane_header( 3129 ctx, 3130 PropertyPaneHeader { 3131 id: header_id, 3132 rect: LayoutRect::new( 3133 rect.origin, 3134 LayoutSize::new(rect.size.width, LayoutPx::new(PM_HEADER_HEIGHT)), 3135 ), 3136 title: strings::PROPERTY_PANE_EXTRUDE_TITLE, 3137 accept_id: header_id.child(WidgetKey::new("accept")), 3138 cancel_id: header_id.child(WidgetKey::new("cancel")), 3139 }, 3140 ); 3141 let confirm = match header.action { 3142 Some(PropertyPaneAction::Accept) => Some(ConfirmAction::Accept), 3143 Some(PropertyPaneAction::Cancel) => Some(ConfirmAction::Cancel), 3144 None => None, 3145 }; 3146 (confirm, header.paint) 3147} 3148 3149fn render_extrude_rows( 3150 ctx: &mut FrameCtx<'_>, 3151 rect: LayoutRect, 3152 id: WidgetId, 3153 clipboard: &mut MemoryClipboard, 3154 editors: &mut PaneEditors<'_>, 3155 feature: ExtrudeFeature, 3156 paints: &mut Vec<WidgetPaint>, 3157) -> ExtrudeRowsOutcome { 3158 ctx.a11y.push( 3159 id, 3160 rect, 3161 AccessNode::new(Role::Form).with_label(strings::PROPERTY_PANE_LABEL), 3162 ); 3163 let (confirm, header_paint) = extrude_pane_header(ctx, id, rect); 3164 paints.extend(header_paint); 3165 3166 let editor = sync_extrude_editor(editors.extrude, feature); 3167 let groups_top = LayoutPx::new(rect.origin.y.value() + PM_HEADER_HEIGHT + PM_GROUP_GAP); 3168 let mut changed: Vec<WidgetId> = Vec::new(); 3169 let scope_top = { 3170 let mut rows = direction_group_rows(editor); 3171 let (next_y, ch) = render_property_group( 3172 ctx, 3173 clipboard, 3174 editors.groups, 3175 PropertyGroupSpec { 3176 id: id.child(WidgetKey::new("group.direction1")), 3177 title: strings::PROPERTY_GROUP_DIRECTION_1, 3178 top_left: LayoutPos::new(rect.origin.x, groups_top), 3179 width: rect.size.width, 3180 }, 3181 &mut rows, 3182 paints, 3183 ); 3184 changed.extend(ch); 3185 next_y 3186 }; 3187 { 3188 let mut rows = scope_group_rows(editor); 3189 let (_next_y, ch) = render_property_group( 3190 ctx, 3191 clipboard, 3192 editors.groups, 3193 PropertyGroupSpec { 3194 id: id.child(WidgetKey::new("group.feature_scope")), 3195 title: strings::PROPERTY_GROUP_FEATURE_SCOPE, 3196 top_left: LayoutPos::new(rect.origin.x, scope_top), 3197 width: rect.size.width, 3198 }, 3199 &mut rows, 3200 paints, 3201 ); 3202 changed.extend(ch); 3203 } 3204 3205 let edit = if changed.contains(&extrude_row_id("end")) { 3206 kind_from_index(editor.end_condition.current).map(ExtrudeEdit::EndCondition) 3207 } else if changed.contains(&extrude_row_id("depth")) { 3208 PositiveLength::new(editor.depth.value) 3209 .ok() 3210 .map(ExtrudeEdit::Depth) 3211 } else if changed.contains(&extrude_row_id("merge")) { 3212 Some(ExtrudeEdit::Merge(if editor.merge.value { 3213 MergeResult::Merge 3214 } else { 3215 MergeResult::Separate 3216 })) 3217 } else { 3218 None 3219 }; 3220 ExtrudeRowsOutcome { edit, confirm } 3221} 3222 3223enum SelectionTarget { 3224 Entity(SketchEntity), 3225 Relation(SketchRelation), 3226 Dimension(SketchDimensionId, SketchDimension), 3227} 3228 3229fn resolve_selection_target(sketch: &Sketch, selection: &Selection) -> Option<SelectionTarget> { 3230 match selection { 3231 Selection::Entities(ids) => match ids.as_slice() { 3232 [id] => sketch 3233 .entities() 3234 .get(*id) 3235 .copied() 3236 .map(SelectionTarget::Entity), 3237 _ => None, 3238 }, 3239 Selection::Relation(id) => sketch 3240 .relations() 3241 .get(*id) 3242 .copied() 3243 .map(SelectionTarget::Relation), 3244 Selection::Dimension(id) => sketch 3245 .dimensions() 3246 .get(*id) 3247 .copied() 3248 .map(|d| SelectionTarget::Dimension(*id, d)), 3249 } 3250} 3251 3252fn render_static_rows( 3253 ctx: &mut FrameCtx<'_>, 3254 rect: LayoutRect, 3255 id: WidgetId, 3256 clipboard: &mut MemoryClipboard, 3257 editors: &mut [PropertyRowSpec], 3258 paints: &mut Vec<WidgetPaint>, 3259) { 3260 let mut rows: Vec<PropertyRow<'_>> = editors 3261 .iter_mut() 3262 .map(|(row_id, label, editor)| PropertyRow { 3263 id: *row_id, 3264 label: *label, 3265 editor: editor.as_mut(), 3266 read_only: true, 3267 }) 3268 .collect(); 3269 let response = show_property_grid( 3270 ctx, 3271 PropertyGrid::new(id, rect, strings::PROPERTY_PANE_LABEL, &mut rows), 3272 clipboard, 3273 ); 3274 paints.extend(response.paint); 3275} 3276 3277#[allow( 3278 clippy::too_many_arguments, 3279 reason = "splitting the property-pane render call harms locality" 3280)] 3281fn render_dimension_rows( 3282 ctx: &mut FrameCtx<'_>, 3283 rect: LayoutRect, 3284 id: WidgetId, 3285 clipboard: &mut MemoryClipboard, 3286 dim_property: &mut Option<DimPropertyEditor>, 3287 sketch_id: SketchId, 3288 dim_id: SketchDimensionId, 3289 dim: SketchDimension, 3290 sketch: &Sketch, 3291 paints: &mut Vec<WidgetPaint>, 3292) -> Option<DimensionEdit> { 3293 let driving = matches!(dim.kind(), DimensionKind::Driving); 3294 let kind_label = dimension_kind_label(dim); 3295 let kind_value_key = dimension_drive_key(dim.kind()); 3296 let value_row_id = WidgetId::ROOT 3297 .child(WidgetKey::new("props.dim")) 3298 .child(WidgetKey::new("value")); 3299 let dim_property_slot = sync_dim_editor(dim_property, sketch_id, dim_id, dim); 3300 let mut static_specs: Vec<PropertyRowSpec> = vec![row_editor( 3301 strings::PROPERTY_ROW_DIM_KIND, 3302 ctx.strings.resolve(kind_label).to_owned(), 3303 )]; 3304 static_specs.extend(dimension_static_rows(ctx.strings, dim, sketch)); 3305 static_specs.push(row_editor( 3306 strings::PROPERTY_ROW_DIM_DRIVES, 3307 ctx.strings.resolve(kind_value_key).to_owned(), 3308 )); 3309 let mut rows: Vec<PropertyRow<'_>> = static_specs 3310 .iter_mut() 3311 .map(|(row_id, label, editor)| PropertyRow { 3312 id: *row_id, 3313 label: *label, 3314 editor: editor.as_mut(), 3315 read_only: true, 3316 }) 3317 .collect(); 3318 let value_label = match dim { 3319 SketchDimension::Linear { .. } => strings::PROPERTY_ROW_DIM_LENGTH, 3320 SketchDimension::Radius { .. } => strings::PROPERTY_ROW_RADIUS, 3321 SketchDimension::Diameter { .. } => strings::PROPERTY_ROW_DIM_DIAMETER, 3322 SketchDimension::Angular { .. } => strings::PROPERTY_ROW_DIM_ANGLE, 3323 }; 3324 let editor_ref: &mut dyn PropertyEditor = match dim_property_slot { 3325 DimPropertyEditor::Length { editor, .. } => editor, 3326 DimPropertyEditor::Angle { editor, .. } => editor, 3327 }; 3328 rows.push(PropertyRow { 3329 id: value_row_id, 3330 label: value_label, 3331 editor: editor_ref, 3332 read_only: !driving, 3333 }); 3334 let response = show_property_grid( 3335 ctx, 3336 PropertyGrid::new(id, rect, strings::PROPERTY_PANE_LABEL, &mut rows), 3337 clipboard, 3338 ); 3339 paints.extend(response.paint); 3340 if !driving || !response.changed_rows.contains(&value_row_id) { 3341 return None; 3342 } 3343 Some(DimensionEdit { 3344 id: dim_id, 3345 value: match dim_property_slot { 3346 DimPropertyEditor::Length { editor, .. } => DimensionValue::Length(editor.value), 3347 DimPropertyEditor::Angle { editor, .. } => DimensionValue::Angle(editor.value), 3348 }, 3349 }) 3350} 3351 3352fn sync_dim_editor( 3353 slot: &mut Option<DimPropertyEditor>, 3354 sketch_id: SketchId, 3355 dim_id: SketchDimensionId, 3356 dim: SketchDimension, 3357) -> &mut DimPropertyEditor { 3358 let editor = match (slot.take(), dim.value()) { 3359 ( 3360 Some(DimPropertyEditor::Length { 3361 sketch_id: prev_sketch, 3362 id, 3363 mut editor, 3364 }), 3365 DimensionValue::Length(v), 3366 ) if prev_sketch == sketch_id && id == dim_id => { 3367 editor.value = v; 3368 DimPropertyEditor::Length { 3369 sketch_id, 3370 id, 3371 editor, 3372 } 3373 } 3374 ( 3375 Some(DimPropertyEditor::Angle { 3376 sketch_id: prev_sketch, 3377 id, 3378 mut editor, 3379 }), 3380 DimensionValue::Angle(v), 3381 ) if prev_sketch == sketch_id && id == dim_id => { 3382 editor.value = v; 3383 DimPropertyEditor::Angle { 3384 sketch_id, 3385 id, 3386 editor, 3387 } 3388 } 3389 (_, DimensionValue::Length(v)) => DimPropertyEditor::Length { 3390 sketch_id, 3391 id: dim_id, 3392 editor: LengthEditor::new(v), 3393 }, 3394 (_, DimensionValue::Angle(v)) => DimPropertyEditor::Angle { 3395 sketch_id, 3396 id: dim_id, 3397 editor: AngleEditor::new(v), 3398 }, 3399 }; 3400 slot.insert(editor) 3401} 3402 3403fn dimension_kind_label(dim: SketchDimension) -> StringKey { 3404 match dim { 3405 SketchDimension::Linear { .. } => strings::PROPERTY_KIND_DIM_LINEAR, 3406 SketchDimension::Radius { .. } => strings::PROPERTY_KIND_DIM_RADIUS, 3407 SketchDimension::Diameter { .. } => strings::PROPERTY_KIND_DIM_DIAMETER, 3408 SketchDimension::Angular { .. } => strings::PROPERTY_KIND_DIM_ANGULAR, 3409 } 3410} 3411 3412fn dimension_drive_key(kind: DimensionKind) -> StringKey { 3413 match kind { 3414 DimensionKind::Driving => strings::PROPERTY_VALUE_DRIVING, 3415 DimensionKind::Driven => strings::PROPERTY_VALUE_DRIVEN, 3416 } 3417} 3418 3419fn dimension_static_rows( 3420 strings_table: &StringTable, 3421 dim: SketchDimension, 3422 sketch: &Sketch, 3423) -> Vec<PropertyRowSpec> { 3424 let label = |id: SketchEntityId| endpoint_or_entity_label(strings_table, sketch, id); 3425 match dim { 3426 SketchDimension::Linear { a, b, .. } | SketchDimension::Angular { a, b, .. } => vec![ 3427 row_editor(strings::PROPERTY_ROW_FROM, label(a)), 3428 row_editor(strings::PROPERTY_ROW_TO, label(b)), 3429 ], 3430 SketchDimension::Radius { target, .. } | SketchDimension::Diameter { target, .. } => { 3431 vec![row_editor(strings::PROPERTY_ROW_TARGET, label(target))] 3432 } 3433 } 3434} 3435 3436fn relation_editors( 3437 strings_table: &StringTable, 3438 rel: SketchRelation, 3439 sketch: &Sketch, 3440) -> Vec<PropertyRowSpec> { 3441 let kind_key = relation_kind_key(rel); 3442 let kind = row_editor( 3443 strings::PROPERTY_ROW_KIND, 3444 strings_table.resolve(kind_key).to_owned(), 3445 ); 3446 let label = |id: SketchEntityId| endpoint_or_entity_label(strings_table, sketch, id); 3447 let mut specs = vec![kind]; 3448 match rel { 3449 SketchRelation::Coincident(a, b) 3450 | SketchRelation::Parallel(a, b) 3451 | SketchRelation::Perpendicular(a, b) 3452 | SketchRelation::Tangent(a, b) 3453 | SketchRelation::Equal(a, b) 3454 | SketchRelation::Concentric(a, b) => { 3455 specs.push(row_editor(strings::PROPERTY_ROW_FIRST, label(a))); 3456 specs.push(row_editor(strings::PROPERTY_ROW_SECOND, label(b))); 3457 } 3458 SketchRelation::Midpoint { point, line } => { 3459 specs.push(row_editor(strings::PROPERTY_ROW_POINT, label(point))); 3460 specs.push(row_editor(strings::PROPERTY_ROW_LINE, label(line))); 3461 } 3462 SketchRelation::Symmetric { a, b, axis } => { 3463 specs.push(row_editor(strings::PROPERTY_ROW_FIRST, label(a))); 3464 specs.push(row_editor(strings::PROPERTY_ROW_SECOND, label(b))); 3465 specs.push(row_editor(strings::PROPERTY_ROW_AXIS, label(axis))); 3466 } 3467 SketchRelation::Horizontal(a) | SketchRelation::Vertical(a) | SketchRelation::Fix(a) => { 3468 specs.push(row_editor(strings::PROPERTY_ROW_TARGET, label(a))); 3469 } 3470 } 3471 specs 3472 .into_iter() 3473 .enumerate() 3474 .map(|(idx, (_default_id, label, editor))| { 3475 let row_id = WidgetId::ROOT 3476 .child(WidgetKey::new("props.relation")) 3477 .child_indexed(WidgetKey::new("row"), idx as u64); 3478 (row_id, label, editor) 3479 }) 3480 .collect() 3481} 3482 3483fn relation_kind_key(rel: SketchRelation) -> StringKey { 3484 match rel { 3485 SketchRelation::Coincident(_, _) => strings::TOOL_COINCIDENT, 3486 SketchRelation::Horizontal(_) => strings::TOOL_HORIZONTAL, 3487 SketchRelation::Vertical(_) => strings::TOOL_VERTICAL, 3488 SketchRelation::Parallel(_, _) => strings::TOOL_PARALLEL, 3489 SketchRelation::Perpendicular(_, _) => strings::TOOL_PERPENDICULAR, 3490 SketchRelation::Tangent(_, _) => strings::TOOL_TANGENT, 3491 SketchRelation::Equal(_, _) => strings::TOOL_EQUAL, 3492 SketchRelation::Concentric(_, _) => strings::TOOL_CONCENTRIC, 3493 SketchRelation::Midpoint { .. } => strings::TOOL_MIDPOINT, 3494 SketchRelation::Symmetric { .. } => strings::TOOL_SYMMETRIC, 3495 SketchRelation::Fix(_) => strings::TOOL_FIX, 3496 } 3497} 3498 3499fn endpoint_or_entity_label( 3500 strings_table: &StringTable, 3501 sketch: &Sketch, 3502 id: SketchEntityId, 3503) -> String { 3504 match sketch.entities().get(id) { 3505 Some(SketchEntity::Point(p)) => { 3506 let (x, y) = p.at().coords_mm(); 3507 format!("({}, {})", format_mm(x), format_mm(y)) 3508 } 3509 Some(SketchEntity::Line(_)) => strings_table 3510 .resolve(strings::PROPERTY_KIND_LINE) 3511 .to_owned(), 3512 Some(SketchEntity::Arc(_)) => strings_table.resolve(strings::PROPERTY_KIND_ARC).to_owned(), 3513 Some(SketchEntity::Circle(_)) => strings_table 3514 .resolve(strings::PROPERTY_KIND_CIRCLE) 3515 .to_owned(), 3516 None => "?".to_owned(), 3517 } 3518} 3519 3520type PropertyRowSpec = (WidgetId, StringKey, Box<dyn PropertyEditor>); 3521 3522fn row_editor(label: StringKey, value: impl Into<String>) -> PropertyRowSpec { 3523 let row_id = WidgetId::ROOT 3524 .child(WidgetKey::new("props.row")) 3525 .child(WidgetKey::new(label.id())); 3526 let editor: Box<dyn PropertyEditor> = Box::new(StaticTextEditor::new(value.into())); 3527 (row_id, label, editor) 3528} 3529 3530fn entity_editors( 3531 strings_table: &StringTable, 3532 entity: SketchEntity, 3533 sketch: &Sketch, 3534) -> Vec<PropertyRowSpec> { 3535 let yes_no = |b: bool| { 3536 if b { 3537 strings_table 3538 .resolve(strings::PROPERTY_VALUE_YES) 3539 .to_owned() 3540 } else { 3541 strings_table.resolve(strings::PROPERTY_VALUE_NO).to_owned() 3542 } 3543 }; 3544 match entity { 3545 SketchEntity::Point(p) => { 3546 let (x, y) = p.at().coords_mm(); 3547 vec![ 3548 row_editor( 3549 strings::PROPERTY_ROW_KIND, 3550 strings_table 3551 .resolve(strings::PROPERTY_KIND_POINT) 3552 .to_owned(), 3553 ), 3554 row_editor(strings::PROPERTY_ROW_X, format_mm(x)), 3555 row_editor(strings::PROPERTY_ROW_Y, format_mm(y)), 3556 ] 3557 } 3558 SketchEntity::Line(l) => { 3559 let from = endpoint_or_entity_label(strings_table, sketch, l.a()); 3560 let to = endpoint_or_entity_label(strings_table, sketch, l.b()); 3561 vec![ 3562 row_editor( 3563 strings::PROPERTY_ROW_KIND, 3564 strings_table 3565 .resolve(strings::PROPERTY_KIND_LINE) 3566 .to_owned(), 3567 ), 3568 row_editor(strings::PROPERTY_ROW_FROM, from), 3569 row_editor(strings::PROPERTY_ROW_TO, to), 3570 row_editor( 3571 strings::PROPERTY_ROW_CONSTRUCTION, 3572 yes_no(l.for_construction()), 3573 ), 3574 ] 3575 } 3576 SketchEntity::Arc(a) => { 3577 let center = endpoint_or_entity_label(strings_table, sketch, a.center()); 3578 let start = endpoint_or_entity_label(strings_table, sketch, a.start()); 3579 let end = endpoint_or_entity_label(strings_table, sketch, a.end()); 3580 vec![ 3581 row_editor( 3582 strings::PROPERTY_ROW_KIND, 3583 strings_table.resolve(strings::PROPERTY_KIND_ARC).to_owned(), 3584 ), 3585 row_editor(strings::PROPERTY_ROW_CENTER, center), 3586 row_editor(strings::PROPERTY_ROW_START, start), 3587 row_editor(strings::PROPERTY_ROW_END, end), 3588 row_editor( 3589 strings::PROPERTY_ROW_CONSTRUCTION, 3590 yes_no(a.for_construction()), 3591 ), 3592 ] 3593 } 3594 SketchEntity::Circle(c) => { 3595 let center = endpoint_or_entity_label(strings_table, sketch, c.center()); 3596 vec![ 3597 row_editor( 3598 strings::PROPERTY_ROW_KIND, 3599 strings_table 3600 .resolve(strings::PROPERTY_KIND_CIRCLE) 3601 .to_owned(), 3602 ), 3603 row_editor(strings::PROPERTY_ROW_CENTER, center), 3604 row_editor(strings::PROPERTY_ROW_RADIUS, format_length(c.radius())), 3605 row_editor( 3606 strings::PROPERTY_ROW_CONSTRUCTION, 3607 yes_no(c.for_construction()), 3608 ), 3609 ] 3610 } 3611 } 3612 .into_iter() 3613 .enumerate() 3614 .map(|(idx, (_default_id, label, editor))| { 3615 let row_id = WidgetId::ROOT 3616 .child(WidgetKey::new("props.entity")) 3617 .child_indexed(WidgetKey::new("row"), idx as u64); 3618 (row_id, label, editor) 3619 }) 3620 .collect() 3621} 3622 3623fn format_mm(value: f64) -> String { 3624 format!("{value:.3} mm") 3625} 3626 3627fn format_length(length: Length) -> String { 3628 format_mm(length.get::<millimeter>()) 3629} 3630 3631struct StaticTextEditor { 3632 value: String, 3633} 3634 3635impl StaticTextEditor { 3636 fn new(value: String) -> Self { 3637 Self { value } 3638 } 3639} 3640 3641impl PropertyEditor for StaticTextEditor { 3642 fn render( 3643 &mut self, 3644 ctx: &mut FrameCtx<'_>, 3645 cell: PropertyCell, 3646 _clipboard: &mut dyn Clipboard, 3647 paint: &mut Vec<WidgetPaint>, 3648 ) -> bool { 3649 let label = ctx.strings.resolve(cell.label); 3650 let a11y_label = if self.value.is_empty() { 3651 label.to_owned() 3652 } else { 3653 format!("{label}: {}", self.value) 3654 }; 3655 ctx.a11y.push( 3656 cell.row_id, 3657 cell.rect, 3658 AccessNode::new(Role::Label).with_label_text(LabelText::Owned(a11y_label)), 3659 ); 3660 paint.push(WidgetPaint::Label { 3661 rect: cell.rect, 3662 text: LabelText::Owned(self.value.clone()), 3663 color: ctx.theme().colors.text_primary(), 3664 role: ctx.theme().typography.label, 3665 }); 3666 false 3667 } 3668} 3669 3670const DOC_TAB_WIDTH_PX: f32 = 80.0; 3671 3672fn render_doc_tabs( 3673 ctx: &mut FrameCtx<'_>, 3674 rect: LayoutRect, 3675 ids: &ShellIds, 3676 paints: &mut Vec<WidgetPaint>, 3677) { 3678 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 3679 return; 3680 } 3681 let theme = ctx.theme(); 3682 paints.push(surface_for(rect, theme.elevation.level1, theme)); 3683 let tab_rect = LayoutRect::new( 3684 rect.origin, 3685 LayoutSize::new(LayoutPx::new(DOC_TAB_WIDTH_PX), rect.size.height), 3686 ); 3687 let tabs = [Tab::new( 3688 ids.doc_tab_model, 3689 tab_rect, 3690 strings::DOC_TAB_MODEL, 3691 )]; 3692 let response = show_tabs( 3693 ctx, 3694 Tabs::new( 3695 ids.doc_tabs, 3696 TabsOrientation::Top, 3697 strings::DOC_TABS_LABEL, 3698 tabs.as_slice(), 3699 ids.doc_tab_model, 3700 ), 3701 ); 3702 paints.extend(response.paint); 3703} 3704 3705#[allow( 3706 clippy::too_many_arguments, 3707 reason = "status bar bundles mode + cursor + status diagnostics in one render pass" 3708)] 3709fn render_status_bar( 3710 ctx: &mut FrameCtx<'_>, 3711 rect: LayoutRect, 3712 id: WidgetId, 3713 mode: &Mode, 3714 document: &Document, 3715 cursor_world: Option<Point2>, 3716 status_report: Option<&SketchStatusReport>, 3717 status_badge_id: WidgetId, 3718 extrude_status: Option<ExtrudeStatus<'_>>, 3719 extrude_badge_id: WidgetId, 3720 paints: &mut Vec<WidgetPaint>, 3721) -> Option<WidgetId> { 3722 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 3723 return None; 3724 } 3725 let mode_label = mode_status_label(ctx.strings, mode, document); 3726 let mode_item = StatusItem::with_text( 3727 id.child(WidgetKey::new("mode")), 3728 mode_label, 3729 StatusAlign::Start, 3730 STATUS_MODE_WIDTH, 3731 ); 3732 let units_item = StatusItem::new( 3733 id.child(WidgetKey::new("units")), 3734 strings::STATUS_UNITS_MM, 3735 StatusAlign::End, 3736 STATUS_UNITS_WIDTH, 3737 ); 3738 let coords_item = mode 3739 .is_sketch() 3740 .then(|| { 3741 cursor_world.map(|world| { 3742 let (x_mm, y_mm) = world.coords_mm(); 3743 StatusItem::with_text( 3744 id.child(WidgetKey::new("coords")), 3745 LabelText::Owned(format!("{x_mm:.2}, {y_mm:.2} mm")), 3746 StatusAlign::Center, 3747 STATUS_COORDS_WIDTH, 3748 ) 3749 }) 3750 }) 3751 .flatten(); 3752 let status_item = status_report.map(|report| { 3753 let has_panel_content = !report.offending().is_empty(); 3754 StatusItem::new( 3755 status_badge_id, 3756 status_label_key(report.status()), 3757 StatusAlign::End, 3758 STATUS_STATUS_WIDTH, 3759 ) 3760 .interactive(has_panel_content) 3761 .badge(status_color(report.status(), &ctx.theme().cad)) 3762 }); 3763 let extrude_item = extrude_status.map(|status| { 3764 let (label, color) = extrude_badge_style(status, &ctx.theme().cad); 3765 StatusItem::new( 3766 extrude_badge_id, 3767 label, 3768 StatusAlign::End, 3769 STATUS_STATUS_WIDTH, 3770 ) 3771 .interactive(status.error().is_some()) 3772 .badge(color) 3773 }); 3774 let mut items: Vec<StatusItem> = vec![mode_item]; 3775 if let Some(coords) = coords_item { 3776 items.push(coords); 3777 } 3778 if let Some(status) = status_item { 3779 items.push(status); 3780 } 3781 if let Some(extrude) = extrude_item { 3782 items.push(extrude); 3783 } 3784 items.push(units_item); 3785 let response = show_status_bar( 3786 ctx, 3787 StatusBar::new(id, rect, strings::STATUS_BAR_LABEL, &items), 3788 ); 3789 paints.extend(response.paint); 3790 response 3791 .activated 3792 .filter(|id| *id == status_badge_id || *id == extrude_badge_id) 3793} 3794 3795fn mode_status_label(strings_table: &StringTable, mode: &Mode, document: &Document) -> LabelText { 3796 match mode { 3797 Mode::Idle => LabelText::Key(strings::STATUS_READY), 3798 Mode::Extrude(_) => LabelText::Key(strings::STATUS_EXTRUDE_ACTIVE), 3799 Mode::Sketch { sketch_id, .. } => { 3800 let Some(label) = document.sketch_label(*sketch_id) else { 3801 tracing::warn!(?sketch_id, "active sketch missing from document"); 3802 return LabelText::Key(strings::STATUS_READY); 3803 }; 3804 let prefix = strings_table.resolve(strings::STATUS_SKETCH_ACTIVE); 3805 LabelText::Owned(format!("{prefix} {label}")) 3806 } 3807 } 3808} 3809 3810fn estimate_label_width(text: &str, font_size_px: f32, min_width: LayoutPx) -> LayoutPx { 3811 #[allow( 3812 clippy::cast_precision_loss, 3813 reason = "string lengths fit in f32 mantissa for any realistic label" 3814 )] 3815 let chars = text.chars().count() as f32; 3816 let est = chars * font_size_px * RIBBON_LABEL_AVG_ADVANCE_RATIO 3817 + 2.0 * RIBBON_LABEL_HORIZONTAL_PADDING_PX; 3818 LayoutPx::new(est.max(min_width.value())) 3819} 3820 3821fn group_width_for(items: &[ToolbarItem], fallback_item_size: LayoutPx, rows: usize) -> LayoutPx { 3822 let rows = rows.max(1); 3823 let col_width = |col: usize| -> f32 { 3824 (0..rows) 3825 .filter_map(|r| items.get(col * rows + r)) 3826 .map(|it| it.width.unwrap_or(fallback_item_size).value()) 3827 .fold(0.0_f32, f32::max) 3828 }; 3829 let total: f32 = (0..items.len().div_ceil(rows)) 3830 .map(|col| col_width(col) + if col == 0 { 0.0 } else { RIBBON_TOOLBAR_GAP_PX }) 3831 .sum(); 3832 LayoutPx::new(total + 2.0 * RIBBON_GROUP_PADDING_PX) 3833} 3834 3835fn group_min_width(item_size: LayoutPx, item_count: usize) -> LayoutPx { 3836 let min_items_extent = match item_count { 3837 0 => 0.0, 3838 1 => item_size.value(), 3839 _ => 2.0 * item_size.value() + RIBBON_TOOLBAR_GAP_PX, 3840 }; 3841 LayoutPx::new(min_items_extent + 2.0 * RIBBON_GROUP_PADDING_PX) 3842} 3843 3844fn relation_tool_buttons( 3845 ribbon: WidgetId, 3846 sketch: Option<&Sketch>, 3847 selection: &[SketchEntityId], 3848 sketch_disabled: bool, 3849) -> Vec<ToolbarItem> { 3850 RelationKind::ALL 3851 .iter() 3852 .copied() 3853 .map(|kind| relation_tool_item(ribbon, kind, sketch, selection, sketch_disabled)) 3854 .collect() 3855} 3856 3857fn relation_tool_item( 3858 ribbon: WidgetId, 3859 kind: RelationKind, 3860 sketch: Option<&Sketch>, 3861 selection: &[SketchEntityId], 3862 sketch_disabled: bool, 3863) -> ToolbarItem { 3864 let item = ToolbarItem::new(relation_widget_id(ribbon, kind), kind.label()) 3865 .with_icon(RibbonIconSize::Small.slot(kind.icon())); 3866 if sketch_disabled { 3867 return item.disabled(true); 3868 } 3869 let Some(sketch) = sketch else { 3870 return item.disabled(true); 3871 }; 3872 match eligibility(kind, sketch, selection) { 3873 Eligibility::Eligible(_) => item, 3874 Eligibility::Disabled(reason) => item.disabled(true).with_tooltip(reason), 3875 } 3876} 3877 3878fn build_relation_index(ribbon: WidgetId) -> BTreeMap<WidgetId, RelationKind> { 3879 RelationKind::ALL 3880 .iter() 3881 .copied() 3882 .map(|k| (relation_widget_id(ribbon, k), k)) 3883 .collect() 3884} 3885 3886fn relation_widget_id(ribbon: WidgetId, kind: RelationKind) -> WidgetId { 3887 ribbon.child(WidgetKey::new(kind.key())) 3888} 3889 3890fn build_tool_index(ribbon: WidgetId) -> BTreeMap<WidgetId, SketchTool> { 3891 SketchTool::ENTITIES 3892 .iter() 3893 .copied() 3894 .map(|t| (tool_widget_id(ribbon, t), t)) 3895 .collect() 3896} 3897 3898fn tool_widget_id(ribbon: WidgetId, tool: SketchTool) -> WidgetId { 3899 ribbon.child(WidgetKey::new(tool_key(tool))) 3900} 3901 3902fn tool_key(tool: SketchTool) -> &'static str { 3903 match tool { 3904 SketchTool::Point => "tool.point", 3905 SketchTool::Line => "tool.line", 3906 SketchTool::CenterpointArc => "tool.centerpoint_arc", 3907 SketchTool::TangentArc => "tool.tangent_arc", 3908 SketchTool::ThreePointArc => "tool.three_point_arc", 3909 SketchTool::Circle => "tool.circle", 3910 SketchTool::PerimeterCircle => "tool.perimeter_circle", 3911 SketchTool::CornerRectangle => "tool.corner_rectangle", 3912 SketchTool::CenterRectangle => "tool.center_rectangle", 3913 SketchTool::ThreePointCornerRectangle => "tool.three_point_corner_rectangle", 3914 SketchTool::ThreePointCenterRectangle => "tool.three_point_center_rectangle", 3915 SketchTool::Parallelogram => "tool.parallelogram", 3916 } 3917} 3918 3919fn tool_label(tool: SketchTool) -> StringKey { 3920 match tool { 3921 SketchTool::Point => strings::TOOL_POINT, 3922 SketchTool::Line => strings::TOOL_LINE, 3923 SketchTool::CenterpointArc => strings::TOOL_CENTERPOINT_ARC, 3924 SketchTool::TangentArc => strings::TOOL_TANGENT_ARC, 3925 SketchTool::ThreePointArc => strings::TOOL_THREE_POINT_ARC, 3926 SketchTool::Circle => strings::TOOL_CIRCLE, 3927 SketchTool::PerimeterCircle => strings::TOOL_PERIMETER_CIRCLE, 3928 SketchTool::CornerRectangle => strings::TOOL_CORNER_RECTANGLE, 3929 SketchTool::CenterRectangle => strings::TOOL_CENTER_RECTANGLE, 3930 SketchTool::ThreePointCornerRectangle => strings::TOOL_THREE_POINT_CORNER_RECTANGLE, 3931 SketchTool::ThreePointCenterRectangle => strings::TOOL_THREE_POINT_CENTER_RECTANGLE, 3932 SketchTool::Parallelogram => strings::TOOL_PARALLELOGRAM, 3933 } 3934} 3935 3936const fn tool_icon(tool: SketchTool) -> IconId { 3937 match tool { 3938 SketchTool::Point => IconId::Point, 3939 SketchTool::Line => IconId::Line, 3940 SketchTool::CenterpointArc => IconId::CenterpointArc, 3941 SketchTool::TangentArc => IconId::TangentArc, 3942 SketchTool::ThreePointArc => IconId::ThreePointArc, 3943 SketchTool::Circle => IconId::Circle, 3944 SketchTool::PerimeterCircle => IconId::PerimeterCircle, 3945 SketchTool::CornerRectangle => IconId::CornerRectangle, 3946 SketchTool::CenterRectangle => IconId::CenterRectangle, 3947 SketchTool::ThreePointCornerRectangle => IconId::ThreePointCornerRectangle, 3948 SketchTool::ThreePointCenterRectangle => IconId::ThreePointCenterRectangle, 3949 SketchTool::Parallelogram => IconId::Parallelogram, 3950 } 3951} 3952 3953fn build_feature_tool_index(ribbon: WidgetId) -> BTreeMap<WidgetId, FeatureTool> { 3954 FeatureTool::ALL 3955 .iter() 3956 .copied() 3957 .map(|t| (feature_tool_widget_id(ribbon, t), t)) 3958 .collect() 3959} 3960 3961fn feature_tool_widget_id(ribbon: WidgetId, tool: FeatureTool) -> WidgetId { 3962 ribbon.child(WidgetKey::new(feature_tool_key(tool))) 3963} 3964 3965fn feature_tool_key(tool: FeatureTool) -> &'static str { 3966 match tool { 3967 FeatureTool::ExtrudedBossBase => "tool.extruded_boss_base", 3968 FeatureTool::ExtrudedCut => "tool.extruded_cut", 3969 } 3970} 3971 3972fn feature_tool_label(tool: FeatureTool) -> StringKey { 3973 match tool { 3974 FeatureTool::ExtrudedBossBase => strings::TOOL_EXTRUDED_BOSS_BASE, 3975 FeatureTool::ExtrudedCut => strings::TOOL_EXTRUDED_CUT, 3976 } 3977} 3978 3979const fn feature_tool_icon(tool: FeatureTool) -> IconId { 3980 match tool { 3981 FeatureTool::ExtrudedBossBase => IconId::ExtrudedBossBase, 3982 FeatureTool::ExtrudedCut => IconId::ExtrudedCut, 3983 } 3984} 3985 3986fn feature_tool_items( 3987 ctx: &FrameCtx<'_>, 3988 ribbon: WidgetId, 3989 mode: &Mode, 3990 large_min: LayoutPx, 3991) -> Vec<ToolbarItem> { 3992 let active = match mode { 3993 Mode::Extrude(_) => Some(FeatureTool::ExtrudedBossBase), 3994 Mode::Idle | Mode::Sketch { .. } => None, 3995 }; 3996 let font = ctx.theme().typography.caption.size.as_px_f32(); 3997 FeatureTool::ALL 3998 .iter() 3999 .copied() 4000 .map(|t| { 4001 let base = ToolbarItem::new(feature_tool_widget_id(ribbon, t), feature_tool_label(t)) 4002 .with_icon(RibbonIconSize::Large.slot(feature_tool_icon(t))) 4003 .active(active == Some(t)); 4004 let item = if mode.is_sketch() { 4005 base.disabled(true) 4006 .with_tooltip(strings::FEATURE_HINT_EXIT_SKETCH) 4007 } else { 4008 base 4009 }; 4010 let width = estimate_label_width(ctx.strings.resolve(item.label), font, large_min); 4011 item.with_width(width) 4012 }) 4013 .collect() 4014} 4015 4016fn paint_walk( 4017 layout: &SolvedLayout, 4018 node: &SolvedNode, 4019 theme: &Theme, 4020 viewport: PanelId, 4021) -> Vec<WidgetPaint> { 4022 let walk_children = || { 4023 node.children 4024 .iter() 4025 .flat_map(|c| paint_walk(layout, layout.node(*c), theme, viewport)) 4026 }; 4027 match &node.kind { 4028 NodeKind::DockHost { .. } 4029 | NodeKind::Pass 4030 | NodeKind::Leaf(_) 4031 | NodeKind::ScrollRegion { .. } => walk_children().collect(), 4032 NodeKind::DockSplit { axis, .. } | NodeKind::Splitter { axis, .. } => walk_children() 4033 .chain(divider_paint(layout, node, *axis, theme)) 4034 .collect(), 4035 NodeKind::DockTabStrip { .. } => { 4036 core::iter::once(surface_for(node.rect, theme.elevation.level2, theme)) 4037 .chain(walk_children()) 4038 .collect() 4039 } 4040 NodeKind::DockPanel { id } if *id == viewport => Vec::new(), 4041 NodeKind::DockPanel { .. } => { 4042 core::iter::once(surface_for(node.rect, theme.elevation.level1, theme)) 4043 .chain(walk_children()) 4044 .collect() 4045 } 4046 } 4047} 4048 4049fn divider_paint( 4050 layout: &SolvedLayout, 4051 node: &SolvedNode, 4052 axis: Axis, 4053 theme: &Theme, 4054) -> Option<WidgetPaint> { 4055 let [first_idx, _] = match node.children.as_slice() { 4056 [a, b] => [*a, *b], 4057 _ => return None, 4058 }; 4059 let first = layout.node(first_idx); 4060 let rect = divider_between(axis, first.rect, node.rect); 4061 let color = theme.colors.neutral.step(Step12::BORDER); 4062 Some(WidgetPaint::Surface { 4063 rect, 4064 fill: color, 4065 border: None, 4066 radius: Radius::px(0.0), 4067 elevation: None, 4068 }) 4069} 4070 4071fn divider_between(axis: Axis, first: LayoutRect, parent: LayoutRect) -> LayoutRect { 4072 let thickness = LayoutPx::new(StrokeWidth::HAIRLINE.value_px()); 4073 match axis { 4074 Axis::Horizontal => LayoutRect::new( 4075 LayoutPos::new(first.max_x(), parent.min_y()), 4076 LayoutSize::new(thickness, parent.size.height), 4077 ), 4078 Axis::Vertical => LayoutRect::new( 4079 LayoutPos::new(parent.min_x(), first.max_y()), 4080 LayoutSize::new(parent.size.width, thickness), 4081 ), 4082 } 4083} 4084 4085fn surface_for(rect: LayoutRect, elevation: ElevationLevel, theme: &Theme) -> WidgetPaint { 4086 WidgetPaint::Surface { 4087 rect, 4088 fill: theme.colors.surface(elevation.surface), 4089 border: elevation.border, 4090 radius: Radius::px(0.0), 4091 elevation: Some(elevation), 4092 } 4093} 4094 4095fn panel_rect(solved: &SolvedLayout, id: PanelId) -> Option<LayoutRect> { 4096 solved 4097 .nodes 4098 .iter() 4099 .find(|n| matches!(n.kind, NodeKind::DockPanel { id: pid } if pid == id)) 4100 .map(|n| n.rect) 4101} 4102 4103fn leaf_rect(solved: &SolvedLayout, id: WidgetId) -> Option<LayoutRect> { 4104 solved 4105 .nodes 4106 .iter() 4107 .find(|n| matches!(n.kind, NodeKind::Leaf(wid) if wid == id)) 4108 .map(|n| n.rect) 4109} 4110 4111const MENU_BAR_HEIGHT_PX: f32 = 24.0; 4112const RIBBON_HEIGHT_PX: f32 = 82.0; 4113const DOC_TABS_HEIGHT_PX: f32 = 22.0; 4114const STATUS_BAR_HEIGHT_PX: f32 = 22.0; 4115 4116struct ChromeRows { 4117 menu: Layout, 4118 ribbon: Layout, 4119 center: Layout, 4120 doc_tabs: Layout, 4121 status: Layout, 4122} 4123 4124fn chrome_grid(rows: ChromeRows) -> Layout { 4125 let ChromeRows { 4126 menu, 4127 ribbon, 4128 center, 4129 doc_tabs, 4130 status, 4131 } = rows; 4132 let one = grid_line(1); 4133 let two = grid_line(2); 4134 let three = grid_line(3); 4135 let four = grid_line(4); 4136 let five = grid_line(5); 4137 let six = grid_line(6); 4138 let span_row = |row_start: GridLine, row_end: GridLine, child: Layout| { 4139 let Some(span) = GridSpan::rect(one, two, row_start, row_end) else { 4140 panic!("chrome row span must be increasing"); 4141 }; 4142 GridChild { span, child } 4143 }; 4144 Layout::Grid { 4145 columns: vec![GridTrack::unnamed(TrackSize::FLEX_1)], 4146 rows: vec![ 4147 GridTrack::unnamed(TrackSize::Fixed(Spacing::px(MENU_BAR_HEIGHT_PX))), 4148 GridTrack::unnamed(TrackSize::Fixed(Spacing::px(RIBBON_HEIGHT_PX))), 4149 GridTrack::unnamed(TrackSize::FLEX_1), 4150 GridTrack::unnamed(TrackSize::Fixed(Spacing::px(DOC_TABS_HEIGHT_PX))), 4151 GridTrack::unnamed(TrackSize::Fixed(Spacing::px(STATUS_BAR_HEIGHT_PX))), 4152 ], 4153 column_gap: Spacing::px(0.0), 4154 row_gap: Spacing::px(0.0), 4155 children: vec![ 4156 span_row(one, two, menu), 4157 span_row(two, three, ribbon), 4158 span_row(three, four, center), 4159 span_row(four, five, doc_tabs), 4160 span_row(five, six, status), 4161 ], 4162 } 4163} 4164 4165fn grid_line(n: u16) -> GridLine { 4166 let Some(nz) = core::num::NonZeroU16::new(n) else { 4167 panic!("grid line must be non-zero"); 4168 }; 4169 GridLine::new(nz) 4170} 4171 4172fn inset_rect(rect: LayoutRect, by: f32) -> LayoutRect { 4173 let w = (rect.size.width.value() - 2.0 * by).max(0.0); 4174 let h = (rect.size.height.value() - 2.0 * by).max(0.0); 4175 LayoutRect::new( 4176 LayoutPos::new( 4177 LayoutPx::saturating(rect.origin.x.value() + by), 4178 LayoutPx::saturating(rect.origin.y.value() + by), 4179 ), 4180 LayoutSize::new( 4181 LayoutPx::saturating_nonneg(w), 4182 LayoutPx::saturating_nonneg(h), 4183 ), 4184 ) 4185} 4186 4187fn zero_rect() -> LayoutRect { 4188 LayoutRect::new(LayoutPos::ORIGIN, LayoutSize::ZERO) 4189} 4190 4191const fn panel(value: u32) -> PanelId { 4192 let Some(nz) = NonZeroU32::new(value) else { 4193 panic!("PanelId value must be non-zero"); 4194 }; 4195 PanelId::new(nz) 4196} 4197 4198const LEFT_PANE_TAB_STRIP_HEIGHT: f32 = 28.0; 4199 4200struct LeftPaneSplit { 4201 tab_strip_rect: LayoutRect, 4202 content_rect: LayoutRect, 4203} 4204 4205fn split_left_pane(rect: LayoutRect) -> LeftPaneSplit { 4206 let strip_height = LayoutPx::new(LEFT_PANE_TAB_STRIP_HEIGHT.min(rect.size.height.value())); 4207 let tab_strip_rect = 4208 LayoutRect::new(rect.origin, LayoutSize::new(rect.size.width, strip_height)); 4209 let content_rect = LayoutRect::new( 4210 LayoutPos::new( 4211 rect.origin.x, 4212 LayoutPx::new(rect.origin.y.value() + strip_height.value()), 4213 ), 4214 LayoutSize::new( 4215 rect.size.width, 4216 LayoutPx::saturating_nonneg(rect.size.height.value() - strip_height.value()), 4217 ), 4218 ); 4219 LeftPaneSplit { 4220 tab_strip_rect, 4221 content_rect, 4222 } 4223} 4224 4225fn update_left_pane_auto( 4226 state: &mut ShellState, 4227 selection: &Selection, 4228 active_tool: Option<SketchTool>, 4229 armed_extrude: bool, 4230) { 4231 let interesting = !selection.is_empty() || active_tool.is_some() || armed_extrude; 4232 if interesting && !state.last_left_pane_interesting { 4233 state.left_pane = LeftPane::Properties; 4234 } else if !interesting && state.last_left_pane_interesting { 4235 state.left_pane = LeftPane::Tree; 4236 } 4237 state.last_left_pane_interesting = interesting; 4238} 4239 4240const LEFT_PANE_TAB_WIDTH_PX: f32 = 28.0; 4241 4242#[derive(Copy, Clone)] 4243struct LeftPaneTabSpec { 4244 id: WidgetId, 4245 label: StringKey, 4246 icon: IconId, 4247 target: Option<LeftPane>, 4248} 4249 4250fn render_left_pane_tabs( 4251 ctx: &mut FrameCtx<'_>, 4252 rect: LayoutRect, 4253 ids: &ShellIds, 4254 active: LeftPane, 4255 paints: &mut Vec<WidgetPaint>, 4256) -> Option<LeftPane> { 4257 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 4258 return None; 4259 } 4260 let specs = [ 4261 LeftPaneTabSpec { 4262 id: ids.left_pane_tab_tree, 4263 label: strings::FEATURE_TREE_LABEL, 4264 icon: IconId::TabTree, 4265 target: Some(LeftPane::Tree), 4266 }, 4267 LeftPaneTabSpec { 4268 id: ids.left_pane_tab_properties, 4269 label: strings::PROPERTY_PANE_LABEL, 4270 icon: IconId::TabProperties, 4271 target: Some(LeftPane::Properties), 4272 }, 4273 LeftPaneTabSpec { 4274 id: ids.left_pane_tab_configuration, 4275 label: strings::LEFT_PANE_TAB_CONFIGURATION, 4276 icon: IconId::TabConfiguration, 4277 target: None, 4278 }, 4279 LeftPaneTabSpec { 4280 id: ids.left_pane_tab_dimension_expert, 4281 label: strings::LEFT_PANE_TAB_DIMENSION_EXPERT, 4282 icon: IconId::TabDimensionExpert, 4283 target: None, 4284 }, 4285 LeftPaneTabSpec { 4286 id: ids.left_pane_tab_display, 4287 label: strings::LEFT_PANE_TAB_DISPLAY, 4288 icon: IconId::TabDisplay, 4289 target: None, 4290 }, 4291 ]; 4292 let tab_views: Vec<Tab> = specs 4293 .iter() 4294 .scan(rect.origin.x.value(), |x, spec| { 4295 let tab_rect = LayoutRect::new( 4296 LayoutPos::new(LayoutPx::new(*x), rect.origin.y), 4297 LayoutSize::new(LayoutPx::new(LEFT_PANE_TAB_WIDTH_PX), rect.size.height), 4298 ); 4299 *x += LEFT_PANE_TAB_WIDTH_PX; 4300 Some( 4301 Tab::new(spec.id, tab_rect, spec.label) 4302 .with_icon(spec.icon) 4303 .disabled(spec.target.is_none()), 4304 ) 4305 }) 4306 .collect(); 4307 let active_id = specs 4308 .iter() 4309 .find_map(|spec| (spec.target == Some(active)).then_some(spec.id)) 4310 .unwrap_or(ids.left_pane_tab_tree); 4311 let response = show_tabs( 4312 ctx, 4313 Tabs::new( 4314 ids.left_pane.child(WidgetKey::new("tabs")), 4315 TabsOrientation::Top, 4316 strings::LEFT_PANE_LABEL, 4317 tab_views.as_slice(), 4318 active_id, 4319 ), 4320 ); 4321 paints.extend(response.paint); 4322 response.activated.and_then(|id| { 4323 specs 4324 .iter() 4325 .find_map(|spec| (spec.id == id).then_some(spec.target).flatten()) 4326 }) 4327} 4328 4329const CONFIRM_BUTTON_PX: f32 = 36.0; 4330const CONFIRM_BUTTON_GAP: f32 = 6.0; 4331const CONFIRM_CORNER_INSET: f32 = 12.0; 4332 4333#[derive(Copy, Clone, Debug, PartialEq, Eq)] 4334pub enum ConfirmAction { 4335 Accept, 4336 Cancel, 4337} 4338 4339fn render_confirm_corner( 4340 ctx: &mut FrameCtx<'_>, 4341 viewport: LayoutRect, 4342 ids: &ShellIds, 4343 visible: bool, 4344 paints: &mut Vec<WidgetPaint>, 4345) -> Option<ConfirmAction> { 4346 let pair_width = 2.0 * CONFIRM_BUTTON_PX + CONFIRM_BUTTON_GAP; 4347 let min_width = pair_width + 2.0 * CONFIRM_CORNER_INSET; 4348 let min_height = CONFIRM_BUTTON_PX + 2.0 * CONFIRM_CORNER_INSET; 4349 if !visible 4350 || viewport.size.width.value() < min_width 4351 || viewport.size.height.value() < min_height 4352 { 4353 return None; 4354 } 4355 let top_y = viewport.origin.y.value() + CONFIRM_CORNER_INSET; 4356 let cancel_x = viewport.origin.x.value() + viewport.size.width.value() 4357 - CONFIRM_CORNER_INSET 4358 - CONFIRM_BUTTON_PX; 4359 let accept_x = cancel_x - CONFIRM_BUTTON_GAP - CONFIRM_BUTTON_PX; 4360 let accept_rect = LayoutRect::new( 4361 LayoutPos::new(LayoutPx::new(accept_x), LayoutPx::new(top_y)), 4362 LayoutSize::new( 4363 LayoutPx::new(CONFIRM_BUTTON_PX), 4364 LayoutPx::new(CONFIRM_BUTTON_PX), 4365 ), 4366 ); 4367 let cancel_rect = LayoutRect::new( 4368 LayoutPos::new(LayoutPx::new(cancel_x), LayoutPx::new(top_y)), 4369 LayoutSize::new( 4370 LayoutPx::new(CONFIRM_BUTTON_PX), 4371 LayoutPx::new(CONFIRM_BUTTON_PX), 4372 ), 4373 ); 4374 let accept_clicked = paint_confirm_button( 4375 ctx, 4376 ids.confirm_accept, 4377 accept_rect, 4378 IconId::Check, 4379 strings::CONFIRM_ACCEPT, 4380 ConfirmTone::Accept, 4381 paints, 4382 ); 4383 let cancel_clicked = paint_confirm_button( 4384 ctx, 4385 ids.confirm_cancel, 4386 cancel_rect, 4387 IconId::Cross, 4388 strings::CONFIRM_CANCEL, 4389 ConfirmTone::Cancel, 4390 paints, 4391 ); 4392 if accept_clicked { 4393 Some(ConfirmAction::Accept) 4394 } else if cancel_clicked { 4395 Some(ConfirmAction::Cancel) 4396 } else { 4397 None 4398 } 4399} 4400 4401#[derive(Copy, Clone)] 4402enum ConfirmTone { 4403 Accept, 4404 Cancel, 4405} 4406 4407fn paint_confirm_button( 4408 ctx: &mut FrameCtx<'_>, 4409 id: WidgetId, 4410 rect: LayoutRect, 4411 icon: IconId, 4412 label: StringKey, 4413 tone: ConfirmTone, 4414 paints: &mut Vec<WidgetPaint>, 4415) -> bool { 4416 let interaction = ctx.interact( 4417 InteractDeclaration::new(id, rect, Sense::INTERACTIVE) 4418 .focusable(true) 4419 .a11y(AccessNode::new(Role::Button).with_label(label)), 4420 ); 4421 let theme = ctx.theme(); 4422 let palette = match tone { 4423 ConfirmTone::Accept => theme.colors.success, 4424 ConfirmTone::Cancel => theme.colors.danger, 4425 }; 4426 let (fill, glyph_color) = if interaction.pressed() { 4427 ( 4428 palette.step(Step12::SELECTED_BG), 4429 palette.step(Step12::HOVER_SOLID), 4430 ) 4431 } else if interaction.hover() { 4432 (palette.step(Step12::HOVER_BG), palette.step(Step12::SOLID)) 4433 } else { 4434 ( 4435 theme.colors.surface(theme.elevation.level3.surface), 4436 palette.step(Step12::SOLID), 4437 ) 4438 }; 4439 paints.push(WidgetPaint::Surface { 4440 rect, 4441 fill, 4442 border: Some(Border { 4443 width: StrokeWidth::HAIRLINE, 4444 color: palette.step(Step12::SOLID), 4445 }), 4446 radius: theme.radius.sm, 4447 elevation: Some(theme.elevation.level3), 4448 }); 4449 paints.push(WidgetPaint::Icon { 4450 rect, 4451 icon, 4452 tint: IconTint::Solid(glyph_color), 4453 }); 4454 interaction.click() 4455} 4456 4457fn build_dock_main(panels: ShellPanels) -> DockNode { 4458 const LEFT_PANE_RATIO: SplitFraction = SplitFraction::clamped(0.12); 4459 DockNode::split( 4460 Axis::Horizontal, 4461 LEFT_PANE_RATIO, 4462 DockNode::tabs(vec![panels.left_pane]), 4463 DockNode::tabs(vec![panels.viewport]), 4464 ) 4465} 4466 4467#[cfg(test)] 4468mod tests { 4469 use super::*; 4470 use bone_document::Document; 4471 use bone_types::{DocumentId, SketchId}; 4472 use bone_ui::a11y::AccessTreeBuilder; 4473 use bone_ui::focus::FocusManager; 4474 use bone_ui::hit_test::{HitFrame, HitState}; 4475 use bone_ui::hotkey::HotkeyTable; 4476 use bone_ui::input::{FrameInstant, InputSnapshot}; 4477 use bone_ui::strings::{Locale, StringKey, StringTable}; 4478 use bone_ui::theme::Theme; 4479 use bone_ui::widgets::LabelText; 4480 use std::sync::Arc; 4481 4482 fn layout_size(w: f32, h: f32) -> LayoutSize { 4483 LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h)) 4484 } 4485 4486 fn sample_document() -> Document { 4487 Document::new(DocumentId::default(), "Sample".to_owned()) 4488 } 4489 4490 fn render_with(theme: Theme, size: LayoutSize, document: &Document, mode: &Mode) -> ShellFrame { 4491 let mut shell = Shell::new(); 4492 render_into_shell( 4493 &mut shell, 4494 theme, 4495 size, 4496 document, 4497 mode, 4498 &Selection::default(), 4499 ) 4500 } 4501 4502 fn render_with_strings( 4503 shell: &mut Shell, 4504 theme: Theme, 4505 size: LayoutSize, 4506 document: &Document, 4507 mode: &Mode, 4508 selection: &Selection, 4509 strings: &StringTable, 4510 ) -> ShellFrame { 4511 let theme = Arc::new(theme); 4512 let table = HotkeyTable::new(); 4513 let mut focus = FocusManager::new(); 4514 let mut hits = HitFrame::new(); 4515 let prev = HitState::new(); 4516 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 4517 let mut shaper = bone_text::Shaper::new(); 4518 let mut a11y = AccessTreeBuilder::new(); 4519 let mut ctx = FrameCtx::new( 4520 theme, 4521 &mut input, 4522 &mut focus, 4523 &table, 4524 strings, 4525 &mut hits, 4526 &prev, 4527 &mut a11y, 4528 &mut shaper, 4529 ); 4530 shell.render( 4531 &mut ctx, 4532 document, 4533 mode, 4534 selection, 4535 &Settings::default(), 4536 size, 4537 None, 4538 None, 4539 None, 4540 &mut ViewUi::default(), 4541 &BTreeMap::new(), 4542 false, 4543 &[], 4544 ) 4545 } 4546 4547 fn render_into_shell( 4548 shell: &mut Shell, 4549 theme: Theme, 4550 size: LayoutSize, 4551 document: &Document, 4552 mode: &Mode, 4553 selection: &Selection, 4554 ) -> ShellFrame { 4555 render_with_strings( 4556 shell, 4557 theme, 4558 size, 4559 document, 4560 mode, 4561 selection, 4562 StringTable::empty(), 4563 ) 4564 } 4565 4566 fn label_rect(paints: &[WidgetPaint], target: StringKey) -> Option<LayoutRect> { 4567 paints.iter().find_map(|p| match p { 4568 WidgetPaint::Label { 4569 rect, 4570 text: LabelText::Key(k), 4571 .. 4572 } 4573 | WidgetPaint::AlignedLabel { 4574 rect, 4575 text: LabelText::Key(k), 4576 .. 4577 } if *k == target => Some(*rect), 4578 _ => None, 4579 }) 4580 } 4581 4582 fn blind_extrude(sketch: SketchId) -> ExtrudeFeature { 4583 use bone_document::{ExtrudeDirection, ExtrudeSense}; 4584 let Ok(depth) = PositiveLength::new(Length::new::<bone_types::millimeter>(10.0)) else { 4585 panic!("ten millimeters is a positive depth"); 4586 }; 4587 ExtrudeFeature { 4588 sketch, 4589 direction: ExtrudeDirection::Normal { 4590 sense: ExtrudeSense::Forward, 4591 }, 4592 end_condition: ExtrudeEndCondition::Blind { depth }, 4593 draft: None, 4594 thin_wall: None, 4595 merge_result: MergeResult::Separate, 4596 } 4597 } 4598 4599 fn push_plain_chain(document: &mut Document, label: &str) -> (SketchId, ExtrudeId) { 4600 let sketch_id = document.allocate_sketch(); 4601 document.insert_sketch(sketch_id, label.to_owned(), Sketch::new(Plane::Xy.basis())); 4602 let extrude_id = document.commit_extrude(blind_extrude(sketch_id)); 4603 (sketch_id, extrude_id) 4604 } 4605 4606 fn test_part_id() -> WidgetId { 4607 use std::num::NonZeroU64; 4608 WidgetId::from_raw(NonZeroU64::MIN).child(WidgetKey::new("part")) 4609 } 4610 4611 #[test] 4612 fn feature_rows_nest_sketch_under_consuming_extrude() { 4613 let mut document = sample_document(); 4614 let (sketch_a, extrude_a) = push_plain_chain(&mut document, "Sketch1"); 4615 let (sketch_b, extrude_b) = push_plain_chain(&mut document, "Sketch2"); 4616 let part_id = test_part_id(); 4617 let rows = feature_rows(&document, part_id, &BTreeMap::new()); 4618 assert_eq!( 4619 rows.len(), 4620 2, 4621 "two extrudes are the only top-level features" 4622 ); 4623 let assert_chain = |node: &TreeNode, extrude_id: ExtrudeId, sketch_id: SketchId| { 4624 assert_eq!(node.id, extrude_widget_id(part_id, extrude_id)); 4625 assert_eq!(node.children.len(), 1, "the extrude absorbs its sketch"); 4626 assert_eq!(node.children[0].id, sketch_widget_id(part_id, sketch_id)); 4627 }; 4628 assert_chain(&rows[0], extrude_a, sketch_a); 4629 assert_chain(&rows[1], extrude_b, sketch_b); 4630 } 4631 4632 #[test] 4633 fn feature_rows_keep_unconsumed_sketch_at_top_level() { 4634 let mut document = sample_document(); 4635 let sketch_id = document.allocate_sketch(); 4636 document.insert_sketch( 4637 sketch_id, 4638 "Sketch1".to_owned(), 4639 Sketch::new(Plane::Xy.basis()), 4640 ); 4641 let part_id = test_part_id(); 4642 let rows = feature_rows(&document, part_id, &BTreeMap::new()); 4643 assert_eq!(rows.len(), 1); 4644 assert_eq!(rows[0].id, sketch_widget_id(part_id, sketch_id)); 4645 assert!( 4646 rows[0].children.is_empty(), 4647 "an unconsumed sketch has no nested feature" 4648 ); 4649 } 4650 4651 fn menu_labels(items: &[MenuItem]) -> Vec<StringKey> { 4652 items 4653 .iter() 4654 .filter_map(|item| match item { 4655 MenuItem::Action { label, .. } => Some(*label), 4656 _ => None, 4657 }) 4658 .collect() 4659 } 4660 4661 #[test] 4662 fn feature_menu_items_for_extrude_include_edit_feature_and_suppress() { 4663 let ids = FeatureMenuIds::new(test_part_id()); 4664 let items = feature_menu_items( 4665 &ids, 4666 FeatureTarget::Extrude(ExtrudeId::default()), 4667 false, 4668 true, 4669 ); 4670 let labels = menu_labels(&items); 4671 assert!(labels.contains(&strings::FEATURE_CTX_EDIT_FEATURE)); 4672 assert!(labels.contains(&strings::FEATURE_CTX_EDIT_SKETCH)); 4673 assert!(labels.contains(&strings::FEATURE_CTX_SUPPRESS)); 4674 assert!(!labels.contains(&strings::FEATURE_CTX_UNSUPPRESS)); 4675 assert!(labels.contains(&strings::FEATURE_CTX_DELETE)); 4676 assert!(labels.contains(&strings::FEATURE_CTX_RELATIONSHIPS)); 4677 } 4678 4679 #[test] 4680 fn feature_menu_items_for_suppressed_sketch_omit_edit_feature_and_offer_unsuppress() { 4681 let ids = FeatureMenuIds::new(test_part_id()); 4682 let items = 4683 feature_menu_items(&ids, FeatureTarget::Sketch(SketchId::default()), true, true); 4684 let labels = menu_labels(&items); 4685 assert!(!labels.contains(&strings::FEATURE_CTX_EDIT_FEATURE)); 4686 assert!(labels.contains(&strings::FEATURE_CTX_UNSUPPRESS)); 4687 assert!(!labels.contains(&strings::FEATURE_CTX_SUPPRESS)); 4688 } 4689 4690 #[test] 4691 fn feature_menu_outcome_maps_ids_to_commands() { 4692 let mut document = sample_document(); 4693 let (sketch, extrude) = push_plain_chain(&mut document, "Sketch1"); 4694 let target = FeatureTarget::Extrude(extrude); 4695 let ids = FeatureMenuIds::new(test_part_id()); 4696 let Some(feature) = feature_id_of(&document, target) else { 4697 panic!("the extrude has a feature id"); 4698 }; 4699 assert_eq!( 4700 feature_menu_outcome_for(&ids, ids.suppress, target, &document), 4701 Some(FeatureMenuOutcome::Command(FeatureCommand::Suppress( 4702 feature 4703 ))), 4704 ); 4705 assert_eq!( 4706 feature_menu_outcome_for(&ids, ids.delete, target, &document), 4707 Some(FeatureMenuOutcome::Command(FeatureCommand::Delete(target))), 4708 ); 4709 assert_eq!( 4710 feature_menu_outcome_for(&ids, ids.edit_sketch, target, &document), 4711 Some(FeatureMenuOutcome::EditSketch(sketch)), 4712 "edit sketch on an extrude resolves to its source sketch", 4713 ); 4714 assert_eq!( 4715 feature_menu_outcome_for(&ids, ids.relationships, target, &document), 4716 Some(FeatureMenuOutcome::ShowRelationships(target)), 4717 ); 4718 } 4719 4720 #[test] 4721 fn rollback_target_and_change_round_trip_through_the_marker() { 4722 let mut document = sample_document(); 4723 let (_sketch, extrude) = push_plain_chain(&mut document, "Sketch1"); 4724 let part_id = test_part_id(); 4725 assert_eq!( 4726 current_rollback_target(&document, part_id), 4727 RollbackTarget::AtEnd, 4728 "a fresh document is rolled to the end", 4729 ); 4730 let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else { 4731 panic!("the extrude resolves to a feature id"); 4732 }; 4733 document.roll_to_here(feature); 4734 let widget = extrude_widget_id(part_id, extrude); 4735 assert_eq!( 4736 current_rollback_target(&document, part_id), 4737 RollbackTarget::Above(widget), 4738 ); 4739 let w2s: BTreeMap<WidgetId, SketchId> = sketch_widget_ids(&document, part_id) 4740 .into_iter() 4741 .map(|(s, w)| (w, s)) 4742 .collect(); 4743 let w2e: BTreeMap<WidgetId, ExtrudeId> = extrude_widget_ids(&document, part_id) 4744 .into_iter() 4745 .map(|(e, w)| (w, e)) 4746 .collect(); 4747 assert_eq!( 4748 resolve_rollback_change(&document, &w2s, &w2e, RollbackTarget::AtEnd), 4749 Some(RollbackChange::ToEnd), 4750 ); 4751 assert_eq!( 4752 resolve_rollback_change(&document, &w2s, &w2e, RollbackTarget::Above(widget)), 4753 Some(RollbackChange::ToFeature(feature)), 4754 ); 4755 } 4756 4757 #[test] 4758 fn rolled_back_feature_node_is_disabled() { 4759 let mut document = sample_document(); 4760 let (_sketch, extrude) = push_plain_chain(&mut document, "Sketch1"); 4761 let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else { 4762 panic!("the extrude resolves to a feature id"); 4763 }; 4764 document.roll_to_here(feature); 4765 let part_id = test_part_id(); 4766 let rows = feature_rows(&document, part_id, &BTreeMap::new()); 4767 assert!( 4768 rows[0].disabled, 4769 "a rolled-back feature is greyed and inert" 4770 ); 4771 } 4772 4773 #[test] 4774 fn extrude_node_shows_its_own_error_badge() { 4775 let mut document = sample_document(); 4776 let (_sketch, extrude) = push_plain_chain(&mut document, "Sketch1"); 4777 let part_id = test_part_id(); 4778 let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else { 4779 panic!("the extrude resolves to a feature id"); 4780 }; 4781 let badges = BTreeMap::from([(feature, TreeBadge::Error)]); 4782 let rows = feature_rows(&document, part_id, &badges); 4783 assert_eq!(rows.len(), 1); 4784 assert_eq!(rows[0].badge, Some(TreeBadge::Error)); 4785 } 4786 4787 #[test] 4788 fn extrude_node_rolls_up_a_warning_from_its_nested_sketch() { 4789 let mut document = sample_document(); 4790 let (sketch, _extrude) = push_plain_chain(&mut document, "Sketch1"); 4791 let part_id = test_part_id(); 4792 let Some(sketch_feature) = document.feature_tree().feature_of_sketch(sketch) else { 4793 panic!("the sketch resolves to a feature id"); 4794 }; 4795 let badges = BTreeMap::from([(sketch_feature, TreeBadge::Warning)]); 4796 let rows = feature_rows(&document, part_id, &badges); 4797 assert_eq!( 4798 rows[0].badge, 4799 Some(TreeBadge::Warning), 4800 "the extrude rolls up its nested sketch's warning", 4801 ); 4802 assert_eq!(rows[0].children[0].badge, Some(TreeBadge::Warning)); 4803 } 4804 4805 #[test] 4806 fn extrude_node_error_outranks_a_nested_warning() { 4807 let mut document = sample_document(); 4808 let (sketch, extrude) = push_plain_chain(&mut document, "Sketch1"); 4809 let part_id = test_part_id(); 4810 let Some(sketch_feature) = document.feature_tree().feature_of_sketch(sketch) else { 4811 panic!("the sketch resolves to a feature id"); 4812 }; 4813 let Some(extrude_feature) = document.feature_tree().feature_of_extrude(extrude) else { 4814 panic!("the extrude resolves to a feature id"); 4815 }; 4816 let badges = BTreeMap::from([ 4817 (extrude_feature, TreeBadge::Warning), 4818 (sketch_feature, TreeBadge::Error), 4819 ]); 4820 let rows = feature_rows(&document, part_id, &badges); 4821 assert_eq!( 4822 rows[0].badge, 4823 Some(TreeBadge::Error), 4824 "an error on the nested sketch outranks the extrude's own warning", 4825 ); 4826 } 4827 4828 #[test] 4829 fn drop_to_reorder_accepts_legal_move_and_rejects_illegal() { 4830 let mut document = sample_document(); 4831 let (sketch_a, extrude_a) = push_plain_chain(&mut document, "Sketch1"); 4832 let (_sketch_b, extrude_b) = push_plain_chain(&mut document, "Sketch2"); 4833 let part_id = test_part_id(); 4834 let w2s: BTreeMap<WidgetId, SketchId> = sketch_widget_ids(&document, part_id) 4835 .into_iter() 4836 .map(|(s, w)| (w, s)) 4837 .collect(); 4838 let w2e: BTreeMap<WidgetId, ExtrudeId> = extrude_widget_ids(&document, part_id) 4839 .into_iter() 4840 .map(|(e, w)| (w, e)) 4841 .collect(); 4842 let src = extrude_widget_id(part_id, extrude_a); 4843 let after_b = DropTarget { 4844 anchor: extrude_widget_id(part_id, extrude_b), 4845 placement: DropPlacement::After, 4846 }; 4847 let Some(reorder) = drop_to_reorder(&document, &w2s, &w2e, src, after_b) else { 4848 panic!("moving the first extrude after the second is legal"); 4849 }; 4850 assert!(!reorder.before); 4851 let before_own_sketch = DropTarget { 4852 anchor: sketch_widget_id(part_id, sketch_a), 4853 placement: DropPlacement::Before, 4854 }; 4855 assert!( 4856 drop_to_reorder(&document, &w2s, &w2e, src, before_own_sketch).is_none(), 4857 "an extrude cannot move before its own sketch", 4858 ); 4859 } 4860 4861 #[test] 4862 fn relationship_rows_list_the_extrude_parent_sketch_and_no_children() { 4863 let mut document = sample_document(); 4864 let (_sketch, extrude) = push_plain_chain(&mut document, "Sketch1"); 4865 let Some(feature) = feature_id_of(&document, FeatureTarget::Extrude(extrude)) else { 4866 panic!("the extrude has a feature id"); 4867 }; 4868 let rows = relationship_rows(&document, feature); 4869 let owned: Vec<&str> = rows 4870 .iter() 4871 .filter_map(|row| match &row.text { 4872 LabelText::Owned(label) => Some(label.as_str()), 4873 LabelText::Key(_) => None, 4874 }) 4875 .collect(); 4876 assert_eq!( 4877 owned, 4878 vec!["Sketch1"], 4879 "the only parent is the source sketch" 4880 ); 4881 let has_none = rows.iter().any( 4882 |row| matches!(&row.text, LabelText::Key(key) if *key == strings::FEATURE_REL_NONE), 4883 ); 4884 assert!(has_none, "a childless extrude shows None under children"); 4885 } 4886 4887 #[test] 4888 fn tools_options_menu_id_maps_to_open_settings_action() { 4889 let shell = Shell::new(); 4890 assert_eq!( 4891 shell.ids.menu_action_for(shell.ids.menu_tools_options), 4892 Some(MenuAction::OpenSettings), 4893 ); 4894 } 4895 4896 #[test] 4897 fn file_menu_ids_map_to_file_actions() { 4898 let shell = Shell::new(); 4899 assert_eq!( 4900 shell.ids.menu_action_for(shell.ids.menu_file_new), 4901 Some(MenuAction::NewDocument), 4902 ); 4903 assert_eq!( 4904 shell.ids.menu_action_for(shell.ids.menu_file_open), 4905 Some(MenuAction::OpenDocument), 4906 ); 4907 assert_eq!( 4908 shell.ids.menu_action_for(shell.ids.menu_file_save), 4909 Some(MenuAction::SaveDocument), 4910 ); 4911 assert_eq!( 4912 shell.ids.menu_action_for(shell.ids.menu_file_save_as), 4913 Some(MenuAction::SaveDocumentAs), 4914 ); 4915 assert_eq!( 4916 shell.ids.menu_action_for(shell.ids.menu_file_import), 4917 Some(MenuAction::ImportStep), 4918 ); 4919 assert_eq!( 4920 shell.ids.menu_action_for(shell.ids.menu_file_export_step), 4921 Some(MenuAction::ExportStep), 4922 ); 4923 assert_eq!(shell.ids.menu_action_for(shell.ids.menu_file_export), None); 4924 } 4925 4926 #[test] 4927 fn file_menu_actions_are_enabled() { 4928 let entries = build_menu_entries( 4929 &ShellIds::standard(), 4930 false, 4931 &crate::hotkeys::HotkeyOverrides::default(), 4932 ); 4933 let Some(file_menu) = entries.iter().find(|e| e.label == strings::MENU_FILE) else { 4934 panic!("file menu entry missing"); 4935 }; 4936 let actions: Vec<(StringKey, bool)> = file_menu 4937 .items 4938 .iter() 4939 .filter_map(|i| match i { 4940 MenuItem::Action { 4941 label, disabled, .. 4942 } => Some((*label, *disabled)), 4943 _ => None, 4944 }) 4945 .collect(); 4946 let entry_for = |key: StringKey| { 4947 actions 4948 .iter() 4949 .find(|(l, _)| *l == key) 4950 .copied() 4951 .unwrap_or((key, true)) 4952 }; 4953 assert!(!entry_for(strings::MENU_FILE_NEW).1); 4954 assert!(!entry_for(strings::MENU_FILE_OPEN).1); 4955 assert!(!entry_for(strings::MENU_FILE_SAVE).1); 4956 assert!(!entry_for(strings::MENU_FILE_SAVE_AS).1); 4957 assert!(!entry_for(strings::MENU_FILE_IMPORT).1); 4958 let export_formats: Vec<(StringKey, bool)> = file_menu 4959 .items 4960 .iter() 4961 .filter_map(|i| match i { 4962 MenuItem::Submenu { label, items, .. } if *label == strings::MENU_FILE_EXPORT => { 4963 Some(items) 4964 } 4965 _ => None, 4966 }) 4967 .flatten() 4968 .filter_map(|i| match i { 4969 MenuItem::Action { 4970 label, disabled, .. 4971 } => Some((*label, *disabled)), 4972 _ => None, 4973 }) 4974 .collect(); 4975 assert_eq!( 4976 export_formats, 4977 vec![(strings::MENU_FILE_EXPORT_STEP, false)], 4978 ); 4979 } 4980 4981 #[test] 4982 fn settings_dialog_does_not_render_when_closed() { 4983 let frame = render_with( 4984 Theme::light(), 4985 layout_size(1280.0, 800.0), 4986 &sample_document(), 4987 &Mode::Idle, 4988 ); 4989 let title_visible = frame 4990 .paints 4991 .iter() 4992 .chain(frame.overlay_paints.iter()) 4993 .any(|p| { 4994 matches!( 4995 p, 4996 WidgetPaint::Label { 4997 text: LabelText::Key(k), 4998 .. 4999 } if *k == strings::SETTINGS_DIALOG_TITLE 5000 ) 5001 }); 5002 assert!(!title_visible, "settings dialog must not paint when closed"); 5003 assert!(frame.settings_change.is_none()); 5004 } 5005 5006 #[test] 5007 fn settings_dialog_paints_title_and_aperture_label_when_open() { 5008 let mut shell = Shell::new(); 5009 shell.state.settings_dialog_open = true; 5010 let frame = render_into_shell( 5011 &mut shell, 5012 Theme::light(), 5013 layout_size(1280.0, 800.0), 5014 &sample_document(), 5015 &Mode::Idle, 5016 &Selection::default(), 5017 ); 5018 let has_title = frame.overlay_paints.iter().any(|p| { 5019 matches!( 5020 p, 5021 WidgetPaint::Label { 5022 text: LabelText::Key(k), 5023 .. 5024 } if *k == strings::SETTINGS_DIALOG_TITLE 5025 ) 5026 }); 5027 assert!(has_title, "open dialog should paint its title key"); 5028 let has_aperture_text = frame.overlay_paints.iter().any(|p| { 5029 matches!( 5030 p, 5031 WidgetPaint::Label { 5032 text: LabelText::Owned(text), 5033 .. 5034 } if text.contains("px") 5035 ) 5036 }); 5037 assert!( 5038 has_aperture_text, 5039 "aperture label should include unit suffix px" 5040 ); 5041 } 5042 5043 #[test] 5044 fn shell_renders_with_non_empty_paint_list() { 5045 let frame = render_with( 5046 Theme::light(), 5047 layout_size(1280.0, 800.0), 5048 &sample_document(), 5049 &Mode::Idle, 5050 ); 5051 assert!(!frame.paints.is_empty()); 5052 } 5053 5054 #[test] 5055 fn shell_carves_out_viewport_region() { 5056 let frame = render_with( 5057 Theme::light(), 5058 layout_size(1280.0, 800.0), 5059 &sample_document(), 5060 &Mode::Idle, 5061 ); 5062 let v = frame.viewport_rect; 5063 assert!(v.size.width.value() > 0.0); 5064 assert!(v.size.height.value() > 0.0); 5065 assert!(v.min_x().value() > 0.0, "left pane carved on left"); 5066 assert!(v.min_y().value() > 0.0, "ribbon carved on top"); 5067 assert!( 5068 v.max_x().value() <= 1280.0, 5069 "viewport bounded by window width" 5070 ); 5071 assert!(v.max_y().value() < 800.0, "status bar carved on bottom"); 5072 } 5073 5074 #[test] 5075 fn shell_does_not_paint_viewport_panel_body() { 5076 let frame = render_with( 5077 Theme::light(), 5078 layout_size(1280.0, 800.0), 5079 &sample_document(), 5080 &Mode::Idle, 5081 ); 5082 let viewport_rect = frame.viewport_rect; 5083 let center = LayoutPos::new( 5084 LayoutPx::new(viewport_rect.min_x().value() + viewport_rect.size.width.value() * 0.5), 5085 LayoutPx::new(viewport_rect.min_y().value() + viewport_rect.size.height.value() * 0.5), 5086 ); 5087 let any_paint_covers_center = frame.paints.iter().any(|p| match p { 5088 WidgetPaint::Surface { rect, .. } => rect.contains(center), 5089 _ => false, 5090 }); 5091 assert!(!any_paint_covers_center); 5092 } 5093 5094 #[test] 5095 fn shell_seeds_part_node_expanded() { 5096 let shell = Shell::new(); 5097 assert!( 5098 shell 5099 .state 5100 .feature_tree 5101 .expanded 5102 .contains(&shell.ids.feature_part) 5103 ); 5104 } 5105 5106 #[test] 5107 fn tool_index_round_trips_every_entity_tool() { 5108 let ids = ShellIds::standard(); 5109 let index = build_tool_index(ids.ribbon); 5110 SketchTool::ENTITIES.iter().copied().for_each(|t| { 5111 let id = tool_widget_id(ids.ribbon, t); 5112 assert_eq!(index.get(&id).copied(), Some(t)); 5113 }); 5114 } 5115 5116 #[test] 5117 fn feature_tool_index_round_trips_both_extrude_buttons() { 5118 let ids = ShellIds::standard(); 5119 let index = build_feature_tool_index(ids.ribbon); 5120 FeatureTool::ALL.iter().copied().for_each(|t| { 5121 let id = feature_tool_widget_id(ids.ribbon, t); 5122 assert_eq!(index.get(&id).copied(), Some(t)); 5123 }); 5124 } 5125 5126 #[test] 5127 fn features_tab_renders_extrude_group_only_when_selected() { 5128 let document = sample_document(); 5129 let size = layout_size(1280.0, 800.0); 5130 let sketch_view = render_with(Theme::light(), size, &document, &Mode::Idle); 5131 assert!( 5132 label_rect(&sketch_view.paints, strings::TOOL_EXTRUDED_BOSS_BASE).is_none(), 5133 "extrude tools hidden while the sketch tab is active", 5134 ); 5135 assert!( 5136 label_rect(&sketch_view.paints, strings::TOOL_POINT).is_some(), 5137 "sketch tab shows its entity tools", 5138 ); 5139 5140 let mut shell = Shell::new(); 5141 shell.state.ribbon_active_tab = Some(features_tab_id(shell.ids.ribbon)); 5142 let features_view = render_into_shell( 5143 &mut shell, 5144 Theme::light(), 5145 size, 5146 &document, 5147 &Mode::Idle, 5148 &Selection::default(), 5149 ); 5150 assert!( 5151 label_rect(&features_view.paints, strings::TOOL_EXTRUDED_BOSS_BASE).is_some(), 5152 "selecting the features tab reveals the extrude tools", 5153 ); 5154 } 5155 5156 #[test] 5157 fn awaiting_sketch_extrude_prompts_in_property_pane() { 5158 let frame = render_with( 5159 Theme::light(), 5160 layout_size(1280.0, 800.0), 5161 &sample_document(), 5162 &Mode::Extrude(ExtrudeArming::AwaitingSketch), 5163 ); 5164 assert!( 5165 label_rect(&frame.paints, strings::EXTRUDE_PROMPT_SELECT_SKETCH).is_some(), 5166 "awaiting-sketch arm prompts via the property pane", 5167 ); 5168 } 5169 5170 fn profile_feature() -> ExtrudeFeature { 5171 let ExtrudeArming::Profile { feature, .. } = ExtrudeArming::profile(SketchId::default()) 5172 else { 5173 unreachable!("profile arming holds a feature"); 5174 }; 5175 feature 5176 } 5177 5178 #[test] 5179 fn extrude_edit_sets_depth_keeping_kind() { 5180 let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(3.0)) else { 5181 unreachable!("3 mm is positive"); 5182 }; 5183 let next = ExtrudeEdit::Depth(depth).apply(profile_feature()); 5184 assert!(matches!( 5185 next.end_condition, 5186 ExtrudeEndCondition::Blind { depth: d } if d == depth 5187 )); 5188 } 5189 5190 #[test] 5191 fn extrude_edit_changes_kind_keeping_depth() { 5192 let feature = profile_feature(); 5193 let before = end_condition_depth(&feature.end_condition); 5194 let next = ExtrudeEdit::EndCondition(EndConditionKind::MidPlane).apply(feature); 5195 assert!(matches!( 5196 next.end_condition, 5197 ExtrudeEndCondition::MidPlane { .. } 5198 )); 5199 assert_eq!(end_condition_depth(&next.end_condition), before); 5200 } 5201 5202 #[test] 5203 fn extrude_edit_toggles_merge_result() { 5204 let next = ExtrudeEdit::Merge(MergeResult::Separate).apply(profile_feature()); 5205 assert_eq!(next.merge_result, MergeResult::Separate); 5206 } 5207 5208 #[test] 5209 fn profiled_extrude_shows_property_controls() { 5210 let frame = render_with( 5211 Theme::light(), 5212 layout_size(1280.0, 800.0), 5213 &sample_document(), 5214 &Mode::Extrude(ExtrudeArming::profile(SketchId::default())), 5215 ); 5216 [ 5217 strings::PROPERTY_ROW_EXTRUDE_END, 5218 strings::PROPERTY_ROW_EXTRUDE_DEPTH, 5219 strings::PROPERTY_ROW_EXTRUDE_MERGE, 5220 ] 5221 .into_iter() 5222 .for_each(|key| { 5223 assert!( 5224 label_rect(&frame.paints, key).is_some(), 5225 "extrude property pane shows {key:?}", 5226 ); 5227 }); 5228 } 5229 5230 #[test] 5231 fn feature_tools_disabled_while_sketching() { 5232 let theme = Arc::new(Theme::light()); 5233 let table = HotkeyTable::new(); 5234 let mut focus = FocusManager::new(); 5235 let mut hits = HitFrame::new(); 5236 let prev = HitState::new(); 5237 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 5238 let mut shaper = bone_text::Shaper::new(); 5239 let mut a11y = AccessTreeBuilder::new(); 5240 let ctx = FrameCtx::new( 5241 theme, 5242 &mut input, 5243 &mut focus, 5244 &table, 5245 StringTable::empty(), 5246 &mut hits, 5247 &prev, 5248 &mut a11y, 5249 &mut shaper, 5250 ); 5251 let ribbon = ShellIds::standard().ribbon; 5252 let large = RibbonIconSize::Large.item_px(); 5253 5254 let sketching = feature_tool_items( 5255 &ctx, 5256 ribbon, 5257 &Mode::enter_sketch(SketchId::default()), 5258 large, 5259 ); 5260 assert!( 5261 sketching.iter().all(|item| item.disabled), 5262 "feature tools disable mid-sketch so a ribbon click can't drop the session", 5263 ); 5264 assert!( 5265 sketching 5266 .iter() 5267 .all(|item| item.tooltip == Some(strings::FEATURE_HINT_EXIT_SKETCH)), 5268 "disabled feature tools explain why through a tooltip", 5269 ); 5270 5271 let idle = feature_tool_items(&ctx, ribbon, &Mode::Idle, large); 5272 assert!( 5273 idle.iter().all(|item| !item.disabled), 5274 "feature tools stay live from idle", 5275 ); 5276 assert!( 5277 idle.iter().all(|item| item.tooltip.is_none()), 5278 "live feature tools carry no disabled-reason tooltip", 5279 ); 5280 } 5281 5282 #[test] 5283 fn entering_sketch_snaps_ribbon_to_sketch_tab() { 5284 let ids = ShellIds::standard(); 5285 let mut state = ShellState { 5286 ribbon_active_tab: Some(features_tab_id(ids.ribbon)), 5287 ..ShellState::default() 5288 }; 5289 sync_ribbon_tab_to_mode( 5290 &mut state, 5291 ids.ribbon, 5292 &Mode::enter_sketch(SketchId::default()), 5293 ); 5294 assert_eq!(state.ribbon_active_tab, Some(sketch_tab_id(ids.ribbon))); 5295 } 5296 5297 #[test] 5298 fn ribbon_tab_sync_only_fires_on_the_sketch_rising_edge() { 5299 let ids = ShellIds::standard(); 5300 let features = Some(features_tab_id(ids.ribbon)); 5301 5302 let mut already_sketching = ShellState { 5303 last_mode_was_sketch: true, 5304 ribbon_active_tab: features, 5305 ..ShellState::default() 5306 }; 5307 sync_ribbon_tab_to_mode( 5308 &mut already_sketching, 5309 ids.ribbon, 5310 &Mode::enter_sketch(SketchId::default()), 5311 ); 5312 assert_eq!( 5313 already_sketching.ribbon_active_tab, features, 5314 "no rising edge, the user's tab choice stands", 5315 ); 5316 5317 let mut idle = ShellState { 5318 ribbon_active_tab: features, 5319 ..ShellState::default() 5320 }; 5321 sync_ribbon_tab_to_mode(&mut idle, ids.ribbon, &Mode::Idle); 5322 assert_eq!(idle.ribbon_active_tab, features, "idle never forces a tab"); 5323 } 5324 5325 #[test] 5326 fn tool_index_omits_smart_dimension() { 5327 let ids = ShellIds::standard(); 5328 let index = build_tool_index(ids.ribbon); 5329 assert!(!index.contains_key(&ids.ribbon_smart_dimension)); 5330 } 5331 5332 #[test] 5333 fn plane_for_recognizes_each_principal_plane() { 5334 let ids = ShellIds::standard(); 5335 assert_eq!(ids.plane_for(ids.plane_xy), Some(Plane::Xy)); 5336 assert_eq!(ids.plane_for(ids.plane_yz), Some(Plane::Yz)); 5337 assert_eq!(ids.plane_for(ids.plane_zx), Some(Plane::Zx)); 5338 assert_eq!(ids.plane_for(ids.feature_tree), None); 5339 assert_eq!(ids.plane_for(ids.confirm_accept), None); 5340 } 5341 5342 #[test] 5343 fn idle_render_emits_no_state_machine_signals() { 5344 let frame = render_with( 5345 Theme::light(), 5346 layout_size(1280.0, 800.0), 5347 &sample_document(), 5348 &Mode::Idle, 5349 ); 5350 assert_eq!(frame.plane_picked, None); 5351 assert!(!frame.exit_sketch); 5352 assert!(frame.activated_tool.is_none()); 5353 } 5354 5355 #[test] 5356 fn idle_render_omits_confirm_corner() { 5357 let frame = render_with( 5358 Theme::light(), 5359 layout_size(1280.0, 800.0), 5360 &sample_document(), 5361 &Mode::Idle, 5362 ); 5363 assert!(!frame.paints.iter().any(is_confirm_glyph)); 5364 } 5365 5366 #[test] 5367 fn sketch_render_includes_confirm_corner() { 5368 let frame = render_with( 5369 Theme::light(), 5370 layout_size(1280.0, 800.0), 5371 &sample_document(), 5372 &Mode::enter_sketch(SketchId::default()), 5373 ); 5374 assert!(frame.paints.iter().any(is_confirm_glyph)); 5375 } 5376 5377 fn is_confirm_glyph(paint: &WidgetPaint) -> bool { 5378 matches!( 5379 paint, 5380 WidgetPaint::Icon { icon, .. } if matches!(icon, IconId::Check | IconId::Cross) 5381 ) 5382 } 5383 5384 #[test] 5385 fn relation_index_covers_every_kind() { 5386 let ids = ShellIds::standard(); 5387 let index = build_relation_index(ids.ribbon); 5388 RelationKind::ALL.iter().copied().for_each(|kind| { 5389 let id = relation_widget_id(ids.ribbon, kind); 5390 assert_eq!(index.get(&id).copied(), Some(kind)); 5391 }); 5392 } 5393 5394 #[test] 5395 fn relation_tool_item_disabled_without_sketch() { 5396 let ids = ShellIds::standard(); 5397 let item = relation_tool_item(ids.ribbon, RelationKind::Horizontal, None, &[], false); 5398 assert!(item.disabled); 5399 assert!(item.tooltip.is_none(), "no sketch, no per-relation reason"); 5400 } 5401 5402 #[test] 5403 fn relation_tool_item_disabled_when_sketch_disabled_flag_set() { 5404 let ids = ShellIds::standard(); 5405 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 5406 let item = relation_tool_item( 5407 ids.ribbon, 5408 RelationKind::Horizontal, 5409 Some(&sketch), 5410 &[], 5411 true, 5412 ); 5413 assert!(item.disabled); 5414 } 5415 5416 #[test] 5417 fn relation_tool_item_carries_reason_tooltip_when_eligibility_fails() { 5418 let ids = ShellIds::standard(); 5419 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 5420 let item = relation_tool_item( 5421 ids.ribbon, 5422 RelationKind::Horizontal, 5423 Some(&sketch), 5424 &[], 5425 false, 5426 ); 5427 assert!(item.disabled); 5428 assert_eq!(item.tooltip, Some(strings::REL_HINT_ONE_LINE)); 5429 } 5430 5431 #[test] 5432 fn relation_tool_item_enabled_when_eligibility_passes() { 5433 let ids = ShellIds::standard(); 5434 let (sketch, line) = sample_sketch_with_line(); 5435 let item = relation_tool_item( 5436 ids.ribbon, 5437 RelationKind::Horizontal, 5438 Some(&sketch), 5439 &[line], 5440 false, 5441 ); 5442 assert!(!item.disabled); 5443 assert!(item.tooltip.is_none()); 5444 } 5445 5446 #[test] 5447 fn resolve_activated_relation_returns_relation_for_eligible_selection() { 5448 let ids = ShellIds::standard(); 5449 let index = build_relation_index(ids.ribbon); 5450 let (sketch, line) = sample_sketch_with_line(); 5451 let id = relation_widget_id(ids.ribbon, RelationKind::Horizontal); 5452 let resolved = resolve_activated_relation(Some(id), &index, Some(&sketch), &[line]); 5453 assert_eq!(resolved, Some(SketchRelation::Horizontal(line))); 5454 } 5455 5456 #[test] 5457 fn resolve_activated_relation_drops_when_selection_invalid() { 5458 let ids = ShellIds::standard(); 5459 let index = build_relation_index(ids.ribbon); 5460 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 5461 let id = relation_widget_id(ids.ribbon, RelationKind::Horizontal); 5462 let resolved = resolve_activated_relation(Some(id), &index, Some(&sketch), &[]); 5463 assert_eq!(resolved, None); 5464 } 5465 5466 #[test] 5467 fn resolve_activated_relation_returns_relation_for_multi_selection() { 5468 let ids = ShellIds::standard(); 5469 let index = build_relation_index(ids.ribbon); 5470 let (sketch, l1, l2) = sample_sketch_with_two_lines(); 5471 let id = relation_widget_id(ids.ribbon, RelationKind::Parallel); 5472 let resolved = resolve_activated_relation(Some(id), &index, Some(&sketch), &[l1, l2]); 5473 assert_eq!(resolved, Some(SketchRelation::Parallel(l1, l2))); 5474 } 5475 5476 #[test] 5477 fn feature_tree_panel_rect_is_independent_of_pending_dim() { 5478 let document = sample_document(); 5479 let idle = render_with( 5480 Theme::light(), 5481 layout_size(1600.0, 900.0), 5482 &document, 5483 &Mode::Idle, 5484 ); 5485 let in_sketch = render_with( 5486 Theme::light(), 5487 layout_size(1600.0, 900.0), 5488 &document, 5489 &Mode::enter_sketch(SketchId::default()), 5490 ); 5491 let tree_rect_idle = panel_surface(&idle.paints, |x| x < 300.0); 5492 let tree_rect_sketch = panel_surface(&in_sketch.paints, |x| x < 300.0); 5493 assert_eq!( 5494 tree_rect_idle, tree_rect_sketch, 5495 "feature tree panel must not change between idle and sketch mode", 5496 ); 5497 } 5498 5499 fn panel_surface(paints: &[WidgetPaint], filter: impl Fn(f32) -> bool) -> Option<LayoutRect> { 5500 paints.iter().find_map(|p| match p { 5501 WidgetPaint::Surface { rect, .. } if filter(rect.min_x().value()) => Some(*rect), 5502 _ => None, 5503 }) 5504 } 5505 5506 #[test] 5507 fn smart_dimension_paints_at_typical_window_with_real_strings() { 5508 use crate::strings as app_strings; 5509 use bone_ui::strings::Locale; 5510 let table = app_strings::make_strings(Locale::EnUs); 5511 let mut shell = Shell::new(); 5512 let theme = Arc::new(Theme::light()); 5513 let hk = HotkeyTable::new(); 5514 let mut focus = FocusManager::new(); 5515 let mut hits = HitFrame::new(); 5516 let prev = HitState::new(); 5517 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 5518 let mut shaper = bone_text::Shaper::new(); 5519 let mut a11y = AccessTreeBuilder::new(); 5520 let mut ctx = FrameCtx::new( 5521 theme, 5522 &mut input, 5523 &mut focus, 5524 &hk, 5525 &table, 5526 &mut hits, 5527 &prev, 5528 &mut a11y, 5529 &mut shaper, 5530 ); 5531 let frame = shell.render( 5532 &mut ctx, 5533 &sample_document(), 5534 &Mode::enter_sketch(SketchId::default()), 5535 &Selection::default(), 5536 &Settings::default(), 5537 layout_size(1600.0, 900.0), 5538 None, 5539 None, 5540 None, 5541 &mut ViewUi::default(), 5542 &BTreeMap::new(), 5543 false, 5544 &[], 5545 ); 5546 let any_smart_dim_label = frame.paints.iter().any(|p| { 5547 matches!( 5548 p, 5549 WidgetPaint::Label { text: LabelText::Key(key), .. } 5550 | WidgetPaint::AlignedLabel { text: LabelText::Key(key), .. } 5551 if *key == strings::TOOL_SMART_DIMENSION 5552 ) 5553 }); 5554 assert!(any_smart_dim_label); 5555 } 5556 5557 #[test] 5558 fn smart_dimension_stays_reachable_in_a_narrow_ribbon() { 5559 let mut shell = Shell::new(); 5560 let dimensions_group = shell.ids.ribbon.child(WidgetKey::new("group.dimensions")); 5561 shell 5562 .state 5563 .ribbon_overflow_open 5564 .insert(dimensions_group, true); 5565 let frame = render_into_shell( 5566 &mut shell, 5567 Theme::light(), 5568 layout_size(800.0, 600.0), 5569 &sample_document(), 5570 &Mode::enter_sketch(SketchId::default()), 5571 &Selection::default(), 5572 ); 5573 let reachable = frame 5574 .paints 5575 .iter() 5576 .chain(frame.overlay_paints.iter()) 5577 .any(|p| { 5578 matches!( 5579 p, 5580 WidgetPaint::Label { text: LabelText::Key(key), .. } 5581 | WidgetPaint::AlignedLabel { text: LabelText::Key(key), .. } 5582 if *key == strings::TOOL_SMART_DIMENSION 5583 ) 5584 }); 5585 assert!( 5586 reachable, 5587 "Smart Dimension must stay reachable on a narrow ribbon, inline or via its group overflow", 5588 ); 5589 } 5590 5591 #[test] 5592 fn smart_dimension_item_disabled_without_sketch() { 5593 let ids = ShellIds::standard(); 5594 let item = smart_dimension_tool_item(ids.ribbon_smart_dimension, None, &[], false); 5595 assert!(item.disabled); 5596 } 5597 5598 #[test] 5599 fn smart_dimension_item_disabled_when_sketch_disabled_flag_set() { 5600 let ids = ShellIds::standard(); 5601 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 5602 let item = smart_dimension_tool_item(ids.ribbon_smart_dimension, Some(&sketch), &[], true); 5603 assert!(item.disabled); 5604 } 5605 5606 #[test] 5607 fn smart_dimension_item_carries_reason_tooltip_when_no_selection() { 5608 let ids = ShellIds::standard(); 5609 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 5610 let item = smart_dimension_tool_item(ids.ribbon_smart_dimension, Some(&sketch), &[], false); 5611 assert!(item.disabled); 5612 assert_eq!(item.tooltip, Some(strings::DIM_HINT_GENERIC)); 5613 } 5614 5615 #[test] 5616 fn smart_dimension_item_enabled_for_eligible_line() { 5617 let ids = ShellIds::standard(); 5618 let (sketch, line) = sample_sketch_with_line(); 5619 let item = 5620 smart_dimension_tool_item(ids.ribbon_smart_dimension, Some(&sketch), &[line], false); 5621 assert!(!item.disabled); 5622 assert!(item.tooltip.is_none()); 5623 } 5624 5625 #[test] 5626 fn resolve_activated_dimension_returns_request_for_eligible_selection() { 5627 let ids = ShellIds::standard(); 5628 let (sketch, line) = sample_sketch_with_line(); 5629 let id = ids.ribbon_smart_dimension; 5630 let resolved = resolve_activated_dimension(Some(id), id, Some(&sketch), &[line]); 5631 let Some(req) = resolved else { 5632 panic!("expected eligible request"); 5633 }; 5634 assert!(matches!( 5635 req.proto, 5636 bone_document::SketchDimension::Linear { .. } 5637 )); 5638 } 5639 5640 #[test] 5641 fn resolve_activated_dimension_drops_when_widget_id_mismatches() { 5642 let ids = ShellIds::standard(); 5643 let (sketch, line) = sample_sketch_with_line(); 5644 let other = relation_widget_id(ids.ribbon, RelationKind::Horizontal); 5645 let resolved = resolve_activated_dimension( 5646 Some(other), 5647 ids.ribbon_smart_dimension, 5648 Some(&sketch), 5649 &[line], 5650 ); 5651 assert_eq!(resolved, None); 5652 } 5653 5654 #[test] 5655 fn resolve_activated_dimension_drops_when_selection_is_invalid() { 5656 let ids = ShellIds::standard(); 5657 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 5658 let resolved = resolve_activated_dimension( 5659 Some(ids.ribbon_smart_dimension), 5660 ids.ribbon_smart_dimension, 5661 Some(&sketch), 5662 &[], 5663 ); 5664 assert_eq!(resolved, None); 5665 } 5666 5667 #[test] 5668 fn partition_overlay_extracts_tooltips_into_overlay_layer() { 5669 let theme = Theme::light(); 5670 let rect = LayoutRect::new( 5671 LayoutPos::new(LayoutPx::new(10.0), LayoutPx::new(20.0)), 5672 LayoutSize::new(LayoutPx::new(80.0), LayoutPx::new(18.0)), 5673 ); 5674 let inputs = vec![ 5675 WidgetPaint::Surface { 5676 rect, 5677 fill: theme.colors.surface(theme.elevation.level1.surface), 5678 border: None, 5679 radius: theme.radius.none, 5680 elevation: None, 5681 }, 5682 WidgetPaint::Tooltip { 5683 rect, 5684 text: LabelText::Owned("hint".to_owned()), 5685 anchor: WidgetId::ROOT, 5686 elevation: theme.elevation.level2, 5687 }, 5688 ]; 5689 let (main, overlay) = partition_overlay(inputs, &theme); 5690 assert_eq!(main.len(), 1, "non-tooltip stays in main"); 5691 assert!(matches!(main[0], WidgetPaint::Surface { .. })); 5692 assert_eq!(overlay.len(), 2, "tooltip expands to surface + label"); 5693 assert!(matches!(overlay[0], WidgetPaint::Surface { .. })); 5694 assert!(matches!(overlay[1], WidgetPaint::Label { .. })); 5695 } 5696 5697 #[test] 5698 fn partition_overlay_floats_popup_paints_above_main() { 5699 let theme = Theme::light(); 5700 let rect = LayoutRect::new( 5701 LayoutPos::new(LayoutPx::new(4.0), LayoutPx::new(6.0)), 5702 LayoutSize::new(LayoutPx::new(40.0), LayoutPx::new(24.0)), 5703 ); 5704 let surface = |fill| WidgetPaint::Surface { 5705 rect, 5706 fill, 5707 border: None, 5708 radius: theme.radius.none, 5709 elevation: None, 5710 }; 5711 let inputs = vec![ 5712 surface(theme.colors.surface(bone_ui::theme::SurfaceLevel::L0)), 5713 WidgetPaint::Popup { 5714 paints: vec![ 5715 surface(theme.colors.surface(bone_ui::theme::SurfaceLevel::L1)), 5716 WidgetPaint::Label { 5717 rect, 5718 text: LabelText::Owned("Blind".to_owned()), 5719 color: theme.colors.text_primary(), 5720 role: theme.typography.body, 5721 }, 5722 ], 5723 }, 5724 ]; 5725 let (main, overlay) = partition_overlay(inputs, &theme); 5726 assert_eq!( 5727 main.len(), 5728 1, 5729 "the row surface beneath the popup stays in main" 5730 ); 5731 assert_eq!( 5732 overlay.len(), 5733 2, 5734 "the popup's surface + label float into the overlay so they draw on top", 5735 ); 5736 assert!(matches!(overlay[0], WidgetPaint::Surface { .. })); 5737 assert!(matches!(overlay[1], WidgetPaint::Label { .. })); 5738 } 5739 5740 fn sample_sketch_with_two_lines() -> (bone_document::Sketch, SketchEntityId, SketchEntityId) { 5741 use bone_types::Point2; 5742 let s = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 5743 let (s, p0) = crate::tools::add_point(s, Point2::from_mm(0.0, 0.0)); 5744 let (s, p1) = crate::tools::add_point(s, Point2::from_mm(1.0, 0.0)); 5745 let (s, p2) = crate::tools::add_point(s, Point2::from_mm(0.0, 1.0)); 5746 let (s, p3) = crate::tools::add_point(s, Point2::from_mm(1.0, 1.0)); 5747 let (s, l1) = crate::tools::add_line(s, p0, p1, false); 5748 let (s, l2) = crate::tools::add_line(s, p2, p3, false); 5749 (s, l1, l2) 5750 } 5751 5752 fn sample_sketch_with_line() -> (bone_document::Sketch, SketchEntityId) { 5753 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 5754 let (sketch, a) = crate::tools::add_point(sketch, bone_types::Point2::from_mm(0.0, 0.0)); 5755 let (sketch, b) = crate::tools::add_point(sketch, bone_types::Point2::from_mm(5.0, 0.0)); 5756 let (sketch, line) = crate::tools::add_line(sketch, a, b, false); 5757 (sketch, line) 5758 } 5759 5760 fn sketch_with_dim(kind: DimensionKind) -> (bone_document::Sketch, SketchDimensionId) { 5761 use bone_document::{EditOutcome, SketchEdit}; 5762 use bone_types::Point2; 5763 use uom::si::length::millimeter; 5764 let s = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 5765 let (s, a) = crate::tools::add_point(s, Point2::from_mm(0.0, 0.0)); 5766 let (s, b) = crate::tools::add_point(s, Point2::from_mm(5.0, 0.0)); 5767 let dim = SketchDimension::Linear { 5768 a, 5769 b, 5770 value: Length::new::<millimeter>(5.0), 5771 kind, 5772 }; 5773 let Ok((s, EditOutcome::Dimension(id))) = s.apply(SketchEdit::AddDimension(dim)) else { 5774 panic!("expected dimension outcome"); 5775 }; 5776 (s, id) 5777 } 5778 5779 fn sketch_with_relation() -> ( 5780 bone_document::Sketch, 5781 bone_types::SketchRelationId, 5782 SketchEntityId, 5783 ) { 5784 use bone_document::{EditOutcome, SketchEdit}; 5785 use bone_types::Point2; 5786 let s = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 5787 let (s, a) = crate::tools::add_point(s, Point2::from_mm(0.0, 0.0)); 5788 let (s, b) = crate::tools::add_point(s, Point2::from_mm(5.0, 0.0)); 5789 let (s, line) = crate::tools::add_line(s, a, b, false); 5790 let Ok((s, EditOutcome::Relation(id))) = 5791 s.apply(SketchEdit::AddRelation(SketchRelation::Horizontal(line))) 5792 else { 5793 panic!("expected relation outcome"); 5794 }; 5795 (s, id, line) 5796 } 5797 5798 fn document_with_sketch(sketch: bone_document::Sketch) -> (Document, SketchId) { 5799 let mut doc = sample_document(); 5800 let id = SketchId::default(); 5801 doc.insert_sketch(id, "Sketch1".to_owned(), sketch); 5802 (doc, id) 5803 } 5804 5805 #[test] 5806 fn property_pane_for_driving_dim_populates_editor_with_value() { 5807 let (sketch, dim_id) = sketch_with_dim(DimensionKind::Driving); 5808 let (doc, sketch_id) = document_with_sketch(sketch); 5809 let mut shell = Shell::new(); 5810 let frame = render_into_shell( 5811 &mut shell, 5812 Theme::light(), 5813 layout_size(1280.0, 800.0), 5814 &doc, 5815 &Mode::enter_sketch(sketch_id), 5816 &Selection::Dimension(dim_id), 5817 ); 5818 let Some(DimPropertyEditor::Length { id, editor, .. }) = &shell.state.dim_property else { 5819 panic!("expected length editor populated"); 5820 }; 5821 assert_eq!(*id, dim_id); 5822 assert!( 5823 (editor.value.get::<millimeter>() - 5.0).abs() < 1e-9, 5824 "editor value: {}", 5825 editor.value.get::<millimeter>() 5826 ); 5827 assert!(frame.dimension_edit.is_none(), "no input commit yet"); 5828 } 5829 5830 #[test] 5831 fn property_pane_keeps_driven_editor_but_marks_read_only() { 5832 let (sketch, dim_id) = sketch_with_dim(DimensionKind::Driven); 5833 let (doc, sketch_id) = document_with_sketch(sketch); 5834 let mut shell = Shell::new(); 5835 let _ = render_into_shell( 5836 &mut shell, 5837 Theme::light(), 5838 layout_size(1280.0, 800.0), 5839 &doc, 5840 &Mode::enter_sketch(sketch_id), 5841 &Selection::Dimension(dim_id), 5842 ); 5843 let Some(DimPropertyEditor::Length { id, .. }) = &shell.state.dim_property else { 5844 panic!("expected length editor populated"); 5845 }; 5846 assert_eq!(*id, dim_id); 5847 } 5848 5849 #[test] 5850 fn property_pane_drops_dim_editor_when_selection_changes_off_dim() { 5851 let (sketch, dim_id) = sketch_with_dim(DimensionKind::Driving); 5852 let (doc, sketch_id) = document_with_sketch(sketch); 5853 let mut shell = Shell::new(); 5854 let _ = render_into_shell( 5855 &mut shell, 5856 Theme::light(), 5857 layout_size(1280.0, 800.0), 5858 &doc, 5859 &Mode::enter_sketch(sketch_id), 5860 &Selection::Dimension(dim_id), 5861 ); 5862 assert!(shell.state.dim_property.is_some()); 5863 let _ = render_into_shell( 5864 &mut shell, 5865 Theme::light(), 5866 layout_size(1280.0, 800.0), 5867 &doc, 5868 &Mode::enter_sketch(sketch_id), 5869 &Selection::default(), 5870 ); 5871 assert!(shell.state.dim_property.is_none()); 5872 } 5873 5874 #[test] 5875 fn property_pane_swaps_editor_when_dim_id_changes() { 5876 use bone_document::{EditOutcome, SketchEdit}; 5877 use bone_types::Point2; 5878 use uom::si::length::millimeter; 5879 let s = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 5880 let (s, a) = crate::tools::add_point(s, Point2::from_mm(0.0, 0.0)); 5881 let (s, b) = crate::tools::add_point(s, Point2::from_mm(5.0, 0.0)); 5882 let (s, c) = crate::tools::add_point(s, Point2::from_mm(0.0, 5.0)); 5883 let Ok((s, EditOutcome::Dimension(dim_a))) = 5884 s.apply(SketchEdit::AddDimension(SketchDimension::Linear { 5885 a, 5886 b, 5887 value: Length::new::<millimeter>(5.0), 5888 kind: DimensionKind::Driving, 5889 })) 5890 else { 5891 panic!("expected first Dimension outcome"); 5892 }; 5893 let Ok((s, EditOutcome::Dimension(dim_b))) = 5894 s.apply(SketchEdit::AddDimension(SketchDimension::Linear { 5895 a, 5896 b: c, 5897 value: Length::new::<millimeter>(5.0), 5898 kind: DimensionKind::Driving, 5899 })) 5900 else { 5901 panic!("expected second Dimension outcome"); 5902 }; 5903 assert_ne!(dim_a, dim_b); 5904 let (doc, sketch_id) = document_with_sketch(s); 5905 let mut shell = Shell::new(); 5906 let _ = render_into_shell( 5907 &mut shell, 5908 Theme::light(), 5909 layout_size(1280.0, 800.0), 5910 &doc, 5911 &Mode::enter_sketch(sketch_id), 5912 &Selection::Dimension(dim_a), 5913 ); 5914 let _ = render_into_shell( 5915 &mut shell, 5916 Theme::light(), 5917 layout_size(1280.0, 800.0), 5918 &doc, 5919 &Mode::enter_sketch(sketch_id), 5920 &Selection::Dimension(dim_b), 5921 ); 5922 let Some(DimPropertyEditor::Length { id, .. }) = &shell.state.dim_property else { 5923 panic!("expected length editor for second dim"); 5924 }; 5925 assert_eq!(*id, dim_b); 5926 } 5927 5928 #[test] 5929 fn property_pane_renders_relation_kind_label() { 5930 let (sketch, _rel_id, _line) = sketch_with_relation(); 5931 let (doc, sketch_id) = document_with_sketch(sketch); 5932 let Some(sketch_ref) = doc.sketch(sketch_id) else { 5933 panic!("expected inserted sketch"); 5934 }; 5935 let Some(rel_id) = sketch_ref.relation_order().first().copied() else { 5936 panic!("expected relation present"); 5937 }; 5938 let mut shell = Shell::new(); 5939 let frame = render_into_shell( 5940 &mut shell, 5941 Theme::light(), 5942 layout_size(1280.0, 800.0), 5943 &doc, 5944 &Mode::enter_sketch(sketch_id), 5945 &Selection::Relation(rel_id), 5946 ); 5947 let any_horizontal_label = frame.paints.iter().any(|p| match p { 5948 WidgetPaint::Label { 5949 text: LabelText::Owned(text), 5950 .. 5951 } => text == StringTable::empty().resolve(strings::TOOL_HORIZONTAL), 5952 _ => false, 5953 }); 5954 assert!(any_horizontal_label, "relation kind label should appear"); 5955 assert!( 5956 shell.state.dim_property.is_none(), 5957 "relation does not own dim editor" 5958 ); 5959 } 5960 5961 fn shell_drive( 5962 shell: &mut Shell, 5963 document: &Document, 5964 mode: &Mode, 5965 selection: &Selection, 5966 focus: &mut FocusManager, 5967 prev: &mut HitState, 5968 snap: &mut InputSnapshot, 5969 ) -> (ShellFrame, HitFrame) { 5970 let theme = Arc::new(Theme::light()); 5971 let table = HotkeyTable::new(); 5972 let mut hits = HitFrame::new(); 5973 let mut shaper = bone_text::Shaper::new(); 5974 let mut a11y = AccessTreeBuilder::new(); 5975 let frame = { 5976 let mut ctx = FrameCtx::new( 5977 theme, 5978 snap, 5979 focus, 5980 &table, 5981 StringTable::empty(), 5982 &mut hits, 5983 prev, 5984 &mut a11y, 5985 &mut shaper, 5986 ); 5987 shell.render( 5988 &mut ctx, 5989 document, 5990 mode, 5991 selection, 5992 &Settings::default(), 5993 layout_size(1280.0, 800.0), 5994 None, 5995 None, 5996 None, 5997 &mut ViewUi::default(), 5998 &BTreeMap::new(), 5999 false, 6000 &[], 6001 ) 6002 }; 6003 *prev = bone_ui::hit_test::resolve(prev, &hits, snap, focus.focused()); 6004 (frame, hits) 6005 } 6006 6007 fn sketch_widget(ids: &ShellIds, sketch_id: SketchId) -> WidgetId { 6008 ids.feature_part 6009 .child_indexed(WidgetKey::new("sketch"), sketch_id.as_u64()) 6010 } 6011 6012 fn extrude_widget(ids: &ShellIds, extrude_id: ExtrudeId) -> WidgetId { 6013 ids.feature_part 6014 .child_indexed(WidgetKey::new("extrude"), extrude_id.as_u64()) 6015 } 6016 6017 fn document_with_extrude() -> (Document, ExtrudeId) { 6018 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 6019 let (mut document, sketch_id) = document_with_sketch(sketch); 6020 let extrude_id = 6021 document.commit_extrude(crate::sketch_mode::default_extrude_feature(sketch_id)); 6022 (document, extrude_id) 6023 } 6024 6025 #[test] 6026 fn f2_with_focused_sketch_row_starts_rename_in_full_shell() { 6027 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 6028 let (document, sketch_id) = document_with_sketch(sketch); 6029 let mut shell = Shell::new(); 6030 let widget = sketch_widget(&shell.ids, sketch_id); 6031 let mut focus = FocusManager::new(); 6032 let mut prev = HitState::new(); 6033 6034 let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 6035 let (_, _) = shell_drive( 6036 &mut shell, 6037 &document, 6038 &Mode::Idle, 6039 &Selection::default(), 6040 &mut focus, 6041 &mut prev, 6042 &mut warm, 6043 ); 6044 focus.request_focus(widget); 6045 let mut warm2 = InputSnapshot::idle(FrameInstant::ZERO); 6046 let (_, _) = shell_drive( 6047 &mut shell, 6048 &document, 6049 &Mode::Idle, 6050 &Selection::default(), 6051 &mut focus, 6052 &mut prev, 6053 &mut warm2, 6054 ); 6055 assert_eq!( 6056 focus.focused(), 6057 Some(widget), 6058 "sketch row must be focusable+focused after second render", 6059 ); 6060 6061 let mut f2 = InputSnapshot::idle(FrameInstant::ZERO); 6062 f2.keys_pressed.push(bone_ui::input::KeyEvent::new( 6063 bone_ui::input::KeyCode::Named(bone_ui::input::NamedKey::F2), 6064 bone_ui::input::ModifierMask::NONE, 6065 )); 6066 let (_, _) = shell_drive( 6067 &mut shell, 6068 &document, 6069 &Mode::Idle, 6070 &Selection::default(), 6071 &mut focus, 6072 &mut prev, 6073 &mut f2, 6074 ); 6075 assert_eq!( 6076 shell.state.feature_tree.renaming, 6077 Some(widget), 6078 "F2 with sketch row focused must enter rename", 6079 ); 6080 } 6081 6082 fn drive_with_snap( 6083 shell: &mut Shell, 6084 document: &Document, 6085 mode: &Mode, 6086 selection: &Selection, 6087 focus: &mut FocusManager, 6088 prev: &mut HitState, 6089 snap: InputSnapshot, 6090 ) -> (ShellFrame, HitFrame) { 6091 let mut snap = snap; 6092 shell_drive(shell, document, mode, selection, focus, prev, &mut snap) 6093 } 6094 6095 #[test] 6096 fn status_bar_uses_current_sketch_label_when_in_sketch_mode() { 6097 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 6098 let (mut document, sketch_id) = document_with_sketch(sketch); 6099 let Ok(()) = document.rename_sketch(sketch_id, "Profile") else { 6100 panic!("rename must accept non-empty label"); 6101 }; 6102 let label = super::mode_status_label( 6103 StringTable::empty(), 6104 &Mode::enter_sketch(sketch_id), 6105 &document, 6106 ); 6107 let LabelText::Owned(text) = label else { 6108 panic!("sketch-mode status label is owned text"); 6109 }; 6110 assert!( 6111 text.contains("Profile"), 6112 "status text must include current sketch label, got {text:?}", 6113 ); 6114 assert!( 6115 !text.contains("Sketch1"), 6116 "status text must not show the prior label after rename, got {text:?}", 6117 ); 6118 } 6119 6120 #[test] 6121 fn sketch_row_hit_rect_lies_within_left_pane_bounds() { 6122 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 6123 let (document, sketch_id) = document_with_sketch(sketch); 6124 let mut shell = Shell::new(); 6125 let widget = sketch_widget(&shell.ids, sketch_id); 6126 let mut focus = FocusManager::new(); 6127 let mut prev = HitState::new(); 6128 let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 6129 let (frame, hits) = shell_drive( 6130 &mut shell, 6131 &document, 6132 &Mode::Idle, 6133 &Selection::default(), 6134 &mut focus, 6135 &mut prev, 6136 &mut warm, 6137 ); 6138 let Some(row_rect) = hits 6139 .items() 6140 .iter() 6141 .find(|item| item.id == widget) 6142 .map(|item| item.rect) 6143 else { 6144 panic!("sketch row must register a hit item"); 6145 }; 6146 let viewport = frame.viewport_rect; 6147 let row_right = row_rect.origin.x.value() + row_rect.size.width.value(); 6148 let row_bottom = row_rect.origin.y.value() + row_rect.size.height.value(); 6149 assert!( 6150 row_right <= viewport.origin.x.value(), 6151 "sketch row must sit left of the viewport, row_right={row_right} viewport_x={}", 6152 viewport.origin.x.value(), 6153 ); 6154 assert!( 6155 row_rect.origin.y.value() >= 0.0, 6156 "sketch row origin y >= 0, got {}", 6157 row_rect.origin.y.value(), 6158 ); 6159 assert!( 6160 row_bottom <= 800.0, 6161 "sketch row must fit within 800px tall window, row_bottom={row_bottom}", 6162 ); 6163 } 6164 6165 #[test] 6166 fn property_header_accept_emits_confirm_via_full_shell() { 6167 use bone_ui::input::{PointerButton, PointerButtonMask, PointerSample}; 6168 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 6169 let (document, sketch_id) = document_with_sketch(sketch); 6170 let mode = Mode::Extrude(ExtrudeArming::profile(sketch_id)); 6171 let mut shell = Shell::new(); 6172 let accept_id = shell 6173 .ids 6174 .property_pane 6175 .child(WidgetKey::new("header")) 6176 .child(WidgetKey::new("accept")); 6177 let mut focus = FocusManager::new(); 6178 let mut prev = HitState::new(); 6179 let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 6180 let (_, hits) = shell_drive( 6181 &mut shell, 6182 &document, 6183 &mode, 6184 &Selection::default(), 6185 &mut focus, 6186 &mut prev, 6187 &mut warm, 6188 ); 6189 let Some(rect) = hits 6190 .items() 6191 .iter() 6192 .find(|item| item.id == accept_id) 6193 .map(|item| item.rect) 6194 else { 6195 panic!("property header accept must register a hit item in the extrude pane"); 6196 }; 6197 let center = LayoutPos::new( 6198 LayoutPx::new(rect.origin.x.value() + rect.size.width.value() / 2.0), 6199 LayoutPx::new(rect.origin.y.value() + rect.size.height.value() / 2.0), 6200 ); 6201 let mut hover = InputSnapshot::idle(FrameInstant::ZERO); 6202 hover.pointer = Some(PointerSample::new(center)); 6203 let _ = drive_with_snap( 6204 &mut shell, 6205 &document, 6206 &mode, 6207 &Selection::default(), 6208 &mut focus, 6209 &mut prev, 6210 hover, 6211 ); 6212 let mut press = InputSnapshot::idle(FrameInstant::ZERO); 6213 press.pointer = Some(PointerSample::new(center)); 6214 press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 6215 let _ = drive_with_snap( 6216 &mut shell, 6217 &document, 6218 &mode, 6219 &Selection::default(), 6220 &mut focus, 6221 &mut prev, 6222 press, 6223 ); 6224 let mut release = InputSnapshot::idle(FrameInstant::ZERO); 6225 release.pointer = Some(PointerSample::new(center)); 6226 release.buttons_released = PointerButtonMask::just(PointerButton::Primary); 6227 let _ = drive_with_snap( 6228 &mut shell, 6229 &document, 6230 &mode, 6231 &Selection::default(), 6232 &mut focus, 6233 &mut prev, 6234 release, 6235 ); 6236 let mut settle = InputSnapshot::idle(FrameInstant::ZERO); 6237 settle.pointer = Some(PointerSample::new(center)); 6238 let (frame, _) = drive_with_snap( 6239 &mut shell, 6240 &document, 6241 &mode, 6242 &Selection::default(), 6243 &mut focus, 6244 &mut prev, 6245 settle, 6246 ); 6247 assert_eq!( 6248 frame.confirm_action, 6249 Some(ConfirmAction::Accept), 6250 "clicking the property header check must accept like the confirm corner", 6251 ); 6252 } 6253 6254 #[test] 6255 fn click_on_sketch_row_then_f2_enters_rename_via_full_shell() { 6256 use bone_ui::input::{ 6257 KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton, PointerButtonMask, 6258 PointerSample, 6259 }; 6260 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 6261 let (document, sketch_id) = document_with_sketch(sketch); 6262 let mut shell = Shell::new(); 6263 let widget = sketch_widget(&shell.ids, sketch_id); 6264 let mut focus = FocusManager::new(); 6265 let mut prev = HitState::new(); 6266 6267 let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 6268 let (_, hits) = shell_drive( 6269 &mut shell, 6270 &document, 6271 &Mode::Idle, 6272 &Selection::default(), 6273 &mut focus, 6274 &mut prev, 6275 &mut warm, 6276 ); 6277 let Some(row_rect) = hits 6278 .items() 6279 .iter() 6280 .find(|item| item.id == widget) 6281 .map(|item| item.rect) 6282 else { 6283 panic!("sketch row must register a hit item in the feature tree"); 6284 }; 6285 let center = LayoutPos::new( 6286 LayoutPx::new(row_rect.origin.x.value() + row_rect.size.width.value() / 2.0), 6287 LayoutPx::new(row_rect.origin.y.value() + row_rect.size.height.value() / 2.0), 6288 ); 6289 6290 let mut press = InputSnapshot::idle(FrameInstant::ZERO); 6291 press.pointer = Some(PointerSample::new(center)); 6292 press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 6293 let _ = drive_with_snap( 6294 &mut shell, 6295 &document, 6296 &Mode::Idle, 6297 &Selection::default(), 6298 &mut focus, 6299 &mut prev, 6300 press, 6301 ); 6302 6303 let mut release = InputSnapshot::idle(FrameInstant::ZERO); 6304 release.pointer = Some(PointerSample::new(center)); 6305 release.buttons_released = PointerButtonMask::just(PointerButton::Primary); 6306 let _ = drive_with_snap( 6307 &mut shell, 6308 &document, 6309 &Mode::Idle, 6310 &Selection::default(), 6311 &mut focus, 6312 &mut prev, 6313 release, 6314 ); 6315 6316 let mut idle = InputSnapshot::idle(FrameInstant::ZERO); 6317 idle.pointer = Some(PointerSample::new(center)); 6318 let _ = drive_with_snap( 6319 &mut shell, 6320 &document, 6321 &Mode::Idle, 6322 &Selection::default(), 6323 &mut focus, 6324 &mut prev, 6325 idle, 6326 ); 6327 6328 assert_eq!( 6329 focus.focused(), 6330 Some(widget), 6331 "click on sketch row must focus it before F2 is pressed", 6332 ); 6333 6334 let mut f2 = InputSnapshot::idle(FrameInstant::ZERO); 6335 f2.pointer = Some(PointerSample::new(center)); 6336 f2.keys_pressed.push(KeyEvent::new( 6337 KeyCode::Named(NamedKey::F2), 6338 ModifierMask::NONE, 6339 )); 6340 let _ = drive_with_snap( 6341 &mut shell, 6342 &document, 6343 &Mode::Idle, 6344 &Selection::default(), 6345 &mut focus, 6346 &mut prev, 6347 f2, 6348 ); 6349 assert_eq!( 6350 shell.state.feature_tree.renaming, 6351 Some(widget), 6352 "click-then-F2 must enter rename mode on sketch row", 6353 ); 6354 } 6355 6356 #[test] 6357 fn double_click_sketch_row_emits_sketch_activated() { 6358 use bone_ui::input::{PointerButton, PointerButtonMask, PointerSample}; 6359 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 6360 let (document, sketch_id) = document_with_sketch(sketch); 6361 let mut shell = Shell::new(); 6362 let widget = sketch_widget(&shell.ids, sketch_id); 6363 let mut focus = FocusManager::new(); 6364 let mut prev = HitState::new(); 6365 6366 let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 6367 let (_, hits) = shell_drive( 6368 &mut shell, 6369 &document, 6370 &Mode::Idle, 6371 &Selection::default(), 6372 &mut focus, 6373 &mut prev, 6374 &mut warm, 6375 ); 6376 let Some(row_rect) = hits 6377 .items() 6378 .iter() 6379 .find(|item| item.id == widget) 6380 .map(|item| item.rect) 6381 else { 6382 panic!("sketch row must register a hit item"); 6383 }; 6384 let center = LayoutPos::new( 6385 LayoutPx::new(row_rect.origin.x.value() + row_rect.size.width.value() / 2.0), 6386 LayoutPx::new(row_rect.origin.y.value() + row_rect.size.height.value() / 2.0), 6387 ); 6388 6389 let click = |shell: &mut Shell, focus: &mut FocusManager, prev: &mut HitState| { 6390 let mut press = InputSnapshot::idle(FrameInstant::ZERO); 6391 press.pointer = Some(PointerSample::new(center)); 6392 press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 6393 let _ = drive_with_snap( 6394 shell, 6395 &document, 6396 &Mode::Idle, 6397 &Selection::default(), 6398 focus, 6399 prev, 6400 press, 6401 ); 6402 let mut release = InputSnapshot::idle(FrameInstant::ZERO); 6403 release.pointer = Some(PointerSample::new(center)); 6404 release.buttons_released = PointerButtonMask::just(PointerButton::Primary); 6405 drive_with_snap( 6406 shell, 6407 &document, 6408 &Mode::Idle, 6409 &Selection::default(), 6410 focus, 6411 prev, 6412 release, 6413 ) 6414 }; 6415 6416 let _ = click(&mut shell, &mut focus, &mut prev); 6417 let _ = click(&mut shell, &mut focus, &mut prev); 6418 let mut idle = InputSnapshot::idle(FrameInstant::ZERO); 6419 idle.pointer = Some(PointerSample::new(center)); 6420 let (frame, _) = drive_with_snap( 6421 &mut shell, 6422 &document, 6423 &Mode::Idle, 6424 &Selection::default(), 6425 &mut focus, 6426 &mut prev, 6427 idle, 6428 ); 6429 assert_eq!( 6430 frame.sketch_activated, 6431 Some(sketch_id), 6432 "double-click on sketch row must emit sketch_activated for that sketch", 6433 ); 6434 } 6435 6436 #[test] 6437 fn double_click_extrude_row_emits_extrude_activated() { 6438 use bone_ui::input::{PointerButton, PointerButtonMask, PointerSample}; 6439 let (document, extrude_id) = document_with_extrude(); 6440 let mut shell = Shell::new(); 6441 let widget = extrude_widget(&shell.ids, extrude_id); 6442 let mut focus = FocusManager::new(); 6443 let mut prev = HitState::new(); 6444 6445 let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 6446 let (_, hits) = shell_drive( 6447 &mut shell, 6448 &document, 6449 &Mode::Idle, 6450 &Selection::default(), 6451 &mut focus, 6452 &mut prev, 6453 &mut warm, 6454 ); 6455 let Some(row_rect) = hits 6456 .items() 6457 .iter() 6458 .find(|item| item.id == widget) 6459 .map(|item| item.rect) 6460 else { 6461 panic!("extrude row must register a hit item"); 6462 }; 6463 let center = LayoutPos::new( 6464 LayoutPx::new(row_rect.origin.x.value() + row_rect.size.width.value() / 2.0), 6465 LayoutPx::new(row_rect.origin.y.value() + row_rect.size.height.value() / 2.0), 6466 ); 6467 let click = |shell: &mut Shell, focus: &mut FocusManager, prev: &mut HitState| { 6468 let mut press = InputSnapshot::idle(FrameInstant::ZERO); 6469 press.pointer = Some(PointerSample::new(center)); 6470 press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 6471 let _ = drive_with_snap( 6472 shell, 6473 &document, 6474 &Mode::Idle, 6475 &Selection::default(), 6476 focus, 6477 prev, 6478 press, 6479 ); 6480 let mut release = InputSnapshot::idle(FrameInstant::ZERO); 6481 release.pointer = Some(PointerSample::new(center)); 6482 release.buttons_released = PointerButtonMask::just(PointerButton::Primary); 6483 drive_with_snap( 6484 shell, 6485 &document, 6486 &Mode::Idle, 6487 &Selection::default(), 6488 focus, 6489 prev, 6490 release, 6491 ) 6492 }; 6493 let _ = click(&mut shell, &mut focus, &mut prev); 6494 let _ = click(&mut shell, &mut focus, &mut prev); 6495 let mut idle = InputSnapshot::idle(FrameInstant::ZERO); 6496 idle.pointer = Some(PointerSample::new(center)); 6497 let (frame, _) = drive_with_snap( 6498 &mut shell, 6499 &document, 6500 &Mode::Idle, 6501 &Selection::default(), 6502 &mut focus, 6503 &mut prev, 6504 idle, 6505 ); 6506 assert_eq!( 6507 frame.extrude_activated, 6508 Some(extrude_id), 6509 "double-click on extrude row must emit extrude_activated for that extrude", 6510 ); 6511 } 6512 6513 #[test] 6514 fn f2_with_focused_extrude_row_starts_rename_in_full_shell() { 6515 let (document, extrude_id) = document_with_extrude(); 6516 let mut shell = Shell::new(); 6517 let widget = extrude_widget(&shell.ids, extrude_id); 6518 let mut focus = FocusManager::new(); 6519 let mut prev = HitState::new(); 6520 6521 let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 6522 let (_, _) = shell_drive( 6523 &mut shell, 6524 &document, 6525 &Mode::Idle, 6526 &Selection::default(), 6527 &mut focus, 6528 &mut prev, 6529 &mut warm, 6530 ); 6531 focus.request_focus(widget); 6532 let mut warm2 = InputSnapshot::idle(FrameInstant::ZERO); 6533 let (_, _) = shell_drive( 6534 &mut shell, 6535 &document, 6536 &Mode::Idle, 6537 &Selection::default(), 6538 &mut focus, 6539 &mut prev, 6540 &mut warm2, 6541 ); 6542 6543 let mut f2 = InputSnapshot::idle(FrameInstant::ZERO); 6544 f2.keys_pressed.push(bone_ui::input::KeyEvent::new( 6545 bone_ui::input::KeyCode::Named(bone_ui::input::NamedKey::F2), 6546 bone_ui::input::ModifierMask::NONE, 6547 )); 6548 let (_, _) = shell_drive( 6549 &mut shell, 6550 &document, 6551 &Mode::Idle, 6552 &Selection::default(), 6553 &mut focus, 6554 &mut prev, 6555 &mut f2, 6556 ); 6557 assert_eq!( 6558 shell.state.feature_tree.renaming, 6559 Some(widget), 6560 "F2 with extrude row focused must enter rename", 6561 ); 6562 } 6563 6564 fn render_with_locale(size: LayoutSize, locale: Locale) -> ShellFrame { 6565 let strings = crate::strings::make_strings(locale); 6566 let mut shell = Shell::new(); 6567 render_with_strings( 6568 &mut shell, 6569 Theme::light(), 6570 size, 6571 &sample_document(), 6572 &Mode::Idle, 6573 &Selection::default(), 6574 &strings, 6575 ) 6576 } 6577 6578 const CHROME_BAND_KEYS: [StringKey; 3] = [ 6579 strings::MENU_FILE, 6580 strings::RIBBON_TAB_SKETCH, 6581 strings::STATUS_READY, 6582 ]; 6583 6584 fn assert_chrome_label_mirrors_under_rtl(key: StringKey) { 6585 let size = layout_size(1600.0, 900.0); 6586 let ltr = render_with_locale(size, Locale::EnUs); 6587 let rtl = render_with_locale(size, Locale::ArXb); 6588 let ltr_rect = 6589 label_rect(&ltr.paints, key).unwrap_or_else(|| panic!("ltr paint missing for {key}")); 6590 let rtl_rect = 6591 label_rect(&rtl.paints, key).unwrap_or_else(|| panic!("rtl paint missing for {key}")); 6592 let half = size.width.value() * 0.5; 6593 assert!( 6594 ltr_rect.origin.x.value() < half, 6595 "{key} must sit on the left half under ltr, got x={}", 6596 ltr_rect.origin.x.value(), 6597 ); 6598 assert!( 6599 rtl_rect.origin.x.value() > half, 6600 "{key} must mirror to the right half under rtl, got x={}", 6601 rtl_rect.origin.x.value(), 6602 ); 6603 } 6604 6605 #[test] 6606 fn rtl_locale_flips_viewport_to_the_left_side() { 6607 let size = layout_size(1600.0, 900.0); 6608 let ltr = render_with_locale(size, Locale::EnUs); 6609 let rtl = render_with_locale(size, Locale::ArXb); 6610 assert!( 6611 ltr.viewport_rect.size.width.value() > 0.0, 6612 "ltr viewport must have width", 6613 ); 6614 assert!( 6615 rtl.viewport_rect.size.width.value() > 0.0, 6616 "rtl viewport must have width", 6617 ); 6618 assert!( 6619 ltr.viewport_rect.origin.x.value() > size.width.value() * 0.1, 6620 "ltr viewport sits right of the left pane, got x={}", 6621 ltr.viewport_rect.origin.x.value(), 6622 ); 6623 assert!( 6624 rtl.viewport_rect.origin.x.value() < size.width.value() * 0.1, 6625 "rtl viewport must hug the left edge, got x={}", 6626 rtl.viewport_rect.origin.x.value(), 6627 ); 6628 assert!( 6629 (ltr.viewport_rect.size.width.value() - rtl.viewport_rect.size.width.value()).abs() 6630 < 1.0, 6631 "viewport width is independent of direction", 6632 ); 6633 } 6634 6635 #[test] 6636 fn rtl_locale_still_renders_every_chrome_band() { 6637 let size = layout_size(1600.0, 900.0); 6638 let rtl = render_with_locale(size, Locale::ArXb); 6639 assert!(!rtl.paints.is_empty(), "rtl shell must emit chrome paints"); 6640 CHROME_BAND_KEYS.into_iter().for_each(|key| { 6641 assert!( 6642 label_rect(&rtl.paints, key).is_some(), 6643 "rtl shell must emit a label paint for {key}", 6644 ); 6645 }); 6646 } 6647 6648 #[test] 6649 fn rtl_locale_mirrors_menu_bar_file_label() { 6650 assert_chrome_label_mirrors_under_rtl(strings::MENU_FILE); 6651 } 6652 6653 #[test] 6654 fn rtl_locale_mirrors_ribbon_sketch_tab() { 6655 assert_chrome_label_mirrors_under_rtl(strings::RIBBON_TAB_SKETCH); 6656 } 6657 6658 #[test] 6659 fn rtl_locale_mirrors_status_bar_mode_label() { 6660 assert_chrome_label_mirrors_under_rtl(strings::STATUS_READY); 6661 } 6662 6663 fn render_a11y_scenario( 6664 canvas: LayoutSize, 6665 table: &StringTable, 6666 doc: &Document, 6667 sketch_id: SketchId, 6668 selection: &Selection, 6669 configure: impl FnOnce(&mut Shell), 6670 ) -> (Shell, AccessTreeBuilder, FocusManager) { 6671 let mut shell = Shell::new(); 6672 configure(&mut shell); 6673 let theme = Arc::new(Theme::light()); 6674 let hk = HotkeyTable::new(); 6675 let mut focus = FocusManager::new(); 6676 let mut hits = HitFrame::new(); 6677 let prev = HitState::new(); 6678 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 6679 let mut shaper = bone_text::Shaper::new(); 6680 let mut a11y = AccessTreeBuilder::new(); 6681 { 6682 let mut ctx = FrameCtx::new( 6683 Arc::clone(&theme), 6684 &mut input, 6685 &mut focus, 6686 &hk, 6687 table, 6688 &mut hits, 6689 &prev, 6690 &mut a11y, 6691 &mut shaper, 6692 ); 6693 let _ = shell.render( 6694 &mut ctx, 6695 doc, 6696 &Mode::enter_sketch(sketch_id), 6697 selection, 6698 &Settings::default(), 6699 canvas, 6700 None, 6701 None, 6702 None, 6703 &mut ViewUi::default(), 6704 &BTreeMap::new(), 6705 false, 6706 &[], 6707 ); 6708 } 6709 (shell, a11y, focus) 6710 } 6711 6712 fn collect_reachable( 6713 update: &accesskit::TreeUpdate, 6714 ) -> std::collections::BTreeSet<accesskit::NodeId> { 6715 let nodes: std::collections::BTreeMap<accesskit::NodeId, &accesskit::Node> = 6716 update.nodes.iter().map(|(id, node)| (*id, node)).collect(); 6717 let mut seen = std::collections::BTreeSet::new(); 6718 if let Some(tree) = update.tree.as_ref() { 6719 visit_reachable(tree.root, &nodes, &mut seen); 6720 } 6721 seen 6722 } 6723 6724 fn visit_reachable( 6725 id: accesskit::NodeId, 6726 nodes: &std::collections::BTreeMap<accesskit::NodeId, &accesskit::Node>, 6727 seen: &mut std::collections::BTreeSet<accesskit::NodeId>, 6728 ) { 6729 if !seen.insert(id) { 6730 return; 6731 } 6732 if let Some(node) = nodes.get(&id) { 6733 node.children() 6734 .iter() 6735 .copied() 6736 .for_each(|c| visit_reachable(c, nodes, seen)); 6737 } 6738 } 6739 6740 fn entity_row_id(idx: usize) -> WidgetId { 6741 WidgetId::ROOT 6742 .child(WidgetKey::new("props.entity")) 6743 .child_indexed(WidgetKey::new("row"), idx as u64) 6744 } 6745 6746 fn relation_row_id(idx: usize) -> WidgetId { 6747 WidgetId::ROOT 6748 .child(WidgetKey::new("props.relation")) 6749 .child_indexed(WidgetKey::new("row"), idx as u64) 6750 } 6751 6752 fn static_row_id(label: StringKey) -> WidgetId { 6753 WidgetId::ROOT 6754 .child(WidgetKey::new("props.row")) 6755 .child(WidgetKey::new(label.id())) 6756 } 6757 6758 fn dim_value_row_id() -> WidgetId { 6759 WidgetId::ROOT 6760 .child(WidgetKey::new("props.dim")) 6761 .child(WidgetKey::new("value")) 6762 } 6763 6764 fn build_a11y_update( 6765 canvas: LayoutSize, 6766 table: &StringTable, 6767 doc: &Document, 6768 sketch_id: SketchId, 6769 selection: &Selection, 6770 configure: impl FnOnce(&mut Shell), 6771 ) -> (Shell, accesskit::TreeUpdate) { 6772 let (shell, a11y, focus) = 6773 render_a11y_scenario(canvas, table, doc, sketch_id, selection, configure); 6774 let update = a11y.build(table, focus.focused()); 6775 (shell, update) 6776 } 6777 6778 fn build_update( 6779 canvas: LayoutSize, 6780 table: &StringTable, 6781 doc: &Document, 6782 sketch_id: SketchId, 6783 selection: &Selection, 6784 configure: impl FnOnce(&mut Shell), 6785 ) -> accesskit::TreeUpdate { 6786 build_a11y_update(canvas, table, doc, sketch_id, selection, configure).1 6787 } 6788 6789 fn chrome_widgets(ids: &ShellIds, sketch_id: SketchId) -> Vec<(WidgetId, &'static str)> { 6790 vec![ 6791 (ids.ribbon, "ribbon"), 6792 (ids.ribbon_smart_dimension, "ribbon.smart_dimension"), 6793 (ids.menu_bar, "menu_bar"), 6794 (ids.menu_file, "menu.file"), 6795 (ids.menu_edit, "menu.edit"), 6796 (ids.menu_view, "menu.view"), 6797 (ids.menu_insert, "menu.insert"), 6798 (ids.menu_tools, "menu.tools"), 6799 (ids.menu_sketch, "menu.sketch"), 6800 (ids.menu_window, "menu.window"), 6801 (ids.menu_help, "menu.help"), 6802 (ids.status_bar, "status_bar"), 6803 (ids.feature_tree, "feature_tree"), 6804 (ids.feature_part, "feature_part"), 6805 (ids.plane_xy, "plane.xy"), 6806 (ids.plane_yz, "plane.yz"), 6807 (ids.plane_zx, "plane.zx"), 6808 (sketch_widget_id(ids.feature_part, sketch_id), "sketch.row"), 6809 (ids.property_pane, "property_pane"), 6810 (ids.doc_tabs, "doc_tabs"), 6811 (ids.doc_tab_model, "doc_tabs.model"), 6812 (ids.left_pane_tab_tree, "left_pane.tab.tree"), 6813 (ids.left_pane_tab_properties, "left_pane.tab.properties"), 6814 ( 6815 ids.left_pane_tab_configuration, 6816 "left_pane.tab.configuration", 6817 ), 6818 ( 6819 ids.left_pane_tab_dimension_expert, 6820 "left_pane.tab.dimension_expert", 6821 ), 6822 (ids.left_pane_tab_display, "left_pane.tab.display"), 6823 (ids.confirm_accept, "confirm.accept"), 6824 (ids.confirm_cancel, "confirm.cancel"), 6825 ] 6826 } 6827 6828 fn tool_widgets(ribbon: WidgetId) -> Vec<(WidgetId, &'static str)> { 6829 SketchTool::ENTITIES 6830 .iter() 6831 .map(|t| (tool_widget_id(ribbon, *t), tool_key(*t))) 6832 .collect() 6833 } 6834 6835 fn relation_widgets(ribbon: WidgetId) -> Vec<(WidgetId, &'static str)> { 6836 RelationKind::ALL 6837 .iter() 6838 .map(|k| (relation_widget_id(ribbon, *k), k.key())) 6839 .collect() 6840 } 6841 6842 fn menu_dropdown_groups(ids: &ShellIds) -> Vec<(WidgetId, Vec<(WidgetId, &'static str)>)> { 6843 vec![ 6844 ( 6845 ids.menu_file, 6846 vec![ 6847 (ids.menu_file_new, "menu.file.new"), 6848 (ids.menu_file_open, "menu.file.open"), 6849 (ids.menu_file_save, "menu.file.save"), 6850 (ids.menu_file_save_as, "menu.file.save_as"), 6851 (ids.menu_file_quit, "menu.file.quit"), 6852 ], 6853 ), 6854 ( 6855 ids.menu_edit, 6856 vec![ 6857 (ids.menu_edit_undo, "menu.edit.undo"), 6858 (ids.menu_edit_redo, "menu.edit.redo"), 6859 ], 6860 ), 6861 ( 6862 ids.menu_view, 6863 vec![(ids.menu_view_zoom_fit, "menu.view.zoom_fit")], 6864 ), 6865 ( 6866 ids.menu_insert, 6867 vec![( 6868 ids.menu_insert.child(WidgetKey::new("soon")), 6869 "menu.insert.soon", 6870 )], 6871 ), 6872 ( 6873 ids.menu_tools, 6874 vec![ 6875 (ids.menu_tools_options, "menu.tools.options"), 6876 (ids.menu_tools_keyboard, "menu.tools.keyboard"), 6877 ], 6878 ), 6879 ( 6880 ids.menu_sketch, 6881 vec![(ids.menu_sketch_exit, "menu.sketch.exit")], 6882 ), 6883 ( 6884 ids.menu_window, 6885 vec![( 6886 ids.menu_window.child(WidgetKey::new("soon")), 6887 "menu.window.soon", 6888 )], 6889 ), 6890 ( 6891 ids.menu_help, 6892 vec![( 6893 ids.menu_help.child(WidgetKey::new("soon")), 6894 "menu.help.soon", 6895 )], 6896 ), 6897 ] 6898 } 6899 6900 fn line_entity_rows() -> Vec<(WidgetId, &'static str)> { 6901 vec![ 6902 (entity_row_id(0), "entity.row.kind"), 6903 (entity_row_id(1), "entity.row.from"), 6904 (entity_row_id(2), "entity.row.to"), 6905 (entity_row_id(3), "entity.row.construction"), 6906 ] 6907 } 6908 6909 fn horizontal_relation_rows() -> Vec<(WidgetId, &'static str)> { 6910 vec![ 6911 (relation_row_id(0), "relation.row.kind"), 6912 (relation_row_id(1), "relation.row.target"), 6913 ] 6914 } 6915 6916 fn linear_dim_rows() -> Vec<(WidgetId, &'static str)> { 6917 vec![ 6918 ( 6919 static_row_id(strings::PROPERTY_ROW_DIM_KIND), 6920 "dim.row.kind", 6921 ), 6922 (static_row_id(strings::PROPERTY_ROW_FROM), "dim.row.from"), 6923 (static_row_id(strings::PROPERTY_ROW_TO), "dim.row.to"), 6924 ( 6925 static_row_id(strings::PROPERTY_ROW_DIM_DRIVES), 6926 "dim.row.drives", 6927 ), 6928 (dim_value_row_id(), "dim.row.value"), 6929 ] 6930 } 6931 6932 fn assert_ribbon_fully_present( 6933 reachable: &std::collections::BTreeSet<accesskit::NodeId>, 6934 ribbon: WidgetId, 6935 ) { 6936 use bone_ui::a11y::widget_node_id; 6937 SketchTool::ENTITIES.iter().for_each(|t| { 6938 let nid = widget_node_id(tool_widget_id(ribbon, *t)); 6939 assert!( 6940 reachable.contains(&nid), 6941 "{} culled by ribbon overflow at base canvas", 6942 tool_key(*t) 6943 ); 6944 }); 6945 RelationKind::ALL.iter().for_each(|k| { 6946 let nid = widget_node_id(relation_widget_id(ribbon, *k)); 6947 assert!( 6948 reachable.contains(&nid), 6949 "{} culled by ribbon overflow at base canvas", 6950 k.key() 6951 ); 6952 }); 6953 } 6954 6955 fn assert_widgets_reachable_and_labeled( 6956 expected: impl Iterator<Item = (WidgetId, &'static str)>, 6957 reachable: &std::collections::BTreeSet<accesskit::NodeId>, 6958 nodes: &std::collections::BTreeMap<accesskit::NodeId, accesskit::Node>, 6959 ) { 6960 use bone_ui::a11y::widget_node_id; 6961 expected.for_each(|(id, name)| { 6962 let nid = widget_node_id(id); 6963 assert!( 6964 reachable.contains(&nid), 6965 "{name} not reachable in a11y tree" 6966 ); 6967 let node = nodes 6968 .get(&nid) 6969 .unwrap_or_else(|| panic!("{name} missing from a11y tree")); 6970 let label = node 6971 .label() 6972 .unwrap_or_else(|| panic!("{name} has no a11y label")); 6973 assert!(!label.is_empty(), "{name} has an empty a11y label"); 6974 }); 6975 } 6976 6977 fn pane_updates( 6978 canvas: LayoutSize, 6979 table: &StringTable, 6980 doc: &Document, 6981 sketch_id: SketchId, 6982 selection: &Selection, 6983 ) -> (Shell, accesskit::TreeUpdate, accesskit::TreeUpdate) { 6984 let (shell, tree_update) = 6985 build_a11y_update(canvas, table, doc, sketch_id, selection, |s| { 6986 s.state.left_pane = LeftPane::Tree; 6987 }); 6988 let props_update = build_update(canvas, table, doc, sketch_id, selection, |s| { 6989 s.state.left_pane = LeftPane::Properties; 6990 }); 6991 (shell, tree_update, props_update) 6992 } 6993 6994 fn open_menu_updates( 6995 canvas: LayoutSize, 6996 table: &StringTable, 6997 doc: &Document, 6998 sketch_id: SketchId, 6999 selection: &Selection, 7000 menus: &[(WidgetId, Vec<(WidgetId, &'static str)>)], 7001 ) -> Vec<accesskit::TreeUpdate> { 7002 menus 7003 .iter() 7004 .map(|(menu_id, _)| { 7005 let menu_id = *menu_id; 7006 build_update(canvas, table, doc, sketch_id, selection, |s| { 7007 s.state.left_pane = LeftPane::Tree; 7008 s.state.menu_bar.open = Some(menu_id); 7009 }) 7010 }) 7011 .collect() 7012 } 7013 7014 fn selection_updates(canvas: LayoutSize, table: &StringTable) -> [accesskit::TreeUpdate; 3] { 7015 let (shared_sketch, rel_id, line_id) = sketch_with_relation(); 7016 let (doc_shared, sketch_id_shared) = document_with_sketch(shared_sketch); 7017 let entity_update = build_update( 7018 canvas, 7019 table, 7020 &doc_shared, 7021 sketch_id_shared, 7022 &Selection::Entities(vec![line_id]), 7023 |s| s.state.left_pane = LeftPane::Properties, 7024 ); 7025 let rel_update = build_update( 7026 canvas, 7027 table, 7028 &doc_shared, 7029 sketch_id_shared, 7030 &Selection::Relation(rel_id), 7031 |s| s.state.left_pane = LeftPane::Properties, 7032 ); 7033 let (dim_sketch, dim_id) = sketch_with_dim(DimensionKind::Driving); 7034 let (doc_dim, sketch_id_dim) = document_with_sketch(dim_sketch); 7035 let dim_update = build_update( 7036 canvas, 7037 table, 7038 &doc_dim, 7039 sketch_id_dim, 7040 &Selection::Dimension(dim_id), 7041 |s| s.state.left_pane = LeftPane::Properties, 7042 ); 7043 [entity_update, rel_update, dim_update] 7044 } 7045 7046 fn union_reachable<'a>( 7047 updates: impl Iterator<Item = &'a accesskit::TreeUpdate>, 7048 ) -> ( 7049 std::collections::BTreeSet<accesskit::NodeId>, 7050 std::collections::BTreeMap<accesskit::NodeId, accesskit::Node>, 7051 ) { 7052 let updates: Vec<&accesskit::TreeUpdate> = updates.collect(); 7053 let reachable = updates.iter().flat_map(|u| collect_reachable(u)).collect(); 7054 let nodes = updates 7055 .iter() 7056 .flat_map(|u| u.nodes.iter().map(|(id, node)| (*id, node.clone()))) 7057 .collect(); 7058 (reachable, nodes) 7059 } 7060 7061 #[test] 7062 fn a11y_smoke_sketch_surface_is_reachable_and_named() { 7063 let table = crate::strings::make_strings(Locale::EnUs); 7064 let canvas = layout_size(3600.0, 900.0); 7065 7066 let (doc_empty, sketch_id_empty) = document_with_sketch(bone_document::Sketch::new( 7067 crate::sketch_mode::Plane::Xy.basis(), 7068 )); 7069 let empty_sel = Selection::default(); 7070 let (shell, tree_update, props_update) = 7071 pane_updates(canvas, &table, &doc_empty, sketch_id_empty, &empty_sel); 7072 7073 let ids = &shell.ids; 7074 let menu_dropdowns = menu_dropdown_groups(ids); 7075 let menu_updates = open_menu_updates( 7076 canvas, 7077 &table, 7078 &doc_empty, 7079 sketch_id_empty, 7080 &empty_sel, 7081 &menu_dropdowns, 7082 ); 7083 let [entity_update, rel_update, dim_update] = selection_updates(canvas, &table); 7084 7085 let (reachable, nodes) = union_reachable( 7086 std::iter::once(&tree_update) 7087 .chain(std::iter::once(&props_update)) 7088 .chain(menu_updates.iter()) 7089 .chain([&entity_update, &rel_update, &dim_update]), 7090 ); 7091 7092 let chrome = chrome_widgets(ids, sketch_id_empty); 7093 let tools = tool_widgets(ids.ribbon); 7094 let relations = relation_widgets(ids.ribbon); 7095 let menu_items: Vec<(WidgetId, &'static str)> = menu_dropdowns 7096 .iter() 7097 .flat_map(|(_, items)| items.iter().copied()) 7098 .collect(); 7099 let expected = chrome 7100 .iter() 7101 .copied() 7102 .chain(tools.iter().copied()) 7103 .chain(relations.iter().copied()) 7104 .chain(menu_items) 7105 .chain(line_entity_rows()) 7106 .chain(horizontal_relation_rows()) 7107 .chain(linear_dim_rows()); 7108 assert_widgets_reachable_and_labeled(expected, &reachable, &nodes); 7109 7110 let tree_reachable = collect_reachable(&tree_update); 7111 assert_ribbon_fully_present(&tree_reachable, ids.ribbon); 7112 7113 let tree_min = chrome.len() + tools.len() + relations.len(); 7114 assert!( 7115 tree_reachable.len() >= tree_min, 7116 "base tree render shrank: {} a11y nodes, expected at least {tree_min}", 7117 tree_reachable.len() 7118 ); 7119 } 7120 7121 fn isometric_camera() -> Camera3 { 7122 use bone_types::{Point3, Projection, UnitVec3}; 7123 use uom::si::{f64::Length as UomLength, length::millimeter}; 7124 let Ok(projection) = Projection::orthographic(UomLength::new::<millimeter>(2.0)) else { 7125 unreachable!("a positive half height yields a projection"); 7126 }; 7127 let Ok(camera) = Camera3::new( 7128 Point3::from_mm(10.0, 10.0, 10.0), 7129 Point3::origin(), 7130 UnitVec3::z_axis(), 7131 projection, 7132 ) else { 7133 unreachable!("a non-degenerate isometric camera"); 7134 }; 7135 camera 7136 } 7137 7138 fn render_phase2( 7139 size: LayoutSize, 7140 strings: &StringTable, 7141 document: &Document, 7142 mode: &Mode, 7143 camera3: Option<Camera3>, 7144 configure: impl FnOnce(&mut Shell), 7145 ) -> (ShellFrame, accesskit::TreeUpdate) { 7146 let mut shell = Shell::new(); 7147 configure(&mut shell); 7148 let theme = Arc::new(Theme::light()); 7149 let hk = HotkeyTable::new(); 7150 let mut focus = FocusManager::new(); 7151 let mut hits = HitFrame::new(); 7152 let prev = HitState::new(); 7153 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 7154 let mut shaper = bone_text::Shaper::new(); 7155 let mut a11y = AccessTreeBuilder::new(); 7156 let mut view = ViewUi::default(); 7157 let frame = { 7158 let mut ctx = FrameCtx::new( 7159 Arc::clone(&theme), 7160 &mut input, 7161 &mut focus, 7162 &hk, 7163 strings, 7164 &mut hits, 7165 &prev, 7166 &mut a11y, 7167 &mut shaper, 7168 ); 7169 shell.render( 7170 &mut ctx, 7171 document, 7172 mode, 7173 &Selection::default(), 7174 &Settings::default(), 7175 size, 7176 None, 7177 camera3, 7178 None, 7179 &mut view, 7180 &BTreeMap::new(), 7181 false, 7182 &[], 7183 ) 7184 }; 7185 let update = a11y.build(strings, focus.focused()); 7186 (frame, update) 7187 } 7188 7189 fn any_label_rect(frame: &ShellFrame, key: StringKey) -> Option<LayoutRect> { 7190 label_rect(&frame.paints, key).or_else(|| label_rect(&frame.overlay_paints, key)) 7191 } 7192 7193 #[test] 7194 fn rtl_mirrors_extrude_property_pane() { 7195 let size = layout_size(1600.0, 900.0); 7196 let doc = sample_document(); 7197 let mode = Mode::Extrude(ExtrudeArming::profile(SketchId::default())); 7198 let ltr = render_phase2( 7199 size, 7200 &crate::strings::make_strings(Locale::EnUs), 7201 &doc, 7202 &mode, 7203 None, 7204 |_| {}, 7205 ) 7206 .0; 7207 let rtl = render_phase2( 7208 size, 7209 &crate::strings::make_strings(Locale::ArXb), 7210 &doc, 7211 &mode, 7212 None, 7213 |_| {}, 7214 ) 7215 .0; 7216 let key = strings::PROPERTY_ROW_EXTRUDE_DEPTH; 7217 let ltr_rect = 7218 any_label_rect(&ltr, key).unwrap_or_else(|| panic!("ltr extrude depth row missing")); 7219 let rtl_rect = 7220 any_label_rect(&rtl, key).unwrap_or_else(|| panic!("rtl extrude depth row missing")); 7221 let half = size.width.value() * 0.5; 7222 assert!( 7223 ltr_rect.origin.x.value() < half, 7224 "extrude depth row sits on the left half under ltr, got x={}", 7225 ltr_rect.origin.x.value(), 7226 ); 7227 assert!( 7228 rtl_rect.origin.x.value() > half, 7229 "extrude depth row mirrors to the right half under rtl, got x={}", 7230 rtl_rect.origin.x.value(), 7231 ); 7232 } 7233 7234 #[test] 7235 fn rtl_mirrors_file_menu_import_item() { 7236 let size = layout_size(1600.0, 900.0); 7237 let doc = sample_document(); 7238 let open_file = |s: &mut Shell| s.state.menu_bar.open = Some(s.ids.menu_file); 7239 let ltr = render_phase2( 7240 size, 7241 &crate::strings::make_strings(Locale::EnUs), 7242 &doc, 7243 &Mode::Idle, 7244 None, 7245 open_file, 7246 ) 7247 .0; 7248 let rtl = render_phase2( 7249 size, 7250 &crate::strings::make_strings(Locale::ArXb), 7251 &doc, 7252 &Mode::Idle, 7253 None, 7254 open_file, 7255 ) 7256 .0; 7257 let key = strings::MENU_FILE_IMPORT; 7258 let ltr_rect = 7259 any_label_rect(&ltr, key).unwrap_or_else(|| panic!("ltr file import item missing")); 7260 let rtl_rect = 7261 any_label_rect(&rtl, key).unwrap_or_else(|| panic!("rtl file import item missing")); 7262 let half = size.width.value() * 0.5; 7263 assert!( 7264 ltr_rect.origin.x.value() < half, 7265 "file import item opens on the left under ltr, got x={}", 7266 ltr_rect.origin.x.value(), 7267 ); 7268 assert!( 7269 rtl_rect.origin.x.value() > half, 7270 "file import item mirrors to the right under rtl, got x={}", 7271 rtl_rect.origin.x.value(), 7272 ); 7273 } 7274 7275 fn cube_group_x0(update: &accesskit::TreeUpdate, cube: WidgetId) -> f64 { 7276 let nid = bone_ui::a11y::widget_node_id(cube); 7277 update 7278 .nodes 7279 .iter() 7280 .find_map(|(id, node)| (*id == nid).then(|| node.bounds()).flatten()) 7281 .unwrap_or_else(|| panic!("view cube group must render")) 7282 .x0 7283 } 7284 7285 #[test] 7286 fn rtl_flips_view_cube_with_the_viewport() { 7287 let size = layout_size(1600.0, 900.0); 7288 let doc = sample_document(); 7289 let cube = Shell::new().ids.view_cube; 7290 let (_, ltr) = render_phase2( 7291 size, 7292 &crate::strings::make_strings(Locale::EnUs), 7293 &doc, 7294 &Mode::Idle, 7295 Some(isometric_camera()), 7296 |_| {}, 7297 ); 7298 let (_, rtl) = render_phase2( 7299 size, 7300 &crate::strings::make_strings(Locale::ArXb), 7301 &doc, 7302 &Mode::Idle, 7303 Some(isometric_camera()), 7304 |_| {}, 7305 ); 7306 let ltr_x0 = cube_group_x0(&ltr, cube); 7307 let rtl_x0 = cube_group_x0(&rtl, cube); 7308 assert!( 7309 ltr_x0 > rtl_x0, 7310 "view cube follows the viewport across rtl: ltr x0={ltr_x0}, rtl x0={rtl_x0}", 7311 ); 7312 } 7313 7314 fn extrude_property_rows() -> Vec<(WidgetId, &'static str)> { 7315 [ 7316 "end", 7317 "depth", 7318 "draft", 7319 "draft_angle", 7320 "direction_two", 7321 "thin", 7322 "merge", 7323 ] 7324 .into_iter() 7325 .map(|key| { 7326 ( 7327 WidgetId::ROOT 7328 .child(WidgetKey::new("props.extrude")) 7329 .child(WidgetKey::new(key)), 7330 key, 7331 ) 7332 }) 7333 .collect() 7334 } 7335 7336 fn visible_cube_faces(ids: &ShellIds) -> Vec<(WidgetId, &'static str)> { 7337 use crate::view_cube::{CubeCell, cell_widget_id}; 7338 [ 7339 (bone_types::StandardView::Top, "view_cube.top"), 7340 (bone_types::StandardView::Right, "view_cube.right"), 7341 (bone_types::StandardView::Back, "view_cube.back"), 7342 ] 7343 .into_iter() 7344 .map(|(view, name)| { 7345 let cell = CubeCell::all() 7346 .into_iter() 7347 .find(|c| c.standard_view() == Some(view)) 7348 .unwrap_or_else(|| panic!("{name} names a cube face")); 7349 (cell_widget_id(ids.view_cube, cell), name) 7350 }) 7351 .collect() 7352 } 7353 7354 #[test] 7355 fn a11y_smoke_post_extrude_surface_is_reachable_and_named() { 7356 let table = crate::strings::make_strings(Locale::EnUs); 7357 let canvas = layout_size(3600.0, 900.0); 7358 let ids = Shell::new().ids; 7359 7360 let (doc, extrude_id) = document_with_extrude(); 7361 7362 let (_, tree_update) = render_phase2( 7363 canvas, 7364 &table, 7365 &doc, 7366 &Mode::Idle, 7367 Some(isometric_camera()), 7368 |s| { 7369 s.state.left_pane = LeftPane::Tree; 7370 s.state.ribbon_active_tab = Some(features_tab_id(s.ids.ribbon)); 7371 }, 7372 ); 7373 7374 let (_, pane_update) = render_phase2( 7375 canvas, 7376 &table, 7377 &doc, 7378 &Mode::Extrude(ExtrudeArming::profile(SketchId::default())), 7379 Some(isometric_camera()), 7380 |_| {}, 7381 ); 7382 7383 let (_, menu_update) = render_phase2(canvas, &table, &doc, &Mode::Idle, None, |s| { 7384 s.state.menu_bar.open = Some(s.ids.menu_file); 7385 s.state.menu_bar.menu.open_submenu = Some(s.ids.menu_file_export); 7386 }); 7387 7388 let (reachable, nodes) = 7389 union_reachable([&tree_update, &pane_update, &menu_update].into_iter()); 7390 7391 let ribbon_buttons = FeatureTool::ALL 7392 .iter() 7393 .map(|t| (feature_tool_widget_id(ids.ribbon, *t), feature_tool_key(*t))); 7394 let extrude_row = 7395 std::iter::once((extrude_widget(&ids, extrude_id), "feature_tree.extrude")); 7396 let file_menu_items = [ 7397 (ids.menu_file_import, "menu.file.import"), 7398 (ids.menu_file_export, "menu.file.export"), 7399 (ids.menu_file_export_step, "menu.file.export.step"), 7400 ]; 7401 7402 let expected = ribbon_buttons 7403 .chain(extrude_row) 7404 .chain(extrude_property_rows()) 7405 .chain(visible_cube_faces(&ids)) 7406 .chain(file_menu_items); 7407 assert_widgets_reachable_and_labeled(expected, &reachable, &nodes); 7408 } 7409}