Another project
0

Configure Feed

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

at main 20 kB View raw
1use std::borrow::Cow; 2 3use bone_render::{ChromeInstance, ConvexInstance, IconInstance, SdfGlyphInstance, StrokeInstance}; 4use bone_text::{FontFace, FontWeight, ShapeRequest, ShapedLine, ShapedText, Shaper}; 5use bone_types::IconTile; 6use bone_ui::layout::LayoutRect; 7use bone_ui::strings::StringTable; 8use bone_ui::text::{MaskAtlas, MaskAtlasKey}; 9use bone_ui::theme::{Border, Color, StrokeWidth, Theme}; 10use bone_ui::widgets::{ 11 ConvexPoly, HorizontalAlign, IconTint, PaintPrim, PolyPath, WidgetPaint, lower_paint, 12}; 13use swash::FontRef; 14 15const MARK_FONT_SCALE: f32 = 0.7; 16 17const _: () = assert!(bone_ui::widgets::MAX_CONVEX_VERTS == bone_render::MAX_PLANES); 18const _: () = assert!(bone_ui::widgets::MAX_PATH_POINTS < bone_render::MAX_STROKE_POINTS); 19 20#[derive(Copy, Clone, Debug, PartialEq, Eq)] 21pub enum SpanVertical { 22 Baseline, 23 RectCenter, 24} 25 26#[derive(Clone, Debug)] 27pub struct ChromeTextSpan<'a> { 28 pub rect_px: [f32; 4], 29 pub text: &'a str, 30 pub color_premul_rgba: [f32; 4], 31 pub font_size_px: f32, 32 pub face: FontFace, 33 pub weight: FontWeight, 34 pub vertical: SpanVertical, 35 pub horizontal: HorizontalAlign, 36} 37 38#[must_use] 39pub fn paint_to_instances(theme: &Theme, paints: &[WidgetPaint]) -> Vec<ChromeInstance> { 40 paints 41 .iter() 42 .filter(|p| { 43 !matches!( 44 p, 45 WidgetPaint::Label { .. } 46 | WidgetPaint::AlignedLabel { .. } 47 | WidgetPaint::Mark { .. } 48 | WidgetPaint::Icon { .. } 49 | WidgetPaint::ConvexFill { .. } 50 | WidgetPaint::Stroke { .. } 51 ) 52 }) 53 .map(|p| prim_to_instance(&lower_paint(theme, p))) 54 .collect() 55} 56 57#[must_use] 58pub 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 72const DISABLED_TINT_VALUE: f32 = 0.75; 73const DISABLED_TINT_ALPHA: f32 = 0.5; 74 75fn 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 86fn 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 91} 92 93#[must_use] 94pub fn paint_to_convex_instances(paints: &[WidgetPaint]) -> Vec<ConvexInstance> { 95 paints 96 .iter() 97 .filter_map(|p| match p { 98 WidgetPaint::ConvexFill { poly, fill, border } => { 99 convex_to_instance(poly, *fill, *border) 100 } 101 _ => None, 102 }) 103 .collect() 104} 105 106#[must_use] 107pub fn paint_to_stroke_instances(paints: &[WidgetPaint]) -> Vec<StrokeInstance> { 108 paints 109 .iter() 110 .filter_map(|p| match p { 111 WidgetPaint::Stroke { path, width, color } => stroke_to_instance(path, *width, *color), 112 _ => None, 113 }) 114 .collect() 115} 116 117fn convex_to_instance( 118 poly: &ConvexPoly, 119 fill: Color, 120 border: Option<Border>, 121) -> Option<ConvexInstance> { 122 let bounds = poly.bounds(); 123 let rect = [ 124 bounds.origin.x.value() - 1.0, 125 bounds.origin.y.value() - 1.0, 126 bounds.size.width.value() + 2.0, 127 bounds.size.height.value() + 2.0, 128 ]; 129 let (border_premul, border_width) = border.map_or(([0.0; 4], 0.0), |b| { 130 (b.color.linear_rgba_premul(), b.width.value_px()) 131 }); 132 ConvexInstance::new( 133 rect, 134 fill.linear_rgba_premul(), 135 border_premul, 136 border_width, 137 &poly.edge_planes(), 138 ) 139} 140 141fn stroke_to_instance(path: &PolyPath, width: StrokeWidth, color: Color) -> Option<StrokeInstance> { 142 let half = width.value_px().max(1.0) * 0.5; 143 let open: Vec<[f32; 2]> = path 144 .points() 145 .iter() 146 .map(|p| [p.x.value(), p.y.value()]) 147 .collect(); 148 let closing = path.is_closed().then(|| open.first().copied()).flatten(); 149 let points: Vec<[f32; 2]> = open.into_iter().chain(closing).collect(); 150 StrokeInstance::new(&points, color.linear_rgba_premul(), half) 151} 152 153#[must_use] 154pub fn paint_to_text_spans<'a>( 155 paints: &'a [WidgetPaint], 156 strings: &'a StringTable, 157) -> Vec<ChromeTextSpan<'a>> { 158 paints 159 .iter() 160 .filter_map(|p| match p { 161 WidgetPaint::Label { 162 rect, 163 text, 164 color, 165 role, 166 } => Some(ChromeTextSpan { 167 rect_px: rect_to_xywh(*rect), 168 text: text.resolve(strings), 169 color_premul_rgba: color.linear_rgba_premul(), 170 font_size_px: role.size.as_px_f32(), 171 face: role.face, 172 weight: role.weight, 173 vertical: SpanVertical::Baseline, 174 horizontal: HorizontalAlign::Center, 175 }), 176 WidgetPaint::AlignedLabel { 177 rect, 178 text, 179 color, 180 role, 181 align, 182 } => Some(ChromeTextSpan { 183 rect_px: rect_to_xywh(*rect), 184 text: text.resolve(strings), 185 color_premul_rgba: color.linear_rgba_premul(), 186 font_size_px: role.size.as_px_f32(), 187 face: role.face, 188 weight: role.weight, 189 vertical: SpanVertical::Baseline, 190 horizontal: *align, 191 }), 192 WidgetPaint::Mark { rect, kind, color } => Some(ChromeTextSpan { 193 rect_px: rect_to_xywh(*rect), 194 text: kind.glyph(), 195 color_premul_rgba: color.linear_rgba_premul(), 196 font_size_px: rect.size.height.value() * MARK_FONT_SCALE, 197 face: FontFace::Sans, 198 weight: FontWeight::Regular, 199 vertical: SpanVertical::RectCenter, 200 horizontal: HorizontalAlign::Center, 201 }), 202 _ => None, 203 }) 204 .collect() 205} 206 207struct ChromeFonts<'a> { 208 sans_font: &'a FontRef<'static>, 209 mono_font: &'a FontRef<'static>, 210} 211 212impl<'a> ChromeFonts<'a> { 213 fn for_face(&self, face: FontFace) -> &'a FontRef<'static> { 214 match face { 215 FontFace::Sans => self.sans_font, 216 FontFace::Mono => self.mono_font, 217 } 218 } 219} 220 221#[must_use] 222pub fn build_glyph_instances( 223 spans: &[ChromeTextSpan<'_>], 224 atlas: &mut MaskAtlas, 225 shaper: &mut Shaper, 226 sans_font: &FontRef<'static>, 227 mono_font: &FontRef<'static>, 228) -> Vec<SdfGlyphInstance> { 229 let fonts = ChromeFonts { 230 sans_font, 231 mono_font, 232 }; 233 spans.iter().fold(Vec::new(), |mut acc, item| { 234 push_span_instances(&mut acc, item, atlas, shaper, &fonts); 235 acc 236 }) 237} 238 239const ELLIPSIS: &str = "\u{2026}"; 240 241fn push_span_instances( 242 out: &mut Vec<SdfGlyphInstance>, 243 item: &ChromeTextSpan<'_>, 244 atlas: &mut MaskAtlas, 245 shaper: &mut Shaper, 246 fonts: &ChromeFonts<'_>, 247) { 248 let request = ShapeRequest { 249 face: item.face, 250 size_px: item.font_size_px, 251 weight: item.weight, 252 line_height_px: 0.0, 253 letter_spacing_px: 0.0, 254 max_width: None, 255 }; 256 let [rx, ry, rw, rh] = item.rect_px; 257 let (_text_for_paint, layout) = fit_with_ellipsis(item.text, rw, shaper, request); 258 let metrics = fonts 259 .for_face(item.face) 260 .metrics(&[]) 261 .scale(item.font_size_px); 262 let visible_advance = layout 263 .lines 264 .first() 265 .map_or(0.0, ShapedLine::visible_advance_px); 266 let start_x = match item.horizontal { 267 HorizontalAlign::Center => rx + ((rw - visible_advance) * 0.5).max(0.0), 268 HorizontalAlign::Start => rx, 269 HorizontalAlign::End => rx + (rw - visible_advance).max(0.0), 270 }; 271 let line = LineLayout { 272 face: item.face, 273 size_px: item.font_size_px, 274 color: item.color_premul_rgba, 275 start_x, 276 baseline_y: ry + (rh + metrics.ascent - metrics.descent) * 0.5, 277 vertical: item.vertical, 278 rect_center_y: ry + rh * 0.5, 279 }; 280 layout 281 .lines 282 .iter() 283 .flat_map(|l| l.runs.iter()) 284 .for_each(|run| { 285 let run_origin = run.origin_x_px; 286 run.glyphs.iter().for_each(|g| { 287 if let Some(instance) = build_instance(atlas, &line, g.id, run_origin + g.x_px) { 288 out.push(instance); 289 } 290 }); 291 }); 292} 293 294fn shape_advance(shaper: &mut Shaper, text: &str, request: ShapeRequest) -> f32 { 295 shaper 296 .shape(text, request) 297 .lines 298 .first() 299 .map_or(0.0, ShapedLine::visible_advance_px) 300} 301 302fn fit_with_ellipsis<'a>( 303 text: &'a str, 304 max_width_px: f32, 305 shaper: &mut Shaper, 306 request: ShapeRequest, 307) -> (Cow<'a, str>, ShapedText) { 308 let initial = shaper.shape(text, request); 309 let visible = initial 310 .lines 311 .first() 312 .map_or(0.0, ShapedLine::visible_advance_px); 313 if max_width_px <= 0.0 || text.is_empty() || visible <= max_width_px { 314 return (Cow::Borrowed(text), initial); 315 } 316 let ellipsis_advance = shape_advance(shaper, ELLIPSIS, request); 317 let target = (max_width_px - ellipsis_advance).max(0.0); 318 if target <= 0.0 { 319 let layout = shaper.shape(ELLIPSIS, request); 320 return (Cow::Owned(ELLIPSIS.to_owned()), layout); 321 } 322 let char_offsets: Vec<usize> = text 323 .char_indices() 324 .map(|(i, _)| i) 325 .chain(core::iter::once(text.len())) 326 .collect(); 327 let n_chars = char_offsets.len().saturating_sub(1); 328 let mut lo = 0usize; 329 let mut hi = n_chars; 330 while lo < hi { 331 let mid = (lo + hi).div_ceil(2); 332 let prefix = &text[..char_offsets[mid]]; 333 let prefix_advance = shape_advance(shaper, prefix, request); 334 if prefix_advance <= target { 335 lo = mid; 336 } else { 337 hi = mid.saturating_sub(1); 338 } 339 } 340 let prefix = &text[..char_offsets[lo]]; 341 let truncated = format!("{prefix}{ELLIPSIS}"); 342 let layout = shaper.shape(truncated.as_str(), request); 343 (Cow::Owned(truncated), layout) 344} 345 346#[derive(Copy, Clone)] 347struct LineLayout { 348 face: FontFace, 349 size_px: f32, 350 color: [f32; 4], 351 start_x: f32, 352 baseline_y: f32, 353 vertical: SpanVertical, 354 rect_center_y: f32, 355} 356 357fn build_instance( 358 atlas: &mut MaskAtlas, 359 line: &LineLayout, 360 glyph_id: bone_text::GlyphId, 361 advance_x: f32, 362) -> Option<SdfGlyphInstance> { 363 let size_px = atlas_size_px(line.size_px); 364 let key = MaskAtlasKey::new(line.face, glyph_id, size_px); 365 let entry = match atlas.ensure(key) { 366 Ok(e) => e, 367 Err(e) => { 368 tracing::warn!(error = %e, face = ?line.face, "glyph atlas ensure failed"); 369 return None; 370 } 371 }; 372 let extent = entry.glyph_size; 373 let bearing = entry.bearing; 374 let glyph_top_y = match line.vertical { 375 SpanVertical::Baseline => line.baseline_y - bearing.dy.value(), 376 SpanVertical::RectCenter => line.rect_center_y - extent.height.value() * 0.5, 377 }; 378 let pixel_x = (line.start_x + advance_x + bearing.dx.value()).round(); 379 let pixel_y = glyph_top_y.round(); 380 Some(SdfGlyphInstance { 381 rect_xywh_px: [ 382 pixel_x, 383 pixel_y, 384 extent.width.value(), 385 extent.height.value(), 386 ], 387 uv_min: entry.uv_min, 388 uv_max: entry.uv_max, 389 color_premul_rgba: line.color, 390 }) 391} 392 393fn prim_to_instance(prim: &PaintPrim) -> ChromeInstance { 394 let (border_color, thickness_px) = prim 395 .border 396 .map_or((Color::TRANSPARENT, 0.0), |b| (b.color, b.width.value_px())); 397 ChromeInstance::new( 398 rect_to_xywh(prim.rect), 399 prim.fill.linear_rgba_premul(), 400 border_color.linear_rgba_premul(), 401 [thickness_px, prim.radius.value_px()], 402 ) 403} 404 405fn atlas_size_px(font_size_px: f32) -> u16 { 406 #[allow( 407 clippy::cast_possible_truncation, 408 clippy::cast_sign_loss, 409 reason = "chrome font sizes are well within u16 after rounding" 410 )] 411 let rounded = font_size_px.round().clamp(1.0, f32::from(u16::MAX)) as u16; 412 rounded 413} 414 415fn rect_to_xywh(rect: LayoutRect) -> [f32; 4] { 416 [ 417 rect.origin.x.value(), 418 rect.origin.y.value(), 419 rect.size.width.value(), 420 rect.size.height.value(), 421 ] 422} 423 424#[cfg(test)] 425mod tests { 426 use super::*; 427 use bone_ui::layout::{LayoutPos, LayoutPx, LayoutSize}; 428 use bone_ui::strings::StringKey; 429 use bone_ui::widgets::{GlyphMark, LabelText}; 430 431 fn rect(x: f32, y: f32, w: f32, h: f32) -> LayoutRect { 432 LayoutRect::new( 433 LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), 434 LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h)), 435 ) 436 } 437 438 fn label(text: LabelText, theme: &Theme) -> WidgetPaint { 439 WidgetPaint::Label { 440 rect: rect(10.0, 20.0, 80.0, 14.0), 441 text, 442 color: theme.colors.text_secondary(), 443 role: theme.typography.caption, 444 } 445 } 446 447 fn surface(theme: &Theme) -> WidgetPaint { 448 WidgetPaint::Surface { 449 rect: rect(0.0, 0.0, 100.0, 100.0), 450 fill: theme.colors.surface(theme.elevation.level1.surface), 451 border: None, 452 radius: theme.radius.none, 453 elevation: None, 454 } 455 } 456 457 #[test] 458 fn rect_path_drops_label_paints() { 459 let theme = Theme::light(); 460 let paints = vec![ 461 surface(&theme), 462 label(LabelText::Owned("hello".to_owned()), &theme), 463 ]; 464 let instances = paint_to_instances(&theme, &paints); 465 assert_eq!(instances.len(), 1); 466 } 467 468 #[test] 469 fn text_span_path_skips_non_label_paints() { 470 let theme = Theme::light(); 471 let paints = vec![ 472 surface(&theme), 473 label(LabelText::Owned("hello".to_owned()), &theme), 474 ]; 475 let spans = paint_to_text_spans(&paints, StringTable::empty()); 476 assert_eq!(spans.len(), 1); 477 assert_eq!(spans[0].text, "hello"); 478 } 479 480 #[test] 481 fn text_span_resolves_string_keys_through_table() { 482 let theme = Theme::light(); 483 let key = StringKey::new("test.label"); 484 let paints = vec![label(LabelText::Key(key), &theme)]; 485 let table = StringTable::from_entries([(key, "Resolved".to_owned())]); 486 let spans = paint_to_text_spans(&paints, &table); 487 assert_eq!(spans.len(), 1); 488 assert_eq!(spans[0].text, "Resolved"); 489 } 490 491 #[test] 492 fn text_span_carries_face_and_weight_from_role() { 493 let theme = Theme::light(); 494 let paints = vec![label(LabelText::Owned("x".to_owned()), &theme)]; 495 let spans = paint_to_text_spans(&paints, StringTable::empty()); 496 assert_eq!(spans.len(), 1); 497 assert_eq!(spans[0].face, theme.typography.caption.face); 498 assert_eq!(spans[0].weight, theme.typography.caption.weight); 499 assert!((spans[0].font_size_px - theme.typography.caption.size.as_px_f32()).abs() < 1e-6); 500 } 501 502 #[test] 503 fn build_glyph_instances_emits_one_quad_per_visible_glyph() { 504 let theme = Theme::light(); 505 let paints = vec![label(LabelText::Owned("Hi".to_owned()), &theme)]; 506 let spans = paint_to_text_spans(&paints, StringTable::empty()); 507 let mut atlas = MaskAtlas::new(bone_ui::MaskAtlasParams::STANDARD); 508 let mut shaper = Shaper::new(); 509 let sans_font = bone_text::load_font(FontFace::Sans); 510 let mono_font = bone_text::load_font(FontFace::Mono); 511 let instances = 512 build_glyph_instances(&spans, &mut atlas, &mut shaper, &sans_font, &mono_font); 513 assert_eq!(instances.len(), 2, "Hi has two glyphs"); 514 } 515 516 fn mark(kind: GlyphMark, theme: &Theme) -> WidgetPaint { 517 WidgetPaint::Mark { 518 rect: rect(40.0, 60.0, 16.0, 16.0), 519 kind, 520 color: theme.colors.text_secondary(), 521 } 522 } 523 524 #[test] 525 fn rect_path_drops_mark_paints() { 526 let theme = Theme::light(); 527 let paints = vec![surface(&theme), mark(GlyphMark::Ellipsis, &theme)]; 528 let instances = paint_to_instances(&theme, &paints); 529 assert_eq!( 530 instances.len(), 531 1, 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" 588 ); 589 } 590 591 #[test] 592 fn text_span_path_includes_mark_glyphs() { 593 let theme = Theme::light(); 594 let paints = vec![mark(GlyphMark::Ellipsis, &theme)]; 595 let spans = paint_to_text_spans(&paints, StringTable::empty()); 596 assert_eq!(spans.len(), 1); 597 assert_eq!(spans[0].text, "\u{2026}"); 598 } 599 600 #[test] 601 fn build_glyph_instances_renders_mark_glyphs_via_atlas() { 602 let theme = Theme::light(); 603 let paints = vec![mark(GlyphMark::Ellipsis, &theme)]; 604 let spans = paint_to_text_spans(&paints, StringTable::empty()); 605 let mut atlas = MaskAtlas::new(bone_ui::MaskAtlasParams::STANDARD); 606 let mut shaper = Shaper::new(); 607 let sans_font = bone_text::load_font(FontFace::Sans); 608 let mono_font = bone_text::load_font(FontFace::Mono); 609 let instances = 610 build_glyph_instances(&spans, &mut atlas, &mut shaper, &sans_font, &mono_font); 611 assert_eq!( 612 instances.len(), 613 1, 614 "Ellipsis is one BMP codepoint, expected one quad" 615 ); 616 } 617 618 #[test] 619 fn build_glyph_instances_is_empty_for_no_spans() { 620 let mut atlas = MaskAtlas::new(bone_ui::MaskAtlasParams::STANDARD); 621 let mut shaper = Shaper::new(); 622 let sans_font = bone_text::load_font(FontFace::Sans); 623 let mono_font = bone_text::load_font(FontFace::Mono); 624 let instances = build_glyph_instances(&[], &mut atlas, &mut shaper, &sans_font, &mono_font); 625 assert!(instances.is_empty()); 626 } 627}