Another project
0

Configure Feed

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

feat(app): multi-select relation ribbon

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

author
Lewis
date (May 10, 2026, 11:36 PM +0300) commit 7a631b44 parent 9135c098 change-id ppttlqzw
+572 -105
+76 -30
crates/bone-app/src/main.rs
··· 3 3 use std::path::{Path, PathBuf}; 4 4 use std::sync::Arc; 5 5 6 - use bone_document::{Document, Sketch, SketchEdit, SketchEntity, UndoStack}; 6 + use bone_document::{Document, Sketch, SketchEdit, SketchEntity, SketchRelation, UndoStack}; 7 7 use bone_render::{ 8 8 Camera2, ChromeInstance, ChromePipeline, ChromeTextPipeline, PickQuery, PickedItem, 9 - PixelsPerMm, RenderTargets, SketchPreview, SketchRenderer, SketchScene, Style, SurfaceContext, 10 - ViewportExtent, ViewportPx, 9 + PixelsPerMm, RenderTargets, SdfGlyphInstance, SketchPreview, SketchRenderer, SketchScene, 10 + Style, SurfaceContext, ViewportExtent, ViewportPx, 11 11 }; 12 - use bone_types::{BudgetCeiling, DocumentId, Length, Point2, SketchEntityId, SketchId, Vec2}; 12 + use bone_types::{BudgetCeiling, DocumentId, Length, Point2, SketchId, Vec2}; 13 13 use bone_ui::a11y::AccessTreeBuilder; 14 14 use bone_ui::focus::FocusManager; 15 15 use bone_ui::frame::FrameCtx; ··· 38 38 }; 39 39 40 40 mod chrome; 41 + mod relation_tools; 42 + mod selection; 41 43 mod shell; 42 44 mod sketch_mode; 43 45 mod snap; 44 46 mod strings; 45 47 mod tools; 46 48 49 + use selection::Selection; 47 50 use sketch_mode::{ClickAnchor, Mode, Pending, Plane, SketchSession, SketchTool}; 48 51 use snap::{Anchor, SnapHit}; 49 52 ··· 112 115 strings: StringTable, 113 116 viewport_rect: LayoutRect, 114 117 undo: UndoStack, 115 - selection: Option<SketchEntityId>, 118 + selection: Selection, 116 119 pending_exit: bool, 117 120 } 118 121 ··· 404 407 } 405 408 } 406 409 407 - fn handle_viewport_click(state: &mut RenderState, cursor: PhysicalPosition<f64>) { 410 + fn handle_viewport_click(state: &mut RenderState, cursor: PhysicalPosition<f64>, additive: bool) { 408 411 let picked = pick_at(state, cursor); 409 - state.selection = match picked { 412 + let picked_id = match picked { 410 413 Some( 411 414 PickedItem::Point(id) 412 415 | PickedItem::Line(id) ··· 415 418 ) => Some(id), 416 419 Some(_) | None => None, 417 420 }; 421 + state.selection = std::mem::take(&mut state.selection).picked(picked_id, additive); 418 422 if let Some(PickedItem::Point(entity)) = picked 423 + && !additive 419 424 && state.mode.is_sketch() 420 425 { 421 426 state.undo.record(state.document.clone()); ··· 822 827 strings, 823 828 viewport_rect, 824 829 undo: UndoStack::with_capacity(undo_capacity), 825 - selection: None, 830 + selection: Selection::default(), 826 831 pending_exit: false, 827 832 }); 828 833 } ··· 890 895 try_place(state, world); 891 896 } 892 897 } else { 893 - handle_viewport_click(state, cursor); 898 + let additive = self.input.modifiers.control_key() 899 + || self.input.modifiers.super_key(); 900 + handle_viewport_click(state, cursor, additive); 894 901 } 895 902 } 896 903 } else { ··· 998 1005 &mut ctx, 999 1006 &state.document, 1000 1007 &state.mode, 1001 - state.selection, 1008 + state.selection.ids(), 1002 1009 layout_size, 1003 1010 ); 1004 1011 let actions = ctx.dispatch_hotkeys(&scopes); ··· 1023 1030 state.mode = next_mode(state.mode, &frame, &hotkey_actions, &state.plane_sketches); 1024 1031 apply_undo_actions(state, &hotkey_actions); 1025 1032 apply_menu_action(state, frame.menu_action); 1033 + apply_relation_action(state, frame.activated_relation); 1026 1034 let cursor_world = input_state 1027 1035 .cursor_px 1028 1036 .filter(|c| state.viewport_rect.contains(physical_to_layout_pos(*c))) 1029 1037 .and_then(|c| cursor_to_world(state.camera, c)); 1030 1038 let preview = build_preview(&state.mode, &state.document, cursor_world, &state.camera); 1031 - let chrome_instances: Vec<ChromeInstance> = 1032 - chrome::paint_to_instances(&state.theme, &frame.paints); 1033 - let text_spans = chrome::paint_to_text_spans(&frame.paints, &state.strings); 1034 - let glyph_instances = chrome::build_glyph_instances( 1035 - &text_spans, 1036 - &mut state.sdf_atlas, 1037 - &mut state.chrome_shaper, 1038 - &state.sans_font, 1039 - &state.mono_font, 1040 - ); 1039 + let main_layer = build_chrome_layer(state, &frame.paints); 1040 + let overlay_layer = build_chrome_layer(state, &frame.overlay_paints); 1041 1041 let atlas_pixels = state.sdf_atlas.pixels(); 1042 1042 let atlas_version = state.sdf_atlas.version(); 1043 1043 let viewport_px = [ ··· 1062 1062 camera, 1063 1063 style, 1064 1064 ); 1065 - chrome_pipeline.draw(encoder, color, viewport_px, &chrome_instances); 1066 - text_pipeline.draw( 1067 - encoder, 1068 - color, 1069 - viewport_px, 1070 - atlas_pixels, 1071 - atlas_version, 1072 - &glyph_instances, 1073 - ); 1065 + [&main_layer, &overlay_layer].into_iter().for_each(|layer| { 1066 + chrome_pipeline.draw(encoder, color, viewport_px, &layer.chrome); 1067 + text_pipeline.draw( 1068 + encoder, 1069 + color, 1070 + viewport_px, 1071 + atlas_pixels, 1072 + atlas_version, 1073 + &layer.glyphs, 1074 + ); 1075 + }); 1074 1076 }, 1075 1077 || window.pre_present_notify(), 1076 1078 ); 1077 1079 } 1078 1080 1081 + struct ChromeLayer { 1082 + chrome: Vec<ChromeInstance>, 1083 + glyphs: Vec<SdfGlyphInstance>, 1084 + } 1085 + 1086 + fn build_chrome_layer( 1087 + state: &mut RenderState, 1088 + paints: &[bone_ui::widgets::WidgetPaint], 1089 + ) -> ChromeLayer { 1090 + let chrome = chrome::paint_to_instances(&state.theme, paints); 1091 + let spans = chrome::paint_to_text_spans(paints, &state.strings); 1092 + let glyphs = chrome::build_glyph_instances( 1093 + &spans, 1094 + &mut state.sdf_atlas, 1095 + &mut state.chrome_shaper, 1096 + &state.sans_font, 1097 + &state.mono_font, 1098 + ); 1099 + ChromeLayer { chrome, glyphs } 1100 + } 1101 + 1079 1102 fn apply_undo_actions(state: &mut RenderState, actions: &[ActionId]) { 1080 1103 if actions.contains(&sketch_mode::UNDO_ACTION) && state.undo.undo(&mut state.document) { 1081 1104 refresh_active_scene(state); ··· 1085 1108 } 1086 1109 } 1087 1110 1111 + fn apply_relation_action(state: &mut RenderState, relation: Option<SketchRelation>) { 1112 + let Some(relation) = relation else { return }; 1113 + let Mode::Sketch { sketch_id, .. } = state.mode else { 1114 + return; 1115 + }; 1116 + let Some(sketch) = state.document.sketch(sketch_id).cloned() else { 1117 + return; 1118 + }; 1119 + let next = match sketch.apply(SketchEdit::AddRelation(relation)) { 1120 + Ok((next, _)) => next, 1121 + Err(e) => { 1122 + tracing::warn!(error = %e, ?relation, "add relation failed"); 1123 + return; 1124 + } 1125 + }; 1126 + state.undo.record(state.document.clone()); 1127 + state.document.replace_sketch(sketch_id, next); 1128 + state.selection = Selection::default(); 1129 + refresh_active_scene(state); 1130 + } 1131 + 1088 1132 fn apply_menu_action(state: &mut RenderState, action: Option<shell::MenuAction>) { 1089 1133 match action { 1090 1134 Some(shell::MenuAction::Quit) => { ··· 1282 1326 fn empty_frame() -> shell::ShellFrame { 1283 1327 shell::ShellFrame { 1284 1328 paints: Vec::new(), 1329 + overlay_paints: Vec::new(), 1285 1330 viewport_rect: empty_rect(), 1286 1331 activated_tool: None, 1332 + activated_relation: None, 1287 1333 plane_picked: None, 1288 1334 exit_sketch: false, 1289 1335 menu_action: None, ··· 1473 1519 pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm( 1474 1520 0.0, 0.0, 1475 1521 )))), 1476 - drag: Some(SketchEntityId::default()), 1522 + drag: Some(bone_types::SketchEntityId::default()), 1477 1523 }, 1478 1524 }; 1479 1525 let preview = build_preview(
+92
crates/bone-app/src/selection.rs
··· 1 + use bone_types::SketchEntityId; 2 + 3 + #[derive(Default, Clone, Debug, PartialEq, Eq)] 4 + pub struct Selection(Vec<SketchEntityId>); 5 + 6 + impl Selection { 7 + #[must_use] 8 + pub fn ids(&self) -> &[SketchEntityId] { 9 + &self.0 10 + } 11 + 12 + #[must_use] 13 + pub fn picked(self, id: Option<SketchEntityId>, additive: bool) -> Self { 14 + match (id, additive) { 15 + (Some(id), true) => self.toggled(id), 16 + (Some(id), false) => Self(vec![id]), 17 + (None, false) => Self::default(), 18 + (None, true) => self, 19 + } 20 + } 21 + 22 + fn toggled(mut self, id: SketchEntityId) -> Self { 23 + match self.0.iter().position(|x| *x == id) { 24 + Some(idx) => { 25 + self.0.remove(idx); 26 + } 27 + None => self.0.push(id), 28 + } 29 + self 30 + } 31 + } 32 + 33 + #[cfg(test)] 34 + mod tests { 35 + use super::*; 36 + use crate::sketch_mode::Plane; 37 + use crate::tools::add_point; 38 + use bone_document::Sketch; 39 + use bone_types::Point2; 40 + 41 + fn three_ids() -> (SketchEntityId, SketchEntityId, SketchEntityId) { 42 + let s = Sketch::new(Plane::Xy.basis()); 43 + let (s, a) = add_point(s, Point2::from_mm(0.0, 0.0)); 44 + let (s, b) = add_point(s, Point2::from_mm(1.0, 0.0)); 45 + let (_, c) = add_point(s, Point2::from_mm(2.0, 0.0)); 46 + (a, b, c) 47 + } 48 + 49 + #[test] 50 + fn default_selection_is_empty() { 51 + assert!(Selection::default().ids().is_empty()); 52 + } 53 + 54 + #[test] 55 + fn pick_replace_overwrites_existing() { 56 + let (a, b, _) = three_ids(); 57 + let s = Selection::default() 58 + .picked(Some(a), false) 59 + .picked(Some(b), false); 60 + assert_eq!(s.ids(), &[b]); 61 + } 62 + 63 + #[test] 64 + fn pick_additive_extends_then_toggles_off() { 65 + let (a, b, _) = three_ids(); 66 + let s = Selection::default() 67 + .picked(Some(a), true) 68 + .picked(Some(b), true); 69 + assert_eq!(s.ids(), &[a, b]); 70 + let s = s.picked(Some(a), true); 71 + assert_eq!(s.ids(), &[b]); 72 + } 73 + 74 + #[test] 75 + fn pick_replace_with_none_clears() { 76 + let (a, b, _) = three_ids(); 77 + let s = Selection::default() 78 + .picked(Some(a), true) 79 + .picked(Some(b), true) 80 + .picked(None, false); 81 + assert!(s.ids().is_empty()); 82 + } 83 + 84 + #[test] 85 + fn pick_additive_with_none_preserves() { 86 + let (a, _, _) = three_ids(); 87 + let s = Selection::default() 88 + .picked(Some(a), true) 89 + .picked(None, true); 90 + assert_eq!(s.ids(), &[a]); 91 + } 92 + }
+336 -69
crates/bone-app/src/shell.rs
··· 2 2 use std::collections::BTreeMap; 3 3 use std::sync::Arc; 4 4 5 - use bone_document::{Document, Sketch, SketchEntity}; 5 + use bone_document::{Document, Sketch, SketchEntity, SketchRelation}; 6 6 use bone_types::{Length, SketchEntityId}; 7 7 use bone_ui::frame::FrameCtx; 8 8 use bone_ui::layout::{ ··· 10 10 LayoutSize, NodeKind, PanelId, RetainedLayout, SolvedLayout, SolvedNode, measure, 11 11 }; 12 12 use bone_ui::strings::{StringKey, StringTable}; 13 - use bone_ui::theme::{ElevationLevel, Radius, Step12, StrokeWidth, Theme}; 13 + use bone_ui::theme::{ElevationLevel, Radius, Spacing, Step12, StrokeWidth, Theme}; 14 14 use bone_ui::widgets::{ 15 15 Clipboard, LabelText, MemoryClipboard, MenuBar, MenuBarEntry, MenuBarState, MenuItem, 16 16 PropertyCell, PropertyEditor, PropertyGrid, PropertyRow, Ribbon, RibbonGroup, RibbonIconSize, ··· 21 21 use bone_ui::{WidgetId, WidgetKey}; 22 22 use uom::si::length::millimeter; 23 23 24 + use crate::relation_tools::{Eligibility, RelationKind, eligibility}; 24 25 use crate::sketch_mode::{Mode, Plane, SketchTool}; 25 26 use crate::strings; 26 27 ··· 161 162 retained_layout: RetainedLayout, 162 163 dock_state: Arc<DockState>, 163 164 tool_index: BTreeMap<WidgetId, SketchTool>, 165 + relation_index: BTreeMap<WidgetId, RelationKind>, 164 166 pub state: ShellState, 165 167 } 166 168 ··· 175 177 #[derive(Clone, Debug, PartialEq)] 176 178 pub struct ShellFrame { 177 179 pub paints: Vec<WidgetPaint>, 180 + pub overlay_paints: Vec<WidgetPaint>, 178 181 pub viewport_rect: LayoutRect, 179 182 pub activated_tool: Option<SketchTool>, 183 + pub activated_relation: Option<SketchRelation>, 180 184 pub plane_picked: Option<Plane>, 181 185 pub exit_sketch: bool, 182 186 pub menu_action: Option<MenuAction>, ··· 186 190 fn empty() -> Self { 187 191 Self { 188 192 paints: Vec::new(), 193 + overlay_paints: Vec::new(), 189 194 viewport_rect: zero_rect(), 190 195 activated_tool: None, 196 + activated_relation: None, 191 197 plane_picked: None, 192 198 exit_sketch: false, 193 199 menu_action: None, ··· 196 202 } 197 203 198 204 impl Shell { 205 + fn build_layout(&self, gap: Spacing) -> Layout { 206 + Layout::dock_host( 207 + self.ids.dock_host, 208 + Arc::clone(&self.dock_state), 209 + vec![ 210 + DockPanel { 211 + id: self.panels.menu_bar, 212 + child: Layout::leaf(self.ids.menu_bar), 213 + }, 214 + DockPanel { 215 + id: self.panels.ribbon, 216 + child: Layout::leaf(self.ids.ribbon), 217 + }, 218 + DockPanel { 219 + id: self.panels.feature_tree, 220 + child: Layout::leaf(self.ids.feature_tree), 221 + }, 222 + DockPanel { 223 + id: self.panels.property_pane, 224 + child: Layout::leaf(self.ids.property_pane), 225 + }, 226 + DockPanel { 227 + id: self.panels.viewport, 228 + child: Layout::leaf(self.ids.viewport), 229 + }, 230 + DockPanel { 231 + id: self.panels.status, 232 + child: Layout::leaf(self.ids.status_bar), 233 + }, 234 + ], 235 + gap, 236 + ) 237 + } 238 + 199 239 pub fn new() -> Result<Self, ShellError> { 200 240 let panels = ShellPanels::standard(); 201 241 let ids = ShellIds::standard(); ··· 208 248 panels.status, 209 249 )?); 210 250 let tool_index = build_tool_index(ids.ribbon); 251 + let relation_index = build_relation_index(ids.ribbon); 211 252 let mut state = ShellState::default(); 212 253 state.feature_tree.expanded.insert(ids.feature_part); 213 254 Ok(Self { ··· 216 257 retained_layout: RetainedLayout::default(), 217 258 dock_state, 218 259 tool_index, 260 + relation_index, 219 261 state, 220 262 }) 221 263 } ··· 225 267 ctx: &mut FrameCtx<'_>, 226 268 document: &Document, 227 269 mode: &Mode, 228 - selection: Option<SketchEntityId>, 270 + selection: &[SketchEntityId], 229 271 viewport_size: LayoutSize, 230 272 ) -> ShellFrame { 231 273 let theme = ctx.theme(); 232 274 let direction = ctx.direction(); 233 - let layout = Layout::dock_host( 234 - self.ids.dock_host, 235 - Arc::clone(&self.dock_state), 236 - vec![ 237 - DockPanel { 238 - id: self.panels.menu_bar, 239 - child: Layout::leaf(self.ids.menu_bar), 240 - }, 241 - DockPanel { 242 - id: self.panels.ribbon, 243 - child: Layout::leaf(self.ids.ribbon), 244 - }, 245 - DockPanel { 246 - id: self.panels.feature_tree, 247 - child: Layout::leaf(self.ids.feature_tree), 248 - }, 249 - DockPanel { 250 - id: self.panels.property_pane, 251 - child: Layout::leaf(self.ids.property_pane), 252 - }, 253 - DockPanel { 254 - id: self.panels.viewport, 255 - child: Layout::leaf(self.ids.viewport), 256 - }, 257 - DockPanel { 258 - id: self.panels.status, 259 - child: Layout::leaf(self.ids.status_bar), 260 - }, 261 - ], 262 - theme.spacing.md, 263 - ); 275 + let layout = self.build_layout(theme.spacing.md); 264 276 let Ok(solved) = measure(&layout, viewport_size, &self.retained_layout, direction) else { 265 277 return ShellFrame::empty(); 266 278 }; ··· 281 293 &mut self.state.menu_bar, 282 294 &mut paints, 283 295 ); 296 + let active_sketch = active_sketch(document, mode); 284 297 let activated_widget = render_ribbon( 285 298 ctx, 286 - ribbon_rect, 287 - self.ids.ribbon, 288 - self.ids.ribbon_exit, 299 + RibbonInputs { 300 + rect: ribbon_rect, 301 + ribbon: self.ids.ribbon, 302 + ribbon_exit: self.ids.ribbon_exit, 303 + mode, 304 + sketch: active_sketch, 305 + selection, 306 + }, 289 307 &mut self.state.ribbon, 290 - mode, 291 308 &mut paints, 292 309 ); 293 310 let double_activated = render_feature_tree( ··· 314 331 render_status_bar(ctx, status_rect, self.ids.status_bar, mode, &mut paints); 315 332 let exit_sketch = activated_widget == Some(self.ids.ribbon_exit); 316 333 let activated_tool = activated_widget.and_then(|id| self.tool_index.get(&id).copied()); 334 + let activated_relation = resolve_activated_relation( 335 + activated_widget, 336 + &self.relation_index, 337 + active_sketch, 338 + selection, 339 + ); 317 340 let plane_picked = double_activated.and_then(|id| self.ids.plane_for(id)); 341 + let (paints, overlay_paints) = partition_overlay(paints, ctx.theme()); 318 342 ShellFrame { 319 343 paints, 344 + overlay_paints, 320 345 viewport_rect, 321 346 activated_tool, 347 + activated_relation, 322 348 plane_picked, 323 349 exit_sketch, 324 350 menu_action, ··· 326 352 } 327 353 } 328 354 355 + fn partition_overlay( 356 + paints: Vec<WidgetPaint>, 357 + theme: &Theme, 358 + ) -> (Vec<WidgetPaint>, Vec<WidgetPaint>) { 359 + paints.into_iter().fold( 360 + (Vec::new(), Vec::new()), 361 + |(mut main, mut overlay), paint| { 362 + match paint { 363 + WidgetPaint::Tooltip { 364 + rect, 365 + text, 366 + elevation, 367 + .. 368 + } => { 369 + overlay.push(WidgetPaint::Surface { 370 + rect, 371 + fill: theme.colors.surface(elevation.surface), 372 + border: elevation.border, 373 + radius: theme.radius.sm, 374 + elevation: Some(elevation), 375 + }); 376 + overlay.push(WidgetPaint::Label { 377 + rect, 378 + text, 379 + color: theme.colors.text_primary(), 380 + role: theme.typography.caption, 381 + }); 382 + } 383 + other => main.push(other), 384 + } 385 + (main, overlay) 386 + }, 387 + ) 388 + } 389 + 390 + fn active_sketch<'a>(document: &'a Document, mode: &Mode) -> Option<&'a Sketch> { 391 + match mode { 392 + Mode::Sketch { sketch_id, .. } => document.sketch(*sketch_id), 393 + Mode::Idle => None, 394 + } 395 + } 396 + 397 + fn resolve_activated_relation( 398 + activated_widget: Option<WidgetId>, 399 + relation_index: &BTreeMap<WidgetId, RelationKind>, 400 + sketch: Option<&Sketch>, 401 + selection: &[SketchEntityId], 402 + ) -> Option<SketchRelation> { 403 + let id = activated_widget?; 404 + let kind = relation_index.get(&id).copied()?; 405 + let sketch = sketch?; 406 + match eligibility(kind, sketch, selection) { 407 + Eligibility::Eligible(rel) => Some(rel), 408 + Eligibility::Disabled(_) => None, 409 + } 410 + } 411 + 329 412 fn render_menu_bar( 330 413 ctx: &mut FrameCtx<'_>, 331 414 rect: LayoutRect, ··· 434 517 response.activated.and_then(|id| ids.menu_action_for(id)) 435 518 } 436 519 437 - fn render_ribbon( 438 - ctx: &mut FrameCtx<'_>, 520 + #[derive(Copy, Clone)] 521 + struct RibbonInputs<'a> { 439 522 rect: LayoutRect, 440 523 ribbon: WidgetId, 441 524 ribbon_exit: WidgetId, 525 + mode: &'a Mode, 526 + sketch: Option<&'a Sketch>, 527 + selection: &'a [SketchEntityId], 528 + } 529 + 530 + fn render_ribbon( 531 + ctx: &mut FrameCtx<'_>, 532 + inputs: RibbonInputs<'_>, 442 533 state: &mut RibbonState, 443 - mode: &Mode, 444 534 paints: &mut Vec<WidgetPaint>, 445 535 ) -> Option<WidgetId> { 536 + let RibbonInputs { 537 + rect, 538 + ribbon, 539 + ribbon_exit, 540 + mode, 541 + sketch, 542 + selection, 543 + } = inputs; 446 544 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 447 545 return None; 448 546 } ··· 480 578 .disabled(tools_disabled), 481 579 large_min, 482 580 )]; 483 - let relation_items: Vec<ToolbarItem> = relation_tool_buttons(ribbon, tools_disabled) 484 - .into_iter() 485 - .map(|item| size_item(item, small_min)) 486 - .collect(); 581 + let relation_items: Vec<ToolbarItem> = 582 + relation_tool_buttons(ribbon, sketch, selection, tools_disabled) 583 + .into_iter() 584 + .map(|item| size_item(item, small_min)) 585 + .collect(); 487 586 let exit_items = vec![size_item( 488 587 ToolbarItem::new(ribbon_exit, strings::TOOL_EXIT_SKETCH), 489 588 large_min, ··· 579 678 struct PropertyState<'a> { 580 679 document: &'a Document, 581 680 mode: &'a Mode, 582 - selection: Option<SketchEntityId>, 681 + selection: &'a [SketchEntityId], 583 682 } 584 683 585 684 fn render_property_pane( ··· 597 696 Mode::Sketch { sketch_id, .. } => state.document.sketch(*sketch_id), 598 697 Mode::Idle => None, 599 698 }; 600 - let resolved = state 601 - .selection 602 - .zip(active_sketch) 603 - .and_then(|(sel, sketch)| sketch.entities().get(sel).map(|e| (*e, sketch))); 699 + let resolved = match state.selection { 700 + [id] => active_sketch.and_then(|s| s.entities().get(*id).map(|e| (*e, s))), 701 + _ => None, 702 + }; 604 703 let mut editors = match resolved { 605 704 Some((entity, sketch)) => entity_editors(ctx.strings, entity, sketch), 606 705 None => vec![row_editor(strings::PROPERTY_PANE_NO_SELECTION, "")], ··· 734 833 Some(_) => strings_table 735 834 .resolve(strings::PROPERTY_PANE_NO_SELECTION) 736 835 .to_owned(), 737 - None => "—".to_owned(), 836 + None => "?".to_owned(), 738 837 } 739 838 } 740 839 ··· 825 924 LayoutPx::new(total + 2.0 * RIBBON_GROUP_PADDING_PX) 826 925 } 827 926 828 - fn relation_tool_buttons(ribbon: WidgetId, disabled: bool) -> Vec<ToolbarItem> { 829 - [ 830 - ("coincident", strings::TOOL_COINCIDENT), 831 - ("horizontal", strings::TOOL_HORIZONTAL), 832 - ("vertical", strings::TOOL_VERTICAL), 833 - ("parallel", strings::TOOL_PARALLEL), 834 - ("perpendicular", strings::TOOL_PERPENDICULAR), 835 - ("tangent", strings::TOOL_TANGENT), 836 - ("equal", strings::TOOL_EQUAL), 837 - ("concentric", strings::TOOL_CONCENTRIC), 838 - ("fix", strings::TOOL_FIX), 839 - ] 840 - .into_iter() 841 - .map(|(key, label)| { 842 - ToolbarItem::new(ribbon.child(WidgetKey::new(key)), label).disabled(disabled) 843 - }) 844 - .collect() 927 + fn relation_tool_buttons( 928 + ribbon: WidgetId, 929 + sketch: Option<&Sketch>, 930 + selection: &[SketchEntityId], 931 + sketch_disabled: bool, 932 + ) -> Vec<ToolbarItem> { 933 + RelationKind::ALL 934 + .iter() 935 + .copied() 936 + .map(|kind| relation_tool_item(ribbon, kind, sketch, selection, sketch_disabled)) 937 + .collect() 938 + } 939 + 940 + fn relation_tool_item( 941 + ribbon: WidgetId, 942 + kind: RelationKind, 943 + sketch: Option<&Sketch>, 944 + selection: &[SketchEntityId], 945 + sketch_disabled: bool, 946 + ) -> ToolbarItem { 947 + let item = ToolbarItem::new(relation_widget_id(ribbon, kind), kind.label()); 948 + if sketch_disabled { 949 + return item.disabled(true); 950 + } 951 + let Some(sketch) = sketch else { 952 + return item.disabled(true); 953 + }; 954 + match eligibility(kind, sketch, selection) { 955 + Eligibility::Eligible(_) => item, 956 + Eligibility::Disabled(reason) => item.disabled(true).with_tooltip(reason), 957 + } 958 + } 959 + 960 + fn build_relation_index(ribbon: WidgetId) -> BTreeMap<WidgetId, RelationKind> { 961 + RelationKind::ALL 962 + .iter() 963 + .copied() 964 + .map(|k| (relation_widget_id(ribbon, k), k)) 965 + .collect() 966 + } 967 + 968 + fn relation_widget_id(ribbon: WidgetId, kind: RelationKind) -> WidgetId { 969 + ribbon.child(WidgetKey::new(kind.key())) 845 970 } 846 971 847 972 fn build_tool_index(ribbon: WidgetId) -> BTreeMap<WidgetId, SketchTool> { ··· 1050 1175 &prev, 1051 1176 &mut a11y, 1052 1177 ); 1053 - shell.render(&mut ctx, document, mode, None, size) 1178 + shell.render(&mut ctx, document, mode, &[], size) 1054 1179 } 1055 1180 1056 1181 #[test] ··· 1187 1312 WidgetPaint::Label { text: LabelText::Key(key), .. } 1188 1313 if *key == crate::strings::RIBBON_GROUP_EXIT 1189 1314 ) 1315 + } 1316 + 1317 + #[test] 1318 + fn relation_index_covers_every_kind() { 1319 + let ids = ShellIds::standard(); 1320 + let index = build_relation_index(ids.ribbon); 1321 + RelationKind::ALL.iter().copied().for_each(|kind| { 1322 + let id = relation_widget_id(ids.ribbon, kind); 1323 + assert_eq!(index.get(&id).copied(), Some(kind)); 1324 + }); 1325 + } 1326 + 1327 + #[test] 1328 + fn relation_tool_item_disabled_without_sketch() { 1329 + let ids = ShellIds::standard(); 1330 + let item = relation_tool_item(ids.ribbon, RelationKind::Horizontal, None, &[], false); 1331 + assert!(item.disabled); 1332 + assert!(item.tooltip.is_none(), "no sketch, no per-relation reason"); 1333 + } 1334 + 1335 + #[test] 1336 + fn relation_tool_item_disabled_when_sketch_disabled_flag_set() { 1337 + let ids = ShellIds::standard(); 1338 + let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 1339 + let item = relation_tool_item( 1340 + ids.ribbon, 1341 + RelationKind::Horizontal, 1342 + Some(&sketch), 1343 + &[], 1344 + true, 1345 + ); 1346 + assert!(item.disabled); 1347 + } 1348 + 1349 + #[test] 1350 + fn relation_tool_item_carries_reason_tooltip_when_eligibility_fails() { 1351 + let ids = ShellIds::standard(); 1352 + let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 1353 + let item = relation_tool_item( 1354 + ids.ribbon, 1355 + RelationKind::Horizontal, 1356 + Some(&sketch), 1357 + &[], 1358 + false, 1359 + ); 1360 + assert!(item.disabled); 1361 + assert_eq!(item.tooltip, Some(strings::REL_HINT_ONE_LINE)); 1362 + } 1363 + 1364 + #[test] 1365 + fn relation_tool_item_enabled_when_eligibility_passes() { 1366 + let ids = ShellIds::standard(); 1367 + let (sketch, line) = sample_sketch_with_line(); 1368 + let item = relation_tool_item( 1369 + ids.ribbon, 1370 + RelationKind::Horizontal, 1371 + Some(&sketch), 1372 + &[line], 1373 + false, 1374 + ); 1375 + assert!(!item.disabled); 1376 + assert!(item.tooltip.is_none()); 1377 + } 1378 + 1379 + #[test] 1380 + fn resolve_activated_relation_returns_relation_for_eligible_selection() { 1381 + let ids = ShellIds::standard(); 1382 + let index = build_relation_index(ids.ribbon); 1383 + let (sketch, line) = sample_sketch_with_line(); 1384 + let id = relation_widget_id(ids.ribbon, RelationKind::Horizontal); 1385 + let resolved = resolve_activated_relation(Some(id), &index, Some(&sketch), &[line]); 1386 + assert_eq!(resolved, Some(SketchRelation::Horizontal(line))); 1387 + } 1388 + 1389 + #[test] 1390 + fn resolve_activated_relation_drops_when_selection_invalid() { 1391 + let ids = ShellIds::standard(); 1392 + let index = build_relation_index(ids.ribbon); 1393 + let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 1394 + let id = relation_widget_id(ids.ribbon, RelationKind::Horizontal); 1395 + let resolved = resolve_activated_relation(Some(id), &index, Some(&sketch), &[]); 1396 + assert_eq!(resolved, None); 1397 + } 1398 + 1399 + #[test] 1400 + fn resolve_activated_relation_returns_relation_for_multi_selection() { 1401 + let ids = ShellIds::standard(); 1402 + let index = build_relation_index(ids.ribbon); 1403 + let (sketch, l1, l2) = sample_sketch_with_two_lines(); 1404 + let id = relation_widget_id(ids.ribbon, RelationKind::Parallel); 1405 + let resolved = resolve_activated_relation(Some(id), &index, Some(&sketch), &[l1, l2]); 1406 + assert_eq!(resolved, Some(SketchRelation::Parallel(l1, l2))); 1407 + } 1408 + 1409 + #[test] 1410 + fn partition_overlay_extracts_tooltips_into_overlay_layer() { 1411 + let theme = Theme::light(); 1412 + let rect = LayoutRect::new( 1413 + LayoutPos::new(LayoutPx::new(10.0), LayoutPx::new(20.0)), 1414 + LayoutSize::new(LayoutPx::new(80.0), LayoutPx::new(18.0)), 1415 + ); 1416 + let inputs = vec![ 1417 + WidgetPaint::Surface { 1418 + rect, 1419 + fill: theme.colors.surface(theme.elevation.level1.surface), 1420 + border: None, 1421 + radius: theme.radius.none, 1422 + elevation: None, 1423 + }, 1424 + WidgetPaint::Tooltip { 1425 + rect, 1426 + text: LabelText::Owned("hint".to_owned()), 1427 + anchor: WidgetId::ROOT, 1428 + elevation: theme.elevation.level2, 1429 + }, 1430 + ]; 1431 + let (main, overlay) = partition_overlay(inputs, &theme); 1432 + assert_eq!(main.len(), 1, "non-tooltip stays in main"); 1433 + assert!(matches!(main[0], WidgetPaint::Surface { .. })); 1434 + assert_eq!(overlay.len(), 2, "tooltip expands to surface + label"); 1435 + assert!(matches!(overlay[0], WidgetPaint::Surface { .. })); 1436 + assert!(matches!(overlay[1], WidgetPaint::Label { .. })); 1437 + } 1438 + 1439 + fn sample_sketch_with_two_lines() -> (bone_document::Sketch, SketchEntityId, SketchEntityId) { 1440 + use bone_types::Point2; 1441 + let s = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 1442 + let (s, p0) = crate::tools::add_point(s, Point2::from_mm(0.0, 0.0)); 1443 + let (s, p1) = crate::tools::add_point(s, Point2::from_mm(1.0, 0.0)); 1444 + let (s, p2) = crate::tools::add_point(s, Point2::from_mm(0.0, 1.0)); 1445 + let (s, p3) = crate::tools::add_point(s, Point2::from_mm(1.0, 1.0)); 1446 + let (s, l1) = crate::tools::add_line(s, p0, p1, false); 1447 + let (s, l2) = crate::tools::add_line(s, p2, p3, false); 1448 + (s, l1, l2) 1449 + } 1450 + 1451 + fn sample_sketch_with_line() -> (bone_document::Sketch, SketchEntityId) { 1452 + let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 1453 + let (sketch, a) = crate::tools::add_point(sketch, bone_types::Point2::from_mm(0.0, 0.0)); 1454 + let (sketch, b) = crate::tools::add_point(sketch, bone_types::Point2::from_mm(5.0, 0.0)); 1455 + let (sketch, line) = crate::tools::add_line(sketch, a, b, false); 1456 + (sketch, line) 1190 1457 } 1191 1458 }
+34
crates/bone-app/src/strings.rs
··· 34 34 pub const TOOL_EQUAL: StringKey = StringKey::new("tool.equal"); 35 35 pub const TOOL_CONCENTRIC: StringKey = StringKey::new("tool.concentric"); 36 36 pub const TOOL_FIX: StringKey = StringKey::new("tool.fix"); 37 + pub const TOOL_MIDPOINT: StringKey = StringKey::new("tool.midpoint"); 38 + 39 + pub const REL_HINT_ONE_LINE: StringKey = StringKey::new("rel.hint.one_line"); 40 + pub const REL_HINT_TWO_LINES: StringKey = StringKey::new("rel.hint.two_lines"); 41 + pub const REL_HINT_COINCIDENT: StringKey = StringKey::new("rel.hint.coincident"); 42 + pub const REL_HINT_TWO_CIRCULAR: StringKey = StringKey::new("rel.hint.two_circular"); 43 + pub const REL_HINT_TANGENT: StringKey = StringKey::new("rel.hint.tangent"); 44 + pub const REL_HINT_EQUAL: StringKey = StringKey::new("rel.hint.equal"); 45 + pub const REL_HINT_MIDPOINT: StringKey = StringKey::new("rel.hint.midpoint"); 46 + pub const REL_HINT_ENTITY: StringKey = StringKey::new("rel.hint.entity"); 37 47 38 48 pub const TOOL_SMART_DIMENSION: StringKey = StringKey::new("tool.smart_dimension"); 39 49 ··· 138 148 (TOOL_EQUAL, "Equal"), 139 149 (TOOL_CONCENTRIC, "Concentric"), 140 150 (TOOL_FIX, "Fix"), 151 + (TOOL_MIDPOINT, "Midpoint"), 152 + (REL_HINT_ONE_LINE, "Select a line"), 153 + (REL_HINT_TWO_LINES, "Select two lines"), 154 + (REL_HINT_COINCIDENT, "Select a point and another entity"), 155 + (REL_HINT_TWO_CIRCULAR, "Select two arcs or circles"), 156 + (REL_HINT_TANGENT, "Select a line and a curve, or two curves"), 157 + (REL_HINT_EQUAL, "Select two lines or two curves"), 158 + (REL_HINT_MIDPOINT, "Select a point and a line"), 159 + (REL_HINT_ENTITY, "Select an entity"), 141 160 (TOOL_SMART_DIMENSION, "Smart Dimension"), 142 161 (FEATURE_TREE_LABEL, "Feature Tree"), 143 162 (FEATURE_ORIGIN, "Origin"), ··· 224 243 (TOOL_EQUAL, "[!! Équal !!]"), 225 244 (TOOL_CONCENTRIC, "[!! Concêntric !!]"), 226 245 (TOOL_FIX, "[!! Fîx !!]"), 246 + (TOOL_MIDPOINT, "[!! Mîdpoint !!]"), 247 + (REL_HINT_ONE_LINE, "[!! Sêlect a lîne !!]"), 248 + (REL_HINT_TWO_LINES, "[!! Sêlect twô lînes !!]"), 249 + ( 250 + REL_HINT_COINCIDENT, 251 + "[!! Sêlect a pôint ând anôther êntity !!]", 252 + ), 253 + (REL_HINT_TWO_CIRCULAR, "[!! Sêlect twô ârcs ôr cîrcles !!]"), 254 + ( 255 + REL_HINT_TANGENT, 256 + "[!! Sêlect a lîne ând a cûrve, ôr twô cûrves !!]", 257 + ), 258 + (REL_HINT_EQUAL, "[!! Sêlect twô lînes ôr twô cûrves !!]"), 259 + (REL_HINT_MIDPOINT, "[!! Sêlect a pôint ând a lîne !!]"), 260 + (REL_HINT_ENTITY, "[!! Sêlect an êntity !!]"), 227 261 (TOOL_SMART_DIMENSION, "[!! Smârt Dimensiôn !!]"), 228 262 (FEATURE_TREE_LABEL, "[!! Featûre Tree !!]"), 229 263 (FEATURE_ORIGIN, "[!! Orîgin !!]"),
+1 -6
crates/bone-app/src/tools/preview.rs
··· 388 388 vec![(p1, p2), (p2, p3), (p3, p4), (p4, p1)] 389 389 } 390 390 391 - fn ghost_arc_ccw( 392 - center: Point2, 393 - radius_mm: f64, 394 - start: Point2, 395 - end: Point2, 396 - ) -> Option<PreviewArc> { 391 + fn ghost_arc_ccw(center: Point2, radius_mm: f64, start: Point2, end: Point2) -> Option<PreviewArc> { 397 392 if !(radius_mm.is_finite() && radius_mm > 0.0) { 398 393 return None; 399 394 }
+33
crates/bone-ui/src/widgets/toolbar.rs
··· 17 17 pub disabled: bool, 18 18 pub active: bool, 19 19 pub width: Option<LayoutPx>, 20 + pub tooltip: Option<StringKey>, 20 21 } 21 22 22 23 impl ToolbarItem { ··· 28 29 disabled: false, 29 30 active: false, 30 31 width: None, 32 + tooltip: None, 31 33 } 32 34 } 33 35 ··· 45 47 pub const fn with_width(self, width: LayoutPx) -> Self { 46 48 Self { 47 49 width: Some(width), 50 + ..self 51 + } 52 + } 53 + 54 + #[must_use] 55 + pub const fn with_tooltip(self, tooltip: StringKey) -> Self { 56 + Self { 57 + tooltip: Some(tooltip), 48 58 ..self 49 59 } 50 60 } ··· 260 270 role: ctx.theme().typography.caption, 261 271 }); 262 272 push_focus_ring(ctx, &mut paint, rect, ctx.theme().radius.sm, live_focused); 273 + if let Some(tip_key) = item.tooltip 274 + && interaction.hover() 275 + { 276 + paint.push(WidgetPaint::Tooltip { 277 + rect: tooltip_rect_below(rect), 278 + text: LabelText::Key(tip_key), 279 + anchor: item.id, 280 + elevation: ctx.theme().elevation.level2, 281 + }); 282 + } 263 283 ItemDraw { 264 284 activated: activated_via_pointer || activated_via_key, 265 285 paint, 266 286 } 287 + } 288 + 289 + fn tooltip_rect_below(anchor: LayoutRect) -> LayoutRect { 290 + const TIP_WIDTH: f32 = 220.0; 291 + const TIP_HEIGHT: f32 = 22.0; 292 + const TIP_GAP: f32 = 4.0; 293 + let center_x = anchor.origin.x.value() + anchor.size.width.value() * 0.5; 294 + let origin_x = center_x - TIP_WIDTH * 0.5; 295 + let origin_y = anchor.origin.y.value() + anchor.size.height.value() + TIP_GAP; 296 + LayoutRect::new( 297 + LayoutPos::new(LayoutPx::new(origin_x), LayoutPx::new(origin_y)), 298 + LayoutSize::new(LayoutPx::new(TIP_WIDTH), LayoutPx::new(TIP_HEIGHT)), 299 + ) 267 300 } 268 301 269 302 fn item_fill(