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