From a197bb5e8132a4a47a08914815eed0f9b999e702 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Thu, 4 Jan 2024 13:56:45 -0800 Subject: [PATCH] Unit-tested, auto-generated screenshots This commit adds my first take at creating a harness for a user's guide using the new capture functionality. The example has tests that ensure the align widget creates the expected results. --- Cargo.lock | 14 +- Cargo.toml | 4 +- examples/offscreen-apng.rs | 1 + examples/offscreen.rs | 20 +-- guide/.gitignore | 1 + guide/book.toml | 6 + guide/guide-examples/Cargo.toml | 8 + guide/guide-examples/examples/align.rs | 137 +++++++++++++++ guide/guide-examples/src/lib.rs | 69 ++++++++ guide/src/SUMMARY.md | 3 + guide/src/chapter_1.md | 33 ++++ guide/src/examples/align-horizontal.png | Bin 0 -> 33666 bytes src/styles.rs | 9 +- src/widgets/space.rs | 40 ++++- src/window.rs | 212 ++++++++++++++++++++---- 15 files changed, 496 insertions(+), 61 deletions(-) create mode 100644 guide/.gitignore create mode 100644 guide/book.toml create mode 100644 guide/guide-examples/Cargo.toml create mode 100644 guide/guide-examples/examples/align.rs create mode 100644 guide/guide-examples/src/lib.rs create mode 100644 guide/src/SUMMARY.md create mode 100644 guide/src/chapter_1.md create mode 100644 guide/src/examples/align-horizontal.png diff --git a/Cargo.lock b/Cargo.lock index 7a9ed23..5e83ec8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -988,6 +988,13 @@ dependencies = [ "bitflags 2.4.1", ] +[[package]] +name = "guide-examples" +version = "0.0.0" +dependencies = [ + "cushy", +] + [[package]] name = "half" version = "2.2.1" @@ -1180,7 +1187,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kludgine" version = "0.7.0" -source = "git+https://github.com/khonsulabs/kludgine#6b0d5fa0477daaf79f75a6f7dad819377a45e645" +source = "git+https://github.com/khonsulabs/kludgine#8017775228d22b5efce6d6b7a89e81dfc9b25961" dependencies = [ "ahash", "alot", @@ -1193,6 +1200,7 @@ dependencies = [ "intentional", "justjson", "lyon_tessellation", + "palette", "pollster", "smallvec", "unicode-bidi", @@ -2332,9 +2340,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.47" +version = "2.0.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1726efe18f42ae774cc644f330953a5e7b3c3003d3edcecf18850fe9d4dd9afb" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 74fb601..504993e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["cushy-macros"] +members = ["cushy-macros", "guide/guide-examples"] [package] name = "cushy" @@ -41,6 +41,7 @@ zeroize = "1.6.1" unicode-segmentation = "1.10.1" pollster = "0.3.0" png = "0.17.10" +image = { version = "0.24.7", features = ["png"] } # [patch.crates-io] @@ -60,7 +61,6 @@ opt-level = 2 [dev-dependencies] rand = "0.8.5" -image = { version = "0.24.7", features = ["png"] } [profile.release] # debug = true diff --git a/examples/offscreen-apng.rs b/examples/offscreen-apng.rs index b1e0452..e1e699b 100644 --- a/examples/offscreen-apng.rs +++ b/examples/offscreen-apng.rs @@ -17,6 +17,7 @@ fn main() { .unwrap(); let initial_point = Point::new(Px::new(140), Px::new(150)); recorder.set_cursor_position(initial_point); + recorder.set_cursor_visible(true); recorder.refresh().unwrap(); let mut animation = recorder.record_animated_png(60); animation diff --git a/examples/offscreen.rs b/examples/offscreen.rs index c1b43ed..7ac5d96 100644 --- a/examples/offscreen.rs +++ b/examples/offscreen.rs @@ -12,15 +12,7 @@ fn main() { .size(Size::new(320, 240)) .finish() .unwrap(); - image::save_buffer_with_format( - "examples/offscreen.png", - recorder.bytes(), - recorder.window.size().width.get(), - recorder.window.size().height.get(), - image::ColorType::Rgb8, - image::ImageFormat::Png, - ) - .unwrap(); + recorder.image().save("examples/offscreen.png").unwrap(); // Creating a recorder with alpha makes the virtual window transparent. let recorder = ui() @@ -29,15 +21,7 @@ fn main() { .size(Size::new(320, 240)) .finish() .unwrap(); - image::save_buffer_with_format( - "examples/offscreen-transparent.png", - recorder.bytes(), - recorder.window.size().width.get(), - recorder.window.size().height.get(), - image::ColorType::Rgba8, - image::ImageFormat::Png, - ) - .unwrap(); + recorder.image().save("examples/offscreen.png").unwrap(); } #[test] diff --git a/guide/.gitignore b/guide/.gitignore new file mode 100644 index 0000000..7585238 --- /dev/null +++ b/guide/.gitignore @@ -0,0 +1 @@ +book diff --git a/guide/book.toml b/guide/book.toml new file mode 100644 index 0000000..0690ff1 --- /dev/null +++ b/guide/book.toml @@ -0,0 +1,6 @@ +[book] +authors = ["Jonathan Johnson"] +language = "en" +multilingual = false +src = "src" +title = "Cushy User's Guide" diff --git a/guide/guide-examples/Cargo.toml b/guide/guide-examples/Cargo.toml new file mode 100644 index 0000000..d1f540f --- /dev/null +++ b/guide/guide-examples/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "guide-examples" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +cushy = { version = "0.2.0", path = "../../" } diff --git a/guide/guide-examples/examples/align.rs b/guide/guide-examples/examples/align.rs new file mode 100644 index 0000000..2797060 --- /dev/null +++ b/guide/guide-examples/examples/align.rs @@ -0,0 +1,137 @@ +use cushy::figures::units::{Lp, Px}; +use cushy::figures::{Point, Size}; +use cushy::styles::{Edges, ThemePair}; +use cushy::widget::MakeWidget; +use cushy::widgets::Space; +use guide_examples::BookExample; + +fn content() -> impl MakeWidget { + Space::primary().size(Size::squared(Px::new(32))) +} + +fn main() { + BookExample::new( + "align-horizontal", + "Default Behavior" + .and(content()) + .and("align_left()") + .and({ + // ANCHOR: align-left + content().align_left() + // ANCHOR_END: align-left + }) + .and("pad_by().align_left()") + .and({ + // ANCHOR: align-left-pad + content() + .pad_by(Edges::default().with_left(Lp::inches(1))) + .align_left() + // ANCHOR_END: align-left-pad + }) + .and("centered()") + .and({ + // ANCHOR: centered + content().centered() + // ANCHOR_END: centered + }) + .and("pad_by().align_right()") + .and({ + // ANCHOR: align-right-pad + content() + .pad_by(Edges::default().with_right(Lp::inches(1))) + .align_right() + // ANCHOR_END: align-right-pad + }) + .and("align_right()") + .and({ + // ANCHOR: align-right + content().align_right() + // ANCHOR_END: align-right + }) + .into_rows(), + ) + .still_frame(|recorder| { + const LEFT: u32 = 40; + const PADDING: u32 = 96; + const RIGHT: u32 = 710; + const CENTER: u32 = 375; + + let container_color = ThemePair::default().dark.surface.lowest_container; + let primary = ThemePair::default().dark.primary.color; + + recorder.assert_pixel_color(Point::new(LEFT, 35), container_color, "surface"); + + // Default fills the entire space + recorder.assert_pixel_color(Point::new(LEFT, 70), primary, "default spacer"); + recorder.assert_pixel_color(Point::new(CENTER, 70), primary, "default spacer"); + recorder.assert_pixel_color(Point::new(RIGHT, 70), primary, "default spacer"); + + // align-left + recorder.assert_pixel_color(Point::new(LEFT, 140), primary, "align-left spacer"); + recorder.assert_pixel_color( + Point::new(LEFT + PADDING, 140), + container_color, + "align-left empty", + ); + + // align-left-pad + recorder.assert_pixel_color( + Point::new(LEFT + PADDING, 215), + primary, + "align-left-pad spacer", + ); + recorder.assert_pixel_color( + Point::new(LEFT, 215), + container_color, + "align-left-pad empty before", + ); + recorder.assert_pixel_color( + Point::new(CENTER, 215), + container_color, + "align-left-pad empty after", + ); + + // centered + recorder.assert_pixel_color(Point::new(CENTER, 295), primary, "centered spacer"); + recorder.assert_pixel_color( + Point::new(LEFT + PADDING, 295), + container_color, + "centered empty before", + ); + recorder.assert_pixel_color( + Point::new(RIGHT - PADDING, 295), + container_color, + "centered empty after", + ); + + // align-right-pad + recorder.assert_pixel_color( + Point::new(RIGHT - PADDING, 360), + primary, + "align-right-pad spacer", + ); + recorder.assert_pixel_color( + Point::new(CENTER, 360), + container_color, + "align-right-pad empty before", + ); + recorder.assert_pixel_color( + Point::new(RIGHT, 360), + container_color, + "align-right-pad empty after", + ); + + // align-right + recorder.assert_pixel_color(Point::new(RIGHT, 435), primary, "align-right spacer"); + recorder.assert_pixel_color( + Point::new(RIGHT - PADDING, 435), + container_color, + "align-right empty", + ); + }); +} + +#[test] +fn runs() { + main(); +} diff --git a/guide/guide-examples/src/lib.rs b/guide/guide-examples/src/lib.rs new file mode 100644 index 0000000..99930ff --- /dev/null +++ b/guide/guide-examples/src/lib.rs @@ -0,0 +1,69 @@ +use std::panic::AssertUnwindSafe; +use std::path::PathBuf; + +use cushy::figures::units::Px; +use cushy::figures::Size; +use cushy::widget::MakeWidget; +use cushy::widgets::container::ContainerShadow; +use cushy::window::{Rgba8, VirtualRecorder, VirtualRecorderBuilder}; + +pub struct BookExample { + name: &'static str, + recorder: VirtualRecorderBuilder, +} + +fn target_dir() -> PathBuf { + let target_dir = std::env::current_dir() + .expect("missing current dir") + .parent() + .expect("missing guide folder") + .join("src") + .join("examples"); + assert!( + target_dir.is_dir(), + "current directory is not guide-examples" + ); + + target_dir +} + +impl BookExample { + pub fn new(name: &'static str, interface: impl MakeWidget) -> Self { + Self { + name, + recorder: interface + .contain() + .shadow(ContainerShadow::drop(Px::new(16), Px::new(32))) + .width(Px::new(750)) + .build_recorder() + .with_alpha() + .resize_to_fit() + .size(Size::new(750, 432)), + } + } + + pub fn still_frame(self, test: Test) + where + Test: FnOnce(&mut VirtualRecorder), + { + let mut recorder = self.recorder.finish().unwrap(); + + let capture = std::env::var("CAPTURE").is_ok(); + let errored = std::panic::catch_unwind(AssertUnwindSafe(|| test(&mut recorder))).is_err(); + if errored || capture { + let path = target_dir().join(format!("{}.png", self.name)); + recorder.image().save(&path).expect("error saving file"); + println!("Wrote {}", path.display()); + + if errored { + std::process::exit(-1); + } + } + } + + // pub fn animated(self, test: Test) + // where + // Test: FnOnce(&mut AnimationRecorder<'_, Rgb8>), + // { + // } +} diff --git a/guide/src/SUMMARY.md b/guide/src/SUMMARY.md new file mode 100644 index 0000000..7390c82 --- /dev/null +++ b/guide/src/SUMMARY.md @@ -0,0 +1,3 @@ +# Summary + +- [Chapter 1](./chapter_1.md) diff --git a/guide/src/chapter_1.md b/guide/src/chapter_1.md new file mode 100644 index 0000000..c94bb7e --- /dev/null +++ b/guide/src/chapter_1.md @@ -0,0 +1,33 @@ +# Aligning Widgets + +![align.rs - horizontal-align](/examples/align-horizontal.png) + +## Align a widget to the left + +```rust,no_run,no_playground +{{#include ../guide-examples/examples/align.rs:align-left}} +``` + +## Align a widget to the left, with padding + +```rust,no_run,no_playground +{{#include ../guide-examples/examples/align.rs:align-left-pad}} +``` + +## Align a widget to the center + +```rust,no_run,no_playground +{{#include ../guide-examples/examples/align.rs:centered}} +``` + +## Align a widget to the right, with padding + +```rust,no_run,no_playground +{{#include ../guide-examples/examples/align.rs:align-right-pad}} +``` + +## Align a widget to the right + +```rust,no_run,no_playground +{{#include ../guide-examples/examples/align.rs:align-right}} +``` diff --git a/guide/src/examples/align-horizontal.png b/guide/src/examples/align-horizontal.png new file mode 100644 index 0000000000000000000000000000000000000000..c74c4d4cdbc84d4c508a5b3d66ebd80ecfb2f9e4 GIT binary patch literal 33666 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%0AvmD>>Vi9s=?i9tHJHXzNxr^7E^wuD8)}= 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 S%g(1P_-hYzUY~2d8~p bool { + (self.hue.into_degrees() - other.hue.into_degrees()).abs() < f32::EPSILON + && self.saturation == other.saturation + } +} + impl ColorSource { /// Returns a new source with the given hue (in degrees) and saturation (0.0 /// - 1.0). diff --git a/src/widgets/space.rs b/src/widgets/space.rs index 9bb7283..86dfa77 100644 --- a/src/widgets/space.rs +++ b/src/widgets/space.rs @@ -3,6 +3,8 @@ use figures::Size; use kludgine::Color; use crate::context::{GraphicsContext, LayoutContext}; +use crate::styles::components::PrimaryColor; +use crate::styles::{DynamicComponent, IntoDynamicComponentValue}; use crate::value::{IntoValue, Value}; use crate::widget::Widget; use crate::ConstraintLimit; @@ -10,7 +12,7 @@ use crate::ConstraintLimit; /// A widget that occupies space, optionally filling it with a color. #[derive(Debug, Clone)] pub struct Space { - color: Value, + color: Value, } impl Default for Space { @@ -24,7 +26,7 @@ impl Space { #[must_use] pub const fn clear() -> Self { Self { - color: Value::Constant(Color::CLEAR_BLACK), + color: Value::Constant(ColorSource::Color(Color::CLEAR_BLACK)), } } @@ -32,14 +34,38 @@ impl Space { #[must_use] pub fn colored(color: impl IntoValue) -> Self { Self { - color: color.into_value(), + color: color + .into_value() + .map_each(|color| ColorSource::Color(*color)), } } + + /// Returns a spacer that fills itself with `dynamic`'s color. + pub fn dynamic(dynamic: impl IntoDynamicComponentValue) -> Self { + Self { + color: dynamic + .into_dynamic_component() + .map_each(|component| ColorSource::Dynamic(component.clone())), + } + } + + /// Returns a spacer that fills itself with the value of [`PrimaryColor`]. + #[must_use] + pub fn primary() -> Self { + Self::dynamic(PrimaryColor) + } } impl Widget for Space { fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_>) { - let color = self.color.get_tracking_redraw(context); + let source = self.color.get_tracking_redraw(context); + let color = match source { + ColorSource::Color(color) => color, + ColorSource::Dynamic(component) => component + .resolve(context) + .and_then(|component| Color::try_from(component).ok()) + .unwrap_or(Color::CLEAR_BLACK), + }; context.fill(color); } @@ -51,3 +77,9 @@ impl Widget for Space { Size::default() } } + +#[derive(Debug, PartialEq, Clone)] +enum ColorSource { + Color(Color), + Dynamic(DynamicComponent), +} diff --git a/src/window.rs b/src/window.rs index ee3afa5..223b078 100644 --- a/src/window.rs +++ b/src/window.rs @@ -20,6 +20,7 @@ use figures::units::{Px, UPx}; use figures::{ Fraction, IntoSigned, IntoUnsigned, Point, Ranged, Rect, Round, ScreenScale, Size, Zero, }; +use image::{DynamicImage, RgbImage, RgbaImage}; use intentional::{Assert, Cast}; use kludgine::app::winit::dpi::{PhysicalPosition, PhysicalSize}; use kludgine::app::winit::event::{ @@ -728,6 +729,7 @@ pub trait WindowBehavior: Sized + 'static { } } +#[allow(clippy::struct_excessive_bools)] struct CushyWindow { behavior: T, tree: Tree, @@ -745,6 +747,7 @@ struct CushyWindow { keyboard_activated: Option, min_inner_size: Option>, max_inner_size: Option>, + resize_to_fit: bool, theme: Option>, current_theme: ThemePair, theme_mode: Value, @@ -1146,6 +1149,7 @@ where keyboard_activated: None, min_inner_size: None, max_inner_size: None, + resize_to_fit: false, current_theme, theme, theme_mode, @@ -1172,7 +1176,7 @@ where self.tree .new_frame(self.redraw_status.invalidations().drain()); - let resizable = window.is_resizable(); + let resizable = window.is_resizable() || self.resize_to_fit; let mut window = RunningWindow::new( window, graphics.id(), @@ -1237,6 +1241,8 @@ where new_size = new_size.min(max_size); } layout_context.request_inner_size(new_size); + } else if self.resize_to_fit && window_size != layout_size { + layout_context.request_inner_size(layout_size); } self.root.set_layout(Rect::from(render_size.into_signed())); @@ -1895,7 +1901,9 @@ pub(crate) mod sealed { use std::cell::RefCell; use figures::units::UPx; - use figures::Size; + use figures::{Point, Size}; + use image::DynamicImage; + use kludgine::Color; use crate::app::Cushy; use crate::context::sealed::InvalidationStatus; @@ -1940,6 +1948,8 @@ pub(crate) mod sealed { const HAS_ALPHA: bool; fn convert_rgba(data: &mut Vec, width: u32, bytes_per_row: u32); + fn load_image(data: &[u8], size: Size) -> DynamicImage; + fn pixel_color(location: Point, data: &[u8], size: Size) -> Color; } } @@ -2708,6 +2718,31 @@ impl sealed::CaptureFormat for Rgb8 { retain }); } + + fn load_image(data: &[u8], size: Size) -> DynamicImage { + DynamicImage::ImageRgb8( + RgbImage::from_vec(size.width.get(), size.height.get(), data.to_vec()) + .expect("incorrect dimensions"), + ) + } + + fn pixel_color(location: Point, data: &[u8], size: Size) -> Color { + let pixel_offset = pixel_offset(data, location, size, 3); + Color::new(pixel_offset[0], pixel_offset[1], pixel_offset[2], 255) + } +} + +fn pixel_offset( + data: &[u8], + location: Point, + size: Size, + bytes_per_component: u32, +) -> &[u8] { + assert!(location.x < size.width && location.y < size.height); + + let width = size.width.get(); + let index = location.y.get() * width + location.x.get(); + &data[usize::try_from(index * bytes_per_component).expect("offset out of bounds")..] } impl CaptureFormat for Rgba8 {} @@ -2727,6 +2762,23 @@ impl sealed::CaptureFormat for Rgba8 { }); } } + + fn load_image(data: &[u8], size: Size) -> DynamicImage { + DynamicImage::ImageRgba8( + RgbaImage::from_vec(size.width.get(), size.height.get(), data.to_vec()) + .expect("incorrect dimensions"), + ) + } + + fn pixel_color(location: Point, data: &[u8], size: Size) -> Color { + let pixel_offset = pixel_offset(data, location, size, 4); + Color::new( + pixel_offset[0], + pixel_offset[1], + pixel_offset[2], + pixel_offset[3], + ) + } } /// A builder of a [`VirtualRecorder`]. @@ -2735,6 +2787,7 @@ pub struct VirtualRecorderBuilder { size: Size, scale: f32, format: PhantomData, + resize_to_fit: bool, } impl VirtualRecorderBuilder { @@ -2745,6 +2798,7 @@ impl VirtualRecorderBuilder { size: Size::new(UPx::new(800), UPx::new(600)), scale: 1.0, format: PhantomData, + resize_to_fit: false, } } @@ -2756,6 +2810,7 @@ impl VirtualRecorderBuilder { contents: self.contents, size: self.size, scale: self.scale, + resize_to_fit: self.resize_to_fit, format: PhantomData, } } @@ -2787,9 +2842,17 @@ where self } + /// Sets this virtual recorder to allow updating its size based on the + /// contents being rendered. + #[must_use] + pub fn resize_to_fit(mut self) -> Self { + self.resize_to_fit = true; + self + } + /// Returns an initialized [`VirtualRecorder`]. pub fn finish(self) -> Result, VirtualRecorderError> { - VirtualRecorder::new(self.size, self.scale, self.contents) + VirtualRecorder::new(self.size, self.scale, self.resize_to_fit, self.contents) } } @@ -2864,7 +2927,9 @@ pub struct VirtualRecorder { queue: Arc, capture: Option>, data: Vec, + data_size: Size, cursor: Dynamic>, + cursor_visible: bool, cursor_graphic: Drawing, format: PhantomData, } @@ -2881,6 +2946,7 @@ where pub fn new( size: Size, scale: f32, + resize_to_fit: bool, contents: impl MakeWidget, ) -> Result { let wgpu = wgpu::Instance::default(); @@ -2912,11 +2978,18 @@ where queue: Arc::new(queue), cursor: Dynamic::default(), cursor_graphic: Drawing::default(), + cursor_visible: false, capture: None, data: Vec::new(), + data_size: Size::ZERO, format: PhantomData, }; + recorder.window.window.resize_to_fit = resize_to_fit; recorder.refresh()?; + + if resize_to_fit && recorder.window.state.size != recorder.window.size() { + recorder.refresh()?; + } Ok(recorder) } @@ -2927,6 +3000,52 @@ where &self.data } + /// Returns the color of the pixel at `location`. + /// + /// # Panics + /// + /// This function will panic if location is outside of the bounds of the + /// captured image. When the window's size has been changed, this function + /// operates on the size of the window when the last call to + /// [`Self::refresh()`] was made. + pub fn pixel_color(&self, location: Point) -> Color + where + Unit: Into, + { + Format::pixel_color(location.map(Into::into), self.bytes(), self.data_size) + } + + /// Asserts that the color of the pixel at `location` is `expected`. + /// + /// This function allows for slight color variations. This is because of how + /// colorspace corrections can lead to rounding errors. + /// + /// # Panics + /// + /// This function panics if the color is not the expected color. + pub fn assert_pixel_color(&self, location: Point, expected: Color, component: &str) + where + Unit: Into, + { + let location = location.map(Into::into); + let color = self.pixel_color(location); + let max_delta = color + .red() + .abs_diff(expected.red()) + .max(color.green().abs_diff(expected.green())) + .max(color.blue().abs_diff(expected.blue())) + .max(color.alpha().abs_diff(expected.alpha())); + assert!( + max_delta <= 1, + "assertion failed: {component} at {location:?} was {color:?}, not {expected:?}" + ); + } + + /// Returns the current contents as an image. + pub fn image(&self) -> DynamicImage { + Format::load_image(self.bytes(), self.data_size) + } + fn recreate_buffers_if_needed(&mut self, size: Size, bytes: u64, bytes_per_row: u32) { if self .capture @@ -2967,20 +3086,28 @@ where } fn redraw(&mut self) { - let render_size = self.window.kludgine.size().ceil(); + let mut render_size = self.window.kludgine.size().ceil(); + if self.window.state.size != render_size { + let current_scale = self.window.scale(); + self.window + .resize(self.window.state.size, current_scale, &self.queue); + render_size = self.window.state.size; + } let bytes_per_row = copy_buffer_aligned_bytes_per_row(render_size.width.get() * 4); let size = u64::from(bytes_per_row) * u64::from(render_size.height.get()); self.recreate_buffers_if_needed(render_size, size, bytes_per_row); let capture = self.capture.as_ref().assert("always initialized above"); - let mut gfx = self.window.graphics(&self.device, &self.queue); - let mut frame = self.cursor_graphic.new_frame(&mut gfx); - frame.draw_shape( - Shape::filled_circle(Px::new(4), Color::WHITE, Origin::Center) - .translate_by(self.cursor.get()), - ); - drop(frame); + if self.cursor_visible { + let mut gfx = self.window.graphics(&self.device, &self.queue); + let mut frame = self.cursor_graphic.new_frame(&mut gfx); + frame.draw_shape( + Shape::filled_circle(Px::new(4), Color::WHITE, Origin::Center) + .translate_by(self.cursor.get()), + ); + drop(frame); + } self.window.prepare(&self.device, &self.queue); @@ -3001,7 +3128,7 @@ where }, &self.device, &self.queue, - Some(&self.cursor_graphic), + self.cursor_visible.then_some(&self.cursor_graphic), ); } @@ -3012,6 +3139,7 @@ where let capture = self.capture.as_ref().assert("always initialized above"); capture.map_into::(&mut self.data, &self.device, &self.queue)?; + self.data_size = capture.texture.size(); Ok(()) } @@ -3021,11 +3149,28 @@ where self.cursor.set(position); } + /// Enables or disables drawing of the virtual cursor. + pub fn set_cursor_visible(&mut self, visible: bool) { + self.cursor_visible = visible; + } + /// Begins recording an animated png. pub fn record_animated_png(&mut self, target_fps: u8) -> AnimationRecorder<'_, Format> { AnimationRecorder { target_fps, - assembler: FrameAssembler::spawn::(self.device.clone(), self.queue.clone()), + assembler: Some(FrameAssembler::spawn::( + self.device.clone(), + self.queue.clone(), + )), + recorder: self, + } + } + + /// Returns a recorder that does not store any rendered frames. + pub fn simulate_animation(&mut self) -> AnimationRecorder<'_, Format> { + AnimationRecorder { + target_fps: 0, + assembler: None, recorder: self, } } @@ -3040,7 +3185,7 @@ fn copy_buffer_aligned_bytes_per_row(width: u32) -> u32 { pub struct AnimationRecorder<'a, Format> { recorder: &'a mut VirtualRecorder, target_fps: u8, - assembler: FrameAssembler, + assembler: Option, } impl AnimationRecorder<'_, Format> @@ -3070,6 +3215,10 @@ where /// Waits until `time`, rendering frames as needed. pub fn wait_until(&mut self, time: Instant) -> Result<(), VirtualRecorderError> { + let Some(assembler) = self.assembler.as_ref() else { + return Ok(()); + }; + let frame_duration = Duration::from_micros(1_000_000 / u64::from(self.target_fps)); let mut last_frame = Instant::now(); @@ -3090,19 +3239,19 @@ where if final_frame || next_frame == now { // Try to reuse an existing capture instead of forcing an // allocation. - if let Ok(capture) = self.assembler.resuable_captures.try_recv() { + if let Ok(capture) = assembler.resuable_captures.try_recv() { self.recorder.capture = Some(capture); } let elapsed = now.saturating_duration_since(last_frame); last_frame = now; self.recorder.redraw(); let capture = self.recorder.capture.take().assert("always present"); - if self.assembler.sender.send((capture, elapsed)).is_err() { + if assembler.sender.send((capture, elapsed)).is_err() { break; } } - if now > time { + if final_frame { break; } @@ -3114,8 +3263,13 @@ where } /// Encodes the currently recorded frames into a new file at `path`. + /// + /// If this animation was created from + /// [`VirtualRecorder::simulate_animation`], this function will do nothing. pub fn write_to(self, path: impl AsRef) -> Result<(), VirtualRecorderError> { - let frames = self.assembler.finish()?; + let Some(frames) = self.assembler.map(FrameAssembler::finish).transpose()? else { + return Ok(()); + }; let mut file = std::fs::OpenOptions::new() .create(true) .truncate(true) @@ -3131,28 +3285,20 @@ where encoder.set_animated(u32::try_from(frames.len()).assert("too many frames"), 0)?; encoder.set_compression(png::Compression::Best); - let mut current_frame_delay = frames.first().assert("always at least one frame").duration; - encoder.set_frame_delay( - current_frame_delay - .as_millis() - // TODO should be checked - .cast(), - 1_000, - )?; + let mut current_frame_delay = Duration::ZERO; let mut writer = encoder.write_header()?; for frame in &frames { + writer.write_image_data(&frame.data)?; if current_frame_delay != frame.duration { current_frame_delay = frame.duration; + // This has a limitation that a single frame can't be longer + // than ~6.5 seconds, but it ensures frame timing is more + // accurate. writer.set_frame_delay( - current_frame_delay - .as_millis() - // TODO should be checked - .cast(), - 1_000, + u16::try_from(current_frame_delay.as_nanos() / 100_000).unwrap_or(u16::MAX), + 10_000, )?; } - - writer.write_image_data(&frame.data)?; } writer.finish()?;