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 0000000..c74c4d4 Binary files /dev/null and b/guide/src/examples/align-horizontal.png differ diff --git a/src/styles.rs b/src/styles.rs index 9fc7b61..ccd8b63 100644 --- a/src/styles.rs +++ b/src/styles.rs @@ -1742,7 +1742,7 @@ impl FixedTheme { /// /// The goal of this type is to allow various tones of a given hue/saturation to /// be generated easily. -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug)] pub struct ColorSource { /// A measurement of hue, in degees, from -180 to 180. /// @@ -1760,6 +1760,13 @@ pub struct ColorSource { pub saturation: ZeroToOne, } +impl PartialEq for ColorSource { + fn eq(&self, other: &Self) -> 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()?;