1use std::sync::{Arc, Mutex};
21
22use decode::EditorVideoStream;
23use edit::style::{BackgroundConfig, BackgroundSource, CropRect, CursorConfig};
24use edit::zoom_anim::ZoomTransform;
25use playback::EditorPlayer;
26use wisp::recording::{CursorRipple, StreamDimensions};
27use wisp::{Color, MaskShape, Rect, Stroke, Transform, Vec2};
28
29use crate::recording::FrameSlot;
30use crate::recording_compose::{ComposedFrame, RecordingCompose};
31
32const FILL_SCALE: f64 = 2.0;
36
37const CURSOR_BASE_HALF: f32 = 0.05;
41
42const WALLPAPER_GEN_W: u32 = 320;
45const WALLPAPER_GEN_H: u32 = 180;
47
48#[must_use]
64#[allow(
65 clippy::cast_possible_truncation,
66 reason = "screen-space NDC transform components are small; the f64→f32 narrowing for the wisp Vec2 is intentional and well within f32 precision"
67)]
68fn framed_transform(zoom: ZoomTransform, crop: CropRect) -> Transform {
69 let w = f64::from(crop.width).max(1e-3);
72 let h = f64::from(crop.height).max(1e-3);
73 let crop_scale = (FILL_SCALE / w, FILL_SCALE / h);
74 let crop_pos = (
75 (1.0 - 2.0 * f64::from(crop.x)) / w - 1.0,
76 (2.0 * f64::from(crop.y) - 1.0) / h + 1.0,
77 );
78
79 let z = zoom.scale.max(1.0);
81 let focal = (2.0 * zoom.center_x - 1.0, -(2.0 * zoom.center_y - 1.0));
82 let zoom_pos = (focal.0 * (1.0 - z), focal.1 * (1.0 - z));
83
84 Transform {
85 scale: Vec2::new((z * crop_scale.0) as f32, (z * crop_scale.1) as f32),
86 position: Vec2::new(
87 (z * crop_pos.0 + zoom_pos.0) as f32,
88 (z * crop_pos.1 + zoom_pos.1) as f32,
89 ),
90 ..Transform::IDENTITY
91 }
92}
93
94#[must_use]
102#[allow(
103 clippy::cast_possible_truncation,
104 reason = "padding factors are in (0, 1] and the transform components are small NDC values, well within f32 precision"
105)]
106fn framed_transform_padded(zoom: ZoomTransform, crop: CropRect, k_x: f64, k_y: f64) -> Transform {
107 let t = framed_transform(zoom, crop);
108 let (kx, ky) = (k_x as f32, k_y as f32);
109 Transform {
110 scale: Vec2::new(t.scale.x * kx, t.scale.y * ky),
111 position: Vec2::new(t.position.x * kx, t.position.y * ky),
112 ..t
113 }
114}
115
116#[must_use]
125#[allow(
126 clippy::cast_possible_truncation,
127 reason = "NDC window extents (|·| ≤ 2) and the corner radius fraction are small, well within f32 precision"
128)]
129fn background_geometry(
130 width: u32,
131 height: u32,
132 padding: u32,
133 corner_radius: u32,
134) -> (Rect, f32, (f64, f64)) {
135 let w = f64::from(width.max(1));
136 let h = f64::from(height.max(1));
137 let k_x = (1.0 - 2.0 * f64::from(padding) / w).clamp(0.05, 1.0);
138 let k_y = (1.0 - 2.0 * f64::from(padding) / h).clamp(0.05, 1.0);
139 let window = Rect::new(
140 -(k_x as f32),
141 -(k_y as f32),
142 (2.0 * k_x) as f32,
143 (2.0 * k_y) as f32,
144 );
145 let corner_ndc = (2.0 * f64::from(corner_radius) / w) as f32;
146 (window, corner_ndc, (k_x, k_y))
147}
148
149fn wallpaper_palette(name: &str) -> [[u8; 3]; 3] {
152 match name.to_ascii_lowercase().as_str() {
153 "sunset" => [[255, 94, 98], [255, 195, 113], [113, 70, 132]],
154 "ocean" | "aqua" => [[0, 79, 131], [0, 160, 176], [137, 218, 196]],
155 "forest" | "mint" => [[20, 60, 50], [44, 110, 73], [149, 200, 120]],
156 _ => [[40, 53, 147], [123, 67, 151], [255, 138, 128]],
158 }
159}
160
161#[must_use]
166#[allow(
167 clippy::cast_precision_loss,
168 clippy::cast_possible_truncation,
169 clippy::cast_sign_loss,
170 reason = "pixel coords + small dims are exact in f32; the channel result is clamped to [0,255] and rounded before the f32→u8 cast"
171)]
172fn wallpaper_rgba(name: &str, width: u32, height: u32) -> Vec<u8> {
173 let [c0, c1, c2] = wallpaper_palette(name);
174 let cols = width.max(1) as usize;
175 let rows = height.max(1) as usize;
176 let (wf, hf) = (cols as f32, rows as f32);
177 let lerp = |lo: [u8; 3], hi: [u8; 3], t: f32| -> [u8; 3] {
178 let t = t.clamp(0.0, 1.0);
179 let chan = |lc: u8, hc: u8| (f32::from(lc) + (f32::from(hc) - f32::from(lc)) * t).round();
180 [
181 chan(lo[0], hi[0]) as u8,
182 chan(lo[1], hi[1]) as u8,
183 chan(lo[2], hi[2]) as u8,
184 ]
185 };
186 let mut out = vec![0u8; cols * rows * 4];
187 for row in 0..rows {
188 for col in 0..cols {
189 let (xf, yf) = (col as f32, row as f32);
190 let diag = (xf / wf).midpoint(yf / hf);
192 let band = (((xf / wf - yf / hf) * std::f32::consts::TAU).sin() * 0.5 + 0.5) * 0.22;
194 let base = if diag < 0.5 {
195 lerp(c0, c1, diag * 2.0)
196 } else {
197 lerp(c1, c2, (diag - 0.5) * 2.0)
198 };
199 let px = lerp(base, c2, band);
200 let idx = (row * cols + col) * 4;
201 out[idx] = px[0];
202 out[idx + 1] = px[1];
203 out[idx + 2] = px[2];
204 out[idx + 3] = 255;
205 }
206 }
207 out
208}
209
210#[must_use]
222fn aspect_fit_factor(source_w: u32, source_h: u32, canvas_w: u32, canvas_h: u32) -> (f64, f64) {
223 let source_aspect = f64::from(source_w.max(1)) / f64::from(source_h.max(1));
224 let canvas_aspect = f64::from(canvas_w.max(1)) / f64::from(canvas_h.max(1));
225 let r = source_aspect / canvas_aspect;
226 if r >= 1.0 { (1.0, 1.0 / r) } else { (r, 1.0) }
227}
228
229pub struct EditorPreview {
232 compose: RecordingCompose,
233 screen_slot: FrameSlot,
234 cam_slot: FrameSlot,
237 width: u32,
238 height: u32,
239 pad: (f64, f64),
244 aspect_fit: (f64, f64),
247}
248
249impl EditorPreview {
250 pub fn new(width: u32, height: u32) -> Result<Self, wisp::Error> {
259 Self::with_canvas(width, height, width, height)
260 }
261
262 pub fn with_canvas(
274 source_w: u32,
275 source_h: u32,
276 canvas_w: u32,
277 canvas_h: u32,
278 ) -> Result<Self, wisp::Error> {
279 let screen = StreamDimensions::new(source_w, source_h);
284 let cam = StreamDimensions::new(2, 2);
288 let compose = RecordingCompose::new(canvas_w, canvas_h, screen, cam)?;
289 let aspect_fit = aspect_fit_factor(source_w, source_h, canvas_w, canvas_h);
290 Ok(Self {
291 compose,
292 screen_slot: Arc::new(Mutex::new(None)),
293 cam_slot: Arc::new(Mutex::new(None)),
294 width: canvas_w,
295 height: canvas_h,
296 pad: aspect_fit,
299 aspect_fit,
300 })
301 }
302
303 #[allow(
316 clippy::cast_possible_truncation,
317 reason = "aspect-fit factors are in (0, 1] and the NDC window extents are small; the f64→f32 narrowing of the scaled window is well within f32 precision"
318 )]
319 pub fn set_background(&mut self, bg: &BackgroundConfig) {
320 let (window0, corner_ndc, (k_x, k_y)) =
321 background_geometry(self.width, self.height, bg.padding, bg.corner_radius);
322 let (fx, fy) = self.aspect_fit;
326 self.pad = (k_x * fx, k_y * fy);
327 let (fxf, fyf) = (fx as f32, fy as f32);
328 let window = Rect::new(
329 window0.min.x * fxf,
330 window0.min.y * fyf,
331 window0.size.x * fxf,
332 window0.size.y * fyf,
333 );
334 self.compose
335 .set_screen_clip(Some(MaskShape::rounded_rect(window, corner_ndc)));
336 self.apply_shadow_and_border(bg, window, corner_ndc);
337
338 match &bg.source {
343 BackgroundSource::Gradient {
344 from,
345 to,
346 angle_deg,
347 } => {
348 self.compose.set_background_gradient(
349 Color::rgb_u8(from[0], from[1], from[2]),
350 Color::rgb_u8(to[0], to[1], to[2]),
351 *angle_deg,
352 );
353 self.compose.set_background_wallpaper_visible(false);
354 }
355 BackgroundSource::Color { rgb } => {
356 self.compose
357 .set_background_color(Color::rgb_u8(rgb[0], rgb[1], rgb[2]));
358 self.compose.set_background_wallpaper_visible(false);
359 }
360 BackgroundSource::Wallpaper { name } => {
361 let rgba = wallpaper_rgba(name, WALLPAPER_GEN_W, WALLPAPER_GEN_H);
365 self.compose
366 .set_background_wallpaper(WALLPAPER_GEN_W, WALLPAPER_GEN_H, &rgba);
367 self.compose.set_background_visible(false);
368 }
369 }
370 }
371
372 #[allow(
377 clippy::cast_possible_truncation,
378 reason = "shadow/border NDC magnitudes are small fractions, well within f32 precision"
379 )]
380 fn apply_shadow_and_border(&mut self, bg: &BackgroundConfig, window: Rect, corner_ndc: f32) {
381 if bg.shadow > 0 {
382 let s = f32::from(u16::try_from(bg.shadow.min(100)).unwrap_or(60)) / 100.0;
383 let mag = s * 0.035;
384 self.compose.set_frame_shadow(
385 window,
386 corner_ndc,
387 Vec2::new(mag * 0.5, -mag),
389 Color::rgba(0.0, 0.0, 0.0, (s * 0.55).min(0.55)),
390 );
391 } else {
392 self.compose.set_frame_shadow_visible(false);
393 }
394
395 if bg.inset > 0 {
396 let stroke_ndc = (2.0 * f64::from(bg.inset) / f64::from(self.width.max(1))) as f32;
397 self.compose.set_frame_border(
398 window,
399 corner_ndc,
400 Stroke::new(stroke_ndc, Color::rgba(1.0, 1.0, 1.0, 0.7)),
401 );
402 } else {
403 self.compose.set_frame_border_visible(false);
404 }
405 }
406
407 #[must_use]
412 pub fn render_frame(&mut self, bgra: Vec<u8>) -> Option<ComposedFrame> {
413 {
414 let mut guard = self
415 .screen_slot
416 .lock()
417 .unwrap_or_else(std::sync::PoisonError::into_inner);
418 *guard = Some(bgra);
419 }
420 self.compose
421 .compose_frame(&self.cam_slot, &self.screen_slot)
422 }
423
424 #[must_use]
435 pub fn render_framed(
436 &mut self,
437 bgra: Vec<u8>,
438 zoom: ZoomTransform,
439 crop: CropRect,
440 ) -> Option<ComposedFrame> {
441 let (k_x, k_y) = self.pad;
442 self.compose
443 .set_screen_transform(framed_transform_padded(zoom, crop, k_x, k_y));
444 self.render_frame(bgra)
445 }
446
447 #[must_use]
456 pub fn render_framed_with_cursor(
457 &mut self,
458 bgra: Vec<u8>,
459 zoom: ZoomTransform,
460 crop: CropRect,
461 cursor: Option<(f32, f32)>,
462 ripples: &[(f32, f32, f32)],
463 cfg: &CursorConfig,
464 ) -> Option<ComposedFrame> {
465 let (k_x, k_y) = self.pad;
466 let t = framed_transform_padded(zoom, crop, k_x, k_y);
467 self.compose.set_screen_transform(t);
468
469 let map = |x: f32, y: f32| {
473 Vec2::new(
474 t.position.x + t.scale.x * (x - 0.5),
475 t.position.y + t.scale.y * -(y - 0.5),
476 )
477 };
478
479 if let Some((cx, cy)) = cursor {
480 let size_factor = f32::from(u16::try_from(cfg.size_pct).unwrap_or(180)) / 100.0;
484 let zoom_scale = (t.scale.y / 2.0).abs();
487 let half = CURSOR_BASE_HALF * size_factor * zoom_scale;
488 let rings: Vec<CursorRipple> = if cfg.click_ripples {
489 ripples
490 .iter()
491 .map(|&(rx, ry, age)| CursorRipple {
492 center: map(rx, ry),
493 radius: half * (1.0 + 3.0 * age),
494 alpha: (1.0 - age) * 0.35,
495 })
496 .collect()
497 } else {
498 Vec::new()
499 };
500 self.compose.set_cursor(map(cx, cy), half, &rings);
501 self.compose.set_cursor_visible(true);
502 } else {
503 self.compose.set_cursor_visible(false);
504 }
505 self.render_frame(bgra)
506 }
507
508 #[must_use]
511 pub fn render_at(
512 &mut self,
513 stream: &mut EditorVideoStream,
514 player: &EditorPlayer,
515 ) -> Option<ComposedFrame> {
516 let frame = stream.frame(player.current_frame())?;
517 self.render_frame(frame.bgra)
518 }
519
520 #[must_use]
522 pub fn dimensions(&self) -> (u32, u32) {
523 (self.width, self.height)
524 }
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 #[test]
532 fn renders_a_source_frame_to_composed_bgra() {
533 let mut preview = EditorPreview::new(64, 64).expect("init wgpu");
534 assert_eq!(preview.dimensions(), (64, 64));
535 let composed = preview
538 .render_frame(vec![128u8; 64 * 64 * 4])
539 .expect("frame composed");
540 assert_eq!(composed.width, 64);
541 assert_eq!(composed.height, 64);
542 assert_eq!(composed.bytes.len(), 64 * 64 * 4);
543 }
544
545 #[test]
546 fn wrong_sized_frame_is_dropped() {
547 let mut preview = EditorPreview::new(64, 64).expect("init wgpu");
548 assert!(preview.render_frame(vec![0u8; 100]).is_none());
551 }
552
553 #[test]
554 fn aspect_fit_factor_is_unit_for_equal_aspects() {
555 let (fx, fy) = aspect_fit_factor(1920, 1080, 1920, 1080);
558 assert!((fx - 1.0).abs() < 1e-9 && (fy - 1.0).abs() < 1e-9);
559 let (fx, fy) = aspect_fit_factor(1280, 720, 640, 360);
560 assert!((fx - 1.0).abs() < 1e-9 && (fy - 1.0).abs() < 1e-9);
561 }
562
563 #[test]
564 fn aspect_fit_factor_letterboxes_a_wide_source_in_a_tall_canvas() {
565 let (fx, fy) = aspect_fit_factor(1920, 1080, 1080, 1920);
568 assert!((fx - 1.0).abs() < 1e-9, "fills width");
569 assert!(
570 (fy - 0.316_406_25).abs() < 1e-6,
571 "shrinks height (letterbox)"
572 );
573 let (fx, fy) = aspect_fit_factor(1920, 1080, 1080, 1080);
575 assert!((fx - 1.0).abs() < 1e-9 && (fy - 0.5625).abs() < 1e-6);
576 }
577
578 #[test]
579 fn aspect_fit_factor_pillarboxes_a_tall_source_in_a_wide_canvas() {
580 let (fx, fy) = aspect_fit_factor(1080, 1920, 1920, 1080);
582 assert!((fy - 1.0).abs() < 1e-9, "fills height");
583 assert!(
584 (fx - 0.316_406_25).abs() < 1e-6,
585 "shrinks width (pillarbox)"
586 );
587 }
588
589 #[test]
594 fn with_canvas_letterboxes_a_wide_source_into_a_tall_canvas() {
595 let mut pv = EditorPreview::with_canvas(160, 90, 90, 160).expect("init wgpu");
597 assert_eq!(pv.dimensions(), (90, 160), "renders at the 9:16 canvas");
598 let white = vec![255u8; 160 * 90 * 4];
599 let f = pv
600 .render_framed(white, ZoomTransform::identity(), CropRect::full())
601 .expect("compose");
602 assert_eq!((f.width, f.height), (90, 160));
603 let px = |col: usize, row: usize| {
604 let i = (row * 90 + col) * 4;
605 [f.bytes[i], f.bytes[i + 1], f.bytes[i + 2]]
606 };
607 let is_white = |p: [u8; 3]| p[0] > 230 && p[1] > 230 && p[2] > 230;
608 let is_dark = |p: [u8; 3]| u16::from(p[0]) + u16::from(p[1]) + u16::from(p[2]) < 60;
609 assert!(is_white(px(45, 80)), "centre is the source (not matte)");
611 assert!(is_dark(px(45, 3)), "top letterbox bar is dark");
614 assert!(is_dark(px(45, 156)), "bottom letterbox bar is dark");
615 }
616
617 #[test]
618 fn framed_transform_is_base_fill_with_no_zoom_no_crop() {
619 let t = framed_transform(ZoomTransform::identity(), CropRect::full());
620 assert!((t.scale.x - 2.0).abs() < 1e-5 && (t.scale.y - 2.0).abs() < 1e-5);
621 assert!(t.position.x.abs() < 1e-5 && t.position.y.abs() < 1e-5);
622 }
623
624 #[test]
625 fn framed_transform_zoom_at_centre_magnifies_in_place() {
626 let z = ZoomTransform {
627 scale: 2.0,
628 center_x: 0.5,
629 center_y: 0.5,
630 };
631 let t = framed_transform(z, CropRect::full());
632 assert!((t.scale.x - 4.0).abs() < 1e-5 && (t.scale.y - 4.0).abs() < 1e-5);
634 assert!(t.position.x.abs() < 1e-5 && t.position.y.abs() < 1e-5);
635 }
636
637 #[test]
638 fn framed_transform_zoom_pins_the_focal_corner() {
639 let z = ZoomTransform {
642 scale: 2.0,
643 center_x: 1.0,
644 center_y: 0.0,
645 };
646 let t = framed_transform(z, CropRect::full());
647 assert!((t.scale.x - 4.0).abs() < 1e-5);
648 assert!((t.position.x + 1.0).abs() < 1e-5, "right edge pinned");
649 assert!((t.position.y + 1.0).abs() < 1e-5, "top edge pinned");
650 }
651
652 #[test]
653 fn framed_transform_crop_fills_the_subrect() {
654 let crop = CropRect {
657 x: 0.0,
658 y: 0.0,
659 width: 0.5,
660 height: 0.5,
661 };
662 let t = framed_transform(ZoomTransform::identity(), crop);
663 assert!((t.scale.x - 4.0).abs() < 1e-5 && (t.scale.y - 4.0).abs() < 1e-5);
664 assert!((t.position.x - 1.0).abs() < 1e-5);
665 assert!((t.position.y + 1.0).abs() < 1e-5);
666 }
667
668 #[test]
669 fn framed_transform_padded_with_unit_factor_equals_unpadded() {
670 let z = ZoomTransform {
672 scale: 1.6,
673 center_x: 0.3,
674 center_y: 0.7,
675 };
676 let crop = CropRect {
677 x: 0.1,
678 y: 0.05,
679 width: 0.7,
680 height: 0.8,
681 };
682 let plain = framed_transform(z, crop);
683 let padded = framed_transform_padded(z, crop, 1.0, 1.0);
684 assert!((plain.scale.x - padded.scale.x).abs() < 1e-6);
685 assert!((plain.scale.y - padded.scale.y).abs() < 1e-6);
686 assert!((plain.position.x - padded.position.x).abs() < 1e-6);
687 assert!((plain.position.y - padded.position.y).abs() < 1e-6);
688 }
689
690 #[test]
691 fn framed_transform_padded_shrinks_about_the_centre() {
692 let t = framed_transform_padded(ZoomTransform::identity(), CropRect::full(), 0.5, 0.5);
694 assert!((t.scale.x - 1.0).abs() < 1e-6 && (t.scale.y - 1.0).abs() < 1e-6);
695 assert!(t.position.x.abs() < 1e-6 && t.position.y.abs() < 1e-6);
696 }
697
698 #[test]
699 fn framed_transform_padded_pins_the_focal_corner_inside_the_window() {
700 let z = ZoomTransform {
704 scale: 2.0,
705 center_x: 1.0,
706 center_y: 0.0,
707 };
708 let t = framed_transform_padded(z, CropRect::full(), 0.5, 0.5);
709 assert!((t.scale.x - 2.0).abs() < 1e-6);
710 assert!(
711 (t.position.x + 0.5).abs() < 1e-6,
712 "right edge pinned to window"
713 );
714 assert!(
715 (t.position.y + 0.5).abs() < 1e-6,
716 "top edge pinned to window"
717 );
718 }
719
720 #[test]
721 fn background_geometry_matches_reference_defaults() {
722 let (window, corner, (k_x, k_y)) = background_geometry(1920, 1080, 64, 14);
724 assert!((k_x - 0.933_333).abs() < 1e-4);
726 assert!((k_y - 0.881_481).abs() < 1e-4);
727 assert!((window.min.x + 0.933_333).abs() < 1e-4);
728 assert!((window.min.y + 0.881_481).abs() < 1e-4);
729 assert!((window.size.x - 1.866_667).abs() < 1e-4);
730 assert!((window.size.y - 1.762_963).abs() < 1e-4);
731 assert!((corner - 0.014_583).abs() < 1e-5);
733 }
734
735 #[test]
736 fn background_geometry_clamps_pathological_padding() {
737 let (_w, _c, (k_x, k_y)) = background_geometry(128, 128, 200, 0);
740 assert!(
741 k_x >= 0.05 && k_y >= 0.05,
742 "padding clamped, screen survives"
743 );
744 }
745
746 #[test]
753 fn render_framed_with_gradient_background_frames_the_screen() {
754 const SIDE: usize = 128;
755 let dim = u32::try_from(SIDE).expect("SIDE fits u32");
756 let mut preview = EditorPreview::new(dim, dim).expect("init wgpu");
757 let bg = BackgroundConfig {
758 source: BackgroundSource::Gradient {
759 from: [255, 138, 128],
760 to: [40, 53, 147],
761 angle_deg: 135.0,
762 },
763 padding: 16,
764 corner_radius: 24,
765 shadow: 0,
766 inset: 0,
767 };
768 preview.set_background(&bg);
769 let white = vec![255u8; SIDE * SIDE * 4];
770 let frame = preview
771 .render_framed(white, ZoomTransform::identity(), CropRect::full())
772 .expect("compose");
773 let pixel = |col: usize, row: usize| {
774 let base = (row * SIDE + col) * 4;
775 [
776 frame.bytes[base],
777 frame.bytes[base + 1],
778 frame.bytes[base + 2],
779 ] };
781 let is_white = |bgr: [u8; 3]| bgr[0] > 230 && bgr[1] > 230 && bgr[2] > 230;
782 assert!(
784 is_white(pixel(SIDE / 2, SIDE / 2)),
785 "centre is the white screen"
786 );
787 let corners = [
790 pixel(3, 3),
791 pixel(3, SIDE - 4),
792 pixel(SIDE - 4, 3),
793 pixel(SIDE - 4, SIDE - 4),
794 ];
795 for bgr in corners {
796 assert!(
797 !is_white(bgr),
798 "corner is backdrop, not the white screen ({bgr:?})"
799 );
800 let lum = u16::from(bgr[0]) + u16::from(bgr[1]) + u16::from(bgr[2]);
801 assert!(lum > 30, "backdrop drew (not black) at a corner ({bgr:?})");
802 }
803 let reds: Vec<i32> = corners.iter().map(|bgr| i32::from(bgr[2])).collect();
805 let spread = reds.iter().max().unwrap() - reds.iter().min().unwrap();
806 assert!(
807 spread > 15,
808 "backdrop varies across the canvas (gradient), spread={spread}"
809 );
810 }
811
812 #[test]
815 fn render_framed_with_color_background_fills_the_margin() {
816 const SIDE: usize = 128;
817 let dim = u32::try_from(SIDE).expect("SIDE fits u32");
818 let mut preview = EditorPreview::new(dim, dim).expect("init wgpu");
819 preview.set_background(&BackgroundConfig {
820 source: BackgroundSource::Color { rgb: [210, 40, 70] },
821 padding: 16,
822 corner_radius: 8,
823 shadow: 0,
824 inset: 0,
825 });
826 let white = vec![255u8; SIDE * SIDE * 4];
827 let frame = preview
828 .render_framed(white, ZoomTransform::identity(), CropRect::full())
829 .expect("compose");
830 let base = ((SIDE / 2) * SIDE + 3) * 4; let (blue, green, red) = (
834 frame.bytes[base],
835 frame.bytes[base + 1],
836 frame.bytes[base + 2],
837 );
838 assert!(
839 red > green && red > blue,
840 "R-dominant fill (got B{blue} G{green} R{red})"
841 );
842 assert!(red > 60, "backdrop colour drew (not black)");
843 assert!(
844 !(red > 230 && green > 230 && blue > 230),
845 "not the white screen"
846 );
847 }
848
849 #[test]
853 fn render_with_shadow_and_border_differs_from_without() {
854 const SIDE: usize = 128;
855 let dim = u32::try_from(SIDE).expect("SIDE fits u32");
856 let mut pv = EditorPreview::new(dim, dim).expect("init wgpu");
857 let cfg = |shadow: u32, inset: u32| BackgroundConfig {
858 source: BackgroundSource::Color {
859 rgb: [235, 235, 240],
860 },
861 padding: 18,
862 corner_radius: 10,
863 shadow,
864 inset,
865 };
866 let gray = || vec![128u8; SIDE * SIDE * 4];
867
868 pv.set_background(&cfg(0, 0));
869 let plain = pv
870 .render_framed(gray(), ZoomTransform::identity(), CropRect::full())
871 .expect("compose");
872 pv.set_background(&cfg(95, 8));
873 let decorated = pv
874 .render_framed(gray(), ZoomTransform::identity(), CropRect::full())
875 .expect("compose");
876
877 let diff = plain
878 .bytes
879 .iter()
880 .zip(decorated.bytes.iter())
881 .filter(|(a, b)| a.abs_diff(**b) > 16)
882 .count();
883 assert!(
884 diff > 200,
885 "shadow + inset border visibly change the frame ({diff} bytes differ)"
886 );
887 }
888
889 #[test]
890 fn wallpaper_rgba_is_well_formed_and_name_keyed() {
891 let aurora = wallpaper_rgba("aurora", 32, 18);
892 assert_eq!(aurora.len(), 32 * 18 * 4, "packed RGBA8 of the right size");
893 assert!(aurora.chunks_exact(4).all(|p| p[3] == 255), "opaque");
894 assert_eq!(wallpaper_rgba("nonsense", 32, 18), aurora);
896 let ocean = wallpaper_rgba("ocean", 32, 18);
898 assert_ne!(ocean, aurora, "named palettes differ");
899 assert!(
901 ocean
902 .chunks_exact(4)
903 .all(|p| u16::from(p[2]) >= u16::from(p[0])),
904 "ocean wallpaper: blue ≥ red everywhere"
905 );
906 }
907
908 #[test]
912 fn render_with_wallpaper_backdrop_fills_the_margin() {
913 const SIDE: usize = 128;
914 let dim = u32::try_from(SIDE).expect("SIDE fits u32");
915 let mut pv = EditorPreview::new(dim, dim).expect("init wgpu");
916 pv.set_background(&BackgroundConfig {
917 source: BackgroundSource::Wallpaper {
918 name: "ocean".to_string(),
919 },
920 padding: 18,
921 corner_radius: 8,
922 shadow: 0,
923 inset: 0,
924 });
925 let white = vec![255u8; SIDE * SIDE * 4];
926 let f = pv
927 .render_framed(white, ZoomTransform::identity(), CropRect::full())
928 .expect("compose");
929 let ci = ((SIDE / 2) * SIDE + SIDE / 2) * 4;
931 assert!(
932 f.bytes[ci] > 230 && f.bytes[ci + 1] > 230 && f.bytes[ci + 2] > 230,
933 "centre is the white screen"
934 );
935 let mi = (4 * SIDE + 4) * 4;
938 let (b, g, r) = (f.bytes[mi], f.bytes[mi + 1], f.bytes[mi + 2]);
939 assert!(
940 b > r,
941 "ocean wallpaper is blue-dominant in the margin (B{b} R{r})"
942 );
943 assert!(
944 !(b > 230 && g > 230 && r > 230),
945 "margin is the wallpaper, not the white screen"
946 );
947 }
948
949 #[test]
956 #[allow(
957 clippy::cast_precision_loss,
958 reason = "SIDE = 128, so every pixel count / coordinate is a small integer exact in f64"
959 )]
960 fn render_framed_with_cursor_rides_the_zoom() {
961 const SIDE: usize = 128;
962 let dim = u32::try_from(SIDE).expect("SIDE fits u32");
963 let mut pv = EditorPreview::new(dim, dim).expect("init wgpu");
964 let cfg = CursorConfig::default();
965 let gray = || vec![128u8; SIDE * SIDE * 4];
966
967 let white = |f: &ComposedFrame| -> (usize, f64, f64) {
969 let (mut n, mut sx, mut sy) = (0usize, 0.0, 0.0);
970 for row in 0..SIDE {
971 for col in 0..SIDE {
972 let i = (row * SIDE + col) * 4;
973 if f.bytes[i] > 220 && f.bytes[i + 1] > 220 && f.bytes[i + 2] > 220 {
974 n += 1;
975 sx += col as f64;
976 sy += row as f64;
977 }
978 }
979 }
980 if n == 0 {
981 (0, 0.0, 0.0)
982 } else {
983 (n, sx / n as f64, sy / n as f64)
984 }
985 };
986
987 let none = pv
989 .render_framed_with_cursor(
990 gray(),
991 ZoomTransform::identity(),
992 CropRect::full(),
993 None,
994 &[],
995 &cfg,
996 )
997 .expect("compose");
998 assert_eq!(white(&none).0, 0, "no pointer drawn when position is None");
999
1000 let one = pv
1002 .render_framed_with_cursor(
1003 gray(),
1004 ZoomTransform::identity(),
1005 CropRect::full(),
1006 Some((0.25, 0.25)),
1007 &[],
1008 &cfg,
1009 )
1010 .expect("compose");
1011 let (n1, cx1, cy1) = white(&one);
1012 assert!(n1 > 4, "pointer visible at 1× ({n1} white px)");
1013
1014 let two = pv
1018 .render_framed_with_cursor(
1019 gray(),
1020 ZoomTransform {
1021 scale: 2.0,
1022 center_x: 0.5,
1023 center_y: 0.5,
1024 },
1025 CropRect::full(),
1026 Some((0.25, 0.25)),
1027 &[],
1028 &cfg,
1029 )
1030 .expect("compose");
1031 let (n2, cx2, cy2) = white(&two);
1032 assert!(n2 > 4, "pointer visible at 2× ({n2} white px)");
1033
1034 let center = (SIDE as f64) / 2.0;
1035 let d1 = (cx1 - center).hypot(cy1 - center);
1036 let d2 = (cx2 - center).hypot(cy2 - center);
1037 assert!(
1038 d2 > d1,
1039 "cursor rides the zoom toward the corner (1×→{d1:.1}, 2×→{d2:.1} from centre)"
1040 );
1041 }
1042
1043 #[test]
1044 fn framed_transform_clamps_sub_one_zoom_to_no_zoom() {
1045 let z = ZoomTransform {
1047 scale: 0.5,
1048 center_x: 0.5,
1049 center_y: 0.5,
1050 };
1051 let t = framed_transform(z, CropRect::full());
1052 assert!((t.scale.x - 2.0).abs() < 1e-5, "clamped to base fill");
1053 }
1054
1055 #[test]
1060 fn render_framed_zoom_magnifies_the_focal_region() {
1061 const S: usize = 128;
1062 let pattern = {
1063 let mut b = vec![0u8; S * S * 4];
1064 for y in 0..S {
1065 for x in 0..S {
1066 let i = (y * S + x) * 4;
1067 if x.abs_diff(S / 2) < 8 && y.abs_diff(S / 2) < 8 {
1068 b[i] = 255;
1069 b[i + 1] = 255;
1070 b[i + 2] = 255;
1071 }
1072 b[i + 3] = 255;
1073 }
1074 }
1075 b
1076 };
1077 let dim = u32::try_from(S).expect("S fits u32");
1078 let mut pv = EditorPreview::new(dim, dim).expect("init wgpu");
1079 let white = |f: &ComposedFrame| {
1080 f.bytes
1081 .chunks_exact(4)
1082 .filter(|p| p[0] > 240 && p[1] > 240 && p[2] > 240)
1083 .count()
1084 };
1085 let none = pv
1086 .render_framed(pattern.clone(), ZoomTransform::identity(), CropRect::full())
1087 .expect("compose");
1088 let zoomed = pv
1089 .render_framed(
1090 pattern,
1091 ZoomTransform {
1092 scale: 2.0,
1093 center_x: 0.5,
1094 center_y: 0.5,
1095 },
1096 CropRect::full(),
1097 )
1098 .expect("compose");
1099 let (a, z) = (white(&none), white(&zoomed));
1100 assert!(a > 0 && z > 0, "marker visible in both ({a}, {z})");
1101 assert!(
1103 z >= 3 * a && z <= 5 * a,
1104 "2× zoom should ~4× the marker area (got {a} → {z})"
1105 );
1106 }
1107}