Another project
0

Configure Feed

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

feat(ui): beginnings of raster png snapshotting

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

author
Lewis
date (May 5, 2026, 11:25 PM +0300) commit afab59ff parent 3f95e64b change-id qmvtvxms
+465
+2
Cargo.lock
··· 212 212 "bone-document", 213 213 "bone-render", 214 214 "bone-types", 215 + "bone-ui", 215 216 "pollster", 216 217 "thiserror 2.0.18", 217 218 "tracing", ··· 314 315 "insta", 315 316 "lyon_tessellation", 316 317 "palette", 318 + "png", 317 319 "ron", 318 320 "serde", 319 321 "swash",
+1
crates/bone-ui/Cargo.toml
··· 10 10 bone-text = { workspace = true } 11 11 lyon_tessellation = { workspace = true } 12 12 palette = { workspace = true } 13 + png = { workspace = true } 13 14 serde = { workspace = true } 14 15 swash = { workspace = true } 15 16 taffy = { workspace = true }
+462
crates/bone-ui/src/raster.rs
··· 1 + use std::io::Cursor; 2 + 3 + use crate::layout::{LayoutPx, LayoutRect}; 4 + use crate::theme::{Color, ElevationLevel, SurfaceLevel, Theme}; 5 + use crate::widgets::WidgetPaint; 6 + 7 + #[derive(Debug, thiserror::Error)] 8 + pub enum PngError { 9 + #[error("png encode failed: {0}")] 10 + Encode(String), 11 + #[error("png decode failed: {0}")] 12 + Decode(String), 13 + #[error("png dimensions {got_w}x{got_h} differ from expected {want_w}x{want_h}")] 14 + Dimensions { 15 + got_w: CanvasPx, 16 + got_h: CanvasPx, 17 + want_w: CanvasPx, 18 + want_h: CanvasPx, 19 + }, 20 + #[error("png decoded buffer size {got} differs from expected {want}")] 21 + Length { got: usize, want: usize }, 22 + } 23 + 24 + #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 25 + pub struct CanvasPx(u32); 26 + 27 + impl CanvasPx { 28 + #[must_use] 29 + pub const fn new(value: u32) -> Self { 30 + Self(value) 31 + } 32 + 33 + #[must_use] 34 + pub const fn value(self) -> u32 { 35 + self.0 36 + } 37 + } 38 + 39 + impl core::fmt::Display for CanvasPx { 40 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 41 + self.0.fmt(f) 42 + } 43 + } 44 + 45 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 46 + pub struct CanvasSize { 47 + pub width: CanvasPx, 48 + pub height: CanvasPx, 49 + } 50 + 51 + impl CanvasSize { 52 + #[must_use] 53 + pub const fn new(width: CanvasPx, height: CanvasPx) -> Self { 54 + Self { width, height } 55 + } 56 + 57 + #[must_use] 58 + pub const fn pixel_count(self) -> usize { 59 + (self.width.value() as usize) * (self.height.value() as usize) 60 + } 61 + } 62 + 63 + #[derive(Clone, Debug)] 64 + struct Canvas { 65 + size: CanvasSize, 66 + pixels: Vec<[f32; 4]>, 67 + } 68 + 69 + impl Canvas { 70 + fn new(size: CanvasSize, clear: Color) -> Self { 71 + let fill = clear.linear_rgba_premul(); 72 + Self { 73 + size, 74 + pixels: vec![fill; size.pixel_count()], 75 + } 76 + } 77 + 78 + fn fill_rect(&mut self, rect: LayoutRect, color: Color) { 79 + let bounds = self.bounds(rect); 80 + let src = color.linear_rgba_premul(); 81 + composite_band(&mut self.pixels, self.size.width.value(), bounds, src); 82 + } 83 + 84 + fn outline_rect(&mut self, rect: LayoutRect, color: Color, thickness: f32) { 85 + let outer = self.bounds(rect); 86 + if outer.is_empty() { 87 + return; 88 + } 89 + #[allow( 90 + clippy::cast_possible_truncation, 91 + clippy::cast_sign_loss, 92 + reason = "stroke widths are small non-negative pixel counts" 93 + )] 94 + let t = thickness.max(1.0).round() as u32; 95 + let h = outer.y1 - outer.y0; 96 + let w = outer.x1 - outer.x0; 97 + let top_h = t.min(h); 98 + let bot_h = t.min(h - top_h); 99 + let left_w = t.min(w); 100 + let right_w = t.min(w - left_w); 101 + let mid_y0 = outer.y0 + top_h; 102 + let mid_y1 = outer.y1 - bot_h; 103 + let src = color.linear_rgba_premul(); 104 + let bands = [ 105 + PixelBounds { 106 + x0: outer.x0, 107 + y0: outer.y0, 108 + x1: outer.x1, 109 + y1: mid_y0, 110 + }, 111 + PixelBounds { 112 + x0: outer.x0, 113 + y0: mid_y1, 114 + x1: outer.x1, 115 + y1: outer.y1, 116 + }, 117 + PixelBounds { 118 + x0: outer.x0, 119 + y0: mid_y0, 120 + x1: outer.x0 + left_w, 121 + y1: mid_y1, 122 + }, 123 + PixelBounds { 124 + x0: outer.x1 - right_w, 125 + y0: mid_y0, 126 + x1: outer.x1, 127 + y1: mid_y1, 128 + }, 129 + ]; 130 + bands.iter().for_each(|band| { 131 + composite_band(&mut self.pixels, self.size.width.value(), *band, src); 132 + }); 133 + } 134 + 135 + fn bounds(&self, rect: LayoutRect) -> PixelBounds { 136 + let x0 = clamp_axis(rect.min_x(), self.size.width); 137 + let y0 = clamp_axis(rect.min_y(), self.size.height); 138 + let x1 = clamp_axis(rect.max_x(), self.size.width); 139 + let y1 = clamp_axis(rect.max_y(), self.size.height); 140 + PixelBounds { 141 + x0, 142 + y0, 143 + x1: x1.max(x0), 144 + y1: y1.max(y0), 145 + } 146 + } 147 + 148 + fn into_srgb_rgba8(self) -> Vec<u8> { 149 + self.pixels 150 + .iter() 151 + .flat_map(|p| pixel_to_srgb_u8(*p)) 152 + .collect() 153 + } 154 + } 155 + 156 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 157 + struct PixelBounds { 158 + x0: u32, 159 + y0: u32, 160 + x1: u32, 161 + y1: u32, 162 + } 163 + 164 + impl PixelBounds { 165 + fn is_empty(self) -> bool { 166 + self.x0 >= self.x1 || self.y0 >= self.y1 167 + } 168 + } 169 + 170 + fn clamp_axis(value: LayoutPx, limit: CanvasPx) -> u32 { 171 + let raw = value.value(); 172 + #[allow( 173 + clippy::cast_precision_loss, 174 + reason = "canvas dimensions far below f32 precision boundary" 175 + )] 176 + let bounded = raw.clamp(0.0, limit.value() as f32); 177 + #[allow( 178 + clippy::cast_possible_truncation, 179 + clippy::cast_sign_loss, 180 + reason = "value clamped to [0, limit] before cast" 181 + )] 182 + let pixel = bounded.round() as u32; 183 + pixel 184 + } 185 + 186 + fn composite_band(pixels: &mut [[f32; 4]], stride: u32, bounds: PixelBounds, src: [f32; 4]) { 187 + if bounds.is_empty() { 188 + return; 189 + } 190 + let inv_alpha = 1.0 - src[3]; 191 + (bounds.y0..bounds.y1).for_each(|y| { 192 + let row_offset = (y as usize) * (stride as usize); 193 + (bounds.x0..bounds.x1).for_each(|x| { 194 + let idx = row_offset + (x as usize); 195 + let dst = pixels[idx]; 196 + pixels[idx] = [ 197 + src[0] + dst[0] * inv_alpha, 198 + src[1] + dst[1] * inv_alpha, 199 + src[2] + dst[2] * inv_alpha, 200 + src[3] + dst[3] * inv_alpha, 201 + ]; 202 + }); 203 + }); 204 + } 205 + 206 + fn pixel_to_srgb_u8(premul: [f32; 4]) -> [u8; 4] { 207 + let alpha = premul[3].clamp(0.0, 1.0); 208 + if alpha <= 0.0 { 209 + return [0, 0, 0, 0]; 210 + } 211 + let inv = 1.0 / alpha; 212 + let to_u8 = |c: f32| { 213 + let unpremul = (c * inv).clamp(0.0, 1.0); 214 + unit_to_byte(linear_to_srgb(unpremul)) 215 + }; 216 + [ 217 + to_u8(premul[0]), 218 + to_u8(premul[1]), 219 + to_u8(premul[2]), 220 + unit_to_byte(alpha), 221 + ] 222 + } 223 + 224 + fn unit_to_byte(unit: f32) -> u8 { 225 + #[allow( 226 + clippy::cast_possible_truncation, 227 + clippy::cast_sign_loss, 228 + reason = "value clamped to [0, 255] before cast" 229 + )] 230 + let byte = (unit * 255.0).round().clamp(0.0, 255.0) as u8; 231 + byte 232 + } 233 + 234 + fn linear_to_srgb(linear: f32) -> f32 { 235 + if linear <= 0.003_130_8 { 236 + 12.92 * linear 237 + } else { 238 + 1.055 * linear.powf(1.0 / 2.4) - 0.055 239 + } 240 + } 241 + 242 + #[must_use] 243 + pub fn rasterize(theme: &Theme, paint: &[WidgetPaint], size: CanvasSize) -> Vec<u8> { 244 + let mut canvas = Canvas::new(size, theme.colors.surface(SurfaceLevel::L0)); 245 + paint.iter().for_each(|p| draw(&mut canvas, p, theme)); 246 + canvas.into_srgb_rgba8() 247 + } 248 + 249 + fn draw(canvas: &mut Canvas, paint: &WidgetPaint, theme: &Theme) { 250 + match paint { 251 + WidgetPaint::Surface { 252 + rect, 253 + fill, 254 + border, 255 + .. 256 + } => { 257 + canvas.fill_rect(*rect, *fill); 258 + if let Some(b) = border { 259 + canvas.outline_rect(*rect, b.color, b.width.value_px()); 260 + } 261 + } 262 + WidgetPaint::Label { rect, color, .. } => { 263 + canvas.fill_rect(label_bar(*rect), color.with_alpha(0.55 * color.alpha())); 264 + } 265 + WidgetPaint::Mark { rect, color, .. } => { 266 + canvas.fill_rect(centered_square(*rect, 0.55), *color); 267 + } 268 + WidgetPaint::FocusRing { 269 + rect, 270 + color, 271 + thickness, 272 + .. 273 + } => { 274 + canvas.outline_rect(*rect, *color, thickness.value_px()); 275 + } 276 + WidgetPaint::SelectionHighlight { rect, color, .. } => { 277 + canvas.fill_rect(*rect, color.with_alpha(0.35 * color.alpha())); 278 + } 279 + WidgetPaint::Caret { rect, color, .. } => { 280 + canvas.fill_rect(caret_band(*rect), *color); 281 + } 282 + WidgetPaint::Tooltip { 283 + rect, elevation, .. 284 + } => { 285 + draw_elevation(canvas, theme, *rect, *elevation); 286 + } 287 + } 288 + } 289 + 290 + fn draw_elevation(canvas: &mut Canvas, theme: &Theme, rect: LayoutRect, elevation: ElevationLevel) { 291 + canvas.fill_rect(rect, theme.colors.surface(elevation.surface)); 292 + if let Some(b) = elevation.border { 293 + canvas.outline_rect(rect, b.color, b.width.value_px()); 294 + } 295 + } 296 + 297 + fn label_bar(rect: LayoutRect) -> LayoutRect { 298 + use crate::layout::{LayoutPos, LayoutSize}; 299 + let height = (rect.size.height.value() * 0.4).max(2.0); 300 + let pad = (rect.size.height.value() - height) * 0.5; 301 + let inset_x = (rect.size.width.value() * 0.1).min(8.0); 302 + LayoutRect::new( 303 + LayoutPos::new( 304 + LayoutPx::saturating(rect.origin.x.value() + inset_x), 305 + LayoutPx::saturating(rect.origin.y.value() + pad), 306 + ), 307 + LayoutSize::new( 308 + LayoutPx::saturating_nonneg(rect.size.width.value() - 2.0 * inset_x), 309 + LayoutPx::saturating_nonneg(height), 310 + ), 311 + ) 312 + } 313 + 314 + fn centered_square(rect: LayoutRect, factor: f32) -> LayoutRect { 315 + use crate::layout::{LayoutPos, LayoutSize}; 316 + let side = rect 317 + .size 318 + .width 319 + .value() 320 + .min(rect.size.height.value()) 321 + .max(0.0) 322 + * factor; 323 + let cx = rect.origin.x.value() + 0.5 * rect.size.width.value(); 324 + let cy = rect.origin.y.value() + 0.5 * rect.size.height.value(); 325 + LayoutRect::new( 326 + LayoutPos::new( 327 + LayoutPx::saturating(cx - 0.5 * side), 328 + LayoutPx::saturating(cy - 0.5 * side), 329 + ), 330 + LayoutSize::new( 331 + LayoutPx::saturating_nonneg(side), 332 + LayoutPx::saturating_nonneg(side), 333 + ), 334 + ) 335 + } 336 + 337 + fn caret_band(rect: LayoutRect) -> LayoutRect { 338 + use crate::layout::{LayoutPos, LayoutSize}; 339 + let width = rect.size.width.value().max(1.0); 340 + LayoutRect::new( 341 + LayoutPos::new(rect.origin.x, rect.origin.y), 342 + LayoutSize::new( 343 + LayoutPx::saturating_nonneg(width), 344 + rect.size.height, 345 + ), 346 + ) 347 + } 348 + 349 + pub fn encode_png(rgba: &[u8], size: CanvasSize) -> Result<Vec<u8>, PngError> { 350 + let expected = size.pixel_count() * 4; 351 + if rgba.len() != expected { 352 + return Err(PngError::Length { 353 + got: rgba.len(), 354 + want: expected, 355 + }); 356 + } 357 + let mut out = Vec::new(); 358 + { 359 + let mut encoder = png::Encoder::new(&mut out, size.width.value(), size.height.value()); 360 + encoder.set_color(png::ColorType::Rgba); 361 + encoder.set_depth(png::BitDepth::Eight); 362 + encoder.set_compression(png::Compression::Best); 363 + let mut writer = encoder 364 + .write_header() 365 + .map_err(|e| PngError::Encode(e.to_string()))?; 366 + writer 367 + .write_image_data(rgba) 368 + .map_err(|e| PngError::Encode(e.to_string()))?; 369 + } 370 + Ok(out) 371 + } 372 + 373 + pub fn decode_png(bytes: &[u8], size: CanvasSize) -> Result<Vec<u8>, PngError> { 374 + let decoder = png::Decoder::new(Cursor::new(bytes)); 375 + let mut reader = decoder 376 + .read_info() 377 + .map_err(|e| PngError::Decode(e.to_string()))?; 378 + let info = reader.info(); 379 + if info.width != size.width.value() || info.height != size.height.value() { 380 + return Err(PngError::Dimensions { 381 + got_w: CanvasPx::new(info.width), 382 + got_h: CanvasPx::new(info.height), 383 + want_w: size.width, 384 + want_h: size.height, 385 + }); 386 + } 387 + let mut buf = vec![0_u8; reader.output_buffer_size()]; 388 + let frame = reader 389 + .next_frame(&mut buf) 390 + .map_err(|e| PngError::Decode(e.to_string()))?; 391 + if frame.color_type != png::ColorType::Rgba { 392 + return Err(PngError::Decode(format!( 393 + "expected rgba color type, got {:?}", 394 + frame.color_type, 395 + ))); 396 + } 397 + let rgba = buf[..frame.buffer_size()].to_vec(); 398 + let expected = size.pixel_count() * 4; 399 + if rgba.len() != expected { 400 + return Err(PngError::Length { 401 + got: rgba.len(), 402 + want: expected, 403 + }); 404 + } 405 + Ok(rgba) 406 + } 407 + 408 + #[cfg(test)] 409 + mod tests { 410 + use super::{Canvas, CanvasPx, CanvasSize, rasterize}; 411 + use crate::gallery::{GALLERY_CANVAS, GalleryState, render}; 412 + use crate::theme::Theme; 413 + use std::sync::Arc; 414 + 415 + #[test] 416 + fn rasterizer_emits_canvas_sized_buffer() { 417 + let mut state = GalleryState::new(); 418 + let paint = render(Arc::new(Theme::light()), &mut state); 419 + let pixels = rasterize(&Theme::light(), &paint, GALLERY_CANVAS); 420 + assert_eq!(pixels.len(), GALLERY_CANVAS.pixel_count() * 4); 421 + } 422 + 423 + #[test] 424 + fn light_and_dark_buffers_differ() { 425 + let render_for = |theme: Theme| { 426 + let mut state = GalleryState::new(); 427 + let paint = render(Arc::new(theme.clone()), &mut state); 428 + rasterize(&theme, &paint, GALLERY_CANVAS) 429 + }; 430 + let light = render_for(Theme::light()); 431 + let dark = render_for(Theme::dark()); 432 + assert_ne!(light, dark, "themes must produce distinct rasterizations"); 433 + } 434 + 435 + #[test] 436 + fn outline_rect_corners_render_once_under_translucent_color() { 437 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 438 + use crate::theme::Color; 439 + let size = CanvasSize::new(CanvasPx::new(8), CanvasPx::new(8)); 440 + let bg = Color::TRANSPARENT.with_alpha(1.0); 441 + let stroke = Theme::light().colors.focus_ring().with_alpha(0.5); 442 + let mut canvas = Canvas::new(size, bg); 443 + canvas.outline_rect( 444 + LayoutRect::new( 445 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 446 + LayoutSize::new(LayoutPx::new(8.0), LayoutPx::new(8.0)), 447 + ), 448 + stroke, 449 + 1.0, 450 + ); 451 + let bytes = canvas.into_srgb_rgba8(); 452 + let pixel = |x: usize, y: usize| { 453 + let i = (y * size.width.value() as usize + x) * 4; 454 + [bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]] 455 + }; 456 + assert_eq!( 457 + pixel(0, 0), 458 + pixel(3, 0), 459 + "corner pixel must match a non-corner top-edge pixel; double-paint detected", 460 + ); 461 + } 462 + }