Another project
0

Configure Feed

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

1use core::num::NonZeroU32; 2use std::collections::BTreeMap; 3use std::sync::Arc; 4 5use bone_document::{ 6 DimensionKind, DimensionValue, Document, Sketch, SketchDimension, SketchEntity, SketchRelation, 7 SketchStatusReport, SketchVersion, 8}; 9use bone_types::{Length, Point2, SketchDimensionId, SketchEntityId, SketchId}; 10use bone_ui::a11y::{AccessNode, Role}; 11use bone_ui::frame::{FrameCtx, InteractDeclaration}; 12use bone_ui::hit_test::Sense; 13use bone_ui::layout::{ 14 Axis, DockNode, DockPanel, DockState, GridChild, GridLine, GridSpan, GridTrack, Layout, 15 LayoutPos, LayoutPx, LayoutRect, LayoutSize, NodeKind, PanelId, RetainedLayout, SolvedLayout, 16 SolvedNode, SplitFraction, TrackSize, measure, 17}; 18use bone_ui::strings::{StringKey, StringTable}; 19use bone_ui::theme::{Border, ElevationLevel, Radius, Spacing, Step12, StrokeWidth, Theme}; 20use bone_ui::widgets::GlyphMark; 21use bone_ui::widgets::{ 22 AngleEditor, Clipboard, Dialog, DialogButton, HotkeyCapture, HotkeyCaptureState, LabelText, 23 LengthEditor, MemoryClipboard, MenuBar, MenuBarEntry, MenuBarState, MenuItem, PanelState, 24 PropertyCell, PropertyEditor, PropertyGrid, PropertyRow, RenameCommit, Ribbon, RibbonGroup, 25 RibbonIconSize, RibbonTab, Slider, SliderRange, SliderStep, StatusAlign, StatusBar, StatusItem, 26 Tab, Tabs, TabsOrientation, ToolbarItem, TreeNode, TreeView, TreeViewState, WidgetPaint, 27 show_dialog, show_hotkey_capture, show_menu_bar, show_property_grid, show_ribbon, show_slider, 28 show_status_bar, show_tabs, show_tree_view, 29}; 30use bone_ui::{WidgetId, WidgetKey}; 31use uom::si::length::millimeter; 32 33use bone_render::PickAperture; 34 35use crate::relation_tools::{Eligibility, RelationKind, eligibility}; 36use crate::selection::Selection; 37use crate::settings::Settings; 38use crate::sketch_mode::PendingDimension; 39use crate::sketch_mode::{Mode, Plane, SketchTool}; 40use crate::smart_dimension; 41use crate::status_badge::{ 42 render_status_panel, status_badge_widget_id, status_color, status_label_key, 43 status_panel_widget_id, 44}; 45use crate::strings; 46 47const RIBBON_GROUP_PADDING_PX: f32 = 8.0; 48const RIBBON_TOOLBAR_GAP_PX: f32 = 4.0; 49const RIBBON_LABEL_HORIZONTAL_PADDING_PX: f32 = 12.0; 50const RIBBON_LABEL_AVG_ADVANCE_RATIO: f32 = 0.6; 51const STATUS_MODE_WIDTH: LayoutPx = LayoutPx::new(220.0); 52const STATUS_UNITS_WIDTH: LayoutPx = LayoutPx::new(80.0); 53const STATUS_COORDS_WIDTH: LayoutPx = LayoutPx::new(180.0); 54const STATUS_STATUS_WIDTH: LayoutPx = LayoutPx::new(180.0); 55 56#[derive(Copy, Clone, Debug, PartialEq, Eq)] 57struct ShellPanels { 58 left_pane: PanelId, 59 viewport: PanelId, 60} 61 62impl ShellPanels { 63 fn standard() -> Self { 64 Self { 65 left_pane: panel(3), 66 viewport: panel(5), 67 } 68 } 69} 70 71#[derive(Copy, Clone, Debug, PartialEq, Eq)] 72struct ShellIds { 73 dock_host: WidgetId, 74 ribbon: WidgetId, 75 ribbon_smart_dimension: WidgetId, 76 left_pane: WidgetId, 77 left_pane_tab_tree: WidgetId, 78 left_pane_tab_properties: WidgetId, 79 left_pane_tab_configuration: WidgetId, 80 left_pane_tab_dimension_expert: WidgetId, 81 left_pane_tab_display: WidgetId, 82 feature_tree: WidgetId, 83 property_pane: WidgetId, 84 viewport: WidgetId, 85 confirm_accept: WidgetId, 86 confirm_cancel: WidgetId, 87 status_bar: WidgetId, 88 doc_tabs: WidgetId, 89 doc_tab_model: WidgetId, 90 feature_part: WidgetId, 91 plane_xy: WidgetId, 92 plane_yz: WidgetId, 93 plane_zx: WidgetId, 94 menu_bar: WidgetId, 95 menu_file: WidgetId, 96 menu_edit: WidgetId, 97 menu_view: WidgetId, 98 menu_insert: WidgetId, 99 menu_tools: WidgetId, 100 menu_sketch: WidgetId, 101 menu_window: WidgetId, 102 menu_help: WidgetId, 103 menu_file_new: WidgetId, 104 menu_file_open: WidgetId, 105 menu_file_save: WidgetId, 106 menu_file_save_as: WidgetId, 107 menu_file_quit: WidgetId, 108 menu_edit_undo: WidgetId, 109 menu_edit_redo: WidgetId, 110 menu_view_zoom_fit: WidgetId, 111 menu_tools_options: WidgetId, 112 menu_tools_keyboard: WidgetId, 113 menu_sketch_exit: WidgetId, 114 settings_dialog: WidgetId, 115 settings_aperture_slider: WidgetId, 116 settings_reset: WidgetId, 117 settings_close: WidgetId, 118 keyboard_dialog: WidgetId, 119 keyboard_dialog_reset: WidgetId, 120 keyboard_dialog_close: WidgetId, 121} 122 123impl ShellIds { 124 fn standard() -> Self { 125 let root = WidgetId::ROOT.child(WidgetKey::new("shell")); 126 let left_pane = root.child(WidgetKey::new("left")); 127 let feature_tree = left_pane.child(WidgetKey::new("tree")); 128 let feature_part = feature_tree.child(WidgetKey::new("part")); 129 let ribbon = root.child(WidgetKey::new("ribbon")); 130 let menu_bar = root.child(WidgetKey::new("menu")); 131 let menu_file = menu_bar.child(WidgetKey::new("file")); 132 let menu_edit = menu_bar.child(WidgetKey::new("edit")); 133 let menu_view = menu_bar.child(WidgetKey::new("view")); 134 let menu_tools = menu_bar.child(WidgetKey::new("tools")); 135 let menu_sketch = menu_bar.child(WidgetKey::new("sketch")); 136 let settings_dialog = root.child(WidgetKey::new("settings.dialog")); 137 let keyboard_dialog = root.child(WidgetKey::new("keyboard.dialog")); 138 let viewport = root.child(WidgetKey::new("viewport")); 139 Self { 140 dock_host: root.child(WidgetKey::new("dock")), 141 ribbon, 142 ribbon_smart_dimension: ribbon.child(WidgetKey::new("tool.smart_dimension")), 143 left_pane, 144 left_pane_tab_tree: left_pane.child(WidgetKey::new("tab.tree")), 145 left_pane_tab_properties: left_pane.child(WidgetKey::new("tab.props")), 146 left_pane_tab_configuration: left_pane.child(WidgetKey::new("tab.config")), 147 left_pane_tab_dimension_expert: left_pane.child(WidgetKey::new("tab.dimxpert")), 148 left_pane_tab_display: left_pane.child(WidgetKey::new("tab.display")), 149 feature_tree, 150 property_pane: left_pane.child(WidgetKey::new("props")), 151 viewport, 152 confirm_accept: viewport.child(WidgetKey::new("confirm.accept")), 153 confirm_cancel: viewport.child(WidgetKey::new("confirm.cancel")), 154 status_bar: root.child(WidgetKey::new("status")), 155 doc_tabs: root.child(WidgetKey::new("doc_tabs")), 156 doc_tab_model: root.child(WidgetKey::new("doc_tabs.model")), 157 feature_part, 158 plane_xy: feature_part.child(WidgetKey::new("plane.xy")), 159 plane_yz: feature_part.child(WidgetKey::new("plane.yz")), 160 plane_zx: feature_part.child(WidgetKey::new("plane.zx")), 161 menu_bar, 162 menu_file, 163 menu_edit, 164 menu_view, 165 menu_insert: menu_bar.child(WidgetKey::new("insert")), 166 menu_tools, 167 menu_sketch, 168 menu_window: menu_bar.child(WidgetKey::new("window")), 169 menu_help: menu_bar.child(WidgetKey::new("help")), 170 menu_file_new: menu_file.child(WidgetKey::new("new")), 171 menu_file_open: menu_file.child(WidgetKey::new("open")), 172 menu_file_save: menu_file.child(WidgetKey::new("save")), 173 menu_file_save_as: menu_file.child(WidgetKey::new("save_as")), 174 menu_file_quit: menu_file.child(WidgetKey::new("quit")), 175 menu_edit_undo: menu_edit.child(WidgetKey::new("undo")), 176 menu_edit_redo: menu_edit.child(WidgetKey::new("redo")), 177 menu_view_zoom_fit: menu_view.child(WidgetKey::new("zoom_fit")), 178 menu_tools_options: menu_tools.child(WidgetKey::new("options")), 179 menu_tools_keyboard: menu_tools.child(WidgetKey::new("keyboard")), 180 menu_sketch_exit: menu_sketch.child(WidgetKey::new("exit")), 181 settings_dialog, 182 settings_aperture_slider: settings_dialog.child(WidgetKey::new("aperture.slider")), 183 settings_reset: settings_dialog.child(WidgetKey::new("button.reset")), 184 settings_close: settings_dialog.child(WidgetKey::new("button.close")), 185 keyboard_dialog, 186 keyboard_dialog_reset: keyboard_dialog.child(WidgetKey::new("button.reset")), 187 keyboard_dialog_close: keyboard_dialog.child(WidgetKey::new("button.close")), 188 } 189 } 190 191 fn plane_for(&self, id: WidgetId) -> Option<Plane> { 192 [ 193 (self.plane_xy, Plane::Xy), 194 (self.plane_yz, Plane::Yz), 195 (self.plane_zx, Plane::Zx), 196 ] 197 .iter() 198 .copied() 199 .find_map(|(plane_id, plane)| (plane_id == id).then_some(plane)) 200 } 201 202 fn menu_action_for(&self, id: WidgetId) -> Option<MenuAction> { 203 [ 204 (self.menu_file_new, MenuAction::NewDocument), 205 (self.menu_file_open, MenuAction::OpenDocument), 206 (self.menu_file_save, MenuAction::SaveDocument), 207 (self.menu_file_save_as, MenuAction::SaveDocumentAs), 208 (self.menu_file_quit, MenuAction::Quit), 209 (self.menu_edit_undo, MenuAction::Undo), 210 (self.menu_edit_redo, MenuAction::Redo), 211 (self.menu_view_zoom_fit, MenuAction::ZoomFit), 212 (self.menu_tools_options, MenuAction::OpenSettings), 213 (self.menu_tools_keyboard, MenuAction::OpenKeyboardCustomize), 214 (self.menu_sketch_exit, MenuAction::ExitSketch), 215 ] 216 .iter() 217 .copied() 218 .find_map(|(menu_id, action)| (menu_id == id).then_some(action)) 219 } 220} 221 222#[derive(Copy, Clone, Debug, PartialEq, Eq)] 223pub enum MenuAction { 224 NewDocument, 225 OpenDocument, 226 SaveDocument, 227 SaveDocumentAs, 228 Quit, 229 Undo, 230 Redo, 231 ZoomFit, 232 OpenSettings, 233 OpenKeyboardCustomize, 234 ExitSketch, 235} 236 237pub struct Shell { 238 panels: ShellPanels, 239 ids: ShellIds, 240 retained_layout: RetainedLayout, 241 dock_state: Arc<DockState>, 242 tool_index: BTreeMap<WidgetId, SketchTool>, 243 relation_index: BTreeMap<WidgetId, RelationKind>, 244 pub state: ShellState, 245} 246 247#[derive(Default)] 248#[allow( 249 clippy::struct_excessive_bools, 250 reason = "shell aggregates independent dialog and panel toggles" 251)] 252pub struct ShellState { 253 pub feature_tree: TreeViewState, 254 pub clipboard: MemoryClipboard, 255 pub menu_bar: MenuBarState, 256 pub dim_property: Option<DimPropertyEditor>, 257 pub settings_dialog_open: bool, 258 pub keyboard_dialog_open: bool, 259 pub hotkey_capture: BTreeMap<bone_ui::hotkey::ActionId, HotkeyCaptureState>, 260 pub left_pane: LeftPane, 261 last_left_pane_interesting: bool, 262 pub status_panel_open: bool, 263 pub status_panel: PanelState, 264 status_cache: Option<(SketchVersion, SketchStatusReport)>, 265 pub ribbon_overflow_open: BTreeMap<WidgetId, bool>, 266} 267 268#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] 269pub enum LeftPane { 270 #[default] 271 Tree, 272 Properties, 273} 274 275pub enum DimPropertyEditor { 276 Length { 277 sketch_id: SketchId, 278 id: SketchDimensionId, 279 editor: LengthEditor, 280 }, 281 Angle { 282 sketch_id: SketchId, 283 id: SketchDimensionId, 284 editor: AngleEditor, 285 }, 286} 287 288#[derive(Clone, Debug, PartialEq)] 289pub struct ShellFrame { 290 pub paints: Vec<WidgetPaint>, 291 pub overlay_paints: Vec<WidgetPaint>, 292 pub viewport_rect: LayoutRect, 293 pub activated_tool: Option<SketchTool>, 294 pub activated_relation: Option<SketchRelation>, 295 pub activated_dimension: Option<PendingDimension>, 296 pub dimension_edit: Option<DimensionEdit>, 297 pub plane_picked: Option<Plane>, 298 pub sketch_activated: Option<SketchId>, 299 pub sketch_rename: Option<SketchRenameRequest>, 300 pub exit_sketch: bool, 301 pub confirm_action: Option<ConfirmAction>, 302 pub menu_action: Option<MenuAction>, 303 pub settings_change: Option<crate::settings::Settings>, 304} 305 306#[derive(Copy, Clone, Debug, PartialEq)] 307pub struct DimensionEdit { 308 pub id: SketchDimensionId, 309 pub value: DimensionValue, 310} 311 312impl ShellFrame { 313 fn empty() -> Self { 314 Self { 315 paints: Vec::new(), 316 overlay_paints: Vec::new(), 317 viewport_rect: zero_rect(), 318 activated_tool: None, 319 activated_relation: None, 320 activated_dimension: None, 321 dimension_edit: None, 322 plane_picked: None, 323 sketch_activated: None, 324 sketch_rename: None, 325 exit_sketch: false, 326 confirm_action: None, 327 menu_action: None, 328 settings_change: None, 329 } 330 } 331} 332 333impl Shell { 334 fn build_layout(&self, gap: Spacing) -> Layout { 335 let center = Layout::dock_host( 336 self.ids.dock_host, 337 Arc::clone(&self.dock_state), 338 vec![ 339 DockPanel { 340 id: self.panels.left_pane, 341 child: Layout::leaf(self.ids.left_pane), 342 }, 343 DockPanel { 344 id: self.panels.viewport, 345 child: Layout::leaf(self.ids.viewport), 346 }, 347 ], 348 gap, 349 ); 350 chrome_grid(ChromeRows { 351 menu: Layout::leaf(self.ids.menu_bar), 352 ribbon: Layout::leaf(self.ids.ribbon), 353 center, 354 doc_tabs: Layout::leaf(self.ids.doc_tabs), 355 status: Layout::leaf(self.ids.status_bar), 356 }) 357 } 358 359 #[must_use] 360 pub fn new() -> Self { 361 let panels = ShellPanels::standard(); 362 let ids = ShellIds::standard(); 363 let dock_state = Arc::new(DockState::new(build_dock_main(panels))); 364 let tool_index = build_tool_index(ids.ribbon); 365 let relation_index = build_relation_index(ids.ribbon); 366 let mut state = ShellState::default(); 367 state.feature_tree.expanded.insert(ids.feature_part); 368 Self { 369 panels, 370 ids, 371 retained_layout: RetainedLayout::default(), 372 dock_state, 373 tool_index, 374 relation_index, 375 state, 376 } 377 } 378 379 #[allow( 380 clippy::too_many_lines, 381 clippy::too_many_arguments, 382 reason = "shell.render orchestrates the chrome layout pipeline" 383 )] 384 pub fn render( 385 &mut self, 386 ctx: &mut FrameCtx<'_>, 387 document: &Document, 388 mode: &Mode, 389 selection: &Selection, 390 settings: &Settings, 391 viewport_size: LayoutSize, 392 cursor_world: Option<Point2>, 393 ) -> ShellFrame { 394 let theme = ctx.theme(); 395 let direction = ctx.direction(); 396 let layout = self.build_layout(theme.spacing.md); 397 let Ok(solved) = measure(&layout, viewport_size, &self.retained_layout, direction) else { 398 return ShellFrame::empty(); 399 }; 400 let inset_px = theme.spacing.sm.value_px(); 401 let mut paints = paint_walk(&solved, solved.root_node(), theme, self.panels.viewport); 402 let viewport_rect = panel_rect(&solved, self.panels.viewport).unwrap_or_else(zero_rect); 403 let ribbon_rect = leaf_rect(&solved, self.ids.ribbon).unwrap_or_else(zero_rect); 404 let menu_bar_rect = leaf_rect(&solved, self.ids.menu_bar).unwrap_or_else(zero_rect); 405 let left_pane_rect = panel_rect(&solved, self.panels.left_pane) 406 .map_or_else(zero_rect, |r| inset_rect(r, inset_px)); 407 let LeftPaneSplit { 408 tab_strip_rect, 409 content_rect, 410 } = split_left_pane(left_pane_rect); 411 let status_rect = leaf_rect(&solved, self.ids.status_bar).unwrap_or_else(zero_rect); 412 let doc_tabs_rect = leaf_rect(&solved, self.ids.doc_tabs).unwrap_or_else(zero_rect); 413 let mut popover_paints: Vec<WidgetPaint> = Vec::new(); 414 let menu_action = render_menu_bar( 415 ctx, 416 menu_bar_rect, 417 &self.ids, 418 &mut self.state.menu_bar, 419 document, 420 mode.is_sketch(), 421 &settings.hotkey_overrides, 422 &mut paints, 423 &mut popover_paints, 424 ); 425 let active_sketch = active_sketch(document, mode); 426 let entity_ids = selection.entity_ids(); 427 let activated_widget = render_ribbon( 428 ctx, 429 RibbonInputs { 430 rect: ribbon_rect, 431 ribbon: self.ids.ribbon, 432 ribbon_smart_dimension: self.ids.ribbon_smart_dimension, 433 mode, 434 sketch: active_sketch, 435 selection: entity_ids, 436 }, 437 &mut paints, 438 &mut popover_paints, 439 &mut self.state.ribbon_overflow_open, 440 ); 441 let active_tool = match mode { 442 Mode::Sketch { session, .. } => session.tool, 443 Mode::Idle => None, 444 }; 445 update_left_pane_auto(&mut self.state, selection, active_tool); 446 let tab_clicked = render_left_pane_tabs( 447 ctx, 448 tab_strip_rect, 449 &self.ids, 450 self.state.left_pane, 451 &mut paints, 452 ); 453 if let Some(target) = tab_clicked { 454 self.state.left_pane = target; 455 } 456 let active_pane = self.state.left_pane; 457 let (tree_rect, property_rect) = match active_pane { 458 LeftPane::Tree => (content_rect, zero_rect()), 459 LeftPane::Properties => (zero_rect(), content_rect), 460 }; 461 let feature_tree = render_feature_tree( 462 ctx, 463 tree_rect, 464 self.ids.feature_tree, 465 self.ids.feature_part, 466 &mut self.state.feature_tree, 467 document, 468 &mut paints, 469 ); 470 let dimension_edit = render_property_pane( 471 ctx, 472 property_rect, 473 self.ids.property_pane, 474 &mut self.state.clipboard, 475 &mut self.state.dim_property, 476 PropertyState { 477 mode, 478 sketch: active_sketch, 479 selection, 480 }, 481 &mut paints, 482 ); 483 render_doc_tabs(ctx, doc_tabs_rect, &self.ids, &mut paints); 484 let status_report: Option<&SketchStatusReport> = if let Some(s) = active_sketch { 485 let v = s.version(); 486 if self 487 .state 488 .status_cache 489 .as_ref() 490 .is_none_or(|(cv, _)| *cv != v) 491 { 492 self.state.status_cache = Some((v, s.status())); 493 } 494 self.state.status_cache.as_ref().map(|(_, r)| r) 495 } else { 496 self.state.status_cache = None; 497 None 498 }; 499 let status_badge_id = status_badge_widget_id(self.ids.status_bar); 500 let status_activated = render_status_bar( 501 ctx, 502 status_rect, 503 self.ids.status_bar, 504 mode, 505 document, 506 cursor_world, 507 status_report, 508 status_badge_id, 509 &mut paints, 510 ); 511 if status_activated { 512 self.state.status_panel_open = !self.state.status_panel_open; 513 } 514 if status_report.is_none_or(|r| r.offending().is_empty()) { 515 self.state.status_panel_open = false; 516 } 517 if self.state.status_panel_open { 518 if let (Some(report), Some(sketch)) = (status_report, active_sketch) { 519 render_status_panel( 520 ctx, 521 status_panel_widget_id(self.ids.status_bar), 522 &mut self.state.status_panel, 523 status_rect, 524 report, 525 sketch, 526 &mut popover_paints, 527 ); 528 } else { 529 self.state.status_panel_open = false; 530 } 531 } 532 let confirm = 533 render_confirm_corner(ctx, viewport_rect, &self.ids, mode.is_sketch(), &mut paints); 534 let confirm_action = confirm; 535 let exit_sketch = confirm_action.is_some() || menu_action == Some(MenuAction::ExitSketch); 536 let activated_tool = activated_widget.and_then(|id| self.tool_index.get(&id).copied()); 537 let activated_relation = resolve_activated_relation( 538 activated_widget, 539 &self.relation_index, 540 active_sketch, 541 entity_ids, 542 ); 543 let activated_dimension = resolve_activated_dimension( 544 activated_widget, 545 self.ids.ribbon_smart_dimension, 546 active_sketch, 547 entity_ids, 548 ); 549 let plane_picked = feature_tree 550 .double_activated 551 .and_then(|id| self.ids.plane_for(id)); 552 let sketch_activated = feature_tree.sketch_activated; 553 let sketch_rename = feature_tree.sketch_rename; 554 let mut dialog_paints: Vec<WidgetPaint> = Vec::new(); 555 let settings_change = render_settings_dialog( 556 ctx, 557 viewport_size, 558 &self.ids, 559 &mut self.state, 560 settings, 561 &mut dialog_paints, 562 ); 563 let keyboard_change = render_keyboard_dialog( 564 ctx, 565 viewport_size, 566 &self.ids, 567 &mut self.state, 568 settings, 569 &mut dialog_paints, 570 ); 571 let settings_change = keyboard_change.or(settings_change); 572 let (paints, mut overlay_paints) = partition_overlay(paints, ctx.theme()); 573 overlay_paints.extend(popover_paints); 574 overlay_paints.extend(dialog_paints); 575 ShellFrame { 576 paints, 577 overlay_paints, 578 viewport_rect, 579 activated_tool, 580 activated_relation, 581 activated_dimension, 582 dimension_edit, 583 plane_picked, 584 sketch_activated, 585 sketch_rename, 586 exit_sketch, 587 confirm_action, 588 menu_action, 589 settings_change, 590 } 591 } 592} 593 594const SETTINGS_DIALOG_WIDTH: f32 = 420.0; 595const SETTINGS_DIALOG_HEIGHT: f32 = 220.0; 596const SETTINGS_DIALOG_GUTTER: f32 = 16.0; 597const SETTINGS_LABEL_HEIGHT: f32 = 20.0; 598const SETTINGS_HINT_HEIGHT: f32 = 36.0; 599const SETTINGS_SLIDER_HEIGHT: f32 = 28.0; 600const SETTINGS_LABEL_TO_HINT_GAP: f32 = 6.0; 601const SETTINGS_HINT_TO_SLIDER_GAP: f32 = 12.0; 602const PICK_APERTURE_MIN_PX: i32 = 1; 603const PICK_APERTURE_MAX_PX: i32 = 30; 604 605fn render_settings_dialog( 606 ctx: &mut FrameCtx<'_>, 607 viewport_size: LayoutSize, 608 ids: &ShellIds, 609 state: &mut ShellState, 610 settings: &Settings, 611 paints: &mut Vec<WidgetPaint>, 612) -> Option<Settings> { 613 if !state.settings_dialog_open { 614 return None; 615 } 616 let viewport = LayoutRect::new( 617 LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)), 618 viewport_size, 619 ); 620 let buttons = [ 621 DialogButton::secondary(ids.settings_reset, strings::SETTINGS_RESET), 622 DialogButton::primary(ids.settings_close, strings::SETTINGS_CLOSE), 623 ]; 624 let dialog_size = LayoutSize::new( 625 LayoutPx::new(SETTINGS_DIALOG_WIDTH), 626 LayoutPx::new(SETTINGS_DIALOG_HEIGHT), 627 ); 628 let aperture_label_text = format!( 629 "{}: {} px", 630 ctx.strings.resolve(strings::SETTINGS_PICK_APERTURE_LABEL), 631 settings.pick_aperture.radius_px(), 632 ); 633 let aperture_slider_id = ids.settings_aperture_slider; 634 let (response, slider_change) = show_dialog( 635 ctx, 636 Dialog::new( 637 ids.settings_dialog, 638 viewport, 639 dialog_size, 640 strings::SETTINGS_DIALOG_TITLE, 641 &buttons, 642 ), 643 |ctx, body_rect, paint| { 644 settings_dialog_body( 645 ctx, 646 body_rect, 647 aperture_slider_id, 648 settings, 649 aperture_label_text, 650 paint, 651 ) 652 }, 653 ); 654 paints.extend(response.paint); 655 if response.dismissed || response.activated == Some(ids.settings_close) { 656 state.settings_dialog_open = false; 657 } 658 if response.activated == Some(ids.settings_reset) { 659 return Some(Settings { 660 pick_aperture: PickAperture::DEFAULT, 661 hotkey_overrides: settings.hotkey_overrides.clone(), 662 }); 663 } 664 slider_change 665} 666 667fn settings_dialog_body( 668 ctx: &mut FrameCtx<'_>, 669 body_rect: LayoutRect, 670 aperture_slider_id: WidgetId, 671 settings: &Settings, 672 aperture_label_text: String, 673 paint: &mut Vec<WidgetPaint>, 674) -> Option<Settings> { 675 let label_rect = settings_label_rect(body_rect); 676 paint.push(WidgetPaint::Label { 677 rect: label_rect, 678 text: LabelText::Owned(aperture_label_text), 679 color: ctx.theme().colors.text_primary(), 680 role: ctx.theme().typography.label, 681 }); 682 let hint_rect = settings_hint_rect(body_rect); 683 paint.push(WidgetPaint::Label { 684 rect: hint_rect, 685 text: LabelText::Key(strings::SETTINGS_PICK_APERTURE_HINT), 686 color: ctx.theme().colors.text_secondary(), 687 role: ctx.theme().typography.caption, 688 }); 689 let Ok(range) = SliderRange::try_new(PICK_APERTURE_MIN_PX, PICK_APERTURE_MAX_PX) else { 690 unreachable!("PICK_APERTURE_MIN_PX < PICK_APERTURE_MAX_PX is statically guaranteed"); 691 }; 692 let Ok(step) = SliderStep::try_new(1i32) else { 693 unreachable!("integer step of 1 is positive"); 694 }; 695 let initial = i32::try_from(settings.pick_aperture.radius_px()).unwrap_or(PICK_APERTURE_MAX_PX); 696 let response = show_slider( 697 ctx, 698 Slider::new( 699 aperture_slider_id, 700 settings_slider_rect(body_rect), 701 strings::SETTINGS_PICK_APERTURE_LABEL, 702 initial, 703 range, 704 step, 705 ), 706 ); 707 paint.extend(response.paint); 708 response.changed.then(|| { 709 let clamped = response 710 .value 711 .clamp(PICK_APERTURE_MIN_PX, PICK_APERTURE_MAX_PX); 712 #[allow( 713 clippy::cast_sign_loss, 714 reason = "value clamped to [PICK_APERTURE_MIN_PX, PICK_APERTURE_MAX_PX] which is non-negative" 715 )] 716 let radius = clamped as u32; 717 Settings { 718 pick_aperture: PickAperture::new(radius), 719 hotkey_overrides: settings.hotkey_overrides.clone(), 720 } 721 }) 722} 723 724fn settings_label_rect(body: LayoutRect) -> LayoutRect { 725 LayoutRect::new( 726 LayoutPos::new( 727 LayoutPx::new(body.origin.x.value() + SETTINGS_DIALOG_GUTTER), 728 LayoutPx::new(body.origin.y.value() + SETTINGS_DIALOG_GUTTER), 729 ), 730 LayoutSize::new( 731 LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * SETTINGS_DIALOG_GUTTER), 732 LayoutPx::new(SETTINGS_LABEL_HEIGHT), 733 ), 734 ) 735} 736 737fn settings_hint_rect(body: LayoutRect) -> LayoutRect { 738 LayoutRect::new( 739 LayoutPos::new( 740 LayoutPx::new(body.origin.x.value() + SETTINGS_DIALOG_GUTTER), 741 LayoutPx::new( 742 body.origin.y.value() 743 + SETTINGS_DIALOG_GUTTER 744 + SETTINGS_LABEL_HEIGHT 745 + SETTINGS_LABEL_TO_HINT_GAP, 746 ), 747 ), 748 LayoutSize::new( 749 LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * SETTINGS_DIALOG_GUTTER), 750 LayoutPx::new(SETTINGS_HINT_HEIGHT), 751 ), 752 ) 753} 754 755fn settings_slider_rect(body: LayoutRect) -> LayoutRect { 756 LayoutRect::new( 757 LayoutPos::new( 758 LayoutPx::new(body.origin.x.value() + SETTINGS_DIALOG_GUTTER), 759 LayoutPx::new( 760 body.origin.y.value() 761 + SETTINGS_DIALOG_GUTTER 762 + SETTINGS_LABEL_HEIGHT 763 + SETTINGS_LABEL_TO_HINT_GAP 764 + SETTINGS_HINT_HEIGHT 765 + SETTINGS_HINT_TO_SLIDER_GAP, 766 ), 767 ), 768 LayoutSize::new( 769 LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * SETTINGS_DIALOG_GUTTER), 770 LayoutPx::new(SETTINGS_SLIDER_HEIGHT), 771 ), 772 ) 773} 774 775const KEYBOARD_DIALOG_WIDTH: f32 = 460.0; 776const KEYBOARD_DIALOG_HEIGHT: f32 = 420.0; 777const KEYBOARD_ROW_HEIGHT: f32 = 32.0; 778const KEYBOARD_ROW_GAP: f32 = 6.0; 779const KEYBOARD_CAPTURE_WIDTH: f32 = 180.0; 780const KEYBOARD_HINT_HEIGHT: f32 = 20.0; 781const KEYBOARD_HINT_TO_ROWS_GAP: f32 = 12.0; 782 783fn render_keyboard_dialog( 784 ctx: &mut FrameCtx<'_>, 785 viewport_size: LayoutSize, 786 ids: &ShellIds, 787 state: &mut ShellState, 788 settings: &Settings, 789 paints: &mut Vec<WidgetPaint>, 790) -> Option<Settings> { 791 if !state.keyboard_dialog_open { 792 return None; 793 } 794 let viewport = LayoutRect::new( 795 LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)), 796 viewport_size, 797 ); 798 let buttons = [ 799 DialogButton::secondary(ids.keyboard_dialog_reset, strings::SETTINGS_RESET), 800 DialogButton::primary(ids.keyboard_dialog_close, strings::SETTINGS_CLOSE), 801 ]; 802 let dialog_size = LayoutSize::new( 803 LayoutPx::new(KEYBOARD_DIALOG_WIDTH), 804 LayoutPx::new(KEYBOARD_DIALOG_HEIGHT), 805 ); 806 let mut next_overrides: Option<crate::hotkeys::HotkeyOverrides> = None; 807 let (response, _) = show_dialog( 808 ctx, 809 Dialog::new( 810 ids.keyboard_dialog, 811 viewport, 812 dialog_size, 813 strings::KEYBOARD_DIALOG_TITLE, 814 &buttons, 815 ), 816 |ctx, body_rect, paint| { 817 next_overrides = keyboard_dialog_body( 818 ctx, 819 body_rect, 820 ids.keyboard_dialog, 821 state, 822 &settings.hotkey_overrides, 823 paint, 824 ); 825 Some(()) 826 }, 827 ); 828 paints.extend(response.paint); 829 if response.dismissed || response.activated == Some(ids.keyboard_dialog_close) { 830 state.keyboard_dialog_open = false; 831 state.hotkey_capture.clear(); 832 } 833 if response.activated == Some(ids.keyboard_dialog_reset) { 834 return Some(Settings { 835 pick_aperture: settings.pick_aperture, 836 hotkey_overrides: crate::hotkeys::HotkeyOverrides::default(), 837 }); 838 } 839 next_overrides.map(|overrides| Settings { 840 pick_aperture: settings.pick_aperture, 841 hotkey_overrides: overrides, 842 }) 843} 844 845fn keyboard_dialog_header_rects(body_rect: LayoutRect) -> (LayoutRect, LayoutRect, f32) { 846 let gutter = SETTINGS_DIALOG_GUTTER; 847 let heading_rect = LayoutRect::new( 848 LayoutPos::new( 849 LayoutPx::new(body_rect.origin.x.value() + gutter), 850 LayoutPx::new(body_rect.origin.y.value() + gutter), 851 ), 852 LayoutSize::new( 853 LayoutPx::saturating_nonneg(body_rect.size.width.value() - 2.0 * gutter), 854 LayoutPx::new(KEYBOARD_HINT_HEIGHT), 855 ), 856 ); 857 let hint_rect = LayoutRect::new( 858 LayoutPos::new( 859 LayoutPx::new(body_rect.origin.x.value() + gutter), 860 LayoutPx::new( 861 body_rect.origin.y.value() + gutter + KEYBOARD_HINT_HEIGHT + KEYBOARD_ROW_GAP, 862 ), 863 ), 864 LayoutSize::new( 865 LayoutPx::saturating_nonneg(body_rect.size.width.value() - 2.0 * gutter), 866 LayoutPx::new(KEYBOARD_HINT_HEIGHT), 867 ), 868 ); 869 let rows_origin_y = body_rect.origin.y.value() 870 + gutter 871 + 2.0 * KEYBOARD_HINT_HEIGHT 872 + KEYBOARD_ROW_GAP 873 + KEYBOARD_HINT_TO_ROWS_GAP; 874 (heading_rect, hint_rect, rows_origin_y) 875} 876 877fn keyboard_row_rects(body_rect: LayoutRect, row_y: f32) -> (LayoutRect, LayoutRect) { 878 let gutter = SETTINGS_DIALOG_GUTTER; 879 let label_rect = LayoutRect::new( 880 LayoutPos::new( 881 LayoutPx::new(body_rect.origin.x.value() + gutter), 882 LayoutPx::new(row_y + 4.0), 883 ), 884 LayoutSize::new( 885 LayoutPx::saturating_nonneg( 886 body_rect.size.width.value() - 3.0 * gutter - KEYBOARD_CAPTURE_WIDTH, 887 ), 888 LayoutPx::new(KEYBOARD_ROW_HEIGHT), 889 ), 890 ); 891 let capture_rect = LayoutRect::new( 892 LayoutPos::new( 893 LayoutPx::new( 894 body_rect.origin.x.value() + body_rect.size.width.value() 895 - gutter 896 - KEYBOARD_CAPTURE_WIDTH, 897 ), 898 LayoutPx::new(row_y), 899 ), 900 LayoutSize::new( 901 LayoutPx::new(KEYBOARD_CAPTURE_WIDTH), 902 LayoutPx::new(KEYBOARD_ROW_HEIGHT), 903 ), 904 ); 905 (label_rect, capture_rect) 906} 907 908fn keyboard_dialog_body( 909 ctx: &mut FrameCtx<'_>, 910 body_rect: LayoutRect, 911 dialog_id: WidgetId, 912 state: &mut ShellState, 913 overrides: &crate::hotkeys::HotkeyOverrides, 914 paint: &mut Vec<WidgetPaint>, 915) -> Option<crate::hotkeys::HotkeyOverrides> { 916 let (heading_rect, hint_rect, rows_origin_y) = keyboard_dialog_header_rects(body_rect); 917 paint.push(WidgetPaint::Label { 918 rect: heading_rect, 919 text: LabelText::Key(strings::HOTKEY_SECTION_HEADING), 920 color: ctx.theme().colors.text_primary(), 921 role: ctx.theme().typography.label, 922 }); 923 paint.push(WidgetPaint::Label { 924 rect: hint_rect, 925 text: LabelText::Key(strings::HOTKEY_RECORDING_HINT), 926 color: ctx.theme().colors.text_secondary(), 927 role: ctx.theme().typography.caption, 928 }); 929 let entries = crate::hotkeys::remap_entries(); 930 let row_advance = KEYBOARD_ROW_HEIGHT + KEYBOARD_ROW_GAP; 931 let captures_changed = entries 932 .iter() 933 .fold( 934 ( 935 rows_origin_y, 936 Vec::<(bone_ui::hotkey::ActionId, bone_ui::hotkey::KeyChord)>::new(), 937 ), 938 |(row_y, mut acc), entry| { 939 let (label_rect, capture_rect) = keyboard_row_rects(body_rect, row_y); 940 paint.push(WidgetPaint::Label { 941 rect: label_rect, 942 text: LabelText::Key(entry.label), 943 color: ctx.theme().colors.text_primary(), 944 role: ctx.theme().typography.label, 945 }); 946 let chord_now = current_chord(overrides, entry); 947 let placeholder = chord_now.map_or(strings::HOTKEY_UNBOUND_LABEL, |_| entry.label); 948 let capture_state = state.hotkey_capture.entry(entry.action).or_insert_with(|| { 949 HotkeyCaptureState { 950 recording: false, 951 chord: chord_now, 952 } 953 }); 954 if capture_state.chord.is_none() { 955 capture_state.chord = chord_now; 956 } 957 let response = show_hotkey_capture( 958 ctx, 959 HotkeyCapture::new( 960 capture_widget_id(dialog_id, entry.action), 961 capture_rect, 962 placeholder, 963 strings::HOTKEY_RECORDING_PROMPT, 964 capture_state, 965 ), 966 ); 967 paint.extend(response.paint); 968 if let Some(chord) = response.captured { 969 acc.push((entry.action, chord)); 970 } 971 (row_y + row_advance, acc) 972 }, 973 ) 974 .1; 975 if captures_changed.is_empty() { 976 return None; 977 } 978 let next = captures_changed 979 .into_iter() 980 .fold(overrides.clone(), |mut acc, (action, chord)| { 981 acc.set(action, chord); 982 acc 983 }); 984 Some(next) 985} 986 987fn current_chord( 988 overrides: &crate::hotkeys::HotkeyOverrides, 989 entry: &crate::hotkeys::RemapEntry, 990) -> Option<bone_ui::hotkey::KeyChord> { 991 overrides.lookup(entry.action).or(entry.default_chord) 992} 993 994fn capture_widget_id(dialog_id: WidgetId, action: bone_ui::hotkey::ActionId) -> WidgetId { 995 dialog_id.child_indexed(WidgetKey::new("capture"), u64::from(action.get().get())) 996} 997 998fn partition_overlay( 999 paints: Vec<WidgetPaint>, 1000 theme: &Theme, 1001) -> (Vec<WidgetPaint>, Vec<WidgetPaint>) { 1002 paints.into_iter().fold( 1003 (Vec::new(), Vec::new()), 1004 |(mut main, mut overlay), paint| { 1005 match paint { 1006 WidgetPaint::Tooltip { 1007 rect, 1008 text, 1009 elevation, 1010 .. 1011 } => { 1012 overlay.push(WidgetPaint::Surface { 1013 rect, 1014 fill: theme.colors.surface(elevation.surface), 1015 border: elevation.border, 1016 radius: theme.radius.sm, 1017 elevation: Some(elevation), 1018 }); 1019 overlay.push(WidgetPaint::Label { 1020 rect, 1021 text, 1022 color: theme.colors.text_primary(), 1023 role: theme.typography.caption, 1024 }); 1025 } 1026 other => main.push(other), 1027 } 1028 (main, overlay) 1029 }, 1030 ) 1031} 1032 1033fn active_sketch<'a>(document: &'a Document, mode: &Mode) -> Option<&'a Sketch> { 1034 match mode { 1035 Mode::Sketch { sketch_id, .. } => document.sketch(*sketch_id), 1036 Mode::Idle => None, 1037 } 1038} 1039 1040fn resolve_activated_relation( 1041 activated_widget: Option<WidgetId>, 1042 relation_index: &BTreeMap<WidgetId, RelationKind>, 1043 sketch: Option<&Sketch>, 1044 selection: &[SketchEntityId], 1045) -> Option<SketchRelation> { 1046 let id = activated_widget?; 1047 let kind = relation_index.get(&id).copied()?; 1048 let sketch = sketch?; 1049 match eligibility(kind, sketch, selection) { 1050 Eligibility::Eligible(rel) => Some(rel), 1051 Eligibility::Disabled(_) => None, 1052 } 1053} 1054 1055fn resolve_activated_dimension( 1056 activated_widget: Option<WidgetId>, 1057 smart_dimension_id: WidgetId, 1058 sketch: Option<&Sketch>, 1059 selection: &[SketchEntityId], 1060) -> Option<PendingDimension> { 1061 if activated_widget? != smart_dimension_id { 1062 return None; 1063 } 1064 match smart_dimension::eligibility(sketch?, selection) { 1065 smart_dimension::Eligibility::Eligible(req) => Some(req), 1066 smart_dimension::Eligibility::Disabled(_) => None, 1067 } 1068} 1069 1070fn smart_dimension_tool_item( 1071 id: WidgetId, 1072 sketch: Option<&Sketch>, 1073 selection: &[SketchEntityId], 1074 sketch_disabled: bool, 1075) -> ToolbarItem { 1076 let item = ToolbarItem::new(id, strings::TOOL_SMART_DIMENSION); 1077 if sketch_disabled { 1078 return item.disabled(true); 1079 } 1080 let Some(sketch) = sketch else { 1081 return item.disabled(true); 1082 }; 1083 match smart_dimension::eligibility(sketch, selection) { 1084 smart_dimension::Eligibility::Eligible(_) => item, 1085 smart_dimension::Eligibility::Disabled(reason) => item.disabled(true).with_tooltip(reason), 1086 } 1087} 1088 1089#[allow( 1090 clippy::too_many_arguments, 1091 reason = "menu bar render bundles a handful of shell-owned references" 1092)] 1093fn render_menu_bar( 1094 ctx: &mut FrameCtx<'_>, 1095 rect: LayoutRect, 1096 ids: &ShellIds, 1097 state: &mut MenuBarState, 1098 document: &Document, 1099 is_sketch: bool, 1100 overrides: &crate::hotkeys::HotkeyOverrides, 1101 paints: &mut Vec<WidgetPaint>, 1102 popover_paints: &mut Vec<WidgetPaint>, 1103) -> Option<MenuAction> { 1104 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 1105 return None; 1106 } 1107 let entries = build_menu_entries(ids, is_sketch, overrides); 1108 let response = show_menu_bar( 1109 ctx, 1110 MenuBar::new(ids.menu_bar, rect, strings::MENU_BAR_LABEL, &entries, state) 1111 .with_trailing_label(LabelText::Owned(document.name().to_owned())), 1112 ); 1113 paints.extend(response.paint); 1114 popover_paints.extend(response.popover_paint); 1115 response.activated.and_then(|id| ids.menu_action_for(id)) 1116} 1117 1118#[allow( 1119 clippy::too_many_lines, 1120 reason = "menu entries are flat data; splitting would scatter related strings" 1121)] 1122fn build_menu_entries( 1123 ids: &ShellIds, 1124 is_sketch: bool, 1125 overrides: &crate::hotkeys::HotkeyOverrides, 1126) -> Vec<MenuBarEntry> { 1127 let placeholder = |menu_id: WidgetId, key: &'static str| MenuItem::Action { 1128 id: menu_id.child(WidgetKey::new(key)), 1129 label: strings::MENU_PLACEHOLDER_COMING_SOON, 1130 shortcut: None, 1131 disabled: true, 1132 }; 1133 let action_with_accel = 1134 |id: WidgetId, label: StringKey, accel: Option<bone_ui::hotkey::ActionId>| { 1135 let shortcut = accel 1136 .and_then(|a| crate::hotkeys::accelerator_label(a, overrides)) 1137 .map(LabelText::Owned); 1138 MenuItem::Action { 1139 id, 1140 label, 1141 shortcut, 1142 disabled: false, 1143 } 1144 }; 1145 let file = ids.menu_file; 1146 let mut entries = vec![ 1147 MenuBarEntry { 1148 id: file, 1149 label: strings::MENU_FILE, 1150 items: vec![ 1151 action_with_accel( 1152 ids.menu_file_new, 1153 strings::MENU_FILE_NEW, 1154 Some(crate::hotkeys::NEW_DOCUMENT_ACTION), 1155 ), 1156 action_with_accel( 1157 ids.menu_file_open, 1158 strings::MENU_FILE_OPEN, 1159 Some(crate::hotkeys::OPEN_DOCUMENT_ACTION), 1160 ), 1161 action_with_accel( 1162 ids.menu_file_save, 1163 strings::MENU_FILE_SAVE, 1164 Some(crate::hotkeys::SAVE_DOCUMENT_ACTION), 1165 ), 1166 action_with_accel(ids.menu_file_save_as, strings::MENU_FILE_SAVE_AS, None), 1167 MenuItem::Separator, 1168 action_with_accel( 1169 ids.menu_file_quit, 1170 strings::MENU_FILE_QUIT, 1171 Some(crate::hotkeys::QUIT_ACTION), 1172 ), 1173 ], 1174 }, 1175 MenuBarEntry { 1176 id: ids.menu_edit, 1177 label: strings::MENU_EDIT, 1178 items: vec![ 1179 action_with_accel( 1180 ids.menu_edit_undo, 1181 strings::MENU_EDIT_UNDO, 1182 Some(crate::sketch_mode::UNDO_ACTION), 1183 ), 1184 action_with_accel( 1185 ids.menu_edit_redo, 1186 strings::MENU_EDIT_REDO, 1187 Some(crate::sketch_mode::REDO_ACTION), 1188 ), 1189 ], 1190 }, 1191 MenuBarEntry { 1192 id: ids.menu_view, 1193 label: strings::MENU_VIEW, 1194 items: vec![action_with_accel( 1195 ids.menu_view_zoom_fit, 1196 strings::MENU_VIEW_ZOOM_FIT, 1197 Some(crate::hotkeys::ZOOM_FIT_ACTION), 1198 )], 1199 }, 1200 MenuBarEntry { 1201 id: ids.menu_insert, 1202 label: strings::MENU_INSERT, 1203 items: vec![placeholder(ids.menu_insert, "soon")], 1204 }, 1205 MenuBarEntry { 1206 id: ids.menu_tools, 1207 label: strings::MENU_TOOLS, 1208 items: vec![ 1209 action_with_accel(ids.menu_tools_options, strings::MENU_TOOLS_OPTIONS, None), 1210 action_with_accel(ids.menu_tools_keyboard, strings::MENU_TOOLS_KEYBOARD, None), 1211 ], 1212 }, 1213 ]; 1214 if is_sketch { 1215 entries.push(MenuBarEntry { 1216 id: ids.menu_sketch, 1217 label: strings::MENU_SKETCH, 1218 items: vec![action_with_accel( 1219 ids.menu_sketch_exit, 1220 strings::MENU_SKETCH_EXIT, 1221 Some(crate::sketch_mode::EXIT_SKETCH_ACTION), 1222 )], 1223 }); 1224 } 1225 entries.extend([ 1226 MenuBarEntry { 1227 id: ids.menu_window, 1228 label: strings::MENU_WINDOW, 1229 items: vec![placeholder(ids.menu_window, "soon")], 1230 }, 1231 MenuBarEntry { 1232 id: ids.menu_help, 1233 label: strings::MENU_HELP, 1234 items: vec![placeholder(ids.menu_help, "soon")], 1235 }, 1236 ]); 1237 entries 1238} 1239 1240#[derive(Copy, Clone)] 1241struct RibbonInputs<'a> { 1242 rect: LayoutRect, 1243 ribbon: WidgetId, 1244 ribbon_smart_dimension: WidgetId, 1245 mode: &'a Mode, 1246 sketch: Option<&'a Sketch>, 1247 selection: &'a [SketchEntityId], 1248} 1249 1250fn render_ribbon( 1251 ctx: &mut FrameCtx<'_>, 1252 inputs: RibbonInputs<'_>, 1253 paints: &mut Vec<WidgetPaint>, 1254 popover_paints: &mut Vec<WidgetPaint>, 1255 overflow_open: &mut BTreeMap<WidgetId, bool>, 1256) -> Option<WidgetId> { 1257 let RibbonInputs { 1258 rect, 1259 ribbon, 1260 ribbon_smart_dimension, 1261 mode, 1262 sketch, 1263 selection, 1264 } = inputs; 1265 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 1266 return None; 1267 } 1268 let active_tool = match mode { 1269 Mode::Sketch { session, .. } => session.tool, 1270 Mode::Idle => None, 1271 }; 1272 let tools_disabled = !mode.is_sketch(); 1273 let label_font_size_px = ctx.theme().typography.caption.size.as_px_f32(); 1274 let size_item = |item: ToolbarItem, min_width: LayoutPx| -> ToolbarItem { 1275 let resolved = ctx.strings.resolve(item.label); 1276 let width = estimate_label_width(resolved, label_font_size_px, min_width); 1277 item.with_width(width) 1278 }; 1279 let large_min = RibbonIconSize::Large.item_px(); 1280 let small_min = RibbonIconSize::Small.item_px(); 1281 let entity_items: Vec<ToolbarItem> = SketchTool::ENTITIES 1282 .iter() 1283 .copied() 1284 .map(|t| { 1285 size_item( 1286 ToolbarItem::new(tool_widget_id(ribbon, t), tool_label(t)) 1287 .active(active_tool == Some(t)) 1288 .disabled(tools_disabled), 1289 large_min, 1290 ) 1291 }) 1292 .collect(); 1293 let dimension_items = vec![size_item( 1294 smart_dimension_tool_item(ribbon_smart_dimension, sketch, selection, tools_disabled), 1295 large_min, 1296 )]; 1297 let relation_items: Vec<ToolbarItem> = 1298 relation_tool_buttons(ribbon, sketch, selection, tools_disabled) 1299 .into_iter() 1300 .map(|item| size_item(item, small_min)) 1301 .collect(); 1302 let tab_id = ribbon.child(WidgetKey::new("tab.sketch")); 1303 let groups = build_sketch_groups( 1304 ribbon, 1305 entity_items, 1306 relation_items, 1307 dimension_items, 1308 large_min, 1309 small_min, 1310 overflow_open, 1311 ); 1312 let placeholder_tab = |key: &'static str, label: StringKey| { 1313 RibbonTab::new(ribbon.child(WidgetKey::new(key)), label, Vec::new()).disabled(true) 1314 }; 1315 let tabs = [ 1316 placeholder_tab("tab.features", strings::RIBBON_TAB_FEATURES), 1317 RibbonTab::new(tab_id, strings::RIBBON_TAB_SKETCH, groups), 1318 placeholder_tab("tab.surfaces", strings::RIBBON_TAB_SURFACES), 1319 placeholder_tab("tab.evaluate", strings::RIBBON_TAB_EVALUATE), 1320 ]; 1321 let pointer_pressed = !ctx.input.buttons_pressed.is_empty(); 1322 let response = show_ribbon( 1323 ctx, 1324 Ribbon::new(ribbon, rect, strings::RIBBON_LABEL, &tabs, tab_id), 1325 ); 1326 process_ribbon_response( 1327 response, 1328 paints, 1329 popover_paints, 1330 overflow_open, 1331 pointer_pressed, 1332 ) 1333} 1334 1335fn build_sketch_groups( 1336 ribbon: WidgetId, 1337 entity_items: Vec<ToolbarItem>, 1338 relation_items: Vec<ToolbarItem>, 1339 dimension_items: Vec<ToolbarItem>, 1340 large_min: LayoutPx, 1341 small_min: LayoutPx, 1342 overflow_open: &BTreeMap<WidgetId, bool>, 1343) -> Vec<RibbonGroup> { 1344 let dimensions_preferred = group_width_for(&dimension_items, large_min); 1345 let entities_id = ribbon.child(WidgetKey::new("group.entities")); 1346 let relations_id = ribbon.child(WidgetKey::new("group.relations")); 1347 let dimensions_id = ribbon.child(WidgetKey::new("group.dimensions")); 1348 let open_of = |id: WidgetId| overflow_open.get(&id).copied().unwrap_or(false); 1349 vec![ 1350 RibbonGroup { 1351 id: entities_id, 1352 label: strings::RIBBON_GROUP_ENTITIES, 1353 min_width: group_min_width(large_min, entity_items.len()), 1354 width: group_width_for(&entity_items, large_min), 1355 items: entity_items, 1356 icon_size: RibbonIconSize::Large, 1357 overflow_open: open_of(entities_id), 1358 overflow_label: Some(strings::TOOLBAR_OVERFLOW), 1359 }, 1360 RibbonGroup { 1361 id: relations_id, 1362 label: strings::RIBBON_GROUP_RELATIONS, 1363 min_width: group_min_width(small_min, relation_items.len()), 1364 width: group_width_for(&relation_items, small_min), 1365 items: relation_items, 1366 icon_size: RibbonIconSize::Small, 1367 overflow_open: open_of(relations_id), 1368 overflow_label: Some(strings::TOOLBAR_OVERFLOW), 1369 }, 1370 RibbonGroup { 1371 id: dimensions_id, 1372 label: strings::RIBBON_GROUP_DIMENSIONS, 1373 min_width: dimensions_preferred, 1374 width: dimensions_preferred, 1375 items: dimension_items, 1376 icon_size: RibbonIconSize::Large, 1377 overflow_open: open_of(dimensions_id), 1378 overflow_label: Some(strings::TOOLBAR_OVERFLOW), 1379 }, 1380 ] 1381} 1382 1383fn process_ribbon_response( 1384 response: bone_ui::widgets::RibbonResponse, 1385 paints: &mut Vec<WidgetPaint>, 1386 popover_paints: &mut Vec<WidgetPaint>, 1387 overflow_open: &mut BTreeMap<WidgetId, bool>, 1388 pointer_pressed: bool, 1389) -> Option<WidgetId> { 1390 paints.extend(response.paint); 1391 popover_paints.extend(response.popover_paint); 1392 response.overflow_toggled.iter().for_each(|id| { 1393 let entry = overflow_open.entry(*id).or_insert(false); 1394 *entry = !*entry; 1395 }); 1396 if let Some(toggled_id) = response.overflow_toggled.first().copied() 1397 && overflow_open.get(&toggled_id).copied().unwrap_or(false) 1398 { 1399 overflow_open 1400 .iter_mut() 1401 .filter(|(k, _)| **k != toggled_id) 1402 .for_each(|(_, v)| *v = false); 1403 } 1404 let any_open = overflow_open.values().any(|v| *v); 1405 let activated_anything = response.activated_tool.is_some(); 1406 let outside_click = pointer_pressed 1407 && any_open 1408 && response.overflow_toggled.is_empty() 1409 && !response.popup_consumed_click; 1410 if activated_anything || outside_click { 1411 overflow_open.values_mut().for_each(|v| *v = false); 1412 } 1413 response.activated_tool 1414} 1415 1416#[derive(Clone, Debug, PartialEq)] 1417pub struct SketchRenameRequest { 1418 pub id: SketchId, 1419 pub label: String, 1420} 1421 1422struct FeatureTreeOutcome { 1423 double_activated: Option<WidgetId>, 1424 sketch_activated: Option<SketchId>, 1425 sketch_rename: Option<SketchRenameRequest>, 1426} 1427 1428fn sketch_widget_id(part_id: WidgetId, sketch_id: SketchId) -> WidgetId { 1429 part_id.child_indexed(WidgetKey::new("sketch"), sketch_id.as_u64()) 1430} 1431 1432fn render_feature_tree( 1433 ctx: &mut FrameCtx<'_>, 1434 rect: LayoutRect, 1435 tree_id: WidgetId, 1436 part_id: WidgetId, 1437 state: &mut TreeViewState, 1438 document: &Document, 1439 paints: &mut Vec<WidgetPaint>, 1440) -> FeatureTreeOutcome { 1441 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 1442 return FeatureTreeOutcome { 1443 double_activated: None, 1444 sketch_activated: None, 1445 sketch_rename: None, 1446 }; 1447 } 1448 let leaf = |key: &'static str, label: StringKey| { 1449 TreeNode::leaf(part_id.child(WidgetKey::new(key)), label) 1450 }; 1451 let feature_leaf = 1452 |key: &'static str, label: StringKey| leaf(key, label).with_glyph(GlyphMark::TreeFeature); 1453 let placeholder = |key: &'static str, label: StringKey| feature_leaf(key, label).disabled(true); 1454 let plane_leaf = 1455 |key: &'static str, label: StringKey| leaf(key, label).with_glyph(GlyphMark::TreePlane); 1456 let sketch_rows: Vec<(SketchId, WidgetId, TreeNode)> = document 1457 .sketches() 1458 .map(|(sketch_id, _)| { 1459 let widget_id = sketch_widget_id(part_id, sketch_id); 1460 let label = document.sketch_label(sketch_id).unwrap_or("").to_owned(); 1461 let node = TreeNode::leaf_owned(widget_id, label).with_glyph(GlyphMark::TreeSketch); 1462 (sketch_id, widget_id, node) 1463 }) 1464 .collect(); 1465 let renamable: Vec<WidgetId> = sketch_rows.iter().map(|(_, w, _)| *w).collect(); 1466 let widget_to_sketch: BTreeMap<WidgetId, SketchId> = 1467 sketch_rows.iter().map(|(s, w, _)| (*w, *s)).collect(); 1468 let children: Vec<TreeNode> = [ 1469 placeholder("history", strings::FEATURE_HISTORY), 1470 placeholder("sensors", strings::FEATURE_SENSORS), 1471 placeholder("annotations", strings::FEATURE_ANNOTATIONS), 1472 placeholder("solid_bodies", strings::FEATURE_SOLID_BODIES), 1473 placeholder("material", strings::FEATURE_MATERIAL), 1474 plane_leaf("plane.xy", strings::FEATURE_PLANE_XY), 1475 plane_leaf("plane.yz", strings::FEATURE_PLANE_YZ), 1476 plane_leaf("plane.zx", strings::FEATURE_PLANE_ZX), 1477 leaf("origin", strings::FEATURE_ORIGIN).with_glyph(GlyphMark::RadioDot), 1478 ] 1479 .into_iter() 1480 .chain(sketch_rows.into_iter().map(|(_, _, node)| node)) 1481 .collect(); 1482 let part = TreeNode::parent_owned(part_id, document.name().to_owned(), children); 1483 let roots = [part]; 1484 let response = show_tree_view( 1485 ctx, 1486 TreeView::new(tree_id, rect, strings::FEATURE_TREE_LABEL, &roots, state) 1487 .renamable(&renamable), 1488 ); 1489 paints.extend(response.paint); 1490 let sketch_activated = response 1491 .double_activated 1492 .and_then(|id| widget_to_sketch.get(&id).copied()); 1493 let sketch_rename = response 1494 .rename_committed 1495 .and_then(|RenameCommit { id, text }| { 1496 widget_to_sketch 1497 .get(&id) 1498 .copied() 1499 .map(|sketch_id| SketchRenameRequest { 1500 id: sketch_id, 1501 label: text, 1502 }) 1503 }); 1504 FeatureTreeOutcome { 1505 double_activated: response.double_activated, 1506 sketch_activated, 1507 sketch_rename, 1508 } 1509} 1510 1511#[derive(Copy, Clone)] 1512struct PropertyState<'a> { 1513 mode: &'a Mode, 1514 sketch: Option<&'a Sketch>, 1515 selection: &'a Selection, 1516} 1517 1518fn render_property_pane( 1519 ctx: &mut FrameCtx<'_>, 1520 rect: LayoutRect, 1521 id: WidgetId, 1522 clipboard: &mut MemoryClipboard, 1523 dim_property: &mut Option<DimPropertyEditor>, 1524 state: PropertyState<'_>, 1525 paints: &mut Vec<WidgetPaint>, 1526) -> Option<DimensionEdit> { 1527 let in_sketch = matches!(state.mode, Mode::Sketch { .. }); 1528 let active_sketch_id = match state.mode { 1529 Mode::Sketch { sketch_id, .. } => Some(*sketch_id), 1530 Mode::Idle => None, 1531 }; 1532 let resolved = state 1533 .sketch 1534 .filter(|_| in_sketch) 1535 .and_then(|s| resolve_selection_target(s, state.selection).map(|t| (s, t))); 1536 if !matches!(resolved, Some((_, SelectionTarget::Dimension(_, _)))) { 1537 *dim_property = None; 1538 } 1539 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 1540 return None; 1541 } 1542 match resolved { 1543 Some((sketch, SelectionTarget::Entity(entity))) => { 1544 let mut editors = entity_editors(ctx.strings, entity, sketch); 1545 render_static_rows(ctx, rect, id, clipboard, &mut editors, paints); 1546 None 1547 } 1548 Some((sketch, SelectionTarget::Relation(rel))) => { 1549 let mut editors = relation_editors(ctx.strings, rel, sketch); 1550 render_static_rows(ctx, rect, id, clipboard, &mut editors, paints); 1551 None 1552 } 1553 Some((sketch, SelectionTarget::Dimension(dim_id, dim))) => { 1554 let sketch_id = active_sketch_id?; 1555 render_dimension_rows( 1556 ctx, 1557 rect, 1558 id, 1559 clipboard, 1560 dim_property, 1561 sketch_id, 1562 dim_id, 1563 dim, 1564 sketch, 1565 paints, 1566 ) 1567 } 1568 None => { 1569 let mut editors = vec![row_editor(strings::PROPERTY_PANE_NO_SELECTION, "")]; 1570 render_static_rows(ctx, rect, id, clipboard, &mut editors, paints); 1571 None 1572 } 1573 } 1574} 1575 1576enum SelectionTarget { 1577 Entity(SketchEntity), 1578 Relation(SketchRelation), 1579 Dimension(SketchDimensionId, SketchDimension), 1580} 1581 1582fn resolve_selection_target(sketch: &Sketch, selection: &Selection) -> Option<SelectionTarget> { 1583 match selection { 1584 Selection::Entities(ids) => match ids.as_slice() { 1585 [id] => sketch 1586 .entities() 1587 .get(*id) 1588 .copied() 1589 .map(SelectionTarget::Entity), 1590 _ => None, 1591 }, 1592 Selection::Relation(id) => sketch 1593 .relations() 1594 .get(*id) 1595 .copied() 1596 .map(SelectionTarget::Relation), 1597 Selection::Dimension(id) => sketch 1598 .dimensions() 1599 .get(*id) 1600 .copied() 1601 .map(|d| SelectionTarget::Dimension(*id, d)), 1602 } 1603} 1604 1605fn render_static_rows( 1606 ctx: &mut FrameCtx<'_>, 1607 rect: LayoutRect, 1608 id: WidgetId, 1609 clipboard: &mut MemoryClipboard, 1610 editors: &mut [PropertyRowSpec], 1611 paints: &mut Vec<WidgetPaint>, 1612) { 1613 let mut rows: Vec<PropertyRow<'_>> = editors 1614 .iter_mut() 1615 .map(|(row_id, label, editor)| PropertyRow { 1616 id: *row_id, 1617 label: *label, 1618 editor: editor.as_mut(), 1619 read_only: true, 1620 }) 1621 .collect(); 1622 let response = show_property_grid( 1623 ctx, 1624 PropertyGrid::new(id, rect, strings::PROPERTY_PANE_LABEL, &mut rows), 1625 clipboard, 1626 ); 1627 paints.extend(response.paint); 1628} 1629 1630#[allow( 1631 clippy::too_many_arguments, 1632 reason = "splitting the property-pane render call harms locality" 1633)] 1634fn render_dimension_rows( 1635 ctx: &mut FrameCtx<'_>, 1636 rect: LayoutRect, 1637 id: WidgetId, 1638 clipboard: &mut MemoryClipboard, 1639 dim_property: &mut Option<DimPropertyEditor>, 1640 sketch_id: SketchId, 1641 dim_id: SketchDimensionId, 1642 dim: SketchDimension, 1643 sketch: &Sketch, 1644 paints: &mut Vec<WidgetPaint>, 1645) -> Option<DimensionEdit> { 1646 let driving = matches!(dim.kind(), DimensionKind::Driving); 1647 let kind_label = dimension_kind_label(dim); 1648 let kind_value_key = dimension_drive_key(dim.kind()); 1649 let value_row_id = WidgetId::ROOT 1650 .child(WidgetKey::new("props.dim")) 1651 .child(WidgetKey::new("value")); 1652 let dim_property_slot = sync_dim_editor(dim_property, sketch_id, dim_id, dim); 1653 let mut static_specs: Vec<PropertyRowSpec> = vec![row_editor( 1654 strings::PROPERTY_ROW_DIM_KIND, 1655 ctx.strings.resolve(kind_label).to_owned(), 1656 )]; 1657 static_specs.extend(dimension_static_rows(ctx.strings, dim, sketch)); 1658 static_specs.push(row_editor( 1659 strings::PROPERTY_ROW_DIM_DRIVES, 1660 ctx.strings.resolve(kind_value_key).to_owned(), 1661 )); 1662 let mut rows: Vec<PropertyRow<'_>> = static_specs 1663 .iter_mut() 1664 .map(|(row_id, label, editor)| PropertyRow { 1665 id: *row_id, 1666 label: *label, 1667 editor: editor.as_mut(), 1668 read_only: true, 1669 }) 1670 .collect(); 1671 let value_label = match dim { 1672 SketchDimension::Linear { .. } => strings::PROPERTY_ROW_DIM_LENGTH, 1673 SketchDimension::Radius { .. } => strings::PROPERTY_ROW_RADIUS, 1674 SketchDimension::Diameter { .. } => strings::PROPERTY_ROW_DIM_DIAMETER, 1675 SketchDimension::Angular { .. } => strings::PROPERTY_ROW_DIM_ANGLE, 1676 }; 1677 let editor_ref: &mut dyn PropertyEditor = match dim_property_slot { 1678 DimPropertyEditor::Length { editor, .. } => editor, 1679 DimPropertyEditor::Angle { editor, .. } => editor, 1680 }; 1681 rows.push(PropertyRow { 1682 id: value_row_id, 1683 label: value_label, 1684 editor: editor_ref, 1685 read_only: !driving, 1686 }); 1687 let response = show_property_grid( 1688 ctx, 1689 PropertyGrid::new(id, rect, strings::PROPERTY_PANE_LABEL, &mut rows), 1690 clipboard, 1691 ); 1692 paints.extend(response.paint); 1693 if !driving || !response.changed_rows.contains(&value_row_id) { 1694 return None; 1695 } 1696 Some(DimensionEdit { 1697 id: dim_id, 1698 value: match dim_property_slot { 1699 DimPropertyEditor::Length { editor, .. } => DimensionValue::Length(editor.value), 1700 DimPropertyEditor::Angle { editor, .. } => DimensionValue::Angle(editor.value), 1701 }, 1702 }) 1703} 1704 1705fn sync_dim_editor( 1706 slot: &mut Option<DimPropertyEditor>, 1707 sketch_id: SketchId, 1708 dim_id: SketchDimensionId, 1709 dim: SketchDimension, 1710) -> &mut DimPropertyEditor { 1711 let editor = match (slot.take(), dim.value()) { 1712 ( 1713 Some(DimPropertyEditor::Length { 1714 sketch_id: prev_sketch, 1715 id, 1716 mut editor, 1717 }), 1718 DimensionValue::Length(v), 1719 ) if prev_sketch == sketch_id && id == dim_id => { 1720 editor.value = v; 1721 DimPropertyEditor::Length { 1722 sketch_id, 1723 id, 1724 editor, 1725 } 1726 } 1727 ( 1728 Some(DimPropertyEditor::Angle { 1729 sketch_id: prev_sketch, 1730 id, 1731 mut editor, 1732 }), 1733 DimensionValue::Angle(v), 1734 ) if prev_sketch == sketch_id && id == dim_id => { 1735 editor.value = v; 1736 DimPropertyEditor::Angle { 1737 sketch_id, 1738 id, 1739 editor, 1740 } 1741 } 1742 (_, DimensionValue::Length(v)) => DimPropertyEditor::Length { 1743 sketch_id, 1744 id: dim_id, 1745 editor: LengthEditor::new(v), 1746 }, 1747 (_, DimensionValue::Angle(v)) => DimPropertyEditor::Angle { 1748 sketch_id, 1749 id: dim_id, 1750 editor: AngleEditor::new(v), 1751 }, 1752 }; 1753 slot.insert(editor) 1754} 1755 1756fn dimension_kind_label(dim: SketchDimension) -> StringKey { 1757 match dim { 1758 SketchDimension::Linear { .. } => strings::PROPERTY_KIND_DIM_LINEAR, 1759 SketchDimension::Radius { .. } => strings::PROPERTY_KIND_DIM_RADIUS, 1760 SketchDimension::Diameter { .. } => strings::PROPERTY_KIND_DIM_DIAMETER, 1761 SketchDimension::Angular { .. } => strings::PROPERTY_KIND_DIM_ANGULAR, 1762 } 1763} 1764 1765fn dimension_drive_key(kind: DimensionKind) -> StringKey { 1766 match kind { 1767 DimensionKind::Driving => strings::PROPERTY_VALUE_DRIVING, 1768 DimensionKind::Driven => strings::PROPERTY_VALUE_DRIVEN, 1769 } 1770} 1771 1772fn dimension_static_rows( 1773 strings_table: &StringTable, 1774 dim: SketchDimension, 1775 sketch: &Sketch, 1776) -> Vec<PropertyRowSpec> { 1777 let label = |id: SketchEntityId| endpoint_or_entity_label(strings_table, sketch, id); 1778 match dim { 1779 SketchDimension::Linear { a, b, .. } | SketchDimension::Angular { a, b, .. } => vec![ 1780 row_editor(strings::PROPERTY_ROW_FROM, label(a)), 1781 row_editor(strings::PROPERTY_ROW_TO, label(b)), 1782 ], 1783 SketchDimension::Radius { target, .. } | SketchDimension::Diameter { target, .. } => { 1784 vec![row_editor(strings::PROPERTY_ROW_TARGET, label(target))] 1785 } 1786 } 1787} 1788 1789fn relation_editors( 1790 strings_table: &StringTable, 1791 rel: SketchRelation, 1792 sketch: &Sketch, 1793) -> Vec<PropertyRowSpec> { 1794 let kind_key = relation_kind_key(rel); 1795 let kind = row_editor( 1796 strings::PROPERTY_ROW_KIND, 1797 strings_table.resolve(kind_key).to_owned(), 1798 ); 1799 let label = |id: SketchEntityId| endpoint_or_entity_label(strings_table, sketch, id); 1800 let mut specs = vec![kind]; 1801 match rel { 1802 SketchRelation::Coincident(a, b) 1803 | SketchRelation::Parallel(a, b) 1804 | SketchRelation::Perpendicular(a, b) 1805 | SketchRelation::Tangent(a, b) 1806 | SketchRelation::Equal(a, b) 1807 | SketchRelation::Concentric(a, b) => { 1808 specs.push(row_editor(strings::PROPERTY_ROW_FIRST, label(a))); 1809 specs.push(row_editor(strings::PROPERTY_ROW_SECOND, label(b))); 1810 } 1811 SketchRelation::Midpoint { point, line } => { 1812 specs.push(row_editor(strings::PROPERTY_ROW_POINT, label(point))); 1813 specs.push(row_editor(strings::PROPERTY_ROW_LINE, label(line))); 1814 } 1815 SketchRelation::Symmetric { a, b, axis } => { 1816 specs.push(row_editor(strings::PROPERTY_ROW_FIRST, label(a))); 1817 specs.push(row_editor(strings::PROPERTY_ROW_SECOND, label(b))); 1818 specs.push(row_editor(strings::PROPERTY_ROW_AXIS, label(axis))); 1819 } 1820 SketchRelation::Horizontal(a) | SketchRelation::Vertical(a) | SketchRelation::Fix(a) => { 1821 specs.push(row_editor(strings::PROPERTY_ROW_TARGET, label(a))); 1822 } 1823 } 1824 specs 1825 .into_iter() 1826 .enumerate() 1827 .map(|(idx, (_default_id, label, editor))| { 1828 let row_id = WidgetId::ROOT 1829 .child(WidgetKey::new("props.relation")) 1830 .child_indexed(WidgetKey::new("row"), idx as u64); 1831 (row_id, label, editor) 1832 }) 1833 .collect() 1834} 1835 1836fn relation_kind_key(rel: SketchRelation) -> StringKey { 1837 match rel { 1838 SketchRelation::Coincident(_, _) => strings::TOOL_COINCIDENT, 1839 SketchRelation::Horizontal(_) => strings::TOOL_HORIZONTAL, 1840 SketchRelation::Vertical(_) => strings::TOOL_VERTICAL, 1841 SketchRelation::Parallel(_, _) => strings::TOOL_PARALLEL, 1842 SketchRelation::Perpendicular(_, _) => strings::TOOL_PERPENDICULAR, 1843 SketchRelation::Tangent(_, _) => strings::TOOL_TANGENT, 1844 SketchRelation::Equal(_, _) => strings::TOOL_EQUAL, 1845 SketchRelation::Concentric(_, _) => strings::TOOL_CONCENTRIC, 1846 SketchRelation::Midpoint { .. } => strings::TOOL_MIDPOINT, 1847 SketchRelation::Symmetric { .. } => strings::TOOL_SYMMETRIC, 1848 SketchRelation::Fix(_) => strings::TOOL_FIX, 1849 } 1850} 1851 1852fn endpoint_or_entity_label( 1853 strings_table: &StringTable, 1854 sketch: &Sketch, 1855 id: SketchEntityId, 1856) -> String { 1857 match sketch.entities().get(id) { 1858 Some(SketchEntity::Point(p)) => { 1859 let (x, y) = p.at().coords_mm(); 1860 format!("({}, {})", format_mm(x), format_mm(y)) 1861 } 1862 Some(SketchEntity::Line(_)) => strings_table 1863 .resolve(strings::PROPERTY_KIND_LINE) 1864 .to_owned(), 1865 Some(SketchEntity::Arc(_)) => strings_table.resolve(strings::PROPERTY_KIND_ARC).to_owned(), 1866 Some(SketchEntity::Circle(_)) => strings_table 1867 .resolve(strings::PROPERTY_KIND_CIRCLE) 1868 .to_owned(), 1869 None => "?".to_owned(), 1870 } 1871} 1872 1873type PropertyRowSpec = (WidgetId, StringKey, Box<dyn PropertyEditor>); 1874 1875fn row_editor(label: StringKey, value: impl Into<String>) -> PropertyRowSpec { 1876 let row_id = WidgetId::ROOT 1877 .child(WidgetKey::new("props.row")) 1878 .child(WidgetKey::new(label.id())); 1879 let editor: Box<dyn PropertyEditor> = Box::new(StaticTextEditor::new(value.into())); 1880 (row_id, label, editor) 1881} 1882 1883fn entity_editors( 1884 strings_table: &StringTable, 1885 entity: SketchEntity, 1886 sketch: &Sketch, 1887) -> Vec<PropertyRowSpec> { 1888 let yes_no = |b: bool| { 1889 if b { 1890 strings_table 1891 .resolve(strings::PROPERTY_VALUE_YES) 1892 .to_owned() 1893 } else { 1894 strings_table.resolve(strings::PROPERTY_VALUE_NO).to_owned() 1895 } 1896 }; 1897 match entity { 1898 SketchEntity::Point(p) => { 1899 let (x, y) = p.at().coords_mm(); 1900 vec![ 1901 row_editor( 1902 strings::PROPERTY_ROW_KIND, 1903 strings_table 1904 .resolve(strings::PROPERTY_KIND_POINT) 1905 .to_owned(), 1906 ), 1907 row_editor(strings::PROPERTY_ROW_X, format_mm(x)), 1908 row_editor(strings::PROPERTY_ROW_Y, format_mm(y)), 1909 ] 1910 } 1911 SketchEntity::Line(l) => { 1912 let from = endpoint_or_entity_label(strings_table, sketch, l.a()); 1913 let to = endpoint_or_entity_label(strings_table, sketch, l.b()); 1914 vec![ 1915 row_editor( 1916 strings::PROPERTY_ROW_KIND, 1917 strings_table 1918 .resolve(strings::PROPERTY_KIND_LINE) 1919 .to_owned(), 1920 ), 1921 row_editor(strings::PROPERTY_ROW_FROM, from), 1922 row_editor(strings::PROPERTY_ROW_TO, to), 1923 row_editor( 1924 strings::PROPERTY_ROW_CONSTRUCTION, 1925 yes_no(l.for_construction()), 1926 ), 1927 ] 1928 } 1929 SketchEntity::Arc(a) => { 1930 let center = endpoint_or_entity_label(strings_table, sketch, a.center()); 1931 let start = endpoint_or_entity_label(strings_table, sketch, a.start()); 1932 let end = endpoint_or_entity_label(strings_table, sketch, a.end()); 1933 vec![ 1934 row_editor( 1935 strings::PROPERTY_ROW_KIND, 1936 strings_table.resolve(strings::PROPERTY_KIND_ARC).to_owned(), 1937 ), 1938 row_editor(strings::PROPERTY_ROW_CENTER, center), 1939 row_editor(strings::PROPERTY_ROW_START, start), 1940 row_editor(strings::PROPERTY_ROW_END, end), 1941 row_editor( 1942 strings::PROPERTY_ROW_CONSTRUCTION, 1943 yes_no(a.for_construction()), 1944 ), 1945 ] 1946 } 1947 SketchEntity::Circle(c) => { 1948 let center = endpoint_or_entity_label(strings_table, sketch, c.center()); 1949 vec![ 1950 row_editor( 1951 strings::PROPERTY_ROW_KIND, 1952 strings_table 1953 .resolve(strings::PROPERTY_KIND_CIRCLE) 1954 .to_owned(), 1955 ), 1956 row_editor(strings::PROPERTY_ROW_CENTER, center), 1957 row_editor(strings::PROPERTY_ROW_RADIUS, format_length(c.radius())), 1958 row_editor( 1959 strings::PROPERTY_ROW_CONSTRUCTION, 1960 yes_no(c.for_construction()), 1961 ), 1962 ] 1963 } 1964 } 1965 .into_iter() 1966 .enumerate() 1967 .map(|(idx, (_default_id, label, editor))| { 1968 let row_id = WidgetId::ROOT 1969 .child(WidgetKey::new("props.entity")) 1970 .child_indexed(WidgetKey::new("row"), idx as u64); 1971 (row_id, label, editor) 1972 }) 1973 .collect() 1974} 1975 1976fn format_mm(value: f64) -> String { 1977 format!("{value:.3} mm") 1978} 1979 1980fn format_length(length: Length) -> String { 1981 format_mm(length.get::<millimeter>()) 1982} 1983 1984struct StaticTextEditor { 1985 value: String, 1986} 1987 1988impl StaticTextEditor { 1989 fn new(value: String) -> Self { 1990 Self { value } 1991 } 1992} 1993 1994impl PropertyEditor for StaticTextEditor { 1995 fn render( 1996 &mut self, 1997 ctx: &mut FrameCtx<'_>, 1998 cell: PropertyCell, 1999 _clipboard: &mut dyn Clipboard, 2000 paint: &mut Vec<WidgetPaint>, 2001 ) -> bool { 2002 let label = ctx.strings.resolve(cell.label); 2003 let a11y_label = if self.value.is_empty() { 2004 label.to_owned() 2005 } else { 2006 format!("{label}: {}", self.value) 2007 }; 2008 ctx.a11y.push( 2009 cell.row_id, 2010 cell.rect, 2011 AccessNode::new(Role::Label).with_label_text(LabelText::Owned(a11y_label)), 2012 ); 2013 paint.push(WidgetPaint::Label { 2014 rect: cell.rect, 2015 text: LabelText::Owned(self.value.clone()), 2016 color: ctx.theme().colors.text_primary(), 2017 role: ctx.theme().typography.label, 2018 }); 2019 false 2020 } 2021} 2022 2023const DOC_TAB_WIDTH_PX: f32 = 80.0; 2024 2025fn render_doc_tabs( 2026 ctx: &mut FrameCtx<'_>, 2027 rect: LayoutRect, 2028 ids: &ShellIds, 2029 paints: &mut Vec<WidgetPaint>, 2030) { 2031 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 2032 return; 2033 } 2034 let theme = ctx.theme(); 2035 paints.push(surface_for(rect, theme.elevation.level1, theme)); 2036 let tab_rect = LayoutRect::new( 2037 rect.origin, 2038 LayoutSize::new(LayoutPx::new(DOC_TAB_WIDTH_PX), rect.size.height), 2039 ); 2040 let tabs = [Tab::new( 2041 ids.doc_tab_model, 2042 tab_rect, 2043 strings::DOC_TAB_MODEL, 2044 )]; 2045 let response = show_tabs( 2046 ctx, 2047 Tabs::new( 2048 ids.doc_tabs, 2049 TabsOrientation::Top, 2050 strings::DOC_TABS_LABEL, 2051 tabs.as_slice(), 2052 ids.doc_tab_model, 2053 ), 2054 ); 2055 paints.extend(response.paint); 2056} 2057 2058#[allow( 2059 clippy::too_many_arguments, 2060 reason = "status bar bundles mode + cursor + status diagnostics in one render pass" 2061)] 2062fn render_status_bar( 2063 ctx: &mut FrameCtx<'_>, 2064 rect: LayoutRect, 2065 id: WidgetId, 2066 mode: &Mode, 2067 document: &Document, 2068 cursor_world: Option<Point2>, 2069 status_report: Option<&SketchStatusReport>, 2070 status_badge_id: WidgetId, 2071 paints: &mut Vec<WidgetPaint>, 2072) -> bool { 2073 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 2074 return false; 2075 } 2076 let mode_label = mode_status_label(ctx.strings, mode, document); 2077 let mode_item = StatusItem::with_text( 2078 id.child(WidgetKey::new("mode")), 2079 mode_label, 2080 StatusAlign::Start, 2081 STATUS_MODE_WIDTH, 2082 ); 2083 let units_item = StatusItem::new( 2084 id.child(WidgetKey::new("units")), 2085 strings::STATUS_UNITS_MM, 2086 StatusAlign::End, 2087 STATUS_UNITS_WIDTH, 2088 ); 2089 let coords_item = mode 2090 .is_sketch() 2091 .then(|| { 2092 cursor_world.map(|world| { 2093 let (x_mm, y_mm) = world.coords_mm(); 2094 StatusItem::with_text( 2095 id.child(WidgetKey::new("coords")), 2096 LabelText::Owned(format!("{x_mm:.2}, {y_mm:.2} mm")), 2097 StatusAlign::Center, 2098 STATUS_COORDS_WIDTH, 2099 ) 2100 }) 2101 }) 2102 .flatten(); 2103 let status_item = status_report.map(|report| { 2104 let has_panel_content = !report.offending().is_empty(); 2105 StatusItem::new( 2106 status_badge_id, 2107 status_label_key(report.status()), 2108 StatusAlign::End, 2109 STATUS_STATUS_WIDTH, 2110 ) 2111 .interactive(has_panel_content) 2112 .badge(status_color(report.status(), &ctx.theme().cad)) 2113 }); 2114 let mut items: Vec<StatusItem> = vec![mode_item]; 2115 if let Some(coords) = coords_item { 2116 items.push(coords); 2117 } 2118 if let Some(status) = status_item { 2119 items.push(status); 2120 } 2121 items.push(units_item); 2122 let response = show_status_bar( 2123 ctx, 2124 StatusBar::new(id, rect, strings::STATUS_BAR_LABEL, &items), 2125 ); 2126 paints.extend(response.paint); 2127 response.activated == Some(status_badge_id) 2128} 2129 2130fn mode_status_label(strings_table: &StringTable, mode: &Mode, document: &Document) -> LabelText { 2131 match mode { 2132 Mode::Idle => LabelText::Key(strings::STATUS_READY), 2133 Mode::Sketch { sketch_id, .. } => { 2134 let Some(label) = document.sketch_label(*sketch_id) else { 2135 tracing::warn!(?sketch_id, "active sketch missing from document"); 2136 return LabelText::Key(strings::STATUS_READY); 2137 }; 2138 let prefix = strings_table.resolve(strings::STATUS_SKETCH_ACTIVE); 2139 LabelText::Owned(format!("{prefix} {label}")) 2140 } 2141 } 2142} 2143 2144fn estimate_label_width(text: &str, font_size_px: f32, min_width: LayoutPx) -> LayoutPx { 2145 #[allow( 2146 clippy::cast_precision_loss, 2147 reason = "string lengths fit in f32 mantissa for any realistic label" 2148 )] 2149 let chars = text.chars().count() as f32; 2150 let est = chars * font_size_px * RIBBON_LABEL_AVG_ADVANCE_RATIO 2151 + 2.0 * RIBBON_LABEL_HORIZONTAL_PADDING_PX; 2152 LayoutPx::new(est.max(min_width.value())) 2153} 2154 2155fn group_width_for(items: &[ToolbarItem], fallback_item_size: LayoutPx) -> LayoutPx { 2156 let total: f32 = items 2157 .iter() 2158 .enumerate() 2159 .map(|(i, it)| { 2160 it.width.unwrap_or(fallback_item_size).value() 2161 + if i == 0 { 0.0 } else { RIBBON_TOOLBAR_GAP_PX } 2162 }) 2163 .sum(); 2164 LayoutPx::new(total + 2.0 * RIBBON_GROUP_PADDING_PX) 2165} 2166 2167fn group_min_width(item_size: LayoutPx, item_count: usize) -> LayoutPx { 2168 let min_items_extent = match item_count { 2169 0 => 0.0, 2170 1 => item_size.value(), 2171 _ => 2.0 * item_size.value() + RIBBON_TOOLBAR_GAP_PX, 2172 }; 2173 LayoutPx::new(min_items_extent + 2.0 * RIBBON_GROUP_PADDING_PX) 2174} 2175 2176fn relation_tool_buttons( 2177 ribbon: WidgetId, 2178 sketch: Option<&Sketch>, 2179 selection: &[SketchEntityId], 2180 sketch_disabled: bool, 2181) -> Vec<ToolbarItem> { 2182 RelationKind::ALL 2183 .iter() 2184 .copied() 2185 .map(|kind| relation_tool_item(ribbon, kind, sketch, selection, sketch_disabled)) 2186 .collect() 2187} 2188 2189fn relation_tool_item( 2190 ribbon: WidgetId, 2191 kind: RelationKind, 2192 sketch: Option<&Sketch>, 2193 selection: &[SketchEntityId], 2194 sketch_disabled: bool, 2195) -> ToolbarItem { 2196 let item = ToolbarItem::new(relation_widget_id(ribbon, kind), kind.label()); 2197 if sketch_disabled { 2198 return item.disabled(true); 2199 } 2200 let Some(sketch) = sketch else { 2201 return item.disabled(true); 2202 }; 2203 match eligibility(kind, sketch, selection) { 2204 Eligibility::Eligible(_) => item, 2205 Eligibility::Disabled(reason) => item.disabled(true).with_tooltip(reason), 2206 } 2207} 2208 2209fn build_relation_index(ribbon: WidgetId) -> BTreeMap<WidgetId, RelationKind> { 2210 RelationKind::ALL 2211 .iter() 2212 .copied() 2213 .map(|k| (relation_widget_id(ribbon, k), k)) 2214 .collect() 2215} 2216 2217fn relation_widget_id(ribbon: WidgetId, kind: RelationKind) -> WidgetId { 2218 ribbon.child(WidgetKey::new(kind.key())) 2219} 2220 2221fn build_tool_index(ribbon: WidgetId) -> BTreeMap<WidgetId, SketchTool> { 2222 SketchTool::ENTITIES 2223 .iter() 2224 .copied() 2225 .map(|t| (tool_widget_id(ribbon, t), t)) 2226 .collect() 2227} 2228 2229fn tool_widget_id(ribbon: WidgetId, tool: SketchTool) -> WidgetId { 2230 ribbon.child(WidgetKey::new(tool_key(tool))) 2231} 2232 2233fn tool_key(tool: SketchTool) -> &'static str { 2234 match tool { 2235 SketchTool::Point => "tool.point", 2236 SketchTool::Line => "tool.line", 2237 SketchTool::CenterpointArc => "tool.centerpoint_arc", 2238 SketchTool::TangentArc => "tool.tangent_arc", 2239 SketchTool::ThreePointArc => "tool.three_point_arc", 2240 SketchTool::Circle => "tool.circle", 2241 SketchTool::PerimeterCircle => "tool.perimeter_circle", 2242 SketchTool::CornerRectangle => "tool.corner_rectangle", 2243 SketchTool::CenterRectangle => "tool.center_rectangle", 2244 SketchTool::ThreePointCornerRectangle => "tool.three_point_corner_rectangle", 2245 SketchTool::ThreePointCenterRectangle => "tool.three_point_center_rectangle", 2246 SketchTool::Parallelogram => "tool.parallelogram", 2247 } 2248} 2249 2250fn tool_label(tool: SketchTool) -> StringKey { 2251 match tool { 2252 SketchTool::Point => strings::TOOL_POINT, 2253 SketchTool::Line => strings::TOOL_LINE, 2254 SketchTool::CenterpointArc => strings::TOOL_CENTERPOINT_ARC, 2255 SketchTool::TangentArc => strings::TOOL_TANGENT_ARC, 2256 SketchTool::ThreePointArc => strings::TOOL_THREE_POINT_ARC, 2257 SketchTool::Circle => strings::TOOL_CIRCLE, 2258 SketchTool::PerimeterCircle => strings::TOOL_PERIMETER_CIRCLE, 2259 SketchTool::CornerRectangle => strings::TOOL_CORNER_RECTANGLE, 2260 SketchTool::CenterRectangle => strings::TOOL_CENTER_RECTANGLE, 2261 SketchTool::ThreePointCornerRectangle => strings::TOOL_THREE_POINT_CORNER_RECTANGLE, 2262 SketchTool::ThreePointCenterRectangle => strings::TOOL_THREE_POINT_CENTER_RECTANGLE, 2263 SketchTool::Parallelogram => strings::TOOL_PARALLELOGRAM, 2264 } 2265} 2266 2267fn paint_walk( 2268 layout: &SolvedLayout, 2269 node: &SolvedNode, 2270 theme: &Theme, 2271 viewport: PanelId, 2272) -> Vec<WidgetPaint> { 2273 let walk_children = || { 2274 node.children 2275 .iter() 2276 .flat_map(|c| paint_walk(layout, layout.node(*c), theme, viewport)) 2277 }; 2278 match &node.kind { 2279 NodeKind::DockHost { .. } 2280 | NodeKind::Pass 2281 | NodeKind::Leaf(_) 2282 | NodeKind::ScrollRegion { .. } => walk_children().collect(), 2283 NodeKind::DockSplit { axis, .. } | NodeKind::Splitter { axis, .. } => walk_children() 2284 .chain(divider_paint(layout, node, *axis, theme)) 2285 .collect(), 2286 NodeKind::DockTabStrip { .. } => { 2287 core::iter::once(surface_for(node.rect, theme.elevation.level2, theme)) 2288 .chain(walk_children()) 2289 .collect() 2290 } 2291 NodeKind::DockPanel { id } if *id == viewport => Vec::new(), 2292 NodeKind::DockPanel { .. } => { 2293 core::iter::once(surface_for(node.rect, theme.elevation.level1, theme)) 2294 .chain(walk_children()) 2295 .collect() 2296 } 2297 } 2298} 2299 2300fn divider_paint( 2301 layout: &SolvedLayout, 2302 node: &SolvedNode, 2303 axis: Axis, 2304 theme: &Theme, 2305) -> Option<WidgetPaint> { 2306 let [first_idx, _] = match node.children.as_slice() { 2307 [a, b] => [*a, *b], 2308 _ => return None, 2309 }; 2310 let first = layout.node(first_idx); 2311 let rect = divider_between(axis, first.rect, node.rect); 2312 let color = theme.colors.neutral.step(Step12::BORDER); 2313 Some(WidgetPaint::Surface { 2314 rect, 2315 fill: color, 2316 border: None, 2317 radius: Radius::px(0.0), 2318 elevation: None, 2319 }) 2320} 2321 2322fn divider_between(axis: Axis, first: LayoutRect, parent: LayoutRect) -> LayoutRect { 2323 let thickness = LayoutPx::new(StrokeWidth::HAIRLINE.value_px()); 2324 match axis { 2325 Axis::Horizontal => LayoutRect::new( 2326 LayoutPos::new(first.max_x(), parent.min_y()), 2327 LayoutSize::new(thickness, parent.size.height), 2328 ), 2329 Axis::Vertical => LayoutRect::new( 2330 LayoutPos::new(parent.min_x(), first.max_y()), 2331 LayoutSize::new(parent.size.width, thickness), 2332 ), 2333 } 2334} 2335 2336fn surface_for(rect: LayoutRect, elevation: ElevationLevel, theme: &Theme) -> WidgetPaint { 2337 WidgetPaint::Surface { 2338 rect, 2339 fill: theme.colors.surface(elevation.surface), 2340 border: elevation.border, 2341 radius: Radius::px(0.0), 2342 elevation: Some(elevation), 2343 } 2344} 2345 2346fn panel_rect(solved: &SolvedLayout, id: PanelId) -> Option<LayoutRect> { 2347 solved 2348 .nodes 2349 .iter() 2350 .find(|n| matches!(n.kind, NodeKind::DockPanel { id: pid } if pid == id)) 2351 .map(|n| n.rect) 2352} 2353 2354fn leaf_rect(solved: &SolvedLayout, id: WidgetId) -> Option<LayoutRect> { 2355 solved 2356 .nodes 2357 .iter() 2358 .find(|n| matches!(n.kind, NodeKind::Leaf(wid) if wid == id)) 2359 .map(|n| n.rect) 2360} 2361 2362const MENU_BAR_HEIGHT_PX: f32 = 24.0; 2363const RIBBON_HEIGHT_PX: f32 = 96.0; 2364const DOC_TABS_HEIGHT_PX: f32 = 22.0; 2365const STATUS_BAR_HEIGHT_PX: f32 = 22.0; 2366 2367struct ChromeRows { 2368 menu: Layout, 2369 ribbon: Layout, 2370 center: Layout, 2371 doc_tabs: Layout, 2372 status: Layout, 2373} 2374 2375fn chrome_grid(rows: ChromeRows) -> Layout { 2376 let ChromeRows { 2377 menu, 2378 ribbon, 2379 center, 2380 doc_tabs, 2381 status, 2382 } = rows; 2383 let one = grid_line(1); 2384 let two = grid_line(2); 2385 let three = grid_line(3); 2386 let four = grid_line(4); 2387 let five = grid_line(5); 2388 let six = grid_line(6); 2389 let span_row = |row_start: GridLine, row_end: GridLine, child: Layout| { 2390 let Some(span) = GridSpan::rect(one, two, row_start, row_end) else { 2391 panic!("chrome row span must be increasing"); 2392 }; 2393 GridChild { span, child } 2394 }; 2395 Layout::Grid { 2396 columns: vec![GridTrack::unnamed(TrackSize::FLEX_1)], 2397 rows: vec![ 2398 GridTrack::unnamed(TrackSize::Fixed(Spacing::px(MENU_BAR_HEIGHT_PX))), 2399 GridTrack::unnamed(TrackSize::Fixed(Spacing::px(RIBBON_HEIGHT_PX))), 2400 GridTrack::unnamed(TrackSize::FLEX_1), 2401 GridTrack::unnamed(TrackSize::Fixed(Spacing::px(DOC_TABS_HEIGHT_PX))), 2402 GridTrack::unnamed(TrackSize::Fixed(Spacing::px(STATUS_BAR_HEIGHT_PX))), 2403 ], 2404 column_gap: Spacing::px(0.0), 2405 row_gap: Spacing::px(0.0), 2406 children: vec![ 2407 span_row(one, two, menu), 2408 span_row(two, three, ribbon), 2409 span_row(three, four, center), 2410 span_row(four, five, doc_tabs), 2411 span_row(five, six, status), 2412 ], 2413 } 2414} 2415 2416fn grid_line(n: u16) -> GridLine { 2417 let Some(nz) = core::num::NonZeroU16::new(n) else { 2418 panic!("grid line must be non-zero"); 2419 }; 2420 GridLine::new(nz) 2421} 2422 2423fn inset_rect(rect: LayoutRect, by: f32) -> LayoutRect { 2424 let w = (rect.size.width.value() - 2.0 * by).max(0.0); 2425 let h = (rect.size.height.value() - 2.0 * by).max(0.0); 2426 LayoutRect::new( 2427 LayoutPos::new( 2428 LayoutPx::saturating(rect.origin.x.value() + by), 2429 LayoutPx::saturating(rect.origin.y.value() + by), 2430 ), 2431 LayoutSize::new( 2432 LayoutPx::saturating_nonneg(w), 2433 LayoutPx::saturating_nonneg(h), 2434 ), 2435 ) 2436} 2437 2438fn zero_rect() -> LayoutRect { 2439 LayoutRect::new(LayoutPos::ORIGIN, LayoutSize::ZERO) 2440} 2441 2442const fn panel(value: u32) -> PanelId { 2443 let Some(nz) = NonZeroU32::new(value) else { 2444 panic!("PanelId value must be non-zero"); 2445 }; 2446 PanelId::new(nz) 2447} 2448 2449const LEFT_PANE_TAB_STRIP_HEIGHT: f32 = 28.0; 2450 2451struct LeftPaneSplit { 2452 tab_strip_rect: LayoutRect, 2453 content_rect: LayoutRect, 2454} 2455 2456fn split_left_pane(rect: LayoutRect) -> LeftPaneSplit { 2457 let strip_height = LayoutPx::new(LEFT_PANE_TAB_STRIP_HEIGHT.min(rect.size.height.value())); 2458 let tab_strip_rect = 2459 LayoutRect::new(rect.origin, LayoutSize::new(rect.size.width, strip_height)); 2460 let content_rect = LayoutRect::new( 2461 LayoutPos::new( 2462 rect.origin.x, 2463 LayoutPx::new(rect.origin.y.value() + strip_height.value()), 2464 ), 2465 LayoutSize::new( 2466 rect.size.width, 2467 LayoutPx::saturating_nonneg(rect.size.height.value() - strip_height.value()), 2468 ), 2469 ); 2470 LeftPaneSplit { 2471 tab_strip_rect, 2472 content_rect, 2473 } 2474} 2475 2476fn update_left_pane_auto( 2477 state: &mut ShellState, 2478 selection: &Selection, 2479 active_tool: Option<SketchTool>, 2480) { 2481 let interesting = !selection.is_empty() || active_tool.is_some(); 2482 if interesting && !state.last_left_pane_interesting { 2483 state.left_pane = LeftPane::Properties; 2484 } else if !interesting && state.last_left_pane_interesting { 2485 state.left_pane = LeftPane::Tree; 2486 } 2487 state.last_left_pane_interesting = interesting; 2488} 2489 2490const LEFT_PANE_TAB_WIDTH_PX: f32 = 28.0; 2491 2492#[derive(Copy, Clone)] 2493struct LeftPaneTabSpec { 2494 id: WidgetId, 2495 label: StringKey, 2496 glyph: GlyphMark, 2497 target: Option<LeftPane>, 2498} 2499 2500fn render_left_pane_tabs( 2501 ctx: &mut FrameCtx<'_>, 2502 rect: LayoutRect, 2503 ids: &ShellIds, 2504 active: LeftPane, 2505 paints: &mut Vec<WidgetPaint>, 2506) -> Option<LeftPane> { 2507 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 2508 return None; 2509 } 2510 let specs = [ 2511 LeftPaneTabSpec { 2512 id: ids.left_pane_tab_tree, 2513 label: strings::FEATURE_TREE_LABEL, 2514 glyph: GlyphMark::TabTree, 2515 target: Some(LeftPane::Tree), 2516 }, 2517 LeftPaneTabSpec { 2518 id: ids.left_pane_tab_properties, 2519 label: strings::PROPERTY_PANE_LABEL, 2520 glyph: GlyphMark::TabProperties, 2521 target: Some(LeftPane::Properties), 2522 }, 2523 LeftPaneTabSpec { 2524 id: ids.left_pane_tab_configuration, 2525 label: strings::LEFT_PANE_TAB_CONFIGURATION, 2526 glyph: GlyphMark::TabConfiguration, 2527 target: None, 2528 }, 2529 LeftPaneTabSpec { 2530 id: ids.left_pane_tab_dimension_expert, 2531 label: strings::LEFT_PANE_TAB_DIMENSION_EXPERT, 2532 glyph: GlyphMark::TabDimensionExpert, 2533 target: None, 2534 }, 2535 LeftPaneTabSpec { 2536 id: ids.left_pane_tab_display, 2537 label: strings::LEFT_PANE_TAB_DISPLAY, 2538 glyph: GlyphMark::TabDisplay, 2539 target: None, 2540 }, 2541 ]; 2542 let tab_views: Vec<Tab> = specs 2543 .iter() 2544 .scan(rect.origin.x.value(), |x, spec| { 2545 let tab_rect = LayoutRect::new( 2546 LayoutPos::new(LayoutPx::new(*x), rect.origin.y), 2547 LayoutSize::new(LayoutPx::new(LEFT_PANE_TAB_WIDTH_PX), rect.size.height), 2548 ); 2549 *x += LEFT_PANE_TAB_WIDTH_PX; 2550 Some( 2551 Tab::new(spec.id, tab_rect, spec.label) 2552 .with_glyph(spec.glyph) 2553 .disabled(spec.target.is_none()), 2554 ) 2555 }) 2556 .collect(); 2557 let active_id = specs 2558 .iter() 2559 .find_map(|spec| (spec.target == Some(active)).then_some(spec.id)) 2560 .unwrap_or(ids.left_pane_tab_tree); 2561 let response = show_tabs( 2562 ctx, 2563 Tabs::new( 2564 ids.left_pane.child(WidgetKey::new("tabs")), 2565 TabsOrientation::Top, 2566 strings::LEFT_PANE_LABEL, 2567 tab_views.as_slice(), 2568 active_id, 2569 ), 2570 ); 2571 paints.extend(response.paint); 2572 response.activated.and_then(|id| { 2573 specs 2574 .iter() 2575 .find_map(|spec| (spec.id == id).then_some(spec.target).flatten()) 2576 }) 2577} 2578 2579const CONFIRM_BUTTON_PX: f32 = 36.0; 2580const CONFIRM_BUTTON_GAP: f32 = 6.0; 2581const CONFIRM_CORNER_INSET: f32 = 12.0; 2582 2583#[derive(Copy, Clone, Debug, PartialEq, Eq)] 2584pub enum ConfirmAction { 2585 Accept, 2586 Cancel, 2587} 2588 2589fn render_confirm_corner( 2590 ctx: &mut FrameCtx<'_>, 2591 viewport: LayoutRect, 2592 ids: &ShellIds, 2593 visible: bool, 2594 paints: &mut Vec<WidgetPaint>, 2595) -> Option<ConfirmAction> { 2596 let pair_width = 2.0 * CONFIRM_BUTTON_PX + CONFIRM_BUTTON_GAP; 2597 let min_width = pair_width + 2.0 * CONFIRM_CORNER_INSET; 2598 let min_height = CONFIRM_BUTTON_PX + 2.0 * CONFIRM_CORNER_INSET; 2599 if !visible 2600 || viewport.size.width.value() < min_width 2601 || viewport.size.height.value() < min_height 2602 { 2603 return None; 2604 } 2605 let top_y = viewport.origin.y.value() + CONFIRM_CORNER_INSET; 2606 let cancel_x = viewport.origin.x.value() + viewport.size.width.value() 2607 - CONFIRM_CORNER_INSET 2608 - CONFIRM_BUTTON_PX; 2609 let accept_x = cancel_x - CONFIRM_BUTTON_GAP - CONFIRM_BUTTON_PX; 2610 let accept_rect = LayoutRect::new( 2611 LayoutPos::new(LayoutPx::new(accept_x), LayoutPx::new(top_y)), 2612 LayoutSize::new( 2613 LayoutPx::new(CONFIRM_BUTTON_PX), 2614 LayoutPx::new(CONFIRM_BUTTON_PX), 2615 ), 2616 ); 2617 let cancel_rect = LayoutRect::new( 2618 LayoutPos::new(LayoutPx::new(cancel_x), LayoutPx::new(top_y)), 2619 LayoutSize::new( 2620 LayoutPx::new(CONFIRM_BUTTON_PX), 2621 LayoutPx::new(CONFIRM_BUTTON_PX), 2622 ), 2623 ); 2624 let accept_clicked = paint_confirm_button( 2625 ctx, 2626 ids.confirm_accept, 2627 accept_rect, 2628 GlyphMark::Checkmark, 2629 strings::CONFIRM_ACCEPT, 2630 ConfirmTone::Accept, 2631 paints, 2632 ); 2633 let cancel_clicked = paint_confirm_button( 2634 ctx, 2635 ids.confirm_cancel, 2636 cancel_rect, 2637 GlyphMark::Close, 2638 strings::CONFIRM_CANCEL, 2639 ConfirmTone::Cancel, 2640 paints, 2641 ); 2642 if accept_clicked { 2643 Some(ConfirmAction::Accept) 2644 } else if cancel_clicked { 2645 Some(ConfirmAction::Cancel) 2646 } else { 2647 None 2648 } 2649} 2650 2651#[derive(Copy, Clone)] 2652enum ConfirmTone { 2653 Accept, 2654 Cancel, 2655} 2656 2657fn paint_confirm_button( 2658 ctx: &mut FrameCtx<'_>, 2659 id: WidgetId, 2660 rect: LayoutRect, 2661 glyph: GlyphMark, 2662 label: StringKey, 2663 tone: ConfirmTone, 2664 paints: &mut Vec<WidgetPaint>, 2665) -> bool { 2666 let interaction = ctx.interact( 2667 InteractDeclaration::new(id, rect, Sense::INTERACTIVE) 2668 .focusable(true) 2669 .a11y(AccessNode::new(Role::Button).with_label(label)), 2670 ); 2671 let theme = ctx.theme(); 2672 let palette = match tone { 2673 ConfirmTone::Accept => theme.colors.success, 2674 ConfirmTone::Cancel => theme.colors.danger, 2675 }; 2676 let (fill, glyph_color) = if interaction.pressed() { 2677 ( 2678 palette.step(Step12::SELECTED_BG), 2679 palette.step(Step12::HOVER_SOLID), 2680 ) 2681 } else if interaction.hover() { 2682 (palette.step(Step12::HOVER_BG), palette.step(Step12::SOLID)) 2683 } else { 2684 ( 2685 theme.colors.surface(theme.elevation.level3.surface), 2686 palette.step(Step12::SOLID), 2687 ) 2688 }; 2689 paints.push(WidgetPaint::Surface { 2690 rect, 2691 fill, 2692 border: Some(Border { 2693 width: StrokeWidth::HAIRLINE, 2694 color: palette.step(Step12::SOLID), 2695 }), 2696 radius: theme.radius.sm, 2697 elevation: Some(theme.elevation.level3), 2698 }); 2699 paints.push(WidgetPaint::Mark { 2700 rect, 2701 kind: glyph, 2702 color: glyph_color, 2703 }); 2704 interaction.click() 2705} 2706 2707fn build_dock_main(panels: ShellPanels) -> DockNode { 2708 const LEFT_PANE_RATIO: SplitFraction = SplitFraction::clamped(0.22); 2709 DockNode::split( 2710 Axis::Horizontal, 2711 LEFT_PANE_RATIO, 2712 DockNode::tabs(vec![panels.left_pane]), 2713 DockNode::tabs(vec![panels.viewport]), 2714 ) 2715} 2716 2717#[cfg(test)] 2718mod tests { 2719 use super::*; 2720 use bone_document::Document; 2721 use bone_types::{DocumentId, SketchId}; 2722 use bone_ui::a11y::AccessTreeBuilder; 2723 use bone_ui::focus::FocusManager; 2724 use bone_ui::hit_test::{HitFrame, HitState}; 2725 use bone_ui::hotkey::HotkeyTable; 2726 use bone_ui::input::{FrameInstant, InputSnapshot}; 2727 use bone_ui::strings::{Locale, StringKey, StringTable}; 2728 use bone_ui::theme::Theme; 2729 use bone_ui::widgets::LabelText; 2730 use std::sync::Arc; 2731 2732 fn layout_size(w: f32, h: f32) -> LayoutSize { 2733 LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h)) 2734 } 2735 2736 fn sample_document() -> Document { 2737 Document::new(DocumentId::default(), "Sample".to_owned()) 2738 } 2739 2740 fn render_with(theme: Theme, size: LayoutSize, document: &Document, mode: &Mode) -> ShellFrame { 2741 let mut shell = Shell::new(); 2742 render_into_shell( 2743 &mut shell, 2744 theme, 2745 size, 2746 document, 2747 mode, 2748 &Selection::default(), 2749 ) 2750 } 2751 2752 fn render_with_strings( 2753 shell: &mut Shell, 2754 theme: Theme, 2755 size: LayoutSize, 2756 document: &Document, 2757 mode: &Mode, 2758 selection: &Selection, 2759 strings: &StringTable, 2760 ) -> ShellFrame { 2761 let theme = Arc::new(theme); 2762 let table = HotkeyTable::new(); 2763 let mut focus = FocusManager::new(); 2764 let mut hits = HitFrame::new(); 2765 let prev = HitState::new(); 2766 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 2767 let mut shaper = bone_text::Shaper::new(); 2768 let mut a11y = AccessTreeBuilder::new(); 2769 let mut ctx = FrameCtx::new( 2770 theme, 2771 &mut input, 2772 &mut focus, 2773 &table, 2774 strings, 2775 &mut hits, 2776 &prev, 2777 &mut a11y, 2778 &mut shaper, 2779 ); 2780 shell.render( 2781 &mut ctx, 2782 document, 2783 mode, 2784 selection, 2785 &Settings::default(), 2786 size, 2787 None, 2788 ) 2789 } 2790 2791 fn render_into_shell( 2792 shell: &mut Shell, 2793 theme: Theme, 2794 size: LayoutSize, 2795 document: &Document, 2796 mode: &Mode, 2797 selection: &Selection, 2798 ) -> ShellFrame { 2799 render_with_strings( 2800 shell, 2801 theme, 2802 size, 2803 document, 2804 mode, 2805 selection, 2806 StringTable::empty(), 2807 ) 2808 } 2809 2810 fn label_rect(paints: &[WidgetPaint], target: StringKey) -> Option<LayoutRect> { 2811 paints.iter().find_map(|p| match p { 2812 WidgetPaint::Label { 2813 rect, 2814 text: LabelText::Key(k), 2815 .. 2816 } 2817 | WidgetPaint::AlignedLabel { 2818 rect, 2819 text: LabelText::Key(k), 2820 .. 2821 } if *k == target => Some(*rect), 2822 _ => None, 2823 }) 2824 } 2825 2826 #[test] 2827 fn tools_options_menu_id_maps_to_open_settings_action() { 2828 let shell = Shell::new(); 2829 assert_eq!( 2830 shell.ids.menu_action_for(shell.ids.menu_tools_options), 2831 Some(MenuAction::OpenSettings), 2832 ); 2833 } 2834 2835 #[test] 2836 fn file_menu_ids_map_to_file_actions() { 2837 let shell = Shell::new(); 2838 assert_eq!( 2839 shell.ids.menu_action_for(shell.ids.menu_file_new), 2840 Some(MenuAction::NewDocument), 2841 ); 2842 assert_eq!( 2843 shell.ids.menu_action_for(shell.ids.menu_file_open), 2844 Some(MenuAction::OpenDocument), 2845 ); 2846 assert_eq!( 2847 shell.ids.menu_action_for(shell.ids.menu_file_save), 2848 Some(MenuAction::SaveDocument), 2849 ); 2850 assert_eq!( 2851 shell.ids.menu_action_for(shell.ids.menu_file_save_as), 2852 Some(MenuAction::SaveDocumentAs), 2853 ); 2854 } 2855 2856 #[test] 2857 fn file_menu_actions_are_enabled() { 2858 let entries = build_menu_entries( 2859 &ShellIds::standard(), 2860 false, 2861 &crate::hotkeys::HotkeyOverrides::default(), 2862 ); 2863 let Some(file_menu) = entries.iter().find(|e| e.label == strings::MENU_FILE) else { 2864 panic!("file menu entry missing"); 2865 }; 2866 let actions: Vec<(StringKey, bool)> = file_menu 2867 .items 2868 .iter() 2869 .filter_map(|i| match i { 2870 MenuItem::Action { 2871 label, disabled, .. 2872 } => Some((*label, *disabled)), 2873 _ => None, 2874 }) 2875 .collect(); 2876 let entry_for = |key: StringKey| { 2877 actions 2878 .iter() 2879 .find(|(l, _)| *l == key) 2880 .copied() 2881 .unwrap_or((key, true)) 2882 }; 2883 assert!(!entry_for(strings::MENU_FILE_NEW).1); 2884 assert!(!entry_for(strings::MENU_FILE_OPEN).1); 2885 assert!(!entry_for(strings::MENU_FILE_SAVE).1); 2886 assert!(!entry_for(strings::MENU_FILE_SAVE_AS).1); 2887 } 2888 2889 #[test] 2890 fn settings_dialog_does_not_render_when_closed() { 2891 let frame = render_with( 2892 Theme::light(), 2893 layout_size(1280.0, 800.0), 2894 &sample_document(), 2895 &Mode::Idle, 2896 ); 2897 let title_visible = frame 2898 .paints 2899 .iter() 2900 .chain(frame.overlay_paints.iter()) 2901 .any(|p| { 2902 matches!( 2903 p, 2904 WidgetPaint::Label { 2905 text: LabelText::Key(k), 2906 .. 2907 } if *k == strings::SETTINGS_DIALOG_TITLE 2908 ) 2909 }); 2910 assert!(!title_visible, "settings dialog must not paint when closed"); 2911 assert!(frame.settings_change.is_none()); 2912 } 2913 2914 #[test] 2915 fn settings_dialog_paints_title_and_aperture_label_when_open() { 2916 let mut shell = Shell::new(); 2917 shell.state.settings_dialog_open = true; 2918 let frame = render_into_shell( 2919 &mut shell, 2920 Theme::light(), 2921 layout_size(1280.0, 800.0), 2922 &sample_document(), 2923 &Mode::Idle, 2924 &Selection::default(), 2925 ); 2926 let has_title = frame.overlay_paints.iter().any(|p| { 2927 matches!( 2928 p, 2929 WidgetPaint::Label { 2930 text: LabelText::Key(k), 2931 .. 2932 } if *k == strings::SETTINGS_DIALOG_TITLE 2933 ) 2934 }); 2935 assert!(has_title, "open dialog should paint its title key"); 2936 let has_aperture_text = frame.overlay_paints.iter().any(|p| { 2937 matches!( 2938 p, 2939 WidgetPaint::Label { 2940 text: LabelText::Owned(text), 2941 .. 2942 } if text.contains("px") 2943 ) 2944 }); 2945 assert!( 2946 has_aperture_text, 2947 "aperture label should include unit suffix px" 2948 ); 2949 } 2950 2951 #[test] 2952 fn shell_renders_with_non_empty_paint_list() { 2953 let frame = render_with( 2954 Theme::light(), 2955 layout_size(1280.0, 800.0), 2956 &sample_document(), 2957 &Mode::Idle, 2958 ); 2959 assert!(!frame.paints.is_empty()); 2960 } 2961 2962 #[test] 2963 fn shell_carves_out_viewport_region() { 2964 let frame = render_with( 2965 Theme::light(), 2966 layout_size(1280.0, 800.0), 2967 &sample_document(), 2968 &Mode::Idle, 2969 ); 2970 let v = frame.viewport_rect; 2971 assert!(v.size.width.value() > 0.0); 2972 assert!(v.size.height.value() > 0.0); 2973 assert!(v.min_x().value() > 0.0, "left pane carved on left"); 2974 assert!(v.min_y().value() > 0.0, "ribbon carved on top"); 2975 assert!( 2976 v.max_x().value() <= 1280.0, 2977 "viewport bounded by window width" 2978 ); 2979 assert!(v.max_y().value() < 800.0, "status bar carved on bottom"); 2980 } 2981 2982 #[test] 2983 fn shell_does_not_paint_viewport_panel_body() { 2984 let frame = render_with( 2985 Theme::light(), 2986 layout_size(1280.0, 800.0), 2987 &sample_document(), 2988 &Mode::Idle, 2989 ); 2990 let viewport_rect = frame.viewport_rect; 2991 let center = LayoutPos::new( 2992 LayoutPx::new(viewport_rect.min_x().value() + viewport_rect.size.width.value() * 0.5), 2993 LayoutPx::new(viewport_rect.min_y().value() + viewport_rect.size.height.value() * 0.5), 2994 ); 2995 let any_paint_covers_center = frame.paints.iter().any(|p| match p { 2996 WidgetPaint::Surface { rect, .. } => rect.contains(center), 2997 _ => false, 2998 }); 2999 assert!(!any_paint_covers_center); 3000 } 3001 3002 #[test] 3003 fn shell_seeds_part_node_expanded() { 3004 let shell = Shell::new(); 3005 assert!( 3006 shell 3007 .state 3008 .feature_tree 3009 .expanded 3010 .contains(&shell.ids.feature_part) 3011 ); 3012 } 3013 3014 #[test] 3015 fn tool_index_round_trips_every_entity_tool() { 3016 let ids = ShellIds::standard(); 3017 let index = build_tool_index(ids.ribbon); 3018 SketchTool::ENTITIES.iter().copied().for_each(|t| { 3019 let id = tool_widget_id(ids.ribbon, t); 3020 assert_eq!(index.get(&id).copied(), Some(t)); 3021 }); 3022 } 3023 3024 #[test] 3025 fn tool_index_omits_smart_dimension() { 3026 let ids = ShellIds::standard(); 3027 let index = build_tool_index(ids.ribbon); 3028 assert!(!index.contains_key(&ids.ribbon_smart_dimension)); 3029 } 3030 3031 #[test] 3032 fn plane_for_recognizes_each_principal_plane() { 3033 let ids = ShellIds::standard(); 3034 assert_eq!(ids.plane_for(ids.plane_xy), Some(Plane::Xy)); 3035 assert_eq!(ids.plane_for(ids.plane_yz), Some(Plane::Yz)); 3036 assert_eq!(ids.plane_for(ids.plane_zx), Some(Plane::Zx)); 3037 assert_eq!(ids.plane_for(ids.feature_tree), None); 3038 assert_eq!(ids.plane_for(ids.confirm_accept), None); 3039 } 3040 3041 #[test] 3042 fn idle_render_emits_no_state_machine_signals() { 3043 let frame = render_with( 3044 Theme::light(), 3045 layout_size(1280.0, 800.0), 3046 &sample_document(), 3047 &Mode::Idle, 3048 ); 3049 assert_eq!(frame.plane_picked, None); 3050 assert!(!frame.exit_sketch); 3051 assert!(frame.activated_tool.is_none()); 3052 } 3053 3054 #[test] 3055 fn idle_render_omits_confirm_corner() { 3056 let frame = render_with( 3057 Theme::light(), 3058 layout_size(1280.0, 800.0), 3059 &sample_document(), 3060 &Mode::Idle, 3061 ); 3062 assert!(!frame.paints.iter().any(is_confirm_glyph)); 3063 } 3064 3065 #[test] 3066 fn sketch_render_includes_confirm_corner() { 3067 let frame = render_with( 3068 Theme::light(), 3069 layout_size(1280.0, 800.0), 3070 &sample_document(), 3071 &Mode::enter_sketch(SketchId::default()), 3072 ); 3073 assert!(frame.paints.iter().any(is_confirm_glyph)); 3074 } 3075 3076 fn is_confirm_glyph(paint: &WidgetPaint) -> bool { 3077 matches!( 3078 paint, 3079 WidgetPaint::Mark { kind, .. } if matches!(kind, bone_ui::widgets::GlyphMark::Checkmark | bone_ui::widgets::GlyphMark::Close) 3080 ) 3081 } 3082 3083 #[test] 3084 fn relation_index_covers_every_kind() { 3085 let ids = ShellIds::standard(); 3086 let index = build_relation_index(ids.ribbon); 3087 RelationKind::ALL.iter().copied().for_each(|kind| { 3088 let id = relation_widget_id(ids.ribbon, kind); 3089 assert_eq!(index.get(&id).copied(), Some(kind)); 3090 }); 3091 } 3092 3093 #[test] 3094 fn relation_tool_item_disabled_without_sketch() { 3095 let ids = ShellIds::standard(); 3096 let item = relation_tool_item(ids.ribbon, RelationKind::Horizontal, None, &[], false); 3097 assert!(item.disabled); 3098 assert!(item.tooltip.is_none(), "no sketch, no per-relation reason"); 3099 } 3100 3101 #[test] 3102 fn relation_tool_item_disabled_when_sketch_disabled_flag_set() { 3103 let ids = ShellIds::standard(); 3104 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3105 let item = relation_tool_item( 3106 ids.ribbon, 3107 RelationKind::Horizontal, 3108 Some(&sketch), 3109 &[], 3110 true, 3111 ); 3112 assert!(item.disabled); 3113 } 3114 3115 #[test] 3116 fn relation_tool_item_carries_reason_tooltip_when_eligibility_fails() { 3117 let ids = ShellIds::standard(); 3118 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3119 let item = relation_tool_item( 3120 ids.ribbon, 3121 RelationKind::Horizontal, 3122 Some(&sketch), 3123 &[], 3124 false, 3125 ); 3126 assert!(item.disabled); 3127 assert_eq!(item.tooltip, Some(strings::REL_HINT_ONE_LINE)); 3128 } 3129 3130 #[test] 3131 fn relation_tool_item_enabled_when_eligibility_passes() { 3132 let ids = ShellIds::standard(); 3133 let (sketch, line) = sample_sketch_with_line(); 3134 let item = relation_tool_item( 3135 ids.ribbon, 3136 RelationKind::Horizontal, 3137 Some(&sketch), 3138 &[line], 3139 false, 3140 ); 3141 assert!(!item.disabled); 3142 assert!(item.tooltip.is_none()); 3143 } 3144 3145 #[test] 3146 fn resolve_activated_relation_returns_relation_for_eligible_selection() { 3147 let ids = ShellIds::standard(); 3148 let index = build_relation_index(ids.ribbon); 3149 let (sketch, line) = sample_sketch_with_line(); 3150 let id = relation_widget_id(ids.ribbon, RelationKind::Horizontal); 3151 let resolved = resolve_activated_relation(Some(id), &index, Some(&sketch), &[line]); 3152 assert_eq!(resolved, Some(SketchRelation::Horizontal(line))); 3153 } 3154 3155 #[test] 3156 fn resolve_activated_relation_drops_when_selection_invalid() { 3157 let ids = ShellIds::standard(); 3158 let index = build_relation_index(ids.ribbon); 3159 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3160 let id = relation_widget_id(ids.ribbon, RelationKind::Horizontal); 3161 let resolved = resolve_activated_relation(Some(id), &index, Some(&sketch), &[]); 3162 assert_eq!(resolved, None); 3163 } 3164 3165 #[test] 3166 fn resolve_activated_relation_returns_relation_for_multi_selection() { 3167 let ids = ShellIds::standard(); 3168 let index = build_relation_index(ids.ribbon); 3169 let (sketch, l1, l2) = sample_sketch_with_two_lines(); 3170 let id = relation_widget_id(ids.ribbon, RelationKind::Parallel); 3171 let resolved = resolve_activated_relation(Some(id), &index, Some(&sketch), &[l1, l2]); 3172 assert_eq!(resolved, Some(SketchRelation::Parallel(l1, l2))); 3173 } 3174 3175 #[test] 3176 fn feature_tree_panel_rect_is_independent_of_pending_dim() { 3177 let document = sample_document(); 3178 let idle = render_with( 3179 Theme::light(), 3180 layout_size(1600.0, 900.0), 3181 &document, 3182 &Mode::Idle, 3183 ); 3184 let in_sketch = render_with( 3185 Theme::light(), 3186 layout_size(1600.0, 900.0), 3187 &document, 3188 &Mode::enter_sketch(SketchId::default()), 3189 ); 3190 let tree_rect_idle = panel_surface(&idle.paints, |x| x < 300.0); 3191 let tree_rect_sketch = panel_surface(&in_sketch.paints, |x| x < 300.0); 3192 assert_eq!( 3193 tree_rect_idle, tree_rect_sketch, 3194 "feature tree panel must not change between idle and sketch mode", 3195 ); 3196 } 3197 3198 fn panel_surface(paints: &[WidgetPaint], filter: impl Fn(f32) -> bool) -> Option<LayoutRect> { 3199 paints.iter().find_map(|p| match p { 3200 WidgetPaint::Surface { rect, .. } if filter(rect.min_x().value()) => Some(*rect), 3201 _ => None, 3202 }) 3203 } 3204 3205 #[test] 3206 fn smart_dimension_paints_at_typical_window_with_real_strings() { 3207 use crate::strings as app_strings; 3208 use bone_ui::strings::Locale; 3209 let table = app_strings::make_strings(Locale::EnUs); 3210 let mut shell = Shell::new(); 3211 let theme = Arc::new(Theme::light()); 3212 let hk = HotkeyTable::new(); 3213 let mut focus = FocusManager::new(); 3214 let mut hits = HitFrame::new(); 3215 let prev = HitState::new(); 3216 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 3217 let mut shaper = bone_text::Shaper::new(); 3218 let mut a11y = AccessTreeBuilder::new(); 3219 let mut ctx = FrameCtx::new( 3220 theme, 3221 &mut input, 3222 &mut focus, 3223 &hk, 3224 &table, 3225 &mut hits, 3226 &prev, 3227 &mut a11y, 3228 &mut shaper, 3229 ); 3230 let frame = shell.render( 3231 &mut ctx, 3232 &sample_document(), 3233 &Mode::enter_sketch(SketchId::default()), 3234 &Selection::default(), 3235 &Settings::default(), 3236 layout_size(1600.0, 900.0), 3237 None, 3238 ); 3239 let any_smart_dim_label = frame.paints.iter().any(|p| { 3240 matches!( 3241 p, 3242 WidgetPaint::Label { text: LabelText::Key(key), .. } 3243 if *key == strings::TOOL_SMART_DIMENSION 3244 ) 3245 }); 3246 assert!(any_smart_dim_label); 3247 } 3248 3249 #[test] 3250 fn smart_dimension_button_paints_even_in_narrow_ribbon() { 3251 let frame = render_with( 3252 Theme::light(), 3253 layout_size(800.0, 600.0), 3254 &sample_document(), 3255 &Mode::enter_sketch(SketchId::default()), 3256 ); 3257 let any_smart_dim_label = frame.paints.iter().any(|p| { 3258 matches!( 3259 p, 3260 WidgetPaint::Label { text: LabelText::Key(key), .. } 3261 if *key == strings::TOOL_SMART_DIMENSION 3262 ) 3263 }); 3264 assert!( 3265 any_smart_dim_label, 3266 "Smart Dimension button must remain reachable on a narrow ribbon", 3267 ); 3268 } 3269 3270 #[test] 3271 fn smart_dimension_item_disabled_without_sketch() { 3272 let ids = ShellIds::standard(); 3273 let item = smart_dimension_tool_item(ids.ribbon_smart_dimension, None, &[], false); 3274 assert!(item.disabled); 3275 } 3276 3277 #[test] 3278 fn smart_dimension_item_disabled_when_sketch_disabled_flag_set() { 3279 let ids = ShellIds::standard(); 3280 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3281 let item = smart_dimension_tool_item(ids.ribbon_smart_dimension, Some(&sketch), &[], true); 3282 assert!(item.disabled); 3283 } 3284 3285 #[test] 3286 fn smart_dimension_item_carries_reason_tooltip_when_no_selection() { 3287 let ids = ShellIds::standard(); 3288 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3289 let item = smart_dimension_tool_item(ids.ribbon_smart_dimension, Some(&sketch), &[], false); 3290 assert!(item.disabled); 3291 assert_eq!(item.tooltip, Some(strings::DIM_HINT_GENERIC)); 3292 } 3293 3294 #[test] 3295 fn smart_dimension_item_enabled_for_eligible_line() { 3296 let ids = ShellIds::standard(); 3297 let (sketch, line) = sample_sketch_with_line(); 3298 let item = 3299 smart_dimension_tool_item(ids.ribbon_smart_dimension, Some(&sketch), &[line], false); 3300 assert!(!item.disabled); 3301 assert!(item.tooltip.is_none()); 3302 } 3303 3304 #[test] 3305 fn resolve_activated_dimension_returns_request_for_eligible_selection() { 3306 let ids = ShellIds::standard(); 3307 let (sketch, line) = sample_sketch_with_line(); 3308 let id = ids.ribbon_smart_dimension; 3309 let resolved = resolve_activated_dimension(Some(id), id, Some(&sketch), &[line]); 3310 let Some(req) = resolved else { 3311 panic!("expected eligible request"); 3312 }; 3313 assert!(matches!( 3314 req.proto, 3315 bone_document::SketchDimension::Linear { .. } 3316 )); 3317 } 3318 3319 #[test] 3320 fn resolve_activated_dimension_drops_when_widget_id_mismatches() { 3321 let ids = ShellIds::standard(); 3322 let (sketch, line) = sample_sketch_with_line(); 3323 let other = relation_widget_id(ids.ribbon, RelationKind::Horizontal); 3324 let resolved = resolve_activated_dimension( 3325 Some(other), 3326 ids.ribbon_smart_dimension, 3327 Some(&sketch), 3328 &[line], 3329 ); 3330 assert_eq!(resolved, None); 3331 } 3332 3333 #[test] 3334 fn resolve_activated_dimension_drops_when_selection_is_invalid() { 3335 let ids = ShellIds::standard(); 3336 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3337 let resolved = resolve_activated_dimension( 3338 Some(ids.ribbon_smart_dimension), 3339 ids.ribbon_smart_dimension, 3340 Some(&sketch), 3341 &[], 3342 ); 3343 assert_eq!(resolved, None); 3344 } 3345 3346 #[test] 3347 fn partition_overlay_extracts_tooltips_into_overlay_layer() { 3348 let theme = Theme::light(); 3349 let rect = LayoutRect::new( 3350 LayoutPos::new(LayoutPx::new(10.0), LayoutPx::new(20.0)), 3351 LayoutSize::new(LayoutPx::new(80.0), LayoutPx::new(18.0)), 3352 ); 3353 let inputs = vec![ 3354 WidgetPaint::Surface { 3355 rect, 3356 fill: theme.colors.surface(theme.elevation.level1.surface), 3357 border: None, 3358 radius: theme.radius.none, 3359 elevation: None, 3360 }, 3361 WidgetPaint::Tooltip { 3362 rect, 3363 text: LabelText::Owned("hint".to_owned()), 3364 anchor: WidgetId::ROOT, 3365 elevation: theme.elevation.level2, 3366 }, 3367 ]; 3368 let (main, overlay) = partition_overlay(inputs, &theme); 3369 assert_eq!(main.len(), 1, "non-tooltip stays in main"); 3370 assert!(matches!(main[0], WidgetPaint::Surface { .. })); 3371 assert_eq!(overlay.len(), 2, "tooltip expands to surface + label"); 3372 assert!(matches!(overlay[0], WidgetPaint::Surface { .. })); 3373 assert!(matches!(overlay[1], WidgetPaint::Label { .. })); 3374 } 3375 3376 fn sample_sketch_with_two_lines() -> (bone_document::Sketch, SketchEntityId, SketchEntityId) { 3377 use bone_types::Point2; 3378 let s = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3379 let (s, p0) = crate::tools::add_point(s, Point2::from_mm(0.0, 0.0)); 3380 let (s, p1) = crate::tools::add_point(s, Point2::from_mm(1.0, 0.0)); 3381 let (s, p2) = crate::tools::add_point(s, Point2::from_mm(0.0, 1.0)); 3382 let (s, p3) = crate::tools::add_point(s, Point2::from_mm(1.0, 1.0)); 3383 let (s, l1) = crate::tools::add_line(s, p0, p1, false); 3384 let (s, l2) = crate::tools::add_line(s, p2, p3, false); 3385 (s, l1, l2) 3386 } 3387 3388 fn sample_sketch_with_line() -> (bone_document::Sketch, SketchEntityId) { 3389 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3390 let (sketch, a) = crate::tools::add_point(sketch, bone_types::Point2::from_mm(0.0, 0.0)); 3391 let (sketch, b) = crate::tools::add_point(sketch, bone_types::Point2::from_mm(5.0, 0.0)); 3392 let (sketch, line) = crate::tools::add_line(sketch, a, b, false); 3393 (sketch, line) 3394 } 3395 3396 fn sketch_with_dim(kind: DimensionKind) -> (bone_document::Sketch, SketchDimensionId) { 3397 use bone_document::{EditOutcome, SketchEdit}; 3398 use bone_types::Point2; 3399 use uom::si::length::millimeter; 3400 let s = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3401 let (s, a) = crate::tools::add_point(s, Point2::from_mm(0.0, 0.0)); 3402 let (s, b) = crate::tools::add_point(s, Point2::from_mm(5.0, 0.0)); 3403 let dim = SketchDimension::Linear { 3404 a, 3405 b, 3406 value: Length::new::<millimeter>(5.0), 3407 kind, 3408 }; 3409 let Ok((s, EditOutcome::Dimension(id))) = s.apply(SketchEdit::AddDimension(dim)) else { 3410 panic!("expected dimension outcome"); 3411 }; 3412 (s, id) 3413 } 3414 3415 fn sketch_with_relation() -> ( 3416 bone_document::Sketch, 3417 bone_types::SketchRelationId, 3418 SketchEntityId, 3419 ) { 3420 use bone_document::{EditOutcome, SketchEdit}; 3421 use bone_types::Point2; 3422 let s = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3423 let (s, a) = crate::tools::add_point(s, Point2::from_mm(0.0, 0.0)); 3424 let (s, b) = crate::tools::add_point(s, Point2::from_mm(5.0, 0.0)); 3425 let (s, line) = crate::tools::add_line(s, a, b, false); 3426 let Ok((s, EditOutcome::Relation(id))) = 3427 s.apply(SketchEdit::AddRelation(SketchRelation::Horizontal(line))) 3428 else { 3429 panic!("expected relation outcome"); 3430 }; 3431 (s, id, line) 3432 } 3433 3434 fn document_with_sketch(sketch: bone_document::Sketch) -> (Document, SketchId) { 3435 let mut doc = sample_document(); 3436 let id = SketchId::default(); 3437 doc.insert_sketch(id, "Sketch1".to_owned(), sketch); 3438 (doc, id) 3439 } 3440 3441 #[test] 3442 fn property_pane_for_driving_dim_populates_editor_with_value() { 3443 let (sketch, dim_id) = sketch_with_dim(DimensionKind::Driving); 3444 let (doc, sketch_id) = document_with_sketch(sketch); 3445 let mut shell = Shell::new(); 3446 let frame = render_into_shell( 3447 &mut shell, 3448 Theme::light(), 3449 layout_size(1280.0, 800.0), 3450 &doc, 3451 &Mode::enter_sketch(sketch_id), 3452 &Selection::Dimension(dim_id), 3453 ); 3454 let Some(DimPropertyEditor::Length { id, editor, .. }) = &shell.state.dim_property else { 3455 panic!("expected length editor populated"); 3456 }; 3457 assert_eq!(*id, dim_id); 3458 assert!( 3459 (editor.value.get::<millimeter>() - 5.0).abs() < 1e-9, 3460 "editor value: {}", 3461 editor.value.get::<millimeter>() 3462 ); 3463 assert!(frame.dimension_edit.is_none(), "no input commit yet"); 3464 } 3465 3466 #[test] 3467 fn property_pane_keeps_driven_editor_but_marks_read_only() { 3468 let (sketch, dim_id) = sketch_with_dim(DimensionKind::Driven); 3469 let (doc, sketch_id) = document_with_sketch(sketch); 3470 let mut shell = Shell::new(); 3471 let _ = render_into_shell( 3472 &mut shell, 3473 Theme::light(), 3474 layout_size(1280.0, 800.0), 3475 &doc, 3476 &Mode::enter_sketch(sketch_id), 3477 &Selection::Dimension(dim_id), 3478 ); 3479 let Some(DimPropertyEditor::Length { id, .. }) = &shell.state.dim_property else { 3480 panic!("expected length editor populated"); 3481 }; 3482 assert_eq!(*id, dim_id); 3483 } 3484 3485 #[test] 3486 fn property_pane_drops_dim_editor_when_selection_changes_off_dim() { 3487 let (sketch, dim_id) = sketch_with_dim(DimensionKind::Driving); 3488 let (doc, sketch_id) = document_with_sketch(sketch); 3489 let mut shell = Shell::new(); 3490 let _ = render_into_shell( 3491 &mut shell, 3492 Theme::light(), 3493 layout_size(1280.0, 800.0), 3494 &doc, 3495 &Mode::enter_sketch(sketch_id), 3496 &Selection::Dimension(dim_id), 3497 ); 3498 assert!(shell.state.dim_property.is_some()); 3499 let _ = render_into_shell( 3500 &mut shell, 3501 Theme::light(), 3502 layout_size(1280.0, 800.0), 3503 &doc, 3504 &Mode::enter_sketch(sketch_id), 3505 &Selection::default(), 3506 ); 3507 assert!(shell.state.dim_property.is_none()); 3508 } 3509 3510 #[test] 3511 fn property_pane_swaps_editor_when_dim_id_changes() { 3512 use bone_document::{EditOutcome, SketchEdit}; 3513 use bone_types::Point2; 3514 use uom::si::length::millimeter; 3515 let s = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3516 let (s, a) = crate::tools::add_point(s, Point2::from_mm(0.0, 0.0)); 3517 let (s, b) = crate::tools::add_point(s, Point2::from_mm(5.0, 0.0)); 3518 let (s, c) = crate::tools::add_point(s, Point2::from_mm(0.0, 5.0)); 3519 let Ok((s, EditOutcome::Dimension(dim_a))) = 3520 s.apply(SketchEdit::AddDimension(SketchDimension::Linear { 3521 a, 3522 b, 3523 value: Length::new::<millimeter>(5.0), 3524 kind: DimensionKind::Driving, 3525 })) 3526 else { 3527 panic!("expected first Dimension outcome"); 3528 }; 3529 let Ok((s, EditOutcome::Dimension(dim_b))) = 3530 s.apply(SketchEdit::AddDimension(SketchDimension::Linear { 3531 a, 3532 b: c, 3533 value: Length::new::<millimeter>(5.0), 3534 kind: DimensionKind::Driving, 3535 })) 3536 else { 3537 panic!("expected second Dimension outcome"); 3538 }; 3539 assert_ne!(dim_a, dim_b); 3540 let (doc, sketch_id) = document_with_sketch(s); 3541 let mut shell = Shell::new(); 3542 let _ = render_into_shell( 3543 &mut shell, 3544 Theme::light(), 3545 layout_size(1280.0, 800.0), 3546 &doc, 3547 &Mode::enter_sketch(sketch_id), 3548 &Selection::Dimension(dim_a), 3549 ); 3550 let _ = render_into_shell( 3551 &mut shell, 3552 Theme::light(), 3553 layout_size(1280.0, 800.0), 3554 &doc, 3555 &Mode::enter_sketch(sketch_id), 3556 &Selection::Dimension(dim_b), 3557 ); 3558 let Some(DimPropertyEditor::Length { id, .. }) = &shell.state.dim_property else { 3559 panic!("expected length editor for second dim"); 3560 }; 3561 assert_eq!(*id, dim_b); 3562 } 3563 3564 #[test] 3565 fn property_pane_renders_relation_kind_label() { 3566 let (sketch, _rel_id, _line) = sketch_with_relation(); 3567 let (doc, sketch_id) = document_with_sketch(sketch); 3568 let Some(sketch_ref) = doc.sketch(sketch_id) else { 3569 panic!("expected inserted sketch"); 3570 }; 3571 let Some(rel_id) = sketch_ref.relation_order().first().copied() else { 3572 panic!("expected relation present"); 3573 }; 3574 let mut shell = Shell::new(); 3575 let frame = render_into_shell( 3576 &mut shell, 3577 Theme::light(), 3578 layout_size(1280.0, 800.0), 3579 &doc, 3580 &Mode::enter_sketch(sketch_id), 3581 &Selection::Relation(rel_id), 3582 ); 3583 let any_horizontal_label = frame.paints.iter().any(|p| match p { 3584 WidgetPaint::Label { 3585 text: LabelText::Owned(text), 3586 .. 3587 } => text == StringTable::empty().resolve(strings::TOOL_HORIZONTAL), 3588 _ => false, 3589 }); 3590 assert!(any_horizontal_label, "relation kind label should appear"); 3591 assert!( 3592 shell.state.dim_property.is_none(), 3593 "relation does not own dim editor" 3594 ); 3595 } 3596 3597 fn shell_drive( 3598 shell: &mut Shell, 3599 document: &Document, 3600 mode: &Mode, 3601 selection: &Selection, 3602 focus: &mut FocusManager, 3603 prev: &mut HitState, 3604 snap: &mut InputSnapshot, 3605 ) -> (ShellFrame, HitFrame) { 3606 let theme = Arc::new(Theme::light()); 3607 let table = HotkeyTable::new(); 3608 let mut hits = HitFrame::new(); 3609 let mut shaper = bone_text::Shaper::new(); 3610 let mut a11y = AccessTreeBuilder::new(); 3611 let frame = { 3612 let mut ctx = FrameCtx::new( 3613 theme, 3614 snap, 3615 focus, 3616 &table, 3617 StringTable::empty(), 3618 &mut hits, 3619 prev, 3620 &mut a11y, 3621 &mut shaper, 3622 ); 3623 shell.render( 3624 &mut ctx, 3625 document, 3626 mode, 3627 selection, 3628 &Settings::default(), 3629 layout_size(1280.0, 800.0), 3630 None, 3631 ) 3632 }; 3633 *prev = bone_ui::hit_test::resolve(prev, &hits, snap, focus.focused()); 3634 (frame, hits) 3635 } 3636 3637 fn sketch_widget(ids: &ShellIds, sketch_id: SketchId) -> WidgetId { 3638 ids.feature_part 3639 .child_indexed(WidgetKey::new("sketch"), sketch_id.as_u64()) 3640 } 3641 3642 #[test] 3643 fn f2_with_focused_sketch_row_starts_rename_in_full_shell() { 3644 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3645 let (document, sketch_id) = document_with_sketch(sketch); 3646 let mut shell = Shell::new(); 3647 let widget = sketch_widget(&shell.ids, sketch_id); 3648 let mut focus = FocusManager::new(); 3649 let mut prev = HitState::new(); 3650 3651 let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 3652 let (_, _) = shell_drive( 3653 &mut shell, 3654 &document, 3655 &Mode::Idle, 3656 &Selection::default(), 3657 &mut focus, 3658 &mut prev, 3659 &mut warm, 3660 ); 3661 focus.request_focus(widget); 3662 let mut warm2 = InputSnapshot::idle(FrameInstant::ZERO); 3663 let (_, _) = shell_drive( 3664 &mut shell, 3665 &document, 3666 &Mode::Idle, 3667 &Selection::default(), 3668 &mut focus, 3669 &mut prev, 3670 &mut warm2, 3671 ); 3672 assert_eq!( 3673 focus.focused(), 3674 Some(widget), 3675 "sketch row must be focusable+focused after second render", 3676 ); 3677 3678 let mut f2 = InputSnapshot::idle(FrameInstant::ZERO); 3679 f2.keys_pressed.push(bone_ui::input::KeyEvent::new( 3680 bone_ui::input::KeyCode::Named(bone_ui::input::NamedKey::F2), 3681 bone_ui::input::ModifierMask::NONE, 3682 )); 3683 let (_, _) = shell_drive( 3684 &mut shell, 3685 &document, 3686 &Mode::Idle, 3687 &Selection::default(), 3688 &mut focus, 3689 &mut prev, 3690 &mut f2, 3691 ); 3692 assert_eq!( 3693 shell.state.feature_tree.renaming, 3694 Some(widget), 3695 "F2 with sketch row focused must enter rename", 3696 ); 3697 } 3698 3699 fn drive_with_snap( 3700 shell: &mut Shell, 3701 document: &Document, 3702 mode: &Mode, 3703 selection: &Selection, 3704 focus: &mut FocusManager, 3705 prev: &mut HitState, 3706 snap: InputSnapshot, 3707 ) -> (ShellFrame, HitFrame) { 3708 let mut snap = snap; 3709 shell_drive(shell, document, mode, selection, focus, prev, &mut snap) 3710 } 3711 3712 #[test] 3713 fn status_bar_uses_current_sketch_label_when_in_sketch_mode() { 3714 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3715 let (mut document, sketch_id) = document_with_sketch(sketch); 3716 let Ok(()) = document.rename_sketch(sketch_id, "Profile") else { 3717 panic!("rename must accept non-empty label"); 3718 }; 3719 let label = super::mode_status_label( 3720 StringTable::empty(), 3721 &Mode::enter_sketch(sketch_id), 3722 &document, 3723 ); 3724 let LabelText::Owned(text) = label else { 3725 panic!("sketch-mode status label is owned text"); 3726 }; 3727 assert!( 3728 text.contains("Profile"), 3729 "status text must include current sketch label, got {text:?}", 3730 ); 3731 assert!( 3732 !text.contains("Sketch1"), 3733 "status text must not show the prior label after rename, got {text:?}", 3734 ); 3735 } 3736 3737 #[test] 3738 fn sketch_row_hit_rect_lies_within_left_pane_bounds() { 3739 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3740 let (document, sketch_id) = document_with_sketch(sketch); 3741 let mut shell = Shell::new(); 3742 let widget = sketch_widget(&shell.ids, sketch_id); 3743 let mut focus = FocusManager::new(); 3744 let mut prev = HitState::new(); 3745 let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 3746 let (frame, hits) = shell_drive( 3747 &mut shell, 3748 &document, 3749 &Mode::Idle, 3750 &Selection::default(), 3751 &mut focus, 3752 &mut prev, 3753 &mut warm, 3754 ); 3755 let Some(row_rect) = hits 3756 .items() 3757 .iter() 3758 .find(|item| item.id == widget) 3759 .map(|item| item.rect) 3760 else { 3761 panic!("sketch row must register a hit item"); 3762 }; 3763 let viewport = frame.viewport_rect; 3764 let row_right = row_rect.origin.x.value() + row_rect.size.width.value(); 3765 let row_bottom = row_rect.origin.y.value() + row_rect.size.height.value(); 3766 assert!( 3767 row_right <= viewport.origin.x.value(), 3768 "sketch row must sit left of the viewport, row_right={row_right} viewport_x={}", 3769 viewport.origin.x.value(), 3770 ); 3771 assert!( 3772 row_rect.origin.y.value() >= 0.0, 3773 "sketch row origin y >= 0, got {}", 3774 row_rect.origin.y.value(), 3775 ); 3776 assert!( 3777 row_bottom <= 800.0, 3778 "sketch row must fit within 800px tall window, row_bottom={row_bottom}", 3779 ); 3780 } 3781 3782 #[test] 3783 fn click_on_sketch_row_then_f2_enters_rename_via_full_shell() { 3784 use bone_ui::input::{ 3785 KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton, PointerButtonMask, 3786 PointerSample, 3787 }; 3788 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3789 let (document, sketch_id) = document_with_sketch(sketch); 3790 let mut shell = Shell::new(); 3791 let widget = sketch_widget(&shell.ids, sketch_id); 3792 let mut focus = FocusManager::new(); 3793 let mut prev = HitState::new(); 3794 3795 let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 3796 let (_, hits) = shell_drive( 3797 &mut shell, 3798 &document, 3799 &Mode::Idle, 3800 &Selection::default(), 3801 &mut focus, 3802 &mut prev, 3803 &mut warm, 3804 ); 3805 let Some(row_rect) = hits 3806 .items() 3807 .iter() 3808 .find(|item| item.id == widget) 3809 .map(|item| item.rect) 3810 else { 3811 panic!("sketch row must register a hit item in the feature tree"); 3812 }; 3813 let center = LayoutPos::new( 3814 LayoutPx::new(row_rect.origin.x.value() + row_rect.size.width.value() / 2.0), 3815 LayoutPx::new(row_rect.origin.y.value() + row_rect.size.height.value() / 2.0), 3816 ); 3817 3818 let mut press = InputSnapshot::idle(FrameInstant::ZERO); 3819 press.pointer = Some(PointerSample::new(center)); 3820 press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 3821 let _ = drive_with_snap( 3822 &mut shell, 3823 &document, 3824 &Mode::Idle, 3825 &Selection::default(), 3826 &mut focus, 3827 &mut prev, 3828 press, 3829 ); 3830 3831 let mut release = InputSnapshot::idle(FrameInstant::ZERO); 3832 release.pointer = Some(PointerSample::new(center)); 3833 release.buttons_released = PointerButtonMask::just(PointerButton::Primary); 3834 let _ = drive_with_snap( 3835 &mut shell, 3836 &document, 3837 &Mode::Idle, 3838 &Selection::default(), 3839 &mut focus, 3840 &mut prev, 3841 release, 3842 ); 3843 3844 let mut idle = InputSnapshot::idle(FrameInstant::ZERO); 3845 idle.pointer = Some(PointerSample::new(center)); 3846 let _ = drive_with_snap( 3847 &mut shell, 3848 &document, 3849 &Mode::Idle, 3850 &Selection::default(), 3851 &mut focus, 3852 &mut prev, 3853 idle, 3854 ); 3855 3856 assert_eq!( 3857 focus.focused(), 3858 Some(widget), 3859 "click on sketch row must focus it before F2 is pressed", 3860 ); 3861 3862 let mut f2 = InputSnapshot::idle(FrameInstant::ZERO); 3863 f2.pointer = Some(PointerSample::new(center)); 3864 f2.keys_pressed.push(KeyEvent::new( 3865 KeyCode::Named(NamedKey::F2), 3866 ModifierMask::NONE, 3867 )); 3868 let _ = drive_with_snap( 3869 &mut shell, 3870 &document, 3871 &Mode::Idle, 3872 &Selection::default(), 3873 &mut focus, 3874 &mut prev, 3875 f2, 3876 ); 3877 assert_eq!( 3878 shell.state.feature_tree.renaming, 3879 Some(widget), 3880 "click-then-F2 must enter rename mode on sketch row", 3881 ); 3882 } 3883 3884 #[test] 3885 fn double_click_sketch_row_emits_sketch_activated() { 3886 use bone_ui::input::{PointerButton, PointerButtonMask, PointerSample}; 3887 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3888 let (document, sketch_id) = document_with_sketch(sketch); 3889 let mut shell = Shell::new(); 3890 let widget = sketch_widget(&shell.ids, sketch_id); 3891 let mut focus = FocusManager::new(); 3892 let mut prev = HitState::new(); 3893 3894 let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 3895 let (_, hits) = shell_drive( 3896 &mut shell, 3897 &document, 3898 &Mode::Idle, 3899 &Selection::default(), 3900 &mut focus, 3901 &mut prev, 3902 &mut warm, 3903 ); 3904 let Some(row_rect) = hits 3905 .items() 3906 .iter() 3907 .find(|item| item.id == widget) 3908 .map(|item| item.rect) 3909 else { 3910 panic!("sketch row must register a hit item"); 3911 }; 3912 let center = LayoutPos::new( 3913 LayoutPx::new(row_rect.origin.x.value() + row_rect.size.width.value() / 2.0), 3914 LayoutPx::new(row_rect.origin.y.value() + row_rect.size.height.value() / 2.0), 3915 ); 3916 3917 let click = |shell: &mut Shell, focus: &mut FocusManager, prev: &mut HitState| { 3918 let mut press = InputSnapshot::idle(FrameInstant::ZERO); 3919 press.pointer = Some(PointerSample::new(center)); 3920 press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 3921 let _ = drive_with_snap( 3922 shell, 3923 &document, 3924 &Mode::Idle, 3925 &Selection::default(), 3926 focus, 3927 prev, 3928 press, 3929 ); 3930 let mut release = InputSnapshot::idle(FrameInstant::ZERO); 3931 release.pointer = Some(PointerSample::new(center)); 3932 release.buttons_released = PointerButtonMask::just(PointerButton::Primary); 3933 drive_with_snap( 3934 shell, 3935 &document, 3936 &Mode::Idle, 3937 &Selection::default(), 3938 focus, 3939 prev, 3940 release, 3941 ) 3942 }; 3943 3944 let _ = click(&mut shell, &mut focus, &mut prev); 3945 let _ = click(&mut shell, &mut focus, &mut prev); 3946 let mut idle = InputSnapshot::idle(FrameInstant::ZERO); 3947 idle.pointer = Some(PointerSample::new(center)); 3948 let (frame, _) = drive_with_snap( 3949 &mut shell, 3950 &document, 3951 &Mode::Idle, 3952 &Selection::default(), 3953 &mut focus, 3954 &mut prev, 3955 idle, 3956 ); 3957 assert_eq!( 3958 frame.sketch_activated, 3959 Some(sketch_id), 3960 "double-click on sketch row must emit sketch_activated for that sketch", 3961 ); 3962 } 3963 3964 fn render_with_locale(size: LayoutSize, locale: Locale) -> ShellFrame { 3965 let strings = crate::strings::make_strings(locale); 3966 let mut shell = Shell::new(); 3967 render_with_strings( 3968 &mut shell, 3969 Theme::light(), 3970 size, 3971 &sample_document(), 3972 &Mode::Idle, 3973 &Selection::default(), 3974 &strings, 3975 ) 3976 } 3977 3978 const CHROME_BAND_KEYS: [StringKey; 3] = [ 3979 strings::MENU_FILE, 3980 strings::RIBBON_TAB_SKETCH, 3981 strings::STATUS_READY, 3982 ]; 3983 3984 fn assert_chrome_label_mirrors_under_rtl(key: StringKey) { 3985 let size = layout_size(1600.0, 900.0); 3986 let ltr = render_with_locale(size, Locale::EnUs); 3987 let rtl = render_with_locale(size, Locale::ArXb); 3988 let ltr_rect = 3989 label_rect(&ltr.paints, key).unwrap_or_else(|| panic!("ltr paint missing for {key}")); 3990 let rtl_rect = 3991 label_rect(&rtl.paints, key).unwrap_or_else(|| panic!("rtl paint missing for {key}")); 3992 let half = size.width.value() * 0.5; 3993 assert!( 3994 ltr_rect.origin.x.value() < half, 3995 "{key} must sit on the left half under ltr, got x={}", 3996 ltr_rect.origin.x.value(), 3997 ); 3998 assert!( 3999 rtl_rect.origin.x.value() > half, 4000 "{key} must mirror to the right half under rtl, got x={}", 4001 rtl_rect.origin.x.value(), 4002 ); 4003 } 4004 4005 #[test] 4006 fn rtl_locale_flips_viewport_to_the_left_side() { 4007 let size = layout_size(1600.0, 900.0); 4008 let ltr = render_with_locale(size, Locale::EnUs); 4009 let rtl = render_with_locale(size, Locale::ArXb); 4010 assert!( 4011 ltr.viewport_rect.size.width.value() > 0.0, 4012 "ltr viewport must have width", 4013 ); 4014 assert!( 4015 rtl.viewport_rect.size.width.value() > 0.0, 4016 "rtl viewport must have width", 4017 ); 4018 assert!( 4019 ltr.viewport_rect.origin.x.value() > size.width.value() * 0.1, 4020 "ltr viewport sits right of the left pane, got x={}", 4021 ltr.viewport_rect.origin.x.value(), 4022 ); 4023 assert!( 4024 rtl.viewport_rect.origin.x.value() < size.width.value() * 0.1, 4025 "rtl viewport must hug the left edge, got x={}", 4026 rtl.viewport_rect.origin.x.value(), 4027 ); 4028 assert!( 4029 (ltr.viewport_rect.size.width.value() - rtl.viewport_rect.size.width.value()).abs() 4030 < 1.0, 4031 "viewport width is independent of direction", 4032 ); 4033 } 4034 4035 #[test] 4036 fn rtl_locale_still_renders_every_chrome_band() { 4037 let size = layout_size(1600.0, 900.0); 4038 let rtl = render_with_locale(size, Locale::ArXb); 4039 assert!(!rtl.paints.is_empty(), "rtl shell must emit chrome paints"); 4040 CHROME_BAND_KEYS.into_iter().for_each(|key| { 4041 assert!( 4042 label_rect(&rtl.paints, key).is_some(), 4043 "rtl shell must emit a label paint for {key}", 4044 ); 4045 }); 4046 } 4047 4048 #[test] 4049 fn rtl_locale_mirrors_menu_bar_file_label() { 4050 assert_chrome_label_mirrors_under_rtl(strings::MENU_FILE); 4051 } 4052 4053 #[test] 4054 fn rtl_locale_mirrors_ribbon_sketch_tab() { 4055 assert_chrome_label_mirrors_under_rtl(strings::RIBBON_TAB_SKETCH); 4056 } 4057 4058 #[test] 4059 fn rtl_locale_mirrors_status_bar_mode_label() { 4060 assert_chrome_label_mirrors_under_rtl(strings::STATUS_READY); 4061 } 4062 4063 fn render_a11y_scenario( 4064 canvas: LayoutSize, 4065 table: &StringTable, 4066 doc: &Document, 4067 sketch_id: SketchId, 4068 selection: &Selection, 4069 configure: impl FnOnce(&mut Shell), 4070 ) -> (Shell, AccessTreeBuilder, FocusManager) { 4071 let mut shell = Shell::new(); 4072 configure(&mut shell); 4073 let theme = Arc::new(Theme::light()); 4074 let hk = HotkeyTable::new(); 4075 let mut focus = FocusManager::new(); 4076 let mut hits = HitFrame::new(); 4077 let prev = HitState::new(); 4078 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 4079 let mut shaper = bone_text::Shaper::new(); 4080 let mut a11y = AccessTreeBuilder::new(); 4081 { 4082 let mut ctx = FrameCtx::new( 4083 Arc::clone(&theme), 4084 &mut input, 4085 &mut focus, 4086 &hk, 4087 table, 4088 &mut hits, 4089 &prev, 4090 &mut a11y, 4091 &mut shaper, 4092 ); 4093 let _ = shell.render( 4094 &mut ctx, 4095 doc, 4096 &Mode::enter_sketch(sketch_id), 4097 selection, 4098 &Settings::default(), 4099 canvas, 4100 None, 4101 ); 4102 } 4103 (shell, a11y, focus) 4104 } 4105 4106 fn collect_reachable( 4107 update: &accesskit::TreeUpdate, 4108 ) -> std::collections::BTreeSet<accesskit::NodeId> { 4109 let nodes: std::collections::BTreeMap<accesskit::NodeId, &accesskit::Node> = 4110 update.nodes.iter().map(|(id, node)| (*id, node)).collect(); 4111 let mut seen = std::collections::BTreeSet::new(); 4112 if let Some(tree) = update.tree.as_ref() { 4113 visit_reachable(tree.root, &nodes, &mut seen); 4114 } 4115 seen 4116 } 4117 4118 fn visit_reachable( 4119 id: accesskit::NodeId, 4120 nodes: &std::collections::BTreeMap<accesskit::NodeId, &accesskit::Node>, 4121 seen: &mut std::collections::BTreeSet<accesskit::NodeId>, 4122 ) { 4123 if !seen.insert(id) { 4124 return; 4125 } 4126 if let Some(node) = nodes.get(&id) { 4127 node.children() 4128 .iter() 4129 .copied() 4130 .for_each(|c| visit_reachable(c, nodes, seen)); 4131 } 4132 } 4133 4134 fn entity_row_id(idx: usize) -> WidgetId { 4135 WidgetId::ROOT 4136 .child(WidgetKey::new("props.entity")) 4137 .child_indexed(WidgetKey::new("row"), idx as u64) 4138 } 4139 4140 fn relation_row_id(idx: usize) -> WidgetId { 4141 WidgetId::ROOT 4142 .child(WidgetKey::new("props.relation")) 4143 .child_indexed(WidgetKey::new("row"), idx as u64) 4144 } 4145 4146 fn static_row_id(label: StringKey) -> WidgetId { 4147 WidgetId::ROOT 4148 .child(WidgetKey::new("props.row")) 4149 .child(WidgetKey::new(label.id())) 4150 } 4151 4152 fn dim_value_row_id() -> WidgetId { 4153 WidgetId::ROOT 4154 .child(WidgetKey::new("props.dim")) 4155 .child(WidgetKey::new("value")) 4156 } 4157 4158 fn build_a11y_update( 4159 canvas: LayoutSize, 4160 table: &StringTable, 4161 doc: &Document, 4162 sketch_id: SketchId, 4163 selection: &Selection, 4164 configure: impl FnOnce(&mut Shell), 4165 ) -> (Shell, accesskit::TreeUpdate) { 4166 let (shell, a11y, focus) = 4167 render_a11y_scenario(canvas, table, doc, sketch_id, selection, configure); 4168 let update = a11y.build(table, focus.focused()); 4169 (shell, update) 4170 } 4171 4172 fn build_update( 4173 canvas: LayoutSize, 4174 table: &StringTable, 4175 doc: &Document, 4176 sketch_id: SketchId, 4177 selection: &Selection, 4178 configure: impl FnOnce(&mut Shell), 4179 ) -> accesskit::TreeUpdate { 4180 build_a11y_update(canvas, table, doc, sketch_id, selection, configure).1 4181 } 4182 4183 fn chrome_widgets(ids: &ShellIds, sketch_id: SketchId) -> Vec<(WidgetId, &'static str)> { 4184 vec![ 4185 (ids.ribbon, "ribbon"), 4186 (ids.ribbon_smart_dimension, "ribbon.smart_dimension"), 4187 (ids.menu_bar, "menu_bar"), 4188 (ids.menu_file, "menu.file"), 4189 (ids.menu_edit, "menu.edit"), 4190 (ids.menu_view, "menu.view"), 4191 (ids.menu_insert, "menu.insert"), 4192 (ids.menu_tools, "menu.tools"), 4193 (ids.menu_sketch, "menu.sketch"), 4194 (ids.menu_window, "menu.window"), 4195 (ids.menu_help, "menu.help"), 4196 (ids.status_bar, "status_bar"), 4197 (ids.feature_tree, "feature_tree"), 4198 (ids.feature_part, "feature_part"), 4199 (ids.plane_xy, "plane.xy"), 4200 (ids.plane_yz, "plane.yz"), 4201 (ids.plane_zx, "plane.zx"), 4202 (sketch_widget_id(ids.feature_part, sketch_id), "sketch.row"), 4203 (ids.property_pane, "property_pane"), 4204 (ids.doc_tabs, "doc_tabs"), 4205 (ids.doc_tab_model, "doc_tabs.model"), 4206 (ids.left_pane_tab_tree, "left_pane.tab.tree"), 4207 (ids.left_pane_tab_properties, "left_pane.tab.properties"), 4208 ( 4209 ids.left_pane_tab_configuration, 4210 "left_pane.tab.configuration", 4211 ), 4212 ( 4213 ids.left_pane_tab_dimension_expert, 4214 "left_pane.tab.dimension_expert", 4215 ), 4216 (ids.left_pane_tab_display, "left_pane.tab.display"), 4217 (ids.confirm_accept, "confirm.accept"), 4218 (ids.confirm_cancel, "confirm.cancel"), 4219 ] 4220 } 4221 4222 fn tool_widgets(ribbon: WidgetId) -> Vec<(WidgetId, &'static str)> { 4223 SketchTool::ENTITIES 4224 .iter() 4225 .map(|t| (tool_widget_id(ribbon, *t), tool_key(*t))) 4226 .collect() 4227 } 4228 4229 fn relation_widgets(ribbon: WidgetId) -> Vec<(WidgetId, &'static str)> { 4230 RelationKind::ALL 4231 .iter() 4232 .map(|k| (relation_widget_id(ribbon, *k), k.key())) 4233 .collect() 4234 } 4235 4236 fn menu_dropdown_groups(ids: &ShellIds) -> Vec<(WidgetId, Vec<(WidgetId, &'static str)>)> { 4237 vec![ 4238 ( 4239 ids.menu_file, 4240 vec![ 4241 (ids.menu_file_new, "menu.file.new"), 4242 (ids.menu_file_open, "menu.file.open"), 4243 (ids.menu_file_save, "menu.file.save"), 4244 (ids.menu_file_save_as, "menu.file.save_as"), 4245 (ids.menu_file_quit, "menu.file.quit"), 4246 ], 4247 ), 4248 ( 4249 ids.menu_edit, 4250 vec![ 4251 (ids.menu_edit_undo, "menu.edit.undo"), 4252 (ids.menu_edit_redo, "menu.edit.redo"), 4253 ], 4254 ), 4255 ( 4256 ids.menu_view, 4257 vec![(ids.menu_view_zoom_fit, "menu.view.zoom_fit")], 4258 ), 4259 ( 4260 ids.menu_insert, 4261 vec![( 4262 ids.menu_insert.child(WidgetKey::new("soon")), 4263 "menu.insert.soon", 4264 )], 4265 ), 4266 ( 4267 ids.menu_tools, 4268 vec![ 4269 (ids.menu_tools_options, "menu.tools.options"), 4270 (ids.menu_tools_keyboard, "menu.tools.keyboard"), 4271 ], 4272 ), 4273 ( 4274 ids.menu_sketch, 4275 vec![(ids.menu_sketch_exit, "menu.sketch.exit")], 4276 ), 4277 ( 4278 ids.menu_window, 4279 vec![( 4280 ids.menu_window.child(WidgetKey::new("soon")), 4281 "menu.window.soon", 4282 )], 4283 ), 4284 ( 4285 ids.menu_help, 4286 vec![( 4287 ids.menu_help.child(WidgetKey::new("soon")), 4288 "menu.help.soon", 4289 )], 4290 ), 4291 ] 4292 } 4293 4294 fn line_entity_rows() -> Vec<(WidgetId, &'static str)> { 4295 vec![ 4296 (entity_row_id(0), "entity.row.kind"), 4297 (entity_row_id(1), "entity.row.from"), 4298 (entity_row_id(2), "entity.row.to"), 4299 (entity_row_id(3), "entity.row.construction"), 4300 ] 4301 } 4302 4303 fn horizontal_relation_rows() -> Vec<(WidgetId, &'static str)> { 4304 vec![ 4305 (relation_row_id(0), "relation.row.kind"), 4306 (relation_row_id(1), "relation.row.target"), 4307 ] 4308 } 4309 4310 fn linear_dim_rows() -> Vec<(WidgetId, &'static str)> { 4311 vec![ 4312 ( 4313 static_row_id(strings::PROPERTY_ROW_DIM_KIND), 4314 "dim.row.kind", 4315 ), 4316 (static_row_id(strings::PROPERTY_ROW_FROM), "dim.row.from"), 4317 (static_row_id(strings::PROPERTY_ROW_TO), "dim.row.to"), 4318 ( 4319 static_row_id(strings::PROPERTY_ROW_DIM_DRIVES), 4320 "dim.row.drives", 4321 ), 4322 (dim_value_row_id(), "dim.row.value"), 4323 ] 4324 } 4325 4326 fn assert_ribbon_fully_present( 4327 reachable: &std::collections::BTreeSet<accesskit::NodeId>, 4328 ribbon: WidgetId, 4329 ) { 4330 use bone_ui::a11y::widget_node_id; 4331 SketchTool::ENTITIES.iter().for_each(|t| { 4332 let nid = widget_node_id(tool_widget_id(ribbon, *t)); 4333 assert!( 4334 reachable.contains(&nid), 4335 "{} culled by ribbon overflow at base canvas", 4336 tool_key(*t) 4337 ); 4338 }); 4339 RelationKind::ALL.iter().for_each(|k| { 4340 let nid = widget_node_id(relation_widget_id(ribbon, *k)); 4341 assert!( 4342 reachable.contains(&nid), 4343 "{} culled by ribbon overflow at base canvas", 4344 k.key() 4345 ); 4346 }); 4347 } 4348 4349 fn assert_widgets_reachable_and_labeled( 4350 expected: impl Iterator<Item = (WidgetId, &'static str)>, 4351 reachable: &std::collections::BTreeSet<accesskit::NodeId>, 4352 nodes: &std::collections::BTreeMap<accesskit::NodeId, accesskit::Node>, 4353 ) { 4354 use bone_ui::a11y::widget_node_id; 4355 expected.for_each(|(id, name)| { 4356 let nid = widget_node_id(id); 4357 assert!( 4358 reachable.contains(&nid), 4359 "{name} not reachable in a11y tree" 4360 ); 4361 let node = nodes 4362 .get(&nid) 4363 .unwrap_or_else(|| panic!("{name} missing from a11y tree")); 4364 let label = node 4365 .label() 4366 .unwrap_or_else(|| panic!("{name} has no a11y label")); 4367 assert!(!label.is_empty(), "{name} has an empty a11y label"); 4368 }); 4369 } 4370 4371 fn pane_updates( 4372 canvas: LayoutSize, 4373 table: &StringTable, 4374 doc: &Document, 4375 sketch_id: SketchId, 4376 selection: &Selection, 4377 ) -> (Shell, accesskit::TreeUpdate, accesskit::TreeUpdate) { 4378 let (shell, tree_update) = 4379 build_a11y_update(canvas, table, doc, sketch_id, selection, |s| { 4380 s.state.left_pane = LeftPane::Tree; 4381 }); 4382 let props_update = build_update(canvas, table, doc, sketch_id, selection, |s| { 4383 s.state.left_pane = LeftPane::Properties; 4384 }); 4385 (shell, tree_update, props_update) 4386 } 4387 4388 fn open_menu_updates( 4389 canvas: LayoutSize, 4390 table: &StringTable, 4391 doc: &Document, 4392 sketch_id: SketchId, 4393 selection: &Selection, 4394 menus: &[(WidgetId, Vec<(WidgetId, &'static str)>)], 4395 ) -> Vec<accesskit::TreeUpdate> { 4396 menus 4397 .iter() 4398 .map(|(menu_id, _)| { 4399 let menu_id = *menu_id; 4400 build_update(canvas, table, doc, sketch_id, selection, |s| { 4401 s.state.left_pane = LeftPane::Tree; 4402 s.state.menu_bar.open = Some(menu_id); 4403 }) 4404 }) 4405 .collect() 4406 } 4407 4408 fn selection_updates(canvas: LayoutSize, table: &StringTable) -> [accesskit::TreeUpdate; 3] { 4409 let (shared_sketch, rel_id, line_id) = sketch_with_relation(); 4410 let (doc_shared, sketch_id_shared) = document_with_sketch(shared_sketch); 4411 let entity_update = build_update( 4412 canvas, 4413 table, 4414 &doc_shared, 4415 sketch_id_shared, 4416 &Selection::Entities(vec![line_id]), 4417 |s| s.state.left_pane = LeftPane::Properties, 4418 ); 4419 let rel_update = build_update( 4420 canvas, 4421 table, 4422 &doc_shared, 4423 sketch_id_shared, 4424 &Selection::Relation(rel_id), 4425 |s| s.state.left_pane = LeftPane::Properties, 4426 ); 4427 let (dim_sketch, dim_id) = sketch_with_dim(DimensionKind::Driving); 4428 let (doc_dim, sketch_id_dim) = document_with_sketch(dim_sketch); 4429 let dim_update = build_update( 4430 canvas, 4431 table, 4432 &doc_dim, 4433 sketch_id_dim, 4434 &Selection::Dimension(dim_id), 4435 |s| s.state.left_pane = LeftPane::Properties, 4436 ); 4437 [entity_update, rel_update, dim_update] 4438 } 4439 4440 fn union_reachable<'a>( 4441 updates: impl Iterator<Item = &'a accesskit::TreeUpdate>, 4442 ) -> ( 4443 std::collections::BTreeSet<accesskit::NodeId>, 4444 std::collections::BTreeMap<accesskit::NodeId, accesskit::Node>, 4445 ) { 4446 let updates: Vec<&accesskit::TreeUpdate> = updates.collect(); 4447 let reachable = updates.iter().flat_map(|u| collect_reachable(u)).collect(); 4448 let nodes = updates 4449 .iter() 4450 .flat_map(|u| u.nodes.iter().map(|(id, node)| (*id, node.clone()))) 4451 .collect(); 4452 (reachable, nodes) 4453 } 4454 4455 #[test] 4456 fn a11y_smoke_sketch_surface_is_reachable_and_named() { 4457 let table = crate::strings::make_strings(Locale::EnUs); 4458 let canvas = layout_size(3600.0, 900.0); 4459 4460 let (doc_empty, sketch_id_empty) = document_with_sketch(bone_document::Sketch::new( 4461 crate::sketch_mode::Plane::Xy.basis(), 4462 )); 4463 let empty_sel = Selection::default(); 4464 let (shell, tree_update, props_update) = 4465 pane_updates(canvas, &table, &doc_empty, sketch_id_empty, &empty_sel); 4466 4467 let ids = &shell.ids; 4468 let menu_dropdowns = menu_dropdown_groups(ids); 4469 let menu_updates = open_menu_updates( 4470 canvas, 4471 &table, 4472 &doc_empty, 4473 sketch_id_empty, 4474 &empty_sel, 4475 &menu_dropdowns, 4476 ); 4477 let [entity_update, rel_update, dim_update] = selection_updates(canvas, &table); 4478 4479 let (reachable, nodes) = union_reachable( 4480 std::iter::once(&tree_update) 4481 .chain(std::iter::once(&props_update)) 4482 .chain(menu_updates.iter()) 4483 .chain([&entity_update, &rel_update, &dim_update]), 4484 ); 4485 4486 let chrome = chrome_widgets(ids, sketch_id_empty); 4487 let tools = tool_widgets(ids.ribbon); 4488 let relations = relation_widgets(ids.ribbon); 4489 let menu_items: Vec<(WidgetId, &'static str)> = menu_dropdowns 4490 .iter() 4491 .flat_map(|(_, items)| items.iter().copied()) 4492 .collect(); 4493 let expected = chrome 4494 .iter() 4495 .copied() 4496 .chain(tools.iter().copied()) 4497 .chain(relations.iter().copied()) 4498 .chain(menu_items) 4499 .chain(line_entity_rows()) 4500 .chain(horizontal_relation_rows()) 4501 .chain(linear_dim_rows()); 4502 assert_widgets_reachable_and_labeled(expected, &reachable, &nodes); 4503 4504 let tree_reachable = collect_reachable(&tree_update); 4505 assert_ribbon_fully_present(&tree_reachable, ids.ribbon); 4506 4507 let tree_min = chrome.len() + tools.len() + relations.len(); 4508 assert!( 4509 tree_reachable.len() >= tree_min, 4510 "base tree render shrank: {} a11y nodes, expected at least {tree_min}", 4511 tree_reachable.len() 4512 ); 4513 } 4514}