Another project
0

Configure Feed

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

A ton of surface-level UI edits

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

author
Lewis
date (Jun 14, 2026, 10:48 PM +0300) commit e0bfe3b3 parent 878bf66f change-id stmvltzm
+6322 -696
+1
Cargo.lock
··· 461 461 "accesskit", 462 462 "bone-app", 463 463 "bone-render", 464 + "bone-types", 464 465 "bone-ui", 465 466 "pollster", 466 467 "ron",
+47 -9
crates/bone-app/src/app.rs
··· 10 10 }; 11 11 use bone_render::{ 12 12 Camera2, CameraTween, ChromeInstance, ChromePipeline, ChromeTextPipeline, ConvexInstance, 13 - ConvexPolyPipeline, DragModifiers, EdgeScene, Gpu, NavGesture, PickIdError, PickIndex, 14 - PickQuery, PickedItem, Picker, PixelsPerMm, RenderTargets, SdfGlyphInstance, SketchPreview, 15 - SketchRenderer, SketchScene, SolidFrameView, SolidRenderer, SolidScene, StrokeInstance, 16 - StrokePipeline, Style, ViewportExtent, ViewportNavigator, ViewportPoint, ViewportPx, 17 - ViewportRegion, frame_current, frame_standard_view, frame_view_direction, orbit_pitch, 18 - orbit_yaw, pan_pixels, roll_by, zoom_about_pixel, 13 + ConvexPolyPipeline, DragModifiers, EdgeScene, Gpu, IconInstance, IconPipeline, NavGesture, 14 + PickIdError, PickIndex, PickQuery, PickedItem, Picker, PixelsPerMm, RenderTargets, 15 + SdfGlyphInstance, SketchPreview, SketchRenderer, SketchScene, SolidFrameView, SolidRenderer, 16 + SolidScene, StrokeInstance, StrokePipeline, Style, ViewportExtent, ViewportNavigator, 17 + ViewportPoint, ViewportPx, ViewportRegion, frame_current, frame_standard_view, 18 + frame_view_direction, orbit_pitch, orbit_yaw, pan_pixels, roll_by, zoom_about_pixel, 19 19 }; 20 20 use bone_types::{ 21 21 Aabb3, Angle, AngleTolerance, BudgetCeiling, Camera3, ChordHeightTolerance, CubicEasing, ··· 76 76 chrome_pipeline: ChromePipeline, 77 77 convex_pipeline: ConvexPolyPipeline, 78 78 stroke_pipeline: StrokePipeline, 79 + icon_pipeline: IconPipeline, 79 80 text_pipeline: ChromeTextPipeline, 80 81 sdf_atlas: MaskAtlas, 81 82 chrome_shaper: Shaper, ··· 178 179 pending_released: PointerButtonMask, 179 180 pending_keys: Vec<UiKeyEvent>, 180 181 pending_text: String, 182 + pending_scroll_y: f32, 181 183 } 182 184 183 185 impl Default for InputState { ··· 191 193 pending_released: PointerButtonMask::EMPTY, 192 194 pending_keys: Vec::new(), 193 195 pending_text: String::new(), 196 + pending_scroll_y: 0.0, 194 197 } 195 198 } 196 199 } ··· 238 241 snap.keys_pressed = core::mem::take(&mut self.pending_keys); 239 242 snap.text_committed = core::mem::take(&mut self.pending_text); 240 243 snap.modifiers = self.modifiers; 244 + snap.scroll_y = core::mem::replace(&mut self.pending_scroll_y, 0.0); 241 245 snap 242 246 } 243 247 ··· 347 351 } 348 352 } 349 353 354 + const WHEEL_LINE_PX: f32 = 48.0; 355 + 356 + fn wheel_offset_px(delta: ScrollDelta) -> f32 { 357 + match delta { 358 + ScrollDelta::Lines { y, .. } => -y * WHEEL_LINE_PX, 359 + #[allow( 360 + clippy::cast_possible_truncation, 361 + reason = "trackpad pixel delta collapses to f32 at the sub-pixel limit" 362 + )] 363 + ScrollDelta::Pixels { y, .. } => -(y as f32), 364 + } 365 + } 366 + 350 367 fn zoom_about(camera: Camera2, cursor: Option<WindowPoint>, factor: f64) -> Camera2 { 351 368 if !factor.is_finite() || factor <= 0.0 { 352 369 return camera; ··· 1307 1324 let chrome_pipeline = ChromePipeline::new(gpu, color_format); 1308 1325 let convex_pipeline = ConvexPolyPipeline::new(gpu, color_format); 1309 1326 let stroke_pipeline = StrokePipeline::new(gpu, color_format); 1327 + let icon_pipeline = IconPipeline::new(gpu, color_format); 1310 1328 let sdf_atlas = MaskAtlas::new(MaskAtlasParams::STANDARD); 1311 1329 let text_pipeline = ChromeTextPipeline::new(gpu, color_format, sdf_atlas.extent()); 1312 1330 let chrome_shaper = Shaper::new(); ··· 1315 1333 let sketch = default_sketch(); 1316 1334 let scene = SketchScene::extract(&sketch)?; 1317 1335 let camera = Camera2::new(extent).with_zoom(PixelsPerMm::new(INITIAL_ZOOM_PX_PER_MM)); 1318 - let style = Style::default(); 1336 + let style = Style::light(); 1319 1337 let theme = Arc::new(Theme::light()); 1320 1338 let shell = shell::Shell::new(); 1321 1339 let (document, sketch_id) = initial_document(sketch); ··· 1340 1358 chrome_pipeline, 1341 1359 convex_pipeline, 1342 1360 stroke_pipeline, 1361 + icon_pipeline, 1343 1362 text_pipeline, 1344 1363 sdf_atlas, 1345 1364 chrome_shaper, ··· 1454 1473 } 1455 1474 1456 1475 fn dispatch_wheel(&mut self, delta: ScrollDelta) { 1457 - let state = &mut self.state; 1458 - if modal_active(state) || !self.input.cursor_in(state.viewport_rect) { 1476 + let over_viewport = self.input.cursor_in(self.state.viewport_rect); 1477 + if !over_viewport || modal_active(&self.state) { 1478 + self.input.pending_scroll_y += wheel_offset_px(delta); 1459 1479 return; 1460 1480 } 1481 + self.dispatch_viewport_wheel(delta); 1482 + } 1483 + 1484 + fn dispatch_viewport_wheel(&mut self, delta: ScrollDelta) { 1485 + let state = &mut self.state; 1461 1486 if state.solid_view.is_none() { 1462 1487 state.camera = zoom_about(state.camera, self.input.cursor_px, zoom_factor(delta)); 1463 1488 return; ··· 1648 1673 bone_ui::theme::ThemeMode::Light => Theme::light(), 1649 1674 bone_ui::theme::ThemeMode::Dark => Theme::dark(), 1650 1675 }); 1676 + self.state.style = match mode { 1677 + bone_ui::theme::ThemeMode::Light => Style::light(), 1678 + bone_ui::theme::ThemeMode::Dark => Style::default(), 1679 + }; 1651 1680 } 1652 1681 1653 1682 pub fn clock_mut(&mut self) -> &mut FrameClock { ··· 1836 1865 chrome: &mut state.chrome_pipeline, 1837 1866 convex: &mut state.convex_pipeline, 1838 1867 stroke: &mut state.stroke_pipeline, 1868 + icon: &mut state.icon_pipeline, 1839 1869 text: &mut state.text_pipeline, 1840 1870 atlas_pixels, 1841 1871 atlas_version, ··· 1913 1943 chrome: &'a mut ChromePipeline, 1914 1944 convex: &'a mut ConvexPolyPipeline, 1915 1945 stroke: &'a mut StrokePipeline, 1946 + icon: &'a mut IconPipeline, 1916 1947 text: &'a mut ChromeTextPipeline, 1917 1948 atlas_pixels: &'a [u8], 1918 1949 atlas_version: u64, ··· 1941 1972 let chrome = merge_layers(&main.chrome, &overlay.chrome); 1942 1973 let convex = merge_layers(&main.convex, &overlay.convex); 1943 1974 let stroke = merge_layers(&main.stroke, &overlay.stroke); 1975 + let icons = merge_layers(&main.icons, &overlay.icons); 1944 1976 let glyphs = merge_layers(&main.glyphs, &overlay.glyphs); 1945 1977 self.chrome.upload(self.viewport_px, &chrome); 1946 1978 self.convex.upload(self.viewport_px, &convex); 1947 1979 self.stroke.upload(self.viewport_px, &stroke); 1980 + self.icon.upload(self.viewport_px, &icons); 1948 1981 self.text.upload( 1949 1982 self.viewport_px, 1950 1983 self.atlas_pixels, ··· 1954 1987 let (mc, tc) = (count_u32(main.chrome.len()), count_u32(chrome.len())); 1955 1988 let (mv, tv) = (count_u32(main.convex.len()), count_u32(convex.len())); 1956 1989 let (ms, ts) = (count_u32(main.stroke.len()), count_u32(stroke.len())); 1990 + let (mi, ti) = (count_u32(main.icons.len()), count_u32(icons.len())); 1957 1991 let (mg, tg) = (count_u32(main.glyphs.len()), count_u32(glyphs.len())); 1958 1992 self.chrome.draw_range(encoder, color, 0..mc); 1959 1993 self.convex.draw_range(encoder, color, 0..mv); 1960 1994 self.stroke.draw_range(encoder, color, 0..ms); 1995 + self.icon.draw_range(encoder, color, 0..mi); 1961 1996 self.text.draw_range(encoder, color, 0..mg); 1962 1997 self.chrome.draw_range(encoder, color, mc..tc); 1963 1998 self.convex.draw_range(encoder, color, mv..tv); 1964 1999 self.stroke.draw_range(encoder, color, ms..ts); 2000 + self.icon.draw_range(encoder, color, mi..ti); 1965 2001 self.text.draw_range(encoder, color, mg..tg); 1966 2002 } 1967 2003 } ··· 1980 2016 chrome: Vec<ChromeInstance>, 1981 2017 convex: Vec<ConvexInstance>, 1982 2018 stroke: Vec<StrokeInstance>, 2019 + icons: Vec<IconInstance>, 1983 2020 glyphs: Vec<SdfGlyphInstance>, 1984 2021 } 1985 2022 ··· 2002 2039 chrome, 2003 2040 convex, 2004 2041 stroke, 2042 + icons: chrome::paint_to_icon_instances(paints), 2005 2043 glyphs, 2006 2044 } 2007 2045 }
+95 -2
crates/bone-app/src/chrome.rs
··· 1 1 use std::borrow::Cow; 2 2 3 - use bone_render::{ChromeInstance, ConvexInstance, SdfGlyphInstance, StrokeInstance}; 3 + use bone_render::{ChromeInstance, ConvexInstance, IconInstance, SdfGlyphInstance, StrokeInstance}; 4 4 use bone_text::{FontFace, FontWeight, ShapeRequest, ShapedLine, ShapedText, Shaper}; 5 + use bone_types::IconTile; 5 6 use bone_ui::layout::LayoutRect; 6 7 use bone_ui::strings::StringTable; 7 8 use bone_ui::text::{MaskAtlas, MaskAtlasKey}; 8 9 use bone_ui::theme::{Border, Color, StrokeWidth, Theme}; 9 10 use bone_ui::widgets::{ 10 - ConvexPoly, HorizontalAlign, PaintPrim, PolyPath, WidgetPaint, lower_paint, 11 + ConvexPoly, HorizontalAlign, IconTint, PaintPrim, PolyPath, WidgetPaint, lower_paint, 11 12 }; 12 13 use swash::FontRef; 13 14 ··· 44 45 WidgetPaint::Label { .. } 45 46 | WidgetPaint::AlignedLabel { .. } 46 47 | WidgetPaint::Mark { .. } 48 + | WidgetPaint::Icon { .. } 47 49 | WidgetPaint::ConvexFill { .. } 48 50 | WidgetPaint::Stroke { .. } 49 51 ) 50 52 }) 51 53 .map(|p| prim_to_instance(&lower_paint(theme, p))) 52 54 .collect() 55 + } 56 + 57 + #[must_use] 58 + pub fn paint_to_icon_instances(paints: &[WidgetPaint]) -> Vec<IconInstance> { 59 + paints 60 + .iter() 61 + .filter_map(|p| match p { 62 + WidgetPaint::Icon { rect, icon, tint } => Some(IconInstance::new( 63 + rect_to_xywh(*rect), 64 + tile_index(icon.tile()), 65 + icon_tint_premul(*tint), 66 + )), 67 + _ => None, 68 + }) 69 + .collect() 70 + } 71 + 72 + const DISABLED_TINT_VALUE: f32 = 0.75; 73 + const DISABLED_TINT_ALPHA: f32 = 0.5; 74 + 75 + fn icon_tint_premul(tint: IconTint) -> [f32; 4] { 76 + match tint { 77 + IconTint::Normal => [1.0, 1.0, 1.0, 1.0], 78 + IconTint::Disabled => { 79 + let premul = DISABLED_TINT_VALUE * DISABLED_TINT_ALPHA; 80 + [premul, premul, premul, DISABLED_TINT_ALPHA] 81 + } 82 + IconTint::Solid(color) => color.linear_rgba_premul(), 83 + } 84 + } 85 + 86 + fn tile_index(tile: IconTile) -> u32 { 87 + let Ok(index) = u32::try_from(tile.as_usize()) else { 88 + panic!("icon tile index {} exceeds u32", tile.as_usize()); 89 + }; 90 + index 53 91 } 54 92 55 93 #[must_use] ··· 492 530 instances.len(), 493 531 1, 494 532 "Mark must not render as a placeholder square" 533 + ); 534 + } 535 + 536 + #[test] 537 + fn icon_paints_route_to_icon_instances() { 538 + let paints = vec![WidgetPaint::Icon { 539 + rect: rect(4.0, 6.0, 16.0, 16.0), 540 + icon: bone_types::IconId::Point, 541 + tint: IconTint::Normal, 542 + }]; 543 + let instances = paint_to_icon_instances(&paints); 544 + assert_eq!(instances.len(), 1); 545 + assert!(rgba_close( 546 + instances[0].rect_xywh_px, 547 + [4.0, 6.0, 16.0, 16.0] 548 + )); 549 + assert_eq!(instances[0].tile_index, 0); 550 + assert!(rgba_close( 551 + instances[0].tint_premul_rgba, 552 + [1.0, 1.0, 1.0, 1.0] 553 + )); 554 + } 555 + 556 + fn rgba_close(a: [f32; 4], b: [f32; 4]) -> bool { 557 + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6) 558 + } 559 + 560 + #[test] 561 + fn disabled_icon_tint_dims_and_fades_while_staying_premultiplied() { 562 + let paints = vec![WidgetPaint::Icon { 563 + rect: rect(0.0, 0.0, 16.0, 16.0), 564 + icon: bone_types::IconId::Point, 565 + tint: IconTint::Disabled, 566 + }]; 567 + let [r, g, b, a] = paint_to_icon_instances(&paints)[0].tint_premul_rgba; 568 + assert!(a < 1.0, "disabled icon fades"); 569 + assert!(r < a && g < a && b < a, "disabled icon dims below identity"); 570 + } 571 + 572 + #[test] 573 + fn icon_paints_drop_from_chrome_rect_path() { 574 + let theme = Theme::light(); 575 + let paints = vec![ 576 + surface(&theme), 577 + WidgetPaint::Icon { 578 + rect: rect(0.0, 0.0, 16.0, 16.0), 579 + icon: bone_types::IconId::Point, 580 + tint: IconTint::Normal, 581 + }, 582 + ]; 583 + let instances = paint_to_instances(&theme, &paints); 584 + assert_eq!( 585 + instances.len(), 586 + 1, 587 + "icon must route to the icon pass, not a chrome placeholder" 495 588 ); 496 589 } 497 590
+3 -1
crates/bone-app/src/clock.rs
··· 30 30 type Error = ZeroFrameCount; 31 31 32 32 fn try_from(value: u32) -> Result<Self, Self::Error> { 33 - NonZeroU32::new(value).map(Self).ok_or(ZeroFrameCount(value)) 33 + NonZeroU32::new(value) 34 + .map(Self) 35 + .ok_or(ZeroFrameCount(value)) 34 36 } 35 37 } 36 38
+243
crates/bone-app/src/heads_up.rs
··· 1 + use bone_types::IconId; 2 + use bone_ui::a11y::{AccessNode, Role}; 3 + use bone_ui::frame::{FrameCtx, InteractDeclaration}; 4 + use bone_ui::hit_test::Sense; 5 + use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 6 + use bone_ui::strings::StringKey; 7 + use bone_ui::theme::{Border, Step12, StrokeWidth}; 8 + use bone_ui::widgets::{IconTint, WidgetPaint}; 9 + use bone_ui::{WidgetId, WidgetKey}; 10 + 11 + use crate::shell::MenuAction; 12 + use crate::strings; 13 + 14 + const BUTTON_PX: f32 = 22.0; 15 + const ICON_PX: f32 = 16.0; 16 + const BUTTON_GAP: f32 = 2.0; 17 + const GROUP_GAP: f32 = 8.0; 18 + const STRIP_PAD: f32 = 4.0; 19 + const STRIP_TOP_INSET: f32 = 8.0; 20 + 21 + #[derive(Copy, Clone)] 22 + struct HeadsUpTool { 23 + key: &'static str, 24 + icon: IconId, 25 + label: StringKey, 26 + action: Option<MenuAction>, 27 + } 28 + 29 + const GROUPS: [&[HeadsUpTool]; 3] = [ 30 + &[ 31 + HeadsUpTool { 32 + key: "zoom_fit", 33 + icon: IconId::ZoomToFit, 34 + label: strings::HEADS_UP_ZOOM_FIT, 35 + action: Some(MenuAction::ZoomFit), 36 + }, 37 + HeadsUpTool { 38 + key: "zoom_area", 39 + icon: IconId::ZoomToArea, 40 + label: strings::HEADS_UP_ZOOM_AREA, 41 + action: None, 42 + }, 43 + HeadsUpTool { 44 + key: "previous_view", 45 + icon: IconId::PreviousView, 46 + label: strings::HEADS_UP_PREVIOUS_VIEW, 47 + action: None, 48 + }, 49 + HeadsUpTool { 50 + key: "section_view", 51 + icon: IconId::SectionView, 52 + label: strings::HEADS_UP_SECTION_VIEW, 53 + action: None, 54 + }, 55 + ], 56 + &[ 57 + HeadsUpTool { 58 + key: "view_orientation", 59 + icon: IconId::ViewOrientation, 60 + label: strings::HEADS_UP_VIEW_ORIENTATION, 61 + action: None, 62 + }, 63 + HeadsUpTool { 64 + key: "display_style", 65 + icon: IconId::DisplayStyle, 66 + label: strings::HEADS_UP_DISPLAY_STYLE, 67 + action: None, 68 + }, 69 + HeadsUpTool { 70 + key: "hide_show", 71 + icon: IconId::HideShowItems, 72 + label: strings::HEADS_UP_HIDE_SHOW, 73 + action: None, 74 + }, 75 + ], 76 + &[ 77 + HeadsUpTool { 78 + key: "edit_appearance", 79 + icon: IconId::EditAppearance, 80 + label: strings::HEADS_UP_EDIT_APPEARANCE, 81 + action: None, 82 + }, 83 + HeadsUpTool { 84 + key: "view_settings", 85 + icon: IconId::ViewSettings, 86 + label: strings::HEADS_UP_VIEW_SETTINGS, 87 + action: None, 88 + }, 89 + ], 90 + ]; 91 + 92 + enum StripItem { 93 + Separator, 94 + Tool(HeadsUpTool), 95 + } 96 + 97 + fn strip_items() -> Vec<StripItem> { 98 + GROUPS 99 + .iter() 100 + .enumerate() 101 + .flat_map(|(index, group)| { 102 + (index > 0) 103 + .then_some(StripItem::Separator) 104 + .into_iter() 105 + .chain(group.iter().copied().map(StripItem::Tool)) 106 + }) 107 + .collect() 108 + } 109 + 110 + fn strip_width() -> f32 { 111 + let buttons: usize = GROUPS.iter().map(|g| g.len()).sum(); 112 + let inner_gaps: usize = GROUPS.iter().map(|g| g.len().saturating_sub(1)).sum(); 113 + let separators = GROUPS.len().saturating_sub(1); 114 + #[allow( 115 + clippy::cast_precision_loss, 116 + reason = "heads-up tool counts fit the f32 mantissa" 117 + )] 118 + let body = 119 + buttons as f32 * BUTTON_PX + inner_gaps as f32 * BUTTON_GAP + separators as f32 * GROUP_GAP; 120 + 2.0 * STRIP_PAD + body 121 + } 122 + 123 + struct StripStyle { 124 + hover_fill: bone_ui::theme::Color, 125 + separator: bone_ui::theme::Color, 126 + radius: bone_ui::theme::Radius, 127 + } 128 + 129 + #[must_use] 130 + pub fn render_heads_up_toolbar( 131 + ctx: &mut FrameCtx<'_>, 132 + viewport: LayoutRect, 133 + base: WidgetId, 134 + paints: &mut Vec<WidgetPaint>, 135 + ) -> Option<MenuAction> { 136 + let width = strip_width(); 137 + let height = 2.0 * STRIP_PAD + BUTTON_PX; 138 + if viewport.size.width.value() < width + 2.0 * STRIP_TOP_INSET 139 + || viewport.size.height.value() < height + 2.0 * STRIP_TOP_INSET 140 + { 141 + return None; 142 + } 143 + let left = viewport.min_x().value() + (viewport.size.width.value() - width) / 2.0; 144 + let top = viewport.min_y().value() + STRIP_TOP_INSET; 145 + let strip_rect = LayoutRect::new( 146 + LayoutPos::new(LayoutPx::new(left), LayoutPx::new(top)), 147 + LayoutSize::new(LayoutPx::new(width), LayoutPx::new(height)), 148 + ); 149 + ctx.a11y.push( 150 + base, 151 + strip_rect, 152 + AccessNode::new(Role::Toolbar).with_label(strings::HEADS_UP_BAR), 153 + ); 154 + let theme = ctx.theme(); 155 + let style = StripStyle { 156 + hover_fill: theme.colors.neutral.step(Step12::HOVER_BG), 157 + separator: theme.colors.neutral.step(Step12::SUBTLE_BORDER), 158 + radius: theme.radius.sm, 159 + }; 160 + paints.push(WidgetPaint::Surface { 161 + rect: strip_rect, 162 + fill: theme.colors.surface(theme.elevation.level2.surface), 163 + border: Some(Border { 164 + width: StrokeWidth::HAIRLINE, 165 + color: style.separator, 166 + }), 167 + radius: style.radius, 168 + elevation: Some(theme.elevation.level2), 169 + }); 170 + let row_top = top + STRIP_PAD; 171 + let (_, clicked) = strip_items().iter().fold( 172 + (left + STRIP_PAD, None::<MenuAction>), 173 + |(x, clicked), item| match item { 174 + StripItem::Separator => { 175 + paints.push(WidgetPaint::Surface { 176 + rect: LayoutRect::new( 177 + LayoutPos::new(LayoutPx::new(x + GROUP_GAP / 2.0), LayoutPx::new(row_top)), 178 + LayoutSize::new( 179 + LayoutPx::new(StrokeWidth::HAIRLINE.value_px()), 180 + LayoutPx::new(BUTTON_PX), 181 + ), 182 + ), 183 + fill: style.separator, 184 + border: None, 185 + radius: ctx.theme().radius.none, 186 + elevation: None, 187 + }); 188 + (x + GROUP_GAP, clicked) 189 + } 190 + StripItem::Tool(tool) => { 191 + let next = clicked.or_else(|| { 192 + draw_tool(ctx, base, tool, x, row_top, &style, paints) 193 + .then_some(tool.action) 194 + .flatten() 195 + }); 196 + (x + BUTTON_PX + BUTTON_GAP, next) 197 + } 198 + }, 199 + ); 200 + clicked 201 + } 202 + 203 + fn draw_tool( 204 + ctx: &mut FrameCtx<'_>, 205 + base: WidgetId, 206 + tool: &HeadsUpTool, 207 + x: f32, 208 + row_top: f32, 209 + style: &StripStyle, 210 + paints: &mut Vec<WidgetPaint>, 211 + ) -> bool { 212 + let button_rect = LayoutRect::new( 213 + LayoutPos::new(LayoutPx::new(x), LayoutPx::new(row_top)), 214 + LayoutSize::new(LayoutPx::new(BUTTON_PX), LayoutPx::new(BUTTON_PX)), 215 + ); 216 + let interaction = ctx.interact( 217 + InteractDeclaration::new( 218 + base.child(WidgetKey::new(tool.key)), 219 + button_rect, 220 + Sense::INTERACTIVE, 221 + ) 222 + .a11y(AccessNode::new(Role::Button).with_label(tool.label)), 223 + ); 224 + if interaction.hover() || interaction.pressed() { 225 + paints.push(WidgetPaint::Surface { 226 + rect: button_rect, 227 + fill: style.hover_fill, 228 + border: None, 229 + radius: style.radius, 230 + elevation: None, 231 + }); 232 + } 233 + let inset = (BUTTON_PX - ICON_PX) / 2.0; 234 + paints.push(WidgetPaint::Icon { 235 + rect: LayoutRect::new( 236 + LayoutPos::new(LayoutPx::new(x + inset), LayoutPx::new(row_top + inset)), 237 + LayoutSize::new(LayoutPx::new(ICON_PX), LayoutPx::new(ICON_PX)), 238 + ), 239 + icon: tool.icon, 240 + tint: IconTint::Normal, 241 + }); 242 + interaction.click() 243 + }
+1
crates/bone-app/src/lib.rs
··· 3 3 mod clock; 4 4 mod dimension_editor; 5 5 mod file_menu; 6 + mod heads_up; 6 7 mod hotkeys; 7 8 mod input; 8 9 mod native_picker;
+18 -1
crates/bone-app/src/relation_tools.rs
··· 1 1 use bone_document::{Sketch, SketchEntity, SketchEntityKind, SketchRelation}; 2 - use bone_types::SketchEntityId; 2 + use bone_types::{IconId, SketchEntityId}; 3 3 use bone_ui::strings::StringKey; 4 4 5 5 use crate::strings; ··· 29 29 struct RelationDescriptor { 30 30 key: &'static str, 31 31 label: StringKey, 32 + icon: IconId, 32 33 check: fn(&Sketch, &[SketchEntityId]) -> Eligibility, 33 34 } 34 35 ··· 52 53 Self::Coincident => RelationDescriptor { 53 54 key: "rel.coincident", 54 55 label: strings::TOOL_COINCIDENT, 56 + icon: IconId::Coincident, 55 57 check: coincident, 56 58 }, 57 59 Self::Horizontal => RelationDescriptor { 58 60 key: "rel.horizontal", 59 61 label: strings::TOOL_HORIZONTAL, 62 + icon: IconId::Horizontal, 60 63 check: horizontal, 61 64 }, 62 65 Self::Vertical => RelationDescriptor { 63 66 key: "rel.vertical", 64 67 label: strings::TOOL_VERTICAL, 68 + icon: IconId::Vertical, 65 69 check: vertical, 66 70 }, 67 71 Self::Parallel => RelationDescriptor { 68 72 key: "rel.parallel", 69 73 label: strings::TOOL_PARALLEL, 74 + icon: IconId::Parallel, 70 75 check: parallel, 71 76 }, 72 77 Self::Perpendicular => RelationDescriptor { 73 78 key: "rel.perpendicular", 74 79 label: strings::TOOL_PERPENDICULAR, 80 + icon: IconId::Perpendicular, 75 81 check: perpendicular, 76 82 }, 77 83 Self::Tangent => RelationDescriptor { 78 84 key: "rel.tangent", 79 85 label: strings::TOOL_TANGENT, 86 + icon: IconId::Tangent, 80 87 check: tangent, 81 88 }, 82 89 Self::Equal => RelationDescriptor { 83 90 key: "rel.equal", 84 91 label: strings::TOOL_EQUAL, 92 + icon: IconId::Equal, 85 93 check: equal, 86 94 }, 87 95 Self::Concentric => RelationDescriptor { 88 96 key: "rel.concentric", 89 97 label: strings::TOOL_CONCENTRIC, 98 + icon: IconId::Concentric, 90 99 check: concentric, 91 100 }, 92 101 Self::Midpoint => RelationDescriptor { 93 102 key: "rel.midpoint", 94 103 label: strings::TOOL_MIDPOINT, 104 + icon: IconId::Midpoint, 95 105 check: midpoint, 96 106 }, 97 107 Self::Symmetric => RelationDescriptor { 98 108 key: "rel.symmetric", 99 109 label: strings::TOOL_SYMMETRIC, 110 + icon: IconId::Symmetric, 100 111 check: symmetric, 101 112 }, 102 113 Self::Fix => RelationDescriptor { 103 114 key: "rel.fix", 104 115 label: strings::TOOL_FIX, 116 + icon: IconId::Fix, 105 117 check: fix, 106 118 }, 107 119 } ··· 115 127 #[must_use] 116 128 pub const fn label(self) -> StringKey { 117 129 self.descriptor().label 130 + } 131 + 132 + #[must_use] 133 + pub const fn icon(self) -> IconId { 134 + self.descriptor().icon 118 135 } 119 136 } 120 137
+435 -142
crates/bone-app/src/shell.rs
··· 8 8 SketchVersion, 9 9 }; 10 10 use bone_types::{ 11 - Angle, Camera3, ExtrudeId, Length, Point2, PositiveLength, SketchDimensionId, SketchEntityId, 12 - SketchId, 11 + Angle, Camera3, ExtrudeId, IconId, Length, Point2, PositiveLength, SketchDimensionId, 12 + SketchEntityId, SketchId, 13 13 }; 14 14 use bone_ui::a11y::{AccessNode, Role}; 15 15 use bone_ui::frame::{FrameCtx, InteractDeclaration}; ··· 21 21 }; 22 22 use bone_ui::strings::{StringKey, StringTable}; 23 23 use bone_ui::theme::{Border, ElevationLevel, Radius, Spacing, Step12, StrokeWidth, Theme}; 24 - use bone_ui::widgets::GlyphMark; 24 + use bone_ui::widgets::IconTint; 25 25 use bone_ui::widgets::{ 26 26 AngleEditor, BoolEditor, Checkbox, CheckboxState, Clipboard, Dialog, DialogButton, 27 27 HotkeyCapture, HotkeyCaptureState, LabelText, LengthEditor, MemoryClipboard, MenuBar, 28 - MenuBarEntry, MenuBarState, MenuItem, PanelState, PropertyCell, PropertyEditor, PropertyGrid, 29 - PropertyOption, PropertyRow, RenameCommit, Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, 28 + MenuBarEntry, MenuBarState, MenuItem, Panel, PanelState, PanelTitlebar, PanelVariant, 29 + PropertyCell, PropertyEditor, PropertyGrid, PropertyOption, PropertyPaneAction, 30 + PropertyPaneHeader, PropertyRow, RenameCommit, Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, 30 31 SelectionEditor, Slider, SliderRange, SliderStep, StatusAlign, StatusBar, StatusItem, Tab, 31 32 Tabs, TabsOrientation, ToolbarItem, TreeNode, TreeView, TreeViewState, WidgetPaint, 32 - show_checkbox, show_dialog, show_hotkey_capture, show_menu_bar, show_property_grid, 33 - show_ribbon, show_slider, show_status_bar, show_tabs, show_tree_view, 33 + show_checkbox, show_dialog, show_hotkey_capture, show_menu_bar, show_panel, show_property_grid, 34 + show_property_pane_header, show_ribbon, show_slider, show_status_bar, show_tabs, 35 + show_tree_view, 34 36 }; 35 37 use bone_ui::{WidgetId, WidgetKey}; 36 38 use uom::si::angle::degree; ··· 100 102 view_cube: WidgetId, 101 103 view_cube_menu: WidgetId, 102 104 view_selector: WidgetId, 105 + heads_up: WidgetId, 103 106 status_bar: WidgetId, 104 107 doc_tabs: WidgetId, 105 108 doc_tab_model: WidgetId, ··· 174 177 view_cube: viewport.child(WidgetKey::new("view_cube")), 175 178 view_cube_menu: viewport.child(WidgetKey::new("view_cube.menu")), 176 179 view_selector: viewport.child(WidgetKey::new("view_selector")), 180 + heads_up: viewport.child(WidgetKey::new("heads_up")), 177 181 status_bar: root.child(WidgetKey::new("status")), 178 182 doc_tabs: root.child(WidgetKey::new("doc_tabs")), 179 183 doc_tab_model: root.child(WidgetKey::new("doc_tabs.model")), ··· 299 303 pub status_panel: PanelState, 300 304 pub extrude_panel_open: bool, 301 305 pub extrude_panel: PanelState, 306 + pub property_groups: BTreeMap<WidgetId, PanelState>, 302 307 status_cache: Option<(SketchVersion, SketchStatusReport)>, 303 308 pub ribbon_overflow_open: BTreeMap<WidgetId, bool>, 304 309 pub ribbon_active_tab: Option<WidgetId>, ··· 654 659 &mut PaneEditors { 655 660 dim: &mut self.state.dim_property, 656 661 extrude: &mut self.state.extrude_property, 662 + groups: &mut self.state.property_groups, 657 663 }, 658 664 PropertyState { 659 665 mode, ··· 737 743 mode.is_sketch() || matches!(mode, Mode::Extrude(ExtrudeArming::Profile { .. })); 738 744 let confirm = 739 745 render_confirm_corner(ctx, viewport_rect, &self.ids, confirm_visible, &mut paints); 740 - let confirm_action = confirm; 746 + let confirm_action = confirm.or(pane.confirm); 741 747 let normal_to_available = active_sketch.is_some(); 742 748 let (view_pick, view_menu) = render_view_controls( 743 749 ctx, ··· 752 758 &mut paints, 753 759 &mut popover_paints, 754 760 ); 761 + let heads_up_action = crate::heads_up::render_heads_up_toolbar( 762 + ctx, 763 + viewport_rect, 764 + self.ids.heads_up, 765 + &mut paints, 766 + ); 767 + let menu_action = menu_action.or(heads_up_action); 755 768 let exit_sketch = confirm_action.is_some() || menu_action == Some(MenuAction::ExitSketch); 756 769 let activated_tool = activated_widget.and_then(|id| self.tool_index.get(&id).copied()); 757 770 let activated_feature_tool = ··· 1366 1379 selection: &[SketchEntityId], 1367 1380 sketch_disabled: bool, 1368 1381 ) -> ToolbarItem { 1369 - let item = ToolbarItem::new(id, strings::TOOL_SMART_DIMENSION); 1382 + let item = ToolbarItem::new(id, strings::TOOL_SMART_DIMENSION) 1383 + .with_icon(RibbonIconSize::Large.slot(IconId::SmartDimension)); 1370 1384 if sketch_disabled { 1371 1385 return item.disabled(true); 1372 1386 } ··· 1401 1415 let response = show_menu_bar( 1402 1416 ctx, 1403 1417 MenuBar::new(ids.menu_bar, rect, strings::MENU_BAR_LABEL, &entries, state) 1404 - .with_trailing_label(LabelText::Owned(document.name().to_owned())), 1418 + .with_document_label(LabelText::Owned(document.name().to_owned())), 1405 1419 ); 1406 1420 paints.extend(response.paint); 1407 1421 popover_paints.extend(response.popover_paint); ··· 1606 1620 .map(|t| { 1607 1621 size_item( 1608 1622 ToolbarItem::new(tool_widget_id(ribbon, t), tool_label(t)) 1623 + .with_icon(RibbonIconSize::Large.slot(tool_icon(t))) 1609 1624 .active(active_tool == Some(t)) 1610 1625 .disabled(tools_disabled), 1611 1626 large_min, ··· 1674 1689 small_min: LayoutPx, 1675 1690 overflow_open: &BTreeMap<WidgetId, bool>, 1676 1691 ) -> Vec<RibbonGroup> { 1677 - let dimensions_preferred = group_width_for(&dimension_items, large_min); 1692 + let large_rows = RibbonIconSize::Large.rows(); 1693 + let small_rows = RibbonIconSize::Small.rows(); 1694 + let dimensions_preferred = group_width_for(&dimension_items, large_min, large_rows); 1695 + let relations_width = group_width_for(&relation_items, small_min, small_rows); 1678 1696 let entities_id = ribbon.child(WidgetKey::new("group.entities")); 1679 1697 let relations_id = ribbon.child(WidgetKey::new("group.relations")); 1680 1698 let dimensions_id = ribbon.child(WidgetKey::new("group.dimensions")); ··· 1684 1702 id: entities_id, 1685 1703 label: strings::RIBBON_GROUP_ENTITIES, 1686 1704 min_width: group_min_width(large_min, entity_items.len()), 1687 - width: group_width_for(&entity_items, large_min), 1705 + width: group_width_for(&entity_items, large_min, large_rows), 1688 1706 items: entity_items, 1689 1707 icon_size: RibbonIconSize::Large, 1690 1708 overflow_open: open_of(entities_id), ··· 1693 1711 RibbonGroup { 1694 1712 id: relations_id, 1695 1713 label: strings::RIBBON_GROUP_RELATIONS, 1696 - min_width: group_min_width(small_min, relation_items.len()), 1697 - width: group_width_for(&relation_items, small_min), 1714 + min_width: relations_width, 1715 + width: relations_width, 1698 1716 items: relation_items, 1699 1717 icon_size: RibbonIconSize::Small, 1700 1718 overflow_open: open_of(relations_id), ··· 1724 1742 id: extrude_id, 1725 1743 label: strings::RIBBON_GROUP_EXTRUDE, 1726 1744 min_width: group_min_width(large_min, feature_items.len()), 1727 - width: group_width_for(&feature_items, large_min), 1745 + width: group_width_for(&feature_items, large_min, RibbonIconSize::Large.rows()), 1728 1746 items: feature_items, 1729 1747 icon_size: RibbonIconSize::Large, 1730 1748 overflow_open: overflow_open.get(&extrude_id).copied().unwrap_or(false), ··· 1799 1817 .map(|(sketch_id, _)| { 1800 1818 let widget_id = sketch_widget_id(part_id, sketch_id); 1801 1819 let label = document.sketch_label(sketch_id).unwrap_or("").to_owned(); 1802 - let node = TreeNode::leaf_owned(widget_id, label).with_glyph(GlyphMark::TreeSketch); 1820 + let node = TreeNode::leaf_owned(widget_id, label).with_icon(IconId::TreeSketch); 1803 1821 (sketch_id, widget_id, node) 1804 1822 }) 1805 1823 .collect() ··· 1819 1837 .map(|extrude_id| { 1820 1838 let widget_id = extrude_widget_id(part_id, extrude_id); 1821 1839 let label = document.extrude_label(extrude_id).unwrap_or("").to_owned(); 1822 - let node = TreeNode::leaf_owned(widget_id, label).with_glyph(GlyphMark::TreeFeature); 1840 + let node = TreeNode::leaf_owned(widget_id, label).with_icon(IconId::TreeFeature); 1823 1841 (extrude_id, widget_id, node) 1824 1842 }) 1825 1843 .collect() ··· 1847 1865 TreeNode::leaf(part_id.child(WidgetKey::new(key)), label) 1848 1866 }; 1849 1867 let feature_leaf = 1850 - |key: &'static str, label: StringKey| leaf(key, label).with_glyph(GlyphMark::TreeFeature); 1868 + |key: &'static str, label: StringKey| leaf(key, label).with_icon(IconId::TreeFeature); 1851 1869 let placeholder = |key: &'static str, label: StringKey| feature_leaf(key, label).disabled(true); 1852 1870 let plane_leaf = 1853 - |key: &'static str, label: StringKey| leaf(key, label).with_glyph(GlyphMark::TreePlane); 1871 + |key: &'static str, label: StringKey| leaf(key, label).with_icon(IconId::TreePlane); 1854 1872 let sketch_rows = sketch_tree_rows(document, part_id); 1855 1873 let extrude_rows = extrude_tree_rows(document, part_id); 1856 1874 let renamable: Vec<WidgetId> = sketch_rows ··· 1871 1889 plane_leaf("plane.xy", strings::FEATURE_PLANE_XY), 1872 1890 plane_leaf("plane.yz", strings::FEATURE_PLANE_YZ), 1873 1891 plane_leaf("plane.zx", strings::FEATURE_PLANE_ZX), 1874 - leaf("origin", strings::FEATURE_ORIGIN).with_glyph(GlyphMark::RadioDot), 1892 + leaf("origin", strings::FEATURE_ORIGIN).with_icon(IconId::TreeOrigin), 1875 1893 ] 1876 1894 .into_iter() 1877 1895 .chain(sketch_rows.into_iter().map(|(_, _, node)| node)) ··· 1936 1954 struct PropertyPaneOutcome { 1937 1955 dimension_edit: Option<DimensionEdit>, 1938 1956 extrude_edit: Option<ExtrudeEdit>, 1957 + confirm: Option<ConfirmAction>, 1939 1958 } 1940 1959 1941 1960 struct PaneEditors<'a> { 1942 1961 dim: &'a mut Option<DimPropertyEditor>, 1943 1962 extrude: &'a mut Option<ExtrudePropertyEditor>, 1963 + groups: &'a mut BTreeMap<WidgetId, PanelState>, 1944 1964 } 1945 1965 1946 1966 fn render_property_pane( ··· 1969 1989 } 1970 1990 if let Mode::Extrude(arming) = state.mode { 1971 1991 return match arming { 1972 - ExtrudeArming::Profile { feature, .. } => PropertyPaneOutcome { 1973 - dimension_edit: None, 1974 - extrude_edit: render_extrude_rows( 1975 - ctx, 1976 - rect, 1977 - id, 1978 - clipboard, 1979 - editors.extrude, 1980 - *feature, 1981 - paints, 1982 - ), 1983 - }, 1992 + ExtrudeArming::Profile { feature, .. } => { 1993 + let outcome = 1994 + render_extrude_rows(ctx, rect, id, clipboard, editors, *feature, paints); 1995 + PropertyPaneOutcome { 1996 + dimension_edit: None, 1997 + extrude_edit: outcome.edit, 1998 + confirm: outcome.confirm, 1999 + } 2000 + } 1984 2001 ExtrudeArming::AwaitingSketch => { 1985 2002 let mut editors = vec![row_editor(strings::EXTRUDE_PROMPT_SELECT_SKETCH, "")]; 1986 2003 render_static_rows(ctx, rect, id, clipboard, &mut editors, paints); ··· 2017 2034 paints, 2018 2035 ), 2019 2036 extrude_edit: None, 2037 + confirm: None, 2020 2038 } 2021 2039 } 2022 2040 None => { ··· 2038 2056 slot.insert(editor) 2039 2057 } 2040 2058 2059 + const PM_HEADER_HEIGHT: f32 = 44.0; 2060 + const PM_GROUP_TITLE_HEIGHT: f32 = 22.0; 2061 + const PM_GROUP_ROW_HEIGHT: f32 = 22.0; 2062 + const PM_GROUP_GAP: f32 = 6.0; 2063 + 2064 + struct ExtrudeRowsOutcome { 2065 + edit: Option<ExtrudeEdit>, 2066 + confirm: Option<ConfirmAction>, 2067 + } 2068 + 2069 + #[derive(Copy, Clone)] 2070 + struct PropertyGroupSpec { 2071 + id: WidgetId, 2072 + title: StringKey, 2073 + top_left: LayoutPos, 2074 + width: LayoutPx, 2075 + } 2076 + 2077 + fn extrude_row_id(key: &'static str) -> WidgetId { 2078 + WidgetId::ROOT 2079 + .child(WidgetKey::new("props.extrude")) 2080 + .child(WidgetKey::new(key)) 2081 + } 2082 + 2083 + fn direction_group_rows(editor: &mut ExtrudePropertyEditor) -> Vec<PropertyRow<'_>> { 2084 + vec![ 2085 + PropertyRow { 2086 + id: extrude_row_id("end"), 2087 + label: strings::PROPERTY_ROW_EXTRUDE_END, 2088 + editor: &mut editor.end_condition, 2089 + read_only: false, 2090 + }, 2091 + PropertyRow { 2092 + id: extrude_row_id("depth"), 2093 + label: strings::PROPERTY_ROW_EXTRUDE_DEPTH, 2094 + editor: &mut editor.depth, 2095 + read_only: false, 2096 + }, 2097 + PropertyRow { 2098 + id: extrude_row_id("draft"), 2099 + label: strings::PROPERTY_ROW_EXTRUDE_DRAFT, 2100 + editor: &mut editor.draft_enabled, 2101 + read_only: true, 2102 + }, 2103 + PropertyRow { 2104 + id: extrude_row_id("draft_angle"), 2105 + label: strings::PROPERTY_ROW_EXTRUDE_DRAFT_ANGLE, 2106 + editor: &mut editor.draft_angle, 2107 + read_only: true, 2108 + }, 2109 + PropertyRow { 2110 + id: extrude_row_id("direction_two"), 2111 + label: strings::PROPERTY_ROW_EXTRUDE_DIRECTION_TWO, 2112 + editor: &mut editor.direction_two, 2113 + read_only: true, 2114 + }, 2115 + ] 2116 + } 2117 + 2118 + fn scope_group_rows(editor: &mut ExtrudePropertyEditor) -> Vec<PropertyRow<'_>> { 2119 + vec![ 2120 + PropertyRow { 2121 + id: extrude_row_id("thin"), 2122 + label: strings::PROPERTY_ROW_EXTRUDE_THIN, 2123 + editor: &mut editor.thin, 2124 + read_only: true, 2125 + }, 2126 + PropertyRow { 2127 + id: extrude_row_id("merge"), 2128 + label: strings::PROPERTY_ROW_EXTRUDE_MERGE, 2129 + editor: &mut editor.merge, 2130 + read_only: false, 2131 + }, 2132 + ] 2133 + } 2134 + 2135 + fn render_property_group( 2136 + ctx: &mut FrameCtx<'_>, 2137 + clipboard: &mut MemoryClipboard, 2138 + groups: &mut BTreeMap<WidgetId, PanelState>, 2139 + spec: PropertyGroupSpec, 2140 + rows: &mut Vec<PropertyRow<'_>>, 2141 + paints: &mut Vec<WidgetPaint>, 2142 + ) -> (LayoutPx, Vec<WidgetId>) { 2143 + let collapsed = groups.get(&spec.id).is_some_and(|s| s.collapsed); 2144 + #[allow( 2145 + clippy::cast_precision_loss, 2146 + reason = "property row counts fit the f32 mantissa" 2147 + )] 2148 + let body_height = if collapsed { 2149 + 0.0 2150 + } else { 2151 + rows.len() as f32 * PM_GROUP_ROW_HEIGHT 2152 + }; 2153 + let group_rect = LayoutRect::new( 2154 + spec.top_left, 2155 + LayoutSize::new( 2156 + spec.width, 2157 + LayoutPx::new(PM_GROUP_TITLE_HEIGHT + body_height), 2158 + ), 2159 + ); 2160 + let state = groups.entry(spec.id).or_default(); 2161 + let response = show_panel( 2162 + ctx, 2163 + Panel::new(spec.id, group_rect, state) 2164 + .variant(PanelVariant::Card) 2165 + .titlebar(PanelTitlebar { 2166 + label: spec.title, 2167 + height: LayoutPx::new(PM_GROUP_TITLE_HEIGHT), 2168 + collapsible: true, 2169 + }), 2170 + ); 2171 + paints.extend(response.paint); 2172 + let changed = match response.body_rect { 2173 + Some(body) => { 2174 + let grid = show_property_grid( 2175 + ctx, 2176 + PropertyGrid::new( 2177 + spec.id.child(WidgetKey::new("grid")), 2178 + body, 2179 + spec.title, 2180 + rows, 2181 + ), 2182 + clipboard, 2183 + ); 2184 + paints.extend(grid.paint); 2185 + grid.changed_rows 2186 + } 2187 + None => Vec::new(), 2188 + }; 2189 + let next_y = 2190 + LayoutPx::new(group_rect.origin.y.value() + group_rect.size.height.value() + PM_GROUP_GAP); 2191 + (next_y, changed) 2192 + } 2193 + 2194 + fn extrude_pane_header( 2195 + ctx: &mut FrameCtx<'_>, 2196 + id: WidgetId, 2197 + rect: LayoutRect, 2198 + ) -> (Option<ConfirmAction>, Vec<WidgetPaint>) { 2199 + let header_id = id.child(WidgetKey::new("header")); 2200 + let header = show_property_pane_header( 2201 + ctx, 2202 + PropertyPaneHeader { 2203 + id: header_id, 2204 + rect: LayoutRect::new( 2205 + rect.origin, 2206 + LayoutSize::new(rect.size.width, LayoutPx::new(PM_HEADER_HEIGHT)), 2207 + ), 2208 + title: strings::PROPERTY_PANE_EXTRUDE_TITLE, 2209 + accept_id: header_id.child(WidgetKey::new("accept")), 2210 + cancel_id: header_id.child(WidgetKey::new("cancel")), 2211 + }, 2212 + ); 2213 + let confirm = match header.action { 2214 + Some(PropertyPaneAction::Accept) => Some(ConfirmAction::Accept), 2215 + Some(PropertyPaneAction::Cancel) => Some(ConfirmAction::Cancel), 2216 + None => None, 2217 + }; 2218 + (confirm, header.paint) 2219 + } 2220 + 2041 2221 fn render_extrude_rows( 2042 2222 ctx: &mut FrameCtx<'_>, 2043 2223 rect: LayoutRect, 2044 2224 id: WidgetId, 2045 2225 clipboard: &mut MemoryClipboard, 2046 - extrude_property: &mut Option<ExtrudePropertyEditor>, 2226 + editors: &mut PaneEditors<'_>, 2047 2227 feature: ExtrudeFeature, 2048 2228 paints: &mut Vec<WidgetPaint>, 2049 - ) -> Option<ExtrudeEdit> { 2050 - let editor = sync_extrude_editor(extrude_property, feature); 2051 - let row = |key: &'static str| { 2052 - WidgetId::ROOT 2053 - .child(WidgetKey::new("props.extrude")) 2054 - .child(WidgetKey::new(key)) 2229 + ) -> ExtrudeRowsOutcome { 2230 + ctx.a11y.push( 2231 + id, 2232 + rect, 2233 + AccessNode::new(Role::Form).with_label(strings::PROPERTY_PANE_LABEL), 2234 + ); 2235 + let (confirm, header_paint) = extrude_pane_header(ctx, id, rect); 2236 + paints.extend(header_paint); 2237 + 2238 + let editor = sync_extrude_editor(editors.extrude, feature); 2239 + let groups_top = LayoutPx::new(rect.origin.y.value() + PM_HEADER_HEIGHT + PM_GROUP_GAP); 2240 + let mut changed: Vec<WidgetId> = Vec::new(); 2241 + let scope_top = { 2242 + let mut rows = direction_group_rows(editor); 2243 + let (next_y, ch) = render_property_group( 2244 + ctx, 2245 + clipboard, 2246 + editors.groups, 2247 + PropertyGroupSpec { 2248 + id: id.child(WidgetKey::new("group.direction1")), 2249 + title: strings::PROPERTY_GROUP_DIRECTION_1, 2250 + top_left: LayoutPos::new(rect.origin.x, groups_top), 2251 + width: rect.size.width, 2252 + }, 2253 + &mut rows, 2254 + paints, 2255 + ); 2256 + changed.extend(ch); 2257 + next_y 2055 2258 }; 2056 - let end_id = row("end"); 2057 - let depth_id = row("depth"); 2058 - let draft_id = row("draft"); 2059 - let draft_angle_id = row("draft_angle"); 2060 - let direction_two_id = row("direction_two"); 2061 - let thin_id = row("thin"); 2062 - let merge_id = row("merge"); 2063 - let changed = { 2064 - let mut rows = vec![ 2065 - PropertyRow { 2066 - id: end_id, 2067 - label: strings::PROPERTY_ROW_EXTRUDE_END, 2068 - editor: &mut editor.end_condition, 2069 - read_only: false, 2070 - }, 2071 - PropertyRow { 2072 - id: depth_id, 2073 - label: strings::PROPERTY_ROW_EXTRUDE_DEPTH, 2074 - editor: &mut editor.depth, 2075 - read_only: false, 2076 - }, 2077 - PropertyRow { 2078 - id: draft_id, 2079 - label: strings::PROPERTY_ROW_EXTRUDE_DRAFT, 2080 - editor: &mut editor.draft_enabled, 2081 - read_only: true, 2082 - }, 2083 - PropertyRow { 2084 - id: draft_angle_id, 2085 - label: strings::PROPERTY_ROW_EXTRUDE_DRAFT_ANGLE, 2086 - editor: &mut editor.draft_angle, 2087 - read_only: true, 2088 - }, 2089 - PropertyRow { 2090 - id: direction_two_id, 2091 - label: strings::PROPERTY_ROW_EXTRUDE_DIRECTION_TWO, 2092 - editor: &mut editor.direction_two, 2093 - read_only: true, 2094 - }, 2095 - PropertyRow { 2096 - id: thin_id, 2097 - label: strings::PROPERTY_ROW_EXTRUDE_THIN, 2098 - editor: &mut editor.thin, 2099 - read_only: true, 2100 - }, 2101 - PropertyRow { 2102 - id: merge_id, 2103 - label: strings::PROPERTY_ROW_EXTRUDE_MERGE, 2104 - editor: &mut editor.merge, 2105 - read_only: false, 2106 - }, 2107 - ]; 2108 - let response = show_property_grid( 2259 + { 2260 + let mut rows = scope_group_rows(editor); 2261 + let (_next_y, ch) = render_property_group( 2109 2262 ctx, 2110 - PropertyGrid::new(id, rect, strings::PROPERTY_PANE_LABEL, &mut rows), 2111 2263 clipboard, 2264 + editors.groups, 2265 + PropertyGroupSpec { 2266 + id: id.child(WidgetKey::new("group.feature_scope")), 2267 + title: strings::PROPERTY_GROUP_FEATURE_SCOPE, 2268 + top_left: LayoutPos::new(rect.origin.x, scope_top), 2269 + width: rect.size.width, 2270 + }, 2271 + &mut rows, 2272 + paints, 2112 2273 ); 2113 - paints.extend(response.paint); 2114 - response.changed_rows 2115 - }; 2116 - if changed.contains(&end_id) { 2274 + changed.extend(ch); 2275 + } 2276 + 2277 + let edit = if changed.contains(&extrude_row_id("end")) { 2117 2278 kind_from_index(editor.end_condition.current).map(ExtrudeEdit::EndCondition) 2118 - } else if changed.contains(&depth_id) { 2279 + } else if changed.contains(&extrude_row_id("depth")) { 2119 2280 PositiveLength::new(editor.depth.value) 2120 2281 .ok() 2121 2282 .map(ExtrudeEdit::Depth) 2122 - } else if changed.contains(&merge_id) { 2283 + } else if changed.contains(&extrude_row_id("merge")) { 2123 2284 Some(ExtrudeEdit::Merge(if editor.merge.value { 2124 2285 MergeResult::Merge 2125 2286 } else { ··· 2127 2288 })) 2128 2289 } else { 2129 2290 None 2130 - } 2291 + }; 2292 + ExtrudeRowsOutcome { edit, confirm } 2131 2293 } 2132 2294 2133 2295 enum SelectionTarget { ··· 2728 2890 LayoutPx::new(est.max(min_width.value())) 2729 2891 } 2730 2892 2731 - fn group_width_for(items: &[ToolbarItem], fallback_item_size: LayoutPx) -> LayoutPx { 2732 - let total: f32 = items 2733 - .iter() 2734 - .enumerate() 2735 - .map(|(i, it)| { 2736 - it.width.unwrap_or(fallback_item_size).value() 2737 - + if i == 0 { 0.0 } else { RIBBON_TOOLBAR_GAP_PX } 2738 - }) 2893 + fn group_width_for(items: &[ToolbarItem], fallback_item_size: LayoutPx, rows: usize) -> LayoutPx { 2894 + let rows = rows.max(1); 2895 + let col_width = |col: usize| -> f32 { 2896 + (0..rows) 2897 + .filter_map(|r| items.get(col * rows + r)) 2898 + .map(|it| it.width.unwrap_or(fallback_item_size).value()) 2899 + .fold(0.0_f32, f32::max) 2900 + }; 2901 + let total: f32 = (0..items.len().div_ceil(rows)) 2902 + .map(|col| col_width(col) + if col == 0 { 0.0 } else { RIBBON_TOOLBAR_GAP_PX }) 2739 2903 .sum(); 2740 2904 LayoutPx::new(total + 2.0 * RIBBON_GROUP_PADDING_PX) 2741 2905 } ··· 2769 2933 selection: &[SketchEntityId], 2770 2934 sketch_disabled: bool, 2771 2935 ) -> ToolbarItem { 2772 - let item = ToolbarItem::new(relation_widget_id(ribbon, kind), kind.label()); 2936 + let item = ToolbarItem::new(relation_widget_id(ribbon, kind), kind.label()) 2937 + .with_icon(RibbonIconSize::Small.slot(kind.icon())); 2773 2938 if sketch_disabled { 2774 2939 return item.disabled(true); 2775 2940 } ··· 2840 3005 } 2841 3006 } 2842 3007 3008 + const fn tool_icon(tool: SketchTool) -> IconId { 3009 + match tool { 3010 + SketchTool::Point => IconId::Point, 3011 + SketchTool::Line => IconId::Line, 3012 + SketchTool::CenterpointArc => IconId::CenterpointArc, 3013 + SketchTool::TangentArc => IconId::TangentArc, 3014 + SketchTool::ThreePointArc => IconId::ThreePointArc, 3015 + SketchTool::Circle => IconId::Circle, 3016 + SketchTool::PerimeterCircle => IconId::PerimeterCircle, 3017 + SketchTool::CornerRectangle => IconId::CornerRectangle, 3018 + SketchTool::CenterRectangle => IconId::CenterRectangle, 3019 + SketchTool::ThreePointCornerRectangle => IconId::ThreePointCornerRectangle, 3020 + SketchTool::ThreePointCenterRectangle => IconId::ThreePointCenterRectangle, 3021 + SketchTool::Parallelogram => IconId::Parallelogram, 3022 + } 3023 + } 3024 + 2843 3025 fn build_feature_tool_index(ribbon: WidgetId) -> BTreeMap<WidgetId, FeatureTool> { 2844 3026 FeatureTool::ALL 2845 3027 .iter() ··· 2866 3048 } 2867 3049 } 2868 3050 3051 + const fn feature_tool_icon(tool: FeatureTool) -> IconId { 3052 + match tool { 3053 + FeatureTool::ExtrudedBossBase => IconId::ExtrudedBossBase, 3054 + FeatureTool::ExtrudedCut => IconId::ExtrudedCut, 3055 + } 3056 + } 3057 + 2869 3058 fn feature_tool_items( 2870 3059 ctx: &FrameCtx<'_>, 2871 3060 ribbon: WidgetId, ··· 2882 3071 .copied() 2883 3072 .map(|t| { 2884 3073 let base = ToolbarItem::new(feature_tool_widget_id(ribbon, t), feature_tool_label(t)) 3074 + .with_icon(RibbonIconSize::Large.slot(feature_tool_icon(t))) 2885 3075 .active(active == Some(t)); 2886 3076 let item = if mode.is_sketch() { 2887 3077 base.disabled(true) ··· 2991 3181 } 2992 3182 2993 3183 const MENU_BAR_HEIGHT_PX: f32 = 24.0; 2994 - const RIBBON_HEIGHT_PX: f32 = 96.0; 3184 + const RIBBON_HEIGHT_PX: f32 = 82.0; 2995 3185 const DOC_TABS_HEIGHT_PX: f32 = 22.0; 2996 3186 const STATUS_BAR_HEIGHT_PX: f32 = 22.0; 2997 3187 ··· 3125 3315 struct LeftPaneTabSpec { 3126 3316 id: WidgetId, 3127 3317 label: StringKey, 3128 - glyph: GlyphMark, 3318 + icon: IconId, 3129 3319 target: Option<LeftPane>, 3130 3320 } 3131 3321 ··· 3143 3333 LeftPaneTabSpec { 3144 3334 id: ids.left_pane_tab_tree, 3145 3335 label: strings::FEATURE_TREE_LABEL, 3146 - glyph: GlyphMark::TabTree, 3336 + icon: IconId::TabTree, 3147 3337 target: Some(LeftPane::Tree), 3148 3338 }, 3149 3339 LeftPaneTabSpec { 3150 3340 id: ids.left_pane_tab_properties, 3151 3341 label: strings::PROPERTY_PANE_LABEL, 3152 - glyph: GlyphMark::TabProperties, 3342 + icon: IconId::TabProperties, 3153 3343 target: Some(LeftPane::Properties), 3154 3344 }, 3155 3345 LeftPaneTabSpec { 3156 3346 id: ids.left_pane_tab_configuration, 3157 3347 label: strings::LEFT_PANE_TAB_CONFIGURATION, 3158 - glyph: GlyphMark::TabConfiguration, 3348 + icon: IconId::TabConfiguration, 3159 3349 target: None, 3160 3350 }, 3161 3351 LeftPaneTabSpec { 3162 3352 id: ids.left_pane_tab_dimension_expert, 3163 3353 label: strings::LEFT_PANE_TAB_DIMENSION_EXPERT, 3164 - glyph: GlyphMark::TabDimensionExpert, 3354 + icon: IconId::TabDimensionExpert, 3165 3355 target: None, 3166 3356 }, 3167 3357 LeftPaneTabSpec { 3168 3358 id: ids.left_pane_tab_display, 3169 3359 label: strings::LEFT_PANE_TAB_DISPLAY, 3170 - glyph: GlyphMark::TabDisplay, 3360 + icon: IconId::TabDisplay, 3171 3361 target: None, 3172 3362 }, 3173 3363 ]; ··· 3181 3371 *x += LEFT_PANE_TAB_WIDTH_PX; 3182 3372 Some( 3183 3373 Tab::new(spec.id, tab_rect, spec.label) 3184 - .with_glyph(spec.glyph) 3374 + .with_icon(spec.icon) 3185 3375 .disabled(spec.target.is_none()), 3186 3376 ) 3187 3377 }) ··· 3257 3447 ctx, 3258 3448 ids.confirm_accept, 3259 3449 accept_rect, 3260 - GlyphMark::Checkmark, 3450 + IconId::Check, 3261 3451 strings::CONFIRM_ACCEPT, 3262 3452 ConfirmTone::Accept, 3263 3453 paints, ··· 3266 3456 ctx, 3267 3457 ids.confirm_cancel, 3268 3458 cancel_rect, 3269 - GlyphMark::Close, 3459 + IconId::Cross, 3270 3460 strings::CONFIRM_CANCEL, 3271 3461 ConfirmTone::Cancel, 3272 3462 paints, ··· 3290 3480 ctx: &mut FrameCtx<'_>, 3291 3481 id: WidgetId, 3292 3482 rect: LayoutRect, 3293 - glyph: GlyphMark, 3483 + icon: IconId, 3294 3484 label: StringKey, 3295 3485 tone: ConfirmTone, 3296 3486 paints: &mut Vec<WidgetPaint>, ··· 3328 3518 radius: theme.radius.sm, 3329 3519 elevation: Some(theme.elevation.level3), 3330 3520 }); 3331 - paints.push(WidgetPaint::Mark { 3521 + paints.push(WidgetPaint::Icon { 3332 3522 rect, 3333 - kind: glyph, 3334 - color: glyph_color, 3523 + icon, 3524 + tint: IconTint::Solid(glyph_color), 3335 3525 }); 3336 3526 interaction.click() 3337 3527 } 3338 3528 3339 3529 fn build_dock_main(panels: ShellPanels) -> DockNode { 3340 - const LEFT_PANE_RATIO: SplitFraction = SplitFraction::clamped(0.22); 3530 + const LEFT_PANE_RATIO: SplitFraction = SplitFraction::clamped(0.12); 3341 3531 DockNode::split( 3342 3532 Axis::Horizontal, 3343 3533 LEFT_PANE_RATIO, ··· 3703 3893 let size = layout_size(1280.0, 800.0); 3704 3894 let sketch_view = render_with(Theme::light(), size, &document, &Mode::Idle); 3705 3895 assert!( 3706 - label_rect(&sketch_view.paints, strings::RIBBON_GROUP_EXTRUDE).is_none(), 3707 - "extrude group hidden while the sketch tab is active", 3896 + label_rect(&sketch_view.paints, strings::TOOL_EXTRUDED_BOSS_BASE).is_none(), 3897 + "extrude tools hidden while the sketch tab is active", 3708 3898 ); 3709 3899 assert!( 3710 - label_rect(&sketch_view.paints, strings::RIBBON_GROUP_ENTITIES).is_some(), 3711 - "sketch tab shows its entity group", 3900 + label_rect(&sketch_view.paints, strings::TOOL_POINT).is_some(), 3901 + "sketch tab shows its entity tools", 3712 3902 ); 3713 3903 3714 3904 let mut shell = Shell::new(); ··· 3722 3912 &Selection::default(), 3723 3913 ); 3724 3914 assert!( 3725 - label_rect(&features_view.paints, strings::RIBBON_GROUP_EXTRUDE).is_some(), 3726 - "selecting the features tab reveals the extrude group", 3915 + label_rect(&features_view.paints, strings::TOOL_EXTRUDED_BOSS_BASE).is_some(), 3916 + "selecting the features tab reveals the extrude tools", 3727 3917 ); 3728 3918 } 3729 3919 ··· 3951 4141 fn is_confirm_glyph(paint: &WidgetPaint) -> bool { 3952 4142 matches!( 3953 4143 paint, 3954 - WidgetPaint::Mark { kind, .. } if matches!(kind, bone_ui::widgets::GlyphMark::Checkmark | bone_ui::widgets::GlyphMark::Close) 4144 + WidgetPaint::Icon { icon, .. } if matches!(icon, IconId::Check | IconId::Cross) 3955 4145 ) 3956 4146 } 3957 4147 ··· 4118 4308 matches!( 4119 4309 p, 4120 4310 WidgetPaint::Label { text: LabelText::Key(key), .. } 4311 + | WidgetPaint::AlignedLabel { text: LabelText::Key(key), .. } 4121 4312 if *key == strings::TOOL_SMART_DIMENSION 4122 4313 ) 4123 4314 }); ··· 4125 4316 } 4126 4317 4127 4318 #[test] 4128 - fn smart_dimension_button_paints_even_in_narrow_ribbon() { 4129 - let frame = render_with( 4319 + fn smart_dimension_stays_reachable_in_a_narrow_ribbon() { 4320 + let mut shell = Shell::new(); 4321 + let dimensions_group = shell.ids.ribbon.child(WidgetKey::new("group.dimensions")); 4322 + shell 4323 + .state 4324 + .ribbon_overflow_open 4325 + .insert(dimensions_group, true); 4326 + let frame = render_into_shell( 4327 + &mut shell, 4130 4328 Theme::light(), 4131 4329 layout_size(800.0, 600.0), 4132 4330 &sample_document(), 4133 4331 &Mode::enter_sketch(SketchId::default()), 4332 + &Selection::default(), 4134 4333 ); 4135 - let any_smart_dim_label = frame.paints.iter().any(|p| { 4136 - matches!( 4137 - p, 4138 - WidgetPaint::Label { text: LabelText::Key(key), .. } 4139 - if *key == strings::TOOL_SMART_DIMENSION 4140 - ) 4141 - }); 4334 + let reachable = frame 4335 + .paints 4336 + .iter() 4337 + .chain(frame.overlay_paints.iter()) 4338 + .any(|p| { 4339 + matches!( 4340 + p, 4341 + WidgetPaint::Label { text: LabelText::Key(key), .. } 4342 + | WidgetPaint::AlignedLabel { text: LabelText::Key(key), .. } 4343 + if *key == strings::TOOL_SMART_DIMENSION 4344 + ) 4345 + }); 4142 4346 assert!( 4143 - any_smart_dim_label, 4144 - "Smart Dimension button must remain reachable on a narrow ribbon", 4347 + reachable, 4348 + "Smart Dimension must stay reachable on a narrow ribbon, inline or via its group overflow", 4145 4349 ); 4146 4350 } 4147 4351 ··· 4713 4917 assert!( 4714 4918 row_bottom <= 800.0, 4715 4919 "sketch row must fit within 800px tall window, row_bottom={row_bottom}", 4920 + ); 4921 + } 4922 + 4923 + #[test] 4924 + fn property_header_accept_emits_confirm_via_full_shell() { 4925 + use bone_ui::input::{PointerButton, PointerButtonMask, PointerSample}; 4926 + let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 4927 + let (document, sketch_id) = document_with_sketch(sketch); 4928 + let mode = Mode::Extrude(ExtrudeArming::profile(sketch_id)); 4929 + let mut shell = Shell::new(); 4930 + let accept_id = shell 4931 + .ids 4932 + .property_pane 4933 + .child(WidgetKey::new("header")) 4934 + .child(WidgetKey::new("accept")); 4935 + let mut focus = FocusManager::new(); 4936 + let mut prev = HitState::new(); 4937 + let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 4938 + let (_, hits) = shell_drive( 4939 + &mut shell, 4940 + &document, 4941 + &mode, 4942 + &Selection::default(), 4943 + &mut focus, 4944 + &mut prev, 4945 + &mut warm, 4946 + ); 4947 + let Some(rect) = hits 4948 + .items() 4949 + .iter() 4950 + .find(|item| item.id == accept_id) 4951 + .map(|item| item.rect) 4952 + else { 4953 + panic!("property header accept must register a hit item in the extrude pane"); 4954 + }; 4955 + let center = LayoutPos::new( 4956 + LayoutPx::new(rect.origin.x.value() + rect.size.width.value() / 2.0), 4957 + LayoutPx::new(rect.origin.y.value() + rect.size.height.value() / 2.0), 4958 + ); 4959 + let mut hover = InputSnapshot::idle(FrameInstant::ZERO); 4960 + hover.pointer = Some(PointerSample::new(center)); 4961 + let _ = drive_with_snap( 4962 + &mut shell, 4963 + &document, 4964 + &mode, 4965 + &Selection::default(), 4966 + &mut focus, 4967 + &mut prev, 4968 + hover, 4969 + ); 4970 + let mut press = InputSnapshot::idle(FrameInstant::ZERO); 4971 + press.pointer = Some(PointerSample::new(center)); 4972 + press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 4973 + let _ = drive_with_snap( 4974 + &mut shell, 4975 + &document, 4976 + &mode, 4977 + &Selection::default(), 4978 + &mut focus, 4979 + &mut prev, 4980 + press, 4981 + ); 4982 + let mut release = InputSnapshot::idle(FrameInstant::ZERO); 4983 + release.pointer = Some(PointerSample::new(center)); 4984 + release.buttons_released = PointerButtonMask::just(PointerButton::Primary); 4985 + let _ = drive_with_snap( 4986 + &mut shell, 4987 + &document, 4988 + &mode, 4989 + &Selection::default(), 4990 + &mut focus, 4991 + &mut prev, 4992 + release, 4993 + ); 4994 + let mut settle = InputSnapshot::idle(FrameInstant::ZERO); 4995 + settle.pointer = Some(PointerSample::new(center)); 4996 + let (frame, _) = drive_with_snap( 4997 + &mut shell, 4998 + &document, 4999 + &mode, 5000 + &Selection::default(), 5001 + &mut focus, 5002 + &mut prev, 5003 + settle, 5004 + ); 5005 + assert_eq!( 5006 + frame.confirm_action, 5007 + Some(ConfirmAction::Accept), 5008 + "clicking the property header check must accept like the confirm corner", 4716 5009 ); 4717 5010 } 4718 5011
+39
crates/bone-app/src/strings.rs
··· 30 30 pub const VIEW_CUBE_SET_HOME: StringKey = StringKey::new("view.cube.set_home"); 31 31 pub const VIEW_CUBE_FIT: StringKey = StringKey::new("view.cube.fit"); 32 32 pub const VIEW_CUBE_NORMAL_TO: StringKey = StringKey::new("view.cube.normal_to"); 33 + pub const HEADS_UP_BAR: StringKey = StringKey::new("heads_up.bar"); 34 + pub const HEADS_UP_ZOOM_FIT: StringKey = StringKey::new("heads_up.zoom_fit"); 35 + pub const HEADS_UP_ZOOM_AREA: StringKey = StringKey::new("heads_up.zoom_area"); 36 + pub const HEADS_UP_PREVIOUS_VIEW: StringKey = StringKey::new("heads_up.previous_view"); 37 + pub const HEADS_UP_SECTION_VIEW: StringKey = StringKey::new("heads_up.section_view"); 38 + pub const HEADS_UP_VIEW_ORIENTATION: StringKey = StringKey::new("heads_up.view_orientation"); 39 + pub const HEADS_UP_DISPLAY_STYLE: StringKey = StringKey::new("heads_up.display_style"); 40 + pub const HEADS_UP_HIDE_SHOW: StringKey = StringKey::new("heads_up.hide_show"); 41 + pub const HEADS_UP_EDIT_APPEARANCE: StringKey = StringKey::new("heads_up.edit_appearance"); 42 + pub const HEADS_UP_VIEW_SETTINGS: StringKey = StringKey::new("heads_up.view_settings"); 33 43 34 44 pub const TOOL_POINT: StringKey = StringKey::new("tool.point"); 35 45 pub const TOOL_LINE: StringKey = StringKey::new("tool.line"); ··· 252 262 StringKey::new("property.row.extrude.direction_two"); 253 263 pub const PROPERTY_ROW_EXTRUDE_THIN: StringKey = StringKey::new("property.row.extrude.thin"); 254 264 pub const PROPERTY_ROW_EXTRUDE_MERGE: StringKey = StringKey::new("property.row.extrude.merge"); 265 + pub const PROPERTY_PANE_EXTRUDE_TITLE: StringKey = StringKey::new("property.pane.extrude.title"); 266 + pub const PROPERTY_GROUP_DIRECTION_1: StringKey = StringKey::new("property.group.direction_1"); 267 + pub const PROPERTY_GROUP_FEATURE_SCOPE: StringKey = StringKey::new("property.group.feature_scope"); 255 268 pub const EXTRUDE_END_BLIND: StringKey = StringKey::new("extrude.end.blind"); 256 269 pub const EXTRUDE_END_MIDPLANE: StringKey = StringKey::new("extrude.end.midplane"); 257 270 pub const STATUS_EXTRUDE_VALID: StringKey = StringKey::new("status.extrude.valid"); ··· 363 376 (VIEW_CUBE_SET_HOME, "Set as Home"), 364 377 (VIEW_CUBE_FIT, "Fit to Window"), 365 378 (VIEW_CUBE_NORMAL_TO, "View Normal To"), 379 + (HEADS_UP_BAR, "View Heads-Up Toolbar"), 380 + (HEADS_UP_ZOOM_FIT, "Zoom to Fit"), 381 + (HEADS_UP_ZOOM_AREA, "Zoom to Area"), 382 + (HEADS_UP_PREVIOUS_VIEW, "Previous View"), 383 + (HEADS_UP_SECTION_VIEW, "Section View"), 384 + (HEADS_UP_VIEW_ORIENTATION, "View Orientation"), 385 + (HEADS_UP_DISPLAY_STYLE, "Display Style"), 386 + (HEADS_UP_HIDE_SHOW, "Hide/Show Items"), 387 + (HEADS_UP_EDIT_APPEARANCE, "Edit Appearance"), 388 + (HEADS_UP_VIEW_SETTINGS, "View Settings"), 366 389 (TOOL_POINT, "Point"), 367 390 (TOOL_LINE, "Line"), 368 391 (TOOL_CENTERPOINT_ARC, "Centerpoint Arc"), ··· 595 618 (PROPERTY_ROW_EXTRUDE_DIRECTION_TWO, "Direction 2"), 596 619 (PROPERTY_ROW_EXTRUDE_THIN, "Thin Feature"), 597 620 (PROPERTY_ROW_EXTRUDE_MERGE, "Merge Result"), 621 + (PROPERTY_PANE_EXTRUDE_TITLE, "Extrude"), 622 + (PROPERTY_GROUP_DIRECTION_1, "Direction 1"), 623 + (PROPERTY_GROUP_FEATURE_SCOPE, "Feature Scope"), 598 624 (EXTRUDE_END_BLIND, "Blind"), 599 625 (EXTRUDE_END_MIDPLANE, "Mid Plane"), 600 626 (STATUS_EXTRUDE_VALID, "Valid Solid"), ··· 711 737 (VIEW_CUBE_SET_HOME, "[!! Sêt as Hôme !!]"), 712 738 (VIEW_CUBE_FIT, "[!! Fît to Wîndow !!]"), 713 739 (VIEW_CUBE_NORMAL_TO, "[!! Vîew Nôrmal Tô !!]"), 740 + (HEADS_UP_BAR, "[!! Vîew Hêads-Up Tôolbar !!]"), 741 + (HEADS_UP_ZOOM_FIT, "[!! Zôom to Fît !!]"), 742 + (HEADS_UP_ZOOM_AREA, "[!! Zôom to Ârea !!]"), 743 + (HEADS_UP_PREVIOUS_VIEW, "[!! Prêvious Vîew !!]"), 744 + (HEADS_UP_SECTION_VIEW, "[!! Sêction Vîew !!]"), 745 + (HEADS_UP_VIEW_ORIENTATION, "[!! Vîew Orientâtion !!]"), 746 + (HEADS_UP_DISPLAY_STYLE, "[!! Dîsplay Stŷle !!]"), 747 + (HEADS_UP_HIDE_SHOW, "[!! Hîde/Shôw Îtems !!]"), 748 + (HEADS_UP_EDIT_APPEARANCE, "[!! Êdit Appêarance !!]"), 749 + (HEADS_UP_VIEW_SETTINGS, "[!! Vîew Sêttings !!]"), 714 750 (TOOL_POINT, "[!! Pôint !!]"), 715 751 (TOOL_LINE, "[!! Lîne !!]"), 716 752 (TOOL_CENTERPOINT_ARC, "[!! Cêntrepoint Arc !!]"), ··· 970 1006 (PROPERTY_ROW_EXTRUDE_DIRECTION_TWO, "[!! Dîrection 2 !!]"), 971 1007 (PROPERTY_ROW_EXTRUDE_THIN, "[!! Thîn Fêature !!]"), 972 1008 (PROPERTY_ROW_EXTRUDE_MERGE, "[!! Mêrge Rêsult !!]"), 1009 + (PROPERTY_PANE_EXTRUDE_TITLE, "[!! Éxtrude !!]"), 1010 + (PROPERTY_GROUP_DIRECTION_1, "[!! Dîrection 1 !!]"), 1011 + (PROPERTY_GROUP_FEATURE_SCOPE, "[!! Fêature Scôpe !!]"), 973 1012 (EXTRUDE_END_BLIND, "[!! Blînd !!]"), 974 1013 (EXTRUDE_END_MIDPLANE, "[!! Mîd Plâne !!]"), 975 1014 (STATUS_EXTRUDE_VALID, "[!! Vâlid Sôlid !!]"),
crates/bone-app/tests/goldens/view_cube_front_256.png

This is a binary file and will not be displayed.

crates/bone-app/tests/goldens/view_cube_front_dark_256.png

This is a binary file and will not be displayed.

crates/bone-app/tests/goldens/view_cube_iso_256.png

This is a binary file and will not be displayed.

crates/bone-app/tests/goldens/view_cube_iso_dark_256.png

This is a binary file and will not be displayed.

crates/bone-app/tests/goldens/view_cube_mid_256.png

This is a binary file and will not be displayed.

crates/bone-app/tests/goldens/view_cube_mid_dark_256.png

This is a binary file and will not be displayed.

+1
crates/bone-jig/Cargo.toml
··· 8 8 [dependencies] 9 9 bone-app = { workspace = true } 10 10 bone-render = { workspace = true } 11 + bone-types = { workspace = true } 11 12 bone-ui = { workspace = true } 12 13 13 14 accesskit = { workspace = true }
+11
crates/bone-jig/scenarios/parity_options_dialog.ron
··· 1 + Scenario( 2 + steps: [ 3 + Resize(width: 1920, height: 1080), 4 + Advance(2), 5 + Click(target: Label("Tools")), 6 + Click(target: Label("Options...")), 7 + Advance(2), 8 + Snapshot("options-dialog"), 9 + DumpTree("options-dialog"), 10 + ], 11 + )
+11
crates/bone-jig/scenarios/parity_property_manager.ron
··· 1 + Scenario( 2 + steps: [ 3 + Resize(width: 1920, height: 1080), 4 + Advance(2), 5 + Click(target: Label("Features")), 6 + Click(target: Label("Extruded Boss/Base")), 7 + Advance(2), 8 + Snapshot("property-manager"), 9 + DumpTree("property-manager"), 10 + ], 11 + )
+10
crates/bone-jig/scenarios/parity_shell.ron
··· 1 + Scenario( 2 + steps: [ 3 + Resize(width: 1920, height: 1080), 4 + Advance(2), 5 + Click(target: Label("Features")), 6 + Advance(2), 7 + Snapshot("shell"), 8 + DumpTree("shell"), 9 + ], 10 + )
+21
crates/bone-jig/scenarios/parity_sketch_entities.ron
··· 1 + Scenario( 2 + steps: [ 3 + Resize(width: 1920, height: 1080), 4 + Advance(2), 5 + Click(target: Label("Sketch1")), 6 + Click(target: Label("Sketch1")), 7 + Advance(2), 8 + Click(target: Label("Line")), 9 + Click(target: At(x: 800, y: 500)), 10 + Click(target: At(x: 1100, y: 500)), 11 + Key(code: Named(Escape)), 12 + Click(target: Label("Circle")), 13 + Click(target: At(x: 950, y: 650)), 14 + Click(target: At(x: 1020, y: 650)), 15 + Key(code: Named(Escape)), 16 + Key(code: Named(Escape)), 17 + Advance(2), 18 + Snapshot("sketch-entities"), 19 + DumpTree("sketch-entities"), 20 + ], 21 + )
+11
crates/bone-jig/scenarios/parity_sketch_mode.ron
··· 1 + Scenario( 2 + steps: [ 3 + Resize(width: 1920, height: 1080), 4 + Advance(2), 5 + Click(target: Label("Sketch1")), 6 + Click(target: Label("Sketch1")), 7 + Advance(2), 8 + Snapshot("sketch-mode"), 9 + DumpTree("sketch-mode"), 10 + ], 11 + )
+225
crates/bone-jig/src/baker.rs
··· 1 + use bone_render::{ 2 + AtlasGrid, AtlasPage, ClearColor, OffscreenContext, Result, SnapshotFrame, SolidRenderer, 3 + Style, TILE_PAD, ViewportExtent, ViewportPx, 4 + }; 5 + use bone_types::{DisplayMode, IconId}; 6 + 7 + use crate::icon::{IconModel, icon_camera, icon_style}; 8 + use crate::models::model; 9 + use crate::offscreen_context; 10 + 11 + pub const MASTER_TILE: u32 = 192; 12 + 13 + const SHADOW_OFFSET: usize = 7; 14 + const SHADOW_BLUR: usize = 9; 15 + const SHADOW_ALPHA: u32 = 90; 16 + const SHADOW_LEVEL: u32 = 10; 17 + 18 + #[derive(Clone, Debug)] 19 + pub struct BakedPage { 20 + pub page: AtlasPage, 21 + pub extent: ViewportExtent, 22 + pub rgba: Vec<u8>, 23 + } 24 + 25 + pub fn bake() -> Result<Vec<BakedPage>> { 26 + let ctx = offscreen_context(ViewportExtent::square(ViewportPx::new(MASTER_TILE)))?; 27 + let mut renderer = SolidRenderer::new(ctx.gpu(), ctx.color_format()); 28 + let master = to_usize(MASTER_TILE); 29 + let shadowed: Vec<(IconId, Vec<u8>)> = IconId::ALL 30 + .iter() 31 + .map(|&id| { 32 + let premul = render_master(&mut renderer, &ctx, &model(id))?; 33 + Ok((id, with_shadow(master, &premul))) 34 + }) 35 + .collect::<Result<Vec<_>>>()?; 36 + 37 + let grid = AtlasGrid::DEFAULT; 38 + Ok(AtlasPage::ALL 39 + .into_iter() 40 + .map(|page| { 41 + let page_side = to_usize(page.side_px()); 42 + let tiles: Vec<(IconId, Vec<u8>)> = shadowed 43 + .iter() 44 + .map(|(id, premul)| (*id, downsample(master, premul, page_side))) 45 + .collect(); 46 + let (width, height) = grid.pixel_size(page); 47 + BakedPage { 48 + page, 49 + extent: ViewportExtent::new(ViewportPx::new(width), ViewportPx::new(height)), 50 + rgba: pack(grid, page, &tiles), 51 + } 52 + }) 53 + .collect()) 54 + } 55 + 56 + fn render_master( 57 + renderer: &mut SolidRenderer, 58 + ctx: &OffscreenContext, 59 + m: &IconModel, 60 + ) -> Result<Vec<u8>> { 61 + let black = render_one(renderer, ctx, m, ClearColor::opaque(0.0, 0.0, 0.0))?; 62 + let white = render_one(renderer, ctx, m, ClearColor::opaque(1.0, 1.0, 1.0))?; 63 + Ok(black 64 + .rgba() 65 + .chunks_exact(4) 66 + .zip(white.rgba().chunks_exact(4)) 67 + .flat_map(|(dark, light)| { 68 + let drop = 69 + |channel: usize| u32::from(light[channel]).saturating_sub(u32::from(dark[channel])); 70 + let coverage_drop = (drop(0) + drop(1) + drop(2)) / 3; 71 + let alpha = u8_of(255u32.saturating_sub(coverage_drop)); 72 + [dark[0], dark[1], dark[2], alpha] 73 + }) 74 + .collect()) 75 + } 76 + 77 + fn render_one( 78 + renderer: &mut SolidRenderer, 79 + ctx: &OffscreenContext, 80 + m: &IconModel, 81 + background: ClearColor, 82 + ) -> Result<SnapshotFrame> { 83 + let (scene, edges) = m.tessellate(); 84 + let style: Style = icon_style().with_background(background); 85 + renderer.render_display( 86 + ctx, 87 + &scene, 88 + &edges, 89 + icon_camera(m.view()), 90 + &style, 91 + DisplayMode::ShadedWithEdges, 92 + ) 93 + } 94 + 95 + fn with_shadow(side: usize, icon: &[u8]) -> Vec<u8> { 96 + let alpha: Vec<u8> = icon.chunks_exact(4).map(|px| px[3]).collect(); 97 + let blurred = box_blur(side, &alpha, SHADOW_BLUR); 98 + icon.chunks_exact(4) 99 + .enumerate() 100 + .flat_map(|(index, top)| { 101 + let x = index % side; 102 + let y = index / side; 103 + let source = if y >= SHADOW_OFFSET { 104 + u32::from(blurred[(y - SHADOW_OFFSET) * side + x]) 105 + } else { 106 + 0 107 + }; 108 + let shadow_alpha = source * SHADOW_ALPHA / 255; 109 + let shadow_rgb = u8_of(SHADOW_LEVEL * shadow_alpha / 255); 110 + let shadow = [shadow_rgb, shadow_rgb, shadow_rgb, u8_of(shadow_alpha)]; 111 + over([top[0], top[1], top[2], top[3]], shadow) 112 + }) 113 + .collect() 114 + } 115 + 116 + fn box_blur(side: usize, src: &[u8], radius: usize) -> Vec<u8> { 117 + let horizontal = blur_axis(side, src, radius, true); 118 + blur_axis(side, &horizontal, radius, false) 119 + } 120 + 121 + fn blur_axis(side: usize, src: &[u8], radius: usize, horizontal: bool) -> Vec<u8> { 122 + (0..side) 123 + .flat_map(|y| (0..side).map(move |x| (x, y))) 124 + .map(|(x, y)| { 125 + let center = if horizontal { x } else { y }; 126 + let lo = center.saturating_sub(radius); 127 + let hi = (center + radius).min(side - 1); 128 + let (sum, count) = (lo..=hi).fold((0u32, 0u32), |(sum, count), p| { 129 + let (sx, sy) = if horizontal { (p, y) } else { (x, p) }; 130 + (sum + u32::from(src[sy * side + sx]), count + 1) 131 + }); 132 + div_round(sum, count) 133 + }) 134 + .collect() 135 + } 136 + 137 + fn downsample(master_side: usize, master: &[u8], page_side: usize) -> Vec<u8> { 138 + assert_eq!( 139 + master_side % page_side, 140 + 0, 141 + "master tile must divide evenly into each atlas page side", 142 + ); 143 + let factor = master_side / page_side; 144 + let area = to_u32(factor * factor); 145 + (0..page_side) 146 + .flat_map(|oy| (0..page_side).map(move |ox| (ox, oy))) 147 + .flat_map(|(ox, oy)| { 148 + let sums = (0..factor) 149 + .flat_map(|dy| (0..factor).map(move |dx| (dx, dy))) 150 + .fold([0u32; 4], |mut acc, (dx, dy)| { 151 + let base = ((oy * factor + dy) * master_side + (ox * factor + dx)) * 4; 152 + acc[0] += u32::from(master[base]); 153 + acc[1] += u32::from(master[base + 1]); 154 + acc[2] += u32::from(master[base + 2]); 155 + acc[3] += u32::from(master[base + 3]); 156 + acc 157 + }); 158 + [ 159 + div_round(sums[0], area), 160 + div_round(sums[1], area), 161 + div_round(sums[2], area), 162 + div_round(sums[3], area), 163 + ] 164 + }) 165 + .collect() 166 + } 167 + 168 + fn pack(grid: AtlasGrid, page: AtlasPage, tiles: &[(IconId, Vec<u8>)]) -> Vec<u8> { 169 + let (width, height) = grid.pixel_size(page); 170 + let width = to_usize(width); 171 + let height = to_usize(height); 172 + let page_side = to_usize(page.side_px()); 173 + let cell = to_usize(page.cell_px()); 174 + let pad = to_usize(TILE_PAD); 175 + tiles 176 + .iter() 177 + .fold(vec![0u8; width * height * 4], |mut atlas, (id, tile)| { 178 + let grid_cell = grid.cell(id.tile()); 179 + let cell_x = grid_cell.col * cell; 180 + let cell_y = grid_cell.row * cell; 181 + (0..cell).for_each(|ly| { 182 + (0..cell).for_each(|lx| { 183 + let sx = lx.saturating_sub(pad).min(page_side - 1); 184 + let sy = ly.saturating_sub(pad).min(page_side - 1); 185 + let src = (sy * page_side + sx) * 4; 186 + let dst = ((cell_y + ly) * width + (cell_x + lx)) * 4; 187 + atlas[dst..dst + 4].copy_from_slice(&tile[src..src + 4]); 188 + }); 189 + }); 190 + atlas 191 + }) 192 + } 193 + 194 + fn over(top: [u8; 4], bottom: [u8; 4]) -> [u8; 4] { 195 + let inverse = 255 - u32::from(top[3]); 196 + let blend = |channel: usize| { 197 + u8_of(u32::from(top[channel]) + (inverse * u32::from(bottom[channel]) + 127) / 255) 198 + }; 199 + [blend(0), blend(1), blend(2), blend(3)] 200 + } 201 + 202 + fn div_round(sum: u32, count: u32) -> u8 { 203 + u8_of((sum + count / 2) / count) 204 + } 205 + 206 + fn u8_of(value: u32) -> u8 { 207 + let Ok(byte) = u8::try_from(value.min(255)) else { 208 + panic!("value clamped to 255 fits a u8"); 209 + }; 210 + byte 211 + } 212 + 213 + fn to_usize(value: u32) -> usize { 214 + let Ok(out) = usize::try_from(value) else { 215 + panic!("atlas dimension fits usize"); 216 + }; 217 + out 218 + } 219 + 220 + fn to_u32(value: usize) -> u32 { 221 + let Ok(out) = u32::try_from(value) else { 222 + panic!("atlas count fits u32"); 223 + }; 224 + out 225 + }
+753
crates/bone-jig/src/icon.rs
··· 1 + use bone_render::{EdgeScene, GenuineEdge, PickId, SolidScene, Style}; 2 + use bone_types::{ 3 + Angle, AxisAngle, Camera3, CreaseAngle, Length, LinearRgba, Point3, Projection, Tolerance, 4 + UnitVec3, millimeter, radian, 5 + }; 6 + 7 + const TOL: Tolerance = Tolerance::new(1.0e-9); 8 + const RIGHT_ANGLE: CreaseAngle = CreaseAngle::from_radians(core::f64::consts::FRAC_PI_2); 9 + const CYL_SEGMENTS: u32 = 20; 10 + const TUBE_SEGMENTS: u32 = 14; 11 + const SPHERE_LON: u32 = 20; 12 + const SPHERE_LAT: u32 = 14; 13 + 14 + type V3 = [f64; 3]; 15 + 16 + #[derive(Copy, Clone, Debug, PartialEq)] 17 + pub struct IconMaterial { 18 + pub color: LinearRgba, 19 + } 20 + 21 + impl IconMaterial { 22 + #[must_use] 23 + pub const fn new(color: LinearRgba) -> Self { 24 + Self { color } 25 + } 26 + } 27 + 28 + #[derive(Copy, Clone, Debug, PartialEq)] 29 + pub struct Rotation { 30 + axis: UnitVec3, 31 + radians: f64, 32 + } 33 + 34 + impl Rotation { 35 + #[must_use] 36 + pub fn identity() -> Self { 37 + Self { 38 + axis: UnitVec3::z_axis(), 39 + radians: 0.0, 40 + } 41 + } 42 + 43 + #[must_use] 44 + pub fn about(axis: UnitVec3, angle: Angle) -> Self { 45 + Self { 46 + axis, 47 + radians: angle.get::<radian>(), 48 + } 49 + } 50 + 51 + #[must_use] 52 + pub fn about_x(angle: Angle) -> Self { 53 + Self::about(UnitVec3::x_axis(), angle) 54 + } 55 + 56 + #[must_use] 57 + pub fn about_y(angle: Angle) -> Self { 58 + Self::about(UnitVec3::y_axis(), angle) 59 + } 60 + 61 + #[must_use] 62 + pub fn about_z(angle: Angle) -> Self { 63 + Self::about(UnitVec3::z_axis(), angle) 64 + } 65 + 66 + fn axis_angle(self) -> AxisAngle { 67 + AxisAngle::new(self.axis, Angle::new::<radian>(self.radians)) 68 + } 69 + 70 + fn rotate_offset(self, o: V3) -> V3 { 71 + let rotated = 72 + Point3::from_mm(o[0], o[1], o[2]).rotated_about(Point3::origin(), self.axis_angle()); 73 + let (x, y, z) = rotated.coords_mm(); 74 + [x, y, z] 75 + } 76 + 77 + fn rotate_unit(self, u: UnitVec3) -> UnitVec3 { 78 + u.rotated(self.axis_angle()) 79 + } 80 + } 81 + 82 + #[derive(Clone, Debug, PartialEq)] 83 + pub enum Prim { 84 + Box { 85 + center: [f32; 3], 86 + half: [f32; 3], 87 + rotation: Rotation, 88 + material: IconMaterial, 89 + }, 90 + Cylinder { 91 + from: [f32; 3], 92 + to: [f32; 3], 93 + radius: f32, 94 + material: IconMaterial, 95 + }, 96 + Sphere { 97 + center: [f32; 3], 98 + radius: f32, 99 + material: IconMaterial, 100 + }, 101 + SweptTube { 102 + path: Vec<[f32; 3]>, 103 + radius: f32, 104 + closed: bool, 105 + material: IconMaterial, 106 + }, 107 + Arrow { 108 + from: [f32; 3], 109 + to: [f32; 3], 110 + shaft_radius: f32, 111 + head_radius: f32, 112 + head_length: f32, 113 + material: IconMaterial, 114 + }, 115 + } 116 + 117 + impl Prim { 118 + #[must_use] 119 + pub fn cuboid(center: [f32; 3], half: [f32; 3], material: IconMaterial) -> Self { 120 + Self::Box { 121 + center, 122 + half, 123 + rotation: Rotation::identity(), 124 + material, 125 + } 126 + } 127 + 128 + #[must_use] 129 + pub fn tilted_cuboid( 130 + center: [f32; 3], 131 + half: [f32; 3], 132 + rotation: Rotation, 133 + material: IconMaterial, 134 + ) -> Self { 135 + Self::Box { 136 + center, 137 + half, 138 + rotation, 139 + material, 140 + } 141 + } 142 + 143 + #[must_use] 144 + pub fn bar(from: [f32; 3], to: [f32; 3], radius: f32, material: IconMaterial) -> Self { 145 + Self::Cylinder { 146 + from, 147 + to, 148 + radius, 149 + material, 150 + } 151 + } 152 + 153 + #[must_use] 154 + pub fn ball(center: [f32; 3], radius: f32, material: IconMaterial) -> Self { 155 + Self::Sphere { 156 + center, 157 + radius, 158 + material, 159 + } 160 + } 161 + 162 + #[must_use] 163 + pub fn loop_tube(path: Vec<[f32; 3]>, radius: f32, material: IconMaterial) -> Self { 164 + Self::SweptTube { 165 + path, 166 + radius, 167 + closed: true, 168 + material, 169 + } 170 + } 171 + 172 + #[must_use] 173 + pub fn open_tube(path: Vec<[f32; 3]>, radius: f32, material: IconMaterial) -> Self { 174 + Self::SweptTube { 175 + path, 176 + radius, 177 + closed: false, 178 + material, 179 + } 180 + } 181 + 182 + #[must_use] 183 + pub fn arrow( 184 + from: [f32; 3], 185 + to: [f32; 3], 186 + shaft_radius: f32, 187 + head_radius: f32, 188 + head_length: f32, 189 + material: IconMaterial, 190 + ) -> Self { 191 + Self::Arrow { 192 + from, 193 + to, 194 + shaft_radius, 195 + head_radius, 196 + head_length, 197 + material, 198 + } 199 + } 200 + 201 + fn emit(&self, mesh: &mut MeshBuilder) { 202 + match self { 203 + Prim::Box { 204 + center, 205 + half, 206 + rotation, 207 + material, 208 + } => emit_cuboid(mesh, *center, *half, *rotation, *material), 209 + Prim::Cylinder { 210 + from, 211 + to, 212 + radius, 213 + material, 214 + } => emit_cylinder(mesh, *from, *to, *radius, *material), 215 + Prim::Sphere { 216 + center, 217 + radius, 218 + material, 219 + } => emit_sphere(mesh, *center, *radius, *material), 220 + Prim::SweptTube { 221 + path, 222 + radius, 223 + closed, 224 + material, 225 + } => emit_swept_tube(mesh, path, *radius, *closed, *material), 226 + Prim::Arrow { 227 + from, 228 + to, 229 + shaft_radius, 230 + head_radius, 231 + head_length, 232 + material, 233 + } => emit_arrow( 234 + mesh, 235 + *from, 236 + *to, 237 + *shaft_radius, 238 + *head_radius, 239 + *head_length, 240 + *material, 241 + ), 242 + } 243 + } 244 + } 245 + 246 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 247 + pub enum IconView { 248 + Iso, 249 + Front, 250 + } 251 + 252 + #[derive(Clone, Debug, PartialEq)] 253 + pub struct IconModel { 254 + prims: Vec<Prim>, 255 + view: IconView, 256 + } 257 + 258 + impl IconModel { 259 + #[must_use] 260 + pub fn new(prims: Vec<Prim>) -> Self { 261 + Self { 262 + prims, 263 + view: IconView::Iso, 264 + } 265 + } 266 + 267 + #[must_use] 268 + pub fn with_view(mut self, view: IconView) -> Self { 269 + self.view = view; 270 + self 271 + } 272 + 273 + #[must_use] 274 + pub fn view(&self) -> IconView { 275 + self.view 276 + } 277 + 278 + #[must_use] 279 + pub fn tessellate(&self) -> (SolidScene, EdgeScene) { 280 + let mesh = self 281 + .prims 282 + .iter() 283 + .fold(MeshBuilder::default(), |mut mesh, prim| { 284 + prim.emit(&mut mesh); 285 + mesh 286 + }); 287 + let scene = 288 + SolidScene::from_parts(mesh.positions, mesh.normals, mesh.colors, mesh.triangles); 289 + let edges = EdgeScene::from_genuine(mesh.edges); 290 + (scene, edges) 291 + } 292 + } 293 + 294 + #[derive(Default)] 295 + struct MeshBuilder { 296 + positions: Vec<Point3>, 297 + normals: Vec<UnitVec3>, 298 + colors: Vec<LinearRgba>, 299 + triangles: Vec<[u32; 3]>, 300 + edges: Vec<GenuineEdge>, 301 + } 302 + 303 + impl MeshBuilder { 304 + fn base(&self) -> u32 { 305 + let Ok(base) = u32::try_from(self.positions.len()) else { 306 + panic!("icon mesh vertex count fits a u32 index"); 307 + }; 308 + base 309 + } 310 + 311 + fn push_quad(&mut self, corners: [Point3; 4], normal: UnitVec3, color: LinearRgba) { 312 + self.push_quad_smooth(corners, [normal; 4], color); 313 + } 314 + 315 + fn push_quad_smooth( 316 + &mut self, 317 + corners: [Point3; 4], 318 + normals: [UnitVec3; 4], 319 + color: LinearRgba, 320 + ) { 321 + let base = self.base(); 322 + self.positions.extend_from_slice(&corners); 323 + self.normals.extend_from_slice(&normals); 324 + (0..4).for_each(|_| self.colors.push(color)); 325 + self.triangles.push([base, base + 1, base + 2]); 326 + self.triangles.push([base, base + 2, base + 3]); 327 + } 328 + 329 + fn push_tri(&mut self, corners: [Point3; 3], normals: [UnitVec3; 3], color: LinearRgba) { 330 + let base = self.base(); 331 + self.positions.extend_from_slice(&corners); 332 + self.normals.extend_from_slice(&normals); 333 + (0..3).for_each(|_| self.colors.push(color)); 334 + self.triangles.push([base, base + 1, base + 2]); 335 + } 336 + 337 + fn push_edge(&mut self, a: Point3, b: Point3) { 338 + self.edges 339 + .push(GenuineEdge::new(a, b, PickId::NONE, RIGHT_ANGLE)); 340 + } 341 + 342 + fn push_loop_edges(&mut self, ring: &[Point3]) { 343 + ring.iter().enumerate().for_each(|(i, &a)| { 344 + let b = ring[(i + 1) % ring.len()]; 345 + self.push_edge(a, b); 346 + }); 347 + } 348 + } 349 + 350 + fn sub(a: V3, b: V3) -> V3 { 351 + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] 352 + } 353 + 354 + fn add(a: V3, b: V3) -> V3 { 355 + [a[0] + b[0], a[1] + b[1], a[2] + b[2]] 356 + } 357 + 358 + fn scale(a: V3, s: f64) -> V3 { 359 + [a[0] * s, a[1] * s, a[2] * s] 360 + } 361 + 362 + fn dot(a: V3, b: V3) -> f64 { 363 + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] 364 + } 365 + 366 + fn cross(a: V3, b: V3) -> V3 { 367 + [ 368 + a[1] * b[2] - a[2] * b[1], 369 + a[2] * b[0] - a[0] * b[2], 370 + a[0] * b[1] - a[1] * b[0], 371 + ] 372 + } 373 + 374 + fn length(a: V3) -> f64 { 375 + dot(a, a).sqrt() 376 + } 377 + 378 + fn normalize(a: V3) -> V3 { 379 + let n = length(a); 380 + if n <= f64::EPSILON { 381 + [0.0, 0.0, 1.0] 382 + } else { 383 + scale(a, 1.0 / n) 384 + } 385 + } 386 + 387 + fn vf(a: [f32; 3]) -> V3 { 388 + [f64::from(a[0]), f64::from(a[1]), f64::from(a[2])] 389 + } 390 + 391 + fn pt(v: V3) -> Point3 { 392 + Point3::from_mm(v[0], v[1], v[2]) 393 + } 394 + 395 + fn unit(v: V3) -> UnitVec3 { 396 + let n = normalize(v); 397 + UnitVec3::new_unchecked(n[0], n[1], n[2]) 398 + } 399 + 400 + fn perpendicular_frame(dir: V3) -> (V3, V3) { 401 + let helper = if dir[1].abs() < 0.9 { 402 + [0.0, 1.0, 0.0] 403 + } else { 404 + [1.0, 0.0, 0.0] 405 + }; 406 + let u = normalize(cross(helper, dir)); 407 + let v = cross(dir, u); 408 + (u, v) 409 + } 410 + 411 + fn ring_points(center: V3, u: V3, v: V3, radius: f64, segments: u32) -> Vec<V3> { 412 + (0..segments) 413 + .map(|i| { 414 + let theta = core::f64::consts::TAU * f64::from(i) / f64::from(segments); 415 + add( 416 + center, 417 + add( 418 + scale(u, radius * theta.cos()), 419 + scale(v, radius * theta.sin()), 420 + ), 421 + ) 422 + }) 423 + .collect() 424 + } 425 + 426 + fn emit_cuboid( 427 + mesh: &mut MeshBuilder, 428 + center: [f32; 3], 429 + half: [f32; 3], 430 + rotation: Rotation, 431 + material: IconMaterial, 432 + ) { 433 + let c = vf(center); 434 + let h = vf(half); 435 + let place = |signs: [f64; 3]| -> Point3 { 436 + let local = [signs[0] * h[0], signs[1] * h[1], signs[2] * h[2]]; 437 + pt(add(c, rotation.rotate_offset(local))) 438 + }; 439 + (0..3).for_each(|axis| { 440 + let u = (axis + 1) % 3; 441 + let v = (axis + 2) % 3; 442 + [1.0_f64, -1.0_f64].into_iter().for_each(|sign| { 443 + let mut axis_dir = [0.0_f64; 3]; 444 + axis_dir[axis] = sign; 445 + let normal = rotation.rotate_unit(unit(axis_dir)); 446 + let at = |du: f64, dv: f64| { 447 + let mut signs = [0.0_f64; 3]; 448 + signs[axis] = sign; 449 + signs[u] = du; 450 + signs[v] = dv; 451 + place(signs) 452 + }; 453 + let p00 = at(-1.0, -1.0); 454 + let p10 = at(1.0, -1.0); 455 + let p11 = at(1.0, 1.0); 456 + let p01 = at(-1.0, 1.0); 457 + let quad = if sign > 0.0 { 458 + [p00, p10, p11, p01] 459 + } else { 460 + [p00, p01, p11, p10] 461 + }; 462 + mesh.push_quad(quad, normal, material.color); 463 + }); 464 + }); 465 + emit_cuboid_edges(mesh, place); 466 + } 467 + 468 + fn emit_cuboid_edges(mesh: &mut MeshBuilder, place: impl Fn([f64; 3]) -> Point3) { 469 + (0..3).for_each(|axis| { 470 + let u = (axis + 1) % 3; 471 + let v = (axis + 2) % 3; 472 + [-1.0_f64, 1.0_f64].into_iter().for_each(|su| { 473 + [-1.0_f64, 1.0_f64].into_iter().for_each(|sv| { 474 + let mut lo = [0.0_f64; 3]; 475 + lo[u] = su; 476 + lo[v] = sv; 477 + lo[axis] = -1.0; 478 + let mut hi = lo; 479 + hi[axis] = 1.0; 480 + mesh.push_edge(place(lo), place(hi)); 481 + }); 482 + }); 483 + }); 484 + } 485 + 486 + fn emit_tube_band( 487 + mesh: &mut MeshBuilder, 488 + ring_a: &[V3], 489 + ring_b: &[V3], 490 + center_a: V3, 491 + center_b: V3, 492 + color: LinearRgba, 493 + ) { 494 + let n = ring_a.len(); 495 + (0..n).for_each(|i| { 496 + let j = (i + 1) % n; 497 + let a0 = ring_a[i]; 498 + let a1 = ring_a[j]; 499 + let b0 = ring_b[i]; 500 + let b1 = ring_b[j]; 501 + let na0 = unit(sub(a0, center_a)); 502 + let na1 = unit(sub(a1, center_a)); 503 + let nb0 = unit(sub(b0, center_b)); 504 + let nb1 = unit(sub(b1, center_b)); 505 + mesh.push_quad_smooth( 506 + [pt(a0), pt(a1), pt(b1), pt(b0)], 507 + [na0, na1, nb1, nb0], 508 + color, 509 + ); 510 + }); 511 + } 512 + 513 + fn emit_cap(mesh: &mut MeshBuilder, ring: &[V3], center: V3, normal: UnitVec3, color: LinearRgba) { 514 + let n = ring.len(); 515 + let (nx, ny, nz) = normal.components(); 516 + let default_dir = cross(sub(ring[0], center), sub(ring[1], center)); 517 + let flip = dot(default_dir, [nx, ny, nz]) < 0.0; 518 + (0..n).for_each(|i| { 519 + let j = (i + 1) % n; 520 + let (a, b) = if flip { 521 + (ring[j], ring[i]) 522 + } else { 523 + (ring[i], ring[j]) 524 + }; 525 + mesh.push_tri([pt(center), pt(a), pt(b)], [normal; 3], color); 526 + }); 527 + } 528 + 529 + fn emit_cylinder( 530 + mesh: &mut MeshBuilder, 531 + from: [f32; 3], 532 + to: [f32; 3], 533 + radius: f32, 534 + material: IconMaterial, 535 + ) { 536 + let p0 = vf(from); 537 + let p1 = vf(to); 538 + let dir = normalize(sub(p1, p0)); 539 + let (u, v) = perpendicular_frame(dir); 540 + let r = f64::from(radius); 541 + let ring0 = ring_points(p0, u, v, r, CYL_SEGMENTS); 542 + let ring1 = ring_points(p1, u, v, r, CYL_SEGMENTS); 543 + emit_tube_band(mesh, &ring0, &ring1, p0, p1, material.color); 544 + emit_cap(mesh, &ring0, p0, unit(scale(dir, -1.0)), material.color); 545 + emit_cap(mesh, &ring1, p1, unit(dir), material.color); 546 + mesh.push_loop_edges(&ring0.iter().map(|&p| pt(p)).collect::<Vec<_>>()); 547 + mesh.push_loop_edges(&ring1.iter().map(|&p| pt(p)).collect::<Vec<_>>()); 548 + } 549 + 550 + fn emit_sphere(mesh: &mut MeshBuilder, center: [f32; 3], radius: f32, material: IconMaterial) { 551 + let c = vf(center); 552 + let r = f64::from(radius); 553 + let lat = SPHERE_LAT; 554 + let lon = SPHERE_LON; 555 + let point = |i: u32, j: u32| -> (V3, UnitVec3) { 556 + let phi = core::f64::consts::PI * f64::from(i) / f64::from(lat); 557 + let theta = core::f64::consts::TAU * f64::from(j) / f64::from(lon); 558 + let n = [phi.sin() * theta.cos(), phi.cos(), phi.sin() * theta.sin()]; 559 + (add(c, scale(n, r)), unit(n)) 560 + }; 561 + (0..lat).for_each(|i| { 562 + (0..lon).for_each(|j| { 563 + let (p00, n00) = point(i, j); 564 + let (p01, n01) = point(i, j + 1); 565 + let (p10, n10) = point(i + 1, j); 566 + let (p11, n11) = point(i + 1, j + 1); 567 + mesh.push_quad_smooth( 568 + [pt(p00), pt(p01), pt(p11), pt(p10)], 569 + [n00, n01, n11, n10], 570 + material.color, 571 + ); 572 + }); 573 + }); 574 + } 575 + 576 + fn path_normal(path: &[V3]) -> V3 { 577 + let n = path.len(); 578 + let newell = (0..n).fold([0.0_f64; 3], |acc, i| { 579 + let a = path[i]; 580 + let b = path[(i + 1) % n]; 581 + [ 582 + acc[0] + (a[1] - b[1]) * (a[2] + b[2]), 583 + acc[1] + (a[2] - b[2]) * (a[0] + b[0]), 584 + acc[2] + (a[0] - b[0]) * (a[1] + b[1]), 585 + ] 586 + }); 587 + if length(newell) <= f64::EPSILON { 588 + let dir = normalize(sub(path[1.min(n - 1)], path[0])); 589 + let (u, _) = perpendicular_frame(dir); 590 + u 591 + } else { 592 + normalize(newell) 593 + } 594 + } 595 + 596 + fn tangent_at(path: &[V3], i: usize, closed: bool) -> V3 { 597 + let n = path.len(); 598 + let prev = if i == 0 { 599 + if closed { Some(path[n - 1]) } else { None } 600 + } else { 601 + Some(path[i - 1]) 602 + }; 603 + let next = if i + 1 == n { 604 + if closed { Some(path[0]) } else { None } 605 + } else { 606 + Some(path[i + 1]) 607 + }; 608 + match (prev, next) { 609 + (Some(a), Some(b)) => normalize(sub(b, a)), 610 + (Some(a), None) => normalize(sub(path[i], a)), 611 + (None, Some(b)) => normalize(sub(b, path[i])), 612 + (None, None) => [0.0, 0.0, 1.0], 613 + } 614 + } 615 + 616 + fn emit_swept_tube( 617 + mesh: &mut MeshBuilder, 618 + path: &[[f32; 3]], 619 + radius: f32, 620 + closed: bool, 621 + material: IconMaterial, 622 + ) { 623 + if path.len() < 2 { 624 + return; 625 + } 626 + let points: Vec<V3> = path.iter().map(|&p| vf(p)).collect(); 627 + let normal = path_normal(&points); 628 + let r = f64::from(radius); 629 + let rings: Vec<Vec<V3>> = points 630 + .iter() 631 + .enumerate() 632 + .map(|(i, &center)| { 633 + let tangent = tangent_at(&points, i, closed); 634 + let in_plane = normalize(cross(tangent, normal)); 635 + ring_points(center, in_plane, normal, r, TUBE_SEGMENTS) 636 + }) 637 + .collect(); 638 + let bands = if closed { 639 + points.len() 640 + } else { 641 + points.len() - 1 642 + }; 643 + (0..bands).for_each(|i| { 644 + let j = (i + 1) % points.len(); 645 + emit_tube_band( 646 + mesh, 647 + &rings[i], 648 + &rings[j], 649 + points[i], 650 + points[j], 651 + material.color, 652 + ); 653 + }); 654 + if !closed { 655 + let last = points.len() - 1; 656 + let t0 = tangent_at(&points, 0, closed); 657 + let t1 = tangent_at(&points, last, closed); 658 + emit_cap( 659 + mesh, 660 + &rings[0], 661 + points[0], 662 + unit(scale(t0, -1.0)), 663 + material.color, 664 + ); 665 + emit_cap(mesh, &rings[last], points[last], unit(t1), material.color); 666 + } 667 + } 668 + 669 + fn emit_arrow( 670 + mesh: &mut MeshBuilder, 671 + from: [f32; 3], 672 + to: [f32; 3], 673 + shaft_radius: f32, 674 + head_radius: f32, 675 + head_length: f32, 676 + material: IconMaterial, 677 + ) { 678 + let p0 = vf(from); 679 + let tip = vf(to); 680 + let dir = normalize(sub(tip, p0)); 681 + let head_len = f64::from(head_length); 682 + let base = sub(tip, scale(dir, head_len)); 683 + let (frame_u, frame_v) = perpendicular_frame(dir); 684 + let shaft_r = f64::from(shaft_radius); 685 + let shaft0 = ring_points(p0, frame_u, frame_v, shaft_r, CYL_SEGMENTS); 686 + let shaft1 = ring_points(base, frame_u, frame_v, shaft_r, CYL_SEGMENTS); 687 + emit_tube_band(mesh, &shaft0, &shaft1, p0, base, material.color); 688 + emit_cap(mesh, &shaft0, p0, unit(scale(dir, -1.0)), material.color); 689 + let head_r = f64::from(head_radius); 690 + let base_ring = ring_points(base, frame_u, frame_v, head_r, CYL_SEGMENTS); 691 + emit_cap( 692 + mesh, 693 + &base_ring, 694 + base, 695 + unit(scale(dir, -1.0)), 696 + material.color, 697 + ); 698 + let count = base_ring.len(); 699 + (0..count).for_each(|i| { 700 + let j = (i + 1) % count; 701 + let cap_a = base_ring[i]; 702 + let cap_b = base_ring[j]; 703 + let na = unit(add(scale(dir, head_r), sub(cap_a, base))); 704 + let nb = unit(add(scale(dir, head_r), sub(cap_b, base))); 705 + let nt = unit(dir); 706 + mesh.push_tri( 707 + [pt(cap_a), pt(cap_b), pt(tip)], 708 + [na, nb, nt], 709 + material.color, 710 + ); 711 + }); 712 + mesh.push_loop_edges(&base_ring.iter().map(|&p| pt(p)).collect::<Vec<_>>()); 713 + } 714 + 715 + #[must_use] 716 + pub fn icon_camera(view: IconView) -> Camera3 { 717 + match view { 718 + IconView::Iso => iso_camera(), 719 + IconView::Front => front_camera(), 720 + } 721 + } 722 + 723 + fn iso_camera() -> Camera3 { 724 + let target = Point3::origin(); 725 + let Ok(direction) = UnitVec3::try_from_components(1.0, 0.8, 1.0, TOL) else { 726 + panic!("icon camera direction is nonzero"); 727 + }; 728 + let eye = target + direction.into_vec(Length::new::<millimeter>(10.0)); 729 + let Ok(projection) = Projection::orthographic(Length::new::<millimeter>(1.5)) else { 730 + panic!("icon camera half-height is positive"); 731 + }; 732 + let Ok(camera) = Camera3::new(eye, target, UnitVec3::y_axis(), projection) else { 733 + panic!("icon camera eye and target are 10 mm apart"); 734 + }; 735 + camera 736 + } 737 + 738 + fn front_camera() -> Camera3 { 739 + let target = Point3::origin(); 740 + let eye = target + UnitVec3::z_axis().into_vec(Length::new::<millimeter>(10.0)); 741 + let Ok(projection) = Projection::orthographic(Length::new::<millimeter>(1.3)) else { 742 + panic!("icon front camera half-height is positive"); 743 + }; 744 + let Ok(camera) = Camera3::new(eye, target, UnitVec3::y_axis(), projection) else { 745 + panic!("icon front camera eye and target are 10 mm apart"); 746 + }; 747 + camera 748 + } 749 + 750 + #[must_use] 751 + pub fn icon_style() -> Style { 752 + Style::light().with_solid_base_color(LinearRgba::new(1.0, 1.0, 1.0, 1.0)) 753 + }
+4 -1
crates/bone-jig/src/lib.rs
··· 1 + pub mod baker; 2 + pub mod icon; 3 + pub mod models; 1 4 mod run; 2 5 mod scenario; 3 6 mod target; ··· 9 12 ScenarioError, ScenarioSource, Step, modifier_mask, 10 13 }; 11 14 pub use target::{Candidate, ResolveError, resolve_target}; 12 - pub use tree::{DumpNode, DumpStates, TreeDump, TreeDumpError}; 15 + pub use tree::{DumpNode, DumpStates, TreeDump, TreeDumpError, TreeLoadError}; 13 16 14 17 use bone_render::{AdapterPolicy, OffscreenContext, Result, ViewportExtent, ViewportPx}; 15 18
+629
crates/bone-jig/src/models.rs
··· 1 + use bone_types::{Angle, IconId, LinearRgba, degree}; 2 + 3 + use crate::icon::{IconMaterial, IconModel, IconView, Prim, Rotation}; 4 + 5 + const STEEL: LinearRgba = LinearRgba::new(0.20, 0.32, 0.52, 1.0); 6 + const STEEL_LIGHT: LinearRgba = LinearRgba::new(0.42, 0.54, 0.72, 1.0); 7 + const AMBER: LinearRgba = LinearRgba::new(0.86, 0.42, 0.06, 1.0); 8 + const AMBER_LIGHT: LinearRgba = LinearRgba::new(0.95, 0.62, 0.18, 1.0); 9 + const GREEN: LinearRgba = LinearRgba::new(0.20, 0.50, 0.22, 1.0); 10 + const RED: LinearRgba = LinearRgba::new(0.62, 0.13, 0.10, 1.0); 11 + const GRAY: LinearRgba = LinearRgba::new(0.52, 0.54, 0.58, 1.0); 12 + const LIGHT: LinearRgba = LinearRgba::new(0.82, 0.85, 0.90, 1.0); 13 + const DARK: LinearRgba = LinearRgba::new(0.12, 0.14, 0.18, 1.0); 14 + const WHITE: LinearRgba = LinearRgba::new(1.0, 1.0, 1.0, 1.0); 15 + const AX_X: LinearRgba = LinearRgba::new(0.72, 0.16, 0.12, 1.0); 16 + const AX_Y: LinearRgba = LinearRgba::new(0.16, 0.56, 0.20, 1.0); 17 + const AX_Z: LinearRgba = LinearRgba::new(0.16, 0.30, 0.70, 1.0); 18 + 19 + const BAR: f32 = 0.09; 20 + const TUBE: f32 = 0.075; 21 + const DOT: f32 = 0.16; 22 + const DOT_SM: f32 = 0.1; 23 + 24 + #[must_use] 25 + pub fn model(id: IconId) -> IconModel { 26 + let prims = match id { 27 + IconId::Point => point(), 28 + IconId::Line => line(), 29 + IconId::CenterpointArc => centerpoint_arc(), 30 + IconId::TangentArc => tangent_arc(), 31 + IconId::ThreePointArc => three_point_arc(), 32 + IconId::Circle => circle(), 33 + IconId::PerimeterCircle => perimeter_circle(), 34 + IconId::CornerRectangle => corner_rectangle(), 35 + IconId::CenterRectangle => center_rectangle(), 36 + IconId::ThreePointCornerRectangle => three_point_corner_rectangle(), 37 + IconId::ThreePointCenterRectangle => three_point_center_rectangle(), 38 + IconId::Parallelogram => parallelogram(), 39 + IconId::SmartDimension => smart_dimension(), 40 + IconId::Coincident => coincident(), 41 + IconId::Horizontal => horizontal(), 42 + IconId::Vertical => vertical(), 43 + IconId::Parallel => parallel(), 44 + IconId::Perpendicular => perpendicular(), 45 + IconId::Tangent => tangent(), 46 + IconId::Equal => equal(), 47 + IconId::Concentric => concentric(), 48 + IconId::Midpoint => midpoint(), 49 + IconId::Symmetric => symmetric(), 50 + IconId::Fix => fix(), 51 + IconId::ExtrudedBossBase => extruded_boss_base(), 52 + IconId::ExtrudedCut => extruded_cut(), 53 + IconId::TreeFeature => tree_feature(), 54 + IconId::TreePlane => tree_plane(), 55 + IconId::TreeSketch => tree_sketch(), 56 + IconId::TreeOrigin => tree_origin(), 57 + IconId::TabTree => tab_tree(), 58 + IconId::TabProperties => tab_properties(), 59 + IconId::TabConfiguration => tab_configuration(), 60 + IconId::TabDimensionExpert => tab_dimension_expert(), 61 + IconId::TabDisplay => tab_display(), 62 + IconId::ZoomToFit => zoom_to_fit(), 63 + IconId::ZoomToArea => zoom_to_area(), 64 + IconId::PreviousView => previous_view(), 65 + IconId::SectionView => section_view(), 66 + IconId::ViewOrientation => view_orientation(), 67 + IconId::DisplayStyle => display_style(), 68 + IconId::HideShowItems => hide_show_items(), 69 + IconId::EditAppearance => edit_appearance(), 70 + IconId::ViewSettings => view_settings(), 71 + IconId::Check => check(), 72 + IconId::Cross => cross(), 73 + }; 74 + IconModel::new(prims).with_view(view_for(id)) 75 + } 76 + 77 + fn view_for(id: IconId) -> IconView { 78 + match id { 79 + IconId::ExtrudedBossBase 80 + | IconId::ExtrudedCut 81 + | IconId::TreeFeature 82 + | IconId::TreePlane 83 + | IconId::TreeOrigin 84 + | IconId::SectionView 85 + | IconId::ViewOrientation 86 + | IconId::DisplayStyle 87 + | IconId::TabConfiguration 88 + | IconId::TabDisplay 89 + | IconId::EditAppearance => IconView::Iso, 90 + _ => IconView::Front, 91 + } 92 + } 93 + 94 + fn mat(color: LinearRgba) -> IconMaterial { 95 + IconMaterial::new(color) 96 + } 97 + 98 + fn z0(p: [f32; 2]) -> [f32; 3] { 99 + [p[0], p[1], 0.0] 100 + } 101 + 102 + fn bar(a: [f32; 2], b: [f32; 2], radius: f32, color: LinearRgba) -> Prim { 103 + Prim::bar(z0(a), z0(b), radius, mat(color)) 104 + } 105 + 106 + fn dot(p: [f32; 2], radius: f32, color: LinearRgba) -> Prim { 107 + Prim::ball(z0(p), radius, mat(color)) 108 + } 109 + 110 + fn circle_path(center: [f32; 2], radius: f32, n: u16) -> Vec<[f32; 3]> { 111 + (0..n) 112 + .map(|i| { 113 + let t = core::f32::consts::TAU * f32::from(i) / f32::from(n); 114 + [ 115 + center[0] + radius * t.cos(), 116 + center[1] + radius * t.sin(), 117 + 0.0, 118 + ] 119 + }) 120 + .collect() 121 + } 122 + 123 + fn arc_path( 124 + center: [f32; 2], 125 + radius: f32, 126 + start_deg: f32, 127 + sweep_deg: f32, 128 + n: u16, 129 + ) -> Vec<[f32; 3]> { 130 + let start = start_deg.to_radians(); 131 + let sweep = sweep_deg.to_radians(); 132 + (0..=n) 133 + .map(|i| { 134 + let t = start + sweep * f32::from(i) / f32::from(n); 135 + [ 136 + center[0] + radius * t.cos(), 137 + center[1] + radius * t.sin(), 138 + 0.0, 139 + ] 140 + }) 141 + .collect() 142 + } 143 + 144 + fn ring(center: [f32; 2], radius: f32, tube: f32, color: LinearRgba) -> Prim { 145 + Prim::loop_tube(circle_path(center, radius, 48), tube, mat(color)) 146 + } 147 + 148 + fn arc( 149 + center: [f32; 2], 150 + radius: f32, 151 + start_deg: f32, 152 + sweep_deg: f32, 153 + tube: f32, 154 + color: LinearRgba, 155 + ) -> Prim { 156 + Prim::open_tube( 157 + arc_path(center, radius, start_deg, sweep_deg, 24), 158 + tube, 159 + mat(color), 160 + ) 161 + } 162 + 163 + fn rotate2(p: [f32; 2], angle_deg: f32) -> [f32; 2] { 164 + let r = angle_deg.to_radians(); 165 + [ 166 + p[0] * r.cos() - p[1] * r.sin(), 167 + p[0] * r.sin() + p[1] * r.cos(), 168 + ] 169 + } 170 + 171 + fn rect(corners: [[f32; 2]; 4], tube: f32, color: LinearRgba) -> Prim { 172 + Prim::loop_tube(corners.into_iter().map(z0).collect(), tube, mat(color)) 173 + } 174 + 175 + fn tilted_rect(half_w: f32, half_h: f32, angle_deg: f32, tube: f32, color: LinearRgba) -> Prim { 176 + let corners = [ 177 + rotate2([-half_w, -half_h], angle_deg), 178 + rotate2([half_w, -half_h], angle_deg), 179 + rotate2([half_w, half_h], angle_deg), 180 + rotate2([-half_w, half_h], angle_deg), 181 + ]; 182 + rect(corners, tube, color) 183 + } 184 + 185 + fn turn(value_deg: f32) -> Angle { 186 + Angle::new::<degree>(f64::from(value_deg)) 187 + } 188 + 189 + fn point() -> Vec<Prim> { 190 + vec![ 191 + dot([0.0, 0.0], 0.24, STEEL), 192 + bar([-0.6, 0.0], [0.6, 0.0], 0.04, AMBER), 193 + bar([0.0, -0.6], [0.0, 0.6], 0.04, AMBER), 194 + ] 195 + } 196 + 197 + fn line() -> Vec<Prim> { 198 + vec![ 199 + bar([-0.7, -0.5], [0.7, 0.5], BAR, STEEL), 200 + dot([-0.7, -0.5], DOT_SM, AMBER), 201 + dot([0.7, 0.5], DOT_SM, AMBER), 202 + ] 203 + } 204 + 205 + fn centerpoint_arc() -> Vec<Prim> { 206 + vec![ 207 + arc([0.0, -0.35], 0.78, 30.0, 120.0, TUBE, STEEL), 208 + dot([0.0, -0.35], DOT, AMBER), 209 + bar([0.0, -0.35], [-0.675, 0.04], 0.045, STEEL_LIGHT), 210 + ] 211 + } 212 + 213 + fn tangent_arc() -> Vec<Prim> { 214 + vec![ 215 + bar([-0.8, -0.55], [-0.1, -0.55], BAR, STEEL), 216 + arc([-0.1, 0.05], 0.6, -90.0, 95.0, TUBE, AMBER), 217 + ] 218 + } 219 + 220 + fn three_point_arc() -> Vec<Prim> { 221 + vec![ 222 + arc([0.0, -0.45], 0.85, 30.0, 120.0, TUBE, STEEL), 223 + dot([-0.736, 0.025], DOT_SM, AMBER), 224 + dot([0.736, 0.025], DOT_SM, AMBER), 225 + dot([0.0, 0.4], DOT_SM, AMBER), 226 + ] 227 + } 228 + 229 + fn circle() -> Vec<Prim> { 230 + vec![ 231 + ring([0.0, 0.0], 0.7, TUBE, STEEL), 232 + dot([0.0, 0.0], DOT_SM, STEEL_LIGHT), 233 + ] 234 + } 235 + 236 + fn perimeter_circle() -> Vec<Prim> { 237 + vec![ 238 + ring([0.0, 0.0], 0.7, TUBE, STEEL), 239 + dot([0.0, 0.7], DOT_SM, AMBER), 240 + dot([-0.606, -0.35], DOT_SM, AMBER), 241 + dot([0.606, -0.35], DOT_SM, AMBER), 242 + ] 243 + } 244 + 245 + fn corner_rectangle() -> Vec<Prim> { 246 + vec![ 247 + rect( 248 + [[-0.7, -0.5], [0.7, -0.5], [0.7, 0.5], [-0.7, 0.5]], 249 + TUBE, 250 + STEEL, 251 + ), 252 + dot([-0.7, -0.5], DOT_SM, AMBER), 253 + dot([0.7, 0.5], DOT_SM, AMBER), 254 + ] 255 + } 256 + 257 + fn center_rectangle() -> Vec<Prim> { 258 + vec![ 259 + rect( 260 + [[-0.7, -0.5], [0.7, -0.5], [0.7, 0.5], [-0.7, 0.5]], 261 + TUBE, 262 + STEEL, 263 + ), 264 + dot([0.0, 0.0], DOT_SM, AMBER), 265 + ] 266 + } 267 + 268 + fn three_point_corner_rectangle() -> Vec<Prim> { 269 + let lower_left = rotate2([-0.72, -0.46], 18.0); 270 + let upper_right = rotate2([0.72, 0.46], 18.0); 271 + vec![ 272 + tilted_rect(0.72, 0.46, 18.0, TUBE, STEEL), 273 + dot(lower_left, DOT_SM, AMBER), 274 + dot(upper_right, DOT_SM, AMBER), 275 + ] 276 + } 277 + 278 + fn three_point_center_rectangle() -> Vec<Prim> { 279 + vec![ 280 + tilted_rect(0.72, 0.46, 18.0, TUBE, STEEL), 281 + dot([0.0, 0.0], DOT_SM, AMBER), 282 + ] 283 + } 284 + 285 + fn parallelogram() -> Vec<Prim> { 286 + vec![rect( 287 + [[-0.8, -0.5], [0.4, -0.5], [0.8, 0.5], [-0.4, 0.5]], 288 + TUBE, 289 + STEEL, 290 + )] 291 + } 292 + 293 + fn smart_dimension() -> Vec<Prim> { 294 + vec![ 295 + bar([-0.6, -0.7], [-0.6, 0.25], 0.05, STEEL_LIGHT), 296 + bar([0.6, -0.7], [0.6, 0.25], 0.05, STEEL_LIGHT), 297 + Prim::arrow( 298 + z0([-0.05, -0.2]), 299 + z0([-0.6, -0.2]), 300 + 0.05, 301 + 0.16, 302 + 0.22, 303 + mat(AMBER), 304 + ), 305 + Prim::arrow( 306 + z0([0.05, -0.2]), 307 + z0([0.6, -0.2]), 308 + 0.05, 309 + 0.16, 310 + 0.22, 311 + mat(AMBER), 312 + ), 313 + ] 314 + } 315 + 316 + fn coincident() -> Vec<Prim> { 317 + vec![ 318 + ring([0.0, 0.0], 0.62, TUBE, STEEL), 319 + dot([0.0, 0.0], 0.24, AMBER), 320 + ] 321 + } 322 + 323 + fn horizontal() -> Vec<Prim> { 324 + vec![ 325 + bar([-0.78, 0.0], [0.78, 0.0], 0.11, AMBER), 326 + dot([-0.78, 0.0], DOT_SM, STEEL), 327 + dot([0.78, 0.0], DOT_SM, STEEL), 328 + ] 329 + } 330 + 331 + fn vertical() -> Vec<Prim> { 332 + vec![ 333 + bar([0.0, -0.78], [0.0, 0.78], 0.11, AMBER), 334 + dot([0.0, -0.78], DOT_SM, STEEL), 335 + dot([0.0, 0.78], DOT_SM, STEEL), 336 + ] 337 + } 338 + 339 + fn parallel() -> Vec<Prim> { 340 + vec![ 341 + bar([-0.45, -0.65], [-0.05, 0.65], BAR, AMBER), 342 + bar([0.25, -0.65], [0.65, 0.65], BAR, AMBER), 343 + ] 344 + } 345 + 346 + fn perpendicular() -> Vec<Prim> { 347 + vec![ 348 + bar([-0.35, -0.6], [-0.35, 0.65], BAR, AMBER), 349 + bar([-0.45, -0.6], [0.7, -0.6], BAR, AMBER), 350 + ] 351 + } 352 + 353 + fn tangent() -> Vec<Prim> { 354 + vec![ 355 + ring([-0.15, -0.12], 0.5, TUBE, STEEL), 356 + bar([-0.85, 0.46], [0.7, 0.46], BAR, AMBER), 357 + ] 358 + } 359 + 360 + fn equal() -> Vec<Prim> { 361 + vec![ 362 + bar([-0.55, 0.2], [0.55, 0.2], 0.1, AMBER), 363 + bar([-0.55, -0.2], [0.55, -0.2], 0.1, AMBER), 364 + ] 365 + } 366 + 367 + fn concentric() -> Vec<Prim> { 368 + vec![ 369 + ring([0.0, 0.0], 0.72, TUBE, STEEL), 370 + ring([0.0, 0.0], 0.38, TUBE, AMBER), 371 + dot([0.0, 0.0], DOT_SM, STEEL_LIGHT), 372 + ] 373 + } 374 + 375 + fn midpoint() -> Vec<Prim> { 376 + vec![ 377 + bar([-0.75, -0.3], [0.75, 0.3], BAR, STEEL), 378 + dot([0.0, 0.0], 0.2, AMBER), 379 + ] 380 + } 381 + 382 + fn symmetric() -> Vec<Prim> { 383 + vec![ 384 + bar([0.0, -0.7], [0.0, 0.7], 0.05, STEEL_LIGHT), 385 + dot([-0.5, 0.0], 0.2, AMBER), 386 + dot([0.5, 0.0], 0.2, AMBER), 387 + ] 388 + } 389 + 390 + fn fix() -> Vec<Prim> { 391 + vec![ 392 + bar([-0.7, -0.5], [0.7, -0.5], 0.07, DARK), 393 + bar([-0.5, -0.5], [-0.7, -0.72], 0.05, DARK), 394 + bar([-0.15, -0.5], [-0.35, -0.72], 0.05, DARK), 395 + bar([0.2, -0.5], [0.0, -0.72], 0.05, DARK), 396 + bar([0.55, -0.5], [0.35, -0.72], 0.05, DARK), 397 + bar([0.0, -0.5], [0.0, 0.05], 0.06, STEEL_LIGHT), 398 + dot([0.0, 0.28], 0.26, STEEL), 399 + ] 400 + } 401 + 402 + fn extruded_boss_base() -> Vec<Prim> { 403 + vec![ 404 + Prim::cuboid([0.0, -0.5, 0.0], [0.78, 0.12, 0.55], mat(GRAY)), 405 + Prim::cuboid([0.0, 0.02, 0.0], [0.4, 0.32, 0.4], mat(GREEN)), 406 + Prim::arrow( 407 + z0([0.0, 0.42]), 408 + z0([0.0, 0.95]), 409 + 0.07, 410 + 0.2, 411 + 0.28, 412 + mat(AMBER), 413 + ), 414 + ] 415 + } 416 + 417 + fn extruded_cut() -> Vec<Prim> { 418 + vec![ 419 + Prim::cuboid([0.0, -0.1, 0.0], [0.72, 0.5, 0.6], mat(GRAY)), 420 + Prim::cuboid([0.0, 0.45, 0.0], [0.3, 0.22, 0.32], mat(RED)), 421 + Prim::arrow(z0([0.0, 0.98]), z0([0.0, 0.42]), 0.07, 0.2, 0.28, mat(RED)), 422 + ] 423 + } 424 + 425 + fn tree_feature() -> Vec<Prim> { 426 + vec![Prim::cuboid( 427 + [0.0, 0.0, 0.0], 428 + [0.56, 0.56, 0.56], 429 + mat(STEEL), 430 + )] 431 + } 432 + 433 + fn tree_plane() -> Vec<Prim> { 434 + vec![Prim::tilted_cuboid( 435 + [0.0, 0.0, 0.0], 436 + [0.72, 0.72, 0.035], 437 + Rotation::about_x(turn(24.0)), 438 + mat(AMBER_LIGHT), 439 + )] 440 + } 441 + 442 + fn tree_sketch() -> Vec<Prim> { 443 + vec![ 444 + bar([-0.6, -0.4], [0.6, -0.4], BAR, STEEL), 445 + ring([0.18, 0.28], 0.35, TUBE, AMBER), 446 + ] 447 + } 448 + 449 + fn tree_origin() -> Vec<Prim> { 450 + vec![ 451 + Prim::bar([0.0, 0.0, 0.0], [0.85, 0.0, 0.0], 0.07, mat(AX_X)), 452 + Prim::bar([0.0, 0.0, 0.0], [0.0, 0.85, 0.0], 0.07, mat(AX_Y)), 453 + Prim::bar([0.0, 0.0, 0.0], [0.0, 0.0, 0.85], 0.07, mat(AX_Z)), 454 + Prim::ball([0.0, 0.0, 0.0], 0.16, mat(DARK)), 455 + ] 456 + } 457 + 458 + fn tab_tree() -> Vec<Prim> { 459 + vec![ 460 + dot([-0.55, 0.45], DOT_SM, AMBER), 461 + dot([-0.55, 0.0], DOT_SM, AMBER), 462 + dot([-0.55, -0.45], DOT_SM, AMBER), 463 + bar([-0.3, 0.45], [0.6, 0.45], 0.07, STEEL), 464 + bar([-0.3, 0.0], [0.6, 0.0], 0.07, STEEL), 465 + bar([-0.3, -0.45], [0.6, -0.45], 0.07, STEEL), 466 + ] 467 + } 468 + 469 + fn tab_properties() -> Vec<Prim> { 470 + vec![ 471 + Prim::cuboid([0.0, 0.0, 0.0], [0.52, 0.68, 0.05], mat(LIGHT)), 472 + bar([-0.32, 0.38], [0.32, 0.38], 0.06, STEEL), 473 + bar([-0.32, 0.08], [0.32, 0.08], 0.06, STEEL), 474 + bar([-0.32, -0.22], [0.1, -0.22], 0.06, STEEL), 475 + ] 476 + } 477 + 478 + fn tab_configuration() -> Vec<Prim> { 479 + vec![ 480 + Prim::cuboid([-0.2, -0.2, -0.12], [0.42, 0.42, 0.1], mat(STEEL_LIGHT)), 481 + Prim::cuboid([0.18, 0.18, 0.12], [0.42, 0.42, 0.1], mat(STEEL)), 482 + ] 483 + } 484 + 485 + fn tab_dimension_expert() -> Vec<Prim> { 486 + vec![ 487 + bar([-0.6, -0.55], [-0.6, 0.3], 0.05, STEEL_LIGHT), 488 + bar([0.6, -0.55], [0.6, 0.3], 0.05, STEEL_LIGHT), 489 + Prim::arrow( 490 + z0([-0.05, -0.1]), 491 + z0([-0.6, -0.1]), 492 + 0.05, 493 + 0.16, 494 + 0.22, 495 + mat(AMBER), 496 + ), 497 + Prim::arrow( 498 + z0([0.05, -0.1]), 499 + z0([0.6, -0.1]), 500 + 0.05, 501 + 0.16, 502 + 0.22, 503 + mat(AMBER), 504 + ), 505 + ] 506 + } 507 + 508 + fn tab_display() -> Vec<Prim> { 509 + vec![ 510 + Prim::ball([0.0, -0.05, 0.0], 0.6, mat(STEEL_LIGHT)), 511 + Prim::ball([-0.22, 0.2, 0.4], 0.14, mat(LIGHT)), 512 + ] 513 + } 514 + 515 + fn zoom_to_fit() -> Vec<Prim> { 516 + vec![ 517 + ring([-0.12, 0.16], 0.46, 0.1, STEEL), 518 + bar([0.2, -0.16], [0.68, -0.64], 0.12, DARK), 519 + bar([-0.7, 0.6], [-0.4, 0.6], 0.06, AMBER), 520 + bar([-0.7, 0.6], [-0.7, 0.3], 0.06, AMBER), 521 + bar([0.7, -0.6], [0.4, -0.6], 0.06, AMBER), 522 + bar([0.7, -0.6], [0.7, -0.3], 0.06, AMBER), 523 + ] 524 + } 525 + 526 + fn zoom_to_area() -> Vec<Prim> { 527 + vec![ 528 + ring([-0.18, 0.18], 0.42, 0.1, STEEL), 529 + bar([0.14, -0.14], [0.62, -0.62], 0.12, DARK), 530 + rect( 531 + [[-0.45, -0.1], [0.35, -0.1], [0.35, 0.5], [-0.45, 0.5]], 532 + 0.05, 533 + AMBER, 534 + ), 535 + ] 536 + } 537 + 538 + fn previous_view() -> Vec<Prim> { 539 + vec![ 540 + arc([0.0, -0.05], 0.62, 35.0, 250.0, 0.1, STEEL), 541 + Prim::arrow( 542 + z0([0.62, 0.18]), 543 + z0([0.06, 0.5]), 544 + 0.05, 545 + 0.2, 546 + 0.26, 547 + mat(AMBER), 548 + ), 549 + ] 550 + } 551 + 552 + fn section_view() -> Vec<Prim> { 553 + vec![ 554 + Prim::cuboid([0.0, 0.0, 0.0], [0.55, 0.55, 0.55], mat(STEEL)), 555 + Prim::tilted_cuboid( 556 + [0.0, 0.0, 0.0], 557 + [0.66, 0.66, 0.025], 558 + Rotation::about_y(turn(45.0)), 559 + mat(AMBER_LIGHT), 560 + ), 561 + ] 562 + } 563 + 564 + fn view_orientation() -> Vec<Prim> { 565 + vec![ 566 + Prim::cuboid([0.0, 0.0, 0.0], [0.5, 0.5, 0.5], mat(STEEL_LIGHT)), 567 + Prim::bar([-0.55, -0.55, -0.55], [0.1, -0.55, -0.55], 0.06, mat(AX_X)), 568 + Prim::bar([-0.55, -0.55, -0.55], [-0.55, 0.1, -0.55], 0.06, mat(AX_Y)), 569 + Prim::bar([-0.55, -0.55, -0.55], [-0.55, -0.55, 0.1], 0.06, mat(AX_Z)), 570 + ] 571 + } 572 + 573 + fn display_style() -> Vec<Prim> { 574 + vec![ 575 + Prim::cuboid([0.0, -0.08, 0.0], [0.56, 0.48, 0.56], mat(STEEL)), 576 + Prim::cuboid([0.0, 0.46, 0.0], [0.56, 0.06, 0.56], mat(LIGHT)), 577 + ] 578 + } 579 + 580 + fn hide_show_items() -> Vec<Prim> { 581 + vec![ 582 + arc([0.0, -0.62], 0.92, 48.0, 84.0, 0.09, STEEL), 583 + arc([0.0, 0.62], 0.92, 228.0, 84.0, 0.09, STEEL), 584 + ring([0.0, 0.0], 0.32, 0.08, STEEL_LIGHT), 585 + dot([0.0, 0.0], 0.18, DARK), 586 + ] 587 + } 588 + 589 + fn edit_appearance() -> Vec<Prim> { 590 + vec![ 591 + Prim::ball([-0.1, -0.16, 0.24], 0.5, mat(AX_X)), 592 + Prim::ball([-0.24, 0.18, -0.12], 0.46, mat(AX_Y)), 593 + Prim::ball([0.2, -0.12, -0.22], 0.46, mat(AX_Z)), 594 + bar([0.18, 0.16], [0.7, 0.68], 0.09, AMBER), 595 + dot([0.16, 0.14], 0.16, DARK), 596 + ] 597 + } 598 + 599 + fn view_settings() -> Vec<Prim> { 600 + let teeth = (0..8u16).map(|k| { 601 + let angle = f32::from(k) * 45.0; 602 + let r = angle.to_radians(); 603 + let center = [0.62 * r.cos(), 0.62 * r.sin(), 0.0]; 604 + Prim::tilted_cuboid( 605 + center, 606 + [0.16, 0.12, 0.16], 607 + Rotation::about_z(turn(angle)), 608 + mat(STEEL), 609 + ) 610 + }); 611 + core::iter::once(ring([0.0, 0.0], 0.52, 0.13, STEEL)) 612 + .chain(teeth) 613 + .chain(core::iter::once(dot([0.0, 0.0], 0.22, DARK))) 614 + .collect() 615 + } 616 + 617 + fn check() -> Vec<Prim> { 618 + vec![ 619 + bar([-0.52, 0.04], [-0.08, -0.42], 0.1, WHITE), 620 + bar([-0.08, -0.42], [0.56, 0.52], 0.1, WHITE), 621 + ] 622 + } 623 + 624 + fn cross() -> Vec<Prim> { 625 + vec![ 626 + bar([-0.46, -0.46], [0.46, 0.46], 0.095, WHITE), 627 + bar([-0.46, 0.46], [0.46, -0.46], 0.095, WHITE), 628 + ] 629 + }
+58 -10
crates/bone-jig/src/tree.rs
··· 1 1 use std::collections::BTreeMap; 2 + use std::path::{Path, PathBuf}; 2 3 3 4 use accesskit::{Node, NodeId, Rect, Role, Toggled, TreeUpdate}; 4 - use serde::Serialize; 5 + use serde::{Deserialize, Serialize}; 5 6 use thiserror::Error; 6 7 7 8 use crate::scenario::NodeRef; 8 9 9 - #[derive(Clone, Debug, PartialEq, Serialize)] 10 + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 10 11 pub struct TreeDump { 11 12 pub root: DumpNode, 12 13 } 13 14 14 - #[derive(Clone, Debug, PartialEq, Serialize)] 15 + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 15 16 pub struct DumpNode { 16 17 #[serde(rename = "ref")] 17 18 pub node: NodeRef, 18 19 pub role: Role, 19 - #[serde(skip_serializing_if = "Option::is_none")] 20 + #[serde(default, skip_serializing_if = "Option::is_none")] 20 21 pub label: Option<String>, 21 - #[serde(skip_serializing_if = "Option::is_none")] 22 + #[serde(default, skip_serializing_if = "Option::is_none")] 22 23 pub bounds: Option<Rect>, 23 24 pub states: DumpStates, 24 - #[serde(skip_serializing_if = "Vec::is_empty")] 25 + #[serde(default, skip_serializing_if = "Vec::is_empty")] 25 26 pub children: Vec<DumpNode>, 26 27 } 27 28 28 - #[derive(Clone, Debug, PartialEq, Serialize)] 29 + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 29 30 pub struct DumpStates { 30 31 pub enabled: bool, 31 - #[serde(skip_serializing_if = "Option::is_none")] 32 + #[serde(default, skip_serializing_if = "Option::is_none")] 32 33 pub selected: Option<bool>, 33 - #[serde(skip_serializing_if = "Option::is_none")] 34 + #[serde(default, skip_serializing_if = "Option::is_none")] 34 35 pub expanded: Option<bool>, 35 - #[serde(skip_serializing_if = "Option::is_none")] 36 + #[serde(default, skip_serializing_if = "Option::is_none")] 36 37 pub toggled: Option<Toggled>, 37 38 } 38 39 ··· 46 47 ZeroNodeId, 47 48 } 48 49 50 + #[derive(Debug, Error)] 51 + pub enum TreeLoadError { 52 + #[error("read {path}: {error}")] 53 + Read { 54 + path: PathBuf, 55 + error: std::io::Error, 56 + }, 57 + #[error("parse {path}: {error}")] 58 + Parse { 59 + path: PathBuf, 60 + error: serde_json::Error, 61 + }, 62 + } 63 + 49 64 impl TreeDump { 50 65 pub fn from_update(update: &TreeUpdate) -> Result<Self, TreeDumpError> { 51 66 let nodes: BTreeMap<NodeId, &Node> = ··· 54 69 Ok(Self { 55 70 root: dump_node(root, &nodes)?, 56 71 }) 72 + } 73 + 74 + pub fn load(path: &Path) -> Result<Self, TreeLoadError> { 75 + let text = std::fs::read_to_string(path).map_err(|error| TreeLoadError::Read { 76 + path: path.to_path_buf(), 77 + error, 78 + })?; 79 + serde_json::from_str(&text).map_err(|error| TreeLoadError::Parse { 80 + path: path.to_path_buf(), 81 + error, 82 + }) 83 + } 84 + 85 + #[must_use] 86 + pub fn nodes(&self) -> Vec<&DumpNode> { 87 + self.root.subtree() 88 + } 89 + 90 + #[must_use] 91 + pub fn matching(&self, role: Role, label: &str) -> Vec<&DumpNode> { 92 + self.nodes() 93 + .into_iter() 94 + .filter(|n| n.role == role && n.label.as_deref() == Some(label)) 95 + .collect() 57 96 } 58 97 59 98 #[must_use] 60 99 pub fn to_text(&self) -> String { 61 100 lines(&self.root, 0).into_iter().map(|l| l + "\n").collect() 101 + } 102 + } 103 + 104 + impl DumpNode { 105 + #[must_use] 106 + pub fn subtree(&self) -> Vec<&Self> { 107 + core::iter::once(self) 108 + .chain(self.children.iter().flat_map(Self::subtree)) 109 + .collect() 62 110 } 63 111 } 64 112
+39
crates/bone-jig/tests/common/mod.rs
··· 1 + use std::path::{Path, PathBuf}; 2 + 3 + use bone_jig::{RunResult, Scenario, ScenarioSource, run_scenario}; 4 + 5 + #[must_use] 6 + pub fn manifest_path(rel: &str) -> PathBuf { 7 + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(rel) 8 + } 9 + 10 + #[must_use] 11 + pub fn run_library_scenario(name: &str, out_dir: &Path) -> RunResult { 12 + let source = ScenarioSource::Path(manifest_path(&format!("scenarios/{name}"))); 13 + let scenario = match Scenario::load(&source) { 14 + Ok(s) => s, 15 + Err(e) => panic!("load {name}: {e}"), 16 + }; 17 + let mut sink = Vec::new(); 18 + match run_scenario(&scenario, out_dir, &mut sink) { 19 + Ok(result) => result, 20 + Err(e) => panic!("run {name}: {e}"), 21 + } 22 + } 23 + 24 + #[must_use] 25 + pub fn find_artifact(out_dir: &Path, suffix: &str) -> PathBuf { 26 + let entries = match std::fs::read_dir(out_dir) { 27 + Ok(it) => it, 28 + Err(e) => panic!("read {}: {e}", out_dir.display()), 29 + }; 30 + entries 31 + .filter_map(Result::ok) 32 + .map(|entry| entry.path()) 33 + .find(|path| { 34 + path.file_name() 35 + .and_then(|n| n.to_str()) 36 + .is_some_and(|n| n.ends_with(suffix)) 37 + }) 38 + .unwrap_or_else(|| panic!("no artifact ending in {suffix} under {}", out_dir.display())) 39 + }
crates/bone-jig/tests/goldens/smoke_final.png

This is a binary file and will not be displayed.

+88
crates/bone-jig/tests/icon_atlas.rs
··· 1 + use std::path::PathBuf; 2 + 3 + use bone_jig::baker::{BakedPage, bake}; 4 + use bone_render::{AtlasGrid, AtlasPage, encode_png_rgba}; 5 + use bone_types::IconId; 6 + 7 + const UPDATE_ENV: &str = "BONE_UPDATE_ICON_ATLAS"; 8 + 9 + fn committed_path(page: AtlasPage) -> PathBuf { 10 + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(format!( 11 + "../bone-render/assets/icons_{}.png", 12 + page.side_px() 13 + )) 14 + } 15 + 16 + fn baked() -> Vec<BakedPage> { 17 + match bake() { 18 + Ok(pages) => pages, 19 + Err(error) => panic!("icon bake failed, is a software adapter available: {error}"), 20 + } 21 + } 22 + 23 + #[test] 24 + fn committed_atlas_pages_match_generator() { 25 + let pages = baked(); 26 + let updating = std::env::var(UPDATE_ENV).is_ok(); 27 + 28 + pages.iter().for_each(|page| { 29 + let Ok(png) = encode_png_rgba(page.extent, &page.rgba) else { 30 + panic!("encode_png_rgba failed for page {:?}", page.page); 31 + }; 32 + let path = committed_path(page.page); 33 + if updating { 34 + let Ok(()) = std::fs::write(&path, &png) else { 35 + panic!("write atlas page {}", path.display()); 36 + }; 37 + return; 38 + } 39 + let Ok(committed) = std::fs::read(&path) else { 40 + panic!( 41 + "committed {} missing; rerun with {UPDATE_ENV}=1 to create it", 42 + path.display(), 43 + ); 44 + }; 45 + assert_eq!( 46 + committed, 47 + png, 48 + "committed {} drifted from the baker; rerun with {UPDATE_ENV}=1 to refresh", 49 + path.display(), 50 + ); 51 + }); 52 + } 53 + 54 + #[test] 55 + fn every_tile_has_visible_pixels() { 56 + let grid = AtlasGrid::DEFAULT; 57 + baked().iter().for_each(|page| { 58 + let empty: Vec<IconId> = IconId::ALL 59 + .iter() 60 + .copied() 61 + .filter(|id| tile_alpha_sum(page, grid, *id) == 0) 62 + .collect(); 63 + assert!( 64 + empty.is_empty(), 65 + "page {:?} baked empty tiles: {empty:?}", 66 + page.page, 67 + ); 68 + }); 69 + } 70 + 71 + fn tile_alpha_sum(page: &BakedPage, grid: AtlasGrid, id: IconId) -> u32 { 72 + let (width, _) = grid.pixel_size(page.page); 73 + let width = usize_of(width); 74 + let side = usize_of(page.page.side_px()); 75 + let (ox, oy) = grid.tile_origin(grid.cell(id.tile()), page.page); 76 + let (ox, oy) = (usize_of(ox), usize_of(oy)); 77 + (0..side) 78 + .flat_map(|dy| (0..side).map(move |dx| (dx, dy))) 79 + .map(|(dx, dy)| u32::from(page.rgba[((oy + dy) * width + (ox + dx)) * 4 + 3])) 80 + .sum() 81 + } 82 + 83 + fn usize_of(value: u32) -> usize { 84 + let Ok(out) = usize::try_from(value) else { 85 + panic!("atlas dimension fits usize"); 86 + }; 87 + out 88 + }
+135
crates/bone-jig/tests/icon_smoke.rs
··· 1 + use bone_jig::icon::{IconMaterial, IconModel, Prim, icon_camera, icon_style}; 2 + use bone_jig::models::model; 3 + use bone_jig::offscreen_context; 4 + use bone_render::{ 5 + SnapshotFrame, SolidRenderer, ViewportExtent, ViewportPx, encode_png, encode_png_rgba, 6 + }; 7 + use bone_types::{DisplayMode, IconId, LinearRgba}; 8 + 9 + const UPDATE_ENV: &str = "BONE_UPDATE_ICON_SMOKE"; 10 + const TILE: u32 = 96; 11 + const COLS: u32 = 8; 12 + 13 + fn box_model() -> IconModel { 14 + IconModel::new(vec![Prim::cuboid( 15 + [0.0, 0.0, 0.0], 16 + [0.6, 0.6, 0.6], 17 + IconMaterial::new(LinearRgba::new(0.82, 0.84, 0.88, 1.0)), 18 + )]) 19 + } 20 + 21 + fn render( 22 + renderer: &mut SolidRenderer, 23 + ctx: &bone_render::OffscreenContext, 24 + m: &IconModel, 25 + ) -> SnapshotFrame { 26 + let (scene, edges) = m.tessellate(); 27 + let Ok(frame) = renderer.render_display( 28 + ctx, 29 + &scene, 30 + &edges, 31 + icon_camera(m.view()), 32 + &icon_style(), 33 + DisplayMode::ShadedWithEdges, 34 + ) else { 35 + panic!("render_display failed"); 36 + }; 37 + frame 38 + } 39 + 40 + fn visible_pixels(frame: &SnapshotFrame, background: [u8; 4]) -> usize { 41 + frame 42 + .rgba() 43 + .chunks_exact(4) 44 + .filter(|px| px[0] != background[0] || px[1] != background[1] || px[2] != background[2]) 45 + .count() 46 + } 47 + 48 + #[test] 49 + fn box_icon_renders_visible_geometry() { 50 + let extent = ViewportExtent::square(ViewportPx::new(256)); 51 + let Ok(ctx) = offscreen_context(extent) else { 52 + panic!("offscreen context with the software adapter is unavailable"); 53 + }; 54 + let mut renderer = SolidRenderer::new(ctx.gpu(), ctx.color_format()); 55 + let frame = render(&mut renderer, &ctx, &box_model()); 56 + let background = icon_style().background().to_rgba8(); 57 + assert!( 58 + visible_pixels(&frame, background) > 1000, 59 + "expected the box to cover many pixels", 60 + ); 61 + 62 + if std::env::var(UPDATE_ENV).is_ok() { 63 + let Ok(png) = encode_png(&frame) else { 64 + panic!("encode_png failed"); 65 + }; 66 + let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) 67 + .join("../../target/icon_smoke.png"); 68 + let Ok(()) = std::fs::write(&path, &png) else { 69 + panic!("write smoke png"); 70 + }; 71 + } 72 + } 73 + 74 + #[test] 75 + fn every_icon_model_renders_visible_geometry() { 76 + let extent = ViewportExtent::square(ViewportPx::new(TILE)); 77 + let Ok(ctx) = offscreen_context(extent) else { 78 + panic!("offscreen context with the software adapter is unavailable"); 79 + }; 80 + let mut renderer = SolidRenderer::new(ctx.gpu(), ctx.color_format()); 81 + let background = icon_style().background().to_rgba8(); 82 + 83 + let frames: Vec<(IconId, SnapshotFrame)> = IconId::ALL 84 + .iter() 85 + .map(|&id| (id, render(&mut renderer, &ctx, &model(id)))) 86 + .collect(); 87 + 88 + frames.iter().for_each(|(id, frame)| { 89 + let visible = visible_pixels(frame, background); 90 + assert!( 91 + visible > 60, 92 + "icon {id:?} rendered nearly empty: {visible} visible pixels", 93 + ); 94 + }); 95 + 96 + if std::env::var(UPDATE_ENV).is_ok() { 97 + write_contact_sheet(&frames, background); 98 + } 99 + } 100 + 101 + fn write_contact_sheet(frames: &[(IconId, SnapshotFrame)], background: [u8; 4]) { 102 + let count = u32::try_from(frames.len()).unwrap_or(0); 103 + let rows = count.div_ceil(COLS); 104 + let width = COLS * TILE; 105 + let height = rows * TILE; 106 + let mut sheet = vec![0u8; (width * height * 4) as usize]; 107 + sheet 108 + .chunks_exact_mut(4) 109 + .for_each(|px| px.copy_from_slice(&background)); 110 + 111 + frames.iter().enumerate().for_each(|(index, (_, frame))| { 112 + let idx = u32::try_from(index).unwrap_or(0); 113 + let col = idx % COLS; 114 + let row = idx / COLS; 115 + let ox = col * TILE; 116 + let oy = row * TILE; 117 + (0..TILE).for_each(|y| { 118 + let src_start = (y * TILE * 4) as usize; 119 + let dst_start = (((oy + y) * width + ox) * 4) as usize; 120 + let span = (TILE * 4) as usize; 121 + sheet[dst_start..dst_start + span] 122 + .copy_from_slice(&frame.rgba()[src_start..src_start + span]); 123 + }); 124 + }); 125 + 126 + let extent = ViewportExtent::new(ViewportPx::new(width), ViewportPx::new(height)); 127 + let Ok(png) = encode_png_rgba(extent, &sheet) else { 128 + panic!("encode_png_rgba failed"); 129 + }; 130 + let path = 131 + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/icon_gallery.png"); 132 + let Ok(()) = std::fs::write(&path, &png) else { 133 + panic!("write gallery png"); 134 + }; 135 + }
+185
crates/bone-jig/tests/parity.rs
··· 1 + mod common; 2 + 3 + use accesskit::{Rect, Role}; 4 + use bone_jig::{RunStatus, TreeDump}; 5 + use common::{find_artifact, run_library_scenario}; 6 + 7 + const WINDOW_W: f64 = 1920.0; 8 + const WINDOW_H: f64 = 1080.0; 9 + const PX_TOLERANCE: f64 = 0.5; 10 + 11 + fn surface_dump(scenario: &str) -> TreeDump { 12 + let dir = match tempfile::tempdir() { 13 + Ok(d) => d, 14 + Err(e) => panic!("tempdir: {e}"), 15 + }; 16 + let result = run_library_scenario(scenario, dir.path()); 17 + assert_eq!(result.status, RunStatus::Passed, "{scenario}: {result:?}"); 18 + let tree_path = find_artifact(dir.path(), ".tree.json"); 19 + match TreeDump::load(&tree_path) { 20 + Ok(dump) => dump, 21 + Err(e) => panic!("{e}"), 22 + } 23 + } 24 + 25 + fn the_one(dump: &TreeDump, role: Role, label: &str) -> Rect { 26 + let matches = dump.matching(role, label); 27 + match matches.as_slice() { 28 + [node] => node.bounds.unwrap_or_else(|| { 29 + panic!( 30 + "{role:?} {label:?} carries no bounds; tree:\n{}", 31 + dump.to_text() 32 + ) 33 + }), 34 + other => panic!( 35 + "expected exactly one {role:?} {label:?}, found {}; tree:\n{}", 36 + other.len(), 37 + dump.to_text(), 38 + ), 39 + } 40 + } 41 + 42 + fn is_selected(dump: &TreeDump, role: Role, label: &str) -> bool { 43 + let matches = dump.matching(role, label); 44 + match matches.as_slice() { 45 + [node] => node.states.selected == Some(true), 46 + other => panic!( 47 + "expected exactly one {role:?} {label:?}, found {}; tree:\n{}", 48 + other.len(), 49 + dump.to_text(), 50 + ), 51 + } 52 + } 53 + 54 + #[track_caller] 55 + fn assert_px(actual: f64, expected: f64, what: &str) { 56 + assert!( 57 + (actual - expected).abs() <= PX_TOLERANCE, 58 + "{what}: {actual} expected {expected}", 59 + ); 60 + } 61 + 62 + fn assert_window_chrome(dump: &TreeDump) { 63 + let menu = the_one(dump, Role::MenuBar, "Menu Bar"); 64 + assert_px(menu.x0, 0.0, "menu bar left edge"); 65 + assert_px(menu.y0, 0.0, "menu bar top edge"); 66 + assert_px(menu.x1, WINDOW_W, "menu bar right edge"); 67 + assert_px(menu.height(), 24.0, "menu bar height"); 68 + let status = the_one(dump, Role::Status, "Status Bar"); 69 + assert_px(status.x0, 0.0, "status bar left edge"); 70 + assert_px(status.x1, WINDOW_W, "status bar right edge"); 71 + assert_px(status.y1, WINDOW_H, "status bar bottom edge"); 72 + assert_px(status.height(), 22.0, "status bar height"); 73 + } 74 + 75 + #[test] 76 + fn shell_chrome_bands_and_tree_metrics() { 77 + let dump = surface_dump("parity_shell.ron"); 78 + assert_window_chrome(&dump); 79 + assert!(is_selected(&dump, Role::Tab, "Features")); 80 + let ribbon = the_one(&dump, Role::TabPanel, "Ribbon"); 81 + assert_px(ribbon.y0, 24.0, "ribbon top edge"); 82 + assert_px(ribbon.height(), 82.0, "ribbon band height"); 83 + assert_px(ribbon.x1, WINDOW_W, "ribbon right edge"); 84 + let tree = the_one(&dump, Role::Tree, "Feature Tree"); 85 + assert_px(tree.x1, 222.0, "feature tree right edge"); 86 + let front = the_one(&dump, Role::TreeItem, "Front Plane"); 87 + let right = the_one(&dump, Role::TreeItem, "Right Plane"); 88 + let top = the_one(&dump, Role::TreeItem, "Top Plane"); 89 + assert_px(right.y0 - front.y0, 20.0, "feature tree row pitch"); 90 + assert_px(top.y0 - right.y0, 20.0, "feature tree row pitch"); 91 + let strip = the_one(&dump, Role::TabList, "Left Pane"); 92 + assert_px(strip.height(), 28.0, "left pane tab strip height"); 93 + let tab = the_one(&dump, Role::Tab, "Feature Tree"); 94 + assert_px(tab.width(), 28.0, "left pane tab width"); 95 + let heads_up = the_one(&dump, Role::Toolbar, "View Heads-Up Toolbar"); 96 + assert_px(heads_up.y0, 114.0, "heads-up toolbar top edge"); 97 + assert_px(heads_up.height(), 30.0, "heads-up toolbar height"); 98 + assert!( 99 + heads_up.x0 > tree.x1, 100 + "heads-up toolbar floats over the viewport, not the left pane", 101 + ); 102 + let status = the_one(&dump, Role::Status, "Status Bar"); 103 + let doc_tabs = the_one(&dump, Role::TabList, "Document Tabs"); 104 + assert_px(doc_tabs.x0, 0.0, "document tabs left edge"); 105 + assert_px( 106 + doc_tabs.y1, 107 + status.y0, 108 + "document tabs sit directly above status bar", 109 + ); 110 + assert_px(doc_tabs.height(), 22.0, "document tabs strip height"); 111 + let model = the_one(&dump, Role::Tab, "Model"); 112 + assert_px(model.x0, 0.0, "model tab is left-aligned"); 113 + } 114 + 115 + #[test] 116 + fn property_manager_pane_replaces_the_tree() { 117 + let dump = surface_dump("parity_property_manager.ron"); 118 + assert_window_chrome(&dump); 119 + assert!(is_selected(&dump, Role::Tab, "Property Manager")); 120 + let pane = the_one(&dump, Role::Form, "Property Manager"); 121 + assert_px(pane.x1, 222.0, "property pane right edge"); 122 + assert!( 123 + dump.matching(Role::Tree, "Feature Tree").is_empty(), 124 + "tree must yield to the property pane; tree:\n{}", 125 + dump.to_text(), 126 + ); 127 + let header = the_one(&dump, Role::Pane, "Extrude"); 128 + assert_px(header.y0, 142.0, "property header top edge"); 129 + assert_px(header.height(), 44.0, "property header height"); 130 + let direction = the_one(&dump, Role::Pane, "Direction 1"); 131 + assert_px(direction.x1, 222.0, "direction group right edge"); 132 + assert!( 133 + direction.y0 > header.y1, 134 + "direction group sits below header" 135 + ); 136 + let scope = the_one(&dump, Role::Pane, "Feature Scope"); 137 + assert!( 138 + scope.y0 > direction.y1, 139 + "feature scope sits below direction group", 140 + ); 141 + let accept = the_one(&dump, Role::Button, "Accept"); 142 + let cancel = the_one(&dump, Role::Button, "Cancel"); 143 + assert_px(accept.width(), 36.0, "accept button width"); 144 + assert_px(cancel.width(), 36.0, "cancel button width"); 145 + } 146 + 147 + #[test] 148 + fn sketch_mode_activates_the_sketch_ribbon() { 149 + let dump = surface_dump("parity_sketch_mode.ron"); 150 + assert_window_chrome(&dump); 151 + assert!(is_selected(&dump, Role::Tab, "Sketch")); 152 + assert!(is_selected(&dump, Role::TreeItem, "Sketch1")); 153 + let entities = the_one(&dump, Role::Toolbar, "Entities"); 154 + let relations = the_one(&dump, Role::Toolbar, "Relations"); 155 + let dimensions = the_one(&dump, Role::Toolbar, "Dimensions"); 156 + assert_px(entities.y0, 32.0, "entities toolbar top edge"); 157 + assert!(relations.x0 > entities.x1, "relations sit after entities"); 158 + assert!( 159 + dimensions.x0 > relations.x1, 160 + "dimensions sit after relations" 161 + ); 162 + let _ = the_one(&dump, Role::Button, "Accept"); 163 + let _ = the_one(&dump, Role::Button, "Cancel"); 164 + } 165 + 166 + #[test] 167 + fn sketch_entities_stay_in_sketch_mode() { 168 + let dump = surface_dump("parity_sketch_entities.ron"); 169 + assert_window_chrome(&dump); 170 + assert!(is_selected(&dump, Role::Tab, "Sketch")); 171 + let _ = the_one(&dump, Role::Toolbar, "Relations"); 172 + } 173 + 174 + #[test] 175 + fn options_dialog_is_centered() { 176 + let dump = surface_dump("parity_options_dialog.ron"); 177 + assert_window_chrome(&dump); 178 + let dialog = the_one(&dump, Role::Dialog, "Selection options"); 179 + assert_px( 180 + dialog.x0, 181 + WINDOW_W - dialog.x1, 182 + "dialog horizontal centering", 183 + ); 184 + assert_px(dialog.y0, WINDOW_H - dialog.y1, "dialog vertical centering"); 185 + }
+5 -35
crates/bone-jig/tests/smoke.rs
··· 1 + mod common; 2 + 1 3 use std::path::{Path, PathBuf}; 2 4 3 - use bone_jig::{RunStatus, Scenario, ScenarioSource, StepOutcome, run_scenario}; 5 + use bone_jig::{RunStatus, Scenario, StepOutcome, run_scenario}; 4 6 use bone_render::{PixelDiff, PixelDiffThreshold, decode_png}; 7 + use common::{find_artifact, manifest_path, run_library_scenario}; 5 8 6 9 const GOLDEN_DIFF_TOLERANCE: f64 = 16.0 / 255.0; 7 10 const UPDATE_ENV: &str = "BONE_UPDATE_JIG_SMOKE"; 8 - 9 - fn manifest_path(rel: &str) -> PathBuf { 10 - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(rel) 11 - } 12 - 13 - fn run_library_scenario(name: &str, out_dir: &Path) -> bone_jig::RunResult { 14 - let source = ScenarioSource::Path(manifest_path(&format!("scenarios/{name}"))); 15 - let scenario = match Scenario::load(&source) { 16 - Ok(s) => s, 17 - Err(e) => panic!("load {name}: {e}"), 18 - }; 19 - let mut sink = Vec::new(); 20 - match run_scenario(&scenario, out_dir, &mut sink) { 21 - Ok(result) => result, 22 - Err(e) => panic!("run {name}: {e}"), 23 - } 24 - } 25 - 26 - fn find_artifact(out_dir: &Path, suffix: &str) -> PathBuf { 27 - let entries = match std::fs::read_dir(out_dir) { 28 - Ok(it) => it, 29 - Err(e) => panic!("read {}: {e}", out_dir.display()), 30 - }; 31 - entries 32 - .filter_map(Result::ok) 33 - .map(|entry| entry.path()) 34 - .find(|path| { 35 - path.file_name() 36 - .and_then(|n| n.to_str()) 37 - .is_some_and(|n| n.ends_with(suffix)) 38 - }) 39 - .unwrap_or_else(|| panic!("no artifact ending in {suffix} under {}", out_dir.display())) 40 - } 41 11 42 12 fn check_golden(actual_png: &Path, golden: &Path) { 43 13 let bytes = match std::fs::read(actual_png) { ··· 214 184 ); 215 185 }) 216 186 .collect(); 217 - assert!(ran.len() >= 7, "library shrank: {ran:?}"); 187 + assert!(ran.len() >= 12, "library shrank: {ran:?}"); 218 188 }
crates/bone-render/assets/icons_16.png

This is a binary file and will not be displayed.

crates/bone-render/assets/icons_24.png

This is a binary file and will not be displayed.

crates/bone-render/assets/icons_32.png

This is a binary file and will not be displayed.

crates/bone-render/assets/icons_48.png

This is a binary file and will not be displayed.

+182
crates/bone-render/src/atlas.rs
··· 1 + use bone_types::{IconId, IconTile}; 2 + 3 + pub const TILE_PAD: u32 = 2; 4 + 5 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] 6 + pub struct GridCell { 7 + pub col: usize, 8 + pub row: usize, 9 + } 10 + 11 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 12 + pub struct AtlasGrid { 13 + cols: usize, 14 + rows: usize, 15 + } 16 + 17 + impl AtlasGrid { 18 + pub const DEFAULT: Self = Self { cols: 7, rows: 7 }; 19 + 20 + #[must_use] 21 + pub const fn new(cols: usize, rows: usize) -> Self { 22 + Self { cols, rows } 23 + } 24 + 25 + #[must_use] 26 + pub const fn cols(self) -> usize { 27 + self.cols 28 + } 29 + 30 + #[must_use] 31 + pub const fn rows(self) -> usize { 32 + self.rows 33 + } 34 + 35 + #[must_use] 36 + pub const fn capacity(self) -> usize { 37 + self.cols * self.rows 38 + } 39 + 40 + #[must_use] 41 + pub fn cell(self, tile: IconTile) -> GridCell { 42 + let index = tile.as_usize(); 43 + GridCell { 44 + col: index % self.cols, 45 + row: index / self.cols, 46 + } 47 + } 48 + 49 + #[must_use] 50 + pub fn pixel_size(self, page: AtlasPage) -> (u32, u32) { 51 + let cell = page.cell_px(); 52 + let Ok(cols) = u32::try_from(self.cols) else { 53 + panic!("atlas grid columns fit a u32"); 54 + }; 55 + let Ok(rows) = u32::try_from(self.rows) else { 56 + panic!("atlas grid rows fit a u32"); 57 + }; 58 + (cols * cell, rows * cell) 59 + } 60 + 61 + #[must_use] 62 + pub fn tile_origin(self, cell: GridCell, page: AtlasPage) -> (u32, u32) { 63 + let stride = page.cell_px(); 64 + let Ok(col) = u32::try_from(cell.col) else { 65 + panic!("atlas cell column fits a u32"); 66 + }; 67 + let Ok(row) = u32::try_from(cell.row) else { 68 + panic!("atlas cell row fits a u32"); 69 + }; 70 + (col * stride + TILE_PAD, row * stride + TILE_PAD) 71 + } 72 + } 73 + 74 + const _: () = assert!( 75 + IconId::COUNT <= AtlasGrid::DEFAULT.capacity(), 76 + "icon count exceeds the default atlas grid; grow AtlasGrid::DEFAULT", 77 + ); 78 + 79 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] 80 + pub enum AtlasPage { 81 + Px16, 82 + Px24, 83 + Px32, 84 + Px48, 85 + } 86 + 87 + impl AtlasPage { 88 + pub const ALL: [AtlasPage; 4] = [ 89 + AtlasPage::Px16, 90 + AtlasPage::Px24, 91 + AtlasPage::Px32, 92 + AtlasPage::Px48, 93 + ]; 94 + 95 + #[must_use] 96 + pub const fn side_px(self) -> u32 { 97 + match self { 98 + AtlasPage::Px16 => 16, 99 + AtlasPage::Px24 => 24, 100 + AtlasPage::Px32 => 32, 101 + AtlasPage::Px48 => 48, 102 + } 103 + } 104 + 105 + #[must_use] 106 + pub const fn cell_px(self) -> u32 { 107 + self.side_px() + 2 * TILE_PAD 108 + } 109 + 110 + #[must_use] 111 + pub fn nearest(requested_px: u32) -> AtlasPage { 112 + AtlasPage::ALL 113 + .into_iter() 114 + .filter(|page| page.side_px() >= requested_px) 115 + .min_by_key(|page| page.side_px()) 116 + .unwrap_or(AtlasPage::Px48) 117 + } 118 + } 119 + 120 + const _: () = assert!( 121 + AtlasPage::Px16.side_px() < AtlasPage::Px24.side_px() 122 + && AtlasPage::Px24.side_px() < AtlasPage::Px32.side_px() 123 + && AtlasPage::Px32.side_px() < AtlasPage::Px48.side_px(), 124 + "AtlasPage sizes must stay distinct and ascending so Px48 is the largest fallback for nearest", 125 + ); 126 + 127 + #[cfg(test)] 128 + mod tests { 129 + use super::{AtlasGrid, AtlasPage, GridCell}; 130 + use bone_types::IconId; 131 + 132 + #[test] 133 + fn default_grid_holds_every_icon() { 134 + assert_eq!(AtlasGrid::DEFAULT.capacity(), 49); 135 + assert!(IconId::COUNT <= AtlasGrid::DEFAULT.capacity()); 136 + } 137 + 138 + #[test] 139 + fn cell_is_row_major() { 140 + let grid = AtlasGrid::DEFAULT; 141 + assert_eq!(grid.cell(IconId::Point.tile()), GridCell { col: 0, row: 0 }); 142 + assert_eq!( 143 + grid.cell(IconId::PerimeterCircle.tile()), 144 + GridCell { col: 6, row: 0 } 145 + ); 146 + assert_eq!( 147 + grid.cell(IconId::CornerRectangle.tile()), 148 + GridCell { col: 0, row: 1 } 149 + ); 150 + assert_eq!( 151 + grid.cell(IconId::CenterRectangle.tile()), 152 + GridCell { col: 1, row: 1 } 153 + ); 154 + } 155 + 156 + #[test] 157 + fn every_icon_cell_is_within_the_grid() { 158 + let grid = AtlasGrid::DEFAULT; 159 + IconId::ALL.iter().for_each(|icon| { 160 + let cell = grid.cell(icon.tile()); 161 + assert!(cell.col < grid.cols(), "{icon:?} column out of range"); 162 + assert!(cell.row < grid.rows(), "{icon:?} row out of range"); 163 + }); 164 + } 165 + 166 + #[test] 167 + fn nearest_page_picks_the_smallest_sufficient_size() { 168 + assert_eq!(AtlasPage::nearest(14), AtlasPage::Px16); 169 + assert_eq!(AtlasPage::nearest(16), AtlasPage::Px16); 170 + assert_eq!(AtlasPage::nearest(17), AtlasPage::Px24); 171 + assert_eq!(AtlasPage::nearest(48), AtlasPage::Px48); 172 + assert_eq!(AtlasPage::nearest(1000), AtlasPage::Px48); 173 + } 174 + 175 + #[test] 176 + fn page_sides_match_logical_sizes() { 177 + assert_eq!(AtlasPage::Px16.side_px(), 16); 178 + assert_eq!(AtlasPage::Px24.side_px(), 24); 179 + assert_eq!(AtlasPage::Px32.side_px(), 32); 180 + assert_eq!(AtlasPage::Px48.side_px(), 48); 181 + } 182 + }
+4 -2
crates/bone-render/src/lib.rs
··· 1 + pub mod atlas; 1 2 pub mod camera; 2 3 pub mod camera3; 3 4 pub mod diff; ··· 11 12 pub mod surface; 12 13 pub mod tween; 13 14 15 + pub use atlas::{AtlasGrid, AtlasPage, GridCell, TILE_PAD}; 14 16 pub use camera::{Camera2, GridSpacing, PixelsPerMm, ViewportExtent, ViewportPx, ViewportRegion}; 15 17 pub use camera3::{ 16 18 ViewportPoint, arcball_rotation, clip_from_world, frame_current, frame_isometric, ··· 26 28 }; 27 29 pub use pipelines::{ 28 30 ArcPipeline, ChromeInstance, ChromePipeline, ChromeTextPipeline, ConvexInstance, 29 - ConvexPolyPipeline, GlyphPipeline, GridPipeline, LinesPipeline, MAX_PLANES, MAX_STROKE_POINTS, 30 - SdfGlyphInstance, StrokeInstance, StrokePipeline, TextPipeline, 31 + ConvexPolyPipeline, GlyphPipeline, GridPipeline, IconInstance, IconPipeline, LinesPipeline, 32 + MAX_PLANES, MAX_STROKE_POINTS, SdfGlyphInstance, StrokeInstance, StrokePipeline, TextPipeline, 31 33 }; 32 34 pub(crate) use pipelines::{ 33 35 Edge3dPipeline, EdgeProjection, EdgeView, FaceFill, HiddenEdges, SolidPipeline, SolidView,
+503
crates/bone-render/src/pipelines/icon.rs
··· 1 + use crate::atlas::{AtlasGrid, AtlasPage, TILE_PAD}; 2 + use crate::gpu::Gpu; 3 + use crate::snapshot::decode_png; 4 + 5 + #[repr(C, align(16))] 6 + #[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] 7 + pub struct IconInstance { 8 + pub rect_xywh_px: [f32; 4], 9 + pub tint_premul_rgba: [f32; 4], 10 + pub tile_index: u32, 11 + pub(crate) pad: [u32; 3], 12 + } 13 + 14 + impl IconInstance { 15 + #[must_use] 16 + pub const fn new(rect_xywh_px: [f32; 4], tile_index: u32, tint_premul_rgba: [f32; 4]) -> Self { 17 + Self { 18 + rect_xywh_px, 19 + tint_premul_rgba, 20 + tile_index, 21 + pad: [0, 0, 0], 22 + } 23 + } 24 + } 25 + 26 + const INSTANCE_STRIDE: u64 = core::mem::size_of::<IconInstance>() as u64; 27 + const INITIAL_INSTANCE_CAP: u64 = 64; 28 + 29 + #[repr(C, align(16))] 30 + #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] 31 + struct IconFrame { 32 + viewport_px: [f32; 2], 33 + atlas_px: [f32; 2], 34 + grid_cols: f32, 35 + tile_pad: f32, 36 + _pad: [f32; 2], 37 + pages: [[f32; 4]; AtlasPage::ALL.len()], 38 + } 39 + 40 + const FRAME_SIZE: u64 = core::mem::size_of::<IconFrame>() as u64; 41 + 42 + const ATLAS_16: &[u8] = include_bytes!("../../assets/icons_16.png"); 43 + const ATLAS_24: &[u8] = include_bytes!("../../assets/icons_24.png"); 44 + const ATLAS_32: &[u8] = include_bytes!("../../assets/icons_32.png"); 45 + const ATLAS_48: &[u8] = include_bytes!("../../assets/icons_48.png"); 46 + 47 + pub struct IconPipeline { 48 + device: wgpu::Device, 49 + queue: wgpu::Queue, 50 + pipeline: wgpu::RenderPipeline, 51 + uniform_buffer: wgpu::Buffer, 52 + bind_group: wgpu::BindGroup, 53 + instance_buffer: wgpu::Buffer, 54 + instance_capacity: u64, 55 + frame: IconFrame, 56 + } 57 + 58 + impl IconPipeline { 59 + #[must_use] 60 + pub fn new(gpu: &Gpu, color_format: wgpu::TextureFormat) -> Self { 61 + let device = gpu.device().clone(); 62 + let queue = gpu.queue().clone(); 63 + let atlas = CombinedAtlas::build(); 64 + let (atlas_view, sampler) = atlas.upload(&device, &queue); 65 + let bind_group_layout = create_bind_group_layout(&device); 66 + let pipeline = create_pipeline(&device, &bind_group_layout, color_format); 67 + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { 68 + label: Some("bone-render:icon-uniform"), 69 + size: FRAME_SIZE, 70 + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, 71 + mapped_at_creation: false, 72 + }); 73 + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { 74 + label: Some("bone-render:icon-bg"), 75 + layout: &bind_group_layout, 76 + entries: &[ 77 + wgpu::BindGroupEntry { 78 + binding: 0, 79 + resource: uniform_buffer.as_entire_binding(), 80 + }, 81 + wgpu::BindGroupEntry { 82 + binding: 1, 83 + resource: wgpu::BindingResource::TextureView(&atlas_view), 84 + }, 85 + wgpu::BindGroupEntry { 86 + binding: 2, 87 + resource: wgpu::BindingResource::Sampler(&sampler), 88 + }, 89 + ], 90 + }); 91 + let instance_buffer = create_instance_buffer(&device, INITIAL_INSTANCE_CAP); 92 + Self { 93 + device, 94 + queue, 95 + pipeline, 96 + uniform_buffer, 97 + bind_group, 98 + instance_buffer, 99 + instance_capacity: INITIAL_INSTANCE_CAP, 100 + frame: atlas.frame(), 101 + } 102 + } 103 + 104 + pub fn upload(&mut self, viewport_px: [f32; 2], instances: &[IconInstance]) { 105 + self.frame.viewport_px = viewport_px; 106 + self.queue 107 + .write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&self.frame)); 108 + if instances.is_empty() { 109 + return; 110 + } 111 + let needed = instances.len() as u64; 112 + if needed > self.instance_capacity { 113 + let new_cap = needed.next_power_of_two().max(self.instance_capacity * 2); 114 + self.instance_buffer = create_instance_buffer(&self.device, new_cap); 115 + self.instance_capacity = new_cap; 116 + } 117 + self.queue 118 + .write_buffer(&self.instance_buffer, 0, bytemuck::cast_slice(instances)); 119 + } 120 + 121 + pub fn draw_range( 122 + &self, 123 + encoder: &mut wgpu::CommandEncoder, 124 + color_view: &wgpu::TextureView, 125 + range: core::ops::Range<u32>, 126 + ) { 127 + if range.start >= range.end { 128 + return; 129 + } 130 + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { 131 + label: Some("bone-render:icon-pass"), 132 + color_attachments: &[Some(wgpu::RenderPassColorAttachment { 133 + view: color_view, 134 + resolve_target: None, 135 + depth_slice: None, 136 + ops: wgpu::Operations { 137 + load: wgpu::LoadOp::Load, 138 + store: wgpu::StoreOp::Store, 139 + }, 140 + })], 141 + depth_stencil_attachment: None, 142 + timestamp_writes: None, 143 + occlusion_query_set: None, 144 + multiview_mask: None, 145 + }); 146 + pass.set_pipeline(&self.pipeline); 147 + pass.set_bind_group(0, &self.bind_group, &[]); 148 + let start_bytes = u64::from(range.start) * INSTANCE_STRIDE; 149 + let end_bytes = u64::from(range.end) * INSTANCE_STRIDE; 150 + pass.set_vertex_buffer(0, self.instance_buffer.slice(start_bytes..end_bytes)); 151 + pass.draw(0..6, 0..(range.end - range.start)); 152 + } 153 + } 154 + 155 + impl core::fmt::Debug for IconPipeline { 156 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 157 + f.debug_struct("IconPipeline").finish_non_exhaustive() 158 + } 159 + } 160 + 161 + fn create_instance_buffer(device: &wgpu::Device, capacity: u64) -> wgpu::Buffer { 162 + device.create_buffer(&wgpu::BufferDescriptor { 163 + label: Some("bone-render:icon-instances"), 164 + size: capacity * INSTANCE_STRIDE, 165 + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, 166 + mapped_at_creation: false, 167 + }) 168 + } 169 + 170 + const INSTANCE_ATTRS: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![ 171 + 0 => Float32x4, 172 + 1 => Float32x4, 173 + 2 => Uint32, 174 + ]; 175 + 176 + fn create_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { 177 + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { 178 + label: Some("bone-render:icon-bgl"), 179 + entries: &[ 180 + wgpu::BindGroupLayoutEntry { 181 + binding: 0, 182 + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, 183 + ty: wgpu::BindingType::Buffer { 184 + ty: wgpu::BufferBindingType::Uniform, 185 + has_dynamic_offset: false, 186 + min_binding_size: wgpu::BufferSize::new(FRAME_SIZE), 187 + }, 188 + count: None, 189 + }, 190 + wgpu::BindGroupLayoutEntry { 191 + binding: 1, 192 + visibility: wgpu::ShaderStages::FRAGMENT, 193 + ty: wgpu::BindingType::Texture { 194 + multisampled: false, 195 + view_dimension: wgpu::TextureViewDimension::D2, 196 + sample_type: wgpu::TextureSampleType::Float { filterable: true }, 197 + }, 198 + count: None, 199 + }, 200 + wgpu::BindGroupLayoutEntry { 201 + binding: 2, 202 + visibility: wgpu::ShaderStages::FRAGMENT, 203 + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), 204 + count: None, 205 + }, 206 + ], 207 + }) 208 + } 209 + 210 + fn create_pipeline( 211 + device: &wgpu::Device, 212 + bind_group_layout: &wgpu::BindGroupLayout, 213 + color_format: wgpu::TextureFormat, 214 + ) -> wgpu::RenderPipeline { 215 + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { 216 + label: Some("bone-render:icon-shader"), 217 + source: wgpu::ShaderSource::Wgsl(include_str!("icon.wgsl").into()), 218 + }); 219 + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { 220 + label: Some("bone-render:icon-layout"), 221 + bind_group_layouts: &[Some(bind_group_layout)], 222 + immediate_size: 0, 223 + }); 224 + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { 225 + label: Some("bone-render:icon-pipeline"), 226 + layout: Some(&pipeline_layout), 227 + vertex: wgpu::VertexState { 228 + module: &shader, 229 + entry_point: Some("vs"), 230 + compilation_options: wgpu::PipelineCompilationOptions::default(), 231 + buffers: &[wgpu::VertexBufferLayout { 232 + array_stride: INSTANCE_STRIDE, 233 + step_mode: wgpu::VertexStepMode::Instance, 234 + attributes: &INSTANCE_ATTRS, 235 + }], 236 + }, 237 + fragment: Some(wgpu::FragmentState { 238 + module: &shader, 239 + entry_point: Some("fs"), 240 + compilation_options: wgpu::PipelineCompilationOptions::default(), 241 + targets: &[Some(wgpu::ColorTargetState { 242 + format: color_format, 243 + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), 244 + write_mask: wgpu::ColorWrites::ALL, 245 + })], 246 + }), 247 + primitive: wgpu::PrimitiveState { 248 + topology: wgpu::PrimitiveTopology::TriangleList, 249 + strip_index_format: None, 250 + front_face: wgpu::FrontFace::Ccw, 251 + cull_mode: None, 252 + polygon_mode: wgpu::PolygonMode::Fill, 253 + conservative: false, 254 + unclipped_depth: false, 255 + }, 256 + depth_stencil: None, 257 + multisample: wgpu::MultisampleState::default(), 258 + multiview_mask: None, 259 + cache: None, 260 + }) 261 + } 262 + 263 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 264 + struct PageGeom { 265 + origin_y: u32, 266 + page_px: u32, 267 + cell_px: u32, 268 + side_px: u32, 269 + } 270 + 271 + struct CombinedAtlas { 272 + width: u32, 273 + height: u32, 274 + rgba: Vec<u8>, 275 + geoms: [PageGeom; AtlasPage::ALL.len()], 276 + } 277 + 278 + fn committed_page_png(page: AtlasPage) -> &'static [u8] { 279 + match page { 280 + AtlasPage::Px16 => ATLAS_16, 281 + AtlasPage::Px24 => ATLAS_24, 282 + AtlasPage::Px32 => ATLAS_32, 283 + AtlasPage::Px48 => ATLAS_48, 284 + } 285 + } 286 + 287 + fn page_geoms() -> [PageGeom; AtlasPage::ALL.len()] { 288 + let grid = AtlasGrid::DEFAULT; 289 + AtlasPage::ALL 290 + .into_iter() 291 + .scan(0u32, |origin_y, page| { 292 + let (page_px, _) = grid.pixel_size(page); 293 + let geom = PageGeom { 294 + origin_y: *origin_y, 295 + page_px, 296 + cell_px: page.cell_px(), 297 + side_px: page.side_px(), 298 + }; 299 + *origin_y += page_px; 300 + Some(geom) 301 + }) 302 + .collect::<Vec<_>>() 303 + .try_into() 304 + .unwrap_or_else(|_| panic!("page_geoms must yield one entry per AtlasPage")) 305 + } 306 + 307 + fn decode_page(page: AtlasPage, expected_px: u32) -> Vec<u8> { 308 + let Ok((extent, rgba)) = decode_png(committed_page_png(page)) else { 309 + panic!("committed icon atlas page {page:?} failed to decode; repo state is broken"); 310 + }; 311 + assert_eq!( 312 + (extent.width().value(), extent.height().value()), 313 + (expected_px, expected_px), 314 + "committed icon atlas page {page:?} extent must equal its grid pixel size", 315 + ); 316 + rgba 317 + } 318 + 319 + fn px_to_f32(px: u32) -> f32 { 320 + let Ok(small) = u16::try_from(px) else { 321 + panic!("atlas dimension {px} exceeds u16; icon atlas layout is broken"); 322 + }; 323 + f32::from(small) 324 + } 325 + 326 + fn page_row_texel(data: &[u8], geom: PageGeom, local_y: u32, x: u32) -> [u8; 4] { 327 + if x >= geom.page_px { 328 + return [0, 0, 0, 0]; 329 + } 330 + let base = ((local_y * geom.page_px + x) * 4) as usize; 331 + [data[base], data[base + 1], data[base + 2], data[base + 3]] 332 + } 333 + 334 + impl CombinedAtlas { 335 + fn build() -> Self { 336 + let geoms = page_geoms(); 337 + let pages: Vec<Vec<u8>> = AtlasPage::ALL 338 + .into_iter() 339 + .zip(geoms) 340 + .map(|(page, geom)| decode_page(page, geom.page_px)) 341 + .collect(); 342 + let width = geoms.iter().map(|g| g.page_px).max().unwrap_or(0); 343 + let height = geoms.iter().map(|g| g.page_px).sum(); 344 + let rgba: Vec<u8> = (0..height) 345 + .flat_map(|y| { 346 + let index = geoms 347 + .iter() 348 + .position(|g| y >= g.origin_y && y < g.origin_y + g.page_px) 349 + .unwrap_or_else(|| panic!("row {y} falls in no icon atlas page")); 350 + let geom = geoms[index]; 351 + let data = &pages[index]; 352 + let local_y = y - geom.origin_y; 353 + (0..width).flat_map(move |x| page_row_texel(data, geom, local_y, x)) 354 + }) 355 + .collect(); 356 + Self { 357 + width, 358 + height, 359 + rgba, 360 + geoms, 361 + } 362 + } 363 + 364 + fn frame(&self) -> IconFrame { 365 + let pages = self.geoms.map(|g| { 366 + [ 367 + px_to_f32(g.origin_y), 368 + px_to_f32(g.page_px), 369 + px_to_f32(g.cell_px), 370 + px_to_f32(g.side_px), 371 + ] 372 + }); 373 + IconFrame { 374 + viewport_px: [0.0, 0.0], 375 + atlas_px: [px_to_f32(self.width), px_to_f32(self.height)], 376 + grid_cols: px_to_f32(grid_cols()), 377 + tile_pad: px_to_f32(TILE_PAD), 378 + _pad: [0.0, 0.0], 379 + pages, 380 + } 381 + } 382 + 383 + fn upload( 384 + &self, 385 + device: &wgpu::Device, 386 + queue: &wgpu::Queue, 387 + ) -> (wgpu::TextureView, wgpu::Sampler) { 388 + assert_eq!( 389 + self.rgba.len(), 390 + (self.width * self.height * 4) as usize, 391 + "combined icon atlas rgba length mismatch", 392 + ); 393 + let texture = device.create_texture(&wgpu::TextureDescriptor { 394 + label: Some("bone-render:icon-atlas"), 395 + size: wgpu::Extent3d { 396 + width: self.width, 397 + height: self.height, 398 + depth_or_array_layers: 1, 399 + }, 400 + mip_level_count: 1, 401 + sample_count: 1, 402 + dimension: wgpu::TextureDimension::D2, 403 + format: wgpu::TextureFormat::Rgba8Unorm, 404 + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, 405 + view_formats: &[], 406 + }); 407 + queue.write_texture( 408 + wgpu::TexelCopyTextureInfo { 409 + texture: &texture, 410 + mip_level: 0, 411 + origin: wgpu::Origin3d::ZERO, 412 + aspect: wgpu::TextureAspect::All, 413 + }, 414 + &self.rgba, 415 + wgpu::TexelCopyBufferLayout { 416 + offset: 0, 417 + bytes_per_row: Some(self.width * 4), 418 + rows_per_image: Some(self.height), 419 + }, 420 + wgpu::Extent3d { 421 + width: self.width, 422 + height: self.height, 423 + depth_or_array_layers: 1, 424 + }, 425 + ); 426 + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); 427 + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { 428 + label: Some("bone-render:icon-sampler"), 429 + address_mode_u: wgpu::AddressMode::ClampToEdge, 430 + address_mode_v: wgpu::AddressMode::ClampToEdge, 431 + address_mode_w: wgpu::AddressMode::ClampToEdge, 432 + mag_filter: wgpu::FilterMode::Linear, 433 + min_filter: wgpu::FilterMode::Linear, 434 + mipmap_filter: wgpu::MipmapFilterMode::Nearest, 435 + ..Default::default() 436 + }); 437 + (view, sampler) 438 + } 439 + } 440 + 441 + fn grid_cols() -> u32 { 442 + let Ok(cols) = u32::try_from(AtlasGrid::DEFAULT.cols()) else { 443 + panic!("atlas grid columns fit a u32"); 444 + }; 445 + cols 446 + } 447 + 448 + #[cfg(test)] 449 + mod tests { 450 + use super::{CombinedAtlas, IconInstance, page_geoms}; 451 + use crate::atlas::AtlasPage; 452 + 453 + #[test] 454 + fn instance_layout_is_tight() { 455 + assert_eq!(core::mem::size_of::<IconInstance>(), 48); 456 + assert_eq!(core::mem::align_of::<IconInstance>(), 16); 457 + } 458 + 459 + #[test] 460 + fn page_geoms_stack_without_gaps() { 461 + let geoms = page_geoms(); 462 + assert_eq!(geoms[0].origin_y, 0); 463 + geoms.windows(2).for_each(|pair| { 464 + assert_eq!(pair[1].origin_y, pair[0].origin_y + pair[0].page_px); 465 + }); 466 + assert_eq!(geoms[0].side_px, AtlasPage::Px16.side_px()); 467 + assert_eq!(geoms[3].side_px, AtlasPage::Px48.side_px()); 468 + } 469 + 470 + #[test] 471 + fn combined_atlas_dims_match_pages() { 472 + let atlas = CombinedAtlas::build(); 473 + let geoms = page_geoms(); 474 + let widest = geoms.iter().map(|g| g.page_px).max().unwrap_or(0); 475 + let total = geoms.iter().map(|g| g.page_px).sum::<u32>(); 476 + assert_eq!(atlas.width, widest); 477 + assert_eq!(atlas.height, total); 478 + assert_eq!(atlas.rgba.len(), (widest * total * 4) as usize); 479 + } 480 + 481 + #[test] 482 + fn every_page_region_has_visible_pixels() { 483 + let atlas = CombinedAtlas::build(); 484 + let geoms = page_geoms(); 485 + let empty: Vec<usize> = geoms 486 + .iter() 487 + .enumerate() 488 + .filter(|(_, g)| region_alpha_sum(&atlas.rgba, atlas.width, **g) == 0) 489 + .map(|(i, _)| i) 490 + .collect(); 491 + assert!( 492 + empty.is_empty(), 493 + "icon atlas pages rasterized empty: {empty:?}" 494 + ); 495 + } 496 + 497 + fn region_alpha_sum(rgba: &[u8], width: u32, geom: super::PageGeom) -> u64 { 498 + (geom.origin_y..geom.origin_y + geom.page_px) 499 + .flat_map(|y| (0..geom.page_px).map(move |x| (x, y))) 500 + .map(|(x, y)| u64::from(rgba[((y * width + x) * 4 + 3) as usize])) 501 + .sum() 502 + } 503 + }
+79
crates/bone-render/src/pipelines/icon.wgsl
··· 1 + struct Frame { 2 + viewport_px: vec2<f32>, 3 + atlas_px: vec2<f32>, 4 + grid_cols: f32, 5 + tile_pad: f32, 6 + _pad: vec2<f32>, 7 + pages: array<vec4<f32>, 4>, 8 + }; 9 + 10 + @group(0) @binding(0) var<uniform> u: Frame; 11 + @group(0) @binding(1) var atlas: texture_2d<f32>; 12 + @group(0) @binding(2) var samp: sampler; 13 + 14 + struct VsOut { 15 + @builtin(position) clip: vec4<f32>, 16 + @location(0) uv: vec2<f32>, 17 + @location(1) tint: vec4<f32>, 18 + }; 19 + 20 + fn select_page(requested_px: f32) -> vec4<f32> { 21 + if requested_px <= u.pages[0].w { 22 + return u.pages[0]; 23 + } 24 + if requested_px <= u.pages[1].w { 25 + return u.pages[1]; 26 + } 27 + if requested_px <= u.pages[2].w { 28 + return u.pages[2]; 29 + } 30 + return u.pages[3]; 31 + } 32 + 33 + @vertex 34 + fn vs( 35 + @builtin(vertex_index) vid: u32, 36 + @location(0) rect_xywh_px: vec4<f32>, 37 + @location(1) tint: vec4<f32>, 38 + @location(2) tile_index: u32, 39 + ) -> VsOut { 40 + var corners = array<vec2<f32>, 6>( 41 + vec2<f32>(0.0, 0.0), 42 + vec2<f32>(1.0, 0.0), 43 + vec2<f32>(0.0, 1.0), 44 + vec2<f32>(1.0, 0.0), 45 + vec2<f32>(1.0, 1.0), 46 + vec2<f32>(0.0, 1.0), 47 + ); 48 + let c = corners[vid]; 49 + let pos_px = rect_xywh_px.xy + c * rect_xywh_px.zw; 50 + let ndc = vec2<f32>( 51 + (pos_px.x / u.viewport_px.x) * 2.0 - 1.0, 52 + 1.0 - (pos_px.y / u.viewport_px.y) * 2.0, 53 + ); 54 + 55 + let requested_px = max(rect_xywh_px.z, rect_xywh_px.w); 56 + let page = select_page(requested_px); 57 + let origin_y = page.x; 58 + let cell_px = page.z; 59 + let side_px = page.w; 60 + 61 + let cols = u32(u.grid_cols); 62 + let col = f32(tile_index % cols); 63 + let row = f32(tile_index / cols); 64 + let tile_origin = vec2<f32>(col, row) * cell_px + vec2<f32>(u.tile_pad, u.tile_pad); 65 + let sample_px = tile_origin + c * side_px; 66 + let global_px = vec2<f32>(sample_px.x, origin_y + sample_px.y); 67 + 68 + var out: VsOut; 69 + out.clip = vec4<f32>(ndc, 0.0, 1.0); 70 + out.uv = global_px / u.atlas_px; 71 + out.tint = tint; 72 + return out; 73 + } 74 + 75 + @fragment 76 + fn fs(in: VsOut) -> @location(0) vec4<f32> { 77 + let texel = textureSample(atlas, samp, in.uv); 78 + return texel * in.tint; 79 + }
+2
crates/bone-render/src/pipelines/mod.rs
··· 4 4 pub mod edge_3d; 5 5 pub mod glyph; 6 6 pub mod grid; 7 + pub mod icon; 7 8 pub mod lines; 8 9 pub mod solid; 9 10 pub mod text; ··· 16 17 pub(crate) use edge_3d::{Edge3dPipeline, EdgeProjection, EdgeView, HiddenEdges}; 17 18 pub use glyph::GlyphPipeline; 18 19 pub use grid::GridPipeline; 20 + pub use icon::{IconInstance, IconPipeline}; 19 21 pub use lines::LinesPipeline; 20 22 pub(crate) use solid::{FaceFill, SolidPipeline, SolidView}; 21 23 pub use text::TextPipeline;
+8 -5
crates/bone-render/src/pipelines/solid.rs
··· 13 13 14 14 const LIGHT_DIR: [f32; 4] = [0.302, 0.503, 0.809, 0.0]; 15 15 const FILL_DIR: [f32; 4] = [-0.302, -0.503, -0.809, 0.4]; 16 - const BASE_COLOR: [f32; 4] = [0.72, 0.74, 0.78, 1.0]; 17 16 const AMBIENT: f32 = 0.28; 18 17 19 18 const SHADING_DEFAULT: u32 = 0; ··· 56 55 struct SolidVertex { 57 56 position: [f32; 3], 58 57 normal: [f32; 3], 58 + color: [f32; 4], 59 59 pick: u32, 60 60 } 61 61 ··· 78 78 79 79 const UNIFORM_SIZE: u64 = core::mem::size_of::<SolidUniform>() as u64; 80 80 81 - const VERTEX_ATTRS: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![ 81 + const VERTEX_ATTRS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![ 82 82 0 => Float32x3, 83 83 1 => Float32x3, 84 - 2 => Uint32, 84 + 2 => Float32x4, 85 + 3 => Uint32, 85 86 ]; 86 87 87 88 pub(crate) struct SolidPipeline { ··· 205 206 clip_from_world: view.clip_from_world, 206 207 light_dir: LIGHT_DIR, 207 208 fill_dir: FILL_DIR, 208 - base_color: BASE_COLOR, 209 + base_color: style.solid_base_color().to_array(), 209 210 background: style.background().to_rgba_array(), 210 211 eye_world: [view.eye_world[0], view.eye_world[1], view.eye_world[2], 1.0], 211 212 ambient: AMBIENT, ··· 265 266 .positions() 266 267 .iter() 267 268 .zip(scene.normals()) 269 + .zip(scene.colors()) 268 270 .zip(scene.pick_ids()) 269 - .map(|((point, normal), pick)| { 271 + .map(|(((point, normal), color), pick)| { 270 272 let (px, py, pz) = point.coords_mm(); 271 273 let (nx, ny, nz) = normal.components(); 272 274 SolidVertex { 273 275 position: [lower_f32(px), lower_f32(py), lower_f32(pz)], 274 276 normal: [lower_f32(nx), lower_f32(ny), lower_f32(nz)], 277 + color: color.to_array(), 275 278 pick: pick.raw(), 276 279 } 277 280 })
+7 -3
crates/bone-render/src/pipelines/solid.wgsl
··· 23 23 @location(0) world_normal: vec3<f32>, 24 24 @location(1) world_pos: vec3<f32>, 25 25 @location(2) @interpolate(flat) pick_id: u32, 26 + @location(3) vertex_color: vec4<f32>, 26 27 }; 27 28 28 29 struct FsOut { ··· 34 35 fn vs( 35 36 @location(0) position: vec3<f32>, 36 37 @location(1) normal: vec3<f32>, 37 - @location(2) pick_id: u32, 38 + @location(2) color: vec4<f32>, 39 + @location(3) pick_id: u32, 38 40 ) -> VsOut { 39 41 var out: VsOut; 40 42 out.clip = u.clip_from_world * vec4<f32>(position, 1.0); 41 43 out.world_normal = normal; 42 44 out.world_pos = position; 43 45 out.pick_id = pick_id; 46 + out.vertex_color = color; 44 47 return out; 45 48 } 46 49 ··· 62 65 let ambient = u.ambient * wrap * wrap; 63 66 let diffuse = min(key + fill, 1.0); 64 67 let shade = ambient + (1.0 - u.ambient) * diffuse; 65 - var rgb = u.base_color.rgb * shade; 68 + let surface = u.base_color.rgb * in.vertex_color.rgb; 69 + var rgb = surface * shade; 66 70 67 71 if (u.shading_model == SHADING_PHONG) { 68 72 let view = normalize(u.eye_world.xyz - in.world_pos); ··· 72 76 } 73 77 74 78 var out: FsOut; 75 - out.color = vec4<f32>(rgb, u.base_color.a); 79 + out.color = vec4<f32>(rgb, u.base_color.a * in.vertex_color.a); 76 80 out.pick_id = in.pick_id; 77 81 return out; 78 82 }
+62 -2
crates/bone-render/src/scene.rs
··· 4 4 }; 5 5 use bone_kernel::{Aabb2, Arc2, BrepSolid, FaceMesh, SolidMesh, arc_bounding_box}; 6 6 use bone_types::{ 7 - Angle, ChordHeightTolerance, CreaseAngle, Length, Point2, Point3, SketchDimensionId, 8 - SketchEntityId, SketchRelationId, Tolerance, UnitVec3, Vec2, 7 + Angle, ChordHeightTolerance, CreaseAngle, Length, LinearRgba, Point2, Point3, 8 + SketchDimensionId, SketchEntityId, SketchRelationId, Tolerance, UnitVec3, Vec2, 9 9 }; 10 10 use core::f64::consts::FRAC_1_SQRT_2; 11 11 use std::collections::BTreeMap; ··· 621 621 }) 622 622 } 623 623 624 + const WHITE: LinearRgba = LinearRgba::new(1.0, 1.0, 1.0, 1.0); 625 + 624 626 #[derive(Clone, Debug, PartialEq)] 625 627 pub struct SolidScene { 626 628 positions: Vec<Point3>, 627 629 normals: Vec<UnitVec3>, 630 + colors: Vec<LinearRgba>, 628 631 pick_ids: Vec<PickId>, 629 632 triangles: Vec<[u32; 3]>, 630 633 } ··· 635 638 Self { 636 639 positions: Vec::new(), 637 640 normals: Vec::new(), 641 + colors: Vec::new(), 638 642 pick_ids: Vec::new(), 639 643 triangles: Vec::new(), 640 644 } ··· 650 654 let pick = PickId::brep_face(face.face())?; 651 655 scene.positions.extend_from_slice(face.positions()); 652 656 scene.normals.extend_from_slice(face.normals()); 657 + scene.colors.extend(face.positions().iter().map(|_| WHITE)); 653 658 scene.pick_ids.extend(face.positions().iter().map(|_| pick)); 654 659 scene 655 660 .triangles ··· 659 664 } 660 665 661 666 #[must_use] 667 + pub fn from_parts( 668 + positions: Vec<Point3>, 669 + normals: Vec<UnitVec3>, 670 + colors: Vec<LinearRgba>, 671 + triangles: Vec<[u32; 3]>, 672 + ) -> Self { 673 + assert_eq!( 674 + positions.len(), 675 + normals.len(), 676 + "solid scene needs one normal per position", 677 + ); 678 + assert_eq!( 679 + positions.len(), 680 + colors.len(), 681 + "solid scene needs one color per position", 682 + ); 683 + let Ok(vertex_count) = u32::try_from(positions.len()) else { 684 + panic!("solid scene vertex count fits a u32 index"); 685 + }; 686 + assert!( 687 + triangles 688 + .iter() 689 + .flatten() 690 + .all(|&index| index < vertex_count), 691 + "solid scene triangle index out of range", 692 + ); 693 + let pick_ids = vec![PickId::NONE; positions.len()]; 694 + Self { 695 + positions, 696 + normals, 697 + colors, 698 + pick_ids, 699 + triangles, 700 + } 701 + } 702 + 703 + #[must_use] 662 704 pub fn positions(&self) -> &[Point3] { 663 705 &self.positions 664 706 } ··· 666 708 #[must_use] 667 709 pub fn normals(&self) -> &[UnitVec3] { 668 710 &self.normals 711 + } 712 + 713 + #[must_use] 714 + pub fn colors(&self) -> &[LinearRgba] { 715 + &self.colors 669 716 } 670 717 671 718 #[must_use] ··· 691 738 } 692 739 693 740 impl GenuineEdge { 741 + #[must_use] 742 + pub const fn new(a: Point3, b: Point3, pick: PickId, crease: CreaseAngle) -> Self { 743 + Self { a, b, pick, crease } 744 + } 745 + 694 746 #[must_use] 695 747 pub const fn a(self) -> Point3 { 696 748 self.a ··· 759 811 pub const fn empty() -> Self { 760 812 Self { 761 813 genuine: Vec::new(), 814 + silhouettes: Vec::new(), 815 + } 816 + } 817 + 818 + #[must_use] 819 + pub fn from_genuine(genuine: Vec<GenuineEdge>) -> Self { 820 + Self { 821 + genuine, 762 822 silhouettes: Vec::new(), 763 823 } 764 824 }
+74
crates/bone-render/src/snapshot.rs
··· 1 + use bone_types::LinearRgba; 2 + 1 3 use crate::camera::ViewportExtent; 2 4 use crate::gpu::BackendTag; 3 5 use crate::{RenderError, Result}; ··· 87 89 minor_spacing_target_px: 24.0, 88 90 }; 89 91 92 + pub const LIGHT: Self = Self { 93 + minor: ClearColor::new(0.0, 0.0, 0.0, 0.06), 94 + major: ClearColor::new(0.0, 0.0, 0.0, 0.12), 95 + axis_x: ClearColor::new(0.78, 0.10, 0.10, 1.0), 96 + axis_y: ClearColor::new(0.10, 0.55, 0.12, 1.0), 97 + origin: ClearColor::new(0.0, 0.0, 0.0, 0.80), 98 + line_width_px: 1.0, 99 + axis_width_px: 1.6, 100 + origin_radius_px: 4.0, 101 + minor_spacing_target_px: 24.0, 102 + }; 103 + 90 104 #[must_use] 91 105 pub const fn minor(self) -> ClearColor { 92 106 self.minor ··· 159 173 construction_dash_on_ratio: 0.5, 160 174 }; 161 175 176 + pub const LIGHT: Self = Self { 177 + stroke: ClearColor::new(0.14, 0.24, 0.62, 1.0), 178 + construction: ClearColor::new(0.45, 0.50, 0.62, 0.85), 179 + stroke_width_px: 1.5, 180 + point_radius_px: 3.0, 181 + construction_dash_period_px: 8.0, 182 + construction_dash_on_ratio: 0.5, 183 + }; 184 + 162 185 #[must_use] 163 186 pub const fn stroke(self) -> ClearColor { 164 187 self.stroke ··· 210 233 tile_px: 22.0, 211 234 }; 212 235 236 + pub const LIGHT: Self = Self { 237 + color: ClearColor::new(0.0, 0.60, 0.17, 1.0), 238 + offset_px: 18.0, 239 + tile_px: 22.0, 240 + }; 241 + 213 242 #[must_use] 214 243 pub const fn color(self) -> ClearColor { 215 244 self.color ··· 241 270 impl TextStyle { 242 271 pub const DEFAULT: Self = Self { 243 272 color: ClearColor::new(0.95, 0.95, 0.98, 1.0), 273 + font_size_px: 14.0, 274 + }; 275 + 276 + pub const LIGHT: Self = Self { 277 + color: ClearColor::new(0.10, 0.11, 0.13, 1.0), 244 278 font_size_px: 14.0, 245 279 }; 246 280 ··· 281 315 hidden: ClearColor::opaque(0.249, 0.274, 0.300), 282 316 }; 283 317 318 + pub const LIGHT: Self = Self { 319 + visible: ClearColor::opaque(0.15, 0.16, 0.19), 320 + hidden: ClearColor::opaque(0.62, 0.63, 0.66), 321 + }; 322 + 284 323 #[must_use] 285 324 pub const fn new(visible: ClearColor, hidden: ClearColor) -> Self { 286 325 Self { visible, hidden } ··· 297 336 } 298 337 } 299 338 339 + const SOLID_BASE_COLOR: LinearRgba = LinearRgba::new(0.72, 0.74, 0.78, 1.0); 340 + 300 341 #[derive(Copy, Clone, Debug, PartialEq)] 301 342 pub struct Style { 302 343 background: ClearColor, ··· 305 346 glyphs: GlyphStyle, 306 347 text: TextStyle, 307 348 edges: EdgeStyle, 349 + solid_base_color: LinearRgba, 308 350 } 309 351 310 352 impl Style { ··· 317 359 glyphs: GlyphStyle::DEFAULT, 318 360 text: TextStyle::DEFAULT, 319 361 edges: EdgeStyle::DEFAULT, 362 + solid_base_color: SOLID_BASE_COLOR, 363 + } 364 + } 365 + 366 + #[must_use] 367 + pub const fn light() -> Self { 368 + Self { 369 + background: ClearColor::opaque(0.965, 0.965, 0.97), 370 + grid: GridStyle::LIGHT, 371 + strokes: StrokeStyle::LIGHT, 372 + glyphs: GlyphStyle::LIGHT, 373 + text: TextStyle::LIGHT, 374 + edges: EdgeStyle::LIGHT, 375 + solid_base_color: SOLID_BASE_COLOR, 320 376 } 321 377 } 322 378 323 379 #[must_use] 324 380 pub const fn background(self) -> ClearColor { 325 381 self.background 382 + } 383 + 384 + #[must_use] 385 + pub const fn solid_base_color(self) -> LinearRgba { 386 + self.solid_base_color 387 + } 388 + 389 + #[must_use] 390 + pub const fn with_solid_base_color(self, solid_base_color: LinearRgba) -> Self { 391 + Self { 392 + solid_base_color, 393 + ..self 394 + } 395 + } 396 + 397 + #[must_use] 398 + pub const fn with_background(self, background: ClearColor) -> Self { 399 + Self { background, ..self } 326 400 } 327 401 328 402 #[must_use]
+33
crates/bone-render/tests/icons.rs
··· 1 + use bone_render::{IconInstance, IconPipeline, Style}; 2 + 3 + mod common; 4 + 5 + use common::{extent_square, make_context}; 6 + 7 + #[test] 8 + fn icon_pipeline_draws_visible_pixels() { 9 + let extent = extent_square(64); 10 + let ctx = make_context(extent); 11 + let mut pipeline = IconPipeline::new(ctx.gpu(), ctx.color_format()); 12 + 13 + let style = Style::light(); 14 + let Ok(cleared) = ctx.render_clear(&style) else { 15 + panic!("render_clear failed"); 16 + }; 17 + 18 + let viewport_px = [64.0, 64.0]; 19 + let instance = IconInstance::new([8.0, 8.0, 48.0, 48.0], 0, [1.0, 1.0, 1.0, 1.0]); 20 + pipeline.upload(viewport_px, &[instance]); 21 + ctx.render_passes(|encoder, color, _pick, _depth| { 22 + pipeline.draw_range(encoder, color, 0..1); 23 + }); 24 + let Ok(drawn) = ctx.capture() else { 25 + panic!("capture failed"); 26 + }; 27 + 28 + assert_ne!( 29 + cleared.rgba(), 30 + drawn.rgba(), 31 + "icon draw left the framebuffer unchanged; shader or sampling regressed", 32 + ); 33 + }
+19
crates/bone-types/src/color.rs
··· 1 + #[derive(Copy, Clone, Debug, PartialEq)] 2 + pub struct LinearRgba { 3 + r: f32, 4 + g: f32, 5 + b: f32, 6 + a: f32, 7 + } 8 + 9 + impl LinearRgba { 10 + #[must_use] 11 + pub const fn new(r: f32, g: f32, b: f32, a: f32) -> Self { 12 + Self { r, g, b, a } 13 + } 14 + 15 + #[must_use] 16 + pub const fn to_array(self) -> [f32; 4] { 17 + [self.r, self.g, self.b, self.a] 18 + } 19 + }
+127
crates/bone-types/src/icon.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + macro_rules! icon_ids { 4 + ($($variant:ident),+ $(,)?) => { 5 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] 6 + #[repr(u8)] 7 + pub enum IconId { 8 + $($variant,)+ 9 + } 10 + 11 + impl IconId { 12 + pub const ALL: &'static [IconId] = &[$(IconId::$variant,)+]; 13 + 14 + pub const COUNT: usize = Self::ALL.len(); 15 + } 16 + }; 17 + } 18 + 19 + icon_ids! { 20 + Point, 21 + Line, 22 + CenterpointArc, 23 + TangentArc, 24 + ThreePointArc, 25 + Circle, 26 + PerimeterCircle, 27 + CornerRectangle, 28 + CenterRectangle, 29 + ThreePointCornerRectangle, 30 + ThreePointCenterRectangle, 31 + Parallelogram, 32 + SmartDimension, 33 + Coincident, 34 + Horizontal, 35 + Vertical, 36 + Parallel, 37 + Perpendicular, 38 + Tangent, 39 + Equal, 40 + Concentric, 41 + Midpoint, 42 + Symmetric, 43 + Fix, 44 + ExtrudedBossBase, 45 + ExtrudedCut, 46 + TreeFeature, 47 + TreePlane, 48 + TreeSketch, 49 + TreeOrigin, 50 + TabTree, 51 + TabProperties, 52 + TabConfiguration, 53 + TabDimensionExpert, 54 + TabDisplay, 55 + ZoomToFit, 56 + ZoomToArea, 57 + PreviousView, 58 + SectionView, 59 + ViewOrientation, 60 + DisplayStyle, 61 + HideShowItems, 62 + EditAppearance, 63 + ViewSettings, 64 + Check, 65 + Cross, 66 + } 67 + 68 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] 69 + pub struct IconTile(u8); 70 + 71 + impl IconTile { 72 + #[must_use] 73 + pub const fn index(self) -> u8 { 74 + self.0 75 + } 76 + 77 + #[must_use] 78 + pub fn as_usize(self) -> usize { 79 + usize::from(self.0) 80 + } 81 + } 82 + 83 + impl IconId { 84 + #[must_use] 85 + pub const fn tile(self) -> IconTile { 86 + IconTile(self as u8) 87 + } 88 + } 89 + 90 + #[cfg(test)] 91 + mod tests { 92 + use super::IconId; 93 + 94 + #[test] 95 + fn tile_index_and_usize_agree() { 96 + IconId::ALL.iter().for_each(|icon| { 97 + let tile = icon.tile(); 98 + assert_eq!(usize::from(tile.index()), tile.as_usize()); 99 + }); 100 + } 101 + 102 + #[test] 103 + fn tile_is_injective_and_dense_over_all() { 104 + let mut indices: Vec<usize> = IconId::ALL 105 + .iter() 106 + .map(|icon| icon.tile().as_usize()) 107 + .collect(); 108 + indices.sort_unstable(); 109 + assert_eq!(indices.len(), IconId::COUNT); 110 + let dense = indices.iter().enumerate().all(|(i, &v)| v == i); 111 + assert!( 112 + dense, 113 + "tile indices over ALL must be exactly 0..COUNT with no gaps or duplicates", 114 + ); 115 + } 116 + 117 + #[test] 118 + fn all_is_ordered_by_discriminant() { 119 + IconId::ALL.iter().enumerate().for_each(|(i, icon)| { 120 + assert_eq!( 121 + usize::from(icon.tile().index()), 122 + i, 123 + "ALL must list variants in discriminant order so the tile matches the slot", 124 + ); 125 + }); 126 + } 127 + }
+4
crates/bone-types/src/lib.rs
··· 5 5 6 6 pub mod camera; 7 7 pub mod cancel; 8 + pub mod color; 8 9 pub mod content; 9 10 pub mod dimensioned_serde; 10 11 pub mod display; 12 + pub mod icon; 11 13 pub mod label; 12 14 pub mod schema; 13 15 pub mod solver; ··· 18 20 Camera3, CubicEasing, OrbitState, Projection, ProjectionKind, StandardView, ZoomFactor, 19 21 }; 20 22 pub use cancel::{Cancel, CancelFlag}; 23 + pub use color::LinearRgba; 21 24 pub use content::SolidKey; 22 25 pub use display::{DisplayMode, ShadingModel}; 26 + pub use icon::{IconId, IconTile}; 23 27 pub use label::{ 24 28 EdgeLabel, EdgeRole, FaceLabel, FaceRole, ImportOrdinal, LoopIndex, SideKind, VertexLabel, 25 29 VertexRole,
+114 -30
crates/bone-ui/src/gallery.rs
··· 1 1 use core::time::Duration; 2 2 use std::sync::Arc; 3 3 4 + use bone_types::IconId; 4 5 use uom::si::angle::degree; 5 6 use uom::si::f64::{Angle, Length}; 6 7 use uom::si::length::millimeter; ··· 11 12 use crate::hit_test::{HitFrame, HitState, Interaction, InteractionState}; 12 13 use crate::hotkey::HotkeyTable; 13 14 use crate::input::{FrameInstant, InputSnapshot}; 14 - use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 15 + use crate::layout::{Axis, LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 15 16 use crate::raster::{CanvasPx, CanvasSize}; 16 17 use crate::strings::{StringKey, StringTable}; 17 18 use crate::theme::Theme; ··· 22 23 DropdownState, FilePickerDialog, FilePickerEntry, FilePickerLabels, FilePickerMode, 23 24 FilePickerState, HotkeyCapture, HotkeyCaptureState, LabelText, LengthEditor, ListItem, 24 25 ListView, ListViewState, MemoryClipboard, Menu, MenuBar, MenuBarEntry, MenuBarState, MenuItem, 25 - MenuState, Modal, NumericInput, Panel, PanelState, PanelTitlebar, PropertyGrid, PropertyOption, 26 - PropertyRow, RadioGroup, RadioOption, Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, 27 - SelectionEditor, Slider, SliderRange, SliderStep, StatusAlign, StatusBar, StatusItem, Tab, 28 - Table, TableColumn, TableRow, TableState, Tabs, TabsOrientation, TextEditor, TextInput, 29 - TextInputState, Toast, ToastKind, ToastState, ToggleButton, Toolbar, ToolbarItem, Tooltip, 30 - TooltipPlacement, TooltipState, TreeNode, TreeView, TreeViewState, WidgetPaint, show_button, 31 - show_checkbox, show_confirmation, show_context_menu, show_dialog, show_dropdown, 32 - show_file_picker, show_hotkey_capture, show_list_view, show_menu, show_menu_bar, show_modal, 33 - show_panel, show_parsed_input, show_property_grid, show_radio_group, show_ribbon, show_slider, 34 - show_status_bar, show_table, show_tabs, show_text_input, show_toast, show_toggle_button, 35 - show_toolbar, show_tooltip, show_tree_view, 26 + MenuState, Modal, NumericInput, Panel, PanelState, PanelTitlebar, PanelVariant, PropertyGrid, 27 + PropertyOption, PropertyPaneHeader, PropertyRow, RadioGroup, RadioOption, Ribbon, RibbonGroup, 28 + RibbonIconSize, RibbonTab, Scrollbar, SelectionEditor, Slider, SliderRange, SliderStep, 29 + StatusAlign, StatusBar, StatusItem, Tab, Table, TableColumn, TableRow, TableState, Tabs, 30 + TabsOrientation, TextEditor, TextInput, TextInputState, Toast, ToastKind, ToastState, 31 + ToggleButton, Toolbar, ToolbarItem, Tooltip, TooltipPlacement, TooltipState, TreeNode, 32 + TreeView, TreeViewState, WidgetPaint, show_button, show_checkbox, show_confirmation, 33 + show_context_menu, show_dialog, show_dropdown, show_file_picker, show_hotkey_capture, 34 + show_list_view, show_menu, show_menu_bar, show_modal, show_panel, show_parsed_input, 35 + show_property_grid, show_property_pane_header, show_radio_group, show_ribbon, show_scrollbar, 36 + show_slider, show_status_bar, show_table, show_tabs, show_text_input, show_toast, 37 + show_toggle_button, show_toolbar, show_tooltip, show_tree_view, 36 38 }; 37 39 38 40 pub const GALLERY_LABEL: StringKey = StringKey::new("gallery.label"); ··· 52 54 pub type StoryPath = &'static [&'static str]; 53 55 54 56 #[derive(Copy, Clone, Debug, PartialEq, Eq)] 57 + pub struct IndexedChild { 58 + pub parent: StoryPath, 59 + pub key: &'static str, 60 + pub count: u64, 61 + } 62 + 63 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 55 64 pub struct Story { 56 65 pub key: &'static str, 57 66 pub parent: Option<&'static str>, 58 67 pub kind: StoryKind, 59 68 pub extras: &'static [StoryPath], 69 + pub indexed: &'static [IndexedChild], 60 70 } 61 71 62 72 impl Story { ··· 66 76 parent: None, 67 77 kind, 68 78 extras: &[], 79 + indexed: &[], 69 80 } 70 81 } 71 82 ··· 75 86 parent: None, 76 87 kind, 77 88 extras, 89 + indexed: &[], 78 90 } 79 91 } 80 92 81 93 pub fn ids(self) -> impl Iterator<Item = WidgetId> { 82 - core::iter::once(story_id(self)).chain(self.extras.iter().copied().map(path_id)) 94 + let extras = self.extras.iter().copied().map(path_id); 95 + let indexed = self.indexed.iter().flat_map(|child| { 96 + let base = path_id(child.parent); 97 + (0..child.count).map(move |index| base.child_indexed(WidgetKey::new(child.key), index)) 98 + }); 99 + core::iter::once(story_id(self)) 100 + .chain(extras) 101 + .chain(indexed) 83 102 } 84 103 } 85 104 ··· 105 124 Story::root("toggle_off", StoryKind::InputPrimitive), 106 125 Story::root_with("radio_a", StoryKind::InputPrimitive, &[&["radio_b"]]), 107 126 Story::root("slider", StoryKind::InputPrimitive), 127 + Story::root("scrollbar", StoryKind::InputPrimitive), 108 128 Story::root_with("tabs", StoryKind::InputPrimitive, &[&["tab_a"], &["tab_b"]]), 109 129 Story::root_with("status_bar", StoryKind::Composite, &[&["status_item"]]), 110 130 Story::root_with("panel", StoryKind::Composite, &[&["panel", "titlebar"]]), ··· 113 133 StoryKind::Composite, 114 134 &[&["panel_collapsed", "titlebar"]], 115 135 ), 116 - Story::root("dropdown", StoryKind::InputPrimitive), 136 + Story { 137 + key: "dropdown", 138 + parent: None, 139 + kind: StoryKind::InputPrimitive, 140 + extras: &[&["dropdown", "popup"]], 141 + indexed: &[IndexedChild { 142 + parent: &["dropdown"], 143 + key: "item", 144 + count: 2, 145 + }], 146 + }, 117 147 Story::root("text_input", StoryKind::InputPrimitive), 118 148 Story::root("numeric_input", StoryKind::InputPrimitive), 119 149 Story::root("hotkey_capture", StoryKind::InputPrimitive), ··· 153 183 &["prop_length"], 154 184 &["prop_angle"], 155 185 ], 186 + ), 187 + Story::root_with( 188 + "property_header", 189 + StoryKind::Composite, 190 + &[&["pane_accept"], &["pane_cancel"]], 156 191 ), 157 192 Story::root_with( 158 193 "toolbar", ··· 231 266 parent: Some(TOOLTIP_ANCHOR_KEY), 232 267 kind: StoryKind::Overlay, 233 268 extras: &[], 269 + indexed: &[], 234 270 }, 235 271 ]; 236 272 ··· 311 347 pub fn new() -> Self { 312 348 let mut tree = TreeViewState::default(); 313 349 tree.expanded.insert(id("tree_root")); 350 + tree.selection.insert(id("tree_child")); 314 351 Self { 315 352 panel: PanelState::open(), 316 353 panel_collapsed: PanelState::collapsed(), 317 - dropdown: DropdownState::closed(), 354 + dropdown: DropdownState { 355 + open: true, 356 + highlighted: Some(0), 357 + ..DropdownState::closed() 358 + }, 318 359 text_input: TextInputState::from_text("Hello"), 319 360 numeric_input: TextInputState::from_text("42"), 320 361 clipboard: MemoryClipboard::default(), ··· 483 524 ); 484 525 paint.extend(response.paint); 485 526 let tab_items = [ 486 - Tab::new(id("tab_a"), rect(0.0, 144.0, 80.0, 24.0), GALLERY_LABEL), 527 + Tab::new(id("tab_a"), rect(0.0, 144.0, 80.0, 24.0), GALLERY_LABEL) 528 + .with_icon(IconId::TabTree), 487 529 Tab::new(id("tab_b"), rect(80.0, 144.0, 80.0, 24.0), GALLERY_LABEL), 488 530 ]; 489 531 let response = show_tabs( ··· 497 539 ), 498 540 ); 499 541 paint.extend(response.paint); 500 - let status_items = [StatusItem::new( 501 - id("status_item"), 502 - GALLERY_LABEL, 503 - StatusAlign::Start, 504 - LayoutPx::new(80.0), 505 - ) 506 - .interactive(true)]; 542 + let status_items = [ 543 + StatusItem::new( 544 + id("status_item"), 545 + GALLERY_LABEL, 546 + StatusAlign::Start, 547 + LayoutPx::new(80.0), 548 + ) 549 + .interactive(true), 550 + StatusItem::new( 551 + id("status_unit"), 552 + GALLERY_LABEL, 553 + StatusAlign::End, 554 + LayoutPx::new(48.0), 555 + ), 556 + StatusItem::new( 557 + id("status_mode"), 558 + GALLERY_LABEL, 559 + StatusAlign::End, 560 + LayoutPx::new(48.0), 561 + ), 562 + ]; 507 563 let response = show_status_bar( 508 564 ctx, 509 565 StatusBar::new( ··· 521 577 rect(0.0, 192.0, 200.0, 100.0), 522 578 &mut state.panel, 523 579 ) 580 + .variant(PanelVariant::Card) 524 581 .titlebar(PanelTitlebar { 525 582 label: GALLERY_LABEL, 526 583 height: LayoutPx::new(22.0), ··· 532 589 ctx, 533 590 Dropdown::new( 534 591 id("dropdown"), 535 - rect(0.0, 296.0, 160.0, 24.0), 592 + rect(0.0, 800.0, 160.0, 24.0), 536 593 LayoutPx::new(20.0), 537 594 vec![ 538 595 DropdownItem { ··· 544 601 label: GALLERY_LABEL, 545 602 }, 546 603 ], 547 - None, 604 + Some(Choice::B), 548 605 GALLERY_LABEL, 549 606 &mut state.dropdown, 550 607 ), ··· 588 645 ), 589 646 ); 590 647 paint.extend(response.paint); 648 + let response = show_scrollbar( 649 + ctx, 650 + Scrollbar::new( 651 + id("scrollbar"), 652 + rect(410.0, 0.0, 14.0, 140.0), 653 + Axis::Vertical, 654 + GALLERY_LABEL, 655 + LayoutPx::new(400.0), 656 + LayoutPx::new(40.0), 657 + ), 658 + ); 659 + paint.extend(response.paint); 591 660 } 592 661 593 662 #[allow( ··· 649 718 let tree_roots = [TreeNode::parent( 650 719 id("tree_root"), 651 720 GALLERY_LABEL, 652 - vec![TreeNode::leaf(id("tree_child"), GALLERY_LABEL)], 653 - )]; 721 + vec![TreeNode::leaf(id("tree_child"), GALLERY_LABEL).with_icon(IconId::TreeSketch)], 722 + ) 723 + .with_icon(IconId::TreeFeature)]; 654 724 let response = show_tree_view( 655 725 ctx, 656 726 TreeView::new( ··· 705 775 &mut state.clipboard, 706 776 ); 707 777 paint.extend(response.paint); 778 + let response = show_property_pane_header( 779 + ctx, 780 + PropertyPaneHeader { 781 + id: id("property_header"), 782 + rect: rect(240.0, 800.0, 240.0, 48.0), 783 + title: GALLERY_LABEL, 784 + accept_id: id("pane_accept"), 785 + cancel_id: id("pane_cancel"), 786 + }, 787 + ); 788 + paint.extend(response.paint); 708 789 } 709 790 710 791 #[allow( ··· 729 810 ); 730 811 paint.extend(response.paint); 731 812 let ribbon_toolbar_items = [ 732 - ToolbarItem::new(id("ribbon_tool_a"), GALLERY_LABEL), 733 - ToolbarItem::new(id("ribbon_tool_b"), GALLERY_LABEL), 813 + ToolbarItem::new(id("ribbon_tool_a"), GALLERY_LABEL) 814 + .with_icon(RibbonIconSize::Large.slot(IconId::Line)) 815 + .active(true), 816 + ToolbarItem::new(id("ribbon_tool_b"), GALLERY_LABEL) 817 + .with_icon(RibbonIconSize::Large.slot(IconId::ExtrudedBossBase)), 734 818 ]; 735 819 let ribbon_tabs = [RibbonTab::new( 736 820 id("ribbon_tab"), ··· 761 845 MenuItem::Action { 762 846 id: id("menu_action"), 763 847 label: GALLERY_LABEL, 764 - shortcut: None, 848 + shortcut: Some(LabelText::Key(GALLERY_LABEL)), 765 849 disabled: false, 766 850 }, 767 851 MenuItem::Separator,
+2
crates/bone-ui/src/input/mod.rs
··· 22 22 pub modifiers: ModifierMask, 23 23 pub double_click_window: DoubleClickWindow, 24 24 pub drag_threshold: DragThreshold, 25 + pub scroll_y: f32, 25 26 } 26 27 27 28 impl InputSnapshot { ··· 37 38 modifiers: ModifierMask::NONE, 38 39 double_click_window: DoubleClickWindow::DEFAULT, 39 40 drag_threshold: DragThreshold::DEFAULT, 41 + scroll_y: 0.0, 40 42 } 41 43 } 42 44 }
+29 -12
crates/bone-ui/src/layout/paint.rs
··· 52 52 } 53 53 54 54 #[must_use] 55 - pub fn paint_plan(solved: &SolvedLayout, theme: &Theme) -> PaintPlan { 55 + pub fn paint_plan(solved: &SolvedLayout, theme: &Theme, hot: Option<WidgetId>) -> PaintPlan { 56 56 PaintPlan { 57 - commands: walk(solved, solved.root_node(), theme), 57 + commands: walk(solved, solved.root_node(), theme, hot), 58 58 } 59 59 } 60 60 61 - fn walk(layout: &SolvedLayout, node: &SolvedNode, theme: &Theme) -> Vec<PaintCommand> { 61 + fn walk( 62 + layout: &SolvedLayout, 63 + node: &SolvedNode, 64 + theme: &Theme, 65 + hot: Option<WidgetId>, 66 + ) -> Vec<PaintCommand> { 62 67 match &node.kind { 63 - NodeKind::Pass => walk_children(layout, node, theme), 68 + NodeKind::Pass => walk_children(layout, node, theme, hot), 64 69 NodeKind::Leaf(id) => vec![PaintCommand::LeafSlot { 65 70 rect: node.rect, 66 71 id: *id, ··· 79 84 LayoutPx::saturating(-offset.y.value()), 80 85 ), 81 86 })) 82 - .chain(walk_children(layout, node, theme)) 87 + .chain(walk_children(layout, node, theme, hot)) 83 88 .chain(core::iter::once(PaintCommand::Untranslate)) 84 89 .chain(core::iter::once(PaintCommand::PopClip)) 85 90 .collect() 86 91 } 87 92 NodeKind::Splitter { axis, .. } | NodeKind::DockSplit { axis, .. } => { 88 - walk_children(layout, node, theme) 93 + walk_children(layout, node, theme, hot) 89 94 .into_iter() 90 - .chain(divider_command(layout, node, *axis, theme)) 95 + .chain(divider_command(layout, node, *axis, theme, hot)) 91 96 .collect() 92 97 } 93 98 NodeKind::DockHost { .. } => core::iter::once(PaintCommand::Surface { 94 99 rect: node.rect, 95 100 elevation: theme.elevation.level0, 96 101 }) 97 - .chain(walk_children(layout, node, theme)) 102 + .chain(walk_children(layout, node, theme, hot)) 98 103 .collect(), 99 104 NodeKind::DockTabStrip { active, tabs } => vec![PaintCommand::TabStrip { 100 105 rect: node.rect, ··· 109 114 rect: node.rect, 110 115 id: *id, 111 116 })) 112 - .chain(walk_children(layout, node, theme)) 117 + .chain(walk_children(layout, node, theme, hot)) 113 118 .collect(), 114 119 } 115 120 } 116 121 117 - fn walk_children(layout: &SolvedLayout, node: &SolvedNode, theme: &Theme) -> Vec<PaintCommand> { 122 + fn walk_children( 123 + layout: &SolvedLayout, 124 + node: &SolvedNode, 125 + theme: &Theme, 126 + hot: Option<WidgetId>, 127 + ) -> Vec<PaintCommand> { 118 128 node.children 119 129 .iter() 120 - .flat_map(|c| walk(layout, layout.node(*c), theme)) 130 + .flat_map(|c| walk(layout, layout.node(*c), theme, hot)) 121 131 .collect() 122 132 } 123 133 ··· 126 136 node: &SolvedNode, 127 137 axis: Axis, 128 138 theme: &Theme, 139 + hot: Option<WidgetId>, 129 140 ) -> Option<PaintCommand> { 130 141 if node.children.len() != 2 { 131 142 return None; 132 143 } 133 144 let first = layout.node(node.children[0]); 145 + let hovered = matches!(node.kind, NodeKind::Splitter { id, .. } if Some(id) == hot); 146 + let color = if hovered { 147 + theme.colors.accent.step(crate::theme::Step12::SOLID) 148 + } else { 149 + theme.colors.neutral.step(crate::theme::Step12::BORDER) 150 + }; 134 151 Some(PaintCommand::Divider { 135 152 rect: divider_between(axis, first.rect, node.rect), 136 153 axis, 137 - color: theme.colors.neutral.step(crate::theme::Step12::BORDER), 154 + color, 138 155 }) 139 156 } 140 157
+45 -8
crates/bone-ui/src/layout/tests.rs
··· 13 13 use super::scroll::{ScrollAxes, clamp_scroll}; 14 14 use super::splitter::{SplitterMove, SplitterStep, apply_keyboard_move, fraction_from_drag}; 15 15 use super::track::{FlexWeight, GridLine, GridLineRef, GridSpan, GridTrack, TrackName, TrackSize}; 16 - use crate::theme::{Spacing, Theme}; 16 + use crate::theme::{Spacing, Step12, Theme}; 17 17 use crate::widget_id::WidgetId; 18 18 19 19 fn wid(n: u64) -> WidgetId { ··· 552 552 b: Box::new(Layout::leaf(wid(2))), 553 553 }; 554 554 let solved = solve(&layout, size(200.0, 50.0), &retained); 555 - let plan = paint_plan(&solved, &Theme::light()); 555 + let plan = paint_plan(&solved, &Theme::light(), None); 556 556 let divider = plan.commands.iter().find_map(|c| match c { 557 557 PaintCommand::Divider { rect, .. } => Some(*rect), 558 558 _ => None, ··· 569 569 } 570 570 571 571 #[test] 572 + fn splitter_divider_takes_accent_when_hovered() { 573 + let id = wid(12); 574 + let mut retained = RetainedLayout::default(); 575 + retained.set_split(id, SplitFraction::clamped(0.4)); 576 + let layout = Layout::Splitter { 577 + id, 578 + axis: Axis::Horizontal, 579 + default_fraction: SplitFraction::HALF, 580 + a: Box::new(Layout::leaf(wid(1))), 581 + b: Box::new(Layout::leaf(wid(2))), 582 + }; 583 + let solved = solve(&layout, size(200.0, 50.0), &retained); 584 + let theme = Theme::light(); 585 + let divider_color = |hot| { 586 + paint_plan(&solved, &theme, hot) 587 + .commands 588 + .iter() 589 + .find_map(|c| match c { 590 + PaintCommand::Divider { color, .. } => Some(*color), 591 + _ => None, 592 + }) 593 + }; 594 + assert_eq!( 595 + divider_color(None), 596 + Some(theme.colors.neutral.step(Step12::BORDER)) 597 + ); 598 + assert_eq!( 599 + divider_color(Some(id)), 600 + Some(theme.colors.accent.step(Step12::SOLID)) 601 + ); 602 + assert_eq!( 603 + divider_color(Some(wid(999))), 604 + Some(theme.colors.neutral.step(Step12::BORDER)) 605 + ); 606 + } 607 + 608 + #[test] 572 609 fn scroll_region_records_offset_and_content_size() { 573 610 let id = wid(3); 574 611 let mut retained = RetainedLayout::default(); ··· 773 810 tab_strip_height: sp(24.0), 774 811 }; 775 812 let solved = solve(&layout, size(300.0, 200.0), &RetainedLayout::default()); 776 - let plan = paint_plan(&solved, &Theme::light()); 813 + let plan = paint_plan(&solved, &Theme::light(), None); 777 814 let strip = plan.commands.iter().find_map(|c| match c { 778 815 PaintCommand::TabStrip { active, tabs, .. } => Some((*active, tabs.clone())), 779 816 _ => None, ··· 828 865 child: Box::new(Layout::leaf(wid(5))), 829 866 }; 830 867 let solved = solve(&layout, size(40.0, 40.0), &RetainedLayout::default()); 831 - let plan = paint_plan(&solved, &Theme::light()); 868 + let plan = paint_plan(&solved, &Theme::light(), None); 832 869 let has_clip = plan 833 870 .commands 834 871 .iter() ··· 850 887 b: Box::new(Layout::leaf(wid(2))), 851 888 }; 852 889 let solved = solve(&layout, size(200.0, 50.0), &RetainedLayout::default()); 853 - let plan = paint_plan(&solved, &Theme::light()); 890 + let plan = paint_plan(&solved, &Theme::light(), None); 854 891 let dividers = plan 855 892 .commands 856 893 .iter() ··· 1049 1086 tab_strip_height: sp(24.0), 1050 1087 }; 1051 1088 let solved = solve(&layout, size(400.0, 300.0), &RetainedLayout::default()); 1052 - let plan = paint_plan(&solved, &Theme::light()); 1089 + let plan = paint_plan(&solved, &Theme::light(), None); 1053 1090 let panel_slots = plan 1054 1091 .commands 1055 1092 .iter() ··· 1068 1105 fn paint_plan_balances_translate_pairs_for_scroll_region() { 1069 1106 let layout = Layout::scroll_region(wid(101), ScrollAxes::Both, Layout::leaf(wid(102))); 1070 1107 let solved = solve(&layout, size(40.0, 40.0), &RetainedLayout::default()); 1071 - let plan = paint_plan(&solved, &Theme::light()); 1108 + let plan = paint_plan(&solved, &Theme::light(), None); 1072 1109 let translates = plan 1073 1110 .commands 1074 1111 .iter() ··· 1100 1137 let inner = Layout::scroll_region(wid(201), ScrollAxes::Vertical, Layout::leaf(wid(202))); 1101 1138 let outer = Layout::scroll_region(wid(200), ScrollAxes::Both, inner); 1102 1139 let solved = solve(&outer, size(80.0, 80.0), &RetainedLayout::default()); 1103 - let plan = paint_plan(&solved, &Theme::light()); 1140 + let plan = paint_plan(&solved, &Theme::light(), None); 1104 1141 let pushes = plan 1105 1142 .commands 1106 1143 .iter()
+2 -2
crates/bone-ui/src/theme/color.rs
··· 398 398 selection_primary: Color::from_srgb_u8(0x2C, 0xCD, 0x33), 399 399 selection_hover: Color::from_srgb_u8(0xFF, 0x8C, 0x00), 400 400 property_pane_active_box: Color::from_srgb_u8(0xFF, 0xE4, 0xD6), 401 - relation_glyph: Color::from_srgb_u8(0x1B, 0x7B, 0x5E), 401 + relation_glyph: Color::from_srgb_u8(0x00, 0xAA, 0x30), 402 402 reference_geometry: Color::from_srgb_u8(0x9B, 0x9B, 0x9B), 403 403 } 404 404 } ··· 412 412 const DANGER_HUE: f32 = 25.0; 413 413 414 414 const NEUTRAL_CHROMAS: [f32; 12] = [ 415 - 0.005, 0.006, 0.008, 0.010, 0.012, 0.014, 0.016, 0.018, 0.020, 0.020, 0.018, 0.012, 415 + 0.001, 0.001, 0.001, 0.001, 0.0015, 0.0015, 0.002, 0.0025, 0.003, 0.003, 0.003, 0.002, 416 416 ]; 417 417 const ACCENT_CHROMAS: [f32; 12] = [ 418 418 0.012, 0.020, 0.035, 0.055, 0.075, 0.095, 0.115, 0.135, 0.170, 0.170, 0.140, 0.080,
+3 -3
crates/bone-ui/src/widgets/checkbox.rs
··· 9 9 10 10 use super::keys::take_activation; 11 11 use super::paint::{GlyphMark, WidgetPaint}; 12 - use super::visuals::{Indicator, push_indicator}; 12 + use super::visuals::{Indicator, IndicatorMark, push_indicator}; 13 13 14 14 #[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)] 15 15 pub enum CheckboxState { ··· 118 118 state: CheckboxState, 119 119 ) -> Vec<WidgetPaint> { 120 120 let mark = match state { 121 - CheckboxState::Checked => Some(GlyphMark::Checkmark), 122 - CheckboxState::Indeterminate => Some(GlyphMark::Indeterminate), 121 + CheckboxState::Checked => Some(IndicatorMark::Check), 122 + CheckboxState::Indeterminate => Some(IndicatorMark::Glyph(GlyphMark::Indeterminate)), 123 123 CheckboxState::Unchecked => None, 124 124 }; 125 125 let mut paint = Vec::new();
+7 -6
crates/bone-ui/src/widgets/dialog.rs
··· 175 175 pub paint: Vec<WidgetPaint>, 176 176 } 177 177 178 - const DIALOG_TITLE_HEIGHT: f32 = 44.0; 179 - const DIALOG_BUTTON_HEIGHT: f32 = 32.0; 178 + const DIALOG_TITLE_HEIGHT: f32 = 32.0; 179 + const DIALOG_BUTTON_HEIGHT: f32 = 23.0; 180 180 const DIALOG_BUTTON_GAP: f32 = 8.0; 181 - const DIALOG_BUTTON_WIDTH: f32 = 96.0; 182 - const DIALOG_PADDING: f32 = 16.0; 181 + const DIALOG_BUTTON_WIDTH: f32 = 88.0; 182 + const DIALOG_PADDING: f32 = 12.0; 183 183 184 184 #[must_use] 185 185 pub fn show_dialog<F, R>(ctx: &mut FrameCtx<'_>, dialog: Dialog<'_>, body: F) -> (DialogResponse, R) ··· 754 754 let dialog_size = LayoutSize::new(LayoutPx::new(400.0), LayoutPx::new(240.0)); 755 755 let dialog_x = (800.0 - 400.0) / 2.0; 756 756 let dialog_y = (600.0 - 240.0) / 2.0; 757 - let confirm_x = dialog_x + 400.0 - 16.0 - 96.0 / 2.0; 758 - let confirm_y = dialog_y + 240.0 - 16.0 - 32.0 / 2.0; 757 + let confirm_x = dialog_x + 400.0 - super::DIALOG_PADDING - super::DIALOG_BUTTON_WIDTH / 2.0; 758 + let confirm_y = 759 + dialog_y + 240.0 - super::DIALOG_PADDING - super::DIALOG_BUTTON_HEIGHT / 2.0; 759 760 let click_pos = LayoutPos::new(LayoutPx::new(confirm_x), LayoutPx::new(confirm_y)); 760 761 let theme = Arc::new(Theme::light()); 761 762 let table = HotkeyTable::new();
+1 -1
crates/bone-ui/src/widgets/dropdown.rs
··· 289 289 popup_paints.push(WidgetPaint::Surface { 290 290 rect: item_rect, 291 291 fill: if highlighted { 292 - ctx.theme().colors.neutral.step(Step12::HOVER_BG) 292 + ctx.theme().colors.accent.step(Step12::HOVER_BG) 293 293 } else if Some(&item.value) == initial_selected { 294 294 ctx.theme().colors.neutral.step(Step12::SELECTED_BG) 295 295 } else {
+1 -1
crates/bone-ui/src/widgets/file_picker.rs
··· 367 367 368 368 fn row_center(entries: &[FilePickerEntry], idx: usize) -> LayoutPos { 369 369 let body_y = viewport().origin.y.value() + viewport().size.height.value() * 0.5 - 210.0; 370 - let body_top = body_y + 44.0; 370 + let body_top = body_y + 32.0; 371 371 let list_top = body_top + 32.0; 372 372 let row_height = 24.0; 373 373 let _ = entries;
+85 -26
crates/bone-ui/src/widgets/menu.rs
··· 638 638 pub state: &'state mut MenuBarState, 639 639 pub min_item_width: LayoutPx, 640 640 pub item_padding: LayoutPx, 641 - pub trailing_label: Option<LabelText>, 641 + pub document_label: Option<LabelText>, 642 642 } 643 643 644 644 impl<'a, 'state> MenuBar<'a, 'state> { ··· 658 658 state, 659 659 min_item_width: LayoutPx::new(36.0), 660 660 item_padding: LayoutPx::new(10.0), 661 - trailing_label: None, 661 + document_label: None, 662 662 } 663 663 } 664 664 665 665 #[must_use] 666 - pub fn with_trailing_label(mut self, label: LabelText) -> Self { 667 - self.trailing_label = Some(label); 666 + pub fn with_document_label(mut self, label: LabelText) -> Self { 667 + self.document_label = Some(label); 668 668 self 669 669 } 670 670 } ··· 686 686 state, 687 687 min_item_width, 688 688 item_padding, 689 - trailing_label, 689 + document_label, 690 690 } = bar; 691 691 ctx.a11y 692 692 .push(id, rect, AccessNode::new(Role::MenuBar).with_label(label)); ··· 741 741 state, 742 742 )); 743 743 }); 744 - if let Some(label_text) = trailing_label { 745 - paint.push(trailing_label_paint( 744 + if let Some(label_text) = document_label { 745 + paint.push(document_label_paint( 746 746 ctx, 747 747 label_text, 748 748 rect, 749 749 request, 750 - item_padding, 751 750 raw_entry_layouts.as_slice(), 752 751 direction, 753 752 )); ··· 875 874 response.activated 876 875 } 877 876 878 - fn trailing_label_paint( 877 + fn document_label_paint( 879 878 ctx: &mut FrameCtx<'_>, 880 879 label_text: LabelText, 881 880 bar_rect: LayoutRect, 882 881 request: ShapeRequest, 883 - item_padding: LayoutPx, 884 882 entry_layouts: &[LayoutRect], 885 883 direction: LayoutDirection, 886 884 ) -> WidgetPaint { ··· 891 889 .lines 892 890 .first() 893 891 .map_or(0.0, ShapedLine::visible_advance_px); 894 - let bar_max_x = bar_rect.origin.x.value() + bar_rect.size.width.value(); 895 - let trailing_width = (advance + 2.0 * item_padding.value()) 896 - .min(bar_rect.size.width.value()) 897 - .max(0.0); 898 - let trailing_x = bar_max_x - trailing_width; 899 - let entries_end = entry_layouts.last().map_or(bar_rect.origin.x.value(), |r| { 900 - r.origin.x.value() + r.size.width.value() 901 - }); 902 - let anchored_x = trailing_x.max(entries_end); 903 - let trailing_rect = LayoutRect::new( 904 - LayoutPos::new(LayoutPx::saturating(anchored_x), bar_rect.origin.y), 905 - LayoutSize::new( 906 - LayoutPx::saturating_nonneg(bar_max_x - anchored_x), 907 - bar_rect.size.height, 908 - ), 892 + let bar_min_x = bar_rect.origin.x.value(); 893 + let bar_max_x = bar_min_x + bar_rect.size.width.value(); 894 + let width = advance.min(bar_rect.size.width.value()).max(0.0); 895 + let entries_end = entry_layouts 896 + .last() 897 + .map_or(bar_min_x, |r| r.origin.x.value() + r.size.width.value()); 898 + let centered_x = bar_min_x + (bar_rect.size.width.value() - width) * 0.5; 899 + let max_x = (bar_max_x - width).max(bar_min_x); 900 + let label_x = centered_x.clamp(entries_end.min(max_x), max_x); 901 + let label_rect = LayoutRect::new( 902 + LayoutPos::new(LayoutPx::saturating(label_x), bar_rect.origin.y), 903 + LayoutSize::new(LayoutPx::saturating_nonneg(width), bar_rect.size.height), 909 904 ); 910 905 WidgetPaint::AlignedLabel { 911 - rect: trailing_rect.mirror_horizontally_within(bar_rect, direction), 906 + rect: label_rect.mirror_horizontally_within(bar_rect, direction), 912 907 text: label_text, 913 908 color: ctx.theme().colors.text_primary(), 914 909 role: ctx.theme().typography.label, ··· 1262 1257 ); 1263 1258 }); 1264 1259 assert_eq!(state.open, Some(entries[0].id)); 1260 + } 1261 + 1262 + #[test] 1263 + fn document_label_centers_in_the_bar() { 1264 + use super::super::paint::{HorizontalAlign, LabelText, WidgetPaint}; 1265 + 1266 + let entries = vec![entry("file"), entry("edit"), entry("view")]; 1267 + let mut state = bar_state(); 1268 + let theme = Arc::new(Theme::light()); 1269 + let table = HotkeyTable::new(); 1270 + let mut focus = FocusManager::new(); 1271 + let bar_rect = LayoutRect::new( 1272 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 1273 + LayoutSize::new(LayoutPx::new(1920.0), LayoutPx::new(24.0)), 1274 + ); 1275 + let doc = "Part117 *"; 1276 + let prev = HitState::new(); 1277 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1278 + let mut hits = HitFrame::new(); 1279 + let mut shaper = bone_text::Shaper::new(); 1280 + let mut a11y = crate::a11y::AccessTreeBuilder::new(); 1281 + let response = { 1282 + let mut ctx = FrameCtx::new( 1283 + theme.clone(), 1284 + &mut snap, 1285 + &mut focus, 1286 + &table, 1287 + StringTable::empty(), 1288 + &mut hits, 1289 + &prev, 1290 + &mut a11y, 1291 + &mut shaper, 1292 + ); 1293 + show_menu_bar( 1294 + &mut ctx, 1295 + MenuBar::new( 1296 + menu_bar_id(), 1297 + bar_rect, 1298 + StringKey::new("test.menu_bar"), 1299 + &entries, 1300 + &mut state, 1301 + ) 1302 + .with_document_label(LabelText::Owned(doc.to_owned())), 1303 + ) 1304 + }; 1305 + let found = response.paint.iter().find_map(|p| match p { 1306 + WidgetPaint::AlignedLabel { 1307 + rect, 1308 + text: LabelText::Owned(t), 1309 + align, 1310 + .. 1311 + } if t.as_str() == doc => Some((*rect, *align)), 1312 + _ => None, 1313 + }); 1314 + let Some((rect, align)) = found else { 1315 + panic!("document label is painted"); 1316 + }; 1317 + assert_eq!(align, HorizontalAlign::Center); 1318 + let label_center = rect.origin.x.value() + rect.size.width.value() * 0.5; 1319 + let bar_center = bar_rect.origin.x.value() + bar_rect.size.width.value() * 0.5; 1320 + assert!( 1321 + (label_center - bar_center).abs() <= 0.5, 1322 + "document label center {label_center} expected bar center {bar_center}", 1323 + ); 1265 1324 } 1266 1325 }
+12 -6
crates/bone-ui/src/widgets/mod.rs
··· 14 14 mod property_grid; 15 15 mod radio_group; 16 16 mod ribbon; 17 + mod scrollbar; 17 18 mod slider; 18 19 mod status_bar; 19 20 mod table; ··· 51 52 }; 52 53 pub use numeric_input::{NumericFloatParseError, NumericInput, NumericInputResponse}; 53 54 pub use paint::{ 54 - ButtonPaintKind, GlyphMark, HorizontalAlign, LabelText, PaintPrim, WidgetPaint, 55 - estimate_label_width_px, lower_paint, 55 + ButtonPaintKind, GlyphMark, HorizontalAlign, IconSlot, IconTint, LabelText, PaintPrim, 56 + WidgetPaint, estimate_label_width_px, lower_paint, 56 57 }; 57 58 pub use panel::{Panel, PanelResponse, PanelState, PanelTitlebar, PanelVariant, show_panel}; 58 59 pub use parsed_input::{ParsedInput, ParsedInputResponse, ParsedValue, show_parsed_input}; 59 60 pub use property_grid::{ 60 61 AngleEditor, BoolEditor, LengthEditor, PropertyCell, PropertyEditor, PropertyGrid, 61 - PropertyGridResponse, PropertyOption, PropertyRow, SelectionEditor, TextEditor, 62 - show_property_grid, 62 + PropertyGridResponse, PropertyOption, PropertyPaneAction, PropertyPaneHeader, 63 + PropertyPaneHeaderResponse, PropertyRow, SelectionEditor, TextEditor, show_property_grid, 64 + show_property_pane_header, 63 65 }; 64 66 pub use radio_group::{ 65 67 RadioGroup, RadioGroupResponse, RadioOption, RadioOrientation, show_radio_group, 66 68 }; 67 69 pub use ribbon::{Ribbon, RibbonGroup, RibbonIconSize, RibbonResponse, RibbonTab, show_ribbon}; 70 + pub use scrollbar::{ 71 + RowWindow, Scrollbar, ScrollbarResponse, row_window, show_scrollbar, wheel_scroll, 72 + window_scrollbar, 73 + }; 68 74 pub use slider::{ 69 75 Slider, SliderCoarseStep, SliderRange, SliderRangeError, SliderResponse, SliderScalar, 70 76 SliderStep, SliderStepError, show_slider, ··· 90 96 }; 91 97 pub use vector::{ConvexPoly, MAX_CONVEX_VERTS, MAX_PATH_POINTS, PolyPath}; 92 98 pub use visuals::{ 93 - FieldVisuals, Indicator, SurfaceVisuals, TextVisuals, indicator_border, indicator_fill, 94 - indicator_label_color, push_focus_ring, push_indicator, 99 + FieldVisuals, Indicator, IndicatorMark, SurfaceVisuals, TextVisuals, indicator_border, 100 + indicator_fill, indicator_label_color, push_focus_ring, push_indicator, 95 101 };
+65 -31
crates/bone-ui/src/widgets/paint.rs
··· 1 + use bone_types::IconId; 1 2 use serde::Serialize; 2 3 3 4 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; ··· 19 20 20 21 #[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)] 21 22 pub enum GlyphMark { 22 - Checkmark, 23 23 Indeterminate, 24 24 RadioDot, 25 25 Caret, 26 26 Chevron, 27 27 SliderThumb, 28 28 Spinner, 29 - Close, 30 29 DisclosureClosed, 31 30 DisclosureOpen, 32 31 SubmenuArrow, 33 32 SortAscending, 34 33 SortDescending, 35 34 Ellipsis, 36 - TreeFeature, 37 - TreePlane, 38 - TreeSketch, 39 - TabTree, 40 - TabProperties, 41 - TabConfiguration, 42 - TabDimensionExpert, 43 - TabDisplay, 44 35 } 45 36 46 37 impl GlyphMark { 47 38 #[must_use] 48 39 pub const fn glyph(self) -> &'static str { 49 40 match self { 50 - Self::Checkmark => "\u{2713}", 51 41 Self::Indeterminate => "\u{2212}", 52 42 Self::RadioDot | Self::SliderThumb => "\u{25CF}", 53 43 Self::Caret | Self::Chevron | Self::DisclosureOpen | Self::SortDescending => "\u{25BC}", 54 44 Self::DisclosureClosed | Self::SubmenuArrow => "\u{25B6}", 55 45 Self::SortAscending => "\u{25B2}", 56 - Self::Close => "\u{00D7}", 57 46 Self::Spinner => "\u{25D0}", 58 47 Self::Ellipsis => "\u{2026}", 59 - Self::TreeFeature => "\u{25CB}", 60 - Self::TreePlane => "\u{25C7}", 61 - Self::TreeSketch => "\u{25A1}", 62 - Self::TabTree => "\u{25A6}", 63 - Self::TabProperties => "\u{25A4}", 64 - Self::TabConfiguration => "\u{25A5}", 65 - Self::TabDimensionExpert => "\u{25C8}", 66 - Self::TabDisplay => "\u{25D1}", 48 + } 49 + } 50 + } 51 + 52 + #[derive(Copy, Clone, Debug, PartialEq, Serialize)] 53 + pub enum IconTint { 54 + Normal, 55 + Disabled, 56 + Solid(Color), 57 + } 58 + 59 + impl IconTint { 60 + #[must_use] 61 + pub const fn from_disabled(disabled: bool) -> Self { 62 + if disabled { 63 + Self::Disabled 64 + } else { 65 + Self::Normal 66 + } 67 + } 68 + } 69 + 70 + #[derive(Copy, Clone, Debug, PartialEq, Serialize)] 71 + pub struct IconSlot { 72 + pub icon: IconId, 73 + pub footprint: LayoutPx, 74 + } 75 + 76 + impl IconSlot { 77 + #[must_use] 78 + pub const fn new(icon: IconId, footprint: LayoutPx) -> Self { 79 + Self { icon, footprint } 80 + } 81 + 82 + #[must_use] 83 + pub fn paint_in(self, rect: LayoutRect, tint: IconTint) -> WidgetPaint { 84 + let side = self 85 + .footprint 86 + .value() 87 + .min(rect.size.width.value()) 88 + .min(rect.size.height.value()); 89 + WidgetPaint::Icon { 90 + rect: centered_box(rect, side), 91 + icon: self.icon, 92 + tint, 67 93 } 68 94 } 69 95 } ··· 136 162 rect: LayoutRect, 137 163 kind: GlyphMark, 138 164 color: Color, 165 + }, 166 + Icon { 167 + rect: LayoutRect, 168 + icon: IconId, 169 + tint: IconTint, 139 170 }, 140 171 FocusRing { 141 172 rect: LayoutRect, ··· 246 277 border: elevation.border, 247 278 radius: Radius::px(0.0), 248 279 }, 249 - WidgetPaint::ConvexFill { .. } | WidgetPaint::Stroke { .. } | WidgetPaint::Popup { .. } => { 250 - PaintPrim::solid( 251 - LayoutRect::new( 252 - LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 253 - LayoutSize::ZERO, 254 - ), 255 - Color::TRANSPARENT, 256 - ) 257 - } 280 + WidgetPaint::Icon { .. } 281 + | WidgetPaint::ConvexFill { .. } 282 + | WidgetPaint::Stroke { .. } 283 + | WidgetPaint::Popup { .. } => PaintPrim::solid( 284 + LayoutRect::new( 285 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 286 + LayoutSize::ZERO, 287 + ), 288 + Color::TRANSPARENT, 289 + ), 258 290 } 259 291 } 260 292 ··· 294 326 .min(rect.size.height.value()) 295 327 .max(0.0) 296 328 * factor; 329 + centered_box(rect, side) 330 + } 331 + 332 + fn centered_box(rect: LayoutRect, side: f32) -> LayoutRect { 297 333 let cx = rect.origin.x.value() + 0.5 * rect.size.width.value(); 298 334 let cy = rect.origin.y.value() + 0.5 * rect.size.height.value(); 299 335 LayoutRect::new( ··· 315 351 #[test] 316 352 fn every_mark_maps_to_a_non_empty_bmp_glyph() { 317 353 let cases = [ 318 - GlyphMark::Checkmark, 319 354 GlyphMark::Indeterminate, 320 355 GlyphMark::RadioDot, 321 356 GlyphMark::Caret, 322 357 GlyphMark::Chevron, 323 358 GlyphMark::SliderThumb, 324 359 GlyphMark::Spinner, 325 - GlyphMark::Close, 326 360 GlyphMark::DisclosureClosed, 327 361 GlyphMark::DisclosureOpen, 328 362 GlyphMark::SubmenuArrow,
+17 -11
crates/bone-ui/src/widgets/panel.rs
··· 7 7 use crate::widget_id::{WidgetId, WidgetKey}; 8 8 9 9 use super::keys::{TakeKey, take_key}; 10 - use super::paint::{GlyphMark, LabelText, WidgetPaint}; 10 + use super::paint::{GlyphMark, HorizontalAlign, LabelText, WidgetPaint}; 11 11 use super::visuals::push_focus_ring; 12 12 13 13 #[derive(Copy, Clone, Debug, PartialEq, Eq)] ··· 207 207 live_focused, 208 208 ); 209 209 } 210 - paint.push(WidgetPaint::Label { 210 + paint.push(WidgetPaint::AlignedLabel { 211 211 rect: label_rect(title_rect, bar.collapsible), 212 212 text: LabelText::Key(bar.label), 213 213 color: ctx.theme().colors.text_primary(), 214 214 role: ctx.theme().typography.title, 215 + align: HorizontalAlign::Start, 215 216 }); 216 217 paint 217 218 } ··· 223 224 let pad = (title.size.height.value() - CHEVRON_PX).max(0.0) / 2.0; 224 225 LayoutRect::new( 225 226 LayoutPos::new( 226 - LayoutPx::new(title.origin.x.value() + pad), 227 + LayoutPx::new(title.origin.x.value() + title.size.width.value() - CHEVRON_PX - pad), 227 228 LayoutPx::new(title.origin.y.value() + pad), 228 229 ), 229 230 LayoutSize::new(LayoutPx::new(CHEVRON_PX), LayoutPx::new(CHEVRON_PX)), ··· 231 232 } 232 233 233 234 fn label_rect(title: LayoutRect, leave_room_for_chevron: bool) -> LayoutRect { 234 - if !leave_room_for_chevron { 235 - return title; 236 - } 237 - let trim = LayoutPx::new(CHEVRON_PX + CHEVRON_GAP); 238 - let new_x = LayoutPx::new(title.origin.x.value() + trim.value()); 239 - let width = LayoutPx::saturating_nonneg(title.size.width.value() - trim.value()); 235 + let trail = if leave_room_for_chevron { 236 + CHEVRON_PX + CHEVRON_GAP 237 + } else { 238 + CHEVRON_GAP 239 + }; 240 240 LayoutRect::new( 241 - LayoutPos::new(new_x, title.origin.y), 242 - LayoutSize::new(width, title.size.height), 241 + LayoutPos::new( 242 + LayoutPx::new(title.origin.x.value() + CHEVRON_GAP), 243 + title.origin.y, 244 + ), 245 + LayoutSize::new( 246 + LayoutPx::saturating_nonneg(title.size.width.value() - CHEVRON_GAP - trail), 247 + title.size.height, 248 + ), 243 249 ) 244 250 } 245 251
+208 -5
crates/bone-ui/src/widgets/property_grid.rs
··· 1 + use bone_types::IconId; 1 2 use uom::si::f64::{Angle, Length}; 2 3 3 4 use crate::a11y::{AccessNode, Role}; 4 - use crate::frame::FrameCtx; 5 + use crate::frame::{FrameCtx, InteractDeclaration}; 6 + use crate::hit_test::Sense; 5 7 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 6 8 use crate::strings::StringKey; 7 - use crate::theme::Step12; 9 + use crate::theme::{Border, Color, Step12, StrokeWidth}; 8 10 use crate::widget_id::WidgetId; 9 11 10 12 use super::checkbox::{Checkbox, CheckboxState, show_checkbox}; 11 13 use super::dimensioned_input::DimensionedInput; 12 14 use super::dropdown::{Dropdown, DropdownItem, DropdownState, show_dropdown}; 13 - use super::paint::{LabelText, WidgetPaint}; 15 + use super::keys::take_activation; 16 + use super::paint::{HorizontalAlign, IconTint, LabelText, WidgetPaint}; 14 17 use super::parsed_input::show_parsed_input; 15 18 use super::text_input::{AlwaysValid, Clipboard, TextInput, TextInputState, show_text_input}; 19 + use super::visuals::push_focus_ring; 16 20 17 21 #[derive(Copy, Clone, Debug, PartialEq)] 18 22 pub struct PropertyCell { ··· 62 66 rect, 63 67 label, 64 68 rows, 65 - row_height: LayoutPx::new(28.0), 69 + row_height: LayoutPx::new(22.0), 66 70 label_width: LayoutPx::new(120.0), 67 71 padding: LayoutPx::new(8.0), 68 72 } ··· 104 108 .enumerate() 105 109 .filter_map(|(idx, row)| { 106 110 let row_rect = property_row_rect(rect, idx, row_height); 107 - paint.push(WidgetPaint::Label { 111 + paint.push(WidgetPaint::AlignedLabel { 108 112 rect: label_rect_at(row_rect, label_width, padding), 109 113 text: LabelText::Key(row.label), 110 114 color: ctx.theme().colors.text_secondary(), 111 115 role: ctx.theme().typography.label, 116 + align: HorizontalAlign::Start, 112 117 }); 113 118 paint.push(WidgetPaint::Surface { 114 119 rect: divider_rect(row_rect), ··· 132 137 changed_rows, 133 138 paint, 134 139 } 140 + } 141 + 142 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 143 + pub enum PropertyPaneAction { 144 + Accept, 145 + Cancel, 146 + } 147 + 148 + #[derive(Copy, Clone, Debug, PartialEq)] 149 + pub struct PropertyPaneHeader { 150 + pub id: WidgetId, 151 + pub rect: LayoutRect, 152 + pub title: StringKey, 153 + pub accept_id: WidgetId, 154 + pub cancel_id: WidgetId, 155 + } 156 + 157 + #[derive(Clone, Debug, PartialEq)] 158 + pub struct PropertyPaneHeaderResponse { 159 + pub action: Option<PropertyPaneAction>, 160 + pub paint: Vec<WidgetPaint>, 161 + } 162 + 163 + const HEADER_PAD: f32 = 8.0; 164 + const HEADER_TITLE_HEIGHT: f32 = 22.0; 165 + const HEADER_BUTTON: f32 = 22.0; 166 + const HEADER_BUTTON_GAP: f32 = 4.0; 167 + 168 + #[must_use] 169 + pub fn show_property_pane_header( 170 + ctx: &mut FrameCtx<'_>, 171 + header: PropertyPaneHeader, 172 + ) -> PropertyPaneHeaderResponse { 173 + let PropertyPaneHeader { 174 + id, 175 + rect, 176 + title, 177 + accept_id, 178 + cancel_id, 179 + } = header; 180 + ctx.a11y 181 + .push(id, rect, AccessNode::new(Role::Pane).with_label(title)); 182 + let mut paint = vec![WidgetPaint::Surface { 183 + rect, 184 + fill: ctx.theme().colors.neutral.step(Step12::SUBTLE_BG), 185 + border: Some(Border { 186 + width: StrokeWidth::HAIRLINE, 187 + color: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER), 188 + }), 189 + radius: ctx.theme().radius.none, 190 + elevation: None, 191 + }]; 192 + paint.push(WidgetPaint::AlignedLabel { 193 + rect: header_title_rect(rect), 194 + text: LabelText::Key(title), 195 + color: ctx.theme().colors.text_primary(), 196 + role: ctx.theme().typography.title, 197 + align: HorizontalAlign::Start, 198 + }); 199 + let accept_color = ctx.theme().colors.success.step(Step12::SOLID); 200 + let cancel_color = ctx.theme().colors.danger.step(Step12::SOLID); 201 + let accepted = header_action_button( 202 + ctx, 203 + accept_id, 204 + header_button_rect(rect, 0), 205 + IconId::Check, 206 + accept_color, 207 + title, 208 + &mut paint, 209 + ); 210 + let cancelled = header_action_button( 211 + ctx, 212 + cancel_id, 213 + header_button_rect(rect, 1), 214 + IconId::Cross, 215 + cancel_color, 216 + title, 217 + &mut paint, 218 + ); 219 + let action = match (accepted, cancelled) { 220 + (true, _) => Some(PropertyPaneAction::Accept), 221 + (false, true) => Some(PropertyPaneAction::Cancel), 222 + (false, false) => None, 223 + }; 224 + PropertyPaneHeaderResponse { action, paint } 225 + } 226 + 227 + fn header_action_button( 228 + ctx: &mut FrameCtx<'_>, 229 + id: WidgetId, 230 + rect: LayoutRect, 231 + icon: IconId, 232 + glyph_color: Color, 233 + label: StringKey, 234 + paint: &mut Vec<WidgetPaint>, 235 + ) -> bool { 236 + let interaction = ctx.interact( 237 + InteractDeclaration::new(id, rect, Sense::INTERACTIVE) 238 + .focusable(true) 239 + .a11y(AccessNode::new(Role::Button).with_label(label)), 240 + ); 241 + let live_focused = ctx.is_focused(id); 242 + if interaction.hover() || interaction.pressed() { 243 + let step = if interaction.pressed() { 244 + Step12::SELECTED_BG 245 + } else { 246 + Step12::HOVER_BG 247 + }; 248 + paint.push(WidgetPaint::Surface { 249 + rect, 250 + fill: ctx.theme().colors.neutral.step(step), 251 + border: None, 252 + radius: ctx.theme().radius.sm, 253 + elevation: None, 254 + }); 255 + } 256 + paint.push(WidgetPaint::Icon { 257 + rect, 258 + icon, 259 + tint: IconTint::Solid(glyph_color), 260 + }); 261 + push_focus_ring(ctx, paint, rect, ctx.theme().radius.sm, live_focused); 262 + interaction.click() || (live_focused && take_activation(ctx.input)) 263 + } 264 + 265 + fn header_title_rect(rect: LayoutRect) -> LayoutRect { 266 + LayoutRect::new( 267 + LayoutPos::new( 268 + LayoutPx::new(rect.origin.x.value() + HEADER_PAD), 269 + rect.origin.y, 270 + ), 271 + LayoutSize::new( 272 + LayoutPx::saturating_nonneg(rect.size.width.value() - 2.0 * HEADER_PAD), 273 + LayoutPx::new(HEADER_TITLE_HEIGHT), 274 + ), 275 + ) 276 + } 277 + 278 + fn header_button_rect(rect: LayoutRect, index: u8) -> LayoutRect { 279 + let x = 280 + rect.origin.x.value() + HEADER_PAD + f32::from(index) * (HEADER_BUTTON + HEADER_BUTTON_GAP); 281 + let y = rect.origin.y.value() + HEADER_TITLE_HEIGHT; 282 + LayoutRect::new( 283 + LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), 284 + LayoutSize::new(LayoutPx::new(HEADER_BUTTON), LayoutPx::new(HEADER_BUTTON)), 285 + ) 135 286 } 136 287 137 288 fn property_row_rect(grid: LayoutRect, idx: usize, row_height: LayoutPx) -> LayoutRect { ··· 545 696 }; 546 697 assert_eq!(response.changed_rows, vec![row_id]); 547 698 assert!(bool_editor.value); 699 + } 700 + 701 + #[test] 702 + fn click_accept_button_emits_accept_action() { 703 + let header_id = WidgetId::ROOT.child(WidgetKey::new("pane")); 704 + let accept_id = WidgetId::ROOT.child(WidgetKey::new("accept")); 705 + let cancel_id = WidgetId::ROOT.child(WidgetKey::new("cancel")); 706 + let mut focus = FocusManager::new(); 707 + let mut prev = HitState::new(); 708 + let click_pos = LayoutPos::new(LayoutPx::new(19.0), LayoutPx::new(33.0)); 709 + let theme = Arc::new(Theme::light()); 710 + let table = HotkeyTable::new(); 711 + let mut last: Option<super::PropertyPaneHeaderResponse> = None; 712 + [press(click_pos), release(click_pos), idle(click_pos)] 713 + .into_iter() 714 + .for_each(|mut snap| { 715 + let mut hits = HitFrame::new(); 716 + let response = { 717 + let mut shaper = bone_text::Shaper::new(); 718 + let mut a11y = crate::a11y::AccessTreeBuilder::new(); 719 + let mut ctx = FrameCtx::new( 720 + theme.clone(), 721 + &mut snap, 722 + &mut focus, 723 + &table, 724 + StringTable::empty(), 725 + &mut hits, 726 + &prev, 727 + &mut a11y, 728 + &mut shaper, 729 + ); 730 + super::show_property_pane_header( 731 + &mut ctx, 732 + super::PropertyPaneHeader { 733 + id: header_id, 734 + rect: LayoutRect::new( 735 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 736 + LayoutSize::new(LayoutPx::new(240.0), LayoutPx::new(48.0)), 737 + ), 738 + title: StringKey::new("test.pane"), 739 + accept_id, 740 + cancel_id, 741 + }, 742 + ) 743 + }; 744 + last = Some(response); 745 + prev = resolve(&prev, &hits, &snap, focus.focused()); 746 + }); 747 + let Some(response) = last else { 748 + panic!("response missing") 749 + }; 750 + assert_eq!(response.action, Some(super::PropertyPaneAction::Accept)); 548 751 } 549 752 550 753 #[test]
+2 -2
crates/bone-ui/src/widgets/radio_group.rs
··· 8 8 9 9 use super::keys::{TakeKey, take_activation, take_key}; 10 10 use super::paint::{GlyphMark, WidgetPaint}; 11 - use super::visuals::{Indicator, push_indicator}; 11 + use super::visuals::{Indicator, IndicatorMark, push_indicator}; 12 12 13 13 #[derive(Copy, Clone, Debug, PartialEq, Eq)] 14 14 pub enum RadioOrientation { ··· 119 119 Indicator { 120 120 rect: option.rect, 121 121 label: option.label, 122 - mark: active.then_some(GlyphMark::RadioDot), 122 + mark: active.then_some(IndicatorMark::Glyph(GlyphMark::RadioDot)), 123 123 active, 124 124 disabled, 125 125 radius: ctx.theme().radius.pill,
+29 -30
crates/bone-ui/src/widgets/ribbon.rs
··· 5 5 use crate::theme::{Border, Step12, StrokeWidth}; 6 6 use crate::widget_id::{WidgetId, WidgetKey}; 7 7 8 - use super::paint::{LabelText, WidgetPaint, estimate_label_width_px}; 8 + use bone_types::IconId; 9 + 10 + use super::paint::{IconSlot, WidgetPaint, estimate_label_width_px}; 9 11 use super::tabs::{Tab, Tabs, TabsOrientation, show_tabs}; 10 12 use super::toolbar::{Toolbar, ToolbarItem, ToolbarOverflowConfig, show_toolbar}; 11 13 ··· 23 25 Self::Small => LayoutPx::new(28.0), 24 26 } 25 27 } 28 + 29 + #[must_use] 30 + pub const fn icon_px(self) -> LayoutPx { 31 + match self { 32 + Self::Large => LayoutPx::new(24.0), 33 + Self::Small => LayoutPx::new(16.0), 34 + } 35 + } 36 + 37 + #[must_use] 38 + pub const fn slot(self, icon: IconId) -> IconSlot { 39 + IconSlot::new(icon, self.icon_px()) 40 + } 41 + 42 + #[must_use] 43 + pub const fn rows(self) -> usize { 44 + match self { 45 + Self::Large => 1, 46 + Self::Small => 2, 47 + } 48 + } 26 49 } 27 50 28 51 #[derive(Clone, Debug, PartialEq)] ··· 79 102 pub tabs: &'a [RibbonTab], 80 103 pub active: WidgetId, 81 104 pub tab_strip_height: LayoutPx, 82 - pub group_label_height: LayoutPx, 83 105 pub group_gap: LayoutPx, 84 106 pub group_padding: LayoutPx, 85 107 } ··· 100 122 tabs, 101 123 active, 102 124 tab_strip_height: LayoutPx::new(28.0), 103 - group_label_height: LayoutPx::new(16.0), 104 125 group_gap: LayoutPx::new(8.0), 105 126 group_padding: LayoutPx::new(8.0), 106 127 } ··· 127 148 tabs, 128 149 active, 129 150 tab_strip_height, 130 - group_label_height, 131 151 group_gap, 132 152 group_padding, 133 153 } = ribbon; ··· 183 203 &active_tab.groups, 184 204 GroupLayout { 185 205 body_rect, 186 - group_label_height, 187 206 group_gap, 188 207 group_padding, 189 208 direction, ··· 240 259 #[derive(Copy, Clone)] 241 260 struct GroupLayout { 242 261 body_rect: LayoutRect, 243 - group_label_height: LayoutPx, 244 262 group_gap: LayoutPx, 245 263 group_padding: LayoutPx, 246 264 direction: LayoutDirection, ··· 257 275 ) -> Vec<WidgetPaint> { 258 276 let GroupLayout { 259 277 body_rect, 260 - group_label_height, 261 278 group_gap, 262 279 group_padding, 263 280 direction, ··· 284 301 *group_rect, 285 302 AccessNode::new(Role::Group).with_label(group.label), 286 303 ); 287 - let toolbar_rect = inner_toolbar_rect(*group_rect, group_label_height, group_padding); 304 + let toolbar_rect = inner_toolbar_rect(*group_rect, group_padding); 288 305 let toolbar = Toolbar::horizontal( 289 306 group.id.child(WidgetKey::new("toolbar")), 290 307 toolbar_rect, ··· 292 309 &group.items, 293 310 group.icon_size.item_px(), 294 311 LayoutPx::new(4.0), 295 - ); 312 + ) 313 + .with_rows(group.icon_size.rows()); 296 314 let toolbar = match group.overflow_label { 297 315 Some(label) => toolbar.with_overflow( 298 316 ToolbarOverflowConfig::new(label).with_open(group.overflow_open), ··· 311 329 if response.overflow_toggled { 312 330 overflow_toggled.push(group.id); 313 331 } 314 - paint.push(WidgetPaint::Label { 315 - rect: group_label_rect(*group_rect, group_label_height), 316 - text: LabelText::Key(group.label), 317 - color: ctx.theme().colors.text_secondary(), 318 - role: ctx.theme().typography.caption, 319 - }); 320 332 }); 321 333 paint 322 334 } ··· 414 426 .collect() 415 427 } 416 428 417 - fn inner_toolbar_rect(group: LayoutRect, label_height: LayoutPx, padding: LayoutPx) -> LayoutRect { 418 - let avail_height = 419 - (group.size.height.value() - label_height.value() - 2.0 * padding.value()).max(0.0); 429 + fn inner_toolbar_rect(group: LayoutRect, padding: LayoutPx) -> LayoutRect { 430 + let avail_height = (group.size.height.value() - 2.0 * padding.value()).max(0.0); 420 431 LayoutRect::new( 421 432 LayoutPos::new( 422 433 LayoutPx::new(group.origin.x.value() + padding.value()), ··· 426 437 LayoutPx::saturating_nonneg(group.size.width.value() - 2.0 * padding.value()), 427 438 LayoutPx::new(avail_height), 428 439 ), 429 - ) 430 - } 431 - 432 - fn group_label_rect(group: LayoutRect, label_height: LayoutPx) -> LayoutRect { 433 - LayoutRect::new( 434 - LayoutPos::new( 435 - group.origin.x, 436 - LayoutPx::new( 437 - group.origin.y.value() + group.size.height.value() - label_height.value(), 438 - ), 439 - ), 440 - LayoutSize::new(group.size.width, label_height), 441 440 ) 442 441 } 443 442
+673
crates/bone-ui/src/widgets/scrollbar.rs
··· 1 + use crate::a11y::{AccessNode, AccessRange, Role}; 2 + use crate::frame::{FrameCtx, InteractDeclaration}; 3 + use crate::hit_test::{Interaction, Sense}; 4 + use crate::input::{KeyCode, NamedKey}; 5 + use crate::layout::{Axis, LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 6 + use crate::strings::StringKey; 7 + use crate::theme::{Border, Step12, StrokeWidth}; 8 + use crate::widget_id::{WidgetId, WidgetKey}; 9 + 10 + use super::keys::{TakeKey, take_key}; 11 + use super::paint::WidgetPaint; 12 + use super::visuals::push_focus_ring; 13 + 14 + const MIN_THUMB_PX: f32 = 18.0; 15 + const LINE_STEP_PX: f32 = 24.0; 16 + const THUMB_INSET_PX: f32 = 2.0; 17 + const SCROLLBAR_WIDTH_PX: f32 = 14.0; 18 + 19 + pub struct RowWindow { 20 + pub content_rect: LayoutRect, 21 + pub bar: Option<LayoutRect>, 22 + pub content_len: LayoutPx, 23 + pub offset: LayoutPx, 24 + pub first_row: usize, 25 + pub last_row: usize, 26 + } 27 + 28 + #[must_use] 29 + pub fn row_window( 30 + rect: LayoutRect, 31 + row_count: usize, 32 + row_height: LayoutPx, 33 + requested: LayoutPx, 34 + ) -> RowWindow { 35 + let row_h = row_height.value(); 36 + let viewport_h = rect.size.height.value(); 37 + #[allow( 38 + clippy::cast_precision_loss, 39 + reason = "row count fits the f32 mantissa" 40 + )] 41 + let content_h = row_count as f32 * row_h; 42 + let scrollable = row_h > 0.0 && content_h > viewport_h + 0.5; 43 + let bar_w = if scrollable { SCROLLBAR_WIDTH_PX } else { 0.0 }; 44 + let content_rect = LayoutRect::new( 45 + rect.origin, 46 + LayoutSize::new( 47 + LayoutPx::saturating_nonneg(rect.size.width.value() - bar_w), 48 + rect.size.height, 49 + ), 50 + ); 51 + if !scrollable { 52 + return RowWindow { 53 + content_rect, 54 + bar: None, 55 + content_len: LayoutPx::new(content_h), 56 + offset: LayoutPx::ZERO, 57 + first_row: 0, 58 + last_row: row_count, 59 + }; 60 + } 61 + let max_offset = (content_h - viewport_h).max(0.0); 62 + let clamped = requested.value().clamp(0.0, max_offset); 63 + let (first_row, fit_rows) = row_span(clamped, viewport_h, row_h); 64 + let last_row = (first_row + fit_rows).min(row_count); 65 + #[allow( 66 + clippy::cast_precision_loss, 67 + reason = "row index fits the f32 mantissa" 68 + )] 69 + let snapped = first_row as f32 * row_h; 70 + let bar = LayoutRect::new( 71 + LayoutPos::new( 72 + LayoutPx::new(rect.origin.x.value() + rect.size.width.value() - bar_w), 73 + rect.origin.y, 74 + ), 75 + LayoutSize::new(LayoutPx::new(bar_w), rect.size.height), 76 + ); 77 + RowWindow { 78 + content_rect, 79 + bar: Some(bar), 80 + content_len: LayoutPx::new(content_h), 81 + offset: LayoutPx::new(snapped), 82 + first_row, 83 + last_row, 84 + } 85 + } 86 + 87 + #[allow( 88 + clippy::cast_possible_truncation, 89 + clippy::cast_sign_loss, 90 + reason = "non-negative row counts stay well within usize" 91 + )] 92 + fn row_span(offset_px: f32, viewport_h: f32, row_h: f32) -> (usize, usize) { 93 + let first = (offset_px / row_h).floor() as usize; 94 + let fit = (viewport_h / row_h).floor() as usize; 95 + (first, fit) 96 + } 97 + 98 + #[must_use] 99 + pub fn wheel_scroll(ctx: &FrameCtx<'_>, rect: LayoutRect, offset: LayoutPx) -> LayoutPx { 100 + if ctx.input.pointer.is_some_and(|p| rect.contains(p.position)) { 101 + LayoutPx::new(offset.value() + ctx.input.scroll_y) 102 + } else { 103 + offset 104 + } 105 + } 106 + 107 + #[must_use] 108 + pub fn window_scrollbar( 109 + ctx: &mut FrameCtx<'_>, 110 + container_id: WidgetId, 111 + label: StringKey, 112 + window: &RowWindow, 113 + offset: LayoutPx, 114 + ) -> (Vec<WidgetPaint>, LayoutPx) { 115 + let Some(bar_rect) = window.bar else { 116 + return (Vec::new(), offset); 117 + }; 118 + let response = show_scrollbar( 119 + ctx, 120 + Scrollbar::new( 121 + container_id.child(WidgetKey::new("scrollbar")), 122 + bar_rect, 123 + Axis::Vertical, 124 + label, 125 + window.content_len, 126 + offset, 127 + ), 128 + ); 129 + let next = if response.changed { 130 + response.offset 131 + } else { 132 + offset 133 + }; 134 + (response.paint, next) 135 + } 136 + 137 + pub struct Scrollbar { 138 + pub id: WidgetId, 139 + pub rect: LayoutRect, 140 + pub axis: Axis, 141 + pub label: StringKey, 142 + pub content: LayoutPx, 143 + pub offset: LayoutPx, 144 + pub disabled: bool, 145 + } 146 + 147 + impl Scrollbar { 148 + #[must_use] 149 + pub fn new( 150 + id: WidgetId, 151 + rect: LayoutRect, 152 + axis: Axis, 153 + label: StringKey, 154 + content: LayoutPx, 155 + offset: LayoutPx, 156 + ) -> Self { 157 + Self { 158 + id, 159 + rect, 160 + axis, 161 + label, 162 + content, 163 + offset, 164 + disabled: false, 165 + } 166 + } 167 + 168 + #[must_use] 169 + pub fn disabled(self, disabled: bool) -> Self { 170 + Self { disabled, ..self } 171 + } 172 + } 173 + 174 + #[derive(Clone, Debug, PartialEq)] 175 + pub struct ScrollbarResponse { 176 + pub interaction: Interaction, 177 + pub offset: LayoutPx, 178 + pub changed: bool, 179 + pub paint: Vec<WidgetPaint>, 180 + } 181 + 182 + #[derive(Copy, Clone, Debug, PartialEq)] 183 + struct Geometry { 184 + track_len: f32, 185 + thumb_len: f32, 186 + max_offset: f32, 187 + } 188 + 189 + impl Geometry { 190 + fn of(rect: LayoutRect, axis: Axis, content: LayoutPx) -> Self { 191 + let track_len = axis_len(rect, axis); 192 + let content = content.value().max(track_len); 193 + let ratio = if content > 0.0 { 194 + (track_len / content).clamp(0.0, 1.0) 195 + } else { 196 + 1.0 197 + }; 198 + let thumb_len = (track_len * ratio).clamp(MIN_THUMB_PX.min(track_len), track_len); 199 + Self { 200 + track_len, 201 + thumb_len, 202 + max_offset: (content - track_len).max(0.0), 203 + } 204 + } 205 + 206 + fn scrollable(self) -> bool { 207 + self.max_offset > 0.0 208 + } 209 + 210 + fn travel(self) -> f32 { 211 + (self.track_len - self.thumb_len).max(0.0) 212 + } 213 + 214 + fn thumb_start(self, offset: f32) -> f32 { 215 + if self.max_offset <= 0.0 { 216 + return 0.0; 217 + } 218 + (offset / self.max_offset).clamp(0.0, 1.0) * self.travel() 219 + } 220 + 221 + fn offset_at(self, local: f32) -> f32 { 222 + let travel = self.travel(); 223 + if travel <= 0.0 { 224 + return 0.0; 225 + } 226 + let unit = ((local - self.thumb_len / 2.0) / travel).clamp(0.0, 1.0); 227 + unit * self.max_offset 228 + } 229 + } 230 + 231 + #[must_use] 232 + #[allow( 233 + clippy::needless_pass_by_value, 234 + reason = "destructure consumes the scrollbar" 235 + )] 236 + pub fn show_scrollbar(ctx: &mut FrameCtx<'_>, scrollbar: Scrollbar) -> ScrollbarResponse { 237 + let Scrollbar { 238 + id, 239 + rect, 240 + axis, 241 + label, 242 + content, 243 + offset: initial, 244 + disabled, 245 + } = scrollbar; 246 + let geom = Geometry::of(rect, axis, content); 247 + let interactive = !disabled && geom.scrollable(); 248 + let interaction = ctx.interact( 249 + InteractDeclaration::new(id, rect, Sense::DRAGGABLE) 250 + .focusable(interactive) 251 + .disabled(!interactive), 252 + ); 253 + 254 + let mut offset = initial.value().clamp(0.0, geom.max_offset); 255 + let mut changed = false; 256 + 257 + if interactive 258 + && (interaction.click() || interaction.drag_start() || interaction.pressed()) 259 + && let Some(next) = pointer_offset(rect, axis, geom, ctx.input) 260 + && (next - offset).abs() > f32::EPSILON 261 + { 262 + offset = next; 263 + changed = true; 264 + } 265 + 266 + let live_focused = ctx.is_focused(id); 267 + if interactive 268 + && live_focused 269 + && let Some(event) = take_key(ctx.input, keyboard_targets(axis)) 270 + { 271 + let next = apply_key(offset, geom, axis, event.code); 272 + if (next - offset).abs() > f32::EPSILON { 273 + offset = next; 274 + changed = true; 275 + } 276 + } 277 + 278 + ctx.a11y.push( 279 + id, 280 + rect, 281 + AccessNode::new(Role::ScrollBar) 282 + .with_label(label) 283 + .with_disabled(!interactive) 284 + .with_range(AccessRange { 285 + value: f64::from(offset), 286 + min: 0.0, 287 + max: f64::from(geom.max_offset), 288 + step: f64::from(LINE_STEP_PX), 289 + }), 290 + ); 291 + 292 + let thumb = thumb_rect(rect, axis, geom, offset); 293 + let paint = build_paint(ctx, rect, thumb, !interactive, interaction, live_focused); 294 + ScrollbarResponse { 295 + interaction, 296 + offset: LayoutPx::new(offset), 297 + changed, 298 + paint, 299 + } 300 + } 301 + 302 + fn keyboard_targets(axis: Axis) -> &'static [TakeKey] { 303 + const VERTICAL: [TakeKey; 6] = [ 304 + TakeKey::named(NamedKey::ArrowUp), 305 + TakeKey::named(NamedKey::ArrowDown), 306 + TakeKey::named(NamedKey::PageUp), 307 + TakeKey::named(NamedKey::PageDown), 308 + TakeKey::named(NamedKey::Home), 309 + TakeKey::named(NamedKey::End), 310 + ]; 311 + const HORIZONTAL: [TakeKey; 6] = [ 312 + TakeKey::named(NamedKey::ArrowLeft), 313 + TakeKey::named(NamedKey::ArrowRight), 314 + TakeKey::named(NamedKey::PageUp), 315 + TakeKey::named(NamedKey::PageDown), 316 + TakeKey::named(NamedKey::Home), 317 + TakeKey::named(NamedKey::End), 318 + ]; 319 + match axis { 320 + Axis::Vertical => &VERTICAL, 321 + Axis::Horizontal => &HORIZONTAL, 322 + } 323 + } 324 + 325 + fn apply_key(offset: f32, geom: Geometry, axis: Axis, code: KeyCode) -> f32 { 326 + let (back, forward) = match axis { 327 + Axis::Vertical => (NamedKey::ArrowUp, NamedKey::ArrowDown), 328 + Axis::Horizontal => (NamedKey::ArrowLeft, NamedKey::ArrowRight), 329 + }; 330 + let next = match code { 331 + KeyCode::Named(key) if key == back => offset - LINE_STEP_PX, 332 + KeyCode::Named(key) if key == forward => offset + LINE_STEP_PX, 333 + KeyCode::Named(NamedKey::PageUp) => offset - geom.track_len, 334 + KeyCode::Named(NamedKey::PageDown) => offset + geom.track_len, 335 + KeyCode::Named(NamedKey::Home) => 0.0, 336 + KeyCode::Named(NamedKey::End) => geom.max_offset, 337 + _ => offset, 338 + }; 339 + next.clamp(0.0, geom.max_offset) 340 + } 341 + 342 + fn pointer_offset( 343 + rect: LayoutRect, 344 + axis: Axis, 345 + geom: Geometry, 346 + input: &crate::input::InputSnapshot, 347 + ) -> Option<f32> { 348 + let pointer = input.pointer?.position; 349 + let local = match axis { 350 + Axis::Vertical => pointer.y.value() - rect.origin.y.value(), 351 + Axis::Horizontal => pointer.x.value() - rect.origin.x.value(), 352 + }; 353 + Some(geom.offset_at(local)) 354 + } 355 + 356 + fn axis_len(rect: LayoutRect, axis: Axis) -> f32 { 357 + match axis { 358 + Axis::Vertical => rect.size.height.value(), 359 + Axis::Horizontal => rect.size.width.value(), 360 + } 361 + } 362 + 363 + fn build_paint( 364 + ctx: &FrameCtx<'_>, 365 + track: LayoutRect, 366 + thumb: LayoutRect, 367 + inactive: bool, 368 + interaction: Interaction, 369 + live_focused: bool, 370 + ) -> Vec<WidgetPaint> { 371 + let neutral = ctx.theme().colors.neutral; 372 + let radius = ctx.theme().radius.sm; 373 + let thumb_fill = if inactive { 374 + neutral.step(Step12::SELECTED_BG) 375 + } else if interaction.pressed() { 376 + neutral.step(Step12::SOLID) 377 + } else if interaction.hover() { 378 + neutral.step(Step12::HOVER_BORDER) 379 + } else { 380 + neutral.step(Step12::BORDER) 381 + }; 382 + let mut paint = vec![ 383 + WidgetPaint::Surface { 384 + rect: track, 385 + fill: neutral.step(Step12::SUBTLE_BG), 386 + border: Some(Border { 387 + width: StrokeWidth::HAIRLINE, 388 + color: neutral.step(Step12::SUBTLE_BORDER), 389 + }), 390 + radius, 391 + elevation: None, 392 + }, 393 + WidgetPaint::Surface { 394 + rect: thumb, 395 + fill: thumb_fill, 396 + border: Some(Border { 397 + width: StrokeWidth::HAIRLINE, 398 + color: neutral.step(Step12::BORDER), 399 + }), 400 + radius, 401 + elevation: None, 402 + }, 403 + ]; 404 + push_focus_ring(ctx, &mut paint, thumb, radius, live_focused); 405 + paint 406 + } 407 + 408 + fn thumb_rect(rect: LayoutRect, axis: Axis, geom: Geometry, offset: f32) -> LayoutRect { 409 + let start = geom.thumb_start(offset); 410 + let inset = THUMB_INSET_PX.min(cross_len(rect, axis) / 2.0); 411 + match axis { 412 + Axis::Vertical => LayoutRect::new( 413 + LayoutPos::new( 414 + LayoutPx::new(rect.origin.x.value() + inset), 415 + LayoutPx::new(rect.origin.y.value() + start), 416 + ), 417 + LayoutSize::new( 418 + LayoutPx::new((rect.size.width.value() - 2.0 * inset).max(0.0)), 419 + LayoutPx::new(geom.thumb_len), 420 + ), 421 + ), 422 + Axis::Horizontal => LayoutRect::new( 423 + LayoutPos::new( 424 + LayoutPx::new(rect.origin.x.value() + start), 425 + LayoutPx::new(rect.origin.y.value() + inset), 426 + ), 427 + LayoutSize::new( 428 + LayoutPx::new(geom.thumb_len), 429 + LayoutPx::new((rect.size.height.value() - 2.0 * inset).max(0.0)), 430 + ), 431 + ), 432 + } 433 + } 434 + 435 + fn cross_len(rect: LayoutRect, axis: Axis) -> f32 { 436 + match axis { 437 + Axis::Vertical => rect.size.width.value(), 438 + Axis::Horizontal => rect.size.height.value(), 439 + } 440 + } 441 + 442 + #[cfg(test)] 443 + mod tests { 444 + use std::sync::Arc; 445 + 446 + use super::{Geometry, MIN_THUMB_PX, Scrollbar, ScrollbarResponse, show_scrollbar}; 447 + use crate::a11y::AccessTreeBuilder; 448 + use crate::focus::FocusManager; 449 + use crate::frame::FrameCtx; 450 + use crate::hit_test::{HitFrame, HitState, resolve}; 451 + use crate::hotkey::HotkeyTable; 452 + use crate::input::{ 453 + FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton, 454 + PointerButtonMask, PointerSample, 455 + }; 456 + use crate::layout::{Axis, LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 457 + use crate::strings::{StringKey, StringTable}; 458 + use crate::theme::Theme; 459 + use crate::widget_id::{WidgetId, WidgetKey}; 460 + 461 + const LABEL: StringKey = StringKey::new("scrollbar.label"); 462 + const TRACK_LEN: f32 = 140.0; 463 + 464 + fn bar_id() -> WidgetId { 465 + WidgetId::ROOT.child(WidgetKey::new("scrollbar")) 466 + } 467 + 468 + fn rect() -> LayoutRect { 469 + LayoutRect::new( 470 + LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)), 471 + LayoutSize::new(LayoutPx::new(14.0), LayoutPx::new(TRACK_LEN)), 472 + ) 473 + } 474 + 475 + #[test] 476 + fn thumb_shrinks_with_content() { 477 + let small = Geometry::of(rect(), Axis::Vertical, LayoutPx::new(280.0)); 478 + let large = Geometry::of(rect(), Axis::Vertical, LayoutPx::new(700.0)); 479 + assert!(small.thumb_len > large.thumb_len); 480 + assert!((small.thumb_len - TRACK_LEN / 2.0).abs() < 1e-3); 481 + } 482 + 483 + #[test] 484 + fn thumb_fills_track_when_content_fits() { 485 + let geom = Geometry::of(rect(), Axis::Vertical, LayoutPx::new(100.0)); 486 + assert!((geom.thumb_len - TRACK_LEN).abs() < 1e-3); 487 + assert!(!geom.scrollable()); 488 + } 489 + 490 + #[test] 491 + fn thumb_never_shorter_than_minimum() { 492 + let geom = Geometry::of(rect(), Axis::Vertical, LayoutPx::new(100_000.0)); 493 + assert!(geom.thumb_len >= MIN_THUMB_PX - 1e-3); 494 + } 495 + 496 + fn run_keys(offset: f32, content: f32, events: Vec<KeyEvent>) -> ScrollbarResponse { 497 + let theme = Arc::new(Theme::light()); 498 + let mut focus = FocusManager::new(); 499 + focus.register_focusable(bar_id()); 500 + focus.request_focus(bar_id()); 501 + focus.end_frame(); 502 + let table = HotkeyTable::new(); 503 + let mut hits = HitFrame::new(); 504 + let prev = HitState::new(); 505 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 506 + input.keys_pressed = events; 507 + let widget = Scrollbar::new( 508 + bar_id(), 509 + rect(), 510 + Axis::Vertical, 511 + LABEL, 512 + LayoutPx::new(content), 513 + LayoutPx::new(offset), 514 + ); 515 + let mut shaper = bone_text::Shaper::new(); 516 + let mut a11y = AccessTreeBuilder::new(); 517 + let mut ctx = FrameCtx::new( 518 + theme, 519 + &mut input, 520 + &mut focus, 521 + &table, 522 + StringTable::empty(), 523 + &mut hits, 524 + &prev, 525 + &mut a11y, 526 + &mut shaper, 527 + ); 528 + show_scrollbar(&mut ctx, widget) 529 + } 530 + 531 + fn key(named: NamedKey) -> KeyEvent { 532 + KeyEvent::new(KeyCode::Named(named), ModifierMask::NONE) 533 + } 534 + 535 + #[test] 536 + fn arrow_down_steps_forward() { 537 + let response = run_keys(0.0, 560.0, vec![key(NamedKey::ArrowDown)]); 538 + assert!((response.offset.value() - 24.0).abs() < 1e-3); 539 + assert!(response.changed); 540 + } 541 + 542 + #[test] 543 + fn arrow_up_clamps_at_top() { 544 + let response = run_keys(0.0, 560.0, vec![key(NamedKey::ArrowUp)]); 545 + assert!((response.offset.value() - 0.0).abs() < 1e-3); 546 + assert!(!response.changed); 547 + } 548 + 549 + #[test] 550 + fn end_jumps_to_max_offset() { 551 + let response = run_keys(0.0, 560.0, vec![key(NamedKey::End)]); 552 + assert!((response.offset.value() - (560.0 - TRACK_LEN)).abs() < 1e-3); 553 + } 554 + 555 + #[test] 556 + fn page_down_steps_by_track_length() { 557 + let response = run_keys(0.0, 560.0, vec![key(NamedKey::PageDown)]); 558 + assert!((response.offset.value() - TRACK_LEN).abs() < 1e-3); 559 + } 560 + 561 + #[test] 562 + fn horizontal_ignores_vertical_arrows() { 563 + let theme = Arc::new(Theme::light()); 564 + let mut focus = FocusManager::new(); 565 + focus.register_focusable(bar_id()); 566 + focus.request_focus(bar_id()); 567 + focus.end_frame(); 568 + let table = HotkeyTable::new(); 569 + let mut hits = HitFrame::new(); 570 + let prev = HitState::new(); 571 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 572 + input.keys_pressed = vec![key(NamedKey::ArrowDown)]; 573 + let rect = LayoutRect::new( 574 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 575 + LayoutSize::new(LayoutPx::new(TRACK_LEN), LayoutPx::new(14.0)), 576 + ); 577 + let widget = Scrollbar::new( 578 + bar_id(), 579 + rect, 580 + Axis::Horizontal, 581 + LABEL, 582 + LayoutPx::new(560.0), 583 + LayoutPx::new(0.0), 584 + ); 585 + let mut shaper = bone_text::Shaper::new(); 586 + let mut a11y = AccessTreeBuilder::new(); 587 + let mut ctx = FrameCtx::new( 588 + theme, 589 + &mut input, 590 + &mut focus, 591 + &table, 592 + StringTable::empty(), 593 + &mut hits, 594 + &prev, 595 + &mut a11y, 596 + &mut shaper, 597 + ); 598 + let response = show_scrollbar(&mut ctx, widget); 599 + assert!(!response.changed); 600 + } 601 + 602 + #[test] 603 + fn not_scrollable_ignores_keys() { 604 + let response = run_keys(0.0, 100.0, vec![key(NamedKey::End)]); 605 + assert!((response.offset.value() - 0.0).abs() < 1e-3); 606 + assert!(!response.changed); 607 + } 608 + 609 + fn run_pointer_press_at(content: f32, y: f32) -> ScrollbarResponse { 610 + let theme = Arc::new(Theme::light()); 611 + let mut focus = FocusManager::new(); 612 + let table = HotkeyTable::new(); 613 + let mut prev_state = HitState::new(); 614 + let mut offset = 0.0_f32; 615 + let pos = LayoutPos::new(LayoutPx::new(7.0), LayoutPx::new(y)); 616 + let mut frame = |pressed: PointerButtonMask| -> ScrollbarResponse { 617 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 618 + snap.pointer = Some(PointerSample::new(pos)); 619 + snap.buttons_pressed = pressed; 620 + let mut hits = HitFrame::new(); 621 + let response = { 622 + let widget = Scrollbar::new( 623 + bar_id(), 624 + rect(), 625 + Axis::Vertical, 626 + LABEL, 627 + LayoutPx::new(content), 628 + LayoutPx::new(offset), 629 + ); 630 + let mut shaper = bone_text::Shaper::new(); 631 + let mut a11y = AccessTreeBuilder::new(); 632 + let mut ctx = FrameCtx::new( 633 + theme.clone(), 634 + &mut snap, 635 + &mut focus, 636 + &table, 637 + StringTable::empty(), 638 + &mut hits, 639 + &prev_state, 640 + &mut a11y, 641 + &mut shaper, 642 + ); 643 + show_scrollbar(&mut ctx, widget) 644 + }; 645 + offset = response.offset.value(); 646 + prev_state = resolve(&prev_state, &hits, &snap, focus.focused()); 647 + response 648 + }; 649 + let _ = frame(PointerButtonMask::just(PointerButton::Primary)); 650 + frame(PointerButtonMask::EMPTY) 651 + } 652 + 653 + #[test] 654 + fn pointer_press_near_bottom_scrolls_toward_max() { 655 + let response = run_pointer_press_at(560.0, TRACK_LEN - 1.0); 656 + assert!( 657 + response.offset.value() > (560.0 - TRACK_LEN) * 0.8, 658 + "expected near max, got {}", 659 + response.offset.value(), 660 + ); 661 + assert!(response.changed); 662 + } 663 + 664 + #[test] 665 + fn pointer_press_near_top_scrolls_toward_zero() { 666 + let response = run_pointer_press_at(560.0, 1.0); 667 + assert!( 668 + response.offset.value() < (560.0 - TRACK_LEN) * 0.2, 669 + "expected near zero, got {}", 670 + response.offset.value(), 671 + ); 672 + } 673 + }
+63 -13
crates/bone-ui/src/widgets/status_bar.rs
··· 7 7 use crate::widget_id::WidgetId; 8 8 9 9 use super::keys::take_activation; 10 - use super::paint::{LabelText, WidgetPaint}; 10 + use super::paint::{HorizontalAlign, LabelText, WidgetPaint}; 11 11 use super::visuals::push_focus_ring; 12 12 13 13 #[derive(Copy, Clone, Debug, PartialEq, Eq)] ··· 115 115 .into_iter() 116 116 .map(|r| r.mirror_horizontally_within(bar.rect, direction)) 117 117 .collect(); 118 + paint.extend(cell_dividers(ctx, bar.rect, bar.items, &layouts)); 118 119 let mut activated: Option<WidgetId> = None; 119 120 bar.items 120 121 .iter() ··· 172 173 elevation: None, 173 174 }); 174 175 } 175 - paint.push(WidgetPaint::Label { 176 + let text_align = if item.badge.is_some() { 177 + HorizontalAlign::Start 178 + } else { 179 + match item.align { 180 + StatusAlign::Start => HorizontalAlign::Start, 181 + StatusAlign::Center => HorizontalAlign::Center, 182 + StatusAlign::End => HorizontalAlign::End, 183 + } 184 + }; 185 + paint.push(WidgetPaint::AlignedLabel { 176 186 rect: label_rect(rect, item.badge.is_some()), 177 187 text: item.label.clone(), 178 188 color: ctx.theme().colors.text_primary(), 179 189 role: ctx.theme().typography.label, 190 + align: text_align, 180 191 }); 181 192 push_focus_ring(ctx, &mut paint, rect, ctx.theme().radius.none, live_focused); 182 193 paint ··· 184 195 185 196 const BADGE_PX: f32 = 8.0; 186 197 const BADGE_GAP: f32 = 6.0; 198 + const ITEM_PAD_X: f32 = 6.0; 187 199 188 200 fn badge_rect(item: LayoutRect) -> LayoutRect { 189 201 let pad = (item.size.height.value() - BADGE_PX).max(0.0) / 2.0; 190 202 LayoutRect::new( 191 203 LayoutPos::new( 192 - LayoutPx::new(item.origin.x.value() + pad), 204 + LayoutPx::new(item.origin.x.value() + ITEM_PAD_X), 193 205 LayoutPx::new(item.origin.y.value() + pad), 194 206 ), 195 207 LayoutSize::new(LayoutPx::new(BADGE_PX), LayoutPx::new(BADGE_PX)), ··· 197 209 } 198 210 199 211 fn label_rect(item: LayoutRect, has_badge: bool) -> LayoutRect { 200 - if !has_badge { 201 - return item; 202 - } 203 - let trim = LayoutPx::new(BADGE_PX + BADGE_GAP); 212 + let lead = ITEM_PAD_X + if has_badge { BADGE_PX + BADGE_GAP } else { 0.0 }; 204 213 LayoutRect::new( 205 - LayoutPos::new( 206 - LayoutPx::new(item.origin.x.value() + trim.value()), 207 - item.origin.y, 208 - ), 214 + LayoutPos::new(LayoutPx::new(item.origin.x.value() + lead), item.origin.y), 209 215 LayoutSize::new( 210 - LayoutPx::saturating_nonneg(item.size.width.value() - trim.value()), 216 + LayoutPx::saturating_nonneg(item.size.width.value() - lead - ITEM_PAD_X), 211 217 item.size.height, 212 218 ), 213 219 ) 214 220 } 215 221 222 + fn cell_dividers( 223 + ctx: &FrameCtx<'_>, 224 + bar: LayoutRect, 225 + items: &[StatusItem], 226 + layouts: &[LayoutRect], 227 + ) -> Vec<WidgetPaint> { 228 + items 229 + .windows(2) 230 + .zip(layouts.windows(2)) 231 + .filter(|(pair, _)| pair[0].align == pair[1].align) 232 + .filter_map(|(_, rects)| divider_at_seam(ctx, bar, rects[0], rects[1])) 233 + .collect() 234 + } 235 + 236 + fn divider_at_seam( 237 + ctx: &FrameCtx<'_>, 238 + bar: LayoutRect, 239 + a: LayoutRect, 240 + b: LayoutRect, 241 + ) -> Option<WidgetPaint> { 242 + let (left, right) = if a.origin.x.value() <= b.origin.x.value() { 243 + (a, b) 244 + } else { 245 + (b, a) 246 + }; 247 + let seam = left.max_x().value(); 248 + if (right.origin.x.value() - seam).abs() > 0.5 { 249 + return None; 250 + } 251 + Some(WidgetPaint::Surface { 252 + rect: LayoutRect::new( 253 + LayoutPos::new(LayoutPx::new(seam), bar.origin.y), 254 + LayoutSize::new( 255 + LayoutPx::new(StrokeWidth::HAIRLINE.value_px()), 256 + bar.size.height, 257 + ), 258 + ), 259 + fill: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER), 260 + border: None, 261 + radius: ctx.theme().radius.none, 262 + elevation: None, 263 + }) 264 + } 265 + 216 266 #[derive(Copy, Clone)] 217 267 struct AlignTotals { 218 268 start: f32, ··· 373 423 let label_count = response 374 424 .paint 375 425 .iter() 376 - .filter(|p| matches!(p, super::WidgetPaint::Label { .. })) 426 + .filter(|p| matches!(p, super::WidgetPaint::AlignedLabel { .. })) 377 427 .count(); 378 428 assert_eq!(label_count, 2); 379 429 }
+147 -46
crates/bone-ui/src/widgets/table.rs
··· 11 11 12 12 use super::keys::{TakeKey, take_key}; 13 13 use super::paint::{GlyphMark, LabelText, WidgetPaint}; 14 + use super::scrollbar::{row_window, wheel_scroll, window_scrollbar}; 14 15 use super::visuals::push_focus_ring; 15 16 16 17 #[derive(Copy, Clone, Debug, PartialEq, Eq)] ··· 47 48 Multi, 48 49 } 49 50 50 - #[derive(Clone, Debug, Default, PartialEq, Eq)] 51 + #[derive(Clone, Debug, Default, PartialEq)] 51 52 pub struct ListViewState { 52 53 pub selection: BTreeSet<WidgetId>, 53 54 pub focused: Option<WidgetId>, 55 + pub scroll_offset: LayoutPx, 54 56 } 55 57 56 58 #[derive(Debug, PartialEq)] ··· 117 119 radius: ctx.theme().radius.none, 118 120 elevation: None, 119 121 }]; 122 + let requested = wheel_scroll(ctx, rect, state.scroll_offset); 123 + let window = row_window(rect, items.len(), row_height, requested); 124 + state.scroll_offset = window.offset; 125 + let content_rect = window.content_rect; 120 126 let entry_id = state 121 127 .focused 122 128 .filter(|f| items.iter().any(|i| i.id == *f)) ··· 126 132 } 127 133 let mut activated: Option<WidgetId> = None; 128 134 let mut opened: Option<WidgetId> = None; 129 - items.iter().enumerate().for_each(|(idx, item)| { 130 - let row_rect = list_row_rect(rect, idx, row_height); 131 - let selected = state.selection.contains(&item.id); 132 - let interaction = ctx.interact( 133 - InteractDeclaration::new(item.id, row_rect, Sense::INTERACTIVE) 134 - .focusable(false) 135 - .active(selected) 136 - .a11y( 137 - AccessNode::new(Role::ListBoxOption) 138 - .with_label_text(item.label.clone()) 139 - .with_selected(selected), 140 - ), 141 - ); 142 - let live_focused = ctx.is_focused(item.id); 143 - if interaction.click() { 144 - apply_list_selection(state, item.id, ctx.input.modifiers, mode); 145 - state.focused = Some(item.id); 146 - ctx.focus.request_focus(item.id); 147 - if activated.is_none() { 148 - activated = Some(item.id); 135 + items 136 + .iter() 137 + .enumerate() 138 + .skip(window.first_row) 139 + .take(window.last_row - window.first_row) 140 + .for_each(|(idx, item)| { 141 + let row_rect = list_row_rect(content_rect, idx - window.first_row, row_height); 142 + let selected = state.selection.contains(&item.id); 143 + let interaction = ctx.interact( 144 + InteractDeclaration::new(item.id, row_rect, Sense::INTERACTIVE) 145 + .focusable(false) 146 + .active(selected) 147 + .a11y( 148 + AccessNode::new(Role::ListBoxOption) 149 + .with_label_text(item.label.clone()) 150 + .with_selected(selected), 151 + ), 152 + ); 153 + let live_focused = ctx.is_focused(item.id); 154 + if interaction.click() { 155 + apply_list_selection(state, item.id, ctx.input.modifiers, mode); 156 + state.focused = Some(item.id); 157 + ctx.focus.request_focus(item.id); 158 + if activated.is_none() { 159 + activated = Some(item.id); 160 + } 149 161 } 150 - } 151 - if interaction.double_click() && opened.is_none() { 152 - opened = Some(item.id); 153 - } 154 - let fill = list_row_fill(ctx, &interaction, state.selection.contains(&item.id)); 155 - paint.push(WidgetPaint::Surface { 156 - rect: row_rect, 157 - fill, 158 - border: None, 159 - radius: ctx.theme().radius.none, 160 - elevation: None, 162 + if interaction.double_click() && opened.is_none() { 163 + opened = Some(item.id); 164 + } 165 + let fill = list_row_fill(ctx, &interaction, state.selection.contains(&item.id)); 166 + paint.push(WidgetPaint::Surface { 167 + rect: row_rect, 168 + fill, 169 + border: None, 170 + radius: ctx.theme().radius.none, 171 + elevation: None, 172 + }); 173 + paint.push(WidgetPaint::Label { 174 + rect: row_rect, 175 + text: item.label.clone(), 176 + color: ctx.theme().colors.text_primary(), 177 + role: ctx.theme().typography.body, 178 + }); 179 + push_focus_ring( 180 + ctx, 181 + &mut paint, 182 + row_rect, 183 + ctx.theme().radius.none, 184 + live_focused, 185 + ); 161 186 }); 162 - paint.push(WidgetPaint::Label { 163 - rect: row_rect, 164 - text: item.label.clone(), 165 - color: ctx.theme().colors.text_primary(), 166 - role: ctx.theme().typography.body, 167 - }); 168 - push_focus_ring( 169 - ctx, 170 - &mut paint, 171 - row_rect, 172 - ctx.theme().radius.none, 173 - live_focused, 174 - ); 175 - }); 187 + let (bar_paint, next_offset) = window_scrollbar(ctx, id, label, &window, state.scroll_offset); 188 + state.scroll_offset = next_offset; 189 + paint.extend(bar_paint); 176 190 handle_list_keyboard(ctx, items, state); 177 191 ListViewResponse { 178 192 activated, ··· 816 830 }; 817 831 let next = resolve(prev, &hits, snap, focus.focused()); 818 832 (response, next) 833 + } 834 + 835 + fn render_list_at( 836 + items: &[ListItem], 837 + state: &mut ListViewState, 838 + scroll_y: f32, 839 + pointer: Option<LayoutPos>, 840 + ) -> (Vec<super::WidgetPaint>, bool) { 841 + let theme = Arc::new(Theme::light()); 842 + let table = HotkeyTable::new(); 843 + let mut focus = FocusManager::new(); 844 + let mut hits = HitFrame::new(); 845 + let prev = HitState::new(); 846 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 847 + snap.scroll_y = scroll_y; 848 + snap.pointer = pointer.map(PointerSample::new); 849 + let rect = LayoutRect::new( 850 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 851 + LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(400.0)), 852 + ); 853 + let scrollbar_id = list_id().child(WidgetKey::new("scrollbar")); 854 + let mut shaper = bone_text::Shaper::new(); 855 + let mut a11y = crate::a11y::AccessTreeBuilder::new(); 856 + let paint = { 857 + let mut ctx = FrameCtx::new( 858 + theme, 859 + &mut snap, 860 + &mut focus, 861 + &table, 862 + StringTable::empty(), 863 + &mut hits, 864 + &prev, 865 + &mut a11y, 866 + &mut shaper, 867 + ); 868 + show_list_view( 869 + &mut ctx, 870 + ListView::new(list_id(), rect, StringKey::new("test.list"), items, state), 871 + ) 872 + .paint 873 + }; 874 + (paint, a11y.contains(scrollbar_id)) 875 + } 876 + 877 + fn count_labels(paint: &[super::WidgetPaint]) -> usize { 878 + paint 879 + .iter() 880 + .filter(|p| matches!(p, super::WidgetPaint::Label { .. })) 881 + .count() 882 + } 883 + 884 + #[test] 885 + fn tall_list_culls_rows_and_shows_a_scrollbar() { 886 + let items = list_items(40); 887 + let mut state = ListViewState::default(); 888 + let (paint, has_bar) = render_list_at(&items, &mut state, 0.0, None); 889 + assert!(has_bar, "an overflowing list shows a scrollbar"); 890 + assert_eq!( 891 + count_labels(&paint), 892 + 18, 893 + "only the rows that fit the 400 px viewport paint", 894 + ); 895 + } 896 + 897 + #[test] 898 + fn list_that_fits_shows_no_scrollbar() { 899 + let items = list_items(3); 900 + let mut state = ListViewState::default(); 901 + let (paint, has_bar) = render_list_at(&items, &mut state, 0.0, None); 902 + assert!(!has_bar, "a list that fits its viewport shows no scrollbar"); 903 + assert_eq!(count_labels(&paint), 3); 904 + } 905 + 906 + #[test] 907 + fn wheel_over_the_list_scrolls_it() { 908 + let items = list_items(40); 909 + let mut state = ListViewState::default(); 910 + let _ = render_list_at( 911 + &items, 912 + &mut state, 913 + 100.0, 914 + Some(LayoutPos::new(LayoutPx::new(100.0), LayoutPx::new(100.0))), 915 + ); 916 + assert!( 917 + state.scroll_offset.value() > 0.0, 918 + "a wheel notch over the list advances the offset", 919 + ); 819 920 } 820 921 821 922 fn press(pos: LayoutPos) -> InputSnapshot {
+35 -77
crates/bone-ui/src/widgets/tabs.rs
··· 4 4 use crate::input::{KeyCode, NamedKey}; 5 5 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 6 6 use crate::strings::StringKey; 7 - use crate::theme::{Color, Step12}; 7 + use crate::theme::{Border, Color, Step12, StrokeWidth, SurfaceLevel}; 8 8 use crate::widget_id::{WidgetId, WidgetKey}; 9 + 10 + use bone_types::IconId; 9 11 10 12 use super::keys::{TakeKey, take_key}; 11 - use super::paint::{GlyphMark, LabelText, WidgetPaint}; 13 + use super::paint::{IconSlot, IconTint, LabelText, WidgetPaint}; 12 14 use super::visuals::push_focus_ring; 13 15 16 + const TAB_ICON_PX: LayoutPx = LayoutPx::new(16.0); 17 + 14 18 #[derive(Copy, Clone, Debug, PartialEq, Eq)] 15 19 pub enum TabsOrientation { 16 20 Top, ··· 25 29 pub label: StringKey, 26 30 pub disabled: bool, 27 31 pub closable: bool, 28 - pub glyph: Option<GlyphMark>, 32 + pub icon: Option<IconId>, 29 33 } 30 34 31 35 impl Tab { ··· 37 41 label, 38 42 disabled: false, 39 43 closable: false, 40 - glyph: None, 44 + icon: None, 41 45 } 42 46 } 43 47 ··· 52 56 } 53 57 54 58 #[must_use] 55 - pub const fn with_glyph(self, glyph: GlyphMark) -> Self { 59 + pub const fn with_icon(self, icon: IconId) -> Self { 56 60 Self { 57 - glyph: Some(glyph), 61 + icon: Some(icon), 58 62 ..self 59 63 } 60 64 } ··· 126 130 let mut paint = Vec::new(); 127 131 let folded = items 128 132 .iter() 129 - .map(|tab| { 130 - draw_tab( 131 - ctx, 132 - tabs_id, 133 - tab, 134 - tab.id == active && active_present, 135 - orientation, 136 - ) 137 - }) 133 + .map(|tab| draw_tab(ctx, tabs_id, tab, tab.id == active && active_present)) 138 134 .fold( 139 135 (None::<WidgetId>, None::<WidgetId>), 140 136 |(activated, closed), per_tab| { ··· 167 163 paint: Vec<WidgetPaint>, 168 164 } 169 165 170 - fn draw_tab( 171 - ctx: &mut FrameCtx<'_>, 172 - tabs_id: WidgetId, 173 - tab: &Tab, 174 - is_active: bool, 175 - orientation: TabsOrientation, 176 - ) -> PerTab { 166 + fn draw_tab(ctx: &mut FrameCtx<'_>, tabs_id: WidgetId, tab: &Tab, is_active: bool) -> PerTab { 177 167 let interactive = !tab.disabled; 178 168 let interaction = ctx.interact( 179 169 InteractDeclaration::new(tab.id, tab.rect, Sense::INTERACTIVE) ··· 192 182 } 193 183 let live_focused = ctx.is_focused(tab.id); 194 184 let mut paint = Vec::new(); 195 - paint.extend(tab_surface_paint( 196 - ctx, 197 - tab.rect, 198 - is_active, 199 - interaction, 200 - orientation, 201 - )); 185 + paint.extend(tab_surface_paint(ctx, tab.rect, is_active, interaction)); 202 186 let label_color = tab_label_color(ctx, is_active, tab.disabled); 203 - if let Some(glyph) = tab.glyph { 204 - paint.push(WidgetPaint::Mark { 205 - rect: label_rect(tab.rect, tab.closable), 206 - kind: glyph, 207 - color: label_color, 208 - }); 187 + if let Some(icon) = tab.icon { 188 + paint.push(IconSlot::new(icon, TAB_ICON_PX).paint_in( 189 + label_rect(tab.rect, tab.closable), 190 + IconTint::from_disabled(tab.disabled), 191 + )); 209 192 } else { 210 193 paint.push(WidgetPaint::Label { 211 194 rect: label_rect(tab.rect, tab.closable), ··· 242 225 radius: ctx.theme().radius.sm, 243 226 elevation: None, 244 227 }); 245 - paint.push(WidgetPaint::Mark { 228 + paint.push(WidgetPaint::Icon { 246 229 rect: close_rect, 247 - kind: GlyphMark::Close, 248 - color: tab_label_color(ctx, is_active, tab.disabled), 230 + icon: IconId::Cross, 231 + tint: IconTint::Solid(tab_label_color(ctx, is_active, tab.disabled)), 249 232 }); 250 233 } 251 234 push_focus_ring( ··· 297 280 rect: LayoutRect, 298 281 active: bool, 299 282 interaction: Interaction, 300 - orientation: TabsOrientation, 301 283 ) -> Vec<WidgetPaint> { 302 284 let neutral = ctx.theme().colors.neutral; 303 - let accent = ctx.theme().colors.accent; 285 + if active && !interaction.disabled() { 286 + return vec![WidgetPaint::Surface { 287 + rect, 288 + fill: ctx.theme().colors.surface(SurfaceLevel::L0), 289 + border: Some(Border { 290 + width: StrokeWidth::HAIRLINE, 291 + color: neutral.step(Step12::SUBTLE_BORDER), 292 + }), 293 + radius: ctx.theme().radius.none, 294 + elevation: None, 295 + }]; 296 + } 304 297 let fill = if interaction.disabled() { 305 298 Color::TRANSPARENT 306 299 } else if interaction.pressed() { ··· 310 303 } else { 311 304 Color::TRANSPARENT 312 305 }; 313 - let mut paints = vec![WidgetPaint::Surface { 306 + vec![WidgetPaint::Surface { 314 307 rect, 315 308 fill, 316 309 border: None, 317 310 radius: ctx.theme().radius.none, 318 311 elevation: None, 319 - }]; 320 - if active { 321 - paints.push(WidgetPaint::Surface { 322 - rect: tab_underline_rect(rect, orientation), 323 - fill: accent.step(Step12::SOLID), 324 - border: None, 325 - radius: ctx.theme().radius.none, 326 - elevation: None, 327 - }); 328 - } 329 - paints 330 - } 331 - 332 - const TAB_UNDERLINE_THICKNESS_PX: f32 = 2.0; 333 - 334 - fn tab_underline_rect(rect: LayoutRect, orientation: TabsOrientation) -> LayoutRect { 335 - let thickness = LayoutPx::new(TAB_UNDERLINE_THICKNESS_PX); 336 - match orientation { 337 - TabsOrientation::Top => LayoutRect::new( 338 - LayoutPos::new( 339 - rect.origin.x, 340 - LayoutPx::new(rect.origin.y.value() + rect.size.height.value() - thickness.value()), 341 - ), 342 - LayoutSize::new(rect.size.width, thickness), 343 - ), 344 - TabsOrientation::Bottom => { 345 - LayoutRect::new(rect.origin, LayoutSize::new(rect.size.width, thickness)) 346 - } 347 - TabsOrientation::Side => LayoutRect::new( 348 - LayoutPos::new( 349 - LayoutPx::new(rect.origin.x.value() + rect.size.width.value() - thickness.value()), 350 - rect.origin.y, 351 - ), 352 - LayoutSize::new(thickness, rect.size.height), 353 - ), 354 - } 312 + }] 355 313 } 356 314 357 315 fn tab_label_color(ctx: &FrameCtx<'_>, active: bool, disabled: bool) -> Color {
+28 -14
crates/bone-ui/src/widgets/toast.rs
··· 1 1 use core::time::Duration; 2 2 3 + use bone_types::IconId; 4 + 3 5 use crate::a11y::{AccessNode, Role}; 4 6 use crate::frame::{FrameCtx, InteractDeclaration}; 5 7 use crate::hit_test::Sense; ··· 10 12 use crate::widget_id::{WidgetId, WidgetKey}; 11 13 12 14 use super::keys::{TakeKey, take_key}; 13 - use super::paint::{GlyphMark, LabelText, WidgetPaint}; 15 + use super::paint::{GlyphMark, IconTint, LabelText, WidgetPaint}; 14 16 use super::visuals::push_focus_ring; 15 17 16 18 #[derive(Copy, Clone, Debug, PartialEq, Eq)] ··· 140 142 radius: ctx.theme().radius.md, 141 143 elevation: Some(ctx.theme().elevation.level1), 142 144 }); 143 - paint.push(WidgetPaint::Mark { 144 - rect: leading_mark_rect(rect), 145 - kind: kind_glyph(kind), 146 - color: leading_mark_color(ctx, kind), 147 - }); 145 + paint.push(leading_mark( 146 + leading_mark_rect(rect), 147 + kind, 148 + leading_mark_color(ctx, kind), 149 + )); 148 150 paint.push(WidgetPaint::Label { 149 151 rect: message_rect(rect, dismissible), 150 152 text: LabelText::Key(message), ··· 198 200 radius: ctx.theme().radius.sm, 199 201 elevation: None, 200 202 }); 201 - paint.push(WidgetPaint::Mark { 203 + paint.push(WidgetPaint::Icon { 202 204 rect: close_rect, 203 - kind: GlyphMark::Close, 204 - color: ctx.theme().colors.text_secondary(), 205 + icon: IconId::Cross, 206 + tint: IconTint::Solid(ctx.theme().colors.text_secondary()), 205 207 }); 206 208 push_focus_ring(ctx, paint, close_rect, ctx.theme().radius.sm, live_focused); 207 209 dismissed_now ··· 237 239 scale.step(Step12::SOLID) 238 240 } 239 241 240 - fn kind_glyph(kind: ToastKind) -> GlyphMark { 241 - match kind { 242 - ToastKind::Info | ToastKind::Success => GlyphMark::Checkmark, 243 - ToastKind::Warning => GlyphMark::Indeterminate, 244 - ToastKind::Danger => GlyphMark::Close, 242 + fn leading_mark(rect: LayoutRect, kind: ToastKind, color: Color) -> WidgetPaint { 243 + let icon = match kind { 244 + ToastKind::Info | ToastKind::Success => Some(IconId::Check), 245 + ToastKind::Danger => Some(IconId::Cross), 246 + ToastKind::Warning => None, 247 + }; 248 + match icon { 249 + Some(icon) => WidgetPaint::Icon { 250 + rect, 251 + icon, 252 + tint: IconTint::Solid(color), 253 + }, 254 + None => WidgetPaint::Mark { 255 + rect, 256 + kind: GlyphMark::Indeterminate, 257 + color, 258 + }, 245 259 } 246 260 } 247 261
+187 -16
crates/bone-ui/src/widgets/toolbar.rs
··· 3 3 use crate::hit_test::{Sense, ZLayer}; 4 4 use crate::layout::{LayoutDirection, LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 5 5 use crate::strings::StringKey; 6 - use crate::theme::Step12; 6 + use crate::theme::{Border, Step12, StrokeWidth}; 7 7 use crate::widget_id::{WidgetId, WidgetKey}; 8 8 9 9 use super::keys::{TakeKey, take_key}; 10 - use super::paint::{GlyphMark, HorizontalAlign, LabelText, WidgetPaint}; 10 + use super::paint::{GlyphMark, HorizontalAlign, IconSlot, IconTint, LabelText, WidgetPaint}; 11 11 use super::visuals::push_focus_ring; 12 12 13 13 #[derive(Copy, Clone, Debug, PartialEq)] 14 14 pub struct ToolbarItem { 15 15 pub id: WidgetId, 16 16 pub label: StringKey, 17 + pub icon: Option<IconSlot>, 17 18 pub disabled: bool, 18 19 pub active: bool, 19 20 pub width: Option<LayoutPx>, ··· 26 27 Self { 27 28 id, 28 29 label, 30 + icon: None, 29 31 disabled: false, 30 32 active: false, 31 33 width: None, ··· 34 36 } 35 37 36 38 #[must_use] 39 + pub const fn with_icon(self, icon: IconSlot) -> Self { 40 + Self { 41 + icon: Some(icon), 42 + ..self 43 + } 44 + } 45 + 46 + #[must_use] 37 47 pub const fn disabled(self, disabled: bool) -> Self { 38 48 Self { disabled, ..self } 39 49 } ··· 94 104 pub item_gap: LayoutPx, 95 105 pub orientation: ToolbarOrientation, 96 106 pub overflow: Option<ToolbarOverflowConfig>, 107 + pub rows: usize, 97 108 } 98 109 99 110 impl<'a> Toolbar<'a> { ··· 115 126 item_gap, 116 127 orientation: ToolbarOrientation::Horizontal, 117 128 overflow: None, 129 + rows: 1, 118 130 } 119 131 } 120 132 ··· 136 148 item_gap, 137 149 orientation: ToolbarOrientation::Vertical, 138 150 overflow: None, 151 + rows: 1, 139 152 } 140 153 } 141 154 ··· 144 157 self.overflow = Some(overflow); 145 158 self 146 159 } 160 + 161 + #[must_use] 162 + pub const fn with_rows(mut self, rows: usize) -> Self { 163 + self.rows = rows; 164 + self 165 + } 147 166 } 148 167 149 168 #[derive(Clone, Debug, PartialEq)] ··· 168 187 item_gap, 169 188 orientation, 170 189 overflow, 190 + rows, 171 191 } = toolbar; 172 192 let direction = ctx.direction(); 173 193 let plan = mirror_plan( 174 - layout_with_overflow(rect, items, item_size, item_gap, orientation, overflow), 194 + layout_with_overflow( 195 + rect, 196 + items, 197 + item_size, 198 + item_gap, 199 + orientation, 200 + overflow, 201 + rows, 202 + ), 175 203 rect, 176 204 orientation, 177 205 direction, ··· 271 299 let mut paint = vec![WidgetPaint::Surface { 272 300 rect, 273 301 fill: item_fill(ctx, item, &interaction, style), 274 - border: None, 302 + border: item_border(ctx, item, &interaction, style), 275 303 radius: ctx.theme().radius.sm, 276 304 elevation: None, 277 305 }]; ··· 280 308 } else { 281 309 ctx.theme().colors.text_primary() 282 310 }; 283 - match style { 284 - ItemStyle::Toolbar => { 311 + match (style, item.icon) { 312 + (ItemStyle::Toolbar, Some(slot)) => { 313 + let (icon_rect, label_rect, align) = icon_item_rects(rect, slot.footprint); 314 + paint.push(slot.paint_in(icon_rect, IconTint::from_disabled(item.disabled))); 315 + paint.push(WidgetPaint::AlignedLabel { 316 + rect: label_rect, 317 + text: LabelText::Key(item.label), 318 + color: label_color, 319 + role: ctx.theme().typography.label, 320 + align, 321 + }); 322 + } 323 + (ItemStyle::Toolbar, None) => { 285 324 paint.push(WidgetPaint::Label { 286 325 rect, 287 326 text: LabelText::Key(item.label), ··· 289 328 role: ctx.theme().typography.label, 290 329 }); 291 330 } 292 - ItemStyle::PopupRow => { 331 + (ItemStyle::PopupRow, _) => { 293 332 paint.push(WidgetPaint::AlignedLabel { 294 333 rect: popup_label_rect(rect), 295 334 text: LabelText::Key(item.label), ··· 315 354 activated: activated_via_pointer || activated_via_key, 316 355 consumed_click: interaction.click() || interaction.pressed(), 317 356 paint, 357 + } 358 + } 359 + 360 + const ICON_STACK_MIN_LABEL_PX: f32 = 12.0; 361 + const ICON_STACK_MAX_LABEL_PX: f32 = 16.0; 362 + const ICON_INLINE_PAD_PX: f32 = 4.0; 363 + 364 + fn icon_item_rects( 365 + rect: LayoutRect, 366 + footprint: LayoutPx, 367 + ) -> (LayoutRect, LayoutRect, HorizontalAlign) { 368 + let spare = rect.size.height.value() - footprint.value(); 369 + if spare >= ICON_STACK_MIN_LABEL_PX { 370 + let label_height = spare.min(ICON_STACK_MAX_LABEL_PX); 371 + let icon_height = LayoutPx::saturating_nonneg(rect.size.height.value() - label_height); 372 + let icon_rect = LayoutRect::new(rect.origin, LayoutSize::new(rect.size.width, icon_height)); 373 + let label_rect = LayoutRect::new( 374 + LayoutPos::new( 375 + rect.origin.x, 376 + LayoutPx::new(rect.origin.y.value() + icon_height.value()), 377 + ), 378 + LayoutSize::new(rect.size.width, LayoutPx::new(label_height)), 379 + ); 380 + (icon_rect, label_rect, HorizontalAlign::Center) 381 + } else { 382 + let column = footprint.value() + 2.0 * ICON_INLINE_PAD_PX; 383 + let icon_rect = LayoutRect::new( 384 + rect.origin, 385 + LayoutSize::new(LayoutPx::new(column), rect.size.height), 386 + ); 387 + let label_rect = LayoutRect::new( 388 + LayoutPos::new(LayoutPx::new(rect.origin.x.value() + column), rect.origin.y), 389 + LayoutSize::new( 390 + LayoutPx::saturating_nonneg(rect.size.width.value() - column), 391 + rect.size.height, 392 + ), 393 + ); 394 + (icon_rect, label_rect, HorizontalAlign::Start) 318 395 } 319 396 } 320 397 ··· 510 587 interaction: &crate::hit_test::Interaction, 511 588 style: ItemStyle, 512 589 ) -> crate::theme::Color { 513 - let neutral = ctx.theme().colors.neutral; 514 590 let accent = ctx.theme().colors.accent; 515 591 let show_hover_when_disabled = style == ItemStyle::PopupRow; 516 592 if item.disabled && !show_hover_when_disabled { 517 593 crate::theme::Color::TRANSPARENT 518 594 } else if item.active || interaction.pressed() { 519 - match style { 520 - ItemStyle::Toolbar => neutral.step(Step12::SELECTED_BG), 521 - ItemStyle::PopupRow => accent.step(Step12::SELECTED_BG), 522 - } 595 + accent.step(Step12::SELECTED_BG) 523 596 } else if interaction.hover() { 524 - match style { 525 - ItemStyle::Toolbar => neutral.step(Step12::HOVER_BG), 526 - ItemStyle::PopupRow => accent.step(Step12::HOVER_BG), 527 - } 597 + accent.step(Step12::HOVER_BG) 528 598 } else { 529 599 crate::theme::Color::TRANSPARENT 530 600 } 531 601 } 532 602 603 + fn item_border( 604 + ctx: &FrameCtx<'_>, 605 + item: &ToolbarItem, 606 + interaction: &crate::hit_test::Interaction, 607 + style: ItemStyle, 608 + ) -> Option<Border> { 609 + if style != ItemStyle::Toolbar || item.disabled { 610 + return None; 611 + } 612 + let highlighted = item.active || interaction.pressed() || interaction.hover(); 613 + highlighted.then(|| Border { 614 + width: StrokeWidth::HAIRLINE, 615 + color: ctx.theme().colors.accent.step(Step12::BORDER), 616 + }) 617 + } 618 + 533 619 fn item_extent(item: &ToolbarItem, fallback: LayoutPx) -> f32 { 534 620 item.width.unwrap_or(fallback).value() 535 621 } ··· 574 660 gap: LayoutPx, 575 661 orientation: ToolbarOrientation, 576 662 overflow: Option<ToolbarOverflowConfig>, 663 + rows: usize, 577 664 ) -> LayoutPlan { 665 + if rows > 1 && orientation == ToolbarOrientation::Horizontal { 666 + return LayoutPlan { 667 + visible: lay_out_grid(rect, items, item_size, gap, rows), 668 + chevron: None, 669 + hidden_count: 0, 670 + }; 671 + } 578 672 let chevron_extent = item_size.value(); 579 673 let available = match orientation { 580 674 ToolbarOrientation::Horizontal => rect.size.width.value(), ··· 658 752 .collect() 659 753 } 660 754 755 + fn lay_out_grid( 756 + rect: LayoutRect, 757 + items: &[ToolbarItem], 758 + item_size: LayoutPx, 759 + gap: LayoutPx, 760 + rows: usize, 761 + ) -> Vec<LayoutRect> { 762 + let rows = rows.max(1); 763 + #[allow( 764 + clippy::cast_precision_loss, 765 + reason = "toolbar row counts fit the f32 mantissa" 766 + )] 767 + let rows_f = rows as f32; 768 + let row_height = ((rect.size.height.value() - (rows_f - 1.0) * gap.value()) / rows_f).max(0.0); 769 + let col_width = |col: usize| -> f32 { 770 + (0..rows) 771 + .filter_map(|r| items.get(col * rows + r)) 772 + .map(|it| item_extent(it, item_size)) 773 + .fold(0.0_f32, f32::max) 774 + }; 775 + let col_x: Vec<f32> = (0..items.len().div_ceil(rows)) 776 + .scan(rect.origin.x.value(), |x, col| { 777 + let cur = *x; 778 + *x += col_width(col) + gap.value(); 779 + Some(cur) 780 + }) 781 + .collect(); 782 + items 783 + .iter() 784 + .enumerate() 785 + .map(|(i, _)| { 786 + let col = i / rows; 787 + #[allow( 788 + clippy::cast_precision_loss, 789 + reason = "toolbar row index fits the f32 mantissa" 790 + )] 791 + let row_pos = (i % rows) as f32; 792 + LayoutRect::new( 793 + LayoutPos::new( 794 + LayoutPx::new(col_x.get(col).copied().unwrap_or(rect.origin.x.value())), 795 + LayoutPx::new(rect.origin.y.value() + row_pos * (row_height + gap.value())), 796 + ), 797 + LayoutSize::new(LayoutPx::new(col_width(col)), LayoutPx::new(row_height)), 798 + ) 799 + }) 800 + .collect() 801 + } 802 + 661 803 const FIT_EPSILON_PX: f32 = 0.5; 662 804 const POPUP_PADDING_PX: f32 = 4.0; 663 805 const POPUP_GAP_PX: f32 = 4.0; ··· 789 931 let (response, _) = render(&items, rect, &mut focus, &mut snap, &prev); 790 932 assert_eq!(response.visible_count, 3); 791 933 assert_eq!(response.overflow_count, 0); 934 + } 935 + 936 + #[test] 937 + fn grid_flows_items_column_major_across_rows() { 938 + let items = items(5); 939 + let rect = LayoutRect::new( 940 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 941 + LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(60.0)), 942 + ); 943 + let cells = super::lay_out_grid(rect, &items, LayoutPx::new(28.0), LayoutPx::new(4.0), 2); 944 + let near = |a: f32, b: f32| (a - b).abs() < 1e-3; 945 + let x = |i: usize| cells[i].origin.x.value(); 946 + let y = |i: usize| cells[i].origin.y.value(); 947 + assert_eq!(cells.len(), 5); 948 + assert!(near(x(0), 0.0) && near(y(0), 0.0)); 949 + assert!( 950 + near(x(1), 0.0) && near(y(1), 32.0), 951 + "second item stacks under the first" 952 + ); 953 + assert!( 954 + near(x(2), 32.0) && near(y(2), 0.0), 955 + "third item starts the next column" 956 + ); 957 + assert!(near(x(3), 32.0) && near(y(3), 32.0)); 958 + assert!(near(x(4), 64.0) && near(y(4), 0.0)); 959 + assert!( 960 + cells.iter().all(|c| near(c.size.height.value(), 28.0)), 961 + "two rows split the 60 px band minus the 4 px gap", 962 + ); 792 963 } 793 964 794 965 #[test]
+278 -40
crates/bone-ui/src/widgets/tree_view.rs
··· 9 9 use crate::theme::{Color, Step12}; 10 10 use crate::widget_id::{WidgetId, WidgetKey}; 11 11 12 + use bone_types::IconId; 13 + 12 14 use super::keys::{TakeKey, take_key}; 13 - use super::paint::{GlyphMark, HorizontalAlign, LabelText, WidgetPaint}; 15 + use super::paint::{GlyphMark, HorizontalAlign, IconSlot, IconTint, LabelText, WidgetPaint}; 16 + use super::scrollbar::{row_window, wheel_scroll, window_scrollbar}; 14 17 use super::text_input::{AlwaysValid, MemoryClipboard, TextInput, TextInputState, show_text_input}; 15 18 use super::visuals::push_focus_ring; 16 19 ··· 20 23 pub label: LabelText, 21 24 pub children: Vec<TreeNode>, 22 25 pub disabled: bool, 23 - pub glyph: Option<GlyphMark>, 26 + pub icon: Option<IconId>, 24 27 } 25 28 26 29 impl TreeNode { ··· 51 54 label, 52 55 children, 53 56 disabled: false, 54 - glyph: None, 57 + icon: None, 55 58 } 56 59 } 57 60 ··· 62 65 } 63 66 64 67 #[must_use] 65 - pub fn with_glyph(mut self, glyph: GlyphMark) -> Self { 66 - self.glyph = Some(glyph); 68 + pub fn with_icon(mut self, icon: IconId) -> Self { 69 + self.icon = Some(icon); 67 70 self 68 71 } 69 72 ··· 103 106 pub drag_source: Option<WidgetId>, 104 107 pub drop_target: Option<DropTarget>, 105 108 pub pending_rename: Option<PendingRename>, 109 + pub scroll_offset: LayoutPx, 106 110 } 107 111 108 112 #[derive(Copy, Clone, Debug, PartialEq, Eq)] ··· 140 144 roots, 141 145 state, 142 146 mode: TreeSelectionMode::Single, 143 - row_height: LayoutPx::new(22.0), 147 + row_height: LayoutPx::new(20.0), 144 148 indent_step: LayoutPx::new(16.0), 145 149 renamable: &[], 146 150 } ··· 196 200 elevation: None, 197 201 }]; 198 202 let visible: Vec<VisibleRow> = flatten(roots, &state.expanded, 0); 203 + let requested = wheel_scroll(ctx, rect, state.scroll_offset); 204 + let window = row_window(rect, visible.len(), row_height, requested); 205 + state.scroll_offset = window.offset; 206 + let content_rect = window.content_rect; 199 207 let entry_id = state 200 208 .focused 201 209 .filter(|f| visible.iter().any(|r| r.id == *f)) ··· 206 214 let mut activated: Option<WidgetId> = None; 207 215 let mut double_activated: Option<WidgetId> = None; 208 216 let mut drop_committed: Option<(WidgetId, DropTarget)> = None; 217 + let row_rect_of = |idx: usize| row_rect_at(content_rect, idx - window.first_row, row_height); 209 218 let row_paint = visible 210 219 .iter() 211 220 .enumerate() 221 + .skip(window.first_row) 222 + .take(window.last_row - window.first_row) 212 223 .map(|(idx, row)| { 213 - let row_rect = row_rect_at(rect, idx, row_height); 214 224 draw_row( 215 225 ctx, 216 226 RowDrawArgs { 217 227 row, 218 - row_rect, 228 + row_rect: row_rect_of(idx), 219 229 indent_step, 220 230 state, 221 231 mode, ··· 231 241 acc 232 242 }); 233 243 paint.extend(row_paint); 244 + let (bar_paint, next_offset) = window_scrollbar(ctx, id, label, &window, state.scroll_offset); 245 + state.scroll_offset = next_offset; 246 + paint.extend(bar_paint); 234 247 let pending_row_rect = state.pending_rename.and_then(|pending| { 235 248 visible 236 249 .iter() 237 250 .position(|row| row.id == pending.id) 238 - .map(|idx| row_rect_at(rect, idx, row_height)) 251 + .filter(|idx| (window.first_row..window.last_row).contains(idx)) 252 + .map(row_rect_of) 239 253 }); 240 254 commit_pending_rename(ctx, &visible, state, renamable, pending_row_rect); 241 - let rename_committed = if let Some(id) = state.renaming 242 - && take_key(ctx.input, &[TakeKey::named(NamedKey::Enter)]).is_some() 243 - { 244 - let text = state.rename_buffer.text.clone(); 245 - state.renaming = None; 246 - state.rename_buffer = TextInputState::default(); 247 - Some(RenameCommit { id, text }) 248 - } else { 249 - None 250 - }; 251 - let rename_cancelled = if let Some(id) = state.renaming 252 - && take_key(ctx.input, &[TakeKey::named(NamedKey::Escape)]).is_some() 253 - { 254 - state.renaming = None; 255 - state.rename_buffer = TextInputState::default(); 256 - Some(id) 257 - } else { 258 - None 259 - }; 255 + let (rename_committed, rename_cancelled) = resolve_rename(ctx, state); 260 256 if state.renaming.is_none() { 261 257 handle_keyboard(ctx, &visible, state, renamable, &mut activated); 262 258 } ··· 277 273 depth: usize, 278 274 has_children: bool, 279 275 disabled: bool, 280 - glyph: Option<GlyphMark>, 276 + icon: Option<IconId>, 281 277 } 282 278 283 279 fn flatten(roots: &[TreeNode], expanded: &BTreeSet<WidgetId>, depth: usize) -> Vec<VisibleRow> { ··· 290 286 depth, 291 287 has_children: node.has_children(), 292 288 disabled: node.disabled, 293 - glyph: node.glyph, 289 + icon: node.icon, 294 290 }; 295 291 let children = if expanded.contains(&node.id) { 296 292 flatten(&node.children, expanded, depth + 1) ··· 300 296 std::iter::once(row).chain(children) 301 297 }) 302 298 .collect() 299 + } 300 + 301 + fn resolve_rename( 302 + ctx: &mut FrameCtx<'_>, 303 + state: &mut TreeViewState, 304 + ) -> (Option<RenameCommit>, Option<WidgetId>) { 305 + let committed = if let Some(id) = state.renaming 306 + && take_key(ctx.input, &[TakeKey::named(NamedKey::Enter)]).is_some() 307 + { 308 + let text = state.rename_buffer.text.clone(); 309 + state.renaming = None; 310 + state.rename_buffer = TextInputState::default(); 311 + Some(RenameCommit { id, text }) 312 + } else { 313 + None 314 + }; 315 + let cancelled = if let Some(id) = state.renaming 316 + && take_key(ctx.input, &[TakeKey::named(NamedKey::Escape)]).is_some() 317 + { 318 + state.renaming = None; 319 + state.rename_buffer = TextInputState::default(); 320 + Some(id) 321 + } else { 322 + None 323 + }; 324 + (committed, cancelled) 303 325 } 304 326 305 327 fn row_rect_at(view: LayoutRect, idx: usize, row_height: LayoutPx) -> LayoutRect { ··· 590 612 } 591 613 592 614 const TREE_GLYPH_COLUMN_PX: f32 = 18.0; 615 + const TREE_ICON_PX: f32 = 14.0; 593 616 594 617 fn label_with_glyph_paint( 595 618 ctx: &FrameCtx<'_>, ··· 601 624 } else { 602 625 ctx.theme().colors.text_primary() 603 626 }; 604 - let secondary = if row.disabled { 605 - ctx.theme().colors.text_disabled() 606 - } else { 607 - ctx.theme().colors.text_secondary() 608 - }; 609 - let glyph_paint = row.glyph.map(|kind| WidgetPaint::Mark { 610 - rect: glyph_slot_rect(label_rect), 611 - kind, 612 - color: secondary, 627 + let glyph_paint = row.icon.map(|icon| { 628 + IconSlot::new(icon, LayoutPx::new(TREE_ICON_PX)).paint_in( 629 + glyph_slot_rect(label_rect), 630 + IconTint::from_disabled(row.disabled), 631 + ) 613 632 }); 614 - let text_rect = if row.glyph.is_some() { 633 + let text_rect = if row.icon.is_some() { 615 634 text_after_glyph_rect(label_rect) 616 635 } else { 617 636 label_rect ··· 1520 1539 )); 1521 1540 let (_, _) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 1522 1541 assert!(state.renaming.is_none()); 1542 + } 1543 + 1544 + fn tall_roots(n: u64) -> Vec<TreeNode> { 1545 + (0..n) 1546 + .map(|i| { 1547 + TreeNode::leaf_owned( 1548 + WidgetId::ROOT.child_indexed(WidgetKey::new("row"), i), 1549 + format!("row{i}"), 1550 + ) 1551 + }) 1552 + .collect() 1553 + } 1554 + 1555 + fn render_scroll( 1556 + roots: &[TreeNode], 1557 + state: &mut TreeViewState, 1558 + height: f32, 1559 + ) -> (Vec<super::WidgetPaint>, bool) { 1560 + let theme = Arc::new(Theme::light()); 1561 + let table = HotkeyTable::new(); 1562 + let mut focus = FocusManager::new(); 1563 + let mut hits = HitFrame::new(); 1564 + let prev = HitState::new(); 1565 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1566 + let rect = LayoutRect::new( 1567 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 1568 + LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(height)), 1569 + ); 1570 + let tree_id = WidgetId::ROOT.child(WidgetKey::new("tree")); 1571 + let scrollbar_id = tree_id.child(WidgetKey::new("scrollbar")); 1572 + let mut shaper = bone_text::Shaper::new(); 1573 + let mut a11y = crate::a11y::AccessTreeBuilder::new(); 1574 + let paint = { 1575 + let mut ctx = FrameCtx::new( 1576 + theme, 1577 + &mut snap, 1578 + &mut focus, 1579 + &table, 1580 + StringTable::empty(), 1581 + &mut hits, 1582 + &prev, 1583 + &mut a11y, 1584 + &mut shaper, 1585 + ); 1586 + show_tree_view( 1587 + &mut ctx, 1588 + TreeView::new(tree_id, rect, StringKey::new("test.tree"), roots, state), 1589 + ) 1590 + .paint 1591 + }; 1592 + (paint, a11y.contains(scrollbar_id)) 1593 + } 1594 + 1595 + fn paints_owned_label(paint: &[super::WidgetPaint], text: &str) -> bool { 1596 + use super::{HorizontalAlign, LabelText, WidgetPaint}; 1597 + paint.iter().any(|p| { 1598 + matches!( 1599 + p, 1600 + WidgetPaint::AlignedLabel { text: LabelText::Owned(t), align: HorizontalAlign::Start, .. } 1601 + if t == text 1602 + ) 1603 + }) 1604 + } 1605 + 1606 + #[test] 1607 + fn tall_tree_culls_offscreen_rows_and_shows_a_scrollbar() { 1608 + let roots = tall_roots(40); 1609 + let mut state = TreeViewState::default(); 1610 + let (paint, has_bar) = render_scroll(&roots, &mut state, 400.0); 1611 + assert!(has_bar, "an overflowing tree shows a scrollbar"); 1612 + assert!(paints_owned_label(&paint, "row0"), "the first row paints"); 1613 + assert!( 1614 + !paints_owned_label(&paint, "row39"), 1615 + "the last row is culled offscreen", 1616 + ); 1617 + assert_eq!( 1618 + state.scroll_offset, 1619 + LayoutPx::ZERO, 1620 + "offset snaps to the top" 1621 + ); 1622 + } 1623 + 1624 + #[test] 1625 + fn scrolling_offset_reveals_later_rows() { 1626 + let roots = tall_roots(40); 1627 + let mut state = TreeViewState { 1628 + scroll_offset: LayoutPx::new(400.0), 1629 + ..TreeViewState::default() 1630 + }; 1631 + let (paint, _) = render_scroll(&roots, &mut state, 400.0); 1632 + assert!( 1633 + paints_owned_label(&paint, "row20"), 1634 + "the scrolled-to row paints", 1635 + ); 1636 + assert!( 1637 + !paints_owned_label(&paint, "row0"), 1638 + "the top row is culled after scrolling", 1639 + ); 1640 + } 1641 + 1642 + #[test] 1643 + fn tree_that_fits_shows_no_scrollbar() { 1644 + let roots = tall_roots(3); 1645 + let mut state = TreeViewState::default(); 1646 + let (paint, has_bar) = render_scroll(&roots, &mut state, 400.0); 1647 + assert!(!has_bar, "a tree that fits its viewport shows no scrollbar"); 1648 + assert!(paints_owned_label(&paint, "row2")); 1649 + } 1650 + 1651 + fn render_scroll_wheel( 1652 + roots: &[TreeNode], 1653 + state: &mut TreeViewState, 1654 + scroll_y: f32, 1655 + ) -> Vec<super::WidgetPaint> { 1656 + let theme = Arc::new(Theme::light()); 1657 + let table = HotkeyTable::new(); 1658 + let mut focus = FocusManager::new(); 1659 + let mut hits = HitFrame::new(); 1660 + let prev = HitState::new(); 1661 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1662 + snap.pointer = Some(PointerSample::new(LayoutPos::new( 1663 + LayoutPx::new(100.0), 1664 + LayoutPx::new(100.0), 1665 + ))); 1666 + snap.scroll_y = scroll_y; 1667 + let rect = LayoutRect::new( 1668 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 1669 + LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(400.0)), 1670 + ); 1671 + let tree_id = WidgetId::ROOT.child(WidgetKey::new("tree")); 1672 + let mut shaper = bone_text::Shaper::new(); 1673 + let mut a11y = crate::a11y::AccessTreeBuilder::new(); 1674 + let mut ctx = FrameCtx::new( 1675 + theme, 1676 + &mut snap, 1677 + &mut focus, 1678 + &table, 1679 + StringTable::empty(), 1680 + &mut hits, 1681 + &prev, 1682 + &mut a11y, 1683 + &mut shaper, 1684 + ); 1685 + show_tree_view( 1686 + &mut ctx, 1687 + TreeView::new(tree_id, rect, StringKey::new("test.tree"), roots, state), 1688 + ) 1689 + .paint 1690 + } 1691 + 1692 + #[test] 1693 + fn wheel_over_the_tree_reveals_later_rows() { 1694 + let roots = tall_roots(40); 1695 + let mut state = TreeViewState::default(); 1696 + let paint = render_scroll_wheel(&roots, &mut state, 100.0); 1697 + assert!( 1698 + state.scroll_offset.value() > 0.0, 1699 + "a wheel notch over the tree advances the offset", 1700 + ); 1701 + assert!( 1702 + paints_owned_label(&paint, "row5"), 1703 + "a later row is revealed" 1704 + ); 1705 + assert!( 1706 + !paints_owned_label(&paint, "row0"), 1707 + "the top row scrolls off", 1708 + ); 1709 + } 1710 + 1711 + #[test] 1712 + fn wheel_off_the_tree_does_not_scroll() { 1713 + let roots = tall_roots(40); 1714 + let mut state = TreeViewState::default(); 1715 + let theme = Arc::new(Theme::light()); 1716 + let table = HotkeyTable::new(); 1717 + let mut focus = FocusManager::new(); 1718 + let mut hits = HitFrame::new(); 1719 + let prev = HitState::new(); 1720 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1721 + snap.pointer = Some(PointerSample::new(LayoutPos::new( 1722 + LayoutPx::new(900.0), 1723 + LayoutPx::new(900.0), 1724 + ))); 1725 + snap.scroll_y = 100.0; 1726 + let rect = LayoutRect::new( 1727 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 1728 + LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(400.0)), 1729 + ); 1730 + let tree_id = WidgetId::ROOT.child(WidgetKey::new("tree")); 1731 + let mut shaper = bone_text::Shaper::new(); 1732 + let mut a11y = crate::a11y::AccessTreeBuilder::new(); 1733 + { 1734 + let mut ctx = FrameCtx::new( 1735 + theme, 1736 + &mut snap, 1737 + &mut focus, 1738 + &table, 1739 + StringTable::empty(), 1740 + &mut hits, 1741 + &prev, 1742 + &mut a11y, 1743 + &mut shaper, 1744 + ); 1745 + let _ = show_tree_view( 1746 + &mut ctx, 1747 + TreeView::new( 1748 + tree_id, 1749 + rect, 1750 + StringKey::new("test.tree"), 1751 + &roots, 1752 + &mut state, 1753 + ), 1754 + ); 1755 + } 1756 + assert_eq!( 1757 + state.scroll_offset, 1758 + LayoutPx::ZERO, 1759 + "a wheel notch away from the tree leaves it unscrolled", 1760 + ); 1523 1761 } 1524 1762 }
+19 -7
crates/bone-ui/src/widgets/visuals.rs
··· 8 8 Border, Color, ElevationLevel, Radius, Spacing, Step12, StrokeWidth, Theme, TypographyRole, 9 9 }; 10 10 11 - use super::paint::{GlyphMark, LabelText, WidgetPaint}; 11 + use bone_types::IconId; 12 + 13 + use super::paint::{GlyphMark, IconTint, LabelText, WidgetPaint}; 14 + 15 + #[derive(Copy, Clone, Debug, PartialEq)] 16 + pub enum IndicatorMark { 17 + Check, 18 + Glyph(GlyphMark), 19 + } 12 20 13 21 #[derive(Copy, Clone, Debug, PartialEq, Serialize)] 14 22 pub struct SurfaceVisuals { ··· 110 118 pub struct Indicator { 111 119 pub rect: LayoutRect, 112 120 pub label: StringKey, 113 - pub mark: Option<GlyphMark>, 121 + pub mark: Option<IndicatorMark>, 114 122 pub active: bool, 115 123 pub disabled: bool, 116 124 pub radius: Radius, ··· 146 154 color: indicator_label_color(ctx.theme(), fill, active, disabled), 147 155 role: ctx.theme().typography.label, 148 156 }); 149 - if let Some(kind) = mark { 150 - paint.push(WidgetPaint::Mark { 151 - rect, 152 - kind, 153 - color: ctx.theme().colors.contrast_text(fill), 157 + if let Some(mark) = mark { 158 + let color = ctx.theme().colors.contrast_text(fill); 159 + paint.push(match mark { 160 + IndicatorMark::Check => WidgetPaint::Icon { 161 + rect, 162 + icon: IconId::Check, 163 + tint: IconTint::Solid(color), 164 + }, 165 + IndicatorMark::Glyph(kind) => WidgetPaint::Mark { rect, kind, color }, 154 166 }); 155 167 } 156 168 push_focus_ring(ctx, paint, rect, radius, live_focused);
+2
crates/bone-ui/tests/gallery_snapshot.rs
··· 285 285 | WidgetPaint::Label { rect, .. } 286 286 | WidgetPaint::AlignedLabel { rect, .. } 287 287 | WidgetPaint::Mark { rect, .. } 288 + | WidgetPaint::Icon { rect, .. } 288 289 | WidgetPaint::FocusRing { rect, .. } 289 290 | WidgetPaint::SelectionHighlight { rect, .. } 290 291 | WidgetPaint::Caret { rect, .. } => (*rect, None), ··· 315 316 WidgetPaint::Label { .. } => "Label", 316 317 WidgetPaint::AlignedLabel { .. } => "AlignedLabel", 317 318 WidgetPaint::Mark { .. } => "Mark", 319 + WidgetPaint::Icon { .. } => "Icon", 318 320 WidgetPaint::FocusRing { .. } => "FocusRing", 319 321 WidgetPaint::SelectionHighlight { .. } => "SelectionHighlight", 320 322 WidgetPaint::Caret { .. } => "Caret",
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.

+48 -48
crates/bone-ui/tests/snapshots/theme_snapshot__theme_dark.snap
··· 6 6 mode: Dark, 7 7 colors: Colors( 8 8 neutral: (Color( 9 - r: 0.005157554, 10 - g: 0.0059472434, 11 - b: 0.0068684025, 9 + r: 0.00569539, 10 + g: 0.005856467, 11 + b: 0.006033507, 12 12 a: 1.0, 13 13 ), Color( 14 - r: 0.008159909, 15 - g: 0.009448808, 16 - b: 0.010955492, 14 + r: 0.009074977, 15 + g: 0.009294367, 16 + b: 0.009534992, 17 17 a: 1.0, 18 18 ), Color( 19 - r: 0.013548436, 20 - g: 0.015976492, 21 - b: 0.018840894, 19 + r: 0.0153612625, 20 + g: 0.015672369, 21 + b: 0.016012948, 22 22 a: 1.0, 23 23 ), Color( 24 - r: 0.020901216, 25 - g: 0.024976067, 26 - b: 0.029815085, 24 + r: 0.024034034, 25 + g: 0.024452819, 26 + b: 0.024910651, 27 27 a: 1.0, 28 28 ), Color( 29 - r: 0.030523386, 30 - g: 0.036844313, 31 - b: 0.044388447, 29 + r: 0.035247855, 30 + g: 0.036060683, 31 + b: 0.03695161, 32 32 a: 1.0, 33 33 ), Color( 34 - r: 0.050495297, 35 - g: 0.060799375, 36 - b: 0.073082335, 34 + r: 0.05835609, 35 + g: 0.059492044, 36 + b: 0.060734827, 37 37 a: 1.0, 38 38 ), Color( 39 - r: 0.083297804, 40 - g: 0.09969727, 41 - b: 0.11918802, 39 + r: 0.09555036, 40 + g: 0.09765661, 41 + b: 0.099963956, 42 42 a: 1.0, 43 43 ), Color( 44 - r: 0.14377098, 45 - g: 0.17019472, 46 - b: 0.20142835, 44 + r: 0.16318446, 45 + g: 0.16694757, 46 + b: 0.17107236, 47 47 a: 1.0, 48 48 ), Color( 49 - r: 0.23950589, 50 - g: 0.28058594, 51 - b: 0.32888895, 49 + r: 0.2692778, 50 + g: 0.27558443, 51 + b: 0.28249842, 52 52 a: 1.0, 53 53 ), Color( 54 - r: 0.30221865, 55 - g: 0.34995604, 56 - b: 0.40575886, 54 + r: 0.33679733, 55 + g: 0.3441137, 56 + b: 0.3521275, 57 57 a: 1.0, 58 58 ), Color( 59 - r: 0.4288367, 60 - g: 0.48244387, 61 - b: 0.5441973, 59 + r: 0.46684873, 60 + g: 0.47593635, 61 + b: 0.48587862, 62 62 a: 1.0, 63 63 ), Color( 64 - r: 0.8118944, 65 - g: 0.86540264, 66 - b: 0.9253415, 64 + r: 0.8497505, 65 + g: 0.85874957, 66 + b: 0.8685517, 67 67 a: 1.0, 68 68 )), 69 69 accent: (Color( ··· 428 428 a: 1.0, 429 429 ), 430 430 relation_glyph: Color( 431 - r: 0.010960091, 432 - g: 0.1980693, 433 - b: 0.11193242, 431 + r: 0.0, 432 + g: 0.40197778, 433 + b: 0.029556828, 434 434 a: 1.0, 435 435 ), 436 436 reference_geometry: Color( ··· 510 510 border: Some(Border( 511 511 width: 1.0, 512 512 color: Color( 513 - r: 0.050495297, 514 - g: 0.060799375, 515 - b: 0.073082335, 513 + r: 0.05835609, 514 + g: 0.059492044, 515 + b: 0.060734827, 516 516 a: 1.0, 517 517 ), 518 518 )), ··· 523 523 border: Some(Border( 524 524 width: 1.0, 525 525 color: Color( 526 - r: 0.083297804, 527 - g: 0.09969727, 528 - b: 0.11918802, 526 + r: 0.09555036, 527 + g: 0.09765661, 528 + b: 0.099963956, 529 529 a: 1.0, 530 530 ), 531 531 )), ··· 536 536 border: Some(Border( 537 537 width: 1.0, 538 538 color: Color( 539 - r: 0.14377098, 540 - g: 0.17019472, 541 - b: 0.20142835, 539 + r: 0.16318446, 540 + g: 0.16694757, 541 + b: 0.17107236, 542 542 a: 1.0, 543 543 ), 544 544 )),
+48 -48
crates/bone-ui/tests/snapshots/theme_snapshot__theme_light.snap
··· 6 6 mode: Light, 7 7 colors: Colors( 8 8 neutral: (Color( 9 - r: 0.95007277, 10 - g: 0.97392607, 11 - b: 1.0, 9 + r: 0.9661559, 10 + g: 0.97104824, 11 + b: 0.976359, 12 12 a: 1.0, 13 13 ), Color( 14 - r: 0.91690433, 15 - g: 0.9455365, 16 - b: 0.97705656, 14 + r: 0.93713224, 15 + g: 0.941926, 16 + b: 0.9471304, 17 17 a: 1.0, 18 18 ), Color( 19 - r: 0.8536979, 20 - g: 0.8902633, 21 - b: 0.93075293, 19 + r: 0.8808403, 20 + g: 0.8854401, 21 + b: 0.8904347, 22 22 a: 1.0, 23 23 ), Color( 24 - r: 0.79343575, 25 - g: 0.8371681, 26 - b: 0.88589585, 24 + r: 0.8268488, 25 + g: 0.83125925, 26 + b: 0.83604777, 27 27 a: 1.0, 28 28 ), Color( 29 - r: 0.71185327, 30 - g: 0.76092607, 31 - b: 0.81597877, 29 + r: 0.74832225, 30 + g: 0.7545186, 31 + b: 0.76125777, 32 32 a: 1.0, 33 33 ), Color( 34 - r: 0.63602823, 35 - g: 0.68943834, 36 - b: 0.74979544, 34 + r: 0.6765639, 35 + g: 0.682358, 36 + b: 0.68866074, 37 37 a: 1.0, 38 38 ), Color( 39 - r: 0.5256735, 40 - g: 0.5798132, 41 - b: 0.6415478, 39 + r: 0.56596816, 40 + g: 0.5728354, 41 + b: 0.5803218, 42 42 a: 1.0, 43 43 ), Color( 44 - r: 0.39559013, 45 - g: 0.44645584, 46 - b: 0.5051464, 44 + r: 0.43287957, 45 + g: 0.4400728, 46 + b: 0.44793084, 47 47 a: 1.0, 48 48 ), Color( 49 - r: 0.23950589, 50 - g: 0.28058594, 51 - b: 0.32888895, 49 + r: 0.2692778, 50 + g: 0.27558443, 51 + b: 0.28249842, 52 52 a: 1.0, 53 53 ), Color( 54 - r: 0.18612036, 55 - g: 0.22104254, 56 - b: 0.26238674, 54 + r: 0.21144488, 55 + g: 0.21681675, 56 + b: 0.22271208, 57 57 a: 1.0, 58 58 ), Color( 59 - r: 0.106354274, 60 - g: 0.1281275, 61 - b: 0.15408953, 59 + r: 0.12183857, 60 + g: 0.12556568, 61 + b: 0.12966707, 62 62 a: 1.0, 63 63 ), Color( 64 - r: 0.022535374, 65 - g: 0.02774201, 66 - b: 0.034016714, 64 + r: 0.02624153, 65 + g: 0.027135534, 66 + b: 0.028121073, 67 67 a: 1.0, 68 68 )), 69 69 accent: (Color( ··· 428 428 a: 1.0, 429 429 ), 430 430 relation_glyph: Color( 431 - r: 0.010960091, 432 - g: 0.1980693, 433 - b: 0.11193242, 431 + r: 0.0, 432 + g: 0.40197778, 433 + b: 0.029556828, 434 434 a: 1.0, 435 435 ), 436 436 reference_geometry: Color( ··· 510 510 border: Some(Border( 511 511 width: 1.0, 512 512 color: Color( 513 - r: 0.63602823, 514 - g: 0.68943834, 515 - b: 0.74979544, 513 + r: 0.6765639, 514 + g: 0.682358, 515 + b: 0.68866074, 516 516 a: 1.0, 517 517 ), 518 518 )), ··· 523 523 border: Some(Border( 524 524 width: 1.0, 525 525 color: Color( 526 - r: 0.5256735, 527 - g: 0.5798132, 528 - b: 0.6415478, 526 + r: 0.56596816, 527 + g: 0.5728354, 528 + b: 0.5803218, 529 529 a: 1.0, 530 530 ), 531 531 )), ··· 536 536 border: Some(Border( 537 537 width: 1.0, 538 538 color: Color( 539 - r: 0.39559013, 540 - g: 0.44645584, 541 - b: 0.5051464, 539 + r: 0.43287957, 540 + g: 0.4400728, 541 + b: 0.44793084, 542 542 a: 1.0, 543 543 ), 544 544 )),