Another project
0

Configure Feed

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

feat(app): dock shell pre-work

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

author
Lewis
date (May 8, 2026, 9:29 AM +0300) commit b629525e parent af10a92f change-id xwyvtono
+677
+677
crates/bone-app/src/shell.rs
··· 1 + use core::num::NonZeroU32; 2 + use std::collections::BTreeMap; 3 + use std::sync::Arc; 4 + 5 + use bone_document::Document; 6 + use bone_ui::frame::FrameCtx; 7 + use bone_ui::layout::{ 8 + Axis, DockPanel, DockState, DockStateError, Layout, LayoutPos, LayoutPx, LayoutRect, 9 + LayoutSize, NodeKind, PanelId, RetainedLayout, SolvedLayout, SolvedNode, measure, 10 + }; 11 + use bone_ui::strings::StringKey; 12 + use bone_ui::theme::{ElevationLevel, Radius, Step12, StrokeWidth, Theme}; 13 + 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, 17 + }; 18 + use bone_ui::{WidgetId, WidgetKey}; 19 + 20 + use crate::sketch_mode::{Mode, SketchTool}; 21 + use crate::strings; 22 + 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); 26 + const STATUS_MODE_WIDTH: LayoutPx = LayoutPx::new(220.0); 27 + 28 + #[derive(Debug, thiserror::Error)] 29 + pub enum ShellError { 30 + #[error("dock state: {0}")] 31 + Dock(#[from] DockStateError), 32 + } 33 + 34 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 35 + struct ShellPanels { 36 + ribbon: PanelId, 37 + feature_tree: PanelId, 38 + property_pane: PanelId, 39 + viewport: PanelId, 40 + status: PanelId, 41 + } 42 + 43 + impl ShellPanels { 44 + fn standard() -> Self { 45 + Self { 46 + ribbon: panel(1), 47 + feature_tree: panel(2), 48 + property_pane: panel(3), 49 + viewport: panel(4), 50 + status: panel(5), 51 + } 52 + } 53 + } 54 + 55 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 56 + struct ShellIds { 57 + dock_host: WidgetId, 58 + ribbon: WidgetId, 59 + feature_tree: WidgetId, 60 + property_pane: WidgetId, 61 + viewport: WidgetId, 62 + status_bar: WidgetId, 63 + feature_part: WidgetId, 64 + } 65 + 66 + impl ShellIds { 67 + fn standard() -> Self { 68 + let root = WidgetId::ROOT.child(WidgetKey::new("shell")); 69 + let feature_tree = root.child(WidgetKey::new("tree")); 70 + Self { 71 + dock_host: root.child(WidgetKey::new("dock")), 72 + ribbon: root.child(WidgetKey::new("ribbon")), 73 + feature_tree, 74 + property_pane: root.child(WidgetKey::new("props")), 75 + viewport: root.child(WidgetKey::new("viewport")), 76 + status_bar: root.child(WidgetKey::new("status")), 77 + feature_part: feature_tree.child(WidgetKey::new("part")), 78 + } 79 + } 80 + } 81 + 82 + pub struct Shell { 83 + panels: ShellPanels, 84 + ids: ShellIds, 85 + retained_layout: RetainedLayout, 86 + dock_state: Arc<DockState>, 87 + tool_index: BTreeMap<WidgetId, SketchTool>, 88 + pub state: ShellState, 89 + } 90 + 91 + #[derive(Default)] 92 + pub struct ShellState { 93 + pub ribbon: RibbonState, 94 + pub feature_tree: TreeViewState, 95 + pub clipboard: MemoryClipboard, 96 + } 97 + 98 + #[derive(Clone, Debug, PartialEq)] 99 + pub struct ShellFrame { 100 + pub paints: Vec<WidgetPaint>, 101 + pub viewport_rect: LayoutRect, 102 + pub activated_tool: Option<SketchTool>, 103 + } 104 + 105 + impl ShellFrame { 106 + fn empty() -> Self { 107 + Self { 108 + paints: Vec::new(), 109 + viewport_rect: zero_rect(), 110 + activated_tool: None, 111 + } 112 + } 113 + } 114 + 115 + impl Shell { 116 + pub fn new() -> Result<Self, ShellError> { 117 + let panels = ShellPanels::standard(); 118 + let ids = ShellIds::standard(); 119 + let dock_state = Arc::new(DockState::solidworks_default( 120 + panels.feature_tree, 121 + panels.property_pane, 122 + panels.ribbon, 123 + panels.viewport, 124 + panels.status, 125 + )?); 126 + let tool_index = build_tool_index(ids.ribbon); 127 + let mut state = ShellState::default(); 128 + state.feature_tree.expanded.insert(ids.feature_part); 129 + Ok(Self { 130 + panels, 131 + ids, 132 + retained_layout: RetainedLayout::default(), 133 + dock_state, 134 + tool_index, 135 + state, 136 + }) 137 + } 138 + 139 + pub fn render( 140 + &mut self, 141 + ctx: &mut FrameCtx<'_>, 142 + document: &Document, 143 + mode: &Mode, 144 + viewport_size: LayoutSize, 145 + ) -> ShellFrame { 146 + let theme = ctx.theme(); 147 + let direction = ctx.direction(); 148 + let layout = Layout::dock_host( 149 + self.ids.dock_host, 150 + Arc::clone(&self.dock_state), 151 + vec![ 152 + DockPanel { 153 + id: self.panels.ribbon, 154 + child: Layout::leaf(self.ids.ribbon), 155 + }, 156 + DockPanel { 157 + id: self.panels.feature_tree, 158 + child: Layout::leaf(self.ids.feature_tree), 159 + }, 160 + DockPanel { 161 + id: self.panels.property_pane, 162 + child: Layout::leaf(self.ids.property_pane), 163 + }, 164 + DockPanel { 165 + id: self.panels.viewport, 166 + child: Layout::leaf(self.ids.viewport), 167 + }, 168 + DockPanel { 169 + id: self.panels.status, 170 + child: Layout::leaf(self.ids.status_bar), 171 + }, 172 + ], 173 + theme.spacing.md, 174 + ); 175 + let Ok(solved) = measure(&layout, viewport_size, &self.retained_layout, direction) else { 176 + return ShellFrame::empty(); 177 + }; 178 + let inset_px = theme.spacing.sm.value_px(); 179 + let mut paints = paint_walk(&solved, solved.root_node(), theme, self.panels.viewport); 180 + let viewport_rect = panel_rect(&solved, self.panels.viewport).unwrap_or_else(zero_rect); 181 + let ribbon_rect = panel_rect(&solved, self.panels.ribbon).unwrap_or_else(zero_rect); 182 + let tree_rect = panel_rect(&solved, self.panels.feature_tree) 183 + .map_or_else(zero_rect, |r| inset_rect(r, inset_px)); 184 + let property_rect = panel_rect(&solved, self.panels.property_pane) 185 + .map_or_else(zero_rect, |r| inset_rect(r, inset_px)); 186 + let status_rect = panel_rect(&solved, self.panels.status).unwrap_or_else(zero_rect); 187 + let activated_widget = render_ribbon( 188 + ctx, 189 + ribbon_rect, 190 + self.ids.ribbon, 191 + &mut self.state.ribbon, 192 + mode, 193 + &mut paints, 194 + ); 195 + render_feature_tree( 196 + ctx, 197 + tree_rect, 198 + self.ids.feature_tree, 199 + self.ids.feature_part, 200 + &mut self.state.feature_tree, 201 + document, 202 + &mut paints, 203 + ); 204 + render_property_pane( 205 + ctx, 206 + property_rect, 207 + self.ids.property_pane, 208 + &mut self.state.clipboard, 209 + &mut paints, 210 + ); 211 + render_status_bar(ctx, status_rect, self.ids.status_bar, mode, &mut paints); 212 + ShellFrame { 213 + paints, 214 + viewport_rect, 215 + activated_tool: activated_widget.and_then(|id| self.tool_index.get(&id).copied()), 216 + } 217 + } 218 + } 219 + 220 + fn render_ribbon( 221 + ctx: &mut FrameCtx<'_>, 222 + rect: LayoutRect, 223 + ribbon: WidgetId, 224 + state: &mut RibbonState, 225 + mode: &Mode, 226 + paints: &mut Vec<WidgetPaint>, 227 + ) -> Option<WidgetId> { 228 + if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 229 + return None; 230 + } 231 + let active_tool = match mode { 232 + Mode::Sketch { session, .. } => session.tool, 233 + Mode::Idle => None, 234 + }; 235 + let entity_items: Vec<ToolbarItem> = SketchTool::ENTITIES 236 + .iter() 237 + .copied() 238 + .map(|t| { 239 + ToolbarItem::new(tool_widget_id(ribbon, t), tool_label(t)) 240 + .active(active_tool == Some(t)) 241 + }) 242 + .collect(); 243 + let dimension_items = vec![ 244 + ToolbarItem::new( 245 + tool_widget_id(ribbon, SketchTool::SmartDimension), 246 + strings::TOOL_SMART_DIMENSION, 247 + ) 248 + .active(active_tool == Some(SketchTool::SmartDimension)), 249 + ]; 250 + let relation_items = relation_tool_buttons(ribbon); 251 + 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 + )]; 279 + let response = show_ribbon( 280 + ctx, 281 + Ribbon::new(ribbon, rect, strings::RIBBON_LABEL, &tabs, tab_id, state), 282 + ); 283 + paints.extend(response.paint); 284 + response.activated_tool 285 + } 286 + 287 + fn render_feature_tree( 288 + ctx: &mut FrameCtx<'_>, 289 + rect: LayoutRect, 290 + tree_id: WidgetId, 291 + part_id: WidgetId, 292 + state: &mut TreeViewState, 293 + document: &Document, 294 + paints: &mut Vec<WidgetPaint>, 295 + ) { 296 + if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 297 + return; 298 + } 299 + let leaf = |key: &'static str, label: StringKey| { 300 + TreeNode::leaf(part_id.child(WidgetKey::new(key)), label) 301 + }; 302 + let sketches: Vec<TreeNode> = document 303 + .sketches() 304 + .enumerate() 305 + .map(|(idx, _)| { 306 + TreeNode::leaf( 307 + part_id.child_indexed(WidgetKey::new("sketch"), idx as u64), 308 + strings::FEATURE_SKETCH_DEFAULT, 309 + ) 310 + }) 311 + .collect(); 312 + let children: Vec<TreeNode> = [ 313 + leaf("origin", strings::FEATURE_ORIGIN), 314 + leaf("plane.xy", strings::FEATURE_PLANE_XY), 315 + leaf("plane.yz", strings::FEATURE_PLANE_YZ), 316 + leaf("plane.zx", strings::FEATURE_PLANE_ZX), 317 + ] 318 + .into_iter() 319 + .chain(sketches) 320 + .collect(); 321 + let part = TreeNode::parent_owned(part_id, document.name().to_owned(), children); 322 + let roots = [part]; 323 + let response = show_tree_view( 324 + ctx, 325 + TreeView::new(tree_id, rect, strings::FEATURE_TREE_LABEL, &roots, state), 326 + ); 327 + paints.extend(response.paint); 328 + } 329 + 330 + fn render_property_pane( 331 + ctx: &mut FrameCtx<'_>, 332 + rect: LayoutRect, 333 + id: WidgetId, 334 + clipboard: &mut MemoryClipboard, 335 + paints: &mut Vec<WidgetPaint>, 336 + ) { 337 + if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 338 + return; 339 + } 340 + let mut rows: [PropertyRow<'_>; 0] = []; 341 + let response = show_property_grid( 342 + ctx, 343 + PropertyGrid::new(id, rect, strings::PROPERTY_PANE_LABEL, &mut rows), 344 + clipboard, 345 + ); 346 + paints.extend(response.paint); 347 + } 348 + 349 + fn render_status_bar( 350 + ctx: &mut FrameCtx<'_>, 351 + rect: LayoutRect, 352 + id: WidgetId, 353 + mode: &Mode, 354 + paints: &mut Vec<WidgetPaint>, 355 + ) { 356 + if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 357 + return; 358 + } 359 + let label = match mode { 360 + Mode::Idle => strings::STATUS_READY, 361 + Mode::Sketch { .. } => strings::STATUS_SKETCH_ACTIVE, 362 + }; 363 + let item = StatusItem::new( 364 + id.child(WidgetKey::new("mode")), 365 + label, 366 + StatusAlign::Start, 367 + STATUS_MODE_WIDTH, 368 + ); 369 + let items = [item]; 370 + let response = show_status_bar( 371 + ctx, 372 + StatusBar::new(id, rect, strings::STATUS_BAR_LABEL, &items), 373 + ); 374 + paints.extend(response.paint); 375 + } 376 + 377 + fn relation_tool_buttons(ribbon: WidgetId) -> Vec<ToolbarItem> { 378 + [ 379 + ("coincident", strings::TOOL_COINCIDENT), 380 + ("horizontal", strings::TOOL_HORIZONTAL), 381 + ("vertical", strings::TOOL_VERTICAL), 382 + ("parallel", strings::TOOL_PARALLEL), 383 + ("perpendicular", strings::TOOL_PERPENDICULAR), 384 + ("tangent", strings::TOOL_TANGENT), 385 + ("equal", strings::TOOL_EQUAL), 386 + ("concentric", strings::TOOL_CONCENTRIC), 387 + ("fix", strings::TOOL_FIX), 388 + ] 389 + .into_iter() 390 + .map(|(key, label)| ToolbarItem::new(ribbon.child(WidgetKey::new(key)), label)) 391 + .collect() 392 + } 393 + 394 + fn build_tool_index(ribbon: WidgetId) -> BTreeMap<WidgetId, SketchTool> { 395 + SketchTool::ENTITIES 396 + .iter() 397 + .copied() 398 + .chain(core::iter::once(SketchTool::SmartDimension)) 399 + .map(|t| (tool_widget_id(ribbon, t), t)) 400 + .collect() 401 + } 402 + 403 + fn tool_widget_id(ribbon: WidgetId, tool: SketchTool) -> WidgetId { 404 + ribbon.child(WidgetKey::new(tool_key(tool))) 405 + } 406 + 407 + fn tool_key(tool: SketchTool) -> &'static str { 408 + match tool { 409 + SketchTool::Point => "tool.point", 410 + SketchTool::Line => "tool.line", 411 + SketchTool::CenterpointArc => "tool.centerpoint_arc", 412 + SketchTool::TangentArc => "tool.tangent_arc", 413 + SketchTool::ThreePointArc => "tool.three_point_arc", 414 + SketchTool::Circle => "tool.circle", 415 + SketchTool::PerimeterCircle => "tool.perimeter_circle", 416 + SketchTool::CornerRectangle => "tool.corner_rectangle", 417 + SketchTool::CenterRectangle => "tool.center_rectangle", 418 + SketchTool::ThreePointCornerRectangle => "tool.three_point_corner_rectangle", 419 + SketchTool::ThreePointCenterRectangle => "tool.three_point_center_rectangle", 420 + SketchTool::Parallelogram => "tool.parallelogram", 421 + SketchTool::SmartDimension => "tool.smart_dimension", 422 + } 423 + } 424 + 425 + fn tool_label(tool: SketchTool) -> StringKey { 426 + match tool { 427 + SketchTool::Point => strings::TOOL_POINT, 428 + SketchTool::Line => strings::TOOL_LINE, 429 + SketchTool::CenterpointArc => strings::TOOL_CENTERPOINT_ARC, 430 + SketchTool::TangentArc => strings::TOOL_TANGENT_ARC, 431 + SketchTool::ThreePointArc => strings::TOOL_THREE_POINT_ARC, 432 + SketchTool::Circle => strings::TOOL_CIRCLE, 433 + SketchTool::PerimeterCircle => strings::TOOL_PERIMETER_CIRCLE, 434 + SketchTool::CornerRectangle => strings::TOOL_CORNER_RECTANGLE, 435 + SketchTool::CenterRectangle => strings::TOOL_CENTER_RECTANGLE, 436 + SketchTool::ThreePointCornerRectangle => strings::TOOL_THREE_POINT_CORNER_RECTANGLE, 437 + SketchTool::ThreePointCenterRectangle => strings::TOOL_THREE_POINT_CENTER_RECTANGLE, 438 + SketchTool::Parallelogram => strings::TOOL_PARALLELOGRAM, 439 + SketchTool::SmartDimension => strings::TOOL_SMART_DIMENSION, 440 + } 441 + } 442 + 443 + fn paint_walk( 444 + layout: &SolvedLayout, 445 + node: &SolvedNode, 446 + theme: &Theme, 447 + viewport: PanelId, 448 + ) -> Vec<WidgetPaint> { 449 + let walk_children = || { 450 + node.children 451 + .iter() 452 + .flat_map(|c| paint_walk(layout, layout.node(*c), theme, viewport)) 453 + }; 454 + match &node.kind { 455 + NodeKind::DockHost { .. } 456 + | NodeKind::Pass 457 + | NodeKind::Leaf(_) 458 + | NodeKind::ScrollRegion { .. } => walk_children().collect(), 459 + NodeKind::DockSplit { axis, .. } | NodeKind::Splitter { axis, .. } => walk_children() 460 + .chain(divider_paint(layout, node, *axis, theme)) 461 + .collect(), 462 + NodeKind::DockTabStrip { .. } => { 463 + core::iter::once(surface_for(node.rect, theme.elevation.level2, theme)) 464 + .chain(walk_children()) 465 + .collect() 466 + } 467 + NodeKind::DockPanel { id } if *id == viewport => Vec::new(), 468 + NodeKind::DockPanel { .. } => { 469 + core::iter::once(surface_for(node.rect, theme.elevation.level1, theme)) 470 + .chain(walk_children()) 471 + .collect() 472 + } 473 + } 474 + } 475 + 476 + fn divider_paint( 477 + layout: &SolvedLayout, 478 + node: &SolvedNode, 479 + axis: Axis, 480 + theme: &Theme, 481 + ) -> Option<WidgetPaint> { 482 + let [first_idx, _] = match node.children.as_slice() { 483 + [a, b] => [*a, *b], 484 + _ => return None, 485 + }; 486 + let first = layout.node(first_idx); 487 + let rect = divider_between(axis, first.rect, node.rect); 488 + let color = theme.colors.neutral.step(Step12::BORDER); 489 + Some(WidgetPaint::Surface { 490 + rect, 491 + fill: color, 492 + border: None, 493 + radius: Radius::px(0.0), 494 + elevation: None, 495 + }) 496 + } 497 + 498 + fn divider_between(axis: Axis, first: LayoutRect, parent: LayoutRect) -> LayoutRect { 499 + let thickness = LayoutPx::new(StrokeWidth::HAIRLINE.value_px()); 500 + match axis { 501 + Axis::Horizontal => LayoutRect::new( 502 + LayoutPos::new(first.max_x(), parent.min_y()), 503 + LayoutSize::new(thickness, parent.size.height), 504 + ), 505 + Axis::Vertical => LayoutRect::new( 506 + LayoutPos::new(parent.min_x(), first.max_y()), 507 + LayoutSize::new(parent.size.width, thickness), 508 + ), 509 + } 510 + } 511 + 512 + fn surface_for(rect: LayoutRect, elevation: ElevationLevel, theme: &Theme) -> WidgetPaint { 513 + WidgetPaint::Surface { 514 + rect, 515 + fill: theme.colors.surface(elevation.surface), 516 + border: elevation.border, 517 + radius: Radius::px(0.0), 518 + elevation: Some(elevation), 519 + } 520 + } 521 + 522 + fn panel_rect(solved: &SolvedLayout, id: PanelId) -> Option<LayoutRect> { 523 + solved 524 + .nodes 525 + .iter() 526 + .find(|n| matches!(n.kind, NodeKind::DockPanel { id: pid } if pid == id)) 527 + .map(|n| n.rect) 528 + } 529 + 530 + fn inset_rect(rect: LayoutRect, by: f32) -> LayoutRect { 531 + let w = (rect.size.width.value() - 2.0 * by).max(0.0); 532 + let h = (rect.size.height.value() - 2.0 * by).max(0.0); 533 + LayoutRect::new( 534 + LayoutPos::new( 535 + LayoutPx::saturating(rect.origin.x.value() + by), 536 + LayoutPx::saturating(rect.origin.y.value() + by), 537 + ), 538 + LayoutSize::new( 539 + LayoutPx::saturating_nonneg(w), 540 + LayoutPx::saturating_nonneg(h), 541 + ), 542 + ) 543 + } 544 + 545 + fn zero_rect() -> LayoutRect { 546 + LayoutRect::new(LayoutPos::ORIGIN, LayoutSize::ZERO) 547 + } 548 + 549 + const fn panel(value: u32) -> PanelId { 550 + let Some(nz) = NonZeroU32::new(value) else { 551 + panic!("PanelId value must be non-zero"); 552 + }; 553 + PanelId::new(nz) 554 + } 555 + 556 + #[cfg(test)] 557 + mod tests { 558 + use super::*; 559 + use bone_document::Document; 560 + use bone_types::DocumentId; 561 + use bone_ui::a11y::AccessTreeBuilder; 562 + use bone_ui::focus::FocusManager; 563 + use bone_ui::hit_test::{HitFrame, HitState}; 564 + use bone_ui::hotkey::HotkeyTable; 565 + use bone_ui::input::{FrameInstant, InputSnapshot}; 566 + use bone_ui::strings::StringTable; 567 + use bone_ui::theme::Theme; 568 + use std::sync::Arc; 569 + 570 + fn layout_size(w: f32, h: f32) -> LayoutSize { 571 + LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h)) 572 + } 573 + 574 + fn sample_document() -> Document { 575 + Document::new(DocumentId::default(), "Sample".to_owned()) 576 + } 577 + 578 + fn render_with(theme: Theme, size: LayoutSize, document: &Document, mode: &Mode) -> ShellFrame { 579 + let Ok(mut shell) = Shell::new() else { 580 + panic!("shell init"); 581 + }; 582 + let theme = Arc::new(theme); 583 + let table = HotkeyTable::new(); 584 + let mut focus = FocusManager::new(); 585 + let mut hits = HitFrame::new(); 586 + let prev = HitState::new(); 587 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 588 + let mut a11y = AccessTreeBuilder::new(); 589 + let mut ctx = FrameCtx::new( 590 + theme, 591 + &mut input, 592 + &mut focus, 593 + &table, 594 + StringTable::empty(), 595 + &mut hits, 596 + &prev, 597 + &mut a11y, 598 + ); 599 + shell.render(&mut ctx, document, mode, size) 600 + } 601 + 602 + #[test] 603 + fn shell_renders_with_non_empty_paint_list() { 604 + let frame = render_with( 605 + Theme::light(), 606 + layout_size(1280.0, 800.0), 607 + &sample_document(), 608 + &Mode::Idle, 609 + ); 610 + assert!(!frame.paints.is_empty()); 611 + } 612 + 613 + #[test] 614 + fn shell_carves_out_viewport_region() { 615 + let frame = render_with( 616 + Theme::light(), 617 + layout_size(1280.0, 800.0), 618 + &sample_document(), 619 + &Mode::Idle, 620 + ); 621 + let v = frame.viewport_rect; 622 + assert!(v.size.width.value() > 0.0); 623 + assert!(v.size.height.value() > 0.0); 624 + assert!(v.min_x().value() > 0.0, "feature tree carved on left"); 625 + assert!(v.min_y().value() > 0.0, "ribbon carved on top"); 626 + assert!(v.max_x().value() < 1280.0, "property pane carved on right"); 627 + assert!(v.max_y().value() < 800.0, "status bar carved on bottom"); 628 + } 629 + 630 + #[test] 631 + fn shell_does_not_paint_viewport_panel_body() { 632 + let frame = render_with( 633 + Theme::light(), 634 + layout_size(1280.0, 800.0), 635 + &sample_document(), 636 + &Mode::Idle, 637 + ); 638 + let viewport_rect = frame.viewport_rect; 639 + let center = LayoutPos::new( 640 + LayoutPx::new(viewport_rect.min_x().value() + viewport_rect.size.width.value() * 0.5), 641 + LayoutPx::new(viewport_rect.min_y().value() + viewport_rect.size.height.value() * 0.5), 642 + ); 643 + let any_paint_covers_center = frame.paints.iter().any(|p| match p { 644 + WidgetPaint::Surface { rect, .. } => rect.contains(center), 645 + _ => false, 646 + }); 647 + assert!(!any_paint_covers_center); 648 + } 649 + 650 + #[test] 651 + fn shell_seeds_part_node_expanded() { 652 + let Ok(shell) = Shell::new() else { 653 + panic!("shell init"); 654 + }; 655 + assert!( 656 + shell 657 + .state 658 + .feature_tree 659 + .expanded 660 + .contains(&shell.ids.feature_part) 661 + ); 662 + } 663 + 664 + #[test] 665 + fn tool_index_round_trips_every_tool() { 666 + let ids = ShellIds::standard(); 667 + let index = build_tool_index(ids.ribbon); 668 + SketchTool::ENTITIES 669 + .iter() 670 + .copied() 671 + .chain(core::iter::once(SketchTool::SmartDimension)) 672 + .for_each(|t| { 673 + let id = tool_widget_id(ids.ribbon, t); 674 + assert_eq!(index.get(&id).copied(), Some(t)); 675 + }); 676 + } 677 + }