diff --git a/Cargo.lock b/Cargo.lock index 265b802e..9d7a5ed3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -476,7 +476,7 @@ dependencies = [ "anyhow", "arrayvec", "log", - "nom", + "nom 7.1.3", "num-rational", "v_frame", ] @@ -2656,6 +2656,7 @@ version = "0.14.0-dev" dependencies = [ "iced_renderer", "iced_runtime", + "nom 8.0.0", "png", "sha2", "thiserror 1.0.69", @@ -3613,6 +3614,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "noop_proc_macro" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index 3c19290b..1e7ef4e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -181,6 +181,7 @@ lilt = "0.8" log = "0.4" lyon = "1.0" lyon_path = "1.0" +nom = "8" num-traits = "0.2" ouroboros = "0.18" png = "0.17" diff --git a/devtools/src/lib.rs b/devtools/src/lib.rs index 44923707..fc6d1cdb 100644 --- a/devtools/src/lib.rs +++ b/devtools/src/lib.rs @@ -1,7 +1,7 @@ #![allow(missing_docs)] use iced_debug as debug; use iced_program as program; -use iced_widget as widget; +use iced_test as test; use iced_widget::core; use iced_widget::runtime; use iced_widget::runtime::futures; @@ -10,6 +10,7 @@ mod comet; mod executor; mod icon; mod time_machine; +mod widget; use crate::core::alignment::Horizontal::Right; use crate::core::border; @@ -24,6 +25,7 @@ use crate::futures::Subscription; use crate::program::Program; use crate::runtime::Task; use crate::runtime::font; +use crate::test::instruction; use crate::time_machine::TimeMachine; use crate::widget::{ Text, bottom_right, button, center, column, container, horizontal_space, @@ -137,6 +139,8 @@ pub enum Message { ChangeWidth(String), ChangeHeight(String), Record, + Stop, + Recorded(core::Event), } enum Mode { @@ -145,9 +149,9 @@ enum Mode { Setup(Setup), } -enum Recorder { - Idle { events: Vec }, - Recording { events: Vec }, +struct Recorder { + instructions: Vec, + is_recording: bool, } enum Setup { @@ -194,21 +198,19 @@ where Task::none() } Message::Toggle => { - match self.mode { + match &self.mode { Mode::Hidden => { self.mode = Mode::Open { - recorder: Recorder::Idle { events: Vec::new() }, + recorder: Recorder { + instructions: Vec::new(), + is_recording: false, + }, }; } - Mode::Open { - recorder: Recorder::Idle { .. }, - } => { + Mode::Open { recorder } if !recorder.is_recording => { self.mode = Mode::Hidden; } - Mode::Setup(_) - | Mode::Open { - recorder: Recorder::Recording { .. }, - } => {} + Mode::Setup(_) | Mode::Open { .. } => {} } Task::none() @@ -256,7 +258,6 @@ where .map(Message::Installing) .map(Event::Message) } - Message::Installing(Ok(installation)) => { let Mode::Setup(Setup::Running { logs }) = &mut self.mode else { @@ -311,15 +312,61 @@ where Task::none() } Message::Record => { - let (state, task) = program.boot(); - - self.state = state; - self.mode = Mode::Open { - recorder: Recorder::Recording { events: Vec::new() }, + let Mode::Open { recorder } = &mut self.mode else { + return Task::none(); }; + recorder.instructions.clear(); + recorder.is_recording = true; + + let (state, task) = program.boot(); + self.state = state; + task.map(Event::Program) } + Message::Recorded(event) => { + let Mode::Open { recorder } = &mut self.mode else { + return Task::none(); + }; + + let Some(interaction) = + instruction::Interaction::from_event(event) + else { + return Task::none(); + }; + + if let Some(test::Instruction::Interact(last_interaction)) = + recorder.instructions.pop() + { + let (last_interaction, new_interaction) = + last_interaction.merge(interaction); + + recorder.instructions.push( + test::Instruction::Interact(last_interaction), + ); + + if let Some(new_interaction) = new_interaction { + recorder.instructions.push( + test::Instruction::Interact(new_interaction), + ); + } + } else { + recorder + .instructions + .push(test::Instruction::Interact(interaction)); + } + + Task::none() + } + Message::Stop => { + let Mode::Open { recorder } = &mut self.mode else { + return Task::none(); + }; + + recorder.is_recording = false; + + Task::none() + } }, Event::Program(message) => { self.time_machine.push(&message); @@ -417,41 +464,43 @@ where let title = monospace("Developer Tools"); let recorder = { - let events = center(match recorder { - Recorder::Idle { events } if events.is_empty() => { - monospace("No events recorded yet!") + let events = container(if recorder.instructions.is_empty() { + Element::from(center( + monospace("No instructions recorded yet!") .size(14) .width(Fill) - .center() - } - Recorder::Idle { events } - | Recorder::Recording { events } => { - monospace(format!("{} events recorded", events.len())) - } + .center(), + )) + } else { + scrollable( + column(recorder.instructions.iter().map( + |instruction| { + monospace(instruction.to_string()) + .size(10) + .into() + }, + )) + .spacing(5), + ) + .spacing(5) + .into() }) - .style(container::bordered_box); + .width(Fill) + .height(Fill) + .style(container::rounded_box) + .padding(5); let controls = { row![ button(icon::play().size(14).width(Fill).center()), - match recorder { - Recorder::Idle { .. } => { - button( - icon::record() - .size(14) - .width(Fill) - .center(), - ) + if recorder.is_recording { + button(icon::stop().size(14).width(Fill).center()) + .on_press(Message::Stop) + .style(button::success) + } else { + button(icon::record().size(14).width(Fill).center()) .on_press(Message::Record) .style(button::danger) - } - Recorder::Recording { .. } => { - button( - icon::stop().size(14).width(Fill).center(), - ) - .on_press(Message::Record) - .style(button::success) - } } ] .spacing(10) @@ -491,7 +540,7 @@ where }; let content = row![if let Mode::Open { recorder } = &self.mode { - let is_recording = matches!(recorder, Recorder::Recording { .. }); + let is_recording = recorder.is_recording; let status = if is_recording { monospace("Recording").style(|theme| text::Style { @@ -507,9 +556,17 @@ where let viewport = container( scrollable( - container(view) - .width(self.size.width) - .height(self.size.height), + container(if recorder.is_recording { + widget::recorder(view) + .on_event(|event| { + Event::Message(Message::Recorded(event)) + }) + .into() + } else { + view + }) + .width(self.size.width) + .height(self.size.height), ) .direction(scrollable::Direction::Both { vertical: scrollable::Scrollbar::default(), @@ -673,7 +730,7 @@ where your iced applications.", column![ "Do you wish to install it with the \ - following command?", + following command?", command ] .spacing(10), diff --git a/devtools/src/widget.rs b/devtools/src/widget.rs new file mode 100644 index 00000000..3067a1ca --- /dev/null +++ b/devtools/src/widget.rs @@ -0,0 +1,12 @@ +mod recorder; + +pub use iced_widget::*; +pub use recorder::Recorder; + +use crate::core::Element; + +pub fn recorder<'a, Message, Theme, Renderer>( + content: impl Into>, +) -> Recorder<'a, Message, Theme, Renderer> { + Recorder::new(content) +} diff --git a/devtools/src/widget/recorder.rs b/devtools/src/widget/recorder.rs new file mode 100644 index 00000000..3fd88436 --- /dev/null +++ b/devtools/src/widget/recorder.rs @@ -0,0 +1,178 @@ +use crate::core::layout; +use crate::core::mouse; +use crate::core::renderer; +use crate::core::widget; +use crate::core::widget::tree; +use crate::core::{ + self, Clipboard, Element, Event, Layout, Length, Point, Rectangle, Shell, + Size, Widget, +}; + +#[allow(missing_debug_implementations)] +pub struct Recorder<'a, Message, Theme, Renderer> { + content: Element<'a, Message, Theme, Renderer>, + on_event: Option Message + 'a>>, +} + +impl<'a, Message, Theme, Renderer> Recorder<'a, Message, Theme, Renderer> { + pub fn new( + content: impl Into>, + ) -> Self { + Self { + content: content.into(), + on_event: None, + } + } + + pub fn on_event( + mut self, + on_event: impl Fn(Event) -> Message + 'a, + ) -> Self { + self.on_event = Some(Box::new(on_event)); + self + } +} + +impl Widget + for Recorder<'_, Message, Theme, Renderer> +where + Renderer: core::Renderer, +{ + fn update( + &mut self, + state: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + if shell.is_event_captured() { + return; + } + + self.content.as_widget_mut().update( + state, event, layout, cursor, renderer, clipboard, shell, viewport, + ); + + if let Some(on_event) = &self.on_event { + match event { + Event::Mouse(event) => { + if !cursor.is_over(layout.bounds()) { + return; + } + + match event { + mouse::Event::ButtonPressed(_) + | mouse::Event::ButtonReleased(_) + | mouse::Event::WheelScrolled { .. } => { + shell + .publish(on_event(Event::Mouse(event.clone()))); + } + mouse::Event::CursorMoved { position } => { + shell.publish(on_event(Event::Mouse( + mouse::Event::CursorMoved { + position: *position + - (layout.bounds().position() + - Point::ORIGIN), + }, + ))); + } + _ => {} + } + } + Event::Keyboard(event) => { + shell.publish(on_event(Event::Keyboard(event.clone()))); + } + _ => {} + } + } + } + + fn tag(&self) -> tree::Tag { + self.content.as_widget().tag() + } + + fn state(&self) -> tree::State { + self.content.as_widget().state() + } + + fn children(&self) -> Vec { + self.content.as_widget().children() + } + + fn diff(&self, tree: &mut tree::Tree) { + self.content.as_widget().diff(tree); + } + + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn size_hint(&self) -> Size { + self.content.as_widget().size_hint() + } + + fn layout( + &self, + tree: &mut widget::Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content.as_widget().layout(tree, renderer, limits) + } + + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content + .as_widget() + .draw(tree, renderer, theme, style, layout, cursor, viewport); + } + + fn mouse_interaction( + &self, + state: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.content + .as_widget() + .mouse_interaction(state, layout, cursor, viewport, renderer) + } + + fn operate( + &self, + state: &mut widget::Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + self.content + .as_widget() + .operate(state, layout, renderer, operation); + } +} + +impl<'a, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: 'a, + Renderer: core::Renderer + 'a, +{ + fn from(recorder: Recorder<'a, Message, Theme, Renderer>) -> Self { + Element::new(recorder) + } +} diff --git a/test/Cargo.toml b/test/Cargo.toml index 2dd35e7f..af5795ed 100644 --- a/test/Cargo.toml +++ b/test/Cargo.toml @@ -19,6 +19,7 @@ iced_runtime.workspace = true iced_renderer.workspace = true iced_renderer.features = ["fira-sans"] +nom.workspace = true png.workspace = true sha2.workspace = true thiserror.workspace = true diff --git a/test/src/error.rs b/test/src/error.rs new file mode 100644 index 00000000..ae475f16 --- /dev/null +++ b/test/src/error.rs @@ -0,0 +1,39 @@ +use crate::Selector; + +use std::io; +use std::sync::Arc; + +/// A test error. +#[derive(Debug, Clone, thiserror::Error)] +pub enum Error { + /// No matching widget was found for the [`Selector`]. + #[error("no matching widget was found for the selector: {0:?}")] + NotFound(Selector), + /// An IO operation failed. + #[error("an IO operation failed: {0}")] + IOFailed(Arc), + /// The decoding of some PNG image failed. + #[error("the decoding of some PNG image failed: {0}")] + PngDecodingFailed(Arc), + /// The encoding of some PNG image failed. + #[error("the encoding of some PNG image failed: {0}")] + 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)) + } +} diff --git a/test/src/instruction.rs b/test/src/instruction.rs new file mode 100644 index 00000000..1f8455f8 --- /dev/null +++ b/test/src/instruction.rs @@ -0,0 +1,343 @@ +use crate::core::keyboard; +use crate::core::mouse; +use crate::core::{Event, Point}; + +use std::fmt; + +#[derive(Debug, Clone)] +pub enum Instruction { + Interact(Interaction), +} + +impl fmt::Display for Instruction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Instruction::Interact(interaction) => interaction.fmt(f), + } + } +} + +#[derive(Debug, Clone)] +pub enum Interaction { + Mouse(Mouse), + Keyboard(Keyboard), +} + +impl Interaction { + pub fn from_event(event: Event) -> Option { + Some(match event { + Event::Mouse(mouse) => Self::Mouse(match mouse { + mouse::Event::CursorMoved { position } => Mouse::Move(position), + mouse::Event::ButtonPressed(button) => { + Mouse::Press { button, at: None } + } + mouse::Event::ButtonReleased(button) => { + Mouse::Release { button, at: None } + } + _ => None?, + }), + Event::Keyboard(keyboard) => Self::Keyboard(match keyboard { + keyboard::Event::KeyPressed { key, text, .. } => match key { + keyboard::Key::Named(keyboard::key::Named::Enter) => { + Keyboard::Press(Key::Enter) + } + keyboard::Key::Named(keyboard::key::Named::Escape) => { + Keyboard::Press(Key::Escape) + } + keyboard::Key::Named(keyboard::key::Named::Tab) => { + Keyboard::Press(Key::Tab) + } + keyboard::Key::Named(keyboard::key::Named::Backspace) => { + Keyboard::Press(Key::Backspace) + } + _ => Keyboard::Typewrite(text?.to_string()), + }, + keyboard::Event::KeyReleased { key, .. } => match key { + keyboard::Key::Named(keyboard::key::Named::Enter) => { + Keyboard::Release(Key::Enter) + } + keyboard::Key::Named(keyboard::key::Named::Escape) => { + Keyboard::Release(Key::Escape) + } + keyboard::Key::Named(keyboard::key::Named::Tab) => { + Keyboard::Release(Key::Tab) + } + keyboard::Key::Named(keyboard::key::Named::Backspace) => { + Keyboard::Release(Key::Backspace) + } + _ => None?, + }, + keyboard::Event::ModifiersChanged(_) => None?, + }), + _ => None?, + }) + } + + pub fn merge(self, next: Self) -> (Self, Option) { + match (self, next) { + (Self::Mouse(current), Self::Mouse(next)) => { + match (current, next) { + (Mouse::Move(_), Mouse::Move(to)) => { + (Self::Mouse(Mouse::Move(to)), None) + } + (Mouse::Move(to), Mouse::Press { button, at: None }) => ( + Self::Mouse(Mouse::Press { + button, + at: Some(to), + }), + None, + ), + (Mouse::Move(to), Mouse::Release { button, at: None }) => ( + Self::Mouse(Mouse::Release { + button, + at: Some(to), + }), + None, + ), + ( + Mouse::Press { + button: press, + at: press_at, + }, + Mouse::Release { + button: release, + at: release_at, + }, + ) if press == release + && release_at.is_none_or(|release_at| { + Some(release_at) == press_at + }) => + { + ( + Self::Mouse(Mouse::Click { + button: press, + at: press_at, + }), + None, + ) + } + (current, next) => { + (Self::Mouse(current), Some(Self::Mouse(next))) + } + } + } + (Self::Keyboard(current), Self::Keyboard(next)) => { + match (current, next) { + ( + Keyboard::Typewrite(current), + Keyboard::Typewrite(next), + ) => ( + Self::Keyboard(Keyboard::Typewrite(format!( + "{current}{next}" + ))), + None, + ), + (Keyboard::Press(current), Keyboard::Release(next)) + if current == next => + { + (Self::Keyboard(Keyboard::Type(current)), None) + } + (current, next) => { + (Self::Keyboard(current), Some(Self::Keyboard(next))) + } + } + } + (current, next) => (current, Some(next)), + } + } +} + +impl fmt::Display for Interaction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Interaction::Mouse(mouse) => mouse.fmt(f), + Interaction::Keyboard(keyboard) => keyboard.fmt(f), + } + } +} + +#[derive(Debug, Clone)] +pub enum Mouse { + Move(Point), + Press { + button: mouse::Button, + at: Option, + }, + Release { + button: mouse::Button, + at: Option, + }, + Click { + button: mouse::Button, + at: Option, + }, +} + +impl fmt::Display for Mouse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Mouse::Move(point) => { + write!(f, "move cursor to ({:.2}, {:.2})", point.x, point.y) + } + Mouse::Press { button, at } => { + write!(f, "press {}", format::button_at(*button, *at)) + } + Mouse::Release { button, at } => { + write!(f, "release {}", format::button_at(*button, *at)) + } + Mouse::Click { button, at } => { + write!(f, "click {}", format::button_at(*button, *at)) + } + } + } +} + +#[derive(Debug, Clone)] +pub enum Keyboard { + Press(Key), + Release(Key), + Type(Key), + Typewrite(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Key { + Enter, + Escape, + Tab, + Backspace, +} + +impl fmt::Display for Keyboard { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Keyboard::Press(key) => { + write!(f, "press {}", format::key(*key)) + } + Keyboard::Release(key) => { + write!(f, "release {}", format::key(*key)) + } + Keyboard::Type(key) => { + write!(f, "type {}", format::key(*key)) + } + Keyboard::Typewrite(text) => { + write!(f, "type \"{text}\"") + } + } + } +} + +mod format { + use super::*; + + pub fn button_at(button: mouse::Button, at: Option) -> String { + if let Some(at) = at { + format!("{} at {}", self::button(button), point(at)) + } else { + self::button(button).to_owned() + } + } + + pub fn button(button: mouse::Button) -> &'static str { + match button { + mouse::Button::Left => "left", + mouse::Button::Right => "right", + mouse::Button::Middle => "middle", + mouse::Button::Back => "back", + mouse::Button::Forward => "forward", + mouse::Button::Other(_) => "other", + } + } + + pub fn point(point: Point) -> String { + format!("({:.2}, {:.2})", point.x, point.y) + } + + pub fn key(key: Key) -> &'static str { + match key { + Key::Enter => "enter", + Key::Escape => "escape", + Key::Tab => "tab", + Key::Backspace => "backspace", + } + } +} + +pub use parser::{Error as ParseError, run as parse}; + +mod parser { + use super::*; + + use nom::branch::alt; + use nom::bytes::complete::tag; + use nom::character::complete::{char, multispace0}; + use nom::combinator::{map, opt}; + use nom::number::float; + use nom::sequence::{delimited, preceded, separated_pair}; + use nom::{Finish, IResult, Parser}; + + #[derive(Debug, Clone, thiserror::Error)] + #[error("parse error: {0}")] + pub struct Error(nom::error::Error); + + pub fn run(input: &str) -> Result { + match instruction.parse_complete(input).finish() { + Ok((_rest, instruction)) => Ok(instruction), + Err(error) => Err(Error(error.cloned())), + } + } + + fn instruction(input: &str) -> IResult<&str, Instruction> { + map(interaction, Instruction::Interact).parse(input) + } + + fn interaction(input: &str) -> IResult<&str, Interaction> { + map(mouse, Interaction::Mouse).parse(input) + } + + fn mouse(input: &str) -> IResult<&str, Mouse> { + let mouse_move = + preceded(tag("move cursor to "), point).map(Mouse::Move); + + alt((mouse_move, mouse_click)).parse(input) + } + + fn mouse_click(input: &str) -> IResult<&str, Mouse> { + let (input, _) = tag("click ")(input)?; + + let (input, (button, at)) = mouse_button_at(input)?; + + Ok((input, Mouse::Click { button, at })) + } + + fn mouse_button_at( + input: &str, + ) -> IResult<&str, (mouse::Button, Option)> { + let (input, button) = mouse_button(input)?; + let (input, at) = opt(preceded(tag(" at "), point)).parse(input)?; + + Ok((input, (button, at))) + } + + fn mouse_button(input: &str) -> IResult<&str, mouse::Button> { + alt(( + tag("left").map(|_| mouse::Button::Left), + tag("right").map(|_| mouse::Button::Right), + )) + .parse(input) + } + + fn point(input: &str) -> IResult<&str, Point> { + let comma = (multispace0, char(','), multispace0); + + map( + delimited( + char('('), + separated_pair(float(), comma, float()), + char(')'), + ), + |(x, y)| Point { x, y }, + ) + .parse(input) + } +} diff --git a/test/src/lib.rs b/test/src/lib.rs index 636b8173..4ec0d8dd 100644 --- a/test/src/lib.rs +++ b/test/src/lib.rs @@ -24,15 +24,14 @@ //! # impl Counter { //! # pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() } //! # } -//! use iced_test::selector::text; //! # use iced_test::simulator; //! # //! # let mut counter = Counter { value: 0 }; //! # let mut ui = simulator(counter.view()); //! -//! let _ = ui.click(text("+")); -//! let _ = ui.click(text("+")); -//! let _ = ui.click(text("-")); +//! let _ = ui.click("+"); +//! let _ = ui.click("+"); +//! let _ = ui.click("-"); //! ``` //! //! [`Simulator::click`] takes a [`Selector`]. A [`Selector`] describes a way to query the widgets of an interface. In this case, @@ -47,15 +46,14 @@ //! # pub fn update(&mut self, message: ()) {} //! # pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() } //! # } -//! # use iced_test::selector::text; //! # use iced_test::simulator; //! # //! # let mut counter = Counter { value: 0 }; //! # let mut ui = simulator(counter.view()); //! # -//! # let _ = ui.click(text("+")); -//! # let _ = ui.click(text("+")); -//! # let _ = ui.click(text("-")); +//! # let _ = ui.click("+"); +//! # let _ = ui.click("+"); +//! # let _ = ui.click("-"); //! # //! for message in ui.into_messages() { //! counter.update(message); @@ -71,13 +69,12 @@ //! # impl Counter { //! # pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() } //! # } -//! # use iced_test::selector::text; //! # use iced_test::simulator; //! # //! # let mut counter = Counter { value: 0 }; //! let mut ui = simulator(counter.view()); //! -//! assert!(ui.find(text("1")).is_ok(), "Counter should display 1!"); +//! assert!(ui.find("1").is_ok(), "Counter should display 1!"); //! ``` //! //! And that's it! That's the gist of testing `iced` applications! @@ -86,575 +83,23 @@ //! [`typewrite`](Simulator::typewrite)—and even perform [_snapshot testing_](Simulator::snapshot)! //! //! [the classical counter interface]: https://book.iced.rs/architecture.html#dissecting-an-interface -pub mod selector; - -pub use selector::Selector; - +#![allow(missing_docs)] 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::UserInterface; -use crate::runtime::user_interface; +pub mod instruction; +pub mod selector; +pub mod simulator; -use std::borrow::Cow; -use std::env; -use std::fs; -use std::io; -use std::path::{Path, PathBuf}; -use std::sync::Arc; +mod error; -/// Creates a new [`Simulator`]. -/// -/// This is just a function version of [`Simulator::new`]. -pub fn simulator<'a, Message, Theme, Renderer>( - element: impl Into>, -) -> Simulator<'a, Message, Theme, Renderer> -where - Theme: theme::Base, - Renderer: core::Renderer + core::renderer::Headless, -{ - Simulator::new(element) -} +pub use error::Error; +pub use instruction::Instruction; +pub use selector::Selector; +pub use simulator::{Simulator, simulator}; -/// A user interface that can be interacted with and inspected programmatically. -#[allow(missing_debug_implementations)] -pub struct Simulator< - 'a, - Message, - Theme = core::Theme, - Renderer = renderer::Renderer, -> { - raw: UserInterface<'a, Message, Theme, Renderer>, - renderer: Renderer, - size: Size, - cursor: mouse::Cursor, - messages: Vec, -} - -/// A specific area of a [`Simulator`], normally containing a widget. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Target { - /// The bounds of the area. - pub bounds: Rectangle, -} - -impl<'a, Message, Theme, Renderer> Simulator<'a, Message, Theme, Renderer> -where - Theme: theme::Base, - Renderer: core::Renderer + core::renderer::Headless, -{ - /// Creates a new [`Simulator`] with default [`Settings`] and a default size (1024x768). - pub fn new( - element: impl Into>, - ) -> Self { - Self::with_settings(Settings::default(), element) - } - - /// Creates a new [`Simulator`] with the given [`Settings`] and a default size (1024x768). - pub fn with_settings( - settings: Settings, - element: impl Into>, - ) -> Self { - Self::with_size(settings, window::Settings::default().size, element) - } - - /// Creates a new [`Simulator`] with the given [`Settings`] and size. - pub fn with_size( - settings: Settings, - size: impl Into, - element: impl Into>, - ) -> Self { - let size = 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 = { - let backend = env::var("ICED_TEST_BACKEND").ok(); - - iced_runtime::futures::futures::executor::block_on(Renderer::new( - default_font, - settings.default_text_size, - backend.as_deref(), - )) - .expect("Create new headless renderer") - }; - - let raw = UserInterface::build( - element, - size, - user_interface::Cache::default(), - &mut renderer, - ); - - Simulator { - raw, - renderer, - size, - cursor: mouse::Cursor::Unavailable, - messages: Vec::new(), - } - } - - /// Finds the [`Target`] of the given widget [`Selector`] in the [`Simulator`]. - 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)) - } - } - } - - /// Points the mouse cursor at the given position in the [`Simulator`]. - /// - /// This does _not_ produce mouse movement events! - pub fn point_at(&mut self, position: impl Into) { - self.cursor = mouse::Cursor::Available(position.into()); - } - - /// Clicks the [`Target`] found by the given [`Selector`], if any. - /// - /// This consists in: - /// - Pointing the mouse cursor at the center of the [`Target`]. - /// - Simulating a [`click`]. - 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) - } - - /// Simulates a key press, followed by a release, in the [`Simulator`]. - pub fn tap_key(&mut self, key: impl Into) -> event::Status { - self.simulate(tap_key(key, None)) - .first() - .copied() - .unwrap_or(event::Status::Ignored) - } - - /// Simulates a user typing in the keyboard the given text in the [`Simulator`]. - 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) - } - - /// Simulates the given raw sequence of events in the [`Simulator`]. - 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 - } - - /// Draws and takes a [`Snapshot`] of the interface in the [`Simulator`]. - pub fn snapshot(&mut self, theme: &Theme) -> Result { - 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, - ); - - 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.size.width * scale_factor).round() as u32, - (self.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), - ), - renderer: self.renderer.name(), - }) - } - - /// Turns the [`Simulator`] into the sequence of messages produced by any interactions. - pub fn into_messages( - self, - ) -> impl Iterator + use { - self.messages.into_iter() - } -} - -/// A frame of a user interface rendered by a [`Simulator`]. #[derive(Debug, Clone)] -pub struct Snapshot { - screenshot: window::Screenshot, - renderer: String, -} - -impl Snapshot { - /// Compares the [`Snapshot`] with the PNG image found in the given path, returning - /// `true` if they are identical. - /// - /// If the PNG image does not exist, it will be created by the [`Snapshot`] for future - /// testing and `true` will be returned. - pub fn matches_image(&self, path: impl AsRef) -> Result { - let path = self.path(path, "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) - } - } - - /// Compares the [`Snapshot`] with the SHA-256 hash file found in the given path, returning - /// `true` if they are identical. - /// - /// If the hash file does not exist, it will be created by the [`Snapshot`] for future - /// testing and `true` will be returned. - pub fn matches_hash(&self, path: impl AsRef) -> Result { - use sha2::{Digest, Sha256}; - - let path = self.path(path, "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) - } - } - - fn path(&self, path: impl AsRef, extension: &str) -> PathBuf { - let path = path.as_ref(); - - path.with_file_name(format!( - "{name}-{renderer}", - name = path - .file_stem() - .map(std::ffi::OsStr::to_string_lossy) - .unwrap_or_default(), - renderer = self.renderer - )) - .with_extension(extension) - } -} - -/// Returns the sequence of events of a click. -pub fn click() -> impl Iterator { - [ - Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), - Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), - ] - .into_iter() -} - -/// Returns the sequence of events of a "key tap" (i.e. pressing and releasing a key). -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() -} - -/// Returns the sequence of events of typewriting the given text in a keyboard. -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))) -} - -/// A test error. -#[derive(Debug, Clone, thiserror::Error)] -pub enum Error { - /// No matching widget was found for the [`Selector`]. - #[error("no matching widget was found for the selector: {0:?}")] - NotFound(Selector), - /// An IO operation failed. - #[error("an IO operation failed: {0}")] - IOFailed(Arc), - /// The decoding of some PNG image failed. - #[error("the decoding of some PNG image failed: {0}")] - PngDecodingFailed(Arc), - /// The encoding of some PNG image failed. - #[error("the encoding of some PNG image failed: {0}")] - 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)) - } -} - -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 Test { + instructions: Vec, } diff --git a/test/src/simulator.rs b/test/src/simulator.rs new file mode 100644 index 00000000..e638e9a0 --- /dev/null +++ b/test/src/simulator.rs @@ -0,0 +1,531 @@ +//! Run a simulation of your application. +use crate::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::renderer; +use crate::runtime::UserInterface; +use crate::runtime::user_interface; +use crate::{Error, Selector}; + +use std::borrow::Cow; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +/// A user interface that can be interacted with and inspected programmatically. +#[allow(missing_debug_implementations)] +pub struct Simulator< + 'a, + Message, + Theme = core::Theme, + Renderer = renderer::Renderer, +> { + raw: UserInterface<'a, Message, Theme, Renderer>, + renderer: Renderer, + size: Size, + cursor: mouse::Cursor, + messages: Vec, +} + +/// A specific area of a [`Simulator`], normally containing a widget. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Target { + /// The bounds of the area. + pub bounds: Rectangle, +} + +impl<'a, Message, Theme, Renderer> Simulator<'a, Message, Theme, Renderer> +where + Theme: theme::Base, + Renderer: core::Renderer + core::renderer::Headless, +{ + /// Creates a new [`Simulator`] with default [`Settings`] and a default size (1024x768). + pub fn new( + element: impl Into>, + ) -> Self { + Self::with_settings(Settings::default(), element) + } + + /// Creates a new [`Simulator`] with the given [`Settings`] and a default size (1024x768). + pub fn with_settings( + settings: Settings, + element: impl Into>, + ) -> Self { + Self::with_size(settings, window::Settings::default().size, element) + } + + /// Creates a new [`Simulator`] with the given [`Settings`] and size. + pub fn with_size( + settings: Settings, + size: impl Into, + element: impl Into>, + ) -> Self { + let size = 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 = { + let backend = env::var("ICED_TEST_BACKEND").ok(); + + iced_runtime::futures::futures::executor::block_on(Renderer::new( + default_font, + settings.default_text_size, + backend.as_deref(), + )) + .expect("Create new headless renderer") + }; + + let raw = UserInterface::build( + element, + size, + user_interface::Cache::default(), + &mut renderer, + ); + + Simulator { + raw, + renderer, + size, + cursor: mouse::Cursor::Unavailable, + messages: Vec::new(), + } + } + + /// Finds the [`Target`] of the given widget [`Selector`] in the [`Simulator`]. + 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)) + } + } + } + + /// Points the mouse cursor at the given position in the [`Simulator`]. + /// + /// This does _not_ produce mouse movement events! + pub fn point_at(&mut self, position: impl Into) { + self.cursor = mouse::Cursor::Available(position.into()); + } + + /// Clicks the [`Target`] found by the given [`Selector`], if any. + /// + /// This consists in: + /// - Pointing the mouse cursor at the center of the [`Target`]. + /// - Simulating a [`click`]. + 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) + } + + /// Simulates a key press, followed by a release, in the [`Simulator`]. + pub fn tap_key(&mut self, key: impl Into) -> event::Status { + self.simulate(tap_key(key, None)) + .first() + .copied() + .unwrap_or(event::Status::Ignored) + } + + /// Simulates a user typing in the keyboard the given text in the [`Simulator`]. + 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) + } + + /// Simulates the given raw sequence of events in the [`Simulator`]. + 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 + } + + /// Draws and takes a [`Snapshot`] of the interface in the [`Simulator`]. + pub fn snapshot(&mut self, theme: &Theme) -> Result { + 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, + ); + + 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.size.width * scale_factor).round() as u32, + (self.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), + ), + renderer: self.renderer.name(), + }) + } + + /// Turns the [`Simulator`] into the sequence of messages produced by any interactions. + pub fn into_messages( + self, + ) -> impl Iterator + use { + self.messages.into_iter() + } +} + +/// A frame of a user interface rendered by a [`Simulator`]. +#[derive(Debug, Clone)] +pub struct Snapshot { + screenshot: window::Screenshot, + renderer: String, +} + +impl Snapshot { + /// Compares the [`Snapshot`] with the PNG image found in the given path, returning + /// `true` if they are identical. + /// + /// If the PNG image does not exist, it will be created by the [`Snapshot`] for future + /// testing and `true` will be returned. + pub fn matches_image(&self, path: impl AsRef) -> Result { + let path = self.path(path, "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) + } + } + + /// Compares the [`Snapshot`] with the SHA-256 hash file found in the given path, returning + /// `true` if they are identical. + /// + /// If the hash file does not exist, it will be created by the [`Snapshot`] for future + /// testing and `true` will be returned. + pub fn matches_hash(&self, path: impl AsRef) -> Result { + use sha2::{Digest, Sha256}; + + let path = self.path(path, "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) + } + } + + fn path(&self, path: impl AsRef, extension: &str) -> PathBuf { + let path = path.as_ref(); + + path.with_file_name(format!( + "{name}-{renderer}", + name = path + .file_stem() + .map(std::ffi::OsStr::to_string_lossy) + .unwrap_or_default(), + renderer = self.renderer + )) + .with_extension(extension) + } +} + +/// Creates a new [`Simulator`]. +/// +/// This is just a function version of [`Simulator::new`]. +pub fn simulator<'a, Message, Theme, Renderer>( + element: impl Into>, +) -> Simulator<'a, Message, Theme, Renderer> +where + Theme: theme::Base, + Renderer: core::Renderer + core::renderer::Headless, +{ + Simulator::new(element) +} + +/// Returns the sequence of events of a click. +pub fn click() -> impl Iterator { + [ + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + ] + .into_iter() +} + +/// Returns the sequence of events of a "key tap" (i.e. pressing and releasing a key). +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() +} + +/// Returns the sequence of events of typewriting the given text in a keyboard. +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))) +} + +fn load_font(font: impl Into>) -> Result<(), Error> { + renderer::graphics::text::font_system() + .write() + .expect("Write to font system") + .load_font(font.into()); + + Ok(()) +} diff --git a/widget/src/container.rs b/widget/src/container.rs index 4f6725b1..73b2502a 100644 --- a/widget/src/container.rs +++ b/widget/src/container.rs @@ -698,6 +698,7 @@ pub fn rounded_box(theme: &Theme) -> Style { Style { background: Some(palette.background.weak.color.into()), + text_color: Some(palette.background.weak.text), border: border::rounded(2), ..Style::default() } @@ -709,6 +710,7 @@ pub fn bordered_box(theme: &Theme) -> Style { Style { background: Some(palette.background.weakest.color.into()), + text_color: Some(palette.background.weakest.text), border: Border { width: 1.0, radius: 5.0.into(), diff --git a/widget/src/themer.rs b/widget/src/themer.rs index 1b9e1c6a..43de86f4 100644 --- a/widget/src/themer.rs +++ b/widget/src/themer.rs @@ -206,13 +206,9 @@ where layout: Layout<'_>, cursor: mouse::Cursor, ) { - self.content.as_overlay().draw( - renderer, - &self.theme, - style, - layout, - cursor, - ); + self.content + .as_overlay() + .draw(renderer, self.theme, style, layout, cursor); } fn update(