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