Another project
0

Configure Feed

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

feat(ui,app): widget marks thru glyph painter

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

author
Lewis
date (May 11, 2026, 8:23 PM +0300) commit 0ebff415 parent c99beeed change-id vmnukvpy
+370 -37
+59 -2
crates/bone-app/src/chrome.rs
··· 7 7 use bone_ui::widgets::{PaintPrim, WidgetPaint, lower_paint}; 8 8 use swash::FontRef; 9 9 10 + const MARK_FONT_SCALE: f32 = 0.7; 11 + 10 12 #[derive(Clone, Debug)] 11 13 pub struct ChromeTextSpan<'a> { 12 14 pub rect_px: [f32; 4], ··· 21 23 pub fn paint_to_instances(theme: &Theme, paints: &[WidgetPaint]) -> Vec<ChromeInstance> { 22 24 paints 23 25 .iter() 24 - .filter(|p| !matches!(p, WidgetPaint::Label { .. })) 26 + .filter(|p| !matches!(p, WidgetPaint::Label { .. } | WidgetPaint::Mark { .. })) 25 27 .map(|p| prim_to_instance(&lower_paint(theme, p))) 26 28 .collect() 27 29 } ··· 46 48 font_size_px: role.size.as_px_f32(), 47 49 face: role.face, 48 50 weight: role.weight, 51 + }), 52 + WidgetPaint::Mark { rect, kind, color } => Some(ChromeTextSpan { 53 + rect_px: rect_to_xywh(*rect), 54 + text: kind.glyph(), 55 + color_premul_rgba: color.linear_rgba_premul(), 56 + font_size_px: rect.size.height.value() * MARK_FONT_SCALE, 57 + face: FontFace::Sans, 58 + weight: FontWeight::Regular, 49 59 }), 50 60 _ => None, 51 61 }) ··· 196 206 use super::*; 197 207 use bone_ui::layout::{LayoutPos, LayoutPx, LayoutSize}; 198 208 use bone_ui::strings::StringKey; 199 - use bone_ui::widgets::LabelText; 209 + use bone_ui::widgets::{GlyphMark, LabelText}; 200 210 201 211 fn rect(x: f32, y: f32, w: f32, h: f32) -> LayoutRect { 202 212 LayoutRect::new( ··· 281 291 let instances = 282 292 build_glyph_instances(&spans, &mut atlas, &mut shaper, &sans_font, &mono_font); 283 293 assert_eq!(instances.len(), 2, "Hi has two glyphs"); 294 + } 295 + 296 + fn mark(kind: GlyphMark, theme: &Theme) -> WidgetPaint { 297 + WidgetPaint::Mark { 298 + rect: rect(40.0, 60.0, 16.0, 16.0), 299 + kind, 300 + color: theme.colors.text_secondary(), 301 + } 302 + } 303 + 304 + #[test] 305 + fn rect_path_drops_mark_paints() { 306 + let theme = Theme::light(); 307 + let paints = vec![surface(&theme), mark(GlyphMark::Ellipsis, &theme)]; 308 + let instances = paint_to_instances(&theme, &paints); 309 + assert_eq!( 310 + instances.len(), 311 + 1, 312 + "Mark must not render as a placeholder square" 313 + ); 314 + } 315 + 316 + #[test] 317 + fn text_span_path_includes_mark_glyphs() { 318 + let theme = Theme::light(); 319 + let paints = vec![mark(GlyphMark::Ellipsis, &theme)]; 320 + let spans = paint_to_text_spans(&paints, StringTable::empty()); 321 + assert_eq!(spans.len(), 1); 322 + assert_eq!(spans[0].text, "\u{2026}"); 323 + } 324 + 325 + #[test] 326 + fn build_glyph_instances_renders_mark_glyphs_via_atlas() { 327 + let theme = Theme::light(); 328 + let paints = vec![mark(GlyphMark::Ellipsis, &theme)]; 329 + let spans = paint_to_text_spans(&paints, StringTable::empty()); 330 + let mut atlas = SdfAtlas::new(bone_ui::SdfAtlasParams::STANDARD); 331 + let mut shaper = Shaper::new(); 332 + let sans_font = bone_text::load_font(FontFace::Sans); 333 + let mono_font = bone_text::load_font(FontFace::Mono); 334 + let instances = 335 + build_glyph_instances(&spans, &mut atlas, &mut shaper, &sans_font, &mono_font); 336 + assert_eq!( 337 + instances.len(), 338 + 1, 339 + "Ellipsis is one BMP codepoint, expected one quad" 340 + ); 284 341 } 285 342 286 343 #[test]
+242 -7
crates/bone-ui/src/raster.rs
··· 1 1 use std::io::Cursor; 2 2 3 + use bone_text::{FontFace, FontWeight, ShapeRequest, ShapedLine, Shaper, load_font}; 4 + use swash::{ 5 + FontRef, 6 + scale::{Render, ScaleContext, Source, image::Image}, 7 + zeno::Format, 8 + }; 9 + 3 10 use crate::layout::{LayoutPx, LayoutRect}; 11 + use crate::strings::StringTable; 4 12 use crate::theme::{Color, SurfaceLevel, Theme}; 5 13 use crate::widgets::{PaintPrim, WidgetPaint, lower_paint}; 14 + 15 + const MARK_FONT_SCALE: f32 = 0.7; 6 16 7 17 #[derive(Debug, thiserror::Error)] 8 18 pub enum PngError { ··· 240 250 } 241 251 242 252 #[must_use] 243 - pub fn rasterize(theme: &Theme, paint: &[WidgetPaint], size: CanvasSize) -> Vec<u8> { 253 + pub fn rasterize( 254 + theme: &Theme, 255 + paint: &[WidgetPaint], 256 + size: CanvasSize, 257 + strings: &StringTable, 258 + ) -> Vec<u8> { 244 259 let mut canvas = Canvas::new(size, theme.colors.surface(SurfaceLevel::L0)); 245 - paint.iter().for_each(|p| draw(&mut canvas, p, theme)); 260 + let mut painter = GlyphPainter::new(); 261 + paint 262 + .iter() 263 + .for_each(|p| draw(&mut canvas, p, theme, strings, &mut painter)); 246 264 canvas.into_srgb_rgba8() 247 265 } 248 266 249 - fn draw(canvas: &mut Canvas, paint: &WidgetPaint, theme: &Theme) { 250 - let prim = lower_paint(theme, paint); 251 - draw_prim(canvas, &prim); 267 + fn draw( 268 + canvas: &mut Canvas, 269 + paint: &WidgetPaint, 270 + theme: &Theme, 271 + strings: &StringTable, 272 + painter: &mut GlyphPainter, 273 + ) { 274 + match paint { 275 + WidgetPaint::Label { 276 + rect, 277 + text, 278 + color, 279 + role, 280 + } => { 281 + painter.paint_text( 282 + canvas, 283 + *rect, 284 + text.resolve(strings), 285 + TextStyle { 286 + font_size_px: role.size.as_px_f32(), 287 + face: role.face, 288 + weight: role.weight, 289 + color: *color, 290 + }, 291 + ); 292 + } 293 + WidgetPaint::Mark { rect, kind, color } => { 294 + painter.paint_text( 295 + canvas, 296 + *rect, 297 + kind.glyph(), 298 + TextStyle { 299 + font_size_px: rect.size.height.value() * MARK_FONT_SCALE, 300 + face: FontFace::Sans, 301 + weight: FontWeight::Regular, 302 + color: *color, 303 + }, 304 + ); 305 + } 306 + other => { 307 + let prim = lower_paint(theme, other); 308 + draw_prim(canvas, &prim); 309 + } 310 + } 252 311 } 253 312 254 313 fn draw_prim(canvas: &mut Canvas, prim: &PaintPrim) { ··· 260 319 } 261 320 } 262 321 322 + struct GlyphPainter { 323 + shaper: Shaper, 324 + scale_ctx: ScaleContext, 325 + sans: FontRef<'static>, 326 + mono: FontRef<'static>, 327 + } 328 + 329 + impl GlyphPainter { 330 + fn new() -> Self { 331 + Self { 332 + shaper: Shaper::new(), 333 + scale_ctx: ScaleContext::new(), 334 + sans: load_font(FontFace::Sans), 335 + mono: load_font(FontFace::Mono), 336 + } 337 + } 338 + 339 + fn font_for(&self, face: FontFace) -> &FontRef<'static> { 340 + match face { 341 + FontFace::Sans => &self.sans, 342 + FontFace::Mono => &self.mono, 343 + } 344 + } 345 + 346 + fn paint_text(&mut self, canvas: &mut Canvas, rect: LayoutRect, text: &str, style: TextStyle) { 347 + if text.is_empty() || style.font_size_px <= 0.0 { 348 + return; 349 + } 350 + let layout = self.shaper.shape( 351 + text, 352 + ShapeRequest { 353 + face: style.face, 354 + size_px: style.font_size_px, 355 + weight: style.weight, 356 + line_height_px: 0.0, 357 + letter_spacing_px: 0.0, 358 + max_width: None, 359 + }, 360 + ); 361 + let metrics = self 362 + .font_for(style.face) 363 + .metrics(&[]) 364 + .scale(style.font_size_px); 365 + let visible_advance = layout 366 + .lines 367 + .first() 368 + .map_or(0.0, ShapedLine::visible_advance_px); 369 + let rx = rect.origin.x.value(); 370 + let ry = rect.origin.y.value(); 371 + let rw = rect.size.width.value(); 372 + let rh = rect.size.height.value(); 373 + let start_x = rx + ((rw - visible_advance) * 0.5).max(0.0); 374 + let baseline_y = ry + (rh + metrics.cap_height) * 0.5; 375 + let font = *self.font_for(style.face); 376 + let mut scaler = self 377 + .scale_ctx 378 + .builder(font) 379 + .size(style.font_size_px) 380 + .hint(false) 381 + .build(); 382 + layout 383 + .lines 384 + .iter() 385 + .flat_map(|l| l.runs.iter()) 386 + .for_each(|run| { 387 + run.glyphs.iter().for_each(|g| { 388 + let Ok(glyph_id_u16) = u16::try_from(g.id.raw()) else { 389 + return; 390 + }; 391 + let mut image = Image::new(); 392 + let rendered = Render::new(&[Source::Outline]) 393 + .format(Format::Alpha) 394 + .render_into(&mut scaler, glyph_id_u16, &mut image); 395 + if !rendered { 396 + return; 397 + } 398 + composite_alpha_mask( 399 + canvas, 400 + &image, 401 + start_x + run.origin_x_px + g.x_px, 402 + baseline_y, 403 + style.color, 404 + ); 405 + }); 406 + }); 407 + } 408 + } 409 + 410 + #[derive(Copy, Clone)] 411 + struct TextStyle { 412 + font_size_px: f32, 413 + face: FontFace, 414 + weight: FontWeight, 415 + color: Color, 416 + } 417 + 418 + fn composite_alpha_mask( 419 + canvas: &mut Canvas, 420 + image: &Image, 421 + origin_x_px: f32, 422 + baseline_y_px: f32, 423 + color: Color, 424 + ) { 425 + let placement = image.placement; 426 + let mask_w = placement.width; 427 + let mask_h = placement.height; 428 + if mask_w == 0 || mask_h == 0 || image.data.len() < (mask_w as usize) * (mask_h as usize) { 429 + return; 430 + } 431 + #[allow( 432 + clippy::cast_precision_loss, 433 + reason = "glyph placement offsets stay well within f32 mantissa precision" 434 + )] 435 + let base_horiz = origin_x_px + placement.left as f32; 436 + #[allow( 437 + clippy::cast_precision_loss, 438 + reason = "glyph placement offsets stay well within f32 mantissa precision" 439 + )] 440 + let base_vert = baseline_y_px - placement.top as f32; 441 + let canvas_w = canvas.size.width.value(); 442 + let canvas_h = canvas.size.height.value(); 443 + let stride = canvas_w as usize; 444 + let color_premul = color.linear_rgba_premul(); 445 + (0..mask_h).for_each(|py| { 446 + let Some(dst_y) = pixel_index(base_vert, py, canvas_h) else { 447 + return; 448 + }; 449 + let row = (py as usize) * (mask_w as usize); 450 + (0..mask_w).for_each(|px| { 451 + let Some(dst_x) = pixel_index(base_horiz, px, canvas_w) else { 452 + return; 453 + }; 454 + let mask = f32::from(image.data[row + (px as usize)]) / 255.0; 455 + if mask <= 0.0 { 456 + return; 457 + } 458 + let src = [ 459 + color_premul[0] * mask, 460 + color_premul[1] * mask, 461 + color_premul[2] * mask, 462 + color_premul[3] * mask, 463 + ]; 464 + let inv_alpha = 1.0 - src[3]; 465 + let idx = (dst_y as usize) * stride + (dst_x as usize); 466 + let dst = canvas.pixels[idx]; 467 + canvas.pixels[idx] = [ 468 + src[0] + dst[0] * inv_alpha, 469 + src[1] + dst[1] * inv_alpha, 470 + src[2] + dst[2] * inv_alpha, 471 + src[3] + dst[3] * inv_alpha, 472 + ]; 473 + }); 474 + }); 475 + } 476 + 477 + #[allow( 478 + clippy::cast_precision_loss, 479 + clippy::cast_possible_truncation, 480 + clippy::cast_sign_loss, 481 + reason = "glyph mask offsets stay within f32 mantissa precision and the rounded coordinate is guarded against negatives and the canvas limit" 482 + )] 483 + fn pixel_index(base: f32, offset: u32, limit: u32) -> Option<u32> { 484 + let pos = (base + offset as f32).round(); 485 + if !pos.is_finite() || pos < 0.0 { 486 + return None; 487 + } 488 + let value = pos as u32; 489 + (value < limit).then_some(value) 490 + } 491 + 263 492 pub fn encode_png(rgba: &[u8], size: CanvasSize) -> Result<Vec<u8>, PngError> { 264 493 let expected = size.pixel_count() * 4; 265 494 if rgba.len() != expected { ··· 323 552 mod tests { 324 553 use super::{Canvas, CanvasPx, CanvasSize, rasterize}; 325 554 use crate::gallery::{GALLERY_CANVAS, GalleryState, render}; 555 + use crate::strings::StringTable; 326 556 use crate::theme::Theme; 327 557 use std::sync::Arc; 328 558 ··· 330 560 fn rasterizer_emits_canvas_sized_buffer() { 331 561 let mut state = GalleryState::new(); 332 562 let paint = render(Arc::new(Theme::light()), &mut state); 333 - let pixels = rasterize(&Theme::light(), &paint, GALLERY_CANVAS); 563 + let pixels = rasterize( 564 + &Theme::light(), 565 + &paint, 566 + GALLERY_CANVAS, 567 + StringTable::empty(), 568 + ); 334 569 assert_eq!(pixels.len(), GALLERY_CANVAS.pixel_count() * 4); 335 570 } 336 571 ··· 339 574 let render_for = |theme: Theme| { 340 575 let mut state = GalleryState::new(); 341 576 let paint = render(Arc::new(theme.clone()), &mut state); 342 - rasterize(&theme, &paint, GALLERY_CANVAS) 577 + rasterize(&theme, &paint, GALLERY_CANVAS, StringTable::empty()) 343 578 }; 344 579 let light = render_for(Theme::light()); 345 580 let dark = render_for(Theme::dark());
+1 -3
crates/bone-ui/src/widgets/mod.rs
··· 49 49 MenuResponse, MenuState, show_context_menu, show_menu, show_menu_bar, 50 50 }; 51 51 pub use numeric_input::{NumericFloatParseError, NumericInput, NumericInputResponse}; 52 - pub use paint::{ 53 - ButtonPaintKind, GlyphMark, LabelText, PaintPrim, SelectionByteRange, WidgetPaint, lower_paint, 54 - }; 52 + pub use paint::{ButtonPaintKind, GlyphMark, LabelText, PaintPrim, WidgetPaint, lower_paint}; 55 53 pub use panel::{Panel, PanelResponse, PanelState, PanelTitlebar, PanelVariant, show_panel}; 56 54 pub use parsed_input::{ParsedInput, ParsedInputResponse, ParsedValue, show_parsed_input}; 57 55 pub use property_grid::{
+67 -24
crates/bone-ui/src/widgets/paint.rs
··· 2 2 3 3 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 4 4 use crate::strings::{StringKey, StringTable}; 5 - use crate::text::SourceByteIndex; 6 5 use crate::theme::{ 7 6 Border, Color, ElevationLevel, Radius, Spacing, StrokeWidth, Theme, TypographyRole, 8 7 }; ··· 35 34 Ellipsis, 36 35 } 37 36 37 + impl GlyphMark { 38 + #[must_use] 39 + pub const fn glyph(self) -> &'static str { 40 + match self { 41 + Self::Checkmark => "\u{2713}", 42 + Self::Indeterminate => "\u{2212}", 43 + Self::RadioDot | Self::SliderThumb => "\u{25CF}", 44 + Self::Caret | Self::Chevron | Self::DisclosureOpen | Self::SortDescending => "\u{25BC}", 45 + Self::DisclosureClosed | Self::SubmenuArrow => "\u{25B6}", 46 + Self::SortAscending => "\u{25B2}", 47 + Self::Close => "\u{00D7}", 48 + Self::Spinner => "\u{25D0}", 49 + Self::Ellipsis => "\u{2026}", 50 + } 51 + } 52 + } 53 + 38 54 #[derive(Clone, Debug, PartialEq, Eq, Serialize)] 39 55 pub enum LabelText { 40 56 Key(StringKey), ··· 51 67 } 52 68 } 53 69 54 - #[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)] 55 - pub struct SelectionByteRange { 56 - pub min: SourceByteIndex, 57 - pub max: SourceByteIndex, 58 - } 59 - 60 - impl SelectionByteRange { 61 - #[must_use] 62 - pub const fn new(min: SourceByteIndex, max: SourceByteIndex) -> Self { 63 - Self { min, max } 64 - } 65 - } 66 - 67 70 #[derive(Clone, Debug, PartialEq, Serialize)] 68 71 pub enum WidgetPaint { 69 72 Surface { ··· 92 95 }, 93 96 SelectionHighlight { 94 97 rect: LayoutRect, 95 - range: SelectionByteRange, 96 98 color: Color, 97 99 }, 98 100 Caret { 99 101 rect: LayoutRect, 100 - byte_offset: SourceByteIndex, 101 102 color: Color, 102 103 }, 103 104 Tooltip { ··· 167 168 }), 168 169 radius: *radius, 169 170 }, 170 - WidgetPaint::SelectionHighlight { rect, color, .. } => PaintPrim::solid( 171 + WidgetPaint::SelectionHighlight { rect, color } => PaintPrim::solid( 171 172 *rect, 172 173 color.with_alpha(SELECTION_PLACEHOLDER_ALPHA * color.alpha()), 173 174 ), 174 - WidgetPaint::Caret { rect, color, .. } => PaintPrim::solid(caret_band(*rect), *color), 175 + WidgetPaint::Caret { rect, color } => PaintPrim::solid(*rect, *color), 175 176 WidgetPaint::Tooltip { 176 177 rect, elevation, .. 177 178 } => PaintPrim { ··· 221 222 ) 222 223 } 223 224 224 - fn caret_band(rect: LayoutRect) -> LayoutRect { 225 - let width = rect.size.width.value().max(1.0); 226 - LayoutRect::new( 227 - rect.origin, 228 - LayoutSize::new(LayoutPx::saturating_nonneg(width), rect.size.height), 229 - ) 225 + #[cfg(test)] 226 + mod tests { 227 + use super::GlyphMark; 228 + 229 + #[test] 230 + fn every_mark_maps_to_a_non_empty_bmp_glyph() { 231 + let cases = [ 232 + GlyphMark::Checkmark, 233 + GlyphMark::Indeterminate, 234 + GlyphMark::RadioDot, 235 + GlyphMark::Caret, 236 + GlyphMark::Chevron, 237 + GlyphMark::SliderThumb, 238 + GlyphMark::Spinner, 239 + GlyphMark::Close, 240 + GlyphMark::DisclosureClosed, 241 + GlyphMark::DisclosureOpen, 242 + GlyphMark::SubmenuArrow, 243 + GlyphMark::SortAscending, 244 + GlyphMark::SortDescending, 245 + GlyphMark::Ellipsis, 246 + ]; 247 + cases.into_iter().for_each(|kind| { 248 + let glyph = kind.glyph(); 249 + assert!(!glyph.is_empty(), "{kind:?} → empty glyph"); 250 + assert_eq!( 251 + glyph.chars().count(), 252 + 1, 253 + "{kind:?} → expected single codepoint", 254 + ); 255 + }); 256 + } 257 + 258 + #[test] 259 + fn disclosure_open_and_closed_pick_distinct_glyphs() { 260 + assert_ne!( 261 + GlyphMark::DisclosureOpen.glyph(), 262 + GlyphMark::DisclosureClosed.glyph(), 263 + ); 264 + } 265 + 266 + #[test] 267 + fn sort_directions_pick_distinct_glyphs() { 268 + assert_ne!( 269 + GlyphMark::SortAscending.glyph(), 270 + GlyphMark::SortDescending.glyph(), 271 + ); 272 + } 230 273 }
+1 -1
crates/bone-ui/tests/gallery_snapshot.rs
··· 69 69 &mut a11y, 70 70 StringTable::empty(), 71 71 ); 72 - rasterize(theme, &paint, GALLERY_CANVAS) 72 + rasterize(theme, &paint, GALLERY_CANVAS, StringTable::empty()) 73 73 } 74 74 75 75 struct Drift {
crates/bone-ui/tests/snapshots/gallery_dark.png

This is a binary file and will not be displayed.

crates/bone-ui/tests/snapshots/gallery_light.png

This is a binary file and will not be displayed.