Another project
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}