Another project
0

Configure Feed

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

feat(ui,document): dock menu slot, toolbar widths, glyph atlas key, edit sketch points

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

author
Lewis
date (May 9, 2026, 12:05 AM +0300) commit e5d646bb parent 2e50ed7c change-id loskwztv
+234 -82
+5 -1
crates/bone-document/src/sketch/edit.rs
··· 1 - use bone_types::{SketchDimensionId, SketchEntityId, SketchParameterId, SketchRelationId}; 1 + use bone_types::{Point2, SketchDimensionId, SketchEntityId, SketchParameterId, SketchRelationId}; 2 2 3 3 use super::dimension::{DimensionValue, SketchDimension}; 4 4 use super::entity::SketchEntity; ··· 15 15 DeleteRelation(SketchRelationId), 16 16 DeleteDimension(SketchDimensionId), 17 17 DeleteParameter(SketchParameterId), 18 + MovePoint { 19 + id: SketchEntityId, 20 + position: Point2, 21 + }, 18 22 SetConstruction { 19 23 id: SketchEntityId, 20 24 for_construction: bool,
+91
crates/bone-document/src/sketch/mod.rs
··· 163 163 SketchEdit::DeleteRelation(id) => self.delete_relation(id), 164 164 SketchEdit::DeleteDimension(id) => self.delete_dimension(id), 165 165 SketchEdit::DeleteParameter(id) => self.delete_parameter(id), 166 + SketchEdit::MovePoint { id, position } => self.move_point(id, position), 166 167 SketchEdit::SetConstruction { 167 168 id, 168 169 for_construction, ··· 435 436 SketchEntity::Arc(a) => SketchEntity::Arc(a.with_construction(for_construction)), 436 437 SketchEntity::Circle(c) => SketchEntity::Circle(c.with_construction(for_construction)), 437 438 }; 439 + if updated == entity { 440 + return Ok((self, EditOutcome::None)); 441 + } 442 + let entities = replace(self.entities, id, updated); 443 + let next = Self { entities, ..self }; 444 + Ok((next, EditOutcome::None)) 445 + } 446 + 447 + fn move_point( 448 + self, 449 + id: SketchEntityId, 450 + position: Point2, 451 + ) -> Result<(Self, EditOutcome), SketchEditError> { 452 + let entity = *self.require_entity(id)?; 453 + let SketchEntity::Point(_) = entity else { 454 + return Err(SketchEditError::ExpectedPoint(id)); 455 + }; 456 + let updated = SketchEntity::point(position); 438 457 if updated == entity { 439 458 return Ok((self, EditOutcome::None)); 440 459 } ··· 893 912 fn delete_entity_errors_on_unknown() { 894 913 let s = Sketch::new(plane()); 895 914 let bad = s.apply(SketchEdit::DeleteEntity(SketchEntityId::default())); 915 + assert!(matches!(bad, Err(SketchEditError::EntityNotFound(_)))); 916 + } 917 + 918 + #[test] 919 + fn move_point_updates_position() { 920 + let Ok((s, EditOutcome::Entity(p))) = Sketch::new(plane()).apply(SketchEdit::AddEntity( 921 + SketchEntity::point(Point2::from_mm(1.0, 2.0)), 922 + )) else { 923 + panic!("seed point"); 924 + }; 925 + let target = Point2::from_mm(7.0, -3.0); 926 + let Ok((after, EditOutcome::None)) = s.apply(SketchEdit::MovePoint { 927 + id: p, 928 + position: target, 929 + }) else { 930 + panic!("move accepts"); 931 + }; 932 + let SketchEntity::Point(pt) = after.entities()[p] else { 933 + panic!("still a point"); 934 + }; 935 + assert_eq!(pt.at(), target); 936 + } 937 + 938 + #[test] 939 + fn move_point_no_op_when_position_unchanged() { 940 + let at = Point2::from_mm(4.0, 4.0); 941 + let Ok((s, EditOutcome::Entity(p))) = 942 + Sketch::new(plane()).apply(SketchEdit::AddEntity(SketchEntity::point(at))) 943 + else { 944 + panic!("seed point"); 945 + }; 946 + let Ok((after, EditOutcome::None)) = s.clone().apply(SketchEdit::MovePoint { 947 + id: p, 948 + position: at, 949 + }) else { 950 + panic!("idempotent move accepts"); 951 + }; 952 + assert_eq!(after, s); 953 + } 954 + 955 + #[test] 956 + fn move_point_rejects_non_point_entity() { 957 + let s = Sketch::new(plane()); 958 + let Ok((s, EditOutcome::Entity(a))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 959 + Point2::from_mm(0.0, 0.0), 960 + ))) else { 961 + panic!("a"); 962 + }; 963 + let Ok((s, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 964 + Point2::from_mm(1.0, 0.0), 965 + ))) else { 966 + panic!("b"); 967 + }; 968 + let Ok((s, EditOutcome::Entity(line))) = 969 + s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) 970 + else { 971 + panic!("line"); 972 + }; 973 + let bad = s.apply(SketchEdit::MovePoint { 974 + id: line, 975 + position: Point2::from_mm(2.0, 2.0), 976 + }); 977 + assert!(matches!(bad, Err(SketchEditError::ExpectedPoint(_)))); 978 + } 979 + 980 + #[test] 981 + fn move_point_errors_on_unknown_id() { 982 + let s = Sketch::new(plane()); 983 + let bad = s.apply(SketchEdit::MovePoint { 984 + id: SketchEntityId::default(), 985 + position: Point2::from_mm(0.0, 0.0), 986 + }); 896 987 assert!(matches!(bad, Err(SketchEditError::EntityNotFound(_)))); 897 988 } 898 989
+10 -2
crates/bone-ui/src/layout/dock.rs
··· 176 176 } 177 177 178 178 pub fn solidworks_default( 179 + menu_bar: PanelId, 179 180 feature_tree: PanelId, 180 181 property_pane: PanelId, 181 182 ribbon: PanelId, ··· 185 186 const FEATURE_TREE_RATIO: SplitFraction = SplitFraction::clamped(0.22); 186 187 const VIEWPORT_RATIO: SplitFraction = SplitFraction::clamped(0.78); 187 188 const RIBBON_RATIO: SplitFraction = SplitFraction::clamped(0.10); 189 + const MENU_RATIO: SplitFraction = SplitFraction::clamped(0.035); 188 190 const STATUS_RATIO: SplitFraction = SplitFraction::clamped(0.97); 189 - [feature_tree, property_pane, ribbon, viewport, status] 191 + [menu_bar, feature_tree, property_pane, ribbon, viewport, status] 190 192 .into_iter() 191 193 .try_fold(BTreeSet::<PanelId>::new(), |mut seen, id| { 192 194 if seen.insert(id) { ··· 212 214 DockNode::tabs(vec![ribbon]), 213 215 center, 214 216 ); 217 + let with_menu = DockNode::split( 218 + Axis::Vertical, 219 + MENU_RATIO, 220 + DockNode::tabs(vec![menu_bar]), 221 + with_ribbon, 222 + ); 215 223 let main = DockNode::split( 216 224 Axis::Vertical, 217 225 STATUS_RATIO, 218 - with_ribbon, 226 + with_menu, 219 227 DockNode::tabs(vec![status]), 220 228 ); 221 229 Ok(Self::new(main))
+18 -11
crates/bone-ui/src/layout/tests.rs
··· 649 649 650 650 #[test] 651 651 fn dock_solidworks_default_panel_set_is_complete() { 652 - let panels = (pid(1), pid(2), pid(3), pid(4), pid(5)); 653 - let Ok(dock) = DockState::solidworks_default(panels.0, panels.1, panels.2, panels.3, panels.4) 654 - else { 652 + let panels = (pid(1), pid(2), pid(3), pid(4), pid(5), pid(6)); 653 + let Ok(dock) = DockState::solidworks_default( 654 + panels.0, panels.1, panels.2, panels.3, panels.4, panels.5, 655 + ) else { 655 656 panic!("solidworks_default rejected distinct ids"); 656 657 }; 657 658 let mut ids = dock.main.panel_ids(); 658 659 ids.sort(); 659 - let mut expected = vec![panels.0, panels.1, panels.2, panels.3, panels.4]; 660 + let mut expected = vec![panels.0, panels.1, panels.2, panels.3, panels.4, panels.5]; 660 661 expected.sort(); 661 662 assert_eq!(ids, expected); 662 663 } 663 664 664 665 #[test] 665 666 fn dock_host_layout_resolves_panels() { 666 - let feature = pid(1); 667 - let viewport = pid(2); 668 - let property = pid(3); 669 - let ribbon = pid(4); 670 - let status = pid(5); 671 - let Ok(dock) = DockState::solidworks_default(feature, property, ribbon, viewport, status) 667 + let menu = pid(1); 668 + let feature = pid(2); 669 + let viewport = pid(3); 670 + let property = pid(4); 671 + let ribbon = pid(5); 672 + let status = pid(6); 673 + let Ok(dock) = 674 + DockState::solidworks_default(menu, feature, property, ribbon, viewport, status) 672 675 else { 673 676 panic!("solidworks_default rejected distinct ids"); 674 677 }; 675 678 let panels = vec![ 679 + DockPanel { 680 + id: menu, 681 + child: Layout::leaf(wid(10)), 682 + }, 676 683 DockPanel { 677 684 id: feature, 678 685 child: Layout::leaf(wid(11)), ··· 1370 1377 1371 1378 #[test] 1372 1379 fn solidworks_default_rejects_duplicate_panel_ids() { 1373 - let result = DockState::solidworks_default(pid(1), pid(2), pid(1), pid(4), pid(5)); 1380 + let result = DockState::solidworks_default(pid(1), pid(2), pid(1), pid(4), pid(5), pid(6)); 1374 1381 let Err(DockStateError::DuplicatePanelId(p)) = result else { 1375 1382 panic!("expected DuplicatePanelId, got {result:?}"); 1376 1383 };
+3 -3
crates/bone-ui/src/lib.rs
··· 31 31 }; 32 32 pub use strings::{Locale, PluralCategory, PluralEntry, StringKey, StringTable}; 33 33 pub use text::{ 34 - AtlasEntry, CaretMove, GlyphId, MaxWidth, OutlineTessellator, SdfAtlas, SdfAtlasError, 35 - SdfAtlasParams, Selection, SelectionAction, ShapeRequest, ShapedText, Shaper, SourceByteIndex, 36 - TessellatedGlyph, TextLayout, TextPrimitive, TextRole, request_for, 34 + AtlasEntry, CaretMove, GlyphAtlasKey, GlyphId, MaxWidth, OutlineTessellator, SdfAtlas, 35 + SdfAtlasError, SdfAtlasParams, Selection, SelectionAction, ShapeRequest, ShapedText, Shaper, 36 + SourceByteIndex, TessellatedGlyph, TextLayout, TextPrimitive, TextRole, request_for, 37 37 }; 38 38 pub use theme::{ 39 39 BlurRadius, Border, CadColors, Color, ColorError, Colors, Easing, ElevationLevel,
+2 -1
crates/bone-ui/src/text/mod.rs
··· 8 8 }; 9 9 pub use primitive::{TextLayout, TextPrimitive, TextRole, request_for}; 10 10 pub use raster::{ 11 - AtlasEntry, OutlineTessellator, SdfAtlas, SdfAtlasError, SdfAtlasParams, TessellatedGlyph, 11 + AtlasEntry, GlyphAtlasKey, OutlineTessellator, SdfAtlas, SdfAtlasError, SdfAtlasParams, 12 + TessellatedGlyph, 12 13 }; 13 14 pub use selection::{CaretMove, Selection, SelectionAction};
+1 -1
crates/bone-ui/src/text/raster/mod.rs
··· 2 2 mod sdf; 3 3 4 4 pub use outline::{OutlineTessellator, TessellatedGlyph}; 5 - pub use sdf::{AtlasEntry, SdfAtlas, SdfAtlasError, SdfAtlasParams}; 5 + pub use sdf::{AtlasEntry, GlyphAtlasKey, SdfAtlas, SdfAtlasError, SdfAtlasParams};
+1 -1
crates/bone-ui/src/theme/color.rs
··· 322 322 323 323 #[must_use] 324 324 pub fn text_disabled(&self) -> Color { 325 - self.neutral.step(Step12::HOVER_BORDER) 325 + self.neutral.step(Step12::TEXT_MUTED) 326 326 } 327 327 328 328 #[must_use]
+5 -2
crates/bone-ui/src/widgets/ribbon.rs
··· 310 310 } 311 311 312 312 fn group_rects(body: LayoutRect, groups: &[RibbonGroup], gap: LayoutPx) -> Vec<LayoutRect> { 313 + let body_max_x = body.origin.x.value() + body.size.width.value(); 313 314 groups 314 315 .iter() 315 316 .scan(body.origin.x.value(), |x, group| { 317 + let remaining = (body_max_x - *x).max(0.0); 318 + let allocated = group.width.value().min(remaining); 316 319 let rect = LayoutRect::new( 317 320 LayoutPos::new(LayoutPx::new(*x), body.origin.y), 318 - LayoutSize::new(group.width, body.size.height), 321 + LayoutSize::new(LayoutPx::new(allocated), body.size.height), 319 322 ); 320 - *x += group.width.value() + gap.value(); 323 + *x += allocated + gap.value(); 321 324 Some(rect) 322 325 }) 323 326 .collect()
+71 -45
crates/bone-ui/src/widgets/toolbar.rs
··· 16 16 pub label: StringKey, 17 17 pub disabled: bool, 18 18 pub active: bool, 19 + pub width: Option<LayoutPx>, 19 20 } 20 21 21 22 impl ToolbarItem { ··· 26 27 label, 27 28 disabled: false, 28 29 active: false, 30 + width: None, 29 31 } 30 32 } 31 33 ··· 37 39 #[must_use] 38 40 pub const fn active(self, active: bool) -> Self { 39 41 Self { active, ..self } 42 + } 43 + 44 + #[must_use] 45 + pub const fn with_width(self, width: LayoutPx) -> Self { 46 + Self { 47 + width: Some(width), 48 + ..self 49 + } 40 50 } 41 51 } 42 52 ··· 123 133 item_gap, 124 134 orientation, 125 135 } = toolbar; 126 - let visible_count = compute_visible_count(rect, items.len(), item_size, item_gap, orientation); 136 + let layout = layout_items(rect, items, item_size, item_gap, orientation); 137 + let visible_count = layout.len(); 127 138 let needs_overflow = visible_count < items.len(); 128 139 ctx.a11y 129 140 .push(id, rect, AccessNode::new(Role::Toolbar).with_label(label)); ··· 132 143 items 133 144 .iter() 134 145 .take(visible_count) 135 - .enumerate() 136 - .for_each(|(idx, item)| { 137 - let item_rect = item_rect(rect, idx, item_size, item_gap, orientation); 138 - let result = draw_item(ctx, item_rect, item); 146 + .zip(layout.iter()) 147 + .for_each(|(item, item_rect)| { 148 + let result = draw_item(ctx, *item_rect, item); 139 149 paint.extend(result.paint); 140 150 if result.activated && activated.is_none() { 141 151 activated = Some(item.id); ··· 143 153 }); 144 154 let overflow_ids: Vec<WidgetId> = items.iter().skip(visible_count).map(|i| i.id).collect(); 145 155 if needs_overflow { 146 - let overflow_rect = item_rect(rect, visible_count, item_size, item_gap, orientation); 156 + let overflow_rect = overflow_rect(rect, &layout, item_size, item_gap, orientation); 147 157 let overflow_id = id.child(WidgetKey::new("overflow")); 148 158 let interaction = ctx.interact( 149 159 InteractDeclaration::new(overflow_id, overflow_rect, Sense::INTERACTIVE) ··· 273 283 } 274 284 } 275 285 276 - fn compute_visible_count( 286 + fn item_extent(item: &ToolbarItem, fallback: LayoutPx) -> f32 { 287 + item.width.unwrap_or(fallback).value() 288 + } 289 + 290 + fn layout_items( 277 291 rect: LayoutRect, 278 - total: usize, 292 + items: &[ToolbarItem], 279 293 item_size: LayoutPx, 280 294 gap: LayoutPx, 281 295 orientation: ToolbarOrientation, 282 - ) -> usize { 283 - if total == 0 { 284 - return 0; 285 - } 296 + ) -> Vec<LayoutRect> { 286 297 let available = match orientation { 287 298 ToolbarOrientation::Horizontal => rect.size.width.value(), 288 299 ToolbarOrientation::Vertical => rect.size.height.value(), 289 300 }; 290 - let span = item_size.value() + gap.value(); 291 - if span <= 0.0 { 292 - return total; 293 - } 294 - #[allow( 295 - clippy::cast_possible_truncation, 296 - clippy::cast_sign_loss, 297 - reason = "item counts fit in usize" 298 - )] 299 - let raw = ((available + gap.value()) / span).floor() as usize; 300 - if raw >= total { 301 - total 301 + let total_extent: f32 = items 302 + .iter() 303 + .enumerate() 304 + .map(|(i, it)| item_extent(it, item_size) + if i == 0 { 0.0 } else { gap.value() }) 305 + .sum(); 306 + let cap = if total_extent <= available { 307 + available 302 308 } else { 303 - raw.saturating_sub(1) 304 - } 309 + (available - item_size.value() - gap.value()).max(0.0) 310 + }; 311 + let mut offset = 0.0_f32; 312 + items 313 + .iter() 314 + .enumerate() 315 + .scan((), |_, (i, item)| { 316 + let extent = item_extent(item, item_size); 317 + let lead = if i == 0 { 0.0 } else { gap.value() }; 318 + let next = offset + lead + extent; 319 + if total_extent > available && next > cap { 320 + return None; 321 + } 322 + let single = single_item_rect(rect, offset + lead, extent, orientation); 323 + offset = next; 324 + Some(single) 325 + }) 326 + .collect() 305 327 } 306 328 307 - fn item_rect( 329 + fn single_item_rect( 308 330 rect: LayoutRect, 309 - index: usize, 310 - size: LayoutPx, 311 - gap: LayoutPx, 331 + lead_offset: f32, 332 + extent: f32, 312 333 orientation: ToolbarOrientation, 313 334 ) -> LayoutRect { 314 - #[allow( 315 - clippy::cast_precision_loss, 316 - reason = "toolbar item indices fit in f32 mantissa" 317 - )] 318 - let i = index as f32; 319 - let stride = size.value() + gap.value(); 320 335 match orientation { 321 336 ToolbarOrientation::Horizontal => LayoutRect::new( 322 337 LayoutPos::new( 323 - LayoutPx::new(rect.origin.x.value() + i * stride), 338 + LayoutPx::new(rect.origin.x.value() + lead_offset), 324 339 rect.origin.y, 325 340 ), 326 - LayoutSize::new(size, rect.size.height.min(size_max(rect, orientation))), 341 + LayoutSize::new(LayoutPx::new(extent), rect.size.height), 327 342 ), 328 343 ToolbarOrientation::Vertical => LayoutRect::new( 329 344 LayoutPos::new( 330 345 rect.origin.x, 331 - LayoutPx::new(rect.origin.y.value() + i * stride), 346 + LayoutPx::new(rect.origin.y.value() + lead_offset), 332 347 ), 333 - LayoutSize::new(rect.size.width.min(size_max(rect, orientation)), size), 348 + LayoutSize::new(rect.size.width, LayoutPx::new(extent)), 334 349 ), 335 350 } 336 351 } 337 352 338 - fn size_max(rect: LayoutRect, orientation: ToolbarOrientation) -> LayoutPx { 339 - match orientation { 340 - ToolbarOrientation::Horizontal => rect.size.height, 341 - ToolbarOrientation::Vertical => rect.size.width, 342 - } 353 + fn overflow_rect( 354 + rect: LayoutRect, 355 + laid_out: &[LayoutRect], 356 + item_size: LayoutPx, 357 + gap: LayoutPx, 358 + orientation: ToolbarOrientation, 359 + ) -> LayoutRect { 360 + let lead_offset = laid_out.last().map_or(0.0, |last| match orientation { 361 + ToolbarOrientation::Horizontal => { 362 + (last.origin.x.value() - rect.origin.x.value()) + last.size.width.value() + gap.value() 363 + } 364 + ToolbarOrientation::Vertical => { 365 + (last.origin.y.value() - rect.origin.y.value()) + last.size.height.value() + gap.value() 366 + } 367 + }); 368 + single_item_rect(rect, lead_offset, item_size.value(), orientation) 343 369 } 344 370 345 371 #[cfg(test)]
+3 -1
crates/bone-ui/tests/dock_snapshot.rs
··· 7 7 } 8 8 9 9 fn solidworks_default() -> DockState { 10 - let Ok(state) = DockState::solidworks_default(pid(1), pid(2), pid(3), pid(4), pid(5)) else { 10 + let Ok(state) = 11 + DockState::solidworks_default(pid(1), pid(2), pid(3), pid(4), pid(5), pid(6)) 12 + else { 11 13 panic!("solidworks_default rejected distinct ids"); 12 14 }; 13 15 state
+24 -14
crates/bone-ui/tests/snapshots/dock_snapshot__dock_solidworks_default.snap
··· 1 1 --- 2 2 source: crates/bone-ui/tests/dock_snapshot.rs 3 - expression: state 3 + expression: solidworks_default() 4 4 --- 5 5 DockState( 6 6 main: Split( ··· 8 8 fraction: 0.97, 9 9 a: Split( 10 10 axis: Vertical, 11 - fraction: 0.1, 11 + fraction: 0.035, 12 12 a: Tabs( 13 13 tabs: [ 14 - 3, 14 + 1, 15 15 ], 16 16 active: 0, 17 17 ), 18 18 b: Split( 19 - axis: Horizontal, 20 - fraction: 0.22, 19 + axis: Vertical, 20 + fraction: 0.1, 21 21 a: Tabs( 22 22 tabs: [ 23 - 1, 23 + 4, 24 24 ], 25 25 active: 0, 26 26 ), 27 27 b: Split( 28 28 axis: Horizontal, 29 - fraction: 0.78, 29 + fraction: 0.22, 30 30 a: Tabs( 31 31 tabs: [ 32 - 4, 33 - ], 34 - active: 0, 35 - ), 36 - b: Tabs( 37 - tabs: [ 38 32 2, 39 33 ], 40 34 active: 0, 41 35 ), 36 + b: Split( 37 + axis: Horizontal, 38 + fraction: 0.78, 39 + a: Tabs( 40 + tabs: [ 41 + 5, 42 + ], 43 + active: 0, 44 + ), 45 + b: Tabs( 46 + tabs: [ 47 + 3, 48 + ], 49 + active: 0, 50 + ), 51 + ), 42 52 ), 43 53 ), 44 54 ), 45 55 b: Tabs( 46 56 tabs: [ 47 - 5, 57 + 6, 48 58 ], 49 59 active: 0, 50 60 ),
crates/bone-ui/tests/snapshots/gallery_dark.png

This is a binary file and will not be displayed.

crates/bone-ui/tests/snapshots/gallery_light.png

This is a binary file and will not be displayed.