Another project
0

Configure Feed

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

feat(render,ui): mask atlas + pick aperture

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

author
Lewis
date (May 16, 2026, 1:28 PM +0300) commit a1711ac3 parent 3225f7b5 change-id ypmqzmoq
+973 -60
+147 -32
crates/bone-app/src/chrome.rs
··· 1 + use std::borrow::Cow; 2 + 1 3 use bone_render::{ChromeInstance, SdfGlyphInstance}; 2 - use bone_text::{FontFace, FontWeight, ShapeRequest, ShapedLine, Shaper}; 4 + use bone_text::{FontFace, FontWeight, ShapeRequest, ShapedLine, ShapedText, Shaper}; 3 5 use bone_ui::layout::LayoutRect; 4 6 use bone_ui::strings::StringTable; 5 - use bone_ui::text::{GlyphAtlasKey, SdfAtlas}; 6 - use bone_ui::theme::{Color, FontSize, Theme}; 7 - use bone_ui::widgets::{PaintPrim, WidgetPaint, lower_paint}; 7 + use bone_ui::text::{MaskAtlas, MaskAtlasKey}; 8 + use bone_ui::theme::{Color, Theme}; 9 + use bone_ui::widgets::{HorizontalAlign, PaintPrim, WidgetPaint, lower_paint}; 8 10 use swash::FontRef; 9 11 10 12 const MARK_FONT_SCALE: f32 = 0.7; 11 13 14 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 15 + pub enum SpanVertical { 16 + Baseline, 17 + RectCenter, 18 + } 19 + 12 20 #[derive(Clone, Debug)] 13 21 pub struct ChromeTextSpan<'a> { 14 22 pub rect_px: [f32; 4], ··· 17 25 pub font_size_px: f32, 18 26 pub face: FontFace, 19 27 pub weight: FontWeight, 28 + pub vertical: SpanVertical, 29 + pub horizontal: HorizontalAlign, 20 30 } 21 31 22 32 #[must_use] 23 33 pub fn paint_to_instances(theme: &Theme, paints: &[WidgetPaint]) -> Vec<ChromeInstance> { 24 34 paints 25 35 .iter() 26 - .filter(|p| !matches!(p, WidgetPaint::Label { .. } | WidgetPaint::Mark { .. })) 36 + .filter(|p| { 37 + !matches!( 38 + p, 39 + WidgetPaint::Label { .. } 40 + | WidgetPaint::AlignedLabel { .. } 41 + | WidgetPaint::Mark { .. } 42 + ) 43 + }) 27 44 .map(|p| prim_to_instance(&lower_paint(theme, p))) 28 45 .collect() 29 46 } ··· 48 65 font_size_px: role.size.as_px_f32(), 49 66 face: role.face, 50 67 weight: role.weight, 68 + vertical: SpanVertical::Baseline, 69 + horizontal: HorizontalAlign::Center, 70 + }), 71 + WidgetPaint::AlignedLabel { 72 + rect, 73 + text, 74 + color, 75 + role, 76 + align, 77 + } => Some(ChromeTextSpan { 78 + rect_px: rect_to_xywh(*rect), 79 + text: text.resolve(strings), 80 + color_premul_rgba: color.linear_rgba_premul(), 81 + font_size_px: role.size.as_px_f32(), 82 + face: role.face, 83 + weight: role.weight, 84 + vertical: SpanVertical::Baseline, 85 + horizontal: *align, 51 86 }), 52 87 WidgetPaint::Mark { rect, kind, color } => Some(ChromeTextSpan { 53 88 rect_px: rect_to_xywh(*rect), ··· 56 91 font_size_px: rect.size.height.value() * MARK_FONT_SCALE, 57 92 face: FontFace::Sans, 58 93 weight: FontWeight::Regular, 94 + vertical: SpanVertical::RectCenter, 95 + horizontal: HorizontalAlign::Center, 59 96 }), 60 97 _ => None, 61 98 }) ··· 79 116 #[must_use] 80 117 pub fn build_glyph_instances( 81 118 spans: &[ChromeTextSpan<'_>], 82 - atlas: &mut SdfAtlas, 119 + atlas: &mut MaskAtlas, 83 120 shaper: &mut Shaper, 84 121 sans_font: &FontRef<'static>, 85 122 mono_font: &FontRef<'static>, ··· 93 130 acc 94 131 }) 95 132 } 133 + 134 + const ELLIPSIS: &str = "\u{2026}"; 96 135 97 136 fn push_span_instances( 98 137 out: &mut Vec<SdfGlyphInstance>, 99 138 item: &ChromeTextSpan<'_>, 100 - atlas: &mut SdfAtlas, 139 + atlas: &mut MaskAtlas, 101 140 shaper: &mut Shaper, 102 141 fonts: &ChromeFonts<'_>, 103 142 ) { 104 - let layout = shaper.shape( 105 - item.text, 106 - ShapeRequest { 107 - face: item.face, 108 - size_px: item.font_size_px, 109 - weight: item.weight, 110 - line_height_px: 0.0, 111 - letter_spacing_px: 0.0, 112 - max_width: None, 113 - }, 114 - ); 143 + let request = ShapeRequest { 144 + face: item.face, 145 + size_px: item.font_size_px, 146 + weight: item.weight, 147 + line_height_px: 0.0, 148 + letter_spacing_px: 0.0, 149 + max_width: None, 150 + }; 151 + let [rx, ry, rw, rh] = item.rect_px; 152 + let (_text_for_paint, layout) = fit_with_ellipsis(item.text, rw, shaper, request); 115 153 let metrics = fonts 116 154 .for_face(item.face) 117 155 .metrics(&[]) ··· 120 158 .lines 121 159 .first() 122 160 .map_or(0.0, ShapedLine::visible_advance_px); 123 - let [rx, ry, rw, rh] = item.rect_px; 161 + let start_x = match item.horizontal { 162 + HorizontalAlign::Center => rx + ((rw - visible_advance) * 0.5).max(0.0), 163 + HorizontalAlign::Start => rx, 164 + HorizontalAlign::End => rx + (rw - visible_advance).max(0.0), 165 + }; 124 166 let line = LineLayout { 125 167 face: item.face, 126 - target: FontSize::from_px(f64::from(item.font_size_px)), 168 + size_px: item.font_size_px, 127 169 color: item.color_premul_rgba, 128 - start_x: rx + ((rw - visible_advance) * 0.5).max(0.0), 129 - baseline_y: ry + (rh + metrics.cap_height) * 0.5, 170 + start_x, 171 + baseline_y: ry + (rh + metrics.ascent - metrics.descent) * 0.5, 172 + vertical: item.vertical, 173 + rect_center_y: ry + rh * 0.5, 130 174 }; 131 175 layout 132 176 .lines ··· 142 186 }); 143 187 } 144 188 189 + fn shape_advance(shaper: &mut Shaper, text: &str, request: ShapeRequest) -> f32 { 190 + shaper 191 + .shape(text, request) 192 + .lines 193 + .first() 194 + .map_or(0.0, ShapedLine::visible_advance_px) 195 + } 196 + 197 + fn fit_with_ellipsis<'a>( 198 + text: &'a str, 199 + max_width_px: f32, 200 + shaper: &mut Shaper, 201 + request: ShapeRequest, 202 + ) -> (Cow<'a, str>, ShapedText) { 203 + let initial = shaper.shape(text, request); 204 + let visible = initial 205 + .lines 206 + .first() 207 + .map_or(0.0, ShapedLine::visible_advance_px); 208 + if max_width_px <= 0.0 || text.is_empty() || visible <= max_width_px { 209 + return (Cow::Borrowed(text), initial); 210 + } 211 + let ellipsis_advance = shape_advance(shaper, ELLIPSIS, request); 212 + let target = (max_width_px - ellipsis_advance).max(0.0); 213 + if target <= 0.0 { 214 + let layout = shaper.shape(ELLIPSIS, request); 215 + return (Cow::Owned(ELLIPSIS.to_owned()), layout); 216 + } 217 + let char_offsets: Vec<usize> = text 218 + .char_indices() 219 + .map(|(i, _)| i) 220 + .chain(core::iter::once(text.len())) 221 + .collect(); 222 + let n_chars = char_offsets.len().saturating_sub(1); 223 + let mut lo = 0usize; 224 + let mut hi = n_chars; 225 + while lo < hi { 226 + let mid = (lo + hi).div_ceil(2); 227 + let prefix = &text[..char_offsets[mid]]; 228 + let prefix_advance = shape_advance(shaper, prefix, request); 229 + if prefix_advance <= target { 230 + lo = mid; 231 + } else { 232 + hi = mid.saturating_sub(1); 233 + } 234 + } 235 + let prefix = &text[..char_offsets[lo]]; 236 + let truncated = format!("{prefix}{ELLIPSIS}"); 237 + let layout = shaper.shape(truncated.as_str(), request); 238 + (Cow::Owned(truncated), layout) 239 + } 240 + 145 241 #[derive(Copy, Clone)] 146 242 struct LineLayout { 147 243 face: FontFace, 148 - target: FontSize, 244 + size_px: f32, 149 245 color: [f32; 4], 150 246 start_x: f32, 151 247 baseline_y: f32, 248 + vertical: SpanVertical, 249 + rect_center_y: f32, 152 250 } 153 251 154 252 fn build_instance( 155 - atlas: &mut SdfAtlas, 253 + atlas: &mut MaskAtlas, 156 254 line: &LineLayout, 157 255 glyph_id: bone_text::GlyphId, 158 256 advance_x: f32, 159 257 ) -> Option<SdfGlyphInstance> { 160 - let key = GlyphAtlasKey::new(line.face, glyph_id); 258 + let size_px = atlas_size_px(line.size_px); 259 + let key = MaskAtlasKey::new(line.face, glyph_id, size_px); 161 260 let entry = match atlas.ensure(key) { 162 261 Ok(e) => e, 163 262 Err(e) => { ··· 165 264 return None; 166 265 } 167 266 }; 168 - let extent = entry.display_extent(line.target); 169 - let bearing = entry.display_bearing(line.target); 267 + let extent = entry.glyph_size; 268 + let bearing = entry.bearing; 269 + let glyph_top_y = match line.vertical { 270 + SpanVertical::Baseline => line.baseline_y - bearing.dy.value(), 271 + SpanVertical::RectCenter => line.rect_center_y - extent.height.value() * 0.5, 272 + }; 273 + let pixel_x = (line.start_x + advance_x + bearing.dx.value()).round(); 274 + let pixel_y = glyph_top_y.round(); 170 275 Some(SdfGlyphInstance { 171 276 rect_xywh_px: [ 172 - line.start_x + advance_x + bearing.dx.value(), 173 - line.baseline_y - bearing.dy.value(), 277 + pixel_x, 278 + pixel_y, 174 279 extent.width.value(), 175 280 extent.height.value(), 176 281 ], ··· 190 295 border_color.linear_rgba_premul(), 191 296 [thickness_px, prim.radius.value_px()], 192 297 ) 298 + } 299 + 300 + fn atlas_size_px(font_size_px: f32) -> u16 { 301 + #[allow( 302 + clippy::cast_possible_truncation, 303 + clippy::cast_sign_loss, 304 + reason = "chrome font sizes are well within u16 after rounding" 305 + )] 306 + let rounded = font_size_px.round().clamp(1.0, f32::from(u16::MAX)) as u16; 307 + rounded 193 308 } 194 309 195 310 fn rect_to_xywh(rect: LayoutRect) -> [f32; 4] { ··· 284 399 let theme = Theme::light(); 285 400 let paints = vec![label(LabelText::Owned("Hi".to_owned()), &theme)]; 286 401 let spans = paint_to_text_spans(&paints, StringTable::empty()); 287 - let mut atlas = SdfAtlas::new(bone_ui::SdfAtlasParams::STANDARD); 402 + let mut atlas = MaskAtlas::new(bone_ui::MaskAtlasParams::STANDARD); 288 403 let mut shaper = Shaper::new(); 289 404 let sans_font = bone_text::load_font(FontFace::Sans); 290 405 let mono_font = bone_text::load_font(FontFace::Mono); ··· 327 442 let theme = Theme::light(); 328 443 let paints = vec![mark(GlyphMark::Ellipsis, &theme)]; 329 444 let spans = paint_to_text_spans(&paints, StringTable::empty()); 330 - let mut atlas = SdfAtlas::new(bone_ui::SdfAtlasParams::STANDARD); 445 + let mut atlas = MaskAtlas::new(bone_ui::MaskAtlasParams::STANDARD); 331 446 let mut shaper = Shaper::new(); 332 447 let sans_font = bone_text::load_font(FontFace::Sans); 333 448 let mono_font = bone_text::load_font(FontFace::Mono); ··· 342 457 343 458 #[test] 344 459 fn build_glyph_instances_is_empty_for_no_spans() { 345 - let mut atlas = SdfAtlas::new(bone_ui::SdfAtlasParams::STANDARD); 460 + let mut atlas = MaskAtlas::new(bone_ui::MaskAtlasParams::STANDARD); 346 461 let mut shaper = Shaper::new(); 347 462 let sans_font = bone_text::load_font(FontFace::Sans); 348 463 let mono_font = bone_text::load_font(FontFace::Mono);
+3 -1
crates/bone-render/src/lib.rs
··· 11 11 pub use camera::{Camera2, GridSpacing, PixelsPerMm, ViewportExtent, ViewportPx}; 12 12 pub use diff::{PixelDiff, PixelDiffError, PixelDiffReport, PixelDiffThreshold, PixelMismatch}; 13 13 pub use gpu::{BackendTag, Capabilities, Gpu, OffscreenContext}; 14 - pub use pick::{EntityKindTag, PickId, PickIdError, PickIndex, PickQuery, PickedItem, Picker}; 14 + pub use pick::{ 15 + EntityKindTag, PickAperture, PickId, PickIdError, PickIndex, PickQuery, PickedItem, Picker, 16 + }; 15 17 pub use pipelines::{ 16 18 ArcPipeline, ChromeInstance, ChromePipeline, ChromeTextPipeline, GlyphPipeline, GridPipeline, 17 19 LinesPipeline, SdfGlyphInstance, TextPipeline,
+328 -3
crates/bone-render/src/pick.rs
··· 185 185 } 186 186 187 187 #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] 188 + pub struct PickAperture(u32); 189 + 190 + impl PickAperture { 191 + pub const EXACT: Self = Self(0); 192 + pub const DEFAULT: Self = Self(5); 193 + 194 + #[must_use] 195 + pub const fn new(radius_px: u32) -> Self { 196 + Self(radius_px) 197 + } 198 + 199 + #[must_use] 200 + pub const fn radius_px(self) -> u32 { 201 + self.0 202 + } 203 + } 204 + 205 + impl Default for PickAperture { 206 + fn default() -> Self { 207 + Self::DEFAULT 208 + } 209 + } 210 + 211 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] 188 212 pub struct PickQuery { 189 213 x: ViewportPx, 190 214 y: ViewportPx, 215 + aperture: PickAperture, 191 216 } 192 217 193 218 impl PickQuery { 194 219 #[must_use] 195 220 pub const fn new(x: ViewportPx, y: ViewportPx) -> Self { 196 - Self { x, y } 221 + Self { 222 + x, 223 + y, 224 + aperture: PickAperture::DEFAULT, 225 + } 226 + } 227 + 228 + #[must_use] 229 + pub const fn with_aperture(self, aperture: PickAperture) -> Self { 230 + Self { aperture, ..self } 197 231 } 198 232 199 233 #[must_use] ··· 204 238 #[must_use] 205 239 pub const fn y(self) -> ViewportPx { 206 240 self.y 241 + } 242 + 243 + #[must_use] 244 + pub const fn aperture(self) -> PickAperture { 245 + self.aperture 207 246 } 208 247 } 209 248 ··· 304 343 } 305 344 306 345 pub fn at(&self, query: PickQuery) -> Result<Option<PickedItem>> { 307 - let pid = self.raw_at(query)?; 308 - Ok(pid.unpack(&self.index)) 346 + let radius = query.aperture.radius_px(); 347 + if radius == 0 { 348 + let pid = self.raw_at(query)?; 349 + return Ok(pid.unpack(&self.index)); 350 + } 351 + self.bounds_check(query)?; 352 + let candidates = self.read_region(query, radius)?; 353 + Ok(nearest_pick(query, &candidates).and_then(|pid| pid.unpack(&self.index))) 354 + } 355 + 356 + fn bounds_check(&self, query: PickQuery) -> Result<()> { 357 + let width = self.extent.width().value(); 358 + let height = self.extent.height().value(); 359 + if query.x.value() >= width || query.y.value() >= height { 360 + return Err(RenderError::PickOutOfBounds { 361 + query, 362 + extent: self.extent, 363 + }); 364 + } 365 + Ok(()) 366 + } 367 + 368 + #[allow( 369 + clippy::cast_possible_wrap, 370 + clippy::cast_sign_loss, 371 + reason = "viewport extents and aperture radius fit i32; bounds are clamped non-negative" 372 + )] 373 + fn read_region(&self, query: PickQuery, radius: u32) -> Result<Vec<(i32, i32, PickId)>> { 374 + let r = radius as i32; 375 + let qx = query.x.value() as i32; 376 + let qy = query.y.value() as i32; 377 + let viewport_w = self.extent.width().value() as i32; 378 + let viewport_h = self.extent.height().value() as i32; 379 + let x0 = (qx - r).max(0); 380 + let y0 = (qy - r).max(0); 381 + let x1 = (qx + r + 1).min(viewport_w); 382 + let y1 = (qy + r + 1).min(viewport_h); 383 + let region_w = (x1 - x0) as u32; 384 + let region_h = (y1 - y0) as u32; 385 + let raw = self.copy_region_to_host(x0 as u32, y0 as u32, region_w, region_h)?; 386 + let bpr = padded_row_bytes(region_w); 387 + Ok(decode_region(x0, y0, region_w, region_h, bpr, &raw)) 388 + } 389 + 390 + fn copy_region_to_host( 391 + &self, 392 + origin_x: u32, 393 + origin_y: u32, 394 + width: u32, 395 + height: u32, 396 + ) -> Result<Vec<u8>> { 397 + let bpr = padded_row_bytes(width); 398 + let device = self.gpu.device(); 399 + let queue = self.gpu.queue(); 400 + let staging = device.create_buffer(&wgpu::BufferDescriptor { 401 + label: Some("bone-render:pick-aperture-staging"), 402 + size: u64::from(bpr) * u64::from(height), 403 + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, 404 + mapped_at_creation: false, 405 + }); 406 + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { 407 + label: Some("bone-render:pick-aperture-encoder"), 408 + }); 409 + encoder.copy_texture_to_buffer( 410 + wgpu::TexelCopyTextureInfo { 411 + texture: self.pick, 412 + mip_level: 0, 413 + origin: wgpu::Origin3d { 414 + x: origin_x, 415 + y: origin_y, 416 + z: 0, 417 + }, 418 + aspect: wgpu::TextureAspect::All, 419 + }, 420 + wgpu::TexelCopyBufferInfo { 421 + buffer: &staging, 422 + layout: wgpu::TexelCopyBufferLayout { 423 + offset: 0, 424 + bytes_per_row: Some(bpr), 425 + rows_per_image: Some(height), 426 + }, 427 + }, 428 + wgpu::Extent3d { 429 + width, 430 + height, 431 + depth_or_array_layers: 1, 432 + }, 433 + ); 434 + queue.submit(Some(encoder.finish())); 435 + 436 + let slice = staging.slice(..); 437 + let (tx, rx) = 438 + std::sync::mpsc::sync_channel::<core::result::Result<(), wgpu::BufferAsyncError>>(1); 439 + slice.map_async(wgpu::MapMode::Read, move |res| { 440 + let _ = tx.send(res); 441 + }); 442 + device 443 + .poll(wgpu::PollType::wait_indefinitely()) 444 + .map_err(RenderError::Poll)?; 445 + match rx.try_recv() { 446 + Ok(Ok(())) => {} 447 + Ok(Err(e)) => return Err(RenderError::Map(e)), 448 + Err(_) => return Err(RenderError::MapMissing), 449 + } 450 + let bytes = slice.get_mapped_range().to_vec(); 451 + staging.unmap(); 452 + Ok(bytes) 309 453 } 310 454 } 311 455 456 + const fn padded_row_bytes(pixels: u32) -> u32 { 457 + let raw = pixels * PICK_BYTES_PER_PIXEL; 458 + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; 459 + raw.div_ceil(align) * align 460 + } 461 + 462 + #[allow( 463 + clippy::cast_possible_wrap, 464 + reason = "decoded coords are bounded by viewport extents which fit i32" 465 + )] 466 + fn decode_region( 467 + origin_x: i32, 468 + origin_y: i32, 469 + width: u32, 470 + height: u32, 471 + bytes_per_row: u32, 472 + raw: &[u8], 473 + ) -> Vec<(i32, i32, PickId)> { 474 + let bpr = bytes_per_row as usize; 475 + let bpp = PICK_BYTES_PER_PIXEL as usize; 476 + assert_eq!( 477 + raw.len(), 478 + bpr * height as usize, 479 + "pick readback length mismatch: got {}, want {} ({} rows of {} bytes)", 480 + raw.len(), 481 + bpr * height as usize, 482 + height, 483 + bpr, 484 + ); 485 + (0..height) 486 + .flat_map(|row| (0..width).map(move |col| (row, col))) 487 + .filter_map(|(row, col)| { 488 + let offset = (row as usize) * bpr + (col as usize) * bpp; 489 + let bytes = raw.get(offset..offset + bpp)?; 490 + let pid = PickId::from_raw(u32::from_le_bytes([ 491 + bytes[0], bytes[1], bytes[2], bytes[3], 492 + ])); 493 + Some((origin_x + col as i32, origin_y + row as i32, pid)) 494 + }) 495 + .collect() 496 + } 497 + 498 + #[allow( 499 + clippy::cast_possible_wrap, 500 + reason = "query coords fit i32 (display pixels)" 501 + )] 502 + #[must_use] 503 + pub(crate) fn nearest_pick( 504 + query: PickQuery, 505 + candidates: &[(i32, i32, PickId)], 506 + ) -> Option<PickId> { 507 + let qx = query.x.value() as i32; 508 + let qy = query.y.value() as i32; 509 + candidates 510 + .iter() 511 + .copied() 512 + .filter(|(_, _, pid)| *pid != PickId::NONE) 513 + .min_by_key(|(px, py, pid)| { 514 + let dx = px - qx; 515 + let dy = py - qy; 516 + let sq_dist = dx * dx + dy * dy; 517 + let tag_priority = pid.tag().map_or(u8::MAX, EntityKindTag::bits); 518 + (sq_dist, tag_priority) 519 + }) 520 + .map(|(_, _, pid)| pid) 521 + } 522 + 312 523 impl core::fmt::Debug for Picker<'_> { 313 524 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 314 525 f.debug_struct("Picker") ··· 666 877 let pid = PickId::from_raw(raw); 667 878 prop_assert!(pid.unpack(&PickIndex::default()).is_none()); 668 879 } 880 + } 881 + 882 + fn pid_for(slot: u32, tag: EntityKindTag) -> PickId { 883 + debug_assert!(slot <= PickId::INDEX_MASK); 884 + PickId::from_raw((u32::from(tag.bits()) << PickId::TAG_SHIFT) | slot) 885 + } 886 + 887 + fn query_at(x: u32, y: u32) -> PickQuery { 888 + PickQuery::new(ViewportPx::new(x), ViewportPx::new(y)) 889 + } 890 + 891 + #[test] 892 + fn aperture_default_is_five_pixels() { 893 + assert_eq!(PickAperture::default().radius_px(), 5); 894 + assert_eq!(PickAperture::EXACT.radius_px(), 0); 895 + } 896 + 897 + #[test] 898 + fn pick_query_carries_default_aperture() { 899 + let q = query_at(10, 20); 900 + assert_eq!(q.aperture(), PickAperture::DEFAULT); 901 + let exact = q.with_aperture(PickAperture::EXACT); 902 + assert_eq!(exact.aperture(), PickAperture::EXACT); 903 + assert_eq!((exact.x(), exact.y()), (q.x(), q.y())); 904 + } 905 + 906 + #[test] 907 + fn nearest_pick_returns_none_when_all_empty() { 908 + let q = query_at(5, 5); 909 + let region = vec![ 910 + (4, 5, PickId::NONE), 911 + (5, 5, PickId::NONE), 912 + (6, 5, PickId::NONE), 913 + ]; 914 + assert_eq!(nearest_pick(q, &region), None); 915 + } 916 + 917 + #[test] 918 + fn nearest_pick_picks_center_when_center_hits() { 919 + let q = query_at(5, 5); 920 + let center_pid = pid_for(7, EntityKindTag::Line); 921 + let neighbour_pid = pid_for(11, EntityKindTag::Line); 922 + let region = vec![ 923 + (3, 5, neighbour_pid), 924 + (5, 5, center_pid), 925 + (8, 5, neighbour_pid), 926 + ]; 927 + assert_eq!(nearest_pick(q, &region), Some(center_pid)); 928 + } 929 + 930 + #[test] 931 + fn nearest_pick_resolves_by_squared_distance() { 932 + let q = query_at(10, 10); 933 + let near_pid = pid_for(1, EntityKindTag::Line); 934 + let far_pid = pid_for(2, EntityKindTag::Line); 935 + let region = vec![(13, 14, far_pid), (12, 11, near_pid)]; 936 + assert_eq!(nearest_pick(q, &region), Some(near_pid)); 937 + } 938 + 939 + #[test] 940 + fn nearest_pick_breaks_ties_by_iteration_order() { 941 + let q = query_at(0, 0); 942 + let first_pid = pid_for(1, EntityKindTag::Line); 943 + let second_pid = pid_for(2, EntityKindTag::Line); 944 + let region = vec![(3, 4, first_pid), (4, 3, second_pid)]; 945 + assert_eq!(nearest_pick(q, &region), Some(first_pid)); 946 + } 947 + 948 + #[test] 949 + fn nearest_pick_skips_none_and_picks_only_real() { 950 + let q = query_at(20, 20); 951 + let real = pid_for(7, EntityKindTag::Arc); 952 + let region = vec![ 953 + (20, 20, PickId::NONE), 954 + (22, 22, real), 955 + (25, 25, PickId::NONE), 956 + ]; 957 + assert_eq!(nearest_pick(q, &region), Some(real)); 958 + } 959 + 960 + #[test] 961 + fn padded_row_bytes_aligns_to_wgpu_alignment() { 962 + assert_eq!( 963 + padded_row_bytes(1), 964 + wgpu::COPY_BYTES_PER_ROW_ALIGNMENT, 965 + "1 px row pads to alignment" 966 + ); 967 + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; 968 + let pixels = align / PICK_BYTES_PER_PIXEL; 969 + assert_eq!( 970 + padded_row_bytes(pixels), 971 + align, 972 + "exactly one alignment-worth of pixels stays at one alignment unit" 973 + ); 974 + assert_eq!( 975 + padded_row_bytes(pixels + 1), 976 + align * 2, 977 + "one extra pixel triggers a second alignment unit" 978 + ); 979 + } 980 + 981 + #[test] 982 + fn decode_region_shifts_indices_by_origin() { 983 + let bpr_u32 = padded_row_bytes(2); 984 + let bpr = bpr_u32 as usize; 985 + let mut raw = vec![0u8; bpr * 2]; 986 + let pid_a = pid_for(1, EntityKindTag::Line); 987 + let pid_b = pid_for(2, EntityKindTag::Arc); 988 + raw[0..4].copy_from_slice(&pid_a.raw().to_le_bytes()); 989 + raw[bpr + 4..bpr + 8].copy_from_slice(&pid_b.raw().to_le_bytes()); 990 + let decoded = decode_region(10, 20, 2, 2, bpr_u32, &raw); 991 + assert_eq!(decoded.len(), 4); 992 + assert_eq!(decoded[0], (10, 20, pid_a)); 993 + assert_eq!(decoded[3], (11, 21, pid_b)); 669 994 } 670 995 }
+1 -3
crates/bone-render/src/pipelines/chrome_text.wgsl
··· 45 45 46 46 @fragment 47 47 fn fs(in: VsOut) -> @location(0) vec4<f32> { 48 - let s = textureSample(atlas, atlas_sampler, in.uv).r; 49 - let aa = max(fwidth(s), 1.0e-4); 50 - let alpha = 1.0 - smoothstep(0.5 - aa, 0.5 + aa, s); 48 + let alpha = textureSample(atlas, atlas_sampler, in.uv).r; 51 49 if alpha <= 0.0 { 52 50 discard; 53 51 }
+93 -5
crates/bone-render/tests/picker.rs
··· 1 1 use bone_document::{EditOutcome, Sketch, SketchEdit, SketchEntity}; 2 2 use bone_render::{ 3 - Camera2, PickQuery, PickedItem, RenderError, SketchRenderer, SketchScene, Style, 3 + Camera2, PickAperture, PickQuery, PickedItem, RenderError, SketchRenderer, SketchScene, Style, 4 4 ViewportExtent, ViewportPx, 5 5 }; 6 6 use bone_types::{Length, Point2, Point3, SketchEntityId, SketchPlaneBasis, Tolerance, UnitVec3}; ··· 141 141 let cases = [ 142 142 ( 143 143 "point", 144 - fx.probe_point.0, 144 + fx.probe_point.0.with_aperture(PickAperture::EXACT), 145 145 PickedItem::Point(fx.probe_point.1), 146 146 ), 147 - ("line", fx.probe_line.0, PickedItem::Line(fx.probe_line.1)), 147 + ( 148 + "line", 149 + fx.probe_line.0.with_aperture(PickAperture::EXACT), 150 + PickedItem::Line(fx.probe_line.1), 151 + ), 148 152 ( 149 153 "circle", 150 - fx.probe_circle.0, 154 + fx.probe_circle.0.with_aperture(PickAperture::EXACT), 151 155 PickedItem::Circle(fx.probe_circle.1), 152 156 ), 153 - ("arc", fx.probe_arc.0, PickedItem::Arc(fx.probe_arc.1)), 157 + ( 158 + "arc", 159 + fx.probe_arc.0.with_aperture(PickAperture::EXACT), 160 + PickedItem::Arc(fx.probe_arc.1), 161 + ), 154 162 ]; 155 163 156 164 let mismatches: Vec<String> = cases ··· 227 235 assert_eq!( 228 236 first, second, 229 237 "repeated render produced non-deterministic ids" 238 + ); 239 + } 240 + 241 + #[test] 242 + fn aperture_picks_line_when_cursor_misses_by_a_few_pixels() { 243 + let ctx = common::make_context(extent()); 244 + let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 245 + let camera = Camera2::new(extent()); 246 + let style = Style::default(); 247 + 248 + let fx = build_fixture(); 249 + let Ok(_) = renderer.render(&ctx, &fx.scene, camera, &style) else { 250 + panic!("SketchRenderer::render failed"); 251 + }; 252 + let Ok(index) = fx.scene.pick_index() else { 253 + panic!("pick index build"); 254 + }; 255 + let picker = ctx.picker(index); 256 + 257 + let line_centroid = fx.probe_line.0; 258 + #[allow( 259 + clippy::cast_possible_truncation, 260 + clippy::cast_sign_loss, 261 + reason = "stroke half-width is small and bounded; ceil keeps offset positive" 262 + )] 263 + let stroke_clearance_px = 264 + (style.strokes().stroke_width_px() * 0.5 + 1.0).ceil() as u32; 265 + let off_axis = PickQuery::new( 266 + ViewportPx::new(line_centroid.x().value() + stroke_clearance_px), 267 + line_centroid.y(), 268 + ); 269 + 270 + let Ok(default_hit) = picker.at(off_axis) else { 271 + panic!("aperture pick failed"); 272 + }; 273 + assert_eq!( 274 + default_hit, 275 + Some(PickedItem::Line(fx.probe_line.1)), 276 + "default aperture should snap a near-miss onto the line" 277 + ); 278 + 279 + let Ok(exact_hit) = picker.at(off_axis.with_aperture(PickAperture::EXACT)) else { 280 + panic!("exact pick failed"); 281 + }; 282 + assert_eq!( 283 + exact_hit, None, 284 + "exact aperture must keep the near-miss as empty space" 285 + ); 286 + } 287 + 288 + #[test] 289 + fn aperture_picks_closer_entity_when_two_in_range() { 290 + let ctx = common::make_context(extent()); 291 + let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 292 + let camera = Camera2::new(extent()); 293 + let style = Style::default(); 294 + 295 + let s = Sketch::new(plane()); 296 + let (s, far_id) = add_point(s, 6.0, 0.0); 297 + let (s, near_id) = add_point(s, 9.0, 0.0); 298 + let Ok(scene) = SketchScene::extract(&s) else { 299 + panic!("scene extract"); 300 + }; 301 + let Ok(_) = renderer.render(&ctx, &scene, camera, &style) else { 302 + panic!("SketchRenderer::render failed"); 303 + }; 304 + let Ok(index) = scene.pick_index() else { 305 + panic!("pick index build"); 306 + }; 307 + let picker = ctx.picker(index); 308 + 309 + let query = PickQuery::new(ViewportPx::new(212), ViewportPx::new(128)) 310 + .with_aperture(PickAperture::new(30)); 311 + let Ok(hit) = picker.at(query) else { 312 + panic!("aperture pick failed"); 313 + }; 314 + assert_eq!( 315 + hit, 316 + Some(PickedItem::Point(near_id)), 317 + "wider aperture must pick the nearer of two in-range entities (near {near_id:?} over far {far_id:?})", 230 318 ); 231 319 } 232 320
+1 -7
crates/bone-ui/src/gallery.rs
··· 23 23 HotkeyCapture, HotkeyCaptureState, LengthEditor, ListItem, ListView, ListViewState, 24 24 MemoryClipboard, Menu, MenuBar, MenuBarEntry, MenuBarState, MenuItem, MenuState, Modal, 25 25 NumericInput, Panel, PanelState, PanelTitlebar, PropertyGrid, PropertyOption, PropertyRow, 26 - RadioGroup, RadioOption, Ribbon, RibbonGroup, RibbonIconSize, RibbonState, RibbonTab, 26 + RadioGroup, RadioOption, Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, 27 27 SelectionEditor, Slider, SliderRange, SliderStep, StatusAlign, StatusBar, StatusItem, Tab, 28 28 Table, TableColumn, TableRow, TableState, Tabs, TabsOrientation, TextEditor, TextInput, 29 29 TextInputState, Toast, ToastKind, ToastState, ToggleButton, Toolbar, ToolbarItem, Tooltip, ··· 273 273 pub list: ListViewState, 274 274 pub table: TableState, 275 275 pub tree: TreeViewState, 276 - pub toolbar_overflow: bool, 277 - pub ribbon: RibbonState, 278 276 pub menu: MenuState, 279 277 pub context_menu: MenuState, 280 278 pub menu_bar: MenuBarState, ··· 314 312 list: ListViewState::default(), 315 313 table: TableState::default(), 316 314 tree, 317 - toolbar_overflow: false, 318 - ribbon: RibbonState::default(), 319 315 menu: MenuState::default(), 320 316 context_menu: MenuState::default(), 321 317 menu_bar: MenuBarState::default(), ··· 720 716 LayoutPx::new(28.0), 721 717 LayoutPx::new(4.0), 722 718 ), 723 - &mut state.toolbar_overflow, 724 719 ); 725 720 paint.extend(response.paint); 726 721 let ribbon_toolbar_items = [ ··· 747 742 GALLERY_LABEL, 748 743 &ribbon_tabs, 749 744 id("ribbon_tab"), 750 - &mut state.ribbon, 751 745 ), 752 746 ); 753 747 paint.extend(response.paint);
+4 -3
crates/bone-ui/src/lib.rs
··· 31 31 }; 32 32 pub use strings::{Locale, PluralCategory, PluralEntry, StringKey, StringTable}; 33 33 pub use text::{ 34 - AtlasEntry, CaretMove, GlyphAtlasKey, GlyphId, MaxWidth, OutlineTessellator, SdfAtlas, 35 - SdfAtlasError, SdfAtlasParams, Selection, SelectionAction, ShapeRequest, ShapedText, Shaper, 36 - SourceByteIndex, TessellatedGlyph, TextLayout, TextPrimitive, TextRole, request_for, 34 + AtlasEntry, CaretMove, GlyphAtlasKey, GlyphId, MaskAtlas, MaskAtlasEntry, MaskAtlasError, 35 + MaskAtlasKey, MaskAtlasParams, MaxWidth, OutlineTessellator, SdfAtlas, SdfAtlasError, 36 + SdfAtlasParams, Selection, SelectionAction, ShapeRequest, ShapedText, Shaper, SourceByteIndex, 37 + TessellatedGlyph, TextLayout, TextPrimitive, TextRole, request_for, 37 38 }; 38 39 pub use theme::{ 39 40 BlurRadius, Border, CadColors, Color, ColorError, Colors, Easing, ElevationLevel,
+29 -2
crates/bone-ui/src/raster.rs
··· 10 10 use crate::layout::{LayoutPx, LayoutRect}; 11 11 use crate::strings::StringTable; 12 12 use crate::theme::{Color, SurfaceLevel, Theme}; 13 - use crate::widgets::{PaintPrim, WidgetPaint, lower_paint}; 13 + use crate::widgets::{HorizontalAlign, PaintPrim, WidgetPaint, lower_paint}; 14 14 15 15 const MARK_FONT_SCALE: f32 = 0.7; 16 16 ··· 287 287 face: role.face, 288 288 weight: role.weight, 289 289 color: *color, 290 + align: HorizontalAlign::Center, 291 + }, 292 + ); 293 + } 294 + WidgetPaint::AlignedLabel { 295 + rect, 296 + text, 297 + color, 298 + role, 299 + align, 300 + } => { 301 + painter.paint_text( 302 + canvas, 303 + *rect, 304 + text.resolve(strings), 305 + TextStyle { 306 + font_size_px: role.size.as_px_f32(), 307 + face: role.face, 308 + weight: role.weight, 309 + color: *color, 310 + align: *align, 290 311 }, 291 312 ); 292 313 } ··· 300 321 face: FontFace::Sans, 301 322 weight: FontWeight::Regular, 302 323 color: *color, 324 + align: HorizontalAlign::Center, 303 325 }, 304 326 ); 305 327 } ··· 370 392 let ry = rect.origin.y.value(); 371 393 let rw = rect.size.width.value(); 372 394 let rh = rect.size.height.value(); 373 - let start_x = rx + ((rw - visible_advance) * 0.5).max(0.0); 395 + let start_x = match style.align { 396 + HorizontalAlign::Center => rx + ((rw - visible_advance) * 0.5).max(0.0), 397 + HorizontalAlign::Start => rx, 398 + HorizontalAlign::End => rx + (rw - visible_advance).max(0.0), 399 + }; 374 400 let baseline_y = ry + (rh + metrics.cap_height) * 0.5; 375 401 let font = *self.font_for(style.face); 376 402 let mut scaler = self ··· 413 439 face: FontFace, 414 440 weight: FontWeight, 415 441 color: Color, 442 + align: HorizontalAlign, 416 443 } 417 444 418 445 fn composite_alpha_mask(
+2 -2
crates/bone-ui/src/text/mod.rs
··· 8 8 }; 9 9 pub use primitive::{TextLayout, TextPrimitive, TextRole, request_for}; 10 10 pub use raster::{ 11 - AtlasEntry, GlyphAtlasKey, OutlineTessellator, SdfAtlas, SdfAtlasError, SdfAtlasParams, 12 - TessellatedGlyph, 11 + AtlasEntry, GlyphAtlasKey, MaskAtlas, MaskAtlasEntry, MaskAtlasError, MaskAtlasKey, 12 + MaskAtlasParams, OutlineTessellator, SdfAtlas, SdfAtlasError, SdfAtlasParams, TessellatedGlyph, 13 13 }; 14 14 pub use selection::{CaretMove, Selection, SelectionAction};
+359
crates/bone-ui/src/text/raster/mask.rs
··· 1 + use std::collections::HashMap; 2 + 3 + use bone_text::{FontFace, GlyphId, load_font}; 4 + use swash::{ 5 + FontRef, 6 + scale::{Render, ScaleContext, Source, image::Image}, 7 + zeno::Format, 8 + }; 9 + use thiserror::Error; 10 + 11 + use crate::layout::{LayoutOffset, LayoutPx, LayoutSize}; 12 + 13 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] 14 + pub struct MaskAtlasKey { 15 + pub face: FontFace, 16 + pub glyph: GlyphId, 17 + pub size_px: u16, 18 + } 19 + 20 + impl MaskAtlasKey { 21 + #[must_use] 22 + pub const fn new(face: FontFace, glyph: GlyphId, size_px: u16) -> Self { 23 + Self { 24 + face, 25 + glyph, 26 + size_px, 27 + } 28 + } 29 + } 30 + 31 + #[derive(Copy, Clone, Debug, PartialEq)] 32 + pub struct MaskAtlasEntry { 33 + pub uv_min: [f32; 2], 34 + pub uv_max: [f32; 2], 35 + pub atlas_origin: [u32; 2], 36 + pub atlas_size: [u32; 2], 37 + pub bearing: LayoutOffset, 38 + pub glyph_size: LayoutSize, 39 + } 40 + 41 + #[derive(Copy, Clone, Debug, PartialEq)] 42 + pub struct MaskAtlasParams { 43 + pub atlas_extent: u32, 44 + } 45 + 46 + impl MaskAtlasParams { 47 + pub const STANDARD: Self = Self { atlas_extent: 2048 }; 48 + } 49 + 50 + #[derive(Debug, Error, PartialEq, Eq)] 51 + pub enum MaskAtlasError { 52 + #[error("glyph id {0} exceeds the swash u16 range")] 53 + GlyphOutOfRange(u32), 54 + #[error("tile {tile_width}x{tile_height} exceeds atlas extent {atlas_extent}")] 55 + TileExceedsAtlas { 56 + tile_width: u32, 57 + tile_height: u32, 58 + atlas_extent: u32, 59 + }, 60 + #[error("atlas full: no shelf space for {tile_width}x{tile_height} tile")] 61 + Full { tile_width: u32, tile_height: u32 }, 62 + } 63 + 64 + pub struct MaskAtlas { 65 + params: MaskAtlasParams, 66 + pixels: Vec<u8>, 67 + sans: FontRef<'static>, 68 + mono: FontRef<'static>, 69 + scale_ctx: ScaleContext, 70 + entries: HashMap<MaskAtlasKey, MaskAtlasEntry>, 71 + packer: ShelfPacker, 72 + version: u64, 73 + } 74 + 75 + impl MaskAtlas { 76 + #[must_use] 77 + pub fn new(params: MaskAtlasParams) -> Self { 78 + let extent = params.atlas_extent as usize; 79 + Self { 80 + params, 81 + pixels: vec![0u8; extent * extent], 82 + sans: load_font(FontFace::Sans), 83 + mono: load_font(FontFace::Mono), 84 + scale_ctx: ScaleContext::new(), 85 + entries: HashMap::new(), 86 + packer: ShelfPacker::new(params.atlas_extent), 87 + version: 0, 88 + } 89 + } 90 + 91 + #[must_use] 92 + pub fn extent(&self) -> u32 { 93 + self.params.atlas_extent 94 + } 95 + 96 + #[must_use] 97 + pub fn pixels(&self) -> &[u8] { 98 + &self.pixels 99 + } 100 + 101 + #[must_use] 102 + pub fn version(&self) -> u64 { 103 + self.version 104 + } 105 + 106 + #[must_use] 107 + pub fn entries_len(&self) -> usize { 108 + self.entries.len() 109 + } 110 + 111 + pub fn ensure(&mut self, key: MaskAtlasKey) -> Result<MaskAtlasEntry, MaskAtlasError> { 112 + if let Some(entry) = self.entries.get(&key) { 113 + return Ok(*entry); 114 + } 115 + let glyph_id_u16 = u16::try_from(key.glyph.raw()) 116 + .map_err(|_| MaskAtlasError::GlyphOutOfRange(key.glyph.raw()))?; 117 + let font = match key.face { 118 + FontFace::Sans => self.sans, 119 + FontFace::Mono => self.mono, 120 + }; 121 + let tile = rasterise_glyph_alpha(&font, glyph_id_u16, key.size_px, &mut self.scale_ctx); 122 + let placed = self 123 + .packer 124 + .place(tile.width.max(1), tile.height.max(1)) 125 + .ok_or(MaskAtlasError::Full { 126 + tile_width: tile.width, 127 + tile_height: tile.height, 128 + })?; 129 + if placed.width > self.params.atlas_extent || placed.height > self.params.atlas_extent { 130 + return Err(MaskAtlasError::TileExceedsAtlas { 131 + tile_width: placed.width, 132 + tile_height: placed.height, 133 + atlas_extent: self.params.atlas_extent, 134 + }); 135 + } 136 + blit_tile(&mut self.pixels, self.params.atlas_extent, placed, &tile); 137 + let entry = atlas_entry(self.params, placed, &tile); 138 + self.entries.insert(key, entry); 139 + self.version = self.version.wrapping_add(1); 140 + Ok(entry) 141 + } 142 + } 143 + 144 + impl core::fmt::Debug for MaskAtlas { 145 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 146 + f.debug_struct("MaskAtlas") 147 + .field("entries", &self.entries.len()) 148 + .field("extent", &self.params.atlas_extent) 149 + .field("version", &self.version) 150 + .finish_non_exhaustive() 151 + } 152 + } 153 + 154 + struct GlyphTile { 155 + pixels: Vec<u8>, 156 + width: u32, 157 + height: u32, 158 + bearing_left: f32, 159 + bearing_top: f32, 160 + } 161 + 162 + fn rasterise_glyph_alpha( 163 + font: &FontRef<'_>, 164 + glyph: u16, 165 + size_px: u16, 166 + scale_ctx: &mut ScaleContext, 167 + ) -> GlyphTile { 168 + let mut scaler = scale_ctx 169 + .builder(*font) 170 + .size(f32::from(size_px)) 171 + .hint(true) 172 + .build(); 173 + let mut image = Image::new(); 174 + if !Render::new(&[Source::Outline]) 175 + .format(Format::Alpha) 176 + .render_into(&mut scaler, glyph, &mut image) 177 + { 178 + return empty_tile(); 179 + } 180 + let placement = image.placement; 181 + #[allow( 182 + clippy::cast_precision_loss, 183 + reason = "glyph bearings stay well within f32 mantissa" 184 + )] 185 + let bearing_left = placement.left as f32; 186 + #[allow( 187 + clippy::cast_precision_loss, 188 + reason = "glyph bearings stay well within f32 mantissa" 189 + )] 190 + let bearing_top = placement.top as f32; 191 + GlyphTile { 192 + pixels: image.data, 193 + width: placement.width, 194 + height: placement.height, 195 + bearing_left, 196 + bearing_top, 197 + } 198 + } 199 + 200 + fn empty_tile() -> GlyphTile { 201 + GlyphTile { 202 + pixels: Vec::new(), 203 + width: 0, 204 + height: 0, 205 + bearing_left: 0.0, 206 + bearing_top: 0.0, 207 + } 208 + } 209 + 210 + #[derive(Copy, Clone, Debug, PartialEq)] 211 + struct PlacedTile { 212 + x: u32, 213 + y: u32, 214 + width: u32, 215 + height: u32, 216 + } 217 + 218 + struct ShelfPacker { 219 + extent: u32, 220 + cursor_x: u32, 221 + shelf_y: u32, 222 + shelf_height: u32, 223 + } 224 + 225 + impl ShelfPacker { 226 + fn new(extent: u32) -> Self { 227 + Self { 228 + extent, 229 + cursor_x: 0, 230 + shelf_y: 0, 231 + shelf_height: 0, 232 + } 233 + } 234 + 235 + fn place(&mut self, tile_w: u32, tile_h: u32) -> Option<PlacedTile> { 236 + if tile_w > self.extent || tile_h > self.extent { 237 + return None; 238 + } 239 + if self.cursor_x.saturating_add(tile_w) > self.extent { 240 + self.shelf_y = self.shelf_y.checked_add(self.shelf_height)?; 241 + self.cursor_x = 0; 242 + self.shelf_height = 0; 243 + } 244 + if self.shelf_y.saturating_add(tile_h) > self.extent { 245 + return None; 246 + } 247 + let placed = PlacedTile { 248 + x: self.cursor_x, 249 + y: self.shelf_y, 250 + width: tile_w, 251 + height: tile_h, 252 + }; 253 + self.cursor_x += tile_w; 254 + if tile_h > self.shelf_height { 255 + self.shelf_height = tile_h; 256 + } 257 + Some(placed) 258 + } 259 + } 260 + 261 + fn blit_tile(dst: &mut [u8], extent: u32, placed: PlacedTile, tile: &GlyphTile) { 262 + if tile.pixels.is_empty() { 263 + return; 264 + } 265 + (0..tile.height).for_each(|row| { 266 + let src_off = (row as usize) * (tile.width as usize); 267 + let dst_off = ((placed.y + row) as usize) * (extent as usize) + placed.x as usize; 268 + let len = tile.width as usize; 269 + dst[dst_off..dst_off + len].copy_from_slice(&tile.pixels[src_off..src_off + len]); 270 + }); 271 + } 272 + 273 + fn atlas_entry( 274 + params: MaskAtlasParams, 275 + placed: PlacedTile, 276 + tile: &GlyphTile, 277 + ) -> MaskAtlasEntry { 278 + #[allow(clippy::cast_precision_loss, reason = "extent fits f32 mantissa")] 279 + let extent = params.atlas_extent as f32; 280 + #[allow(clippy::cast_precision_loss, reason = "placed coords fit f32 mantissa")] 281 + let uv_min = [placed.x as f32 / extent, placed.y as f32 / extent]; 282 + #[allow(clippy::cast_precision_loss, reason = "placed coords fit f32 mantissa")] 283 + let uv_max = [ 284 + (placed.x + placed.width) as f32 / extent, 285 + (placed.y + placed.height) as f32 / extent, 286 + ]; 287 + let bearing = LayoutOffset::new( 288 + LayoutPx::saturating(tile.bearing_left), 289 + LayoutPx::saturating(tile.bearing_top), 290 + ); 291 + #[allow(clippy::cast_precision_loss, reason = "tile size fits f32 mantissa")] 292 + let glyph_size = LayoutSize::new( 293 + LayoutPx::saturating_nonneg(tile.width as f32), 294 + LayoutPx::saturating_nonneg(tile.height as f32), 295 + ); 296 + MaskAtlasEntry { 297 + uv_min, 298 + uv_max, 299 + atlas_origin: [placed.x, placed.y], 300 + atlas_size: [placed.width, placed.height], 301 + bearing, 302 + glyph_size, 303 + } 304 + } 305 + 306 + #[cfg(test)] 307 + mod tests { 308 + use super::{MaskAtlas, MaskAtlasKey, MaskAtlasParams}; 309 + use bone_text::{FontFace, load_font}; 310 + 311 + fn glyph_for(face: FontFace, ch: char) -> bone_text::GlyphId { 312 + let font = load_font(face); 313 + let glyph = font.charmap().map(ch); 314 + bone_text::GlyphId::new(u32::from(glyph)) 315 + } 316 + 317 + #[test] 318 + fn fresh_atlas_has_zero_entries() { 319 + let atlas = MaskAtlas::new(MaskAtlasParams::STANDARD); 320 + assert_eq!(atlas.entries_len(), 0); 321 + assert_eq!(atlas.version(), 0); 322 + } 323 + 324 + fn ensure_or_panic(atlas: &mut MaskAtlas, key: MaskAtlasKey) -> super::MaskAtlasEntry { 325 + let Ok(entry) = atlas.ensure(key) else { 326 + panic!("ensure failed for {key:?}"); 327 + }; 328 + entry 329 + } 330 + 331 + #[test] 332 + fn ensure_inserts_entry_and_bumps_version() { 333 + let mut atlas = MaskAtlas::new(MaskAtlasParams::STANDARD); 334 + let key = MaskAtlasKey::new(FontFace::Sans, glyph_for(FontFace::Sans, 'a'), 12); 335 + let _ = ensure_or_panic(&mut atlas, key); 336 + assert_eq!(atlas.entries_len(), 1); 337 + assert_eq!(atlas.version(), 1); 338 + } 339 + 340 + #[test] 341 + fn ensure_idempotent_for_same_key() { 342 + let mut atlas = MaskAtlas::new(MaskAtlasParams::STANDARD); 343 + let key = MaskAtlasKey::new(FontFace::Sans, glyph_for(FontFace::Sans, 'a'), 12); 344 + let first = ensure_or_panic(&mut atlas, key); 345 + let second = ensure_or_panic(&mut atlas, key); 346 + assert_eq!(first, second); 347 + assert_eq!(atlas.version(), 1); 348 + } 349 + 350 + #[test] 351 + fn different_sizes_produce_distinct_entries() { 352 + let mut atlas = MaskAtlas::new(MaskAtlasParams::STANDARD); 353 + let small = MaskAtlasKey::new(FontFace::Sans, glyph_for(FontFace::Sans, 'a'), 12); 354 + let large = MaskAtlasKey::new(FontFace::Sans, glyph_for(FontFace::Sans, 'a'), 48); 355 + let small_entry = ensure_or_panic(&mut atlas, small); 356 + let large_entry = ensure_or_panic(&mut atlas, large); 357 + assert!(large_entry.glyph_size.width.value() > small_entry.glyph_size.width.value()); 358 + } 359 + }
+2
crates/bone-ui/src/text/raster/mod.rs
··· 1 + mod mask; 1 2 mod outline; 2 3 mod sdf; 3 4 5 + pub use mask::{MaskAtlas, MaskAtlasEntry, MaskAtlasError, MaskAtlasKey, MaskAtlasParams}; 4 6 pub use outline::{OutlineTessellator, TessellatedGlyph}; 5 7 pub use sdf::{AtlasEntry, GlyphAtlasKey, SdfAtlas, SdfAtlasError, SdfAtlasParams};
+2 -2
crates/bone-ui/src/text/raster/sdf.rs
··· 15 15 pub struct SdfBaseSize(u32); 16 16 17 17 impl SdfBaseSize { 18 - pub const DEFAULT: Self = Self(32); 18 + pub const DEFAULT: Self = Self(64); 19 19 20 20 #[must_use] 21 21 pub const fn new(value: u32) -> Option<Self> { ··· 36 36 pub struct SdfSpread(u32); 37 37 38 38 impl SdfSpread { 39 - pub const DEFAULT: Self = Self(4); 39 + pub const DEFAULT: Self = Self(8); 40 40 41 41 #[must_use] 42 42 pub const fn new(value: u32) -> Option<Self> {
+2
crates/bone-ui/tests/gallery_snapshot.rs
··· 273 273 let (rect, anchor) = match p { 274 274 WidgetPaint::Surface { rect, .. } 275 275 | WidgetPaint::Label { rect, .. } 276 + | WidgetPaint::AlignedLabel { rect, .. } 276 277 | WidgetPaint::Mark { rect, .. } 277 278 | WidgetPaint::FocusRing { rect, .. } 278 279 | WidgetPaint::SelectionHighlight { rect, .. } ··· 299 300 match p { 300 301 WidgetPaint::Surface { .. } => "Surface", 301 302 WidgetPaint::Label { .. } => "Label", 303 + WidgetPaint::AlignedLabel { .. } => "AlignedLabel", 302 304 WidgetPaint::Mark { .. } => "Mark", 303 305 WidgetPaint::FocusRing { .. } => "FocusRing", 304 306 WidgetPaint::SelectionHighlight { .. } => "SelectionHighlight",