Another project
0

Configure Feed

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

feat(app): tabbed left pane + dimension dialog wiring

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

author
Lewis
date (May 17, 2026, 11:01 AM +0300) commit 42955ec7 parent 50d1cb3c change-id skqunkko
+1550 -237
+1550 -237
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, Sketch, SketchEntity, SketchRelation}; 6 - use bone_types::{Length, SketchEntityId}; 7 - use bone_ui::frame::FrameCtx; 5 + use bone_document::{ 6 + DimensionKind, DimensionValue, Document, Sketch, SketchDimension, SketchEntity, SketchRelation, 7 + }; 8 + use bone_types::{Length, Point2, SketchDimensionId, SketchEntityId, SketchId}; 9 + use bone_ui::a11y::{AccessNode, Role}; 10 + use bone_ui::frame::{FrameCtx, InteractDeclaration}; 11 + use bone_ui::hit_test::Sense; 12 + use bone_ui::widgets::GlyphMark; 8 13 use bone_ui::layout::{ 9 - Axis, DockPanel, DockState, DockStateError, Layout, LayoutPos, LayoutPx, LayoutRect, 10 - LayoutSize, NodeKind, PanelId, RetainedLayout, SolvedLayout, SolvedNode, measure, 14 + Axis, DockNode, DockPanel, DockState, GridChild, GridLine, GridSpan, GridTrack, 15 + Layout, LayoutPos, LayoutPx, LayoutRect, LayoutSize, NodeKind, PanelId, RetainedLayout, 16 + SolvedLayout, SolvedNode, SplitFraction, TrackSize, measure, 11 17 }; 12 18 use bone_ui::strings::{StringKey, StringTable}; 13 - use bone_ui::theme::{ElevationLevel, Radius, Spacing, Step12, StrokeWidth, Theme}; 19 + use bone_ui::theme::{Border, ElevationLevel, Radius, Spacing, Step12, StrokeWidth, Theme}; 14 20 use bone_ui::widgets::{ 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, 21 + AngleEditor, Clipboard, Dialog, DialogButton, LabelText, LengthEditor, MemoryClipboard, 22 + MenuBar, MenuBarEntry, MenuBarState, MenuItem, PropertyCell, PropertyEditor, PropertyGrid, 23 + PropertyRow, Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, Slider, SliderRange, 24 + SliderStep, StatusAlign, StatusBar, StatusItem, Tab, Tabs, TabsOrientation, ToolbarItem, 25 + TreeNode, TreeView, TreeViewState, WidgetPaint, show_dialog, show_menu_bar, show_property_grid, 26 + show_ribbon, show_slider, show_status_bar, show_tabs, show_tree_view, 20 27 }; 21 28 use bone_ui::{WidgetId, WidgetKey}; 22 29 use uom::si::length::millimeter; 30 + 31 + use bone_render::PickAperture; 23 32 24 33 use crate::relation_tools::{Eligibility, RelationKind, eligibility}; 34 + use crate::selection::Selection; 35 + use crate::settings::Settings; 25 36 use crate::sketch_mode::PendingDimension; 26 37 use crate::sketch_mode::{Mode, Plane, SketchTool}; 27 38 use crate::smart_dimension; ··· 32 43 const RIBBON_LABEL_HORIZONTAL_PADDING_PX: f32 = 12.0; 33 44 const RIBBON_LABEL_AVG_ADVANCE_RATIO: f32 = 0.6; 34 45 const STATUS_MODE_WIDTH: LayoutPx = LayoutPx::new(220.0); 35 - 36 - #[derive(Debug, thiserror::Error)] 37 - pub enum ShellError { 38 - #[error("dock state: {0}")] 39 - Dock(#[from] DockStateError), 40 - } 46 + const STATUS_UNITS_WIDTH: LayoutPx = LayoutPx::new(80.0); 47 + const STATUS_COORDS_WIDTH: LayoutPx = LayoutPx::new(180.0); 41 48 42 49 #[derive(Copy, Clone, Debug, PartialEq, Eq)] 43 50 struct ShellPanels { 44 - menu_bar: PanelId, 45 - ribbon: PanelId, 46 - feature_tree: PanelId, 47 - property_pane: PanelId, 51 + left_pane: PanelId, 48 52 viewport: PanelId, 49 - status: PanelId, 50 53 } 51 54 52 55 impl ShellPanels { 53 56 fn standard() -> Self { 54 57 Self { 55 - menu_bar: panel(1), 56 - ribbon: panel(2), 57 - feature_tree: panel(3), 58 - property_pane: panel(4), 58 + left_pane: panel(3), 59 59 viewport: panel(5), 60 - status: panel(6), 61 60 } 62 61 } 63 62 } ··· 66 65 struct ShellIds { 67 66 dock_host: WidgetId, 68 67 ribbon: WidgetId, 69 - ribbon_exit: WidgetId, 70 68 ribbon_smart_dimension: WidgetId, 69 + left_pane: WidgetId, 70 + left_pane_tab_tree: WidgetId, 71 + left_pane_tab_properties: WidgetId, 72 + left_pane_tab_configuration: WidgetId, 73 + left_pane_tab_dimension_expert: WidgetId, 74 + left_pane_tab_display: WidgetId, 71 75 feature_tree: WidgetId, 72 76 property_pane: WidgetId, 73 77 viewport: WidgetId, 78 + confirm_accept: WidgetId, 79 + confirm_cancel: WidgetId, 74 80 status_bar: WidgetId, 81 + doc_tabs: WidgetId, 82 + doc_tab_model: WidgetId, 75 83 feature_part: WidgetId, 76 84 plane_xy: WidgetId, 77 85 plane_yz: WidgetId, ··· 82 90 menu_view: WidgetId, 83 91 menu_insert: WidgetId, 84 92 menu_tools: WidgetId, 93 + menu_sketch: WidgetId, 85 94 menu_window: WidgetId, 86 95 menu_help: WidgetId, 87 96 menu_file_quit: WidgetId, 88 97 menu_edit_undo: WidgetId, 89 98 menu_edit_redo: WidgetId, 90 99 menu_view_zoom_fit: WidgetId, 100 + menu_tools_options: WidgetId, 101 + menu_sketch_exit: WidgetId, 102 + settings_dialog: WidgetId, 103 + settings_aperture_slider: WidgetId, 104 + settings_reset: WidgetId, 105 + settings_close: WidgetId, 91 106 } 92 107 93 108 impl ShellIds { 94 109 fn standard() -> Self { 95 110 let root = WidgetId::ROOT.child(WidgetKey::new("shell")); 96 - let feature_tree = root.child(WidgetKey::new("tree")); 111 + let left_pane = root.child(WidgetKey::new("left")); 112 + let feature_tree = left_pane.child(WidgetKey::new("tree")); 97 113 let feature_part = feature_tree.child(WidgetKey::new("part")); 98 114 let ribbon = root.child(WidgetKey::new("ribbon")); 99 115 let menu_bar = root.child(WidgetKey::new("menu")); 100 116 let menu_file = menu_bar.child(WidgetKey::new("file")); 101 117 let menu_edit = menu_bar.child(WidgetKey::new("edit")); 102 118 let menu_view = menu_bar.child(WidgetKey::new("view")); 119 + let menu_tools = menu_bar.child(WidgetKey::new("tools")); 120 + let menu_sketch = menu_bar.child(WidgetKey::new("sketch")); 121 + let settings_dialog = root.child(WidgetKey::new("settings.dialog")); 122 + let viewport = root.child(WidgetKey::new("viewport")); 103 123 Self { 104 124 dock_host: root.child(WidgetKey::new("dock")), 105 125 ribbon, 106 - ribbon_exit: ribbon.child(WidgetKey::new("tool.exit_sketch")), 107 126 ribbon_smart_dimension: ribbon.child(WidgetKey::new("tool.smart_dimension")), 127 + left_pane, 128 + left_pane_tab_tree: left_pane.child(WidgetKey::new("tab.tree")), 129 + left_pane_tab_properties: left_pane.child(WidgetKey::new("tab.props")), 130 + left_pane_tab_configuration: left_pane.child(WidgetKey::new("tab.config")), 131 + left_pane_tab_dimension_expert: left_pane.child(WidgetKey::new("tab.dimxpert")), 132 + left_pane_tab_display: left_pane.child(WidgetKey::new("tab.display")), 108 133 feature_tree, 109 - property_pane: root.child(WidgetKey::new("props")), 110 - viewport: root.child(WidgetKey::new("viewport")), 134 + property_pane: left_pane.child(WidgetKey::new("props")), 135 + viewport, 136 + confirm_accept: viewport.child(WidgetKey::new("confirm.accept")), 137 + confirm_cancel: viewport.child(WidgetKey::new("confirm.cancel")), 111 138 status_bar: root.child(WidgetKey::new("status")), 139 + doc_tabs: root.child(WidgetKey::new("doc_tabs")), 140 + doc_tab_model: root.child(WidgetKey::new("doc_tabs.model")), 112 141 feature_part, 113 142 plane_xy: feature_part.child(WidgetKey::new("plane.xy")), 114 143 plane_yz: feature_part.child(WidgetKey::new("plane.yz")), ··· 118 147 menu_edit, 119 148 menu_view, 120 149 menu_insert: menu_bar.child(WidgetKey::new("insert")), 121 - menu_tools: menu_bar.child(WidgetKey::new("tools")), 150 + menu_tools, 151 + menu_sketch, 122 152 menu_window: menu_bar.child(WidgetKey::new("window")), 123 153 menu_help: menu_bar.child(WidgetKey::new("help")), 124 154 menu_file_quit: menu_file.child(WidgetKey::new("quit")), 125 155 menu_edit_undo: menu_edit.child(WidgetKey::new("undo")), 126 156 menu_edit_redo: menu_edit.child(WidgetKey::new("redo")), 127 157 menu_view_zoom_fit: menu_view.child(WidgetKey::new("zoom_fit")), 158 + menu_tools_options: menu_tools.child(WidgetKey::new("options")), 159 + menu_sketch_exit: menu_sketch.child(WidgetKey::new("exit")), 160 + settings_dialog, 161 + settings_aperture_slider: settings_dialog.child(WidgetKey::new("aperture.slider")), 162 + settings_reset: settings_dialog.child(WidgetKey::new("button.reset")), 163 + settings_close: settings_dialog.child(WidgetKey::new("button.close")), 128 164 } 129 165 } 130 166 ··· 145 181 (self.menu_edit_undo, MenuAction::Undo), 146 182 (self.menu_edit_redo, MenuAction::Redo), 147 183 (self.menu_view_zoom_fit, MenuAction::ZoomFit), 184 + (self.menu_tools_options, MenuAction::OpenSettings), 185 + (self.menu_sketch_exit, MenuAction::ExitSketch), 148 186 ] 149 187 .iter() 150 188 .copied() ··· 158 196 Undo, 159 197 Redo, 160 198 ZoomFit, 199 + OpenSettings, 200 + ExitSketch, 161 201 } 162 202 163 203 pub struct Shell { ··· 172 212 173 213 #[derive(Default)] 174 214 pub struct ShellState { 175 - pub ribbon: RibbonState, 176 215 pub feature_tree: TreeViewState, 177 216 pub clipboard: MemoryClipboard, 178 217 pub menu_bar: MenuBarState, 218 + pub dim_property: Option<DimPropertyEditor>, 219 + pub settings_dialog_open: bool, 220 + pub left_pane: LeftPane, 221 + last_left_pane_interesting: bool, 222 + } 223 + 224 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] 225 + pub enum LeftPane { 226 + #[default] 227 + Tree, 228 + Properties, 229 + } 230 + 231 + pub enum DimPropertyEditor { 232 + Length { 233 + sketch_id: SketchId, 234 + id: SketchDimensionId, 235 + editor: LengthEditor, 236 + }, 237 + Angle { 238 + sketch_id: SketchId, 239 + id: SketchDimensionId, 240 + editor: AngleEditor, 241 + }, 179 242 } 180 243 181 244 #[derive(Clone, Debug, PartialEq)] ··· 186 249 pub activated_tool: Option<SketchTool>, 187 250 pub activated_relation: Option<SketchRelation>, 188 251 pub activated_dimension: Option<PendingDimension>, 252 + pub dimension_edit: Option<DimensionEdit>, 189 253 pub plane_picked: Option<Plane>, 190 254 pub exit_sketch: bool, 255 + pub confirm_action: Option<ConfirmAction>, 191 256 pub menu_action: Option<MenuAction>, 257 + pub settings_change: Option<crate::settings::Settings>, 258 + } 259 + 260 + #[derive(Copy, Clone, Debug, PartialEq)] 261 + pub struct DimensionEdit { 262 + pub id: SketchDimensionId, 263 + pub value: DimensionValue, 192 264 } 193 265 194 266 impl ShellFrame { ··· 200 272 activated_tool: None, 201 273 activated_relation: None, 202 274 activated_dimension: None, 275 + dimension_edit: None, 203 276 plane_picked: None, 204 277 exit_sketch: false, 278 + confirm_action: None, 205 279 menu_action: None, 280 + settings_change: None, 206 281 } 207 282 } 208 283 } 209 284 210 285 impl Shell { 211 286 fn build_layout(&self, gap: Spacing) -> Layout { 212 - Layout::dock_host( 287 + let center = Layout::dock_host( 213 288 self.ids.dock_host, 214 289 Arc::clone(&self.dock_state), 215 290 vec![ 216 291 DockPanel { 217 - id: self.panels.menu_bar, 218 - child: Layout::leaf(self.ids.menu_bar), 219 - }, 220 - DockPanel { 221 - id: self.panels.ribbon, 222 - child: Layout::leaf(self.ids.ribbon), 223 - }, 224 - DockPanel { 225 - id: self.panels.feature_tree, 226 - child: Layout::leaf(self.ids.feature_tree), 227 - }, 228 - DockPanel { 229 - id: self.panels.property_pane, 230 - child: Layout::leaf(self.ids.property_pane), 292 + id: self.panels.left_pane, 293 + child: Layout::leaf(self.ids.left_pane), 231 294 }, 232 295 DockPanel { 233 296 id: self.panels.viewport, 234 297 child: Layout::leaf(self.ids.viewport), 235 298 }, 236 - DockPanel { 237 - id: self.panels.status, 238 - child: Layout::leaf(self.ids.status_bar), 239 - }, 240 299 ], 241 300 gap, 301 + ); 302 + chrome_grid( 303 + ChromeRows { 304 + menu: Layout::leaf(self.ids.menu_bar), 305 + ribbon: Layout::leaf(self.ids.ribbon), 306 + center, 307 + doc_tabs: Layout::leaf(self.ids.doc_tabs), 308 + status: Layout::leaf(self.ids.status_bar), 309 + }, 242 310 ) 243 311 } 244 312 245 - pub fn new() -> Result<Self, ShellError> { 313 + #[must_use] 314 + pub fn new() -> Self { 246 315 let panels = ShellPanels::standard(); 247 316 let ids = ShellIds::standard(); 248 - let dock_state = Arc::new(DockState::solidworks_default( 249 - panels.menu_bar, 250 - panels.feature_tree, 251 - panels.property_pane, 252 - panels.ribbon, 253 - panels.viewport, 254 - panels.status, 255 - )?); 317 + let dock_state = Arc::new(DockState::new(build_dock_main(panels))); 256 318 let tool_index = build_tool_index(ids.ribbon); 257 319 let relation_index = build_relation_index(ids.ribbon); 258 320 let mut state = ShellState::default(); 259 321 state.feature_tree.expanded.insert(ids.feature_part); 260 - Ok(Self { 322 + Self { 261 323 panels, 262 324 ids, 263 325 retained_layout: RetainedLayout::default(), ··· 265 327 tool_index, 266 328 relation_index, 267 329 state, 268 - }) 330 + } 269 331 } 270 332 333 + #[allow( 334 + clippy::too_many_lines, 335 + clippy::too_many_arguments, 336 + reason = "shell.render orchestrates the chrome layout pipeline" 337 + )] 271 338 pub fn render( 272 339 &mut self, 273 340 ctx: &mut FrameCtx<'_>, 274 341 document: &Document, 275 342 mode: &Mode, 276 - selection: &[SketchEntityId], 343 + selection: &Selection, 344 + settings: Settings, 277 345 viewport_size: LayoutSize, 346 + cursor_world: Option<Point2>, 278 347 ) -> ShellFrame { 279 348 let theme = ctx.theme(); 280 349 let direction = ctx.direction(); ··· 285 354 let inset_px = theme.spacing.sm.value_px(); 286 355 let mut paints = paint_walk(&solved, solved.root_node(), theme, self.panels.viewport); 287 356 let viewport_rect = panel_rect(&solved, self.panels.viewport).unwrap_or_else(zero_rect); 288 - let ribbon_rect = panel_rect(&solved, self.panels.ribbon).unwrap_or_else(zero_rect); 289 - let menu_bar_rect = panel_rect(&solved, self.panels.menu_bar).unwrap_or_else(zero_rect); 290 - let tree_rect = panel_rect(&solved, self.panels.feature_tree) 357 + let ribbon_rect = leaf_rect(&solved, self.ids.ribbon).unwrap_or_else(zero_rect); 358 + let menu_bar_rect = leaf_rect(&solved, self.ids.menu_bar).unwrap_or_else(zero_rect); 359 + let left_pane_rect = panel_rect(&solved, self.panels.left_pane) 291 360 .map_or_else(zero_rect, |r| inset_rect(r, inset_px)); 292 - let property_rect = panel_rect(&solved, self.panels.property_pane) 293 - .map_or_else(zero_rect, |r| inset_rect(r, inset_px)); 294 - let status_rect = panel_rect(&solved, self.panels.status).unwrap_or_else(zero_rect); 361 + let LeftPaneSplit { tab_strip_rect, content_rect } = split_left_pane(left_pane_rect); 362 + let status_rect = leaf_rect(&solved, self.ids.status_bar).unwrap_or_else(zero_rect); 363 + let doc_tabs_rect = leaf_rect(&solved, self.ids.doc_tabs).unwrap_or_else(zero_rect); 364 + let mut popover_paints: Vec<WidgetPaint> = Vec::new(); 295 365 let menu_action = render_menu_bar( 296 366 ctx, 297 367 menu_bar_rect, 298 368 &self.ids, 299 369 &mut self.state.menu_bar, 370 + document, 371 + mode.is_sketch(), 300 372 &mut paints, 373 + &mut popover_paints, 301 374 ); 302 375 let active_sketch = active_sketch(document, mode); 376 + let entity_ids = selection.entity_ids(); 303 377 let activated_widget = render_ribbon( 304 378 ctx, 305 379 RibbonInputs { 306 380 rect: ribbon_rect, 307 381 ribbon: self.ids.ribbon, 308 - ribbon_exit: self.ids.ribbon_exit, 309 382 ribbon_smart_dimension: self.ids.ribbon_smart_dimension, 310 383 mode, 311 384 sketch: active_sketch, 312 - selection, 385 + selection: entity_ids, 313 386 }, 314 - &mut self.state.ribbon, 387 + &mut paints, 388 + ); 389 + let active_tool = match mode { 390 + Mode::Sketch { session, .. } => session.tool, 391 + Mode::Idle => None, 392 + }; 393 + update_left_pane_auto(&mut self.state, selection, active_tool); 394 + let tab_clicked = render_left_pane_tabs( 395 + ctx, 396 + tab_strip_rect, 397 + &self.ids, 398 + self.state.left_pane, 315 399 &mut paints, 316 400 ); 401 + if let Some(target) = tab_clicked { 402 + self.state.left_pane = target; 403 + } 404 + let active_pane = self.state.left_pane; 405 + let (tree_rect, property_rect) = match active_pane { 406 + LeftPane::Tree => (content_rect, zero_rect()), 407 + LeftPane::Properties => (zero_rect(), content_rect), 408 + }; 317 409 let double_activated = render_feature_tree( 318 410 ctx, 319 411 tree_rect, ··· 323 415 document, 324 416 &mut paints, 325 417 ); 326 - render_property_pane( 418 + let dimension_edit = render_property_pane( 327 419 ctx, 328 420 property_rect, 329 421 self.ids.property_pane, 330 422 &mut self.state.clipboard, 423 + &mut self.state.dim_property, 331 424 PropertyState { 332 - document, 333 425 mode, 426 + sketch: active_sketch, 334 427 selection, 335 428 }, 336 429 &mut paints, 337 430 ); 338 - render_status_bar(ctx, status_rect, self.ids.status_bar, mode, &mut paints); 339 - let exit_sketch = activated_widget == Some(self.ids.ribbon_exit); 431 + render_doc_tabs(ctx, doc_tabs_rect, &self.ids, &mut paints); 432 + render_status_bar( 433 + ctx, 434 + status_rect, 435 + self.ids.status_bar, 436 + mode, 437 + document, 438 + cursor_world, 439 + &mut paints, 440 + ); 441 + let confirm = render_confirm_corner( 442 + ctx, 443 + viewport_rect, 444 + &self.ids, 445 + mode.is_sketch(), 446 + &mut paints, 447 + ); 448 + let confirm_action = confirm; 449 + let exit_sketch = confirm_action.is_some() 450 + || menu_action == Some(MenuAction::ExitSketch); 340 451 let activated_tool = activated_widget.and_then(|id| self.tool_index.get(&id).copied()); 341 452 let activated_relation = resolve_activated_relation( 342 453 activated_widget, 343 454 &self.relation_index, 344 455 active_sketch, 345 - selection, 456 + entity_ids, 346 457 ); 347 458 let activated_dimension = resolve_activated_dimension( 348 459 activated_widget, 349 460 self.ids.ribbon_smart_dimension, 350 461 active_sketch, 351 - selection, 462 + entity_ids, 352 463 ); 353 464 let plane_picked = double_activated.and_then(|id| self.ids.plane_for(id)); 354 - let (paints, overlay_paints) = partition_overlay(paints, ctx.theme()); 465 + let mut dialog_paints: Vec<WidgetPaint> = Vec::new(); 466 + let settings_change = render_settings_dialog( 467 + ctx, 468 + viewport_size, 469 + &self.ids, 470 + &mut self.state, 471 + settings, 472 + &mut dialog_paints, 473 + ); 474 + let (paints, mut overlay_paints) = partition_overlay(paints, ctx.theme()); 475 + overlay_paints.extend(popover_paints); 476 + overlay_paints.extend(dialog_paints); 355 477 ShellFrame { 356 478 paints, 357 479 overlay_paints, ··· 359 481 activated_tool, 360 482 activated_relation, 361 483 activated_dimension, 484 + dimension_edit, 362 485 plane_picked, 363 486 exit_sketch, 487 + confirm_action, 364 488 menu_action, 489 + settings_change, 365 490 } 366 491 } 367 492 } 368 493 494 + const SETTINGS_DIALOG_WIDTH: f32 = 420.0; 495 + const SETTINGS_DIALOG_HEIGHT: f32 = 220.0; 496 + const SETTINGS_DIALOG_GUTTER: f32 = 16.0; 497 + const SETTINGS_LABEL_HEIGHT: f32 = 20.0; 498 + const SETTINGS_HINT_HEIGHT: f32 = 36.0; 499 + const SETTINGS_SLIDER_HEIGHT: f32 = 28.0; 500 + const SETTINGS_LABEL_TO_HINT_GAP: f32 = 6.0; 501 + const SETTINGS_HINT_TO_SLIDER_GAP: f32 = 12.0; 502 + const PICK_APERTURE_MIN_PX: i32 = 1; 503 + const PICK_APERTURE_MAX_PX: i32 = 30; 504 + 505 + fn render_settings_dialog( 506 + ctx: &mut FrameCtx<'_>, 507 + viewport_size: LayoutSize, 508 + ids: &ShellIds, 509 + state: &mut ShellState, 510 + settings: Settings, 511 + paints: &mut Vec<WidgetPaint>, 512 + ) -> Option<Settings> { 513 + if !state.settings_dialog_open { 514 + return None; 515 + } 516 + let viewport = LayoutRect::new( 517 + LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)), 518 + viewport_size, 519 + ); 520 + let buttons = [ 521 + DialogButton::secondary(ids.settings_reset, strings::SETTINGS_RESET), 522 + DialogButton::primary(ids.settings_close, strings::SETTINGS_CLOSE), 523 + ]; 524 + let dialog_size = LayoutSize::new( 525 + LayoutPx::new(SETTINGS_DIALOG_WIDTH), 526 + LayoutPx::new(SETTINGS_DIALOG_HEIGHT), 527 + ); 528 + let aperture_label_text = format!( 529 + "{}: {} px", 530 + ctx.strings.resolve(strings::SETTINGS_PICK_APERTURE_LABEL), 531 + settings.pick_aperture.radius_px(), 532 + ); 533 + let aperture_slider_id = ids.settings_aperture_slider; 534 + let (response, slider_change) = show_dialog( 535 + ctx, 536 + Dialog::new( 537 + ids.settings_dialog, 538 + viewport, 539 + dialog_size, 540 + strings::SETTINGS_DIALOG_TITLE, 541 + &buttons, 542 + ), 543 + |ctx, body_rect, paint| { 544 + settings_dialog_body( 545 + ctx, 546 + body_rect, 547 + aperture_slider_id, 548 + settings, 549 + aperture_label_text, 550 + paint, 551 + ) 552 + }, 553 + ); 554 + paints.extend(response.paint); 555 + if response.dismissed || response.activated == Some(ids.settings_close) { 556 + state.settings_dialog_open = false; 557 + } 558 + if response.activated == Some(ids.settings_reset) { 559 + return Some(Settings::default()); 560 + } 561 + slider_change 562 + } 563 + 564 + fn settings_dialog_body( 565 + ctx: &mut FrameCtx<'_>, 566 + body_rect: LayoutRect, 567 + aperture_slider_id: WidgetId, 568 + settings: Settings, 569 + aperture_label_text: String, 570 + paint: &mut Vec<WidgetPaint>, 571 + ) -> Option<Settings> { 572 + let label_rect = settings_label_rect(body_rect); 573 + paint.push(WidgetPaint::Label { 574 + rect: label_rect, 575 + text: LabelText::Owned(aperture_label_text), 576 + color: ctx.theme().colors.text_primary(), 577 + role: ctx.theme().typography.label, 578 + }); 579 + let hint_rect = settings_hint_rect(body_rect); 580 + paint.push(WidgetPaint::Label { 581 + rect: hint_rect, 582 + text: LabelText::Key(strings::SETTINGS_PICK_APERTURE_HINT), 583 + color: ctx.theme().colors.text_secondary(), 584 + role: ctx.theme().typography.caption, 585 + }); 586 + let Ok(range) = SliderRange::try_new(PICK_APERTURE_MIN_PX, PICK_APERTURE_MAX_PX) else { 587 + unreachable!("PICK_APERTURE_MIN_PX < PICK_APERTURE_MAX_PX is statically guaranteed"); 588 + }; 589 + let Ok(step) = SliderStep::try_new(1i32) else { 590 + unreachable!("integer step of 1 is positive"); 591 + }; 592 + let initial = i32::try_from(settings.pick_aperture.radius_px()).unwrap_or(PICK_APERTURE_MAX_PX); 593 + let response = show_slider( 594 + ctx, 595 + Slider::new( 596 + aperture_slider_id, 597 + settings_slider_rect(body_rect), 598 + strings::SETTINGS_PICK_APERTURE_LABEL, 599 + initial, 600 + range, 601 + step, 602 + ), 603 + ); 604 + paint.extend(response.paint); 605 + response.changed.then(|| { 606 + let clamped = response 607 + .value 608 + .clamp(PICK_APERTURE_MIN_PX, PICK_APERTURE_MAX_PX); 609 + #[allow( 610 + clippy::cast_sign_loss, 611 + reason = "value clamped to [PICK_APERTURE_MIN_PX, PICK_APERTURE_MAX_PX] which is non-negative" 612 + )] 613 + let radius = clamped as u32; 614 + Settings { 615 + pick_aperture: PickAperture::new(radius), 616 + } 617 + }) 618 + } 619 + 620 + fn settings_label_rect(body: LayoutRect) -> LayoutRect { 621 + LayoutRect::new( 622 + LayoutPos::new( 623 + LayoutPx::new(body.origin.x.value() + SETTINGS_DIALOG_GUTTER), 624 + LayoutPx::new(body.origin.y.value() + SETTINGS_DIALOG_GUTTER), 625 + ), 626 + LayoutSize::new( 627 + LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * SETTINGS_DIALOG_GUTTER), 628 + LayoutPx::new(SETTINGS_LABEL_HEIGHT), 629 + ), 630 + ) 631 + } 632 + 633 + fn settings_hint_rect(body: LayoutRect) -> LayoutRect { 634 + LayoutRect::new( 635 + LayoutPos::new( 636 + LayoutPx::new(body.origin.x.value() + SETTINGS_DIALOG_GUTTER), 637 + LayoutPx::new( 638 + body.origin.y.value() 639 + + SETTINGS_DIALOG_GUTTER 640 + + SETTINGS_LABEL_HEIGHT 641 + + SETTINGS_LABEL_TO_HINT_GAP, 642 + ), 643 + ), 644 + LayoutSize::new( 645 + LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * SETTINGS_DIALOG_GUTTER), 646 + LayoutPx::new(SETTINGS_HINT_HEIGHT), 647 + ), 648 + ) 649 + } 650 + 651 + fn settings_slider_rect(body: LayoutRect) -> LayoutRect { 652 + LayoutRect::new( 653 + LayoutPos::new( 654 + LayoutPx::new(body.origin.x.value() + SETTINGS_DIALOG_GUTTER), 655 + LayoutPx::new( 656 + body.origin.y.value() 657 + + SETTINGS_DIALOG_GUTTER 658 + + SETTINGS_LABEL_HEIGHT 659 + + SETTINGS_LABEL_TO_HINT_GAP 660 + + SETTINGS_HINT_HEIGHT 661 + + SETTINGS_HINT_TO_SLIDER_GAP, 662 + ), 663 + ), 664 + LayoutSize::new( 665 + LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * SETTINGS_DIALOG_GUTTER), 666 + LayoutPx::new(SETTINGS_SLIDER_HEIGHT), 667 + ), 668 + ) 669 + } 670 + 369 671 fn partition_overlay( 370 672 paints: Vec<WidgetPaint>, 371 673 theme: &Theme, ··· 457 759 } 458 760 } 459 761 762 + #[allow( 763 + clippy::too_many_arguments, 764 + reason = "menu bar render bundles a handful of shell-owned references" 765 + )] 460 766 fn render_menu_bar( 461 767 ctx: &mut FrameCtx<'_>, 462 768 rect: LayoutRect, 463 769 ids: &ShellIds, 464 770 state: &mut MenuBarState, 771 + document: &Document, 772 + is_sketch: bool, 465 773 paints: &mut Vec<WidgetPaint>, 774 + popover_paints: &mut Vec<WidgetPaint>, 466 775 ) -> Option<MenuAction> { 467 776 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 468 777 return None; 469 778 } 779 + let entries = build_menu_entries(ids, is_sketch); 780 + let response = show_menu_bar( 781 + ctx, 782 + MenuBar::new(ids.menu_bar, rect, strings::MENU_BAR_LABEL, &entries, state) 783 + .with_trailing_label(LabelText::Owned(document.name().to_owned())), 784 + ); 785 + paints.extend(response.paint); 786 + popover_paints.extend(response.popover_paint); 787 + response.activated.and_then(|id| ids.menu_action_for(id)) 788 + } 789 + 790 + #[allow( 791 + clippy::too_many_lines, 792 + reason = "menu entries are flat data; splitting would scatter related strings" 793 + )] 794 + fn build_menu_entries(ids: &ShellIds, is_sketch: bool) -> Vec<MenuBarEntry> { 470 795 let placeholder = |menu_id: WidgetId, key: &'static str| MenuItem::Action { 471 796 id: menu_id.child(WidgetKey::new(key)), 472 797 label: strings::MENU_PLACEHOLDER_COMING_SOON, 473 798 shortcut: None, 474 799 disabled: true, 475 800 }; 476 - let file_items = vec![ 477 - MenuItem::Action { 478 - id: ids.menu_file.child(WidgetKey::new("new")), 479 - label: strings::MENU_FILE_NEW, 480 - shortcut: None, 481 - disabled: true, 482 - }, 483 - MenuItem::Action { 484 - id: ids.menu_file.child(WidgetKey::new("open")), 485 - label: strings::MENU_FILE_OPEN, 486 - shortcut: None, 487 - disabled: true, 488 - }, 801 + let action = |id: WidgetId, label: StringKey, shortcut: Option<StringKey>, disabled: bool| { 489 802 MenuItem::Action { 490 - id: ids.menu_file.child(WidgetKey::new("save")), 491 - label: strings::MENU_FILE_SAVE, 492 - shortcut: None, 493 - disabled: true, 494 - }, 495 - MenuItem::Separator, 496 - MenuItem::Action { 497 - id: ids.menu_file_quit, 498 - label: strings::MENU_FILE_QUIT, 499 - shortcut: Some(strings::SHORTCUT_QUIT), 500 - disabled: false, 501 - }, 502 - ]; 503 - let edit_items = vec![ 504 - MenuItem::Action { 505 - id: ids.menu_edit_undo, 506 - label: strings::MENU_EDIT_UNDO, 507 - shortcut: Some(strings::SHORTCUT_UNDO), 508 - disabled: false, 509 - }, 510 - MenuItem::Action { 511 - id: ids.menu_edit_redo, 512 - label: strings::MENU_EDIT_REDO, 513 - shortcut: Some(strings::SHORTCUT_REDO), 514 - disabled: false, 515 - }, 516 - ]; 517 - let view_items = vec![MenuItem::Action { 518 - id: ids.menu_view_zoom_fit, 519 - label: strings::MENU_VIEW_ZOOM_FIT, 520 - shortcut: Some(strings::SHORTCUT_ZOOM_FIT), 521 - disabled: false, 522 - }]; 523 - let entries = vec![ 803 + id, 804 + label, 805 + shortcut, 806 + disabled, 807 + } 808 + }; 809 + let file = ids.menu_file; 810 + let mut entries = vec![ 524 811 MenuBarEntry { 525 - id: ids.menu_file, 812 + id: file, 526 813 label: strings::MENU_FILE, 527 - items: file_items, 814 + items: vec![ 815 + action(file.child(WidgetKey::new("new")), strings::MENU_FILE_NEW, None, true), 816 + action( 817 + file.child(WidgetKey::new("open")), 818 + strings::MENU_FILE_OPEN, 819 + None, 820 + true, 821 + ), 822 + action( 823 + file.child(WidgetKey::new("save")), 824 + strings::MENU_FILE_SAVE, 825 + None, 826 + true, 827 + ), 828 + MenuItem::Separator, 829 + action( 830 + ids.menu_file_quit, 831 + strings::MENU_FILE_QUIT, 832 + Some(strings::SHORTCUT_QUIT), 833 + false, 834 + ), 835 + ], 528 836 }, 529 837 MenuBarEntry { 530 838 id: ids.menu_edit, 531 839 label: strings::MENU_EDIT, 532 - items: edit_items, 840 + items: vec![ 841 + action( 842 + ids.menu_edit_undo, 843 + strings::MENU_EDIT_UNDO, 844 + Some(strings::SHORTCUT_UNDO), 845 + false, 846 + ), 847 + action( 848 + ids.menu_edit_redo, 849 + strings::MENU_EDIT_REDO, 850 + Some(strings::SHORTCUT_REDO), 851 + false, 852 + ), 853 + ], 533 854 }, 534 855 MenuBarEntry { 535 856 id: ids.menu_view, 536 857 label: strings::MENU_VIEW, 537 - items: view_items, 858 + items: vec![action( 859 + ids.menu_view_zoom_fit, 860 + strings::MENU_VIEW_ZOOM_FIT, 861 + Some(strings::SHORTCUT_ZOOM_FIT), 862 + false, 863 + )], 538 864 }, 539 865 MenuBarEntry { 540 866 id: ids.menu_insert, ··· 544 870 MenuBarEntry { 545 871 id: ids.menu_tools, 546 872 label: strings::MENU_TOOLS, 547 - items: vec![placeholder(ids.menu_tools, "soon")], 873 + items: vec![action( 874 + ids.menu_tools_options, 875 + strings::MENU_TOOLS_OPTIONS, 876 + None, 877 + false, 878 + )], 548 879 }, 880 + ]; 881 + if is_sketch { 882 + entries.push(MenuBarEntry { 883 + id: ids.menu_sketch, 884 + label: strings::MENU_SKETCH, 885 + items: vec![action( 886 + ids.menu_sketch_exit, 887 + strings::MENU_SKETCH_EXIT, 888 + None, 889 + false, 890 + )], 891 + }); 892 + } 893 + entries.extend([ 549 894 MenuBarEntry { 550 895 id: ids.menu_window, 551 896 label: strings::MENU_WINDOW, ··· 556 901 label: strings::MENU_HELP, 557 902 items: vec![placeholder(ids.menu_help, "soon")], 558 903 }, 559 - ]; 560 - let response = show_menu_bar( 561 - ctx, 562 - MenuBar::new(ids.menu_bar, rect, strings::MENU_BAR_LABEL, &entries, state), 563 - ); 564 - paints.extend(response.paint); 565 - response.activated.and_then(|id| ids.menu_action_for(id)) 904 + ]); 905 + entries 566 906 } 567 907 568 908 #[derive(Copy, Clone)] 569 909 struct RibbonInputs<'a> { 570 910 rect: LayoutRect, 571 911 ribbon: WidgetId, 572 - ribbon_exit: WidgetId, 573 912 ribbon_smart_dimension: WidgetId, 574 913 mode: &'a Mode, 575 914 sketch: Option<&'a Sketch>, ··· 579 918 fn render_ribbon( 580 919 ctx: &mut FrameCtx<'_>, 581 920 inputs: RibbonInputs<'_>, 582 - state: &mut RibbonState, 583 921 paints: &mut Vec<WidgetPaint>, 584 922 ) -> Option<WidgetId> { 585 923 let RibbonInputs { 586 924 rect, 587 925 ribbon, 588 - ribbon_exit, 589 926 ribbon_smart_dimension, 590 927 mode, 591 928 sketch, ··· 628 965 .into_iter() 629 966 .map(|item| size_item(item, small_min)) 630 967 .collect(); 631 - let exit_items = vec![size_item( 632 - ToolbarItem::new(ribbon_exit, strings::TOOL_EXIT_SKETCH), 633 - large_min, 634 - )]; 635 - let exit_preferred = group_width_for(&exit_items, large_min); 636 968 let tab_id = ribbon.child(WidgetKey::new("tab.sketch")); 637 - let exit_group = mode.is_sketch().then(|| RibbonGroup { 638 - id: ribbon.child(WidgetKey::new("group.exit")), 639 - label: strings::RIBBON_GROUP_EXIT, 640 - min_width: exit_preferred, 641 - width: exit_preferred, 642 - items: exit_items, 643 - icon_size: RibbonIconSize::Large, 644 - }); 645 969 let dimensions_preferred = group_width_for(&dimension_items, large_min); 646 - let groups: Vec<RibbonGroup> = [ 970 + let groups: Vec<RibbonGroup> = vec![ 647 971 RibbonGroup { 648 972 id: ribbon.child(WidgetKey::new("group.entities")), 649 973 label: strings::RIBBON_GROUP_ENTITIES, ··· 668 992 items: dimension_items, 669 993 icon_size: RibbonIconSize::Large, 670 994 }, 671 - ] 672 - .into_iter() 673 - .chain(exit_group) 674 - .collect(); 675 - let tabs = [RibbonTab::new(tab_id, strings::RIBBON_TAB_SKETCH, groups)]; 995 + ]; 996 + let placeholder_tab = |key: &'static str, label: StringKey| { 997 + RibbonTab::new(ribbon.child(WidgetKey::new(key)), label, Vec::new()).disabled(true) 998 + }; 999 + let tabs = [ 1000 + placeholder_tab("tab.features", strings::RIBBON_TAB_FEATURES), 1001 + RibbonTab::new(tab_id, strings::RIBBON_TAB_SKETCH, groups), 1002 + placeholder_tab("tab.surfaces", strings::RIBBON_TAB_SURFACES), 1003 + placeholder_tab("tab.evaluate", strings::RIBBON_TAB_EVALUATE), 1004 + ]; 676 1005 let response = show_ribbon( 677 1006 ctx, 678 - Ribbon::new(ribbon, rect, strings::RIBBON_LABEL, &tabs, tab_id, state), 1007 + Ribbon::new(ribbon, rect, strings::RIBBON_LABEL, &tabs, tab_id), 679 1008 ); 680 1009 paints.extend(response.paint); 681 1010 response.activated_tool ··· 696 1025 let leaf = |key: &'static str, label: StringKey| { 697 1026 TreeNode::leaf(part_id.child(WidgetKey::new(key)), label) 698 1027 }; 1028 + let feature_leaf = |key: &'static str, label: StringKey| { 1029 + leaf(key, label).with_glyph(GlyphMark::TreeFeature) 1030 + }; 1031 + let placeholder = |key: &'static str, label: StringKey| { 1032 + feature_leaf(key, label).disabled(true) 1033 + }; 1034 + let plane_leaf = |key: &'static str, label: StringKey| { 1035 + leaf(key, label).with_glyph(GlyphMark::TreePlane) 1036 + }; 699 1037 let sketches: Vec<TreeNode> = document 700 1038 .sketches() 701 1039 .enumerate() ··· 704 1042 part_id.child_indexed(WidgetKey::new("sketch"), idx as u64), 705 1043 strings::FEATURE_SKETCH_DEFAULT, 706 1044 ) 1045 + .with_glyph(GlyphMark::TreeSketch) 707 1046 }) 708 1047 .collect(); 709 1048 let children: Vec<TreeNode> = [ 710 - leaf("origin", strings::FEATURE_ORIGIN), 711 - leaf("plane.xy", strings::FEATURE_PLANE_XY), 712 - leaf("plane.yz", strings::FEATURE_PLANE_YZ), 713 - leaf("plane.zx", strings::FEATURE_PLANE_ZX), 1049 + placeholder("history", strings::FEATURE_HISTORY), 1050 + placeholder("sensors", strings::FEATURE_SENSORS), 1051 + placeholder("annotations", strings::FEATURE_ANNOTATIONS), 1052 + placeholder("solid_bodies", strings::FEATURE_SOLID_BODIES), 1053 + placeholder("material", strings::FEATURE_MATERIAL), 1054 + plane_leaf("plane.xy", strings::FEATURE_PLANE_XY), 1055 + plane_leaf("plane.yz", strings::FEATURE_PLANE_YZ), 1056 + plane_leaf("plane.zx", strings::FEATURE_PLANE_ZX), 1057 + leaf("origin", strings::FEATURE_ORIGIN).with_glyph(GlyphMark::RadioDot), 714 1058 ] 715 1059 .into_iter() 716 1060 .chain(sketches) ··· 727 1071 728 1072 #[derive(Copy, Clone)] 729 1073 struct PropertyState<'a> { 730 - document: &'a Document, 731 1074 mode: &'a Mode, 732 - selection: &'a [SketchEntityId], 1075 + sketch: Option<&'a Sketch>, 1076 + selection: &'a Selection, 733 1077 } 734 1078 735 1079 fn render_property_pane( ··· 737 1081 rect: LayoutRect, 738 1082 id: WidgetId, 739 1083 clipboard: &mut MemoryClipboard, 1084 + dim_property: &mut Option<DimPropertyEditor>, 740 1085 state: PropertyState<'_>, 741 1086 paints: &mut Vec<WidgetPaint>, 742 - ) { 743 - if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 744 - return; 745 - } 746 - let active_sketch = match state.mode { 747 - Mode::Sketch { sketch_id, .. } => state.document.sketch(*sketch_id), 1087 + ) -> Option<DimensionEdit> { 1088 + let in_sketch = matches!(state.mode, Mode::Sketch { .. }); 1089 + let active_sketch_id = match state.mode { 1090 + Mode::Sketch { sketch_id, .. } => Some(*sketch_id), 748 1091 Mode::Idle => None, 749 1092 }; 750 - let resolved = match state.selection { 751 - [id] => active_sketch.and_then(|s| s.entities().get(*id).map(|e| (*e, s))), 752 - _ => None, 753 - }; 754 - let mut editors = match resolved { 755 - Some((entity, sketch)) => entity_editors(ctx.strings, entity, sketch), 756 - None => vec![row_editor(strings::PROPERTY_PANE_NO_SELECTION, "")], 757 - }; 1093 + let resolved = state 1094 + .sketch 1095 + .filter(|_| in_sketch) 1096 + .and_then(|s| resolve_selection_target(s, state.selection).map(|t| (s, t))); 1097 + if !matches!(resolved, Some((_, SelectionTarget::Dimension(_, _)))) { 1098 + *dim_property = None; 1099 + } 1100 + if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 1101 + return None; 1102 + } 1103 + match resolved { 1104 + Some((sketch, SelectionTarget::Entity(entity))) => { 1105 + let mut editors = entity_editors(ctx.strings, entity, sketch); 1106 + render_static_rows(ctx, rect, id, clipboard, &mut editors, paints); 1107 + None 1108 + } 1109 + Some((sketch, SelectionTarget::Relation(rel))) => { 1110 + let mut editors = relation_editors(ctx.strings, rel, sketch); 1111 + render_static_rows(ctx, rect, id, clipboard, &mut editors, paints); 1112 + None 1113 + } 1114 + Some((sketch, SelectionTarget::Dimension(dim_id, dim))) => { 1115 + let sketch_id = active_sketch_id?; 1116 + render_dimension_rows( 1117 + ctx, 1118 + rect, 1119 + id, 1120 + clipboard, 1121 + dim_property, 1122 + sketch_id, 1123 + dim_id, 1124 + dim, 1125 + sketch, 1126 + paints, 1127 + ) 1128 + } 1129 + None => { 1130 + let mut editors = vec![row_editor(strings::PROPERTY_PANE_NO_SELECTION, "")]; 1131 + render_static_rows(ctx, rect, id, clipboard, &mut editors, paints); 1132 + None 1133 + } 1134 + } 1135 + } 1136 + 1137 + enum SelectionTarget { 1138 + Entity(SketchEntity), 1139 + Relation(SketchRelation), 1140 + Dimension(SketchDimensionId, SketchDimension), 1141 + } 1142 + 1143 + fn resolve_selection_target(sketch: &Sketch, selection: &Selection) -> Option<SelectionTarget> { 1144 + match selection { 1145 + Selection::Entities(ids) => match ids.as_slice() { 1146 + [id] => sketch 1147 + .entities() 1148 + .get(*id) 1149 + .copied() 1150 + .map(SelectionTarget::Entity), 1151 + _ => None, 1152 + }, 1153 + Selection::Relation(id) => sketch 1154 + .relations() 1155 + .get(*id) 1156 + .copied() 1157 + .map(SelectionTarget::Relation), 1158 + Selection::Dimension(id) => sketch 1159 + .dimensions() 1160 + .get(*id) 1161 + .copied() 1162 + .map(|d| SelectionTarget::Dimension(*id, d)), 1163 + } 1164 + } 1165 + 1166 + fn render_static_rows( 1167 + ctx: &mut FrameCtx<'_>, 1168 + rect: LayoutRect, 1169 + id: WidgetId, 1170 + clipboard: &mut MemoryClipboard, 1171 + editors: &mut [PropertyRowSpec], 1172 + paints: &mut Vec<WidgetPaint>, 1173 + ) { 758 1174 let mut rows: Vec<PropertyRow<'_>> = editors 759 1175 .iter_mut() 760 1176 .map(|(row_id, label, editor)| PropertyRow { ··· 772 1188 paints.extend(response.paint); 773 1189 } 774 1190 1191 + #[allow( 1192 + clippy::too_many_arguments, 1193 + reason = "splitting the property-pane render call harms locality" 1194 + )] 1195 + fn render_dimension_rows( 1196 + ctx: &mut FrameCtx<'_>, 1197 + rect: LayoutRect, 1198 + id: WidgetId, 1199 + clipboard: &mut MemoryClipboard, 1200 + dim_property: &mut Option<DimPropertyEditor>, 1201 + sketch_id: SketchId, 1202 + dim_id: SketchDimensionId, 1203 + dim: SketchDimension, 1204 + sketch: &Sketch, 1205 + paints: &mut Vec<WidgetPaint>, 1206 + ) -> Option<DimensionEdit> { 1207 + let driving = matches!(dim.kind(), DimensionKind::Driving); 1208 + let kind_label = dimension_kind_label(dim); 1209 + let kind_value_key = dimension_drive_key(dim.kind()); 1210 + let value_row_id = WidgetId::ROOT 1211 + .child(WidgetKey::new("props.dim")) 1212 + .child(WidgetKey::new("value")); 1213 + let dim_property_slot = sync_dim_editor(dim_property, sketch_id, dim_id, dim); 1214 + let mut static_specs: Vec<PropertyRowSpec> = vec![row_editor( 1215 + strings::PROPERTY_ROW_DIM_KIND, 1216 + ctx.strings.resolve(kind_label).to_owned(), 1217 + )]; 1218 + static_specs.extend(dimension_static_rows(ctx.strings, dim, sketch)); 1219 + static_specs.push(row_editor( 1220 + strings::PROPERTY_ROW_DIM_DRIVES, 1221 + ctx.strings.resolve(kind_value_key).to_owned(), 1222 + )); 1223 + let mut rows: Vec<PropertyRow<'_>> = static_specs 1224 + .iter_mut() 1225 + .map(|(row_id, label, editor)| PropertyRow { 1226 + id: *row_id, 1227 + label: *label, 1228 + editor: editor.as_mut(), 1229 + read_only: true, 1230 + }) 1231 + .collect(); 1232 + let value_label = match dim { 1233 + SketchDimension::Linear { .. } => strings::PROPERTY_ROW_DIM_LENGTH, 1234 + SketchDimension::Radius { .. } => strings::PROPERTY_ROW_RADIUS, 1235 + SketchDimension::Diameter { .. } => strings::PROPERTY_ROW_DIM_DIAMETER, 1236 + SketchDimension::Angular { .. } => strings::PROPERTY_ROW_DIM_ANGLE, 1237 + }; 1238 + let editor_ref: &mut dyn PropertyEditor = match dim_property_slot { 1239 + DimPropertyEditor::Length { editor, .. } => editor, 1240 + DimPropertyEditor::Angle { editor, .. } => editor, 1241 + }; 1242 + rows.push(PropertyRow { 1243 + id: value_row_id, 1244 + label: value_label, 1245 + editor: editor_ref, 1246 + read_only: !driving, 1247 + }); 1248 + let response = show_property_grid( 1249 + ctx, 1250 + PropertyGrid::new(id, rect, strings::PROPERTY_PANE_LABEL, &mut rows), 1251 + clipboard, 1252 + ); 1253 + paints.extend(response.paint); 1254 + if !driving || !response.changed_rows.contains(&value_row_id) { 1255 + return None; 1256 + } 1257 + Some(DimensionEdit { 1258 + id: dim_id, 1259 + value: match dim_property_slot { 1260 + DimPropertyEditor::Length { editor, .. } => DimensionValue::Length(editor.value), 1261 + DimPropertyEditor::Angle { editor, .. } => DimensionValue::Angle(editor.value), 1262 + }, 1263 + }) 1264 + } 1265 + 1266 + fn sync_dim_editor( 1267 + slot: &mut Option<DimPropertyEditor>, 1268 + sketch_id: SketchId, 1269 + dim_id: SketchDimensionId, 1270 + dim: SketchDimension, 1271 + ) -> &mut DimPropertyEditor { 1272 + let editor = match (slot.take(), dim.value()) { 1273 + ( 1274 + Some(DimPropertyEditor::Length { 1275 + sketch_id: prev_sketch, 1276 + id, 1277 + mut editor, 1278 + }), 1279 + DimensionValue::Length(v), 1280 + ) if prev_sketch == sketch_id && id == dim_id => { 1281 + editor.value = v; 1282 + DimPropertyEditor::Length { 1283 + sketch_id, 1284 + id, 1285 + editor, 1286 + } 1287 + } 1288 + ( 1289 + Some(DimPropertyEditor::Angle { 1290 + sketch_id: prev_sketch, 1291 + id, 1292 + mut editor, 1293 + }), 1294 + DimensionValue::Angle(v), 1295 + ) if prev_sketch == sketch_id && id == dim_id => { 1296 + editor.value = v; 1297 + DimPropertyEditor::Angle { 1298 + sketch_id, 1299 + id, 1300 + editor, 1301 + } 1302 + } 1303 + (_, DimensionValue::Length(v)) => DimPropertyEditor::Length { 1304 + sketch_id, 1305 + id: dim_id, 1306 + editor: LengthEditor::new(v), 1307 + }, 1308 + (_, DimensionValue::Angle(v)) => DimPropertyEditor::Angle { 1309 + sketch_id, 1310 + id: dim_id, 1311 + editor: AngleEditor::new(v), 1312 + }, 1313 + }; 1314 + slot.insert(editor) 1315 + } 1316 + 1317 + fn dimension_kind_label(dim: SketchDimension) -> StringKey { 1318 + match dim { 1319 + SketchDimension::Linear { .. } => strings::PROPERTY_KIND_DIM_LINEAR, 1320 + SketchDimension::Radius { .. } => strings::PROPERTY_KIND_DIM_RADIUS, 1321 + SketchDimension::Diameter { .. } => strings::PROPERTY_KIND_DIM_DIAMETER, 1322 + SketchDimension::Angular { .. } => strings::PROPERTY_KIND_DIM_ANGULAR, 1323 + } 1324 + } 1325 + 1326 + fn dimension_drive_key(kind: DimensionKind) -> StringKey { 1327 + match kind { 1328 + DimensionKind::Driving => strings::PROPERTY_VALUE_DRIVING, 1329 + DimensionKind::Driven => strings::PROPERTY_VALUE_DRIVEN, 1330 + } 1331 + } 1332 + 1333 + fn dimension_static_rows( 1334 + strings_table: &StringTable, 1335 + dim: SketchDimension, 1336 + sketch: &Sketch, 1337 + ) -> Vec<PropertyRowSpec> { 1338 + let label = |id: SketchEntityId| endpoint_or_entity_label(strings_table, sketch, id); 1339 + match dim { 1340 + SketchDimension::Linear { a, b, .. } | SketchDimension::Angular { a, b, .. } => vec![ 1341 + row_editor(strings::PROPERTY_ROW_FROM, label(a)), 1342 + row_editor(strings::PROPERTY_ROW_TO, label(b)), 1343 + ], 1344 + SketchDimension::Radius { target, .. } | SketchDimension::Diameter { target, .. } => { 1345 + vec![row_editor(strings::PROPERTY_ROW_TARGET, label(target))] 1346 + } 1347 + } 1348 + } 1349 + 1350 + fn relation_editors( 1351 + strings_table: &StringTable, 1352 + rel: SketchRelation, 1353 + sketch: &Sketch, 1354 + ) -> Vec<PropertyRowSpec> { 1355 + let kind_key = relation_kind_key(rel); 1356 + let kind = row_editor( 1357 + strings::PROPERTY_ROW_KIND, 1358 + strings_table.resolve(kind_key).to_owned(), 1359 + ); 1360 + let label = |id: SketchEntityId| endpoint_or_entity_label(strings_table, sketch, id); 1361 + let mut specs = vec![kind]; 1362 + match rel { 1363 + SketchRelation::Coincident(a, b) 1364 + | SketchRelation::Parallel(a, b) 1365 + | SketchRelation::Perpendicular(a, b) 1366 + | SketchRelation::Tangent(a, b) 1367 + | SketchRelation::Equal(a, b) 1368 + | SketchRelation::Concentric(a, b) => { 1369 + specs.push(row_editor(strings::PROPERTY_ROW_FIRST, label(a))); 1370 + specs.push(row_editor(strings::PROPERTY_ROW_SECOND, label(b))); 1371 + } 1372 + SketchRelation::Midpoint { point, line } => { 1373 + specs.push(row_editor(strings::PROPERTY_ROW_POINT, label(point))); 1374 + specs.push(row_editor(strings::PROPERTY_ROW_LINE, label(line))); 1375 + } 1376 + SketchRelation::Horizontal(a) | SketchRelation::Vertical(a) | SketchRelation::Fix(a) => { 1377 + specs.push(row_editor(strings::PROPERTY_ROW_TARGET, label(a))); 1378 + } 1379 + } 1380 + specs 1381 + .into_iter() 1382 + .enumerate() 1383 + .map(|(idx, (_default_id, label, editor))| { 1384 + let row_id = WidgetId::ROOT 1385 + .child(WidgetKey::new("props.relation")) 1386 + .child_indexed(WidgetKey::new("row"), idx as u64); 1387 + (row_id, label, editor) 1388 + }) 1389 + .collect() 1390 + } 1391 + 1392 + fn relation_kind_key(rel: SketchRelation) -> StringKey { 1393 + match rel { 1394 + SketchRelation::Coincident(_, _) => strings::TOOL_COINCIDENT, 1395 + SketchRelation::Horizontal(_) => strings::TOOL_HORIZONTAL, 1396 + SketchRelation::Vertical(_) => strings::TOOL_VERTICAL, 1397 + SketchRelation::Parallel(_, _) => strings::TOOL_PARALLEL, 1398 + SketchRelation::Perpendicular(_, _) => strings::TOOL_PERPENDICULAR, 1399 + SketchRelation::Tangent(_, _) => strings::TOOL_TANGENT, 1400 + SketchRelation::Equal(_, _) => strings::TOOL_EQUAL, 1401 + SketchRelation::Concentric(_, _) => strings::TOOL_CONCENTRIC, 1402 + SketchRelation::Midpoint { .. } => strings::TOOL_MIDPOINT, 1403 + SketchRelation::Fix(_) => strings::TOOL_FIX, 1404 + } 1405 + } 1406 + 1407 + fn endpoint_or_entity_label( 1408 + strings_table: &StringTable, 1409 + sketch: &Sketch, 1410 + id: SketchEntityId, 1411 + ) -> String { 1412 + match sketch.entities().get(id) { 1413 + Some(SketchEntity::Point(p)) => { 1414 + let (x, y) = p.at().coords_mm(); 1415 + format!("({}, {})", format_mm(x), format_mm(y)) 1416 + } 1417 + Some(SketchEntity::Line(_)) => strings_table 1418 + .resolve(strings::PROPERTY_KIND_LINE) 1419 + .to_owned(), 1420 + Some(SketchEntity::Arc(_)) => strings_table 1421 + .resolve(strings::PROPERTY_KIND_ARC) 1422 + .to_owned(), 1423 + Some(SketchEntity::Circle(_)) => strings_table 1424 + .resolve(strings::PROPERTY_KIND_CIRCLE) 1425 + .to_owned(), 1426 + None => "?".to_owned(), 1427 + } 1428 + } 1429 + 775 1430 type PropertyRowSpec = (WidgetId, StringKey, Box<dyn PropertyEditor>); 776 1431 777 1432 fn row_editor(label: StringKey, value: impl Into<String>) -> PropertyRowSpec { ··· 811 1466 ] 812 1467 } 813 1468 SketchEntity::Line(l) => { 814 - let from = endpoint_label(strings_table, sketch, l.a()); 815 - let to = endpoint_label(strings_table, sketch, l.b()); 1469 + let from = endpoint_or_entity_label(strings_table, sketch, l.a()); 1470 + let to = endpoint_or_entity_label(strings_table, sketch, l.b()); 816 1471 vec![ 817 1472 row_editor( 818 1473 strings::PROPERTY_ROW_KIND, ··· 829 1484 ] 830 1485 } 831 1486 SketchEntity::Arc(a) => { 832 - let center = endpoint_label(strings_table, sketch, a.center()); 833 - let start = endpoint_label(strings_table, sketch, a.start()); 834 - let end = endpoint_label(strings_table, sketch, a.end()); 1487 + let center = endpoint_or_entity_label(strings_table, sketch, a.center()); 1488 + let start = endpoint_or_entity_label(strings_table, sketch, a.start()); 1489 + let end = endpoint_or_entity_label(strings_table, sketch, a.end()); 835 1490 vec![ 836 1491 row_editor( 837 1492 strings::PROPERTY_ROW_KIND, ··· 847 1502 ] 848 1503 } 849 1504 SketchEntity::Circle(c) => { 850 - let center = endpoint_label(strings_table, sketch, c.center()); 1505 + let center = endpoint_or_entity_label(strings_table, sketch, c.center()); 851 1506 vec![ 852 1507 row_editor( 853 1508 strings::PROPERTY_ROW_KIND, ··· 875 1530 .collect() 876 1531 } 877 1532 878 - fn endpoint_label(strings_table: &StringTable, sketch: &Sketch, id: SketchEntityId) -> String { 879 - match sketch.entities().get(id) { 880 - Some(SketchEntity::Point(p)) => { 881 - let (x, y) = p.at().coords_mm(); 882 - format!("({}, {})", format_mm(x), format_mm(y)) 883 - } 884 - Some(_) => strings_table 885 - .resolve(strings::PROPERTY_PANE_NO_SELECTION) 886 - .to_owned(), 887 - None => "?".to_owned(), 888 - } 889 - } 890 - 891 1533 fn format_mm(value: f64) -> String { 892 1534 format!("{value:.3} mm") 893 1535 } ··· 924 1566 } 925 1567 } 926 1568 1569 + const DOC_TAB_WIDTH_PX: f32 = 80.0; 1570 + 1571 + fn render_doc_tabs( 1572 + ctx: &mut FrameCtx<'_>, 1573 + rect: LayoutRect, 1574 + ids: &ShellIds, 1575 + paints: &mut Vec<WidgetPaint>, 1576 + ) { 1577 + if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 1578 + return; 1579 + } 1580 + let theme = ctx.theme(); 1581 + paints.push(surface_for(rect, theme.elevation.level1, theme)); 1582 + let tab_rect = LayoutRect::new( 1583 + rect.origin, 1584 + LayoutSize::new(LayoutPx::new(DOC_TAB_WIDTH_PX), rect.size.height), 1585 + ); 1586 + let tabs = [Tab::new(ids.doc_tab_model, tab_rect, strings::DOC_TAB_MODEL)]; 1587 + let response = show_tabs( 1588 + ctx, 1589 + Tabs::new( 1590 + ids.doc_tabs, 1591 + TabsOrientation::Top, 1592 + strings::DOC_TABS_LABEL, 1593 + tabs.as_slice(), 1594 + ids.doc_tab_model, 1595 + ), 1596 + ); 1597 + paints.extend(response.paint); 1598 + } 1599 + 927 1600 fn render_status_bar( 928 1601 ctx: &mut FrameCtx<'_>, 929 1602 rect: LayoutRect, 930 1603 id: WidgetId, 931 1604 mode: &Mode, 1605 + document: &Document, 1606 + cursor_world: Option<Point2>, 932 1607 paints: &mut Vec<WidgetPaint>, 933 1608 ) { 934 1609 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 935 1610 return; 936 1611 } 937 - let label = match mode { 938 - Mode::Idle => strings::STATUS_READY, 939 - Mode::Sketch { .. } => strings::STATUS_SKETCH_ACTIVE, 940 - }; 941 - let item = StatusItem::new( 1612 + let mode_label = mode_status_label(ctx.strings, mode, document); 1613 + let mode_item = StatusItem::with_text( 942 1614 id.child(WidgetKey::new("mode")), 943 - label, 1615 + mode_label, 944 1616 StatusAlign::Start, 945 1617 STATUS_MODE_WIDTH, 946 1618 ); 947 - let items = [item]; 1619 + let units_item = StatusItem::new( 1620 + id.child(WidgetKey::new("units")), 1621 + strings::STATUS_UNITS_MM, 1622 + StatusAlign::End, 1623 + STATUS_UNITS_WIDTH, 1624 + ); 1625 + let coords_item = mode 1626 + .is_sketch() 1627 + .then(|| { 1628 + cursor_world.map(|world| { 1629 + let (x_mm, y_mm) = world.coords_mm(); 1630 + StatusItem::with_text( 1631 + id.child(WidgetKey::new("coords")), 1632 + LabelText::Owned(format!("{x_mm:.2}, {y_mm:.2} mm")), 1633 + StatusAlign::Center, 1634 + STATUS_COORDS_WIDTH, 1635 + ) 1636 + }) 1637 + }) 1638 + .flatten(); 1639 + let mut items: Vec<StatusItem> = vec![mode_item]; 1640 + if let Some(coords) = coords_item { 1641 + items.push(coords); 1642 + } 1643 + items.push(units_item); 948 1644 let response = show_status_bar( 949 1645 ctx, 950 1646 StatusBar::new(id, rect, strings::STATUS_BAR_LABEL, &items), ··· 952 1648 paints.extend(response.paint); 953 1649 } 954 1650 1651 + fn mode_status_label(strings_table: &StringTable, mode: &Mode, document: &Document) -> LabelText { 1652 + match mode { 1653 + Mode::Idle => LabelText::Key(strings::STATUS_READY), 1654 + Mode::Sketch { sketch_id, .. } => { 1655 + let Some(position) = document.sketches().position(|(id, _)| id == *sketch_id) else { 1656 + tracing::warn!(?sketch_id, "active sketch missing from document"); 1657 + return LabelText::Key(strings::STATUS_READY); 1658 + }; 1659 + let prefix = strings_table.resolve(strings::STATUS_SKETCH_ACTIVE); 1660 + LabelText::Owned(format!("{prefix} {}", position + 1)) 1661 + } 1662 + } 1663 + } 1664 + 955 1665 fn estimate_label_width(text: &str, font_size_px: f32, min_width: LayoutPx) -> LayoutPx { 956 1666 #[allow( 957 1667 clippy::cast_precision_loss, ··· 1162 1872 .map(|n| n.rect) 1163 1873 } 1164 1874 1875 + fn leaf_rect(solved: &SolvedLayout, id: WidgetId) -> Option<LayoutRect> { 1876 + solved 1877 + .nodes 1878 + .iter() 1879 + .find(|n| matches!(n.kind, NodeKind::Leaf(wid) if wid == id)) 1880 + .map(|n| n.rect) 1881 + } 1882 + 1883 + const MENU_BAR_HEIGHT_PX: f32 = 24.0; 1884 + const RIBBON_HEIGHT_PX: f32 = 96.0; 1885 + const DOC_TABS_HEIGHT_PX: f32 = 22.0; 1886 + const STATUS_BAR_HEIGHT_PX: f32 = 22.0; 1887 + 1888 + struct ChromeRows { 1889 + menu: Layout, 1890 + ribbon: Layout, 1891 + center: Layout, 1892 + doc_tabs: Layout, 1893 + status: Layout, 1894 + } 1895 + 1896 + fn chrome_grid(rows: ChromeRows) -> Layout { 1897 + let ChromeRows { 1898 + menu, 1899 + ribbon, 1900 + center, 1901 + doc_tabs, 1902 + status, 1903 + } = rows; 1904 + let one = grid_line(1); 1905 + let two = grid_line(2); 1906 + let three = grid_line(3); 1907 + let four = grid_line(4); 1908 + let five = grid_line(5); 1909 + let six = grid_line(6); 1910 + let span_row = |row_start: GridLine, row_end: GridLine, child: Layout| { 1911 + let Some(span) = GridSpan::rect(one, two, row_start, row_end) else { 1912 + panic!("chrome row span must be increasing"); 1913 + }; 1914 + GridChild { span, child } 1915 + }; 1916 + Layout::Grid { 1917 + columns: vec![GridTrack::unnamed(TrackSize::FLEX_1)], 1918 + rows: vec![ 1919 + GridTrack::unnamed(TrackSize::Fixed(Spacing::px(MENU_BAR_HEIGHT_PX))), 1920 + GridTrack::unnamed(TrackSize::Fixed(Spacing::px(RIBBON_HEIGHT_PX))), 1921 + GridTrack::unnamed(TrackSize::FLEX_1), 1922 + GridTrack::unnamed(TrackSize::Fixed(Spacing::px(DOC_TABS_HEIGHT_PX))), 1923 + GridTrack::unnamed(TrackSize::Fixed(Spacing::px(STATUS_BAR_HEIGHT_PX))), 1924 + ], 1925 + column_gap: Spacing::px(0.0), 1926 + row_gap: Spacing::px(0.0), 1927 + children: vec![ 1928 + span_row(one, two, menu), 1929 + span_row(two, three, ribbon), 1930 + span_row(three, four, center), 1931 + span_row(four, five, doc_tabs), 1932 + span_row(five, six, status), 1933 + ], 1934 + } 1935 + } 1936 + 1937 + fn grid_line(n: u16) -> GridLine { 1938 + let Some(nz) = core::num::NonZeroU16::new(n) else { 1939 + panic!("grid line must be non-zero"); 1940 + }; 1941 + GridLine::new(nz) 1942 + } 1943 + 1165 1944 fn inset_rect(rect: LayoutRect, by: f32) -> LayoutRect { 1166 1945 let w = (rect.size.width.value() - 2.0 * by).max(0.0); 1167 1946 let h = (rect.size.height.value() - 2.0 * by).max(0.0); ··· 1188 1967 PanelId::new(nz) 1189 1968 } 1190 1969 1970 + const LEFT_PANE_TAB_STRIP_HEIGHT: f32 = 28.0; 1971 + 1972 + struct LeftPaneSplit { 1973 + tab_strip_rect: LayoutRect, 1974 + content_rect: LayoutRect, 1975 + } 1976 + 1977 + fn split_left_pane(rect: LayoutRect) -> LeftPaneSplit { 1978 + let strip_height = LayoutPx::new(LEFT_PANE_TAB_STRIP_HEIGHT.min(rect.size.height.value())); 1979 + let tab_strip_rect = LayoutRect::new( 1980 + rect.origin, 1981 + LayoutSize::new(rect.size.width, strip_height), 1982 + ); 1983 + let content_rect = LayoutRect::new( 1984 + LayoutPos::new( 1985 + rect.origin.x, 1986 + LayoutPx::new(rect.origin.y.value() + strip_height.value()), 1987 + ), 1988 + LayoutSize::new( 1989 + rect.size.width, 1990 + LayoutPx::saturating_nonneg(rect.size.height.value() - strip_height.value()), 1991 + ), 1992 + ); 1993 + LeftPaneSplit { 1994 + tab_strip_rect, 1995 + content_rect, 1996 + } 1997 + } 1998 + 1999 + fn update_left_pane_auto( 2000 + state: &mut ShellState, 2001 + selection: &Selection, 2002 + active_tool: Option<SketchTool>, 2003 + ) { 2004 + let interesting = !selection.is_empty() || active_tool.is_some(); 2005 + if interesting && !state.last_left_pane_interesting { 2006 + state.left_pane = LeftPane::Properties; 2007 + } else if !interesting && state.last_left_pane_interesting { 2008 + state.left_pane = LeftPane::Tree; 2009 + } 2010 + state.last_left_pane_interesting = interesting; 2011 + } 2012 + 2013 + const LEFT_PANE_TAB_WIDTH_PX: f32 = 28.0; 2014 + 2015 + #[derive(Copy, Clone)] 2016 + struct LeftPaneTabSpec { 2017 + id: WidgetId, 2018 + label: StringKey, 2019 + glyph: GlyphMark, 2020 + target: Option<LeftPane>, 2021 + } 2022 + 2023 + fn render_left_pane_tabs( 2024 + ctx: &mut FrameCtx<'_>, 2025 + rect: LayoutRect, 2026 + ids: &ShellIds, 2027 + active: LeftPane, 2028 + paints: &mut Vec<WidgetPaint>, 2029 + ) -> Option<LeftPane> { 2030 + if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 2031 + return None; 2032 + } 2033 + let specs = [ 2034 + LeftPaneTabSpec { 2035 + id: ids.left_pane_tab_tree, 2036 + label: strings::FEATURE_TREE_LABEL, 2037 + glyph: GlyphMark::TabTree, 2038 + target: Some(LeftPane::Tree), 2039 + }, 2040 + LeftPaneTabSpec { 2041 + id: ids.left_pane_tab_properties, 2042 + label: strings::PROPERTY_PANE_LABEL, 2043 + glyph: GlyphMark::TabProperties, 2044 + target: Some(LeftPane::Properties), 2045 + }, 2046 + LeftPaneTabSpec { 2047 + id: ids.left_pane_tab_configuration, 2048 + label: strings::LEFT_PANE_TAB_CONFIGURATION, 2049 + glyph: GlyphMark::TabConfiguration, 2050 + target: None, 2051 + }, 2052 + LeftPaneTabSpec { 2053 + id: ids.left_pane_tab_dimension_expert, 2054 + label: strings::LEFT_PANE_TAB_DIMENSION_EXPERT, 2055 + glyph: GlyphMark::TabDimensionExpert, 2056 + target: None, 2057 + }, 2058 + LeftPaneTabSpec { 2059 + id: ids.left_pane_tab_display, 2060 + label: strings::LEFT_PANE_TAB_DISPLAY, 2061 + glyph: GlyphMark::TabDisplay, 2062 + target: None, 2063 + }, 2064 + ]; 2065 + let tab_views: Vec<Tab> = specs 2066 + .iter() 2067 + .scan(rect.origin.x.value(), |x, spec| { 2068 + let tab_rect = LayoutRect::new( 2069 + LayoutPos::new(LayoutPx::new(*x), rect.origin.y), 2070 + LayoutSize::new(LayoutPx::new(LEFT_PANE_TAB_WIDTH_PX), rect.size.height), 2071 + ); 2072 + *x += LEFT_PANE_TAB_WIDTH_PX; 2073 + Some( 2074 + Tab::new(spec.id, tab_rect, spec.label) 2075 + .with_glyph(spec.glyph) 2076 + .disabled(spec.target.is_none()), 2077 + ) 2078 + }) 2079 + .collect(); 2080 + let active_id = specs 2081 + .iter() 2082 + .find_map(|spec| (spec.target == Some(active)).then_some(spec.id)) 2083 + .unwrap_or(ids.left_pane_tab_tree); 2084 + let response = show_tabs( 2085 + ctx, 2086 + Tabs::new( 2087 + ids.left_pane.child(WidgetKey::new("tabs")), 2088 + TabsOrientation::Top, 2089 + strings::LEFT_PANE_LABEL, 2090 + tab_views.as_slice(), 2091 + active_id, 2092 + ), 2093 + ); 2094 + paints.extend(response.paint); 2095 + response.activated.and_then(|id| { 2096 + specs 2097 + .iter() 2098 + .find_map(|spec| (spec.id == id).then_some(spec.target).flatten()) 2099 + }) 2100 + } 2101 + 2102 + const CONFIRM_BUTTON_PX: f32 = 36.0; 2103 + const CONFIRM_BUTTON_GAP: f32 = 6.0; 2104 + const CONFIRM_CORNER_INSET: f32 = 12.0; 2105 + 2106 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 2107 + pub enum ConfirmAction { 2108 + Accept, 2109 + Cancel, 2110 + } 2111 + 2112 + fn render_confirm_corner( 2113 + ctx: &mut FrameCtx<'_>, 2114 + viewport: LayoutRect, 2115 + ids: &ShellIds, 2116 + visible: bool, 2117 + paints: &mut Vec<WidgetPaint>, 2118 + ) -> Option<ConfirmAction> { 2119 + let pair_width = 2.0 * CONFIRM_BUTTON_PX + CONFIRM_BUTTON_GAP; 2120 + let min_width = pair_width + 2.0 * CONFIRM_CORNER_INSET; 2121 + let min_height = CONFIRM_BUTTON_PX + 2.0 * CONFIRM_CORNER_INSET; 2122 + if !visible 2123 + || viewport.size.width.value() < min_width 2124 + || viewport.size.height.value() < min_height 2125 + { 2126 + return None; 2127 + } 2128 + let top_y = viewport.origin.y.value() + CONFIRM_CORNER_INSET; 2129 + let cancel_x = viewport.origin.x.value() + viewport.size.width.value() 2130 + - CONFIRM_CORNER_INSET 2131 + - CONFIRM_BUTTON_PX; 2132 + let accept_x = cancel_x - CONFIRM_BUTTON_GAP - CONFIRM_BUTTON_PX; 2133 + let accept_rect = LayoutRect::new( 2134 + LayoutPos::new(LayoutPx::new(accept_x), LayoutPx::new(top_y)), 2135 + LayoutSize::new(LayoutPx::new(CONFIRM_BUTTON_PX), LayoutPx::new(CONFIRM_BUTTON_PX)), 2136 + ); 2137 + let cancel_rect = LayoutRect::new( 2138 + LayoutPos::new(LayoutPx::new(cancel_x), LayoutPx::new(top_y)), 2139 + LayoutSize::new(LayoutPx::new(CONFIRM_BUTTON_PX), LayoutPx::new(CONFIRM_BUTTON_PX)), 2140 + ); 2141 + let accept_clicked = paint_confirm_button( 2142 + ctx, 2143 + ids.confirm_accept, 2144 + accept_rect, 2145 + GlyphMark::Checkmark, 2146 + strings::CONFIRM_ACCEPT, 2147 + ConfirmTone::Accept, 2148 + paints, 2149 + ); 2150 + let cancel_clicked = paint_confirm_button( 2151 + ctx, 2152 + ids.confirm_cancel, 2153 + cancel_rect, 2154 + GlyphMark::Close, 2155 + strings::CONFIRM_CANCEL, 2156 + ConfirmTone::Cancel, 2157 + paints, 2158 + ); 2159 + if accept_clicked { 2160 + Some(ConfirmAction::Accept) 2161 + } else if cancel_clicked { 2162 + Some(ConfirmAction::Cancel) 2163 + } else { 2164 + None 2165 + } 2166 + } 2167 + 2168 + #[derive(Copy, Clone)] 2169 + enum ConfirmTone { 2170 + Accept, 2171 + Cancel, 2172 + } 2173 + 2174 + fn paint_confirm_button( 2175 + ctx: &mut FrameCtx<'_>, 2176 + id: WidgetId, 2177 + rect: LayoutRect, 2178 + glyph: GlyphMark, 2179 + label: StringKey, 2180 + tone: ConfirmTone, 2181 + paints: &mut Vec<WidgetPaint>, 2182 + ) -> bool { 2183 + let interaction = ctx.interact( 2184 + InteractDeclaration::new(id, rect, Sense::INTERACTIVE) 2185 + .focusable(true) 2186 + .a11y(AccessNode::new(Role::Button).with_label(label)), 2187 + ); 2188 + let theme = ctx.theme(); 2189 + let palette = match tone { 2190 + ConfirmTone::Accept => theme.colors.success, 2191 + ConfirmTone::Cancel => theme.colors.danger, 2192 + }; 2193 + let (fill, glyph_color) = if interaction.pressed() { 2194 + (palette.step(Step12::SELECTED_BG), palette.step(Step12::HOVER_SOLID)) 2195 + } else if interaction.hover() { 2196 + (palette.step(Step12::HOVER_BG), palette.step(Step12::SOLID)) 2197 + } else { 2198 + ( 2199 + theme.colors.surface(theme.elevation.level3.surface), 2200 + palette.step(Step12::SOLID), 2201 + ) 2202 + }; 2203 + paints.push(WidgetPaint::Surface { 2204 + rect, 2205 + fill, 2206 + border: Some(Border { 2207 + width: StrokeWidth::HAIRLINE, 2208 + color: palette.step(Step12::SOLID), 2209 + }), 2210 + radius: theme.radius.sm, 2211 + elevation: Some(theme.elevation.level3), 2212 + }); 2213 + paints.push(WidgetPaint::Mark { 2214 + rect, 2215 + kind: glyph, 2216 + color: glyph_color, 2217 + }); 2218 + interaction.click() 2219 + } 2220 + 2221 + fn build_dock_main(panels: ShellPanels) -> DockNode { 2222 + const LEFT_PANE_RATIO: SplitFraction = SplitFraction::clamped(0.22); 2223 + DockNode::split( 2224 + Axis::Horizontal, 2225 + LEFT_PANE_RATIO, 2226 + DockNode::tabs(vec![panels.left_pane]), 2227 + DockNode::tabs(vec![panels.viewport]), 2228 + ) 2229 + } 2230 + 1191 2231 #[cfg(test)] 1192 2232 mod tests { 1193 2233 use super::*; ··· 1212 2252 } 1213 2253 1214 2254 fn render_with(theme: Theme, size: LayoutSize, document: &Document, mode: &Mode) -> ShellFrame { 1215 - let Ok(mut shell) = Shell::new() else { 1216 - panic!("shell init"); 1217 - }; 2255 + let mut shell = Shell::new(); 2256 + render_into_shell( 2257 + &mut shell, 2258 + theme, 2259 + size, 2260 + document, 2261 + mode, 2262 + &Selection::default(), 2263 + ) 2264 + } 2265 + 2266 + fn render_into_shell( 2267 + shell: &mut Shell, 2268 + theme: Theme, 2269 + size: LayoutSize, 2270 + document: &Document, 2271 + mode: &Mode, 2272 + selection: &Selection, 2273 + ) -> ShellFrame { 1218 2274 let theme = Arc::new(theme); 1219 2275 let table = HotkeyTable::new(); 1220 2276 let mut focus = FocusManager::new(); ··· 1234 2290 &mut a11y, 1235 2291 &mut shaper, 1236 2292 ); 1237 - shell.render(&mut ctx, document, mode, &[], size) 2293 + shell.render(&mut ctx, document, mode, selection, Settings::default(), size, None) 2294 + } 2295 + 2296 + #[test] 2297 + fn tools_options_menu_id_maps_to_open_settings_action() { 2298 + let shell = Shell::new(); 2299 + assert_eq!( 2300 + shell.ids.menu_action_for(shell.ids.menu_tools_options), 2301 + Some(MenuAction::OpenSettings), 2302 + ); 2303 + } 2304 + 2305 + #[test] 2306 + fn settings_dialog_does_not_render_when_closed() { 2307 + let frame = render_with( 2308 + Theme::light(), 2309 + layout_size(1280.0, 800.0), 2310 + &sample_document(), 2311 + &Mode::Idle, 2312 + ); 2313 + let title_visible = frame 2314 + .paints 2315 + .iter() 2316 + .chain(frame.overlay_paints.iter()) 2317 + .any(|p| { 2318 + matches!( 2319 + p, 2320 + WidgetPaint::Label { 2321 + text: LabelText::Key(k), 2322 + .. 2323 + } if *k == strings::SETTINGS_DIALOG_TITLE 2324 + ) 2325 + }); 2326 + assert!(!title_visible, "settings dialog must not paint when closed"); 2327 + assert!(frame.settings_change.is_none()); 2328 + } 2329 + 2330 + #[test] 2331 + fn settings_dialog_paints_title_and_aperture_label_when_open() { 2332 + let mut shell = Shell::new(); 2333 + shell.state.settings_dialog_open = true; 2334 + let frame = render_into_shell( 2335 + &mut shell, 2336 + Theme::light(), 2337 + layout_size(1280.0, 800.0), 2338 + &sample_document(), 2339 + &Mode::Idle, 2340 + &Selection::default(), 2341 + ); 2342 + let has_title = frame.overlay_paints.iter().any(|p| { 2343 + matches!( 2344 + p, 2345 + WidgetPaint::Label { 2346 + text: LabelText::Key(k), 2347 + .. 2348 + } if *k == strings::SETTINGS_DIALOG_TITLE 2349 + ) 2350 + }); 2351 + assert!(has_title, "open dialog should paint its title key"); 2352 + let has_aperture_text = frame.overlay_paints.iter().any(|p| { 2353 + matches!( 2354 + p, 2355 + WidgetPaint::Label { 2356 + text: LabelText::Owned(text), 2357 + .. 2358 + } if text.contains("px") 2359 + ) 2360 + }); 2361 + assert!( 2362 + has_aperture_text, 2363 + "aperture label should include unit suffix px" 2364 + ); 1238 2365 } 1239 2366 1240 2367 #[test] ··· 1259 2386 let v = frame.viewport_rect; 1260 2387 assert!(v.size.width.value() > 0.0); 1261 2388 assert!(v.size.height.value() > 0.0); 1262 - assert!(v.min_x().value() > 0.0, "feature tree carved on left"); 2389 + assert!(v.min_x().value() > 0.0, "left pane carved on left"); 1263 2390 assert!(v.min_y().value() > 0.0, "ribbon carved on top"); 1264 - assert!(v.max_x().value() < 1280.0, "property pane carved on right"); 2391 + assert!(v.max_x().value() <= 1280.0, "viewport bounded by window width"); 1265 2392 assert!(v.max_y().value() < 800.0, "status bar carved on bottom"); 1266 2393 } 1267 2394 ··· 1287 2414 1288 2415 #[test] 1289 2416 fn shell_seeds_part_node_expanded() { 1290 - let Ok(shell) = Shell::new() else { 1291 - panic!("shell init"); 1292 - }; 2417 + let shell = Shell::new(); 1293 2418 assert!( 1294 2419 shell 1295 2420 .state ··· 1323 2448 assert_eq!(ids.plane_for(ids.plane_yz), Some(Plane::Yz)); 1324 2449 assert_eq!(ids.plane_for(ids.plane_zx), Some(Plane::Zx)); 1325 2450 assert_eq!(ids.plane_for(ids.feature_tree), None); 1326 - assert_eq!(ids.plane_for(ids.ribbon_exit), None); 1327 - } 1328 - 1329 - #[test] 1330 - fn ribbon_exit_id_is_distinct_from_tool_ids() { 1331 - let ids = ShellIds::standard(); 1332 - let tools = build_tool_index(ids.ribbon); 1333 - assert!(!tools.contains_key(&ids.ribbon_exit)); 2451 + assert_eq!(ids.plane_for(ids.confirm_accept), None); 1334 2452 } 1335 2453 1336 2454 #[test] ··· 1347 2465 } 1348 2466 1349 2467 #[test] 1350 - fn idle_render_omits_exit_group_label() { 2468 + fn idle_render_omits_confirm_corner() { 1351 2469 let frame = render_with( 1352 2470 Theme::light(), 1353 2471 layout_size(1280.0, 800.0), 1354 2472 &sample_document(), 1355 2473 &Mode::Idle, 1356 2474 ); 1357 - assert!(!frame.paints.iter().any(is_exit_group_label)); 2475 + assert!(!frame.paints.iter().any(is_confirm_glyph)); 1358 2476 } 1359 2477 1360 2478 #[test] 1361 - fn sketch_render_includes_exit_group_label() { 2479 + fn sketch_render_includes_confirm_corner() { 1362 2480 let frame = render_with( 1363 2481 Theme::light(), 1364 2482 layout_size(1280.0, 800.0), 1365 2483 &sample_document(), 1366 2484 &Mode::enter_sketch(SketchId::default()), 1367 2485 ); 1368 - assert!(frame.paints.iter().any(is_exit_group_label)); 2486 + assert!(frame.paints.iter().any(is_confirm_glyph)); 1369 2487 } 1370 2488 1371 - fn is_exit_group_label(paint: &WidgetPaint) -> bool { 2489 + fn is_confirm_glyph(paint: &WidgetPaint) -> bool { 1372 2490 matches!( 1373 2491 paint, 1374 - WidgetPaint::Label { text: LabelText::Key(key), .. } 1375 - if *key == crate::strings::RIBBON_GROUP_EXIT 2492 + WidgetPaint::Mark { kind, .. } if matches!(kind, bone_ui::widgets::GlyphMark::Checkmark | bone_ui::widgets::GlyphMark::Close) 1376 2493 ) 1377 2494 } 1378 2495 ··· 1503 2620 use crate::strings as app_strings; 1504 2621 use bone_ui::strings::Locale; 1505 2622 let table = app_strings::make_strings(Locale::EnUs); 1506 - let Ok(mut shell) = Shell::new() else { 1507 - panic!("shell init"); 1508 - }; 2623 + let mut shell = Shell::new(); 1509 2624 let theme = Arc::new(Theme::light()); 1510 2625 let hk = HotkeyTable::new(); 1511 2626 let mut focus = FocusManager::new(); ··· 1529 2644 &mut ctx, 1530 2645 &sample_document(), 1531 2646 &Mode::enter_sketch(SketchId::default()), 1532 - &[], 2647 + &Selection::default(), 2648 + Settings::default(), 1533 2649 layout_size(1600.0, 900.0), 2650 + None, 1534 2651 ); 1535 2652 let any_smart_dim_label = frame.paints.iter().any(|p| { 1536 2653 matches!( ··· 1687 2804 let (sketch, b) = crate::tools::add_point(sketch, bone_types::Point2::from_mm(5.0, 0.0)); 1688 2805 let (sketch, line) = crate::tools::add_line(sketch, a, b, false); 1689 2806 (sketch, line) 2807 + } 2808 + 2809 + fn sketch_with_dim( 2810 + kind: DimensionKind, 2811 + ) -> (bone_document::Sketch, SketchDimensionId) { 2812 + use bone_document::{EditOutcome, SketchEdit}; 2813 + use bone_types::Point2; 2814 + use uom::si::length::millimeter; 2815 + let s = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 2816 + let (s, a) = crate::tools::add_point(s, Point2::from_mm(0.0, 0.0)); 2817 + let (s, b) = crate::tools::add_point(s, Point2::from_mm(5.0, 0.0)); 2818 + let dim = SketchDimension::Linear { 2819 + a, 2820 + b, 2821 + value: Length::new::<millimeter>(5.0), 2822 + kind, 2823 + }; 2824 + let Ok((s, EditOutcome::Dimension(id))) = s.apply(SketchEdit::AddDimension(dim)) else { 2825 + panic!("expected dimension outcome"); 2826 + }; 2827 + (s, id) 2828 + } 2829 + 2830 + fn sketch_with_relation() -> (bone_document::Sketch, bone_types::SketchRelationId, SketchEntityId) { 2831 + use bone_document::{EditOutcome, SketchEdit}; 2832 + use bone_types::Point2; 2833 + let s = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 2834 + let (s, a) = crate::tools::add_point(s, Point2::from_mm(0.0, 0.0)); 2835 + let (s, b) = crate::tools::add_point(s, Point2::from_mm(5.0, 0.0)); 2836 + let (s, line) = crate::tools::add_line(s, a, b, false); 2837 + let Ok((s, EditOutcome::Relation(id))) = 2838 + s.apply(SketchEdit::AddRelation(SketchRelation::Horizontal(line))) 2839 + else { 2840 + panic!("expected relation outcome"); 2841 + }; 2842 + (s, id, line) 2843 + } 2844 + 2845 + fn document_with_sketch(sketch: bone_document::Sketch) -> (Document, SketchId) { 2846 + let mut doc = sample_document(); 2847 + let id = SketchId::default(); 2848 + doc.insert_sketch(id, "Sketch1".to_owned(), sketch); 2849 + (doc, id) 2850 + } 2851 + 2852 + #[test] 2853 + fn property_pane_for_driving_dim_populates_editor_with_value() { 2854 + let (sketch, dim_id) = sketch_with_dim(DimensionKind::Driving); 2855 + let (doc, sketch_id) = document_with_sketch(sketch); 2856 + let mut shell = Shell::new(); 2857 + let frame = render_into_shell( 2858 + &mut shell, 2859 + Theme::light(), 2860 + layout_size(1280.0, 800.0), 2861 + &doc, 2862 + &Mode::enter_sketch(sketch_id), 2863 + &Selection::Dimension(dim_id), 2864 + ); 2865 + let Some(DimPropertyEditor::Length { id, editor, .. }) = &shell.state.dim_property 2866 + else { 2867 + panic!("expected length editor populated"); 2868 + }; 2869 + assert_eq!(*id, dim_id); 2870 + assert!( 2871 + (editor.value.get::<millimeter>() - 5.0).abs() < 1e-9, 2872 + "editor value: {}", 2873 + editor.value.get::<millimeter>() 2874 + ); 2875 + assert!(frame.dimension_edit.is_none(), "no input commit yet"); 2876 + } 2877 + 2878 + #[test] 2879 + fn property_pane_keeps_driven_editor_but_marks_read_only() { 2880 + let (sketch, dim_id) = sketch_with_dim(DimensionKind::Driven); 2881 + let (doc, sketch_id) = document_with_sketch(sketch); 2882 + let mut shell = Shell::new(); 2883 + let _ = render_into_shell( 2884 + &mut shell, 2885 + Theme::light(), 2886 + layout_size(1280.0, 800.0), 2887 + &doc, 2888 + &Mode::enter_sketch(sketch_id), 2889 + &Selection::Dimension(dim_id), 2890 + ); 2891 + let Some(DimPropertyEditor::Length { id, .. }) = &shell.state.dim_property else { 2892 + panic!("expected length editor populated"); 2893 + }; 2894 + assert_eq!(*id, dim_id); 2895 + } 2896 + 2897 + #[test] 2898 + fn property_pane_drops_dim_editor_when_selection_changes_off_dim() { 2899 + let (sketch, dim_id) = sketch_with_dim(DimensionKind::Driving); 2900 + let (doc, sketch_id) = document_with_sketch(sketch); 2901 + let mut shell = Shell::new(); 2902 + let _ = render_into_shell( 2903 + &mut shell, 2904 + Theme::light(), 2905 + layout_size(1280.0, 800.0), 2906 + &doc, 2907 + &Mode::enter_sketch(sketch_id), 2908 + &Selection::Dimension(dim_id), 2909 + ); 2910 + assert!(shell.state.dim_property.is_some()); 2911 + let _ = render_into_shell( 2912 + &mut shell, 2913 + Theme::light(), 2914 + layout_size(1280.0, 800.0), 2915 + &doc, 2916 + &Mode::enter_sketch(sketch_id), 2917 + &Selection::default(), 2918 + ); 2919 + assert!(shell.state.dim_property.is_none()); 2920 + } 2921 + 2922 + #[test] 2923 + fn property_pane_swaps_editor_when_dim_id_changes() { 2924 + use bone_document::{EditOutcome, SketchEdit}; 2925 + use bone_types::Point2; 2926 + use uom::si::length::millimeter; 2927 + let s = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 2928 + let (s, a) = crate::tools::add_point(s, Point2::from_mm(0.0, 0.0)); 2929 + let (s, b) = crate::tools::add_point(s, Point2::from_mm(5.0, 0.0)); 2930 + let (s, c) = crate::tools::add_point(s, Point2::from_mm(0.0, 5.0)); 2931 + let Ok((s, EditOutcome::Dimension(dim_a))) = 2932 + s.apply(SketchEdit::AddDimension(SketchDimension::Linear { 2933 + a, 2934 + b, 2935 + value: Length::new::<millimeter>(5.0), 2936 + kind: DimensionKind::Driving, 2937 + })) 2938 + else { 2939 + panic!("expected first Dimension outcome"); 2940 + }; 2941 + let Ok((s, EditOutcome::Dimension(dim_b))) = 2942 + s.apply(SketchEdit::AddDimension(SketchDimension::Linear { 2943 + a, 2944 + b: c, 2945 + value: Length::new::<millimeter>(5.0), 2946 + kind: DimensionKind::Driving, 2947 + })) 2948 + else { 2949 + panic!("expected second Dimension outcome"); 2950 + }; 2951 + assert_ne!(dim_a, dim_b); 2952 + let (doc, sketch_id) = document_with_sketch(s); 2953 + let mut shell = Shell::new(); 2954 + let _ = render_into_shell( 2955 + &mut shell, 2956 + Theme::light(), 2957 + layout_size(1280.0, 800.0), 2958 + &doc, 2959 + &Mode::enter_sketch(sketch_id), 2960 + &Selection::Dimension(dim_a), 2961 + ); 2962 + let _ = render_into_shell( 2963 + &mut shell, 2964 + Theme::light(), 2965 + layout_size(1280.0, 800.0), 2966 + &doc, 2967 + &Mode::enter_sketch(sketch_id), 2968 + &Selection::Dimension(dim_b), 2969 + ); 2970 + let Some(DimPropertyEditor::Length { id, .. }) = &shell.state.dim_property else { 2971 + panic!("expected length editor for second dim"); 2972 + }; 2973 + assert_eq!(*id, dim_b); 2974 + } 2975 + 2976 + #[test] 2977 + fn property_pane_renders_relation_kind_label() { 2978 + let (sketch, _rel_id, _line) = sketch_with_relation(); 2979 + let (doc, sketch_id) = document_with_sketch(sketch); 2980 + let Some(sketch_ref) = doc.sketch(sketch_id) else { 2981 + panic!("expected inserted sketch"); 2982 + }; 2983 + let Some(rel_id) = sketch_ref.relation_order().first().copied() else { 2984 + panic!("expected relation present"); 2985 + }; 2986 + let mut shell = Shell::new(); 2987 + let frame = render_into_shell( 2988 + &mut shell, 2989 + Theme::light(), 2990 + layout_size(1280.0, 800.0), 2991 + &doc, 2992 + &Mode::enter_sketch(sketch_id), 2993 + &Selection::Relation(rel_id), 2994 + ); 2995 + let any_horizontal_label = frame.paints.iter().any(|p| match p { 2996 + WidgetPaint::Label { text: LabelText::Owned(text), .. } => { 2997 + text == StringTable::empty().resolve(strings::TOOL_HORIZONTAL) 2998 + } 2999 + _ => false, 3000 + }); 3001 + assert!(any_horizontal_label, "relation kind label should appear"); 3002 + assert!(shell.state.dim_property.is_none(), "relation does not own dim editor"); 1690 3003 } 1691 3004 }