Add screenshot helpers to iced_test

This commit is contained in:
Héctor Ramón Jiménez 2025-11-12 00:53:10 +01:00
parent ea614387f4
commit 085c8fae8d
No known key found for this signature in database
GPG key ID: 7CC46565708259A7
10 changed files with 183 additions and 78 deletions

View file

@ -1,8 +1,6 @@
//! Load and draw raster graphics. //! Load and draw raster graphics.
pub use bytes::Bytes;
use crate::border; use crate::border;
use crate::{Radians, Rectangle, Size}; use crate::{Bytes, Radians, Rectangle, Size};
use rustc_hash::FxHasher; use rustc_hash::FxHasher;

View file

@ -83,6 +83,7 @@ pub use transformation::Transformation;
pub use vector::Vector; pub use vector::Vector;
pub use widget::Widget; pub use widget::Widget;
pub use bytes::Bytes;
pub use smol_str::SmolStr; pub use smol_str::SmolStr;
/// A function that can _never_ be called. /// A function that can _never_ be called.

View file

@ -1,7 +1,6 @@
//! Take screenshots of a window. //! Take screenshots of a window.
use crate::{Rectangle, Size}; use crate::{Bytes, Rectangle, Size};
use bytes::Bytes;
use std::fmt::{Debug, Formatter}; use std::fmt::{Debug, Formatter};
/// Data of a screenshot, captured with `window::screenshot()`. /// Data of a screenshot, captured with `window::screenshot()`.
@ -9,8 +8,8 @@ use std::fmt::{Debug, Formatter};
/// The `bytes` of this screenshot will always be ordered as `RGBA` in the `sRGB` color space. /// The `bytes` of this screenshot will always be ordered as `RGBA` in the `sRGB` color space.
#[derive(Clone)] #[derive(Clone)]
pub struct Screenshot { pub struct Screenshot {
/// The bytes of the [`Screenshot`]. /// The RGBA bytes of the [`Screenshot`].
pub bytes: Bytes, pub rgba: Bytes,
/// The size of the [`Screenshot`] in physical pixels. /// The size of the [`Screenshot`] in physical pixels.
pub size: Size<u32>, pub size: Size<u32>,
/// The scale factor of the [`Screenshot`]. This can be useful when converting between widget /// The scale factor of the [`Screenshot`]. This can be useful when converting between widget
@ -23,7 +22,7 @@ impl Debug for Screenshot {
write!( write!(
f, f,
"Screenshot: {{ \n bytes: {}\n scale: {}\n size: {:?} }}", "Screenshot: {{ \n bytes: {}\n scale: {}\n size: {:?} }}",
self.bytes.len(), self.rgba.len(),
self.scale_factor, self.scale_factor,
self.size self.size
) )
@ -33,12 +32,12 @@ impl Debug for Screenshot {
impl Screenshot { impl Screenshot {
/// Creates a new [`Screenshot`]. /// Creates a new [`Screenshot`].
pub fn new( pub fn new(
bytes: impl Into<Bytes>, rgba: impl Into<Bytes>,
size: Size<u32>, size: Size<u32>,
scale_factor: f32, scale_factor: f32,
) -> Self { ) -> Self {
Self { Self {
bytes: bytes.into(), rgba: rgba.into(),
size, size,
scale_factor, scale_factor,
} }
@ -65,7 +64,7 @@ impl Screenshot {
let column_range = region.x as usize * PIXEL_SIZE let column_range = region.x as usize * PIXEL_SIZE
..(region.x + region.width) as usize * PIXEL_SIZE; ..(region.x + region.width) as usize * PIXEL_SIZE;
let chopped = self.bytes.chunks(bytes_per_row).enumerate().fold( let chopped = self.rgba.chunks(bytes_per_row).enumerate().fold(
vec![], vec![],
|mut acc, (row, bytes)| { |mut acc, (row, bytes)| {
if row_range.contains(&row) { if row_range.contains(&row) {
@ -77,7 +76,7 @@ impl Screenshot {
); );
Ok(Self { Ok(Self {
bytes: Bytes::from(chopped), rgba: Bytes::from(chopped),
size: Size::new(region.width, region.height), size: Size::new(region.width, region.height),
scale_factor: self.scale_factor, scale_factor: self.scale_factor,
}) })
@ -86,13 +85,13 @@ impl Screenshot {
impl AsRef<[u8]> for Screenshot { impl AsRef<[u8]> for Screenshot {
fn as_ref(&self) -> &[u8] { fn as_ref(&self) -> &[u8] {
&self.bytes &self.rgba
} }
} }
impl From<Screenshot> for Bytes { impl From<Screenshot> for Bytes {
fn from(screenshot: Screenshot) -> Self { fn from(screenshot: Screenshot) -> Self {
screenshot.bytes screenshot.rgba
} }
} }

View file

@ -59,7 +59,7 @@ impl Example {
image::Handle::from_rgba( image::Handle::from_rgba(
screenshot.size.width, screenshot.size.width,
screenshot.size.height, screenshot.size.height,
screenshot.bytes, screenshot.rgba,
), ),
)); ));
} }
@ -105,7 +105,7 @@ impl Example {
image::Handle::from_rgba( image::Handle::from_rgba(
screenshot.size.width, screenshot.size.width,
screenshot.size.height, screenshot.size.height,
screenshot.bytes, screenshot.rgba,
), ),
)); ));
self.crop_error = None; self.crop_error = None;
@ -243,7 +243,7 @@ async fn save_to_png(screenshot: Screenshot) -> Result<String, PngError> {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
img::save_buffer( img::save_buffer(
&path, &path,
&screenshot.bytes, &screenshot.rgba,
screenshot.size.width, screenshot.size.width,
screenshot.size.height, screenshot.size.height,
ColorType::Rgba8, ColorType::Rgba8,

View file

@ -20,7 +20,8 @@ pub fn main() -> iced::Result {
application().run() application().run()
} }
fn application() -> Application<impl Program<Message = Message>> { fn application() -> Application<impl Program<Message = Message, Theme = Theme>>
{
iced::application(Todos::new, Todos::update, Todos::view) iced::application(Todos::new, Todos::update, Todos::view)
.subscription(Todos::subscription) .subscription(Todos::subscription)
.title(Todos::title) .title(Todos::title)

View file

@ -2,9 +2,9 @@
#[cfg(feature = "image")] #[cfg(feature = "image")]
pub use ::image as image_rs; pub use ::image as image_rs;
use crate::core::Rectangle;
use crate::core::image; use crate::core::image;
use crate::core::svg; use crate::core::svg;
use crate::core::{Bytes, Rectangle};
/// A raster or vector image. /// A raster or vector image.
#[allow(missing_docs)] #[allow(missing_docs)]
@ -39,7 +39,7 @@ impl Image {
/// An image buffer. /// An image buffer.
#[cfg(feature = "image")] #[cfg(feature = "image")]
pub type Buffer = ::image::ImageBuffer<::image::Rgba<u8>, image::Bytes>; pub type Buffer = ::image::ImageBuffer<::image::Rgba<u8>, Bytes>;
#[cfg(feature = "image")] #[cfg(feature = "image")]
/// Tries to load an image by its [`Handle`]. /// Tries to load an image by its [`Handle`].
@ -127,11 +127,7 @@ pub fn load(handle: &image::Handle) -> Result<Buffer, image::Error> {
let rgba = operation.perform(image).into_rgba8(); let rgba = operation.perform(image).into_rgba8();
( (rgba.width(), rgba.height(), Bytes::from(rgba.into_raw()))
rgba.width(),
rgba.height(),
image::Bytes::from(rgba.into_raw()),
)
} }
image::Handle::Bytes(_, bytes) => { image::Handle::Bytes(_, bytes) => {
let image = ::image::load_from_memory(bytes).map_err(to_error)?; let image = ::image::load_from_memory(bytes).map_err(to_error)?;
@ -143,11 +139,7 @@ pub fn load(handle: &image::Handle) -> Result<Buffer, image::Error> {
let rgba = operation.perform(image).into_rgba8(); let rgba = operation.perform(image).into_rgba8();
( (rgba.width(), rgba.height(), Bytes::from(rgba.into_raw()))
rgba.width(),
rgba.height(),
image::Bytes::from(rgba.into_raw()),
)
} }
image::Handle::Rgba { image::Handle::Rgba {
width, width,

View file

@ -2,8 +2,10 @@
use crate::core; use crate::core;
use crate::core::mouse; use crate::core::mouse;
use crate::core::renderer; use crate::core::renderer;
use crate::core::time::Instant;
use crate::core::widget; use crate::core::widget;
use crate::core::{Element, Point, Size}; use crate::core::window;
use crate::core::{Bytes, Element, Point, Size};
use crate::instruction; use crate::instruction;
use crate::program; use crate::program;
use crate::program::Program; use crate::program::Program;
@ -15,7 +17,6 @@ use crate::runtime::futures::subscription;
use crate::runtime::futures::{Executor, Runtime}; use crate::runtime::futures::{Executor, Runtime};
use crate::runtime::task; use crate::runtime::task;
use crate::runtime::user_interface; use crate::runtime::user_interface;
use crate::runtime::window;
use crate::runtime::{Task, UserInterface}; use crate::runtime::{Task, UserInterface};
use crate::{Instruction, Selector}; use crate::{Instruction, Selector};
@ -209,50 +210,55 @@ impl<P: Program + 'static> Emulator<P> {
// TODO // TODO
dbg!(action); dbg!(action);
} }
runtime::Action::Window(action) => match action { runtime::Action::Window(action) => {
window::Action::Open(id, _settings, sender) => { use crate::runtime::window;
self.window = id;
let _ = sender.send(self.window); match action {
} window::Action::Open(id, _settings, sender) => {
window::Action::GetOldest(sender) self.window = id;
| window::Action::GetLatest(sender) => {
let _ = sender.send(Some(self.window)); let _ = sender.send(self.window);
} }
window::Action::GetSize(id, sender) => { window::Action::GetOldest(sender)
if id == self.window { | window::Action::GetLatest(sender) => {
let _ = sender.send(self.size); let _ = sender.send(Some(self.window));
}
window::Action::GetSize(id, sender) => {
if id == self.window {
let _ = sender.send(self.size);
}
}
window::Action::GetMaximized(id, sender) => {
if id == self.window {
let _ = sender.send(false);
}
}
window::Action::GetMinimized(id, sender) => {
if id == self.window {
let _ = sender.send(None);
}
}
window::Action::GetPosition(id, sender) => {
if id == self.window {
let _ = sender.send(Some(Point::ORIGIN));
}
}
window::Action::GetScaleFactor(id, sender) => {
if id == self.window {
let _ = sender.send(1.0);
}
}
window::Action::GetMode(id, sender) => {
if id == self.window {
let _ =
sender.send(core::window::Mode::Windowed);
}
}
_ => {
// Ignored
} }
} }
window::Action::GetMaximized(id, sender) => { }
if id == self.window {
let _ = sender.send(false);
}
}
window::Action::GetMinimized(id, sender) => {
if id == self.window {
let _ = sender.send(None);
}
}
window::Action::GetPosition(id, sender) => {
if id == self.window {
let _ = sender.send(Some(Point::ORIGIN));
}
}
window::Action::GetScaleFactor(id, sender) => {
if id == self.window {
let _ = sender.send(1.0);
}
}
window::Action::GetMode(id, sender) => {
if id == self.window {
let _ = sender.send(core::window::Mode::Windowed);
}
}
_ => {
// Ignored
}
},
runtime::Action::System(action) => { runtime::Action::System(action) => {
// TODO // TODO
dbg!(action); dbg!(action);
@ -435,6 +441,62 @@ impl<P: Program + 'static> Emulator<P> {
program.theme(&self.state, self.window) program.theme(&self.state, self.window)
} }
/// Takes a [`window::Screenshot`] of the current state of the [`Emulator`].
pub fn screenshot(
&mut self,
program: &P,
theme: &P::Theme,
scale_factor: f32,
) -> window::Screenshot {
use core::renderer::Headless;
let style = program.style(&self.state, theme);
let mut user_interface = UserInterface::build(
program.view(&self.state, self.window),
self.size,
self.cache.take().unwrap(),
&mut self.renderer,
);
// TODO: Nested redraws!
let _ = user_interface.update(
&[core::Event::Window(window::Event::RedrawRequested(
Instant::now(),
))],
mouse::Cursor::Unavailable,
&mut self.renderer,
&mut self.clipboard,
&mut Vec::new(),
);
user_interface.draw(
&mut self.renderer,
theme,
&renderer::Style {
text_color: style.text_color,
},
mouse::Cursor::Unavailable,
);
let physical_size = Size::new(
(self.size.width * scale_factor).round() as u32,
(self.size.height * scale_factor).round() as u32,
);
let rgba = self.renderer.screenshot(
physical_size,
scale_factor,
style.background_color,
);
window::Screenshot {
rgba: Bytes::from(rgba),
size: physical_size,
scale_factor,
}
}
/// Turns the [`Emulator`] into its internal state. /// Turns the [`Emulator`] into its internal state.
pub fn into_state(self) -> (P::State, core::window::Id) { pub fn into_state(self) -> (P::State, core::window::Id) {
(self.state, self.window) (self.state, self.window)

View file

@ -104,6 +104,10 @@ pub use instruction::Instruction;
pub use selector::Selector; pub use selector::Selector;
pub use simulator::{Simulator, simulator}; pub use simulator::{Simulator, simulator};
use crate::core::Size;
use crate::core::time::{Duration, Instant};
use crate::core::window;
use std::path::Path; use std::path::Path;
/// Runs an [`Ice`] test suite for the given [`Program`](program::Program). /// Runs an [`Ice`] test suite for the given [`Program`](program::Program).
@ -213,3 +217,50 @@ pub fn run(
Ok(()) Ok(())
} }
/// Takes a screenshot of the given [`Program`](program::Program) with the given theme, viewport,
/// and scale factor after running it for the given [`Duration`].
pub fn screenshot<P: program::Program + 'static>(
program: P,
theme: &P::Theme,
viewport: impl Into<Size>,
scale_factor: f32,
duration: Duration,
) -> window::Screenshot {
use crate::runtime::futures::futures::channel::mpsc;
let (sender, mut receiver) = mpsc::channel(100);
let mut emulator = Emulator::new(
sender,
&program,
emulator::Mode::Immediate,
viewport.into(),
);
let start = Instant::now();
loop {
if let Some(event) = receiver.try_next().ok().flatten() {
match event {
emulator::Event::Action(action) => {
emulator.perform(&program, action);
}
emulator::Event::Failed(_) => {
unreachable!(
"no instructions should be executed during a screenshot"
);
}
emulator::Event::Ready => {}
}
}
if start.elapsed() >= duration {
break;
}
std::thread::sleep(Duration::from_millis(1));
}
emulator.screenshot(&program, theme, scale_factor)
}

View file

@ -276,7 +276,7 @@ impl Snapshot {
let mut bytes = vec![0; n]; let mut bytes = vec![0; n];
let info = reader.next_frame(&mut bytes)?; let info = reader.next_frame(&mut bytes)?;
Ok(self.screenshot.bytes == bytes[..info.buffer_size()]) Ok(self.screenshot.rgba == bytes[..info.buffer_size()])
} else { } else {
if let Some(directory) = path.parent() { if let Some(directory) = path.parent() {
fs::create_dir_all(directory)?; fs::create_dir_all(directory)?;
@ -292,7 +292,7 @@ impl Snapshot {
encoder.set_color(png::ColorType::Rgba); encoder.set_color(png::ColorType::Rgba);
let mut writer = encoder.write_header()?; let mut writer = encoder.write_header()?;
writer.write_image_data(&self.screenshot.bytes)?; writer.write_image_data(&self.screenshot.rgba)?;
writer.finish()?; writer.finish()?;
Ok(true) Ok(true)
@ -311,7 +311,7 @@ impl Snapshot {
let hash = { let hash = {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(&self.screenshot.bytes); hasher.update(&self.screenshot.rgba);
format!("{:x}", hasher.finalize()) format!("{:x}", hasher.finalize())
}; };

View file

@ -410,6 +410,7 @@ fn load_image<'a>(
#[cfg(all(feature = "image", not(target_arch = "wasm32")))] #[cfg(all(feature = "image", not(target_arch = "wasm32")))]
mod worker { mod worker {
use crate::core::Bytes;
use crate::core::image; use crate::core::image;
use crate::graphics::Shell; use crate::graphics::Shell;
use crate::image::atlas::{self, Atlas}; use crate::image::atlas::{self, Atlas};
@ -505,7 +506,7 @@ mod worker {
Load(image::Handle), Load(image::Handle),
Upload { Upload {
handle: image::Handle, handle: image::Handle,
rgba: image::Bytes, rgba: Bytes,
width: u32, width: u32,
height: u32, height: u32,
}, },
@ -580,7 +581,7 @@ mod worker {
handle: image::Handle, handle: image::Handle,
width: u32, width: u32,
height: u32, height: u32,
rgba: image::Bytes, rgba: Bytes,
callback: fn(&Shell), callback: fn(&Shell),
) { ) {
let mut encoder = self.device.create_command_encoder( let mut encoder = self.device.create_command_encoder(