//! Test your `iced` applications in headless mode. #![allow(missing_docs, missing_debug_implementations)] pub mod selector; pub use selector::Selector; use iced_renderer as renderer; use iced_runtime as runtime; use iced_runtime::core; use crate::core::clipboard; use crate::core::event; use crate::core::keyboard; use crate::core::mouse; use crate::core::theme; use crate::core::time; use crate::core::widget; use crate::core::window; use crate::core::{ Element, Event, Font, Point, Rectangle, Settings, Size, SmolStr, }; use crate::runtime::user_interface; use crate::runtime::UserInterface; use std::borrow::Cow; use std::fs; use std::io; use std::path::Path; use std::sync::Arc; pub fn simulator<'a, Message, Theme, Renderer>( element: impl Into>, ) -> Simulator<'a, Message, Theme, Renderer> where Theme: Default + theme::Base, Renderer: core::Renderer + core::renderer::Headless, { Simulator::new(element) } fn load_font(font: impl Into>) -> Result<(), Error> { renderer::graphics::text::font_system() .write() .expect("Write to font system") .load_font(font.into()); Ok(()) } pub struct Simulator< 'a, Message, Theme = core::Theme, Renderer = renderer::Renderer, > { raw: UserInterface<'a, Message, Theme, Renderer>, renderer: Renderer, window_size: Size, cursor: mouse::Cursor, messages: Vec, } pub struct Target { pub bounds: Rectangle, } impl<'a, Message, Theme, Renderer> Simulator<'a, Message, Theme, Renderer> where Theme: Default + theme::Base, Renderer: core::Renderer + core::renderer::Headless, { pub fn new( element: impl Into>, ) -> Self { Self::with_settings(Settings::default(), element) } pub fn with_settings( settings: Settings, element: impl Into>, ) -> Self { Self::with_window_size( settings, window::Settings::default().size, element, ) } pub fn with_window_size( settings: Settings, window_size: impl Into, element: impl Into>, ) -> Self { let window_size = window_size.into(); let default_font = match settings.default_font { Font::DEFAULT => Font::with_name("Fira Sans"), _ => settings.default_font, }; for font in settings.fonts { load_font(font).expect("Font must be valid"); } let mut renderer = Renderer::new(default_font, settings.default_text_size); let raw = UserInterface::build( element, window_size, user_interface::Cache::default(), &mut renderer, ); Simulator { raw, renderer, window_size, cursor: mouse::Cursor::Unavailable, messages: Vec::new(), } } pub fn find( &mut self, selector: impl Into, ) -> Result { let selector = selector.into(); match &selector { Selector::Id(id) => { struct FindById<'a> { id: &'a widget::Id, target: Option, } impl widget::Operation for FindById<'_> { fn container( &mut self, id: Option<&widget::Id>, bounds: Rectangle, operate_on_children: &mut dyn FnMut( &mut dyn widget::Operation<()>, ), ) { if self.target.is_some() { return; } if Some(self.id) == id { self.target = Some(Target { bounds }); return; } operate_on_children(self); } fn scrollable( &mut self, id: Option<&widget::Id>, bounds: Rectangle, _content_bounds: Rectangle, _translation: core::Vector, _state: &mut dyn widget::operation::Scrollable, ) { if self.target.is_some() { return; } if Some(self.id) == id { self.target = Some(Target { bounds }); } } fn text_input( &mut self, id: Option<&widget::Id>, bounds: Rectangle, _state: &mut dyn widget::operation::TextInput, ) { if self.target.is_some() { return; } if Some(self.id) == id { self.target = Some(Target { bounds }); } } fn text( &mut self, id: Option<&widget::Id>, bounds: Rectangle, _text: &str, ) { if self.target.is_some() { return; } if Some(self.id) == id { self.target = Some(Target { bounds }); } } fn custom( &mut self, id: Option<&widget::Id>, bounds: Rectangle, _state: &mut dyn std::any::Any, ) { if self.target.is_some() { return; } if Some(self.id) == id { self.target = Some(Target { bounds }); } } } let mut find = FindById { id, target: None }; self.raw.operate(&self.renderer, &mut find); find.target.ok_or(Error::NotFound(selector)) } Selector::Text(text) => { struct FindByText<'a> { text: &'a str, target: Option, } impl widget::Operation for FindByText<'_> { fn container( &mut self, _id: Option<&widget::Id>, _bounds: Rectangle, operate_on_children: &mut dyn FnMut( &mut dyn widget::Operation<()>, ), ) { if self.target.is_some() { return; } operate_on_children(self); } fn text( &mut self, _id: Option<&widget::Id>, bounds: Rectangle, text: &str, ) { if self.target.is_some() { return; } if self.text == text { self.target = Some(Target { bounds }); } } } let mut find = FindByText { text, target: None }; self.raw.operate(&self.renderer, &mut find); find.target.ok_or(Error::NotFound(selector)) } } } pub fn point_at(&mut self, position: impl Into) { self.cursor = mouse::Cursor::Available(position.into()); } pub fn click( &mut self, selector: impl Into, ) -> Result { let target = self.find(selector)?; self.point_at(target.bounds.center()); let _ = self.simulate(click()); Ok(target) } pub fn tap_key(&mut self, key: impl Into) -> event::Status { self.simulate(tap_key(key, None)) .first() .copied() .unwrap_or(event::Status::Ignored) } pub fn typewrite(&mut self, text: &str) -> event::Status { let statuses = self.simulate(typewrite(text)); statuses .into_iter() .fold(event::Status::Ignored, event::Status::merge) } pub fn simulate( &mut self, events: impl IntoIterator, ) -> Vec { let events: Vec = events.into_iter().collect(); let (_state, statuses) = self.raw.update( &events, self.cursor, &mut self.renderer, &mut clipboard::Null, &mut self.messages, ); statuses } pub fn snapshot(&mut self) -> Result { let theme = Theme::default(); let base = theme.base(); let _ = self.raw.update( &[Event::Window(window::Event::RedrawRequested( time::Instant::now(), ))], self.cursor, &mut self.renderer, &mut clipboard::Null, &mut self.messages, ); let _ = self.raw.draw( &mut self.renderer, &theme, &core::renderer::Style { text_color: base.text_color, }, self.cursor, ); let scale_factor = 2.0; let physical_size = Size::new( (self.window_size.width * scale_factor).round() as u32, (self.window_size.height * scale_factor).round() as u32, ); let rgba = self.renderer.screenshot( physical_size, scale_factor, base.background_color, ); Ok(Snapshot { screenshot: window::Screenshot::new( rgba, physical_size, f64::from(scale_factor), ), }) } pub fn into_messages(self) -> impl Iterator { self.messages.into_iter() } } pub struct Snapshot { screenshot: window::Screenshot, } impl Snapshot { pub fn matches_image(&self, path: impl AsRef) -> Result { let path = path.as_ref().with_extension("png"); if path.exists() { let file = fs::File::open(&path)?; let decoder = png::Decoder::new(file); let mut reader = decoder.read_info()?; let mut bytes = vec![0; reader.output_buffer_size()]; let info = reader.next_frame(&mut bytes)?; Ok(self.screenshot.bytes == bytes[..info.buffer_size()]) } else { if let Some(directory) = path.parent() { fs::create_dir_all(directory)?; } let file = fs::File::create(path)?; let mut encoder = png::Encoder::new( file, self.screenshot.size.width, self.screenshot.size.height, ); encoder.set_color(png::ColorType::Rgba); let mut writer = encoder.write_header()?; writer.write_image_data(&self.screenshot.bytes)?; writer.finish()?; Ok(true) } } pub fn matches_hash(&self, path: impl AsRef) -> Result { use sha2::{Digest, Sha256}; let path = path.as_ref().with_extension("sha256"); let hash = { let mut hasher = Sha256::new(); hasher.update(&self.screenshot.bytes); format!("{:x}", hasher.finalize()) }; if path.exists() { let saved_hash = fs::read_to_string(&path)?; Ok(hash == saved_hash) } else { if let Some(directory) = path.parent() { fs::create_dir_all(directory)?; } fs::write(path, hash)?; Ok(true) } } } pub fn click() -> impl Iterator { [ Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), ] .into_iter() } pub fn tap_key( key: impl Into, text: Option, ) -> impl Iterator { let key = key.into(); [ Event::Keyboard(keyboard::Event::KeyPressed { key: key.clone(), modified_key: key.clone(), physical_key: keyboard::key::Physical::Unidentified( keyboard::key::NativeCode::Unidentified, ), location: keyboard::Location::Standard, modifiers: keyboard::Modifiers::default(), text, }), Event::Keyboard(keyboard::Event::KeyReleased { key: key.clone(), modified_key: key, physical_key: keyboard::key::Physical::Unidentified( keyboard::key::NativeCode::Unidentified, ), location: keyboard::Location::Standard, modifiers: keyboard::Modifiers::default(), }), ] .into_iter() } pub fn typewrite(text: &str) -> impl Iterator + '_ { text.chars() .map(|c| SmolStr::new_inline(&c.to_string())) .flat_map(|c| tap_key(keyboard::Key::Character(c.clone()), Some(c))) } #[derive(Debug, Clone)] pub enum Error { NotFound(Selector), IOFailed(Arc), PngDecodingFailed(Arc), PngEncodingFailed(Arc), } impl From for Error { fn from(error: io::Error) -> Self { Self::IOFailed(Arc::new(error)) } } impl From for Error { fn from(error: png::DecodingError) -> Self { Self::PngDecodingFailed(Arc::new(error)) } } impl From for Error { fn from(error: png::EncodingError) -> Self { Self::PngEncodingFailed(Arc::new(error)) } }