Draft Instruction DSL in iced_test
This commit is contained in:
parent
327522eb99
commit
921467b5be
12 changed files with 1246 additions and 631 deletions
12
Cargo.lock
generated
12
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<core::Event> },
|
||||
Recording { events: Vec<core::Event> },
|
||||
struct Recorder {
|
||||
instructions: Vec<test::Instruction>,
|
||||
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),
|
||||
|
|
|
|||
12
devtools/src/widget.rs
Normal file
12
devtools/src/widget.rs
Normal file
|
|
@ -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<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Recorder<'a, Message, Theme, Renderer> {
|
||||
Recorder::new(content)
|
||||
}
|
||||
178
devtools/src/widget/recorder.rs
Normal file
178
devtools/src/widget/recorder.rs
Normal file
|
|
@ -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<Box<dyn Fn(Event) -> Message + 'a>>,
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer> Recorder<'a, Message, Theme, Renderer> {
|
||||
pub fn new(
|
||||
content: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> 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<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||
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<widget::Tree> {
|
||||
self.content.as_widget().children()
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut tree::Tree) {
|
||||
self.content.as_widget().diff(tree);
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
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<Recorder<'a, Message, Theme, Renderer>>
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
39
test/src/error.rs
Normal file
39
test/src/error.rs
Normal file
|
|
@ -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<io::Error>),
|
||||
/// The decoding of some PNG image failed.
|
||||
#[error("the decoding of some PNG image failed: {0}")]
|
||||
PngDecodingFailed(Arc<png::DecodingError>),
|
||||
/// The encoding of some PNG image failed.
|
||||
#[error("the encoding of some PNG image failed: {0}")]
|
||||
PngEncodingFailed(Arc<png::EncodingError>),
|
||||
}
|
||||
|
||||
impl From<io::Error> for Error {
|
||||
fn from(error: io::Error) -> Self {
|
||||
Self::IOFailed(Arc::new(error))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<png::DecodingError> for Error {
|
||||
fn from(error: png::DecodingError) -> Self {
|
||||
Self::PngDecodingFailed(Arc::new(error))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<png::EncodingError> for Error {
|
||||
fn from(error: png::EncodingError) -> Self {
|
||||
Self::PngEncodingFailed(Arc::new(error))
|
||||
}
|
||||
}
|
||||
343
test/src/instruction.rs
Normal file
343
test/src/instruction.rs
Normal file
|
|
@ -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<Self> {
|
||||
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<Self>) {
|
||||
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<Point>,
|
||||
},
|
||||
Release {
|
||||
button: mouse::Button,
|
||||
at: Option<Point>,
|
||||
},
|
||||
Click {
|
||||
button: mouse::Button,
|
||||
at: Option<Point>,
|
||||
},
|
||||
}
|
||||
|
||||
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<Point>) -> 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<String>);
|
||||
|
||||
pub fn run(input: &str) -> Result<Instruction, Error> {
|
||||
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<Point>)> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
591
test/src/lib.rs
591
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<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> 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<Message>,
|
||||
}
|
||||
|
||||
/// 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<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> 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<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> 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<Size>,
|
||||
element: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> 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<Selector>,
|
||||
) -> Result<Target, Error> {
|
||||
let selector = selector.into();
|
||||
|
||||
match &selector {
|
||||
Selector::Id(id) => {
|
||||
struct FindById<'a> {
|
||||
id: &'a widget::Id,
|
||||
target: Option<Target>,
|
||||
}
|
||||
|
||||
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<Target>,
|
||||
}
|
||||
|
||||
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<Point>) {
|
||||
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<Selector>,
|
||||
) -> Result<Target, Error> {
|
||||
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<keyboard::Key>) -> 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<Item = Event>,
|
||||
) -> Vec<event::Status> {
|
||||
let events: Vec<Event> = 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<Snapshot, Error> {
|
||||
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<Item = Message> + use<Message, Theme, Renderer> {
|
||||
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<Path>) -> Result<bool, Error> {
|
||||
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<Path>) -> Result<bool, Error> {
|
||||
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<Path>, 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<Item = Event> {
|
||||
[
|
||||
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<keyboard::Key>,
|
||||
text: Option<SmolStr>,
|
||||
) -> impl Iterator<Item = Event> {
|
||||
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<Item = Event> + '_ {
|
||||
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<io::Error>),
|
||||
/// The decoding of some PNG image failed.
|
||||
#[error("the decoding of some PNG image failed: {0}")]
|
||||
PngDecodingFailed(Arc<png::DecodingError>),
|
||||
/// The encoding of some PNG image failed.
|
||||
#[error("the encoding of some PNG image failed: {0}")]
|
||||
PngEncodingFailed(Arc<png::EncodingError>),
|
||||
}
|
||||
|
||||
impl From<io::Error> for Error {
|
||||
fn from(error: io::Error) -> Self {
|
||||
Self::IOFailed(Arc::new(error))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<png::DecodingError> for Error {
|
||||
fn from(error: png::DecodingError) -> Self {
|
||||
Self::PngDecodingFailed(Arc::new(error))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<png::EncodingError> for Error {
|
||||
fn from(error: png::EncodingError) -> Self {
|
||||
Self::PngEncodingFailed(Arc::new(error))
|
||||
}
|
||||
}
|
||||
|
||||
fn load_font(font: impl Into<Cow<'static, [u8]>>) -> Result<(), Error> {
|
||||
renderer::graphics::text::font_system()
|
||||
.write()
|
||||
.expect("Write to font system")
|
||||
.load_font(font.into());
|
||||
|
||||
Ok(())
|
||||
pub struct Test {
|
||||
instructions: Vec<Instruction>,
|
||||
}
|
||||
|
|
|
|||
531
test/src/simulator.rs
Normal file
531
test/src/simulator.rs
Normal file
|
|
@ -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<Message>,
|
||||
}
|
||||
|
||||
/// 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<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> 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<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> 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<Size>,
|
||||
element: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> 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<Selector>,
|
||||
) -> Result<Target, Error> {
|
||||
let selector = selector.into();
|
||||
|
||||
match &selector {
|
||||
Selector::Id(id) => {
|
||||
struct FindById<'a> {
|
||||
id: &'a widget::Id,
|
||||
target: Option<Target>,
|
||||
}
|
||||
|
||||
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<Target>,
|
||||
}
|
||||
|
||||
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<Point>) {
|
||||
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<Selector>,
|
||||
) -> Result<Target, Error> {
|
||||
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<keyboard::Key>) -> 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<Item = Event>,
|
||||
) -> Vec<event::Status> {
|
||||
let events: Vec<Event> = 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<Snapshot, Error> {
|
||||
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<Item = Message> + use<Message, Theme, Renderer> {
|
||||
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<Path>) -> Result<bool, Error> {
|
||||
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<Path>) -> Result<bool, Error> {
|
||||
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<Path>, 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<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> 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<Item = Event> {
|
||||
[
|
||||
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<keyboard::Key>,
|
||||
text: Option<SmolStr>,
|
||||
) -> impl Iterator<Item = Event> {
|
||||
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<Item = Event> + '_ {
|
||||
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<Cow<'static, [u8]>>) -> Result<(), Error> {
|
||||
renderer::graphics::text::font_system()
|
||||
.write()
|
||||
.expect("Write to font system")
|
||||
.load_font(font.into());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue