From bc52be440f19dc6479b191aee539e8be9f118f0f Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Wed, 3 Jan 2024 17:45:11 -0800 Subject: [PATCH] Added animation recording + event wiring Not great, but it technically exists. --- .github/workflows/rust.yml | 3 + Cargo.lock | 15 +- Cargo.toml | 1 + examples/custom-widgets.rs | 3 +- examples/offscreen-apng.rs | 38 ++ examples/offscreen.rs | 8 +- src/animation.rs | 38 +- src/context.rs | 6 +- src/widget.rs | 6 +- src/widgets/button.rs | 4 +- src/widgets/color.rs | 3 +- src/widgets/custom.rs | 5 +- src/widgets/disclose.rs | 5 +- src/widgets/input.rs | 8 +- src/widgets/scroll.rs | 3 +- src/widgets/slider.rs | 3 +- src/widgets/tilemap.rs | 3 +- src/window.rs | 985 ++++++++++++++++++++++++++----------- 18 files changed, 807 insertions(+), 330 deletions(-) create mode 100644 examples/offscreen-apng.rs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d586037..f3d77bc 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -41,5 +41,8 @@ jobs: cargo build --all-features --all-targets - name: Run all features unit tests + # for msrv, we only check build compatibility, as it's possible bugs are + # fixed purely by updating the rust version. + if: matrix.version == 'stable' run: | cargo test --all-features --all-targets diff --git a/Cargo.lock b/Cargo.lock index f24cbea..7a9ed23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -582,6 +582,7 @@ dependencies = [ "kempt", "kludgine", "palette", + "png", "pollster", "rand", "tracing", @@ -1179,7 +1180,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kludgine" version = "0.7.0" -source = "git+https://github.com/khonsulabs/kludgine#9999ff4a323a6dec1deebb9a0a1825d561559478" +source = "git+https://github.com/khonsulabs/kludgine#6b0d5fa0477daaf79f75a6f7dad819377a45e645" dependencies = [ "ahash", "alot", @@ -1858,9 +1859,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.74" +version = "1.0.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2de98502f212cfcea8d0bb305bd0f49d7ebdd75b64ba0a68f937d888f4e0d6db" +checksum = "907a61bd0f64c2f29cd1cf1dc34d05176426a3f504a78010f08416ddb7b13708" dependencies = [ "unicode-ident", ] @@ -2331,9 +2332,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.46" +version = "2.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89456b690ff72fddcecf231caedbe615c59480c93358a93dfae7fc29e3ebbf0e" +checksum = "1726efe18f42ae774cc644f330953a5e7b3c3003d3edcecf18850fe9d4dd9afb" dependencies = [ "proc-macro2", "quote", @@ -3233,9 +3234,9 @@ dependencies = [ [[package]] name = "winnow" -version = "0.5.31" +version = "0.5.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a4882e6b134d6c28953a387571f1acdd3496830d5e36c5e3a1075580ea641c" +checksum = "8434aeec7b290e8da5c3f0d628cb0eac6cabcb31d14bb74f779a08109a5914d6" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index f302ca9..74fb601 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ arboard = "3.2.1" zeroize = "1.6.1" unicode-segmentation = "1.10.1" pollster = "0.3.0" +png = "0.17.10" # [patch.crates-io] diff --git a/examples/custom-widgets.rs b/examples/custom-widgets.rs index 9891f40..14173ef 100644 --- a/examples/custom-widgets.rs +++ b/examples/custom-widgets.rs @@ -4,6 +4,7 @@ use cushy::value::{Destination, Dynamic, Source}; use cushy::widget::{MakeWidget, MakeWidgetWithTag, Widget, WidgetInstance, WidgetTag, HANDLED}; use cushy::widgets::Custom; +use cushy::window::DeviceId; use cushy::Run; use figures::units::{Lp, UPx}; use figures::{ScreenScale, Size}; @@ -116,7 +117,7 @@ impl Widget for Toggle { fn mouse_down( &mut self, _location: figures::Point, - _device_id: kludgine::app::winit::event::DeviceId, + _device_id: DeviceId, _button: kludgine::app::winit::event::MouseButton, _context: &mut cushy::context::EventContext<'_>, ) -> cushy::widget::EventHandling { diff --git a/examples/offscreen-apng.rs b/examples/offscreen-apng.rs new file mode 100644 index 0000000..8fc6751 --- /dev/null +++ b/examples/offscreen-apng.rs @@ -0,0 +1,38 @@ +use std::time::Duration; + +use cushy::animation::easings::EaseInOutSine; +use cushy::widget::MakeWidget; +use figures::units::Px; +use figures::{Point, Size}; + +fn ui() -> impl MakeWidget { + "Hello World".into_button().centered() +} + +fn main() { + // The default recorder generated solid, rgb images. + let mut recorder = ui() + .build_recorder() + .size(Size::new(320, 240)) + .finish() + .unwrap(); + let initial_point = Point::new(Px::new(140), Px::new(150)); + recorder.set_cursor_position(initial_point); + recorder.refresh().unwrap(); + let mut animation = recorder.record_animated_png(60); + animation + .animate_cursor_to( + Point::new(Px::new(160), Px::new(120)), + Duration::from_millis(250), + EaseInOutSine, + ) + .unwrap(); + animation.wait_for(Duration::from_millis(500)).unwrap(); + animation + .animate_cursor_to(initial_point, Duration::from_millis(250), EaseInOutSine) + .unwrap(); + animation.wait_for(Duration::from_millis(500)).unwrap(); + animation + .write_to("examples/offscreen-animated.png") + .unwrap(); +} diff --git a/examples/offscreen.rs b/examples/offscreen.rs index dca0bd9..c1b43ed 100644 --- a/examples/offscreen.rs +++ b/examples/offscreen.rs @@ -15,8 +15,8 @@ fn main() { image::save_buffer_with_format( "examples/offscreen.png", recorder.bytes(), - recorder.size().width.get(), - recorder.size().height.get(), + recorder.window.size().width.get(), + recorder.window.size().height.get(), image::ColorType::Rgb8, image::ImageFormat::Png, ) @@ -32,8 +32,8 @@ fn main() { image::save_buffer_with_format( "examples/offscreen-transparent.png", recorder.bytes(), - recorder.size().width.get(), - recorder.size().height.get(), + recorder.window.size().width.get(), + recorder.window.size().height.get(), image::ColorType::Rgba8, image::ImageFormat::Png, ) diff --git a/src/animation.rs b/src/animation.rs index 256cdc6..f3594b2 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -49,7 +49,7 @@ use std::time::{Duration, Instant}; use alot::{LotId, Lots}; use figures::units::{Lp, Px, UPx}; -use figures::{Angle, Ranged, UnscaledUnit, Zero}; +use figures::{Angle, Point, Ranged, Rect, Size, UnscaledUnit, Zero}; use intentional::Cast; use kempt::Set; use kludgine::Color; @@ -929,6 +929,42 @@ impl_unscaled_lerp!(Px); impl_unscaled_lerp!(Lp); impl_unscaled_lerp!(UPx); +impl LinearInterpolate for Point +where + Unit: LinearInterpolate, +{ + fn lerp(&self, target: &Self, percent: f32) -> Self { + Self::new( + self.x.lerp(&target.x, percent), + self.y.lerp(&target.y, percent), + ) + } +} + +impl LinearInterpolate for Size +where + Unit: LinearInterpolate, +{ + fn lerp(&self, target: &Self, percent: f32) -> Self { + Self::new( + self.width.lerp(&target.width, percent), + self.height.lerp(&target.height, percent), + ) + } +} + +impl LinearInterpolate for Rect +where + Unit: LinearInterpolate, +{ + fn lerp(&self, target: &Self, percent: f32) -> Self { + Self::new( + self.origin.lerp(&target.origin, percent), + self.size.lerp(&target.size, percent), + ) + } +} + #[test] fn integer_lerps() { #[track_caller] diff --git a/src/context.rs b/src/context.rs index 417d3b0..56b215a 100644 --- a/src/context.rs +++ b/src/context.rs @@ -4,9 +4,7 @@ use std::ops::{Deref, DerefMut}; use figures::units::{Lp, Px, UPx}; use figures::{IntoSigned, Point, Px2D, Rect, Round, ScreenScale, Size, Zero}; -use kludgine::app::winit::event::{ - DeviceId, Ime, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, -}; +use kludgine::app::winit::event::{Ime, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase}; use kludgine::app::winit::window::CursorIcon; use kludgine::shapes::{Shape, StrokeOptions}; use kludgine::{Color, Kludgine, KludgineId}; @@ -21,7 +19,7 @@ use crate::styles::{ComponentDefinition, Styles, Theme, ThemePair}; use crate::tree::Tree; use crate::value::{IntoValue, Source, Value}; use crate::widget::{EventHandling, MountedWidget, RootBehavior, WidgetId, WidgetInstance}; -use crate::window::{CursorState, PlatformWindow, ThemeMode}; +use crate::window::{CursorState, DeviceId, PlatformWindow, ThemeMode}; use crate::ConstraintLimit; /// A context to an event function. diff --git a/src/widget.rs b/src/widget.rs index b1e43f3..bcc556e 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -12,9 +12,7 @@ use alot::LotId; use figures::units::{Px, UPx}; use figures::{IntoSigned, IntoUnsigned, Point, Rect, Size}; use intentional::Assert; -use kludgine::app::winit::event::{ - DeviceId, Ime, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, -}; +use kludgine::app::winit::event::{Ime, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase}; use kludgine::app::winit::window::CursorIcon; use kludgine::Color; @@ -47,7 +45,7 @@ use crate::widgets::{ }; use crate::window::sealed::WindowCommand; use crate::window::{ - Rgb8, RunningWindow, ThemeMode, VirtualRecorderBuilder, VirtualWindowBuilder, Window, + DeviceId, Rgb8, RunningWindow, ThemeMode, VirtualRecorderBuilder, VirtualWindowBuilder, Window, WindowBehavior, WindowHandle, WindowLocal, }; use crate::ConstraintLimit; diff --git a/src/widgets/button.rs b/src/widgets/button.rs index 55d0c82..b0d3a4a 100644 --- a/src/widgets/button.rs +++ b/src/widgets/button.rs @@ -3,7 +3,7 @@ use std::time::Duration; use figures::units::{Lp, Px, UPx}; use figures::{IntoSigned, Point, Rect, Round, ScreenScale, Size}; -use kludgine::app::winit::event::{DeviceId, MouseButton}; +use kludgine::app::winit::event::MouseButton; use kludgine::app::winit::window::CursorIcon; use kludgine::shapes::{Shape, StrokeOptions}; use kludgine::Color; @@ -21,7 +21,7 @@ use crate::styles::components::{ use crate::styles::{ColorExt, Styles}; use crate::value::{Destination, Dynamic, IntoValue, Source, Value}; use crate::widget::{Callback, EventHandling, MakeWidget, Widget, WidgetRef, HANDLED}; -use crate::window::WindowLocal; +use crate::window::{DeviceId, WindowLocal}; use crate::FitMeasuredSize; /// A clickable button. diff --git a/src/widgets/color.rs b/src/widgets/color.rs index 86c59f6..f026ed9 100644 --- a/src/widgets/color.rs +++ b/src/widgets/color.rs @@ -4,7 +4,7 @@ use std::ops::Range; use figures::units::{Lp, Px}; use figures::{FloatConversion, Point, Rect, Round, ScreenScale, Zero}; use intentional::Cast; -use kludgine::app::winit::event::{DeviceId, MouseButton}; +use kludgine::app::winit::event::MouseButton; use kludgine::shapes::{self, FillOptions, PathBuilder, Shape, StrokeOptions}; use kludgine::{Color, DrawableExt, Origin}; @@ -14,6 +14,7 @@ use crate::styles::components::{HighlightColor, OutlineColor, TextColor}; use crate::styles::{ColorExt, ColorSource}; use crate::value::{Destination, Dynamic, IntoValue, Source, Value}; use crate::widget::{EventHandling, Widget, HANDLED}; +use crate::window::DeviceId; /// A widget that selects a [`ColorSource`]. #[derive(Debug)] diff --git a/src/widgets/custom.rs b/src/widgets/custom.rs index 1553c93..8220346 100644 --- a/src/widgets/custom.rs +++ b/src/widgets/custom.rs @@ -2,9 +2,7 @@ use std::fmt::Debug; use figures::units::Px; use figures::{Point, Size}; -use kludgine::app::winit::event::{ - DeviceId, Ime, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, -}; +use kludgine::app::winit::event::{Ime, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase}; use kludgine::app::winit::window::CursorIcon; use kludgine::Color; @@ -13,6 +11,7 @@ use crate::styles::VisualOrder; use crate::value::{IntoValue, Value}; use crate::widget::{EventHandling, MakeWidget, WidgetRef, WrappedLayout, WrapperWidget, IGNORED}; use crate::widgets::Space; +use crate::window::DeviceId; use crate::ConstraintLimit; /// A callback-based custom widget. diff --git a/src/widgets/disclose.rs b/src/widgets/disclose.rs index 3c01365..870e311 100644 --- a/src/widgets/disclose.rs +++ b/src/widgets/disclose.rs @@ -18,6 +18,7 @@ use crate::widget::{ EventHandling, MakeWidget, MakeWidgetWithTag, Widget, WidgetInstance, WidgetRef, WidgetTag, HANDLED, IGNORED, }; +use crate::window::DeviceId; use crate::ConstraintLimit; /// A widget that hides and shows another widget. @@ -316,7 +317,7 @@ impl Widget for DiscloseIndicator { fn mouse_down( &mut self, location: Point, - _device_id: kludgine::app::winit::event::DeviceId, + _device_id: DeviceId, _button: kludgine::app::winit::event::MouseButton, context: &mut EventContext<'_>, ) -> EventHandling { @@ -333,7 +334,7 @@ impl Widget for DiscloseIndicator { fn mouse_up( &mut self, _location: Option>, - _device_id: kludgine::app::winit::event::DeviceId, + _device_id: DeviceId, _button: kludgine::app::winit::event::MouseButton, context: &mut EventContext<'_>, ) { diff --git a/src/widgets/input.rs b/src/widgets/input.rs index 1dddd11..515bdc0 100644 --- a/src/widgets/input.rs +++ b/src/widgets/input.rs @@ -973,7 +973,7 @@ where fn mouse_down( &mut self, location: Point, - _device_id: kludgine::app::winit::event::DeviceId, + _device_id: crate::window::DeviceId, _button: kludgine::app::winit::event::MouseButton, context: &mut EventContext<'_>, ) -> EventHandling { @@ -997,7 +997,7 @@ where fn mouse_drag( &mut self, location: Point, - _device_id: kludgine::app::winit::event::DeviceId, + _device_id: crate::window::DeviceId, _button: kludgine::app::winit::event::MouseButton, context: &mut EventContext<'_>, ) { @@ -1012,7 +1012,7 @@ where fn mouse_up( &mut self, _location: Option>, - _device_id: kludgine::app::winit::event::DeviceId, + _device_id: crate::window::DeviceId, _button: kludgine::app::winit::event::MouseButton, _context: &mut EventContext<'_>, ) { @@ -1188,7 +1188,7 @@ where fn keyboard_input( &mut self, - _device_id: kludgine::app::winit::event::DeviceId, + _device_id: crate::window::DeviceId, input: kludgine::app::winit::event::KeyEvent, _is_synthetic: bool, context: &mut EventContext<'_>, diff --git a/src/widgets/scroll.rs b/src/widgets/scroll.rs index 208e0bc..41bf7dd 100644 --- a/src/widgets/scroll.rs +++ b/src/widgets/scroll.rs @@ -4,7 +4,7 @@ use std::time::{Duration, Instant}; use figures::units::{Lp, Px, UPx}; use figures::{FloatConversion, IntoSigned, IntoUnsigned, Point, Rect, ScreenScale, Size, Zero}; use intentional::Cast; -use kludgine::app::winit::event::{DeviceId, MouseScrollDelta, TouchPhase}; +use kludgine::app::winit::event::{MouseScrollDelta, TouchPhase}; use kludgine::app::winit::window::CursorIcon; use kludgine::shapes::Shape; use kludgine::Color; @@ -15,6 +15,7 @@ use crate::styles::components::{EasingIn, EasingOut, LineHeight}; use crate::styles::Dimension; use crate::value::{Destination, Dynamic, Source}; use crate::widget::{EventHandling, MakeWidget, Widget, WidgetRef, HANDLED, IGNORED}; +use crate::window::DeviceId; use crate::ConstraintLimit; /// A widget that supports scrolling its contents. diff --git a/src/widgets/slider.rs b/src/widgets/slider.rs index 390400a..4987564 100644 --- a/src/widgets/slider.rs +++ b/src/widgets/slider.rs @@ -6,7 +6,7 @@ use std::ops::RangeInclusive; use figures::units::{Lp, Px, UPx}; use figures::{FloatConversion, IntoSigned, Point, Ranged, Rect, Round, ScreenScale, Size}; use intentional::{Assert, Cast as _}; -use kludgine::app::winit::event::{DeviceId, MouseButton, MouseScrollDelta, TouchPhase}; +use kludgine::app::winit::event::{MouseButton, MouseScrollDelta, TouchPhase}; use kludgine::app::winit::keyboard::{Key, NamedKey}; use kludgine::app::winit::window::CursorIcon; use kludgine::shapes::{Shape, StrokeOptions}; @@ -21,6 +21,7 @@ use crate::styles::components::{ use crate::styles::{Dimension, HorizontalOrder, VerticalOrder, VisualOrder}; use crate::value::{Destination, Dynamic, IntoDynamic, IntoValue, Source, Value}; use crate::widget::{EventHandling, Widget, HANDLED, IGNORED}; +use crate::window::DeviceId; use crate::ConstraintLimit; /// A widget that allows sliding between two values. diff --git a/src/widgets/tilemap.rs b/src/widgets/tilemap.rs index 521470b..b976470 100644 --- a/src/widgets/tilemap.rs +++ b/src/widgets/tilemap.rs @@ -3,7 +3,7 @@ use std::fmt::Debug; use figures::units::{Px, UPx}; use figures::{Point, Size}; use intentional::Cast; -use kludgine::app::winit::event::{DeviceId, ElementState, KeyEvent, MouseScrollDelta, TouchPhase}; +use kludgine::app::winit::event::{ElementState, KeyEvent, MouseScrollDelta, TouchPhase}; use kludgine::app::winit::window::CursorIcon; use kludgine::tilemap; use kludgine::tilemap::TileMapFocus; @@ -12,6 +12,7 @@ use crate::context::{EventContext, GraphicsContext, LayoutContext, Trackable}; use crate::tick::Tick; use crate::value::{Dynamic, IntoValue, Value}; use crate::widget::{EventHandling, Widget, HANDLED, IGNORED}; +use crate::window::DeviceId; use crate::ConstraintLimit; /// A layered tile-based 2d game surface. diff --git a/src/window.rs b/src/window.rs index fcbc53a..394920a 100644 --- a/src/window.rs +++ b/src/window.rs @@ -16,22 +16,27 @@ use ahash::AHashMap; use alot::LotId; use arboard::Clipboard; use figures::units::{Px, UPx}; -use figures::{IntoSigned, IntoUnsigned, Point, Ranged, Rect, Round, ScreenScale, Size, Zero}; +use figures::{ + Fraction, IntoSigned, IntoUnsigned, Point, Ranged, Rect, Round, ScreenScale, Size, Zero, +}; use intentional::{Assert, Cast}; use kludgine::app::winit::dpi::{PhysicalPosition, PhysicalSize}; use kludgine::app::winit::event::{ - DeviceId, ElementState, Ime, KeyEvent, Modifiers, MouseButton, MouseScrollDelta, TouchPhase, + ElementState, Ime, KeyEvent, Modifiers, MouseButton, MouseScrollDelta, TouchPhase, }; use kludgine::app::winit::keyboard::{Key, NamedKey}; use kludgine::app::winit::window::{self, CursorIcon}; use kludgine::app::{winit, WindowBehavior as _}; use kludgine::cosmic_text::{fontdb, Family, FamilyOwned}; use kludgine::render::Drawing; +use kludgine::shapes::Shape; use kludgine::wgpu::{self, CompositeAlphaMode, COPY_BYTES_PER_ROW_ALIGNMENT}; -use kludgine::{Color, Kludgine, KludgineId, Texture}; +use kludgine::{Color, DrawableExt, Kludgine, KludgineId, Origin, Texture}; use tracing::Level; -use crate::animation::{LinearInterpolate, PercentBetween, ZeroToOne}; +use crate::animation::{ + AnimationTarget, Easing, LinearInterpolate, PercentBetween, Spawn, ZeroToOne, +}; use crate::app::{Application, Cushy, Open, PendingApp, Run}; use crate::context::sealed::InvalidationStatus; use crate::context::{ @@ -701,9 +706,9 @@ pub trait WindowBehavior: Sized + 'static { /// the window will be closed. Returning false prevents the window from /// closing. #[allow(unused_variables)] - fn close_requested(&self, window: &mut RunningWindow) -> bool + fn close_requested(&self, window: &mut W) -> bool where - W: PlatformWindowImplementation, + W: PlatformWindow, { true } @@ -762,13 +767,15 @@ where *should_close } - fn keyboard_activate_widget( + fn keyboard_activate_widget( &mut self, is_pressed: bool, widget: Option, - window: &mut RunningWindow>, + window: &mut W, kludgine: &mut Kludgine, - ) { + ) where + W: PlatformWindow, + { if is_pressed { if let Some(default) = widget.and_then(|id| self.tree.widget_from_node(id)) { if let Some(previously_active) = self @@ -968,19 +975,22 @@ where fonts } - fn handle_window_keyboard_input( + fn handle_window_keyboard_input( &mut self, - window: &mut RunningWindow>, + window: &mut W, kludgine: &mut Kludgine, input: KeyEvent, - ) { + ) -> EventHandling + where + W: PlatformWindow, + { match input.logical_key { Key::Character(ch) if ch == "w" && window.modifiers().primary() => { - if input.state.is_pressed() - && Self::request_close(&mut self.should_close, &mut self.behavior, window) - { + if input.state.is_pressed() && self.behavior.close_requested(window) { + self.should_close = true; window.set_needs_redraw(); } + HANDLED } Key::Named(NamedKey::Space) if !window.modifiers().possible_shortcut() => { let target = self.tree.focused_widget().unwrap_or(self.root.node_id); @@ -1008,6 +1018,7 @@ where target.deactivate(); } } + HANDLED } Key::Named(NamedKey::Tab) if !window.modifiers().possible_shortcut() => { @@ -1033,6 +1044,7 @@ where target.advance_focus(); } } + HANDLED } Key::Named(NamedKey::Enter) => { self.keyboard_activate_widget( @@ -1041,6 +1053,7 @@ where window, kludgine, ); + HANDLED } Key::Named(NamedKey::Escape) => { self.keyboard_activate_widget( @@ -1049,6 +1062,7 @@ where window, kludgine, ); + HANDLED } _ => { tracing::event!( @@ -1058,6 +1072,7 @@ where state = ?input.state, "Ignored Keyboard Input", ); + IGNORED } } } @@ -1262,6 +1277,336 @@ where false } } + + fn resized(&mut self, new_size: Size) { + self.inner_size.set(new_size); + // We want to prevent a resize request for this resized event. + self.inner_size_generation = self.inner_size.generation(); + self.root.invalidate(); + } + + pub fn keyboard_input( + &mut self, + window: W, + kludgine: &mut Kludgine, + device_id: DeviceId, + input: KeyEvent, + is_synthetic: bool, + ) -> EventHandling + where + W: PlatformWindowImplementation, + { + let mut window = RunningWindow::new( + window, + kludgine.id(), + &self.redraw_status, + &self.cushy, + &self.focused, + &self.occluded, + &self.inner_size, + ); + let target = self.tree.focused_widget().unwrap_or(self.root.node_id); + let Some(target) = self.tree.widget_from_node(target) else { + return IGNORED; + }; + let mut target = EventContext::new( + WidgetContext::new( + target, + &self.current_theme, + &mut window, + self.theme_mode.get(), + &mut self.cursor, + ), + kludgine, + ); + + if recursively_handle_event(&mut target, |widget| { + widget.keyboard_input(device_id, input.clone(), is_synthetic) + }) + .is_some() + { + return HANDLED; + } + drop(target); + + self.handle_window_keyboard_input(&mut window, kludgine, input) + } + + pub fn mouse_wheel( + &mut self, + window: W, + kludgine: &mut Kludgine, + device_id: DeviceId, + delta: MouseScrollDelta, + phase: TouchPhase, + ) -> EventHandling + where + W: PlatformWindowImplementation, + { + let mut window = RunningWindow::new( + window, + kludgine.id(), + &self.redraw_status, + &self.cushy, + &self.focused, + &self.occluded, + &self.inner_size, + ); + let widget = self + .tree + .hovered_widget() + .and_then(|hovered| self.tree.widget_from_node(hovered)) + .unwrap_or_else(|| self.tree.widget(self.root.id()).expect("missing widget")); + + let mut widget = EventContext::new( + WidgetContext::new( + widget, + &self.current_theme, + &mut window, + self.theme_mode.get(), + &mut self.cursor, + ), + kludgine, + ); + if recursively_handle_event(&mut widget, |widget| { + widget.mouse_wheel(device_id, delta, phase) + }) + .is_some() + { + HANDLED + } else { + IGNORED + } + } + + fn ime(&mut self, window: W, kludgine: &mut Kludgine, ime: &Ime) -> EventHandling + where + W: PlatformWindowImplementation, + { + let mut window = RunningWindow::new( + window, + kludgine.id(), + &self.redraw_status, + &self.cushy, + &self.focused, + &self.occluded, + &self.inner_size, + ); + let widget = self + .tree + .focused_widget() + .and_then(|hovered| self.tree.widget_from_node(hovered)) + .unwrap_or_else(|| self.tree.widget(self.root.id()).expect("missing widget")); + let mut target = EventContext::new( + WidgetContext::new( + widget, + &self.current_theme, + &mut window, + self.theme_mode.get(), + &mut self.cursor, + ), + kludgine, + ); + + if recursively_handle_event(&mut target, |widget| widget.ime(ime.clone())).is_some() { + HANDLED + } else { + IGNORED + } + } + + fn cursor_moved( + &mut self, + window: W, + kludgine: &mut Kludgine, + device_id: DeviceId, + position: impl Into>, + ) where + W: PlatformWindowImplementation, + { + let mut window = RunningWindow::new( + window, + kludgine.id(), + &self.redraw_status, + &self.cushy, + &self.focused, + &self.occluded, + &self.inner_size, + ); + + let location = position.into(); + self.cursor.location = Some(location); + + EventContext::new( + WidgetContext::new( + self.root.clone(), + &self.current_theme, + &mut window, + self.theme_mode.get(), + &mut self.cursor, + ), + kludgine, + ) + .update_hovered_widget(); + + if let Some(state) = self.mouse_buttons.get(&device_id) { + // Mouse Drag + for (button, handler) in state { + let Some(handler) = self.tree.widget(*handler) else { + continue; + }; + let mut context = EventContext::new( + WidgetContext::new( + handler.clone(), + &self.current_theme, + &mut window, + self.theme_mode.get(), + &mut self.cursor, + ), + kludgine, + ); + let Some(last_rendered_at) = context.last_layout() else { + continue; + }; + context.mouse_drag(location - last_rendered_at.origin, device_id, *button); + } + } + } + + fn cursor_left(&mut self, window: W, kludgine: &mut Kludgine) + where + W: PlatformWindowImplementation, + { + if self.cursor.widget.take().is_some() { + let mut window = RunningWindow::new( + window, + kludgine.id(), + &self.redraw_status, + &self.cushy, + &self.focused, + &self.occluded, + &self.inner_size, + ); + + let mut context = EventContext::new( + WidgetContext::new( + self.root.clone(), + &self.current_theme, + &mut window, + self.theme_mode.get(), + &mut self.cursor, + ), + kludgine, + ); + context.clear_hover(); + } + } + + fn mouse_input( + &mut self, + window: W, + kludgine: &mut Kludgine, + device_id: DeviceId, + state: ElementState, + button: MouseButton, + ) -> EventHandling + where + W: PlatformWindowImplementation, + { + let mut window = RunningWindow::new( + window, + kludgine.id(), + &self.redraw_status, + &self.cushy, + &self.focused, + &self.occluded, + &self.inner_size, + ); + match state { + ElementState::Pressed => { + EventContext::new( + WidgetContext::new( + self.root.clone(), + &self.current_theme, + &mut window, + self.theme_mode.get(), + &mut self.cursor, + ), + kludgine, + ) + .clear_focus(); + + if let (ElementState::Pressed, Some(location), Some(hovered)) = ( + state, + self.cursor.location, + self.cursor.widget.and_then(|id| self.tree.widget(id)), + ) { + if let Some(handler) = recursively_handle_event( + &mut EventContext::new( + WidgetContext::new( + hovered.clone(), + &self.current_theme, + &mut window, + self.theme_mode.get(), + &mut self.cursor, + ), + kludgine, + ), + |context| { + let Some(layout) = context.last_layout() else { + return IGNORED; + }; + let relative = location - layout.origin; + context.mouse_down(relative, device_id, button) + }, + ) { + self.mouse_buttons + .entry(device_id) + .or_default() + .insert(button, handler.id()); + return HANDLED; + } + } + IGNORED + } + ElementState::Released => { + let Some(device_buttons) = self.mouse_buttons.get_mut(&device_id) else { + return IGNORED; + }; + let Some(handler) = device_buttons.remove(&button) else { + return IGNORED; + }; + if device_buttons.is_empty() { + self.mouse_buttons.remove(&device_id); + } + let Some(handler) = self.tree.widget(handler) else { + return IGNORED; + }; + let cursor_location = self.cursor.location; + let mut context = EventContext::new( + WidgetContext::new( + handler, + &self.current_theme, + &mut window, + self.theme_mode.get(), + &mut self.cursor, + ), + kludgine, + ); + + let relative = if let (Some(last_rendered), Some(location)) = + (context.last_layout(), cursor_location) + { + Some(location - last_rendered.origin) + } else { + None + }; + + context.mouse_up(relative, device_id, button); + HANDLED + } + } + } } #[derive(Clone, Copy, Eq, PartialEq, Debug)] @@ -1402,10 +1747,7 @@ where window: kludgine::app::Window<'_, WindowCommand>, _kludgine: &mut Kludgine, ) { - self.inner_size.set(window.inner_size()); - // We want to prevent a resize request for this resized event. - self.inner_size_generation = self.inner_size.generation(); - self.root.invalidate(); + self.resized(window.inner_size()); } // fn theme_changed(&mut self, window: kludgine::app::Window<'_, ()>) {} @@ -1422,81 +1764,22 @@ where &mut self, window: kludgine::app::Window<'_, WindowCommand>, kludgine: &mut Kludgine, - device_id: DeviceId, + device_id: winit::event::DeviceId, input: KeyEvent, is_synthetic: bool, ) { - let target = self.tree.focused_widget().unwrap_or(self.root.node_id); - let Some(target) = self.tree.widget_from_node(target) else { - return; - }; - let mut window = RunningWindow::new( - window, - kludgine.id(), - &self.redraw_status, - &self.cushy, - &self.focused, - &self.occluded, - &self.inner_size, - ); - let mut target = EventContext::new( - WidgetContext::new( - target, - &self.current_theme, - &mut window, - self.theme_mode.get(), - &mut self.cursor, - ), - kludgine, - ); - - let handled = recursively_handle_event(&mut target, |widget| { - widget.keyboard_input(device_id, input.clone(), is_synthetic) - }) - .is_some(); - drop(target); - - if !handled { - self.handle_window_keyboard_input(&mut window, kludgine, input); - } + self.keyboard_input(window, kludgine, device_id.into(), input, is_synthetic); } fn mouse_wheel( &mut self, window: kludgine::app::Window<'_, WindowCommand>, kludgine: &mut Kludgine, - device_id: DeviceId, + device_id: winit::event::DeviceId, delta: MouseScrollDelta, phase: TouchPhase, ) { - let widget = self - .tree - .hovered_widget() - .and_then(|hovered| self.tree.widget_from_node(hovered)) - .unwrap_or_else(|| self.tree.widget(self.root.id()).expect("missing widget")); - - let mut window = RunningWindow::new( - window, - kludgine.id(), - &self.redraw_status, - &self.cushy, - &self.focused, - &self.occluded, - &self.inner_size, - ); - let mut widget = EventContext::new( - WidgetContext::new( - widget, - &self.current_theme, - &mut window, - self.theme_mode.get(), - &mut self.cursor, - ), - kludgine, - ); - recursively_handle_event(&mut widget, |widget| { - widget.mouse_wheel(device_id, delta, phase) - }); + self.mouse_wheel(window, kludgine, device_id.into(), delta, phase); } // fn modifiers_changed(&mut self, window: kludgine::app::Window<'_, ()>) {} @@ -1507,219 +1790,37 @@ where kludgine: &mut Kludgine, ime: Ime, ) { - let widget = self - .tree - .focused_widget() - .and_then(|hovered| self.tree.widget_from_node(hovered)) - .unwrap_or_else(|| self.tree.widget(self.root.id()).expect("missing widget")); - let mut window = RunningWindow::new( - window, - kludgine.id(), - &self.redraw_status, - &self.cushy, - &self.focused, - &self.occluded, - &self.inner_size, - ); - let mut target = EventContext::new( - WidgetContext::new( - widget, - &self.current_theme, - &mut window, - self.theme_mode.get(), - &mut self.cursor, - ), - kludgine, - ); - - let _handled = - recursively_handle_event(&mut target, |widget| widget.ime(ime.clone())).is_some(); + self.ime(window, kludgine, &ime); } fn cursor_moved( &mut self, window: kludgine::app::Window<'_, WindowCommand>, kludgine: &mut Kludgine, - device_id: DeviceId, + device_id: winit::event::DeviceId, position: PhysicalPosition, ) { - let location = Point::::from(position); - self.cursor.location = Some(location); - - let mut window = RunningWindow::new( - window, - kludgine.id(), - &self.redraw_status, - &self.cushy, - &self.focused, - &self.occluded, - &self.inner_size, - ); - - EventContext::new( - WidgetContext::new( - self.root.clone(), - &self.current_theme, - &mut window, - self.theme_mode.get(), - &mut self.cursor, - ), - kludgine, - ) - .update_hovered_widget(); - - if let Some(state) = self.mouse_buttons.get(&device_id) { - // Mouse Drag - for (button, handler) in state { - let Some(handler) = self.tree.widget(*handler) else { - continue; - }; - let mut context = EventContext::new( - WidgetContext::new( - handler.clone(), - &self.current_theme, - &mut window, - self.theme_mode.get(), - &mut self.cursor, - ), - kludgine, - ); - let Some(last_rendered_at) = context.last_layout() else { - continue; - }; - context.mouse_drag(location - last_rendered_at.origin, device_id, *button); - } - } + self.cursor_moved(window, kludgine, device_id.into(), position); } fn cursor_left( &mut self, window: kludgine::app::Window<'_, WindowCommand>, kludgine: &mut Kludgine, - _device_id: DeviceId, + _device_id: winit::event::DeviceId, ) { - if self.cursor.widget.take().is_some() { - let mut window = RunningWindow::new( - window, - kludgine.id(), - &self.redraw_status, - &self.cushy, - &self.focused, - &self.occluded, - &self.inner_size, - ); - let mut context = EventContext::new( - WidgetContext::new( - self.root.clone(), - &self.current_theme, - &mut window, - self.theme_mode.get(), - &mut self.cursor, - ), - kludgine, - ); - context.clear_hover(); - } + self.cursor_left(window, kludgine); } fn mouse_input( &mut self, window: kludgine::app::Window<'_, WindowCommand>, kludgine: &mut Kludgine, - device_id: DeviceId, + device_id: winit::event::DeviceId, state: ElementState, button: MouseButton, ) { - let mut window = RunningWindow::new( - window, - kludgine.id(), - &self.redraw_status, - &self.cushy, - &self.focused, - &self.occluded, - &self.inner_size, - ); - match state { - ElementState::Pressed => { - EventContext::new( - WidgetContext::new( - self.root.clone(), - &self.current_theme, - &mut window, - self.theme_mode.get(), - &mut self.cursor, - ), - kludgine, - ) - .clear_focus(); - - if let (ElementState::Pressed, Some(location), Some(hovered)) = ( - state, - self.cursor.location, - self.cursor.widget.and_then(|id| self.tree.widget(id)), - ) { - if let Some(handler) = recursively_handle_event( - &mut EventContext::new( - WidgetContext::new( - hovered.clone(), - &self.current_theme, - &mut window, - self.theme_mode.get(), - &mut self.cursor, - ), - kludgine, - ), - |context| { - let Some(layout) = context.last_layout() else { - return IGNORED; - }; - let relative = location - layout.origin; - context.mouse_down(relative, device_id, button) - }, - ) { - self.mouse_buttons - .entry(device_id) - .or_default() - .insert(button, handler.id()); - } - } - } - ElementState::Released => { - let Some(device_buttons) = self.mouse_buttons.get_mut(&device_id) else { - return; - }; - let Some(handler) = device_buttons.remove(&button) else { - return; - }; - if device_buttons.is_empty() { - self.mouse_buttons.remove(&device_id); - } - let Some(handler) = self.tree.widget(handler) else { - return; - }; - let cursor_location = self.cursor.location; - let mut context = EventContext::new( - WidgetContext::new( - handler, - &self.current_theme, - &mut window, - self.theme_mode.get(), - &mut self.cursor, - ), - kludgine, - ); - - let relative = if let (Some(last_rendered), Some(location)) = - (context.last_layout(), cursor_location) - { - Some(location - last_rendered.origin) - } else { - None - }; - - context.mouse_up(relative, device_id, button); - } - } + self.mouse_input(window, kludgine, device_id.into(), state, button); } fn theme_changed( @@ -2419,9 +2520,27 @@ impl VirtualWindow { device: &wgpu::Device, queue: &wgpu::Queue, ) -> Option { + self.render_with(pass, device, queue, None) + } + + /// Renders this window in a wgpu render pass created from `pass`. + /// + /// Returns the submission index of the last command submission, if any + /// commands were submitted. + pub fn render_with( + &mut self, + pass: &wgpu::RenderPassDescriptor<'_, '_>, + device: &wgpu::Device, + queue: &wgpu::Queue, + additional_drawing: Option<&Drawing>, + ) -> Option { + self.state.dynamic.redraw_target.set(RedrawTarget::Never); let mut frame = self.kludgine.next_frame(); let mut gfx = frame.render(pass, device, queue); self.window.contents.render(1., &mut gfx); + if let Some(additional) = additional_drawing { + additional.render(1., &mut gfx); + } drop(gfx); frame.submit(queue) } @@ -2434,6 +2553,7 @@ impl VirtualWindow { device: &wgpu::Device, queue: &wgpu::Queue, ) -> Option { + self.state.dynamic.redraw_target.set(RedrawTarget::Never); let mut frame = self.kludgine.next_frame(); let mut gfx = frame.render_into(texture, load_op, device, queue); self.window.contents.render(1., &mut gfx); @@ -2472,6 +2592,95 @@ impl VirtualWindow { pub fn closed(&self) -> bool { self.state.closed } + + /// Returns a reference to the window's state. + #[must_use] + pub const fn state(&self) -> &VirtualState { + &self.state + } + + /// Returns the current size of the window. + pub const fn size(&self) -> Size { + self.kludgine.size() + } + + /// Returns the current DPI scale of the window. + pub const fn scale(&self) -> Fraction { + self.kludgine.scale() + } + + /// Updates the dimensions and DPI scaling of the window. + pub fn resize(&mut self, new_size: Size, new_scale: impl Into, queue: &wgpu::Queue) { + self.kludgine.resize(new_size, new_scale.into(), queue); + self.window.resized(new_size); + } + + /// Provide keyboard input to this virtual window. + /// + /// Returns whether the event was [`HANDLED`] or [`IGNORED`]. + pub fn keyboard_input( + &mut self, + device_id: DeviceId, + input: KeyEvent, + is_synthetic: bool, + ) -> EventHandling { + self.window.keyboard_input( + &mut self.state, + &mut self.kludgine, + device_id, + input, + is_synthetic, + ) + } + + /// Provides mouse wheel input to this window. + /// + /// Returns whether the event was [`HANDLED`] or [`IGNORED`]. + pub fn mouse_wheel( + &mut self, + device_id: DeviceId, + delta: MouseScrollDelta, + phase: TouchPhase, + ) -> EventHandling { + self.window + .mouse_wheel(&mut self.state, &mut self.kludgine, device_id, delta, phase) + } + + /// Provides input manager events to this window. + /// + /// Returns whether the event was [`HANDLED`] or [`IGNORED`]. + pub fn ime(&mut self, ime: &Ime) -> EventHandling { + self.window.ime(&mut self.state, &mut self.kludgine, ime) + } + + /// Provides cursor movement events to this window. + pub fn cursor_moved(&mut self, device_id: DeviceId, position: impl Into>) { + self.window + .cursor_moved(&mut self.state, &mut self.kludgine, device_id, position); + } + + /// Notifies the window that the cursor is no longer within the window. + pub fn cursor_left(&mut self) { + self.window.cursor_left(&mut self.state, &mut self.kludgine); + } + + /// Provides mouse input events to tihs window. + /// + /// Returns whether the event was [`HANDLED`] or [`IGNORED`]. + pub fn mouse_input( + &mut self, + device_id: DeviceId, + state: ElementState, + button: MouseButton, + ) -> EventHandling { + self.window.mouse_input( + &mut self.state, + &mut self.kludgine, + device_id, + state, + button, + ) + } } /// A color format containing 8-bit red, green, and blue channels. @@ -2565,6 +2774,18 @@ where self } + /// Sets the DPI scaling to apply to this virtual window. + /// + /// When scale is 1.0, resolution-independent content will be rendered at + /// 96-ppi. + /// + /// This setting does not affect the image's pixel dimensions. + #[must_use] + pub fn scale(mut self, scale: f32) -> Self { + self.scale = scale; + self + } + /// Returns an initialized [`VirtualRecorder`]. pub fn finish(self) -> Result, VirtualRecorderError> { VirtualRecorder::new(self.size, self.scale, self.contents) @@ -2579,11 +2800,14 @@ struct Capture { /// A recorder of a [`VirtualWindow`]. pub struct VirtualRecorder { - window: VirtualWindow, + /// The virtual window being recorded. + pub window: VirtualWindow, device: wgpu::Device, queue: wgpu::Queue, capture: Option, data: Vec, + cursor: Dynamic>, + cursor_graphic: Drawing, format: PhantomData, } @@ -2614,26 +2838,26 @@ where None, ))?; - let mut recorder = Self { - window: VirtualWindow::new( - contents.make_widget(), - 4, - size, - scale, - Format::HAS_ALPHA, - &device, - &queue, - ), + let window = VirtualWindow::new( + contents.make_widget(), + 4, + size, + scale, + Format::HAS_ALPHA, + &device, + &queue, + ); + + Ok(Self { + window, device, queue, + cursor: Dynamic::default(), + cursor_graphic: Drawing::default(), capture: None, data: Vec::new(), format: PhantomData, - }; - - recorder.refresh()?; - - Ok(recorder) + }) } /// Returns the tightly-packed captured bytes. @@ -2643,11 +2867,6 @@ where &self.data } - /// Returns the current size of the recorder. - pub const fn size(&self) -> Size { - self.window.kludgine.size() - } - fn recreate_buffers_if_needed(&mut self, size: Size, bytes: u64) { if self .capture @@ -2694,9 +2913,17 @@ where 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); + self.window.prepare(&self.device, &self.queue); - self.window.render( + self.window.render_with( &wgpu::RenderPassDescriptor { label: None, color_attachments: &[Some(wgpu::RenderPassColorAttachment { @@ -2713,6 +2940,7 @@ where }, &self.device, &self.queue, + Some(&self.cursor_graphic), ); let mut encoder = self @@ -2740,7 +2968,6 @@ where let map_result = map_result.clone(); let condvar = condvar.clone(); move || { - condvar.notify_one(); slice.map_async(wgpu::MapMode::Read, { move |result| { *map_result.lock().assert("thread panicked") = Some(result); @@ -2775,11 +3002,26 @@ where })?; self.data.extend_from_slice(&slice.get_mapped_range()); + capture.buffer.unmap(); Format::convert_rgba(&mut self.data, render_size.width.get(), bytes_per_row); Ok(()) } + + /// Sets the cursor position immediately. + pub fn set_cursor_position(&self, position: Point) { + self.cursor.set(position); + } + + /// Begins recording an animated png. + pub fn record_animated_png(&mut self, target_fps: u8) -> AnimationRecorder<'_, Format> { + AnimationRecorder { + recorder: self, + target_fps, + frames: Vec::new(), + } + } } fn copy_buffer_aligned_bytes_per_row(width: u32) -> u32 { @@ -2787,6 +3029,146 @@ fn copy_buffer_aligned_bytes_per_row(width: u32) -> u32 { * COPY_BYTES_PER_ROW_ALIGNMENT } +/// An animated PNG recorder. +pub struct AnimationRecorder<'a, Format> { + recorder: &'a mut VirtualRecorder, + target_fps: u8, + frames: Vec, +} + +impl AnimationRecorder<'_, Format> +where + Format: CaptureFormat, +{ + /// Animates the cursor to move from its current location to `location`. + pub fn animate_cursor_to( + &mut self, + location: Point, + over: Duration, + easing: impl Easing, + ) -> Result<(), VirtualRecorderError> { + self.recorder + .cursor + .transition_to(location) + .over(over) + .with_easing(easing) + .launch(); + self.wait_for(over) + } + + /// Waits for `duration`, rendering frames as needed. + pub fn wait_for(&mut self, duration: Duration) -> Result<(), VirtualRecorderError> { + self.wait_until(Instant::now() + duration) + } + + /// Waits until `time`, rendering frames as needed. + pub fn wait_until(&mut self, time: Instant) -> Result<(), VirtualRecorderError> { + let frame_duration = Duration::from_micros(1_000_000 / u64::from(self.target_fps)); + let mut last_frame = Instant::now(); + + loop { + let now = Instant::now(); + let final_frame = now > time; + + self.recorder + .window + .cursor_moved(DeviceId::Virtual(0), self.recorder.cursor.get()); + + let next_frame = match self.recorder.window.state.dynamic.redraw_target.get() { + RedrawTarget::Never => now + frame_duration, + RedrawTarget::Now => now, + RedrawTarget::At(instant) => now.min(instant), + }; + + if final_frame || next_frame == now { + let elapsed = now.saturating_duration_since(last_frame); + last_frame = now; + self.recorder.refresh()?; + match self.frames.last_mut() { + Some(frame) if frame.data == self.recorder.bytes() => { + frame.duration += elapsed; + } + _ => { + self.frames.push(Frame { + data: self.recorder.bytes().to_vec(), + duration: elapsed, + }); + } + } + } + + if now > time { + break; + } + + let render_duration = now.elapsed(); + std::thread::sleep(frame_duration.saturating_sub(render_duration)); + } + + Ok(()) + } + + /// Encodes the currently recorded frames into a new file at `path`. + pub fn write_to(&self, path: impl AsRef) -> Result<(), png::EncodingError> { + let mut file = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(path)?; + let mut encoder = png::Encoder::new( + &mut file, + self.recorder.window.size().width.get(), + self.recorder.window.size().height.get(), + ); + encoder.set_color(png::ColorType::Rgb); + encoder.set_adaptive_filter(png::AdaptiveFilterType::Adaptive); + encoder.set_animated( + u32::try_from(self.frames.len()).assert("too many frames"), + 0, + )?; + encoder.set_compression(png::Compression::Best); + + let mut current_frame_delay = self + .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 writer = encoder.write_header()?; + for frame in &self.frames { + if current_frame_delay != frame.duration { + current_frame_delay = frame.duration; + writer.set_frame_delay( + current_frame_delay + .as_millis() + // TODO should be checked + .cast(), + 1_000, + )?; + } + + writer.write_image_data(&frame.data)?; + } + + writer.finish()?; + + file.sync_all()?; + + Ok(()) + } +} + +struct Frame { + data: Vec, + duration: Duration, +} + /// An error from a [`VirtualRecorder`]. #[derive(Debug)] pub enum VirtualRecorderError { @@ -2818,3 +3200,18 @@ impl From for VirtualRecorderError { Self::TooLarge } } + +/// A unique identifier of an input device. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum DeviceId { + /// A winit-supplied device id. + Winit(winit::event::DeviceId), + /// A simulated device. + Virtual(u64), +} + +impl From for DeviceId { + fn from(value: winit::event::DeviceId) -> Self { + Self::Winit(value) + } +}