Another project
0

Configure Feed

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

ui: convex poly polyline path painting

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

author
Lewis
date (Jun 11, 2026, 9:24 PM +0300) commit c3566f16 parent 029586de change-id rxxovxtt
+541 -10
+249 -3
crates/bone-ui/src/raster.rs
··· 7 7 zeno::Format, 8 8 }; 9 9 10 - use crate::layout::{LayoutPx, LayoutRect}; 10 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 11 11 use crate::strings::StringTable; 12 - use crate::theme::{Color, SurfaceLevel, Theme}; 13 - use crate::widgets::{HorizontalAlign, PaintPrim, WidgetPaint, lower_paint}; 12 + use crate::theme::{Border, Color, SurfaceLevel, Theme}; 13 + use crate::widgets::{ConvexPoly, HorizontalAlign, PaintPrim, PolyPath, WidgetPaint, lower_paint}; 14 + 15 + const VECTOR_AA_PX: f32 = 0.5; 14 16 15 17 const MARK_FONT_SCALE: f32 = 0.7; 16 18 ··· 155 157 } 156 158 } 157 159 160 + fn expanded_bounds(&self, rect: LayoutRect, pad: f32) -> PixelBounds { 161 + let grown = LayoutRect::new( 162 + LayoutPos::new( 163 + LayoutPx::saturating(rect.min_x().value() - pad), 164 + LayoutPx::saturating(rect.min_y().value() - pad), 165 + ), 166 + LayoutSize::new( 167 + LayoutPx::saturating_nonneg(rect.size.width.value() + 2.0 * pad), 168 + LayoutPx::saturating_nonneg(rect.size.height.value() + 2.0 * pad), 169 + ), 170 + ); 171 + self.bounds(grown) 172 + } 173 + 174 + fn fill_convex(&mut self, poly: &ConvexPoly, fill: Color, border: Option<Border>) { 175 + let planes = poly.edge_planes(); 176 + let bounds = self.expanded_bounds(poly.bounds(), VECTOR_AA_PX); 177 + if bounds.is_empty() { 178 + return; 179 + } 180 + let fill_premul = fill.linear_rgba_premul(); 181 + let (border_premul, border_width) = match border { 182 + Some(b) => (b.color.linear_rgba_premul(), b.width.value_px()), 183 + None => ([0.0; 4], 0.0), 184 + }; 185 + let stride = self.size.width.value(); 186 + (bounds.y0..bounds.y1).for_each(|y| { 187 + let py = pixel_center(y); 188 + (bounds.x0..bounds.x1).for_each(|x| { 189 + let sd = convex_signed_distance(&planes, pixel_center(x), py); 190 + let cov_outer = coverage(sd); 191 + if cov_outer <= 0.0 { 192 + return; 193 + } 194 + let cov_inner = if border_width > 0.0 { 195 + coverage(sd + border_width) 196 + } else { 197 + cov_outer 198 + }; 199 + let src = blend_fill_border( 200 + fill_premul, 201 + cov_inner, 202 + border_premul, 203 + (cov_outer - cov_inner).max(0.0), 204 + ); 205 + composite_pixel(&mut self.pixels, stride, x, y, src); 206 + }); 207 + }); 208 + } 209 + 210 + fn stroke_path(&mut self, path: &PolyPath, width: f32, color: Color) { 211 + let half = width.max(1.0) * 0.5; 212 + let bounds = self.expanded_bounds(path.bounds(), half + VECTOR_AA_PX); 213 + if bounds.is_empty() { 214 + return; 215 + } 216 + let span_w = (bounds.x1 - bounds.x0) as usize; 217 + let span_h = (bounds.y1 - bounds.y0) as usize; 218 + let mut mask = vec![0.0_f32; span_w * span_h]; 219 + path.segments().for_each(|(a, b)| { 220 + accumulate_segment(&mut mask, bounds, span_w, a, b, half); 221 + }); 222 + let premul = color.linear_rgba_premul(); 223 + let stride = self.size.width.value(); 224 + (bounds.y0..bounds.y1).for_each(|y| { 225 + let row = (y - bounds.y0) as usize; 226 + (bounds.x0..bounds.x1).for_each(|x| { 227 + let cov = mask[row * span_w + (x - bounds.x0) as usize]; 228 + if cov <= 0.0 { 229 + return; 230 + } 231 + let src = [ 232 + premul[0] * cov, 233 + premul[1] * cov, 234 + premul[2] * cov, 235 + premul[3] * cov, 236 + ]; 237 + composite_pixel(&mut self.pixels, stride, x, y, src); 238 + }); 239 + }); 240 + } 241 + 158 242 fn into_srgb_rgba8(self) -> Vec<u8> { 159 243 self.pixels 160 244 .iter() ··· 213 297 }); 214 298 } 215 299 300 + #[allow( 301 + clippy::cast_precision_loss, 302 + reason = "pixel coordinates stay far below the f32 mantissa limit" 303 + )] 304 + fn pixel_center(coord: u32) -> f32 { 305 + coord as f32 + 0.5 306 + } 307 + 308 + fn coverage(signed_distance: f32) -> f32 { 309 + (0.5 - signed_distance / VECTOR_AA_PX).clamp(0.0, 1.0) 310 + } 311 + 312 + fn convex_signed_distance(planes: &[[f32; 3]], x: f32, y: f32) -> f32 { 313 + planes 314 + .iter() 315 + .map(|[nx, ny, d]| nx * x + ny * y - d) 316 + .fold(f32::NEG_INFINITY, f32::max) 317 + } 318 + 319 + fn blend_fill_border( 320 + fill_premul: [f32; 4], 321 + fill_cov: f32, 322 + border_premul: [f32; 4], 323 + border_cov: f32, 324 + ) -> [f32; 4] { 325 + let border = [ 326 + border_premul[0] * border_cov, 327 + border_premul[1] * border_cov, 328 + border_premul[2] * border_cov, 329 + border_premul[3] * border_cov, 330 + ]; 331 + let inv = 1.0 - border[3]; 332 + [ 333 + border[0] + fill_premul[0] * fill_cov * inv, 334 + border[1] + fill_premul[1] * fill_cov * inv, 335 + border[2] + fill_premul[2] * fill_cov * inv, 336 + border[3] + fill_premul[3] * fill_cov * inv, 337 + ] 338 + } 339 + 340 + fn composite_pixel(pixels: &mut [[f32; 4]], stride: u32, x: u32, y: u32, src: [f32; 4]) { 341 + let idx = (y as usize) * (stride as usize) + (x as usize); 342 + let dst = pixels[idx]; 343 + let inv_alpha = 1.0 - src[3]; 344 + pixels[idx] = [ 345 + src[0] + dst[0] * inv_alpha, 346 + src[1] + dst[1] * inv_alpha, 347 + src[2] + dst[2] * inv_alpha, 348 + src[3] + dst[3] * inv_alpha, 349 + ]; 350 + } 351 + 352 + fn accumulate_segment( 353 + mask: &mut [f32], 354 + bounds: PixelBounds, 355 + span_w: usize, 356 + a: LayoutPos, 357 + b: LayoutPos, 358 + half: f32, 359 + ) { 360 + let (ax, ay) = (a.x.value(), a.y.value()); 361 + let (bx, by) = (b.x.value(), b.y.value()); 362 + (bounds.y0..bounds.y1).for_each(|y| { 363 + let row = (y - bounds.y0) as usize; 364 + let py = pixel_center(y); 365 + (bounds.x0..bounds.x1).for_each(|x| { 366 + let cov = coverage(dist_point_segment(pixel_center(x), py, ax, ay, bx, by) - half); 367 + if cov > 0.0 { 368 + let idx = row * span_w + (x - bounds.x0) as usize; 369 + mask[idx] = mask[idx].max(cov); 370 + } 371 + }); 372 + }); 373 + } 374 + 375 + fn dist_point_segment(px: f32, py: f32, ax: f32, ay: f32, bx: f32, by: f32) -> f32 { 376 + let (dx, dy) = (bx - ax, by - ay); 377 + let len2 = dx * dx + dy * dy; 378 + let t = if len2 <= f32::EPSILON { 379 + 0.0 380 + } else { 381 + (((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0) 382 + }; 383 + (px - (ax + t * dx)).hypot(py - (ay + t * dy)) 384 + } 385 + 216 386 fn pixel_to_srgb_u8(premul: [f32; 4]) -> [u8; 4] { 217 387 let alpha = premul[3].clamp(0.0, 1.0); 218 388 if alpha <= 0.0 { ··· 324 494 align: HorizontalAlign::Center, 325 495 }, 326 496 ); 497 + } 498 + WidgetPaint::ConvexFill { poly, fill, border } => { 499 + canvas.fill_convex(poly, *fill, *border); 500 + } 501 + WidgetPaint::Stroke { path, width, color } => { 502 + canvas.stroke_path(path, width.value_px(), *color); 327 503 } 328 504 WidgetPaint::Popup { paints } => { 329 505 paints ··· 638 814 pixel(0, 0), 639 815 pixel(3, 0), 640 816 "corner pixel must match a non-corner top-edge pixel; double-paint detected", 817 + ); 818 + } 819 + 820 + #[test] 821 + fn convex_fill_and_stroke_paint_inside_their_shapes() { 822 + use crate::layout::{LayoutPos, LayoutPx}; 823 + use crate::theme::{Step12, StrokeWidth}; 824 + use crate::widgets::{ConvexPoly, PolyPath, WidgetPaint}; 825 + let theme = Theme::light(); 826 + let size = CanvasSize::new(CanvasPx::new(32), CanvasPx::new(32)); 827 + let ink = theme.colors.accent.step(Step12::SOLID); 828 + let bg = rasterize(&theme, &[], size, StringTable::empty()); 829 + let rgb = |buf: &[u8], x: usize, y: usize| { 830 + let i = (y * 32 + x) * 4; 831 + [buf[i], buf[i + 1], buf[i + 2]] 832 + }; 833 + let Some(poly) = ConvexPoly::new(vec![ 834 + LayoutPos::new(LayoutPx::new(8.0), LayoutPx::new(8.0)), 835 + LayoutPos::new(LayoutPx::new(24.0), LayoutPx::new(8.0)), 836 + LayoutPos::new(LayoutPx::new(24.0), LayoutPx::new(24.0)), 837 + LayoutPos::new(LayoutPx::new(8.0), LayoutPx::new(24.0)), 838 + ]) else { 839 + panic!("the square is convex"); 840 + }; 841 + let filled = rasterize( 842 + &theme, 843 + &[WidgetPaint::ConvexFill { 844 + poly, 845 + fill: ink, 846 + border: None, 847 + }], 848 + size, 849 + StringTable::empty(), 850 + ); 851 + assert_ne!( 852 + rgb(&filled, 16, 16), 853 + rgb(&bg, 16, 16), 854 + "interior is painted" 855 + ); 856 + assert_eq!( 857 + rgb(&filled, 1, 1), 858 + rgb(&bg, 1, 1), 859 + "far corner is untouched" 860 + ); 861 + 862 + let Some(path) = PolyPath::open(vec![ 863 + LayoutPos::new(LayoutPx::new(4.0), LayoutPx::new(16.0)), 864 + LayoutPos::new(LayoutPx::new(28.0), LayoutPx::new(16.0)), 865 + ]) else { 866 + panic!("two points form a path"); 867 + }; 868 + let stroked = rasterize( 869 + &theme, 870 + &[WidgetPaint::Stroke { 871 + path, 872 + width: StrokeWidth::px(3.0), 873 + color: ink, 874 + }], 875 + size, 876 + StringTable::empty(), 877 + ); 878 + assert_ne!( 879 + rgb(&stroked, 16, 16), 880 + rgb(&bg, 16, 16), 881 + "stroke covers the line" 882 + ); 883 + assert_eq!( 884 + rgb(&stroked, 16, 3), 885 + rgb(&bg, 16, 3), 886 + "stroke stays off the line" 641 887 ); 642 888 } 643 889 }
+2
crates/bone-ui/src/widgets/mod.rs
··· 24 24 mod toolbar; 25 25 mod tooltip; 26 26 mod tree_view; 27 + mod vector; 27 28 mod visuals; 28 29 29 30 pub use button::{ ··· 87 88 DropPlacement, DropTarget, RenameCommit, TreeNode, TreeSelectionMode, TreeView, 88 89 TreeViewResponse, TreeViewState, show_tree_view, 89 90 }; 91 + pub use vector::{ConvexPoly, MAX_CONVEX_VERTS, MAX_PATH_POINTS, PolyPath}; 90 92 pub use visuals::{ 91 93 FieldVisuals, Indicator, SurfaceVisuals, TextVisuals, indicator_border, indicator_fill, 92 94 indicator_label_color, push_focus_ring, push_indicator,
+20 -7
crates/bone-ui/src/widgets/paint.rs
··· 6 6 Border, Color, ElevationLevel, Radius, Spacing, StrokeWidth, Theme, TypographyRole, 7 7 }; 8 8 use crate::widget_id::WidgetId; 9 + use crate::widgets::vector::{ConvexPoly, PolyPath}; 9 10 10 11 #[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)] 11 12 pub enum ButtonPaintKind { ··· 156 157 anchor: WidgetId, 157 158 elevation: ElevationLevel, 158 159 }, 160 + ConvexFill { 161 + poly: ConvexPoly, 162 + fill: Color, 163 + border: Option<Border>, 164 + }, 165 + Stroke { 166 + path: PolyPath, 167 + width: StrokeWidth, 168 + color: Color, 169 + }, 159 170 Popup { 160 171 paints: Vec<WidgetPaint>, 161 172 }, ··· 235 246 border: elevation.border, 236 247 radius: Radius::px(0.0), 237 248 }, 238 - WidgetPaint::Popup { .. } => PaintPrim::solid( 239 - LayoutRect::new( 240 - LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 241 - LayoutSize::ZERO, 242 - ), 243 - Color::TRANSPARENT, 244 - ), 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 + } 245 258 } 246 259 } 247 260
+266
crates/bone-ui/src/widgets/vector.rs
··· 1 + use serde::Serialize; 2 + 3 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 4 + 5 + pub const MAX_CONVEX_VERTS: usize = 8; 6 + 7 + pub const MAX_PATH_POINTS: usize = 8; 8 + 9 + const GEOMETRY_EPSILON: f32 = 1.0e-4; 10 + 11 + #[derive(Clone, Debug, PartialEq, Serialize)] 12 + pub struct ConvexPoly { 13 + verts: Vec<LayoutPos>, 14 + } 15 + 16 + impl ConvexPoly { 17 + #[must_use] 18 + pub fn new(verts: Vec<LayoutPos>) -> Option<Self> { 19 + if !(3..=MAX_CONVEX_VERTS).contains(&verts.len()) { 20 + return None; 21 + } 22 + let n = verts.len(); 23 + let edges_nondegenerate = 24 + (0..n).all(|i| edge_length(verts[i], verts[(i + 1) % n]) > GEOMETRY_EPSILON); 25 + if !edges_nondegenerate { 26 + return None; 27 + } 28 + is_convex(&verts).then_some(Self { verts }) 29 + } 30 + 31 + #[must_use] 32 + pub fn verts(&self) -> &[LayoutPos] { 33 + &self.verts 34 + } 35 + 36 + #[must_use] 37 + pub fn bounds(&self) -> LayoutRect { 38 + bounds_of(&self.verts) 39 + } 40 + 41 + #[must_use] 42 + pub fn edge_planes(&self) -> Vec<[f32; 3]> { 43 + let center = centroid(&self.verts); 44 + let n = self.verts.len(); 45 + (0..n) 46 + .map(|i| outward_plane(self.verts[i], self.verts[(i + 1) % n], center)) 47 + .collect() 48 + } 49 + } 50 + 51 + #[derive(Clone, Debug, PartialEq, Serialize)] 52 + pub struct PolyPath { 53 + points: Vec<LayoutPos>, 54 + closed: bool, 55 + } 56 + 57 + impl PolyPath { 58 + #[must_use] 59 + pub fn new(points: Vec<LayoutPos>, closed: bool) -> Option<Self> { 60 + (2..=MAX_PATH_POINTS) 61 + .contains(&points.len()) 62 + .then_some(Self { points, closed }) 63 + } 64 + 65 + #[must_use] 66 + pub fn open(points: Vec<LayoutPos>) -> Option<Self> { 67 + Self::new(points, false) 68 + } 69 + 70 + #[must_use] 71 + pub fn closed_loop(points: Vec<LayoutPos>) -> Option<Self> { 72 + Self::new(points, true) 73 + } 74 + 75 + #[must_use] 76 + pub fn points(&self) -> &[LayoutPos] { 77 + &self.points 78 + } 79 + 80 + #[must_use] 81 + pub fn is_closed(&self) -> bool { 82 + self.closed 83 + } 84 + 85 + pub fn segments(&self) -> impl Iterator<Item = (LayoutPos, LayoutPos)> + '_ { 86 + let n = self.points.len(); 87 + let count = if self.closed { n } else { n - 1 }; 88 + (0..count).map(move |i| (self.points[i], self.points[(i + 1) % n])) 89 + } 90 + 91 + #[must_use] 92 + pub fn bounds(&self) -> LayoutRect { 93 + bounds_of(&self.points) 94 + } 95 + } 96 + 97 + fn edge_length(a: LayoutPos, b: LayoutPos) -> f32 { 98 + (b.x.value() - a.x.value()).hypot(b.y.value() - a.y.value()) 99 + } 100 + 101 + fn is_convex(verts: &[LayoutPos]) -> bool { 102 + let n = verts.len(); 103 + let crosses = (0..n).map(|i| turn_cross(verts[i], verts[(i + 1) % n], verts[(i + 2) % n])); 104 + let signs = crosses.fold((false, false, false), |(pos, neg, any), c| { 105 + ( 106 + pos || c > GEOMETRY_EPSILON, 107 + neg || c < -GEOMETRY_EPSILON, 108 + any || c.abs() > GEOMETRY_EPSILON, 109 + ) 110 + }); 111 + let (positive, negative, any_nonzero) = signs; 112 + any_nonzero && !(positive && negative) 113 + } 114 + 115 + fn turn_cross(a: LayoutPos, b: LayoutPos, c: LayoutPos) -> f32 { 116 + let (abx, aby) = (b.x.value() - a.x.value(), b.y.value() - a.y.value()); 117 + let (bcx, bcy) = (c.x.value() - b.x.value(), c.y.value() - b.y.value()); 118 + abx * bcy - aby * bcx 119 + } 120 + 121 + fn centroid(verts: &[LayoutPos]) -> (f32, f32) { 122 + let sum = verts.iter().fold((0.0, 0.0), |(sx, sy), p| { 123 + (sx + p.x.value(), sy + p.y.value()) 124 + }); 125 + let count = lossless_count(verts.len()); 126 + (sum.0 / count, sum.1 / count) 127 + } 128 + 129 + fn outward_plane(a: LayoutPos, b: LayoutPos, center: (f32, f32)) -> [f32; 3] { 130 + let (dx, dy) = (b.x.value() - a.x.value(), b.y.value() - a.y.value()); 131 + let len = dx.hypot(dy).max(GEOMETRY_EPSILON); 132 + let (mut nx, mut ny) = (dy / len, -dx / len); 133 + let d = nx * a.x.value() + ny * a.y.value(); 134 + if nx * center.0 + ny * center.1 - d > 0.0 { 135 + nx = -nx; 136 + ny = -ny; 137 + } 138 + [nx, ny, nx * a.x.value() + ny * a.y.value()] 139 + } 140 + 141 + fn bounds_of(points: &[LayoutPos]) -> LayoutRect { 142 + let fold_minmax = |pick: fn(LayoutPos) -> f32| { 143 + points 144 + .iter() 145 + .fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| { 146 + (lo.min(pick(*p)), hi.max(pick(*p))) 147 + }) 148 + }; 149 + let (min_x, max_x) = fold_minmax(|p| p.x.value()); 150 + let (min_y, max_y) = fold_minmax(|p| p.y.value()); 151 + LayoutRect::new( 152 + LayoutPos::new(LayoutPx::saturating(min_x), LayoutPx::saturating(min_y)), 153 + LayoutSize::new( 154 + LayoutPx::saturating_nonneg(max_x - min_x), 155 + LayoutPx::saturating_nonneg(max_y - min_y), 156 + ), 157 + ) 158 + } 159 + 160 + #[allow( 161 + clippy::cast_precision_loss, 162 + reason = "vertex counts stay far below the f32 mantissa limit" 163 + )] 164 + fn lossless_count(len: usize) -> f32 { 165 + len as f32 166 + } 167 + 168 + #[cfg(test)] 169 + mod tests { 170 + use super::*; 171 + 172 + fn pos(x: f32, y: f32) -> LayoutPos { 173 + LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)) 174 + } 175 + 176 + #[test] 177 + fn a_square_is_convex() { 178 + assert!( 179 + ConvexPoly::new(vec![ 180 + pos(0.0, 0.0), 181 + pos(4.0, 0.0), 182 + pos(4.0, 4.0), 183 + pos(0.0, 4.0) 184 + ]) 185 + .is_some() 186 + ); 187 + } 188 + 189 + #[test] 190 + fn a_dart_is_rejected_as_concave() { 191 + let dart = vec![pos(0.0, 0.0), pos(4.0, 2.0), pos(0.0, 4.0), pos(1.0, 2.0)]; 192 + assert!(ConvexPoly::new(dart).is_none()); 193 + } 194 + 195 + #[test] 196 + fn fewer_than_three_vertices_is_rejected() { 197 + assert!(ConvexPoly::new(vec![pos(0.0, 0.0), pos(1.0, 1.0)]).is_none()); 198 + } 199 + 200 + #[test] 201 + fn more_than_the_cap_is_rejected() { 202 + let many = (0..=MAX_CONVEX_VERTS) 203 + .map(|i| pos(lossless_count(i), 0.0)) 204 + .collect(); 205 + assert!(ConvexPoly::new(many).is_none()); 206 + } 207 + 208 + #[test] 209 + fn collinear_points_are_rejected() { 210 + assert!(ConvexPoly::new(vec![pos(0.0, 0.0), pos(1.0, 0.0), pos(2.0, 0.0)]).is_none()); 211 + } 212 + 213 + #[test] 214 + fn coincident_vertices_are_rejected() { 215 + assert!(ConvexPoly::new(vec![pos(0.0, 0.0), pos(0.0, 0.0), pos(4.0, 4.0)]).is_none()); 216 + } 217 + 218 + #[test] 219 + fn edge_planes_face_outward_from_every_edge() { 220 + let Some(poly) = ConvexPoly::new(vec![ 221 + pos(0.0, 0.0), 222 + pos(4.0, 0.0), 223 + pos(4.0, 4.0), 224 + pos(0.0, 4.0), 225 + ]) else { 226 + panic!("the unit square is convex"); 227 + }; 228 + let center = (2.0, 2.0); 229 + poly.edge_planes().into_iter().for_each(|[nx, ny, d]| { 230 + assert!( 231 + nx * center.0 + ny * center.1 - d < 0.0, 232 + "the centroid sits inside every edge half-plane", 233 + ); 234 + assert!( 235 + (nx.hypot(ny) - 1.0).abs() < 1.0e-5, 236 + "edge normals are unit length" 237 + ); 238 + }); 239 + } 240 + 241 + #[test] 242 + fn an_open_path_has_one_fewer_segment_than_a_closed_loop() { 243 + let pts = vec![pos(0.0, 0.0), pos(1.0, 0.0), pos(1.0, 1.0)]; 244 + let Some(open) = PolyPath::open(pts.clone()) else { 245 + panic!("three points form a path"); 246 + }; 247 + let Some(closed) = PolyPath::closed_loop(pts) else { 248 + panic!("three points form a loop"); 249 + }; 250 + assert_eq!(open.segments().count(), 2); 251 + assert_eq!(closed.segments().count(), 3); 252 + } 253 + 254 + #[test] 255 + fn a_single_point_is_not_a_path() { 256 + assert!(PolyPath::new(vec![pos(0.0, 0.0)], false).is_none()); 257 + } 258 + 259 + #[test] 260 + fn more_points_than_the_cap_is_not_a_path() { 261 + let many = (0..=MAX_PATH_POINTS) 262 + .map(|i| pos(lossless_count(i), 0.0)) 263 + .collect(); 264 + assert!(PolyPath::new(many, false).is_none()); 265 + } 266 + }
+4
crates/bone-ui/tests/gallery_snapshot.rs
··· 288 288 | WidgetPaint::FocusRing { rect, .. } 289 289 | WidgetPaint::SelectionHighlight { rect, .. } 290 290 | WidgetPaint::Caret { rect, .. } => (*rect, None), 291 + WidgetPaint::ConvexFill { poly, .. } => (poly.bounds(), None), 292 + WidgetPaint::Stroke { path, .. } => (path.bounds(), None), 291 293 WidgetPaint::Tooltip { rect, anchor, .. } => (*rect, Some(*anchor)), 292 294 WidgetPaint::Popup { .. } => return None, 293 295 }; ··· 316 318 WidgetPaint::FocusRing { .. } => "FocusRing", 317 319 WidgetPaint::SelectionHighlight { .. } => "SelectionHighlight", 318 320 WidgetPaint::Caret { .. } => "Caret", 321 + WidgetPaint::ConvexFill { .. } => "ConvexFill", 322 + WidgetPaint::Stroke { .. } => "Stroke", 319 323 WidgetPaint::Tooltip { .. } => "Tooltip", 320 324 WidgetPaint::Popup { .. } => "Popup", 321 325 }