Another project
0

Configure Feed

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

feat(app): menu bar, property pane, preview surface

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

author
Lewis
date (May 9, 2026, 9:40 AM +0300) commit c41e1b0f parent 3b8aae2e change-id zlnzwmxv
+547 -58
+547 -58
crates/bone-app/src/shell.rs
··· 2 2 use std::collections::BTreeMap; 3 3 use std::sync::Arc; 4 4 5 - use bone_document::Document; 5 + use bone_document::{Document, Sketch, SketchEntity}; 6 + use bone_types::{Length, SketchEntityId}; 6 7 use bone_ui::frame::FrameCtx; 7 8 use bone_ui::layout::{ 8 9 Axis, DockPanel, DockState, DockStateError, Layout, LayoutPos, LayoutPx, LayoutRect, 9 10 LayoutSize, NodeKind, PanelId, RetainedLayout, SolvedLayout, SolvedNode, measure, 10 11 }; 11 - use bone_ui::strings::StringKey; 12 + use bone_ui::strings::{StringKey, StringTable}; 12 13 use bone_ui::theme::{ElevationLevel, Radius, Step12, StrokeWidth, Theme}; 13 14 use bone_ui::widgets::{ 14 - MemoryClipboard, PropertyGrid, PropertyRow, Ribbon, RibbonGroup, RibbonIconSize, RibbonState, 15 - RibbonTab, StatusAlign, StatusBar, StatusItem, ToolbarItem, TreeNode, TreeView, TreeViewState, 16 - WidgetPaint, show_property_grid, show_ribbon, show_status_bar, show_tree_view, 15 + Clipboard, LabelText, MemoryClipboard, MenuBar, MenuBarEntry, MenuBarState, MenuItem, 16 + PropertyCell, PropertyEditor, PropertyGrid, PropertyRow, Ribbon, RibbonGroup, RibbonIconSize, 17 + RibbonState, RibbonTab, StatusAlign, StatusBar, StatusItem, ToolbarItem, TreeNode, TreeView, 18 + TreeViewState, WidgetPaint, show_menu_bar, show_property_grid, show_ribbon, show_status_bar, 19 + show_tree_view, 17 20 }; 18 21 use bone_ui::{WidgetId, WidgetKey}; 22 + use uom::si::length::millimeter; 19 23 20 - use crate::sketch_mode::{Mode, SketchTool}; 24 + use crate::sketch_mode::{Mode, Plane, SketchTool}; 21 25 use crate::strings; 22 26 23 - const RIBBON_ENTITY_GROUP_WIDTH: LayoutPx = LayoutPx::new(420.0); 24 - const RIBBON_RELATION_GROUP_WIDTH: LayoutPx = LayoutPx::new(280.0); 25 - const RIBBON_DIMENSION_GROUP_WIDTH: LayoutPx = LayoutPx::new(140.0); 27 + const RIBBON_GROUP_PADDING_PX: f32 = 8.0; 28 + const RIBBON_TOOLBAR_GAP_PX: f32 = 4.0; 29 + const RIBBON_LABEL_HORIZONTAL_PADDING_PX: f32 = 12.0; 30 + const RIBBON_LABEL_AVG_ADVANCE_RATIO: f32 = 0.6; 26 31 const STATUS_MODE_WIDTH: LayoutPx = LayoutPx::new(220.0); 27 32 28 33 #[derive(Debug, thiserror::Error)] ··· 33 38 34 39 #[derive(Copy, Clone, Debug, PartialEq, Eq)] 35 40 struct ShellPanels { 41 + menu_bar: PanelId, 36 42 ribbon: PanelId, 37 43 feature_tree: PanelId, 38 44 property_pane: PanelId, ··· 43 49 impl ShellPanels { 44 50 fn standard() -> Self { 45 51 Self { 46 - ribbon: panel(1), 47 - feature_tree: panel(2), 48 - property_pane: panel(3), 49 - viewport: panel(4), 50 - status: panel(5), 52 + menu_bar: panel(1), 53 + ribbon: panel(2), 54 + feature_tree: panel(3), 55 + property_pane: panel(4), 56 + viewport: panel(5), 57 + status: panel(6), 51 58 } 52 59 } 53 60 } ··· 56 63 struct ShellIds { 57 64 dock_host: WidgetId, 58 65 ribbon: WidgetId, 66 + ribbon_exit: WidgetId, 59 67 feature_tree: WidgetId, 60 68 property_pane: WidgetId, 61 69 viewport: WidgetId, 62 70 status_bar: WidgetId, 63 71 feature_part: WidgetId, 72 + plane_xy: WidgetId, 73 + plane_yz: WidgetId, 74 + plane_zx: WidgetId, 75 + menu_bar: WidgetId, 76 + menu_file: WidgetId, 77 + menu_edit: WidgetId, 78 + menu_view: WidgetId, 79 + menu_insert: WidgetId, 80 + menu_tools: WidgetId, 81 + menu_window: WidgetId, 82 + menu_help: WidgetId, 83 + menu_file_quit: WidgetId, 84 + menu_edit_undo: WidgetId, 85 + menu_edit_redo: WidgetId, 86 + menu_view_zoom_fit: WidgetId, 64 87 } 65 88 66 89 impl ShellIds { 67 90 fn standard() -> Self { 68 91 let root = WidgetId::ROOT.child(WidgetKey::new("shell")); 69 92 let feature_tree = root.child(WidgetKey::new("tree")); 93 + let feature_part = feature_tree.child(WidgetKey::new("part")); 94 + let ribbon = root.child(WidgetKey::new("ribbon")); 95 + let menu_bar = root.child(WidgetKey::new("menu")); 96 + let menu_file = menu_bar.child(WidgetKey::new("file")); 97 + let menu_edit = menu_bar.child(WidgetKey::new("edit")); 98 + let menu_view = menu_bar.child(WidgetKey::new("view")); 70 99 Self { 71 100 dock_host: root.child(WidgetKey::new("dock")), 72 - ribbon: root.child(WidgetKey::new("ribbon")), 101 + ribbon, 102 + ribbon_exit: ribbon.child(WidgetKey::new("tool.exit_sketch")), 73 103 feature_tree, 74 104 property_pane: root.child(WidgetKey::new("props")), 75 105 viewport: root.child(WidgetKey::new("viewport")), 76 106 status_bar: root.child(WidgetKey::new("status")), 77 - feature_part: feature_tree.child(WidgetKey::new("part")), 107 + feature_part, 108 + plane_xy: feature_part.child(WidgetKey::new("plane.xy")), 109 + plane_yz: feature_part.child(WidgetKey::new("plane.yz")), 110 + plane_zx: feature_part.child(WidgetKey::new("plane.zx")), 111 + menu_bar, 112 + menu_file, 113 + menu_edit, 114 + menu_view, 115 + menu_insert: menu_bar.child(WidgetKey::new("insert")), 116 + menu_tools: menu_bar.child(WidgetKey::new("tools")), 117 + menu_window: menu_bar.child(WidgetKey::new("window")), 118 + menu_help: menu_bar.child(WidgetKey::new("help")), 119 + menu_file_quit: menu_file.child(WidgetKey::new("quit")), 120 + menu_edit_undo: menu_edit.child(WidgetKey::new("undo")), 121 + menu_edit_redo: menu_edit.child(WidgetKey::new("redo")), 122 + menu_view_zoom_fit: menu_view.child(WidgetKey::new("zoom_fit")), 78 123 } 79 124 } 125 + 126 + fn plane_for(&self, id: WidgetId) -> Option<Plane> { 127 + [ 128 + (self.plane_xy, Plane::Xy), 129 + (self.plane_yz, Plane::Yz), 130 + (self.plane_zx, Plane::Zx), 131 + ] 132 + .iter() 133 + .copied() 134 + .find_map(|(plane_id, plane)| (plane_id == id).then_some(plane)) 135 + } 136 + 137 + fn menu_action_for(&self, id: WidgetId) -> Option<MenuAction> { 138 + [ 139 + (self.menu_file_quit, MenuAction::Quit), 140 + (self.menu_edit_undo, MenuAction::Undo), 141 + (self.menu_edit_redo, MenuAction::Redo), 142 + (self.menu_view_zoom_fit, MenuAction::ZoomFit), 143 + ] 144 + .iter() 145 + .copied() 146 + .find_map(|(menu_id, action)| (menu_id == id).then_some(action)) 147 + } 148 + } 149 + 150 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 151 + pub enum MenuAction { 152 + Quit, 153 + Undo, 154 + Redo, 155 + ZoomFit, 80 156 } 81 157 82 158 pub struct Shell { ··· 93 169 pub ribbon: RibbonState, 94 170 pub feature_tree: TreeViewState, 95 171 pub clipboard: MemoryClipboard, 172 + pub menu_bar: MenuBarState, 96 173 } 97 174 98 175 #[derive(Clone, Debug, PartialEq)] ··· 100 177 pub paints: Vec<WidgetPaint>, 101 178 pub viewport_rect: LayoutRect, 102 179 pub activated_tool: Option<SketchTool>, 180 + pub plane_picked: Option<Plane>, 181 + pub exit_sketch: bool, 182 + pub menu_action: Option<MenuAction>, 103 183 } 104 184 105 185 impl ShellFrame { ··· 108 188 paints: Vec::new(), 109 189 viewport_rect: zero_rect(), 110 190 activated_tool: None, 191 + plane_picked: None, 192 + exit_sketch: false, 193 + menu_action: None, 111 194 } 112 195 } 113 196 } ··· 117 200 let panels = ShellPanels::standard(); 118 201 let ids = ShellIds::standard(); 119 202 let dock_state = Arc::new(DockState::solidworks_default( 203 + panels.menu_bar, 120 204 panels.feature_tree, 121 205 panels.property_pane, 122 206 panels.ribbon, ··· 141 225 ctx: &mut FrameCtx<'_>, 142 226 document: &Document, 143 227 mode: &Mode, 228 + selection: Option<SketchEntityId>, 144 229 viewport_size: LayoutSize, 145 230 ) -> ShellFrame { 146 231 let theme = ctx.theme(); ··· 150 235 Arc::clone(&self.dock_state), 151 236 vec![ 152 237 DockPanel { 238 + id: self.panels.menu_bar, 239 + child: Layout::leaf(self.ids.menu_bar), 240 + }, 241 + DockPanel { 153 242 id: self.panels.ribbon, 154 243 child: Layout::leaf(self.ids.ribbon), 155 244 }, ··· 179 268 let mut paints = paint_walk(&solved, solved.root_node(), theme, self.panels.viewport); 180 269 let viewport_rect = panel_rect(&solved, self.panels.viewport).unwrap_or_else(zero_rect); 181 270 let ribbon_rect = panel_rect(&solved, self.panels.ribbon).unwrap_or_else(zero_rect); 271 + let menu_bar_rect = panel_rect(&solved, self.panels.menu_bar).unwrap_or_else(zero_rect); 182 272 let tree_rect = panel_rect(&solved, self.panels.feature_tree) 183 273 .map_or_else(zero_rect, |r| inset_rect(r, inset_px)); 184 274 let property_rect = panel_rect(&solved, self.panels.property_pane) 185 275 .map_or_else(zero_rect, |r| inset_rect(r, inset_px)); 186 276 let status_rect = panel_rect(&solved, self.panels.status).unwrap_or_else(zero_rect); 277 + let menu_action = render_menu_bar( 278 + ctx, 279 + menu_bar_rect, 280 + &self.ids, 281 + &mut self.state.menu_bar, 282 + &mut paints, 283 + ); 187 284 let activated_widget = render_ribbon( 188 285 ctx, 189 286 ribbon_rect, 190 287 self.ids.ribbon, 288 + self.ids.ribbon_exit, 191 289 &mut self.state.ribbon, 192 290 mode, 193 291 &mut paints, 194 292 ); 195 - render_feature_tree( 293 + let double_activated = render_feature_tree( 196 294 ctx, 197 295 tree_rect, 198 296 self.ids.feature_tree, ··· 206 304 property_rect, 207 305 self.ids.property_pane, 208 306 &mut self.state.clipboard, 307 + document, 308 + mode, 309 + selection, 209 310 &mut paints, 210 311 ); 211 312 render_status_bar(ctx, status_rect, self.ids.status_bar, mode, &mut paints); 313 + let exit_sketch = activated_widget == Some(self.ids.ribbon_exit); 314 + let activated_tool = activated_widget.and_then(|id| self.tool_index.get(&id).copied()); 315 + let plane_picked = double_activated.and_then(|id| self.ids.plane_for(id)); 212 316 ShellFrame { 213 317 paints, 214 318 viewport_rect, 215 - activated_tool: activated_widget.and_then(|id| self.tool_index.get(&id).copied()), 319 + activated_tool, 320 + plane_picked, 321 + exit_sketch, 322 + menu_action, 216 323 } 217 324 } 218 325 } 219 326 327 + fn render_menu_bar( 328 + ctx: &mut FrameCtx<'_>, 329 + rect: LayoutRect, 330 + ids: &ShellIds, 331 + state: &mut MenuBarState, 332 + paints: &mut Vec<WidgetPaint>, 333 + ) -> Option<MenuAction> { 334 + if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 335 + return None; 336 + } 337 + let placeholder = |menu_id: WidgetId, key: &'static str| MenuItem::Action { 338 + id: menu_id.child(WidgetKey::new(key)), 339 + label: strings::MENU_PLACEHOLDER_COMING_SOON, 340 + shortcut: None, 341 + disabled: true, 342 + }; 343 + let file_items = vec![ 344 + MenuItem::Action { 345 + id: ids.menu_file.child(WidgetKey::new("new")), 346 + label: strings::MENU_FILE_NEW, 347 + shortcut: None, 348 + disabled: true, 349 + }, 350 + MenuItem::Action { 351 + id: ids.menu_file.child(WidgetKey::new("open")), 352 + label: strings::MENU_FILE_OPEN, 353 + shortcut: None, 354 + disabled: true, 355 + }, 356 + MenuItem::Action { 357 + id: ids.menu_file.child(WidgetKey::new("save")), 358 + label: strings::MENU_FILE_SAVE, 359 + shortcut: None, 360 + disabled: true, 361 + }, 362 + MenuItem::Separator, 363 + MenuItem::Action { 364 + id: ids.menu_file_quit, 365 + label: strings::MENU_FILE_QUIT, 366 + shortcut: Some(strings::SHORTCUT_QUIT), 367 + disabled: false, 368 + }, 369 + ]; 370 + let edit_items = vec![ 371 + MenuItem::Action { 372 + id: ids.menu_edit_undo, 373 + label: strings::MENU_EDIT_UNDO, 374 + shortcut: Some(strings::SHORTCUT_UNDO), 375 + disabled: false, 376 + }, 377 + MenuItem::Action { 378 + id: ids.menu_edit_redo, 379 + label: strings::MENU_EDIT_REDO, 380 + shortcut: Some(strings::SHORTCUT_REDO), 381 + disabled: false, 382 + }, 383 + ]; 384 + let view_items = vec![MenuItem::Action { 385 + id: ids.menu_view_zoom_fit, 386 + label: strings::MENU_VIEW_ZOOM_FIT, 387 + shortcut: Some(strings::SHORTCUT_ZOOM_FIT), 388 + disabled: false, 389 + }]; 390 + let entries = vec![ 391 + MenuBarEntry { 392 + id: ids.menu_file, 393 + label: strings::MENU_FILE, 394 + items: file_items, 395 + }, 396 + MenuBarEntry { 397 + id: ids.menu_edit, 398 + label: strings::MENU_EDIT, 399 + items: edit_items, 400 + }, 401 + MenuBarEntry { 402 + id: ids.menu_view, 403 + label: strings::MENU_VIEW, 404 + items: view_items, 405 + }, 406 + MenuBarEntry { 407 + id: ids.menu_insert, 408 + label: strings::MENU_INSERT, 409 + items: vec![placeholder(ids.menu_insert, "soon")], 410 + }, 411 + MenuBarEntry { 412 + id: ids.menu_tools, 413 + label: strings::MENU_TOOLS, 414 + items: vec![placeholder(ids.menu_tools, "soon")], 415 + }, 416 + MenuBarEntry { 417 + id: ids.menu_window, 418 + label: strings::MENU_WINDOW, 419 + items: vec![placeholder(ids.menu_window, "soon")], 420 + }, 421 + MenuBarEntry { 422 + id: ids.menu_help, 423 + label: strings::MENU_HELP, 424 + items: vec![placeholder(ids.menu_help, "soon")], 425 + }, 426 + ]; 427 + let response = show_menu_bar( 428 + ctx, 429 + MenuBar::new(ids.menu_bar, rect, strings::MENU_BAR_LABEL, &entries, state), 430 + ); 431 + paints.extend(response.paint); 432 + response.activated.and_then(|id| ids.menu_action_for(id)) 433 + } 434 + 220 435 fn render_ribbon( 221 436 ctx: &mut FrameCtx<'_>, 222 437 rect: LayoutRect, 223 438 ribbon: WidgetId, 439 + ribbon_exit: WidgetId, 224 440 state: &mut RibbonState, 225 441 mode: &Mode, 226 442 paints: &mut Vec<WidgetPaint>, ··· 232 448 Mode::Sketch { session, .. } => session.tool, 233 449 Mode::Idle => None, 234 450 }; 451 + let tools_disabled = !mode.is_sketch(); 452 + let label_font_size_px = ctx.theme().typography.caption.size.as_px_f32(); 453 + let size_item = |item: ToolbarItem, min_width: LayoutPx| -> ToolbarItem { 454 + let resolved = ctx.strings.resolve(item.label); 455 + let width = estimate_label_width(resolved, label_font_size_px, min_width); 456 + item.with_width(width) 457 + }; 458 + let large_min = RibbonIconSize::Large.item_px(); 459 + let small_min = RibbonIconSize::Small.item_px(); 235 460 let entity_items: Vec<ToolbarItem> = SketchTool::ENTITIES 236 461 .iter() 237 462 .copied() 238 463 .map(|t| { 239 - ToolbarItem::new(tool_widget_id(ribbon, t), tool_label(t)) 240 - .active(active_tool == Some(t)) 464 + size_item( 465 + ToolbarItem::new(tool_widget_id(ribbon, t), tool_label(t)) 466 + .active(active_tool == Some(t)) 467 + .disabled(tools_disabled), 468 + large_min, 469 + ) 241 470 }) 242 471 .collect(); 243 - let dimension_items = vec![ 472 + let dimension_items = vec![size_item( 244 473 ToolbarItem::new( 245 474 tool_widget_id(ribbon, SketchTool::SmartDimension), 246 475 strings::TOOL_SMART_DIMENSION, 247 476 ) 248 - .active(active_tool == Some(SketchTool::SmartDimension)), 249 - ]; 250 - let relation_items = relation_tool_buttons(ribbon); 477 + .active(active_tool == Some(SketchTool::SmartDimension)) 478 + .disabled(tools_disabled), 479 + large_min, 480 + )]; 481 + let relation_items: Vec<ToolbarItem> = relation_tool_buttons(ribbon, tools_disabled) 482 + .into_iter() 483 + .map(|item| size_item(item, small_min)) 484 + .collect(); 485 + let exit_items = vec![size_item( 486 + ToolbarItem::new(ribbon_exit, strings::TOOL_EXIT_SKETCH), 487 + large_min, 488 + )]; 251 489 let tab_id = ribbon.child(WidgetKey::new("tab.sketch")); 252 - let tabs = [RibbonTab::new( 253 - tab_id, 254 - strings::RIBBON_TAB_SKETCH, 255 - vec![ 256 - RibbonGroup { 257 - id: ribbon.child(WidgetKey::new("group.entities")), 258 - label: strings::RIBBON_GROUP_ENTITIES, 259 - items: entity_items, 260 - icon_size: RibbonIconSize::Large, 261 - width: RIBBON_ENTITY_GROUP_WIDTH, 262 - }, 263 - RibbonGroup { 264 - id: ribbon.child(WidgetKey::new("group.relations")), 265 - label: strings::RIBBON_GROUP_RELATIONS, 266 - items: relation_items, 267 - icon_size: RibbonIconSize::Small, 268 - width: RIBBON_RELATION_GROUP_WIDTH, 269 - }, 270 - RibbonGroup { 271 - id: ribbon.child(WidgetKey::new("group.dimensions")), 272 - label: strings::RIBBON_GROUP_DIMENSIONS, 273 - items: dimension_items, 274 - icon_size: RibbonIconSize::Large, 275 - width: RIBBON_DIMENSION_GROUP_WIDTH, 276 - }, 277 - ], 278 - )]; 490 + let exit_group = mode.is_sketch().then(|| RibbonGroup { 491 + id: ribbon.child(WidgetKey::new("group.exit")), 492 + label: strings::RIBBON_GROUP_EXIT, 493 + width: group_width_for(&exit_items, large_min), 494 + items: exit_items, 495 + icon_size: RibbonIconSize::Large, 496 + }); 497 + let groups: Vec<RibbonGroup> = [ 498 + RibbonGroup { 499 + id: ribbon.child(WidgetKey::new("group.entities")), 500 + label: strings::RIBBON_GROUP_ENTITIES, 501 + width: group_width_for(&entity_items, large_min), 502 + items: entity_items, 503 + icon_size: RibbonIconSize::Large, 504 + }, 505 + RibbonGroup { 506 + id: ribbon.child(WidgetKey::new("group.relations")), 507 + label: strings::RIBBON_GROUP_RELATIONS, 508 + width: group_width_for(&relation_items, small_min), 509 + items: relation_items, 510 + icon_size: RibbonIconSize::Small, 511 + }, 512 + RibbonGroup { 513 + id: ribbon.child(WidgetKey::new("group.dimensions")), 514 + label: strings::RIBBON_GROUP_DIMENSIONS, 515 + width: group_width_for(&dimension_items, large_min), 516 + items: dimension_items, 517 + icon_size: RibbonIconSize::Large, 518 + }, 519 + ] 520 + .into_iter() 521 + .chain(exit_group) 522 + .collect(); 523 + let tabs = [RibbonTab::new(tab_id, strings::RIBBON_TAB_SKETCH, groups)]; 279 524 let response = show_ribbon( 280 525 ctx, 281 526 Ribbon::new(ribbon, rect, strings::RIBBON_LABEL, &tabs, tab_id, state), ··· 292 537 state: &mut TreeViewState, 293 538 document: &Document, 294 539 paints: &mut Vec<WidgetPaint>, 295 - ) { 540 + ) -> Option<WidgetId> { 296 541 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 297 - return; 542 + return None; 298 543 } 299 544 let leaf = |key: &'static str, label: StringKey| { 300 545 TreeNode::leaf(part_id.child(WidgetKey::new(key)), label) ··· 325 570 TreeView::new(tree_id, rect, strings::FEATURE_TREE_LABEL, &roots, state), 326 571 ); 327 572 paints.extend(response.paint); 573 + response.double_activated 328 574 } 329 575 330 576 fn render_property_pane( ··· 332 578 rect: LayoutRect, 333 579 id: WidgetId, 334 580 clipboard: &mut MemoryClipboard, 581 + document: &Document, 582 + mode: &Mode, 583 + selection: Option<SketchEntityId>, 335 584 paints: &mut Vec<WidgetPaint>, 336 585 ) { 337 586 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 338 587 return; 339 588 } 340 - let mut rows: [PropertyRow<'_>; 0] = []; 589 + let active_sketch = match mode { 590 + Mode::Sketch { sketch_id, .. } => document.sketch(*sketch_id), 591 + Mode::Idle => None, 592 + }; 593 + let resolved = selection 594 + .zip(active_sketch) 595 + .and_then(|(sel, sketch)| sketch.entities().get(sel).map(|e| (*e, sketch))); 596 + let mut editors = match resolved { 597 + Some((entity, sketch)) => entity_editors(ctx.strings, entity, sketch), 598 + None => vec![row_editor(strings::PROPERTY_PANE_NO_SELECTION, "")], 599 + }; 600 + let mut rows: Vec<PropertyRow<'_>> = editors 601 + .iter_mut() 602 + .map(|(row_id, label, editor)| PropertyRow { 603 + id: *row_id, 604 + label: *label, 605 + editor: editor.as_mut(), 606 + read_only: true, 607 + }) 608 + .collect(); 341 609 let response = show_property_grid( 342 610 ctx, 343 611 PropertyGrid::new(id, rect, strings::PROPERTY_PANE_LABEL, &mut rows), ··· 346 614 paints.extend(response.paint); 347 615 } 348 616 617 + type PropertyRowSpec = (WidgetId, StringKey, Box<dyn PropertyEditor>); 618 + 619 + fn row_editor(label: StringKey, value: impl Into<String>) -> PropertyRowSpec { 620 + let row_id = WidgetId::ROOT 621 + .child(WidgetKey::new("props.row")) 622 + .child(WidgetKey::new(label.id())); 623 + let editor: Box<dyn PropertyEditor> = Box::new(StaticTextEditor::new(value.into())); 624 + (row_id, label, editor) 625 + } 626 + 627 + fn entity_editors( 628 + strings_table: &StringTable, 629 + entity: SketchEntity, 630 + sketch: &Sketch, 631 + ) -> Vec<PropertyRowSpec> { 632 + let yes_no = |b: bool| { 633 + if b { 634 + strings_table.resolve(strings::PROPERTY_VALUE_YES).to_owned() 635 + } else { 636 + strings_table.resolve(strings::PROPERTY_VALUE_NO).to_owned() 637 + } 638 + }; 639 + match entity { 640 + SketchEntity::Point(p) => { 641 + let (x, y) = p.at().coords_mm(); 642 + vec![ 643 + row_editor( 644 + strings::PROPERTY_ROW_KIND, 645 + strings_table.resolve(strings::PROPERTY_KIND_POINT).to_owned(), 646 + ), 647 + row_editor(strings::PROPERTY_ROW_X, format_mm(x)), 648 + row_editor(strings::PROPERTY_ROW_Y, format_mm(y)), 649 + ] 650 + } 651 + SketchEntity::Line(l) => { 652 + let from = endpoint_label(strings_table, sketch, l.a()); 653 + let to = endpoint_label(strings_table, sketch, l.b()); 654 + vec![ 655 + row_editor( 656 + strings::PROPERTY_ROW_KIND, 657 + strings_table.resolve(strings::PROPERTY_KIND_LINE).to_owned(), 658 + ), 659 + row_editor(strings::PROPERTY_ROW_FROM, from), 660 + row_editor(strings::PROPERTY_ROW_TO, to), 661 + row_editor(strings::PROPERTY_ROW_CONSTRUCTION, yes_no(l.for_construction())), 662 + ] 663 + } 664 + SketchEntity::Arc(a) => { 665 + let center = endpoint_label(strings_table, sketch, a.center()); 666 + let start = endpoint_label(strings_table, sketch, a.start()); 667 + let end = endpoint_label(strings_table, sketch, a.end()); 668 + vec![ 669 + row_editor( 670 + strings::PROPERTY_ROW_KIND, 671 + strings_table.resolve(strings::PROPERTY_KIND_ARC).to_owned(), 672 + ), 673 + row_editor(strings::PROPERTY_ROW_CENTER, center), 674 + row_editor(strings::PROPERTY_ROW_START, start), 675 + row_editor(strings::PROPERTY_ROW_END, end), 676 + row_editor(strings::PROPERTY_ROW_CONSTRUCTION, yes_no(a.for_construction())), 677 + ] 678 + } 679 + SketchEntity::Circle(c) => { 680 + let center = endpoint_label(strings_table, sketch, c.center()); 681 + vec![ 682 + row_editor( 683 + strings::PROPERTY_ROW_KIND, 684 + strings_table.resolve(strings::PROPERTY_KIND_CIRCLE).to_owned(), 685 + ), 686 + row_editor(strings::PROPERTY_ROW_CENTER, center), 687 + row_editor(strings::PROPERTY_ROW_RADIUS, format_length(c.radius())), 688 + row_editor(strings::PROPERTY_ROW_CONSTRUCTION, yes_no(c.for_construction())), 689 + ] 690 + } 691 + } 692 + .into_iter() 693 + .enumerate() 694 + .map(|(idx, (_default_id, label, editor))| { 695 + let row_id = WidgetId::ROOT 696 + .child(WidgetKey::new("props.entity")) 697 + .child_indexed(WidgetKey::new("row"), idx as u64); 698 + (row_id, label, editor) 699 + }) 700 + .collect() 701 + } 702 + 703 + fn endpoint_label(strings_table: &StringTable, sketch: &Sketch, id: SketchEntityId) -> String { 704 + match sketch.entities().get(id) { 705 + Some(SketchEntity::Point(p)) => { 706 + let (x, y) = p.at().coords_mm(); 707 + format!("({}, {})", format_mm(x), format_mm(y)) 708 + } 709 + Some(_) => strings_table 710 + .resolve(strings::PROPERTY_PANE_NO_SELECTION) 711 + .to_owned(), 712 + None => "—".to_owned(), 713 + } 714 + } 715 + 716 + fn format_mm(value: f64) -> String { 717 + format!("{value:.3} mm") 718 + } 719 + 720 + fn format_length(length: Length) -> String { 721 + format_mm(length.get::<millimeter>()) 722 + } 723 + 724 + struct StaticTextEditor { 725 + value: String, 726 + } 727 + 728 + impl StaticTextEditor { 729 + fn new(value: String) -> Self { 730 + Self { value } 731 + } 732 + } 733 + 734 + impl PropertyEditor for StaticTextEditor { 735 + fn render( 736 + &mut self, 737 + ctx: &mut FrameCtx<'_>, 738 + cell: PropertyCell, 739 + _clipboard: &mut dyn Clipboard, 740 + paint: &mut Vec<WidgetPaint>, 741 + ) -> bool { 742 + paint.push(WidgetPaint::Label { 743 + rect: cell.rect, 744 + text: LabelText::Owned(self.value.clone()), 745 + color: ctx.theme().colors.text_primary(), 746 + role: ctx.theme().typography.label, 747 + }); 748 + false 749 + } 750 + } 751 + 349 752 fn render_status_bar( 350 753 ctx: &mut FrameCtx<'_>, 351 754 rect: LayoutRect, ··· 374 777 paints.extend(response.paint); 375 778 } 376 779 377 - fn relation_tool_buttons(ribbon: WidgetId) -> Vec<ToolbarItem> { 780 + fn estimate_label_width(text: &str, font_size_px: f32, min_width: LayoutPx) -> LayoutPx { 781 + #[allow( 782 + clippy::cast_precision_loss, 783 + reason = "string lengths fit in f32 mantissa for any realistic label" 784 + )] 785 + let chars = text.chars().count() as f32; 786 + let est = chars * font_size_px * RIBBON_LABEL_AVG_ADVANCE_RATIO 787 + + 2.0 * RIBBON_LABEL_HORIZONTAL_PADDING_PX; 788 + LayoutPx::new(est.max(min_width.value())) 789 + } 790 + 791 + fn group_width_for(items: &[ToolbarItem], fallback_item_size: LayoutPx) -> LayoutPx { 792 + let total: f32 = items 793 + .iter() 794 + .enumerate() 795 + .map(|(i, it)| { 796 + it.width.unwrap_or(fallback_item_size).value() 797 + + if i == 0 { 0.0 } else { RIBBON_TOOLBAR_GAP_PX } 798 + }) 799 + .sum(); 800 + LayoutPx::new(total + 2.0 * RIBBON_GROUP_PADDING_PX) 801 + } 802 + 803 + fn relation_tool_buttons(ribbon: WidgetId, disabled: bool) -> Vec<ToolbarItem> { 378 804 [ 379 805 ("coincident", strings::TOOL_COINCIDENT), 380 806 ("horizontal", strings::TOOL_HORIZONTAL), ··· 387 813 ("fix", strings::TOOL_FIX), 388 814 ] 389 815 .into_iter() 390 - .map(|(key, label)| ToolbarItem::new(ribbon.child(WidgetKey::new(key)), label)) 816 + .map(|(key, label)| { 817 + ToolbarItem::new(ribbon.child(WidgetKey::new(key)), label).disabled(disabled) 818 + }) 391 819 .collect() 392 820 } 393 821 ··· 557 985 mod tests { 558 986 use super::*; 559 987 use bone_document::Document; 560 - use bone_types::DocumentId; 988 + use bone_types::{DocumentId, SketchId}; 561 989 use bone_ui::a11y::AccessTreeBuilder; 562 990 use bone_ui::focus::FocusManager; 563 991 use bone_ui::hit_test::{HitFrame, HitState}; ··· 565 993 use bone_ui::input::{FrameInstant, InputSnapshot}; 566 994 use bone_ui::strings::StringTable; 567 995 use bone_ui::theme::Theme; 996 + use bone_ui::widgets::LabelText; 568 997 use std::sync::Arc; 569 998 570 999 fn layout_size(w: f32, h: f32) -> LayoutSize { ··· 596 1025 &prev, 597 1026 &mut a11y, 598 1027 ); 599 - shell.render(&mut ctx, document, mode, size) 1028 + shell.render(&mut ctx, document, mode, None, size) 600 1029 } 601 1030 602 1031 #[test] ··· 673 1102 let id = tool_widget_id(ids.ribbon, t); 674 1103 assert_eq!(index.get(&id).copied(), Some(t)); 675 1104 }); 1105 + } 1106 + 1107 + #[test] 1108 + fn plane_for_recognizes_each_principal_plane() { 1109 + let ids = ShellIds::standard(); 1110 + assert_eq!(ids.plane_for(ids.plane_xy), Some(Plane::Xy)); 1111 + assert_eq!(ids.plane_for(ids.plane_yz), Some(Plane::Yz)); 1112 + assert_eq!(ids.plane_for(ids.plane_zx), Some(Plane::Zx)); 1113 + assert_eq!(ids.plane_for(ids.feature_tree), None); 1114 + assert_eq!(ids.plane_for(ids.ribbon_exit), None); 1115 + } 1116 + 1117 + #[test] 1118 + fn ribbon_exit_id_is_distinct_from_tool_ids() { 1119 + let ids = ShellIds::standard(); 1120 + let tools = build_tool_index(ids.ribbon); 1121 + assert!(!tools.contains_key(&ids.ribbon_exit)); 1122 + } 1123 + 1124 + #[test] 1125 + fn idle_render_emits_no_state_machine_signals() { 1126 + let frame = render_with( 1127 + Theme::light(), 1128 + layout_size(1280.0, 800.0), 1129 + &sample_document(), 1130 + &Mode::Idle, 1131 + ); 1132 + assert_eq!(frame.plane_picked, None); 1133 + assert!(!frame.exit_sketch); 1134 + assert!(frame.activated_tool.is_none()); 1135 + } 1136 + 1137 + #[test] 1138 + fn idle_render_omits_exit_group_label() { 1139 + let frame = render_with( 1140 + Theme::light(), 1141 + layout_size(1280.0, 800.0), 1142 + &sample_document(), 1143 + &Mode::Idle, 1144 + ); 1145 + assert!(!frame.paints.iter().any(is_exit_group_label)); 1146 + } 1147 + 1148 + #[test] 1149 + fn sketch_render_includes_exit_group_label() { 1150 + let frame = render_with( 1151 + Theme::light(), 1152 + layout_size(1280.0, 800.0), 1153 + &sample_document(), 1154 + &Mode::enter_sketch(SketchId::default()), 1155 + ); 1156 + assert!(frame.paints.iter().any(is_exit_group_label)); 1157 + } 1158 + 1159 + fn is_exit_group_label(paint: &WidgetPaint) -> bool { 1160 + matches!( 1161 + paint, 1162 + WidgetPaint::Label { text: LabelText::Key(key), .. } 1163 + if *key == crate::strings::RIBBON_GROUP_EXIT 1164 + ) 676 1165 } 677 1166 }