Move tester to a new iced_tester subcrate

This commit is contained in:
Héctor Ramón Jiménez 2025-08-29 08:39:44 +02:00
parent 9e81c2b9e8
commit 4f7444bddf
No known key found for this signature in database
GPG key ID: 7CC46565708259A7
28 changed files with 392 additions and 355 deletions

View file

@ -1,5 +1,4 @@
use crate::executor;
use crate::runtime::Task;
use crate::runtime::task::{self, Task};
use std::process;
@ -7,7 +6,7 @@ pub const COMPATIBLE_REVISION: &str =
"20f9c9a897fecac5dce0977bbb5639fdce1f54b9";
pub fn launch() -> Task<launch::Result> {
executor::try_spawn_blocking(|mut sender| {
task::try_blocking(|mut sender| {
let cargo_install = process::Command::new("cargo")
.args(["install", "--list"])
.output()?;
@ -48,7 +47,7 @@ pub fn launch() -> Task<launch::Result> {
}
pub fn install() -> Task<install::Result> {
executor::try_spawn_blocking(|mut sender| {
task::try_blocking(|mut sender| {
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};

View file

@ -1,43 +0,0 @@
use crate::futures::futures::channel::mpsc;
use crate::futures::futures::channel::oneshot;
use crate::futures::futures::stream::{self, StreamExt};
use crate::runtime::Task;
use std::thread;
pub fn spawn_blocking<T>(
f: impl FnOnce(mpsc::Sender<T>) + Send + 'static,
) -> Task<T>
where
T: Send + 'static,
{
let (sender, receiver) = mpsc::channel(1);
let _ = thread::spawn(move || {
f(sender);
});
Task::stream(receiver)
}
pub fn try_spawn_blocking<T, E>(
f: impl FnOnce(mpsc::Sender<T>) -> Result<(), E> + Send + 'static,
) -> Task<Result<T, E>>
where
T: Send + 'static,
E: Send + 'static,
{
let (sender, receiver) = mpsc::channel(1);
let (error_sender, error_receiver) = oneshot::channel();
let _ = thread::spawn(move || {
if let Err(error) = f(sender) {
let _ = error_sender.send(Err(error));
}
});
Task::stream(stream::select(
receiver.map(Ok),
stream::once(error_receiver).filter_map(async |result| result.ok()),
))
}

View file

@ -1,118 +0,0 @@
#![allow(unused)]
use crate::core::Font;
use crate::program;
use crate::widget::{Text, text};
pub const FONT: &[u8] = include_bytes!("../fonts/iced_devtools-icons.ttf");
pub fn cancel<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{2715}")
}
pub fn check<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{2713}")
}
pub fn floppy<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{1F4BE}")
}
pub fn folder<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{1F4C1}")
}
pub fn keyboard<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{2328}")
}
pub fn lightbulb<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{F0EB}")
}
pub fn mouse_pointer<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{F245}")
}
pub fn pause<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{2389}")
}
pub fn pencil<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{270E}")
}
pub fn play<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{25B6}")
}
pub fn record<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{26AB}")
}
pub fn stop<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{25A0}")
}
pub fn tape<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{2707}")
}
fn icon<'a, Theme, Renderer>(codepoint: &'a str) -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
text(codepoint).font(Font::with_name("iced_devtools-icons"))
}

View file

@ -3,39 +3,28 @@ use iced_debug as debug;
use iced_program as program;
use iced_program::runtime;
use iced_program::runtime::futures;
#[cfg(feature = "tester")]
use iced_test as test;
use iced_widget as widget;
use iced_widget::core;
mod comet;
mod executor;
mod icon;
mod time_machine;
mod widget;
#[cfg(feature = "tester")]
mod tester;
#[cfg(not(feature = "tester"))]
#[path = "tester/null.rs"]
mod tester;
use crate::tester::Tester;
use crate::core::border;
use crate::core::keyboard;
use crate::core::theme::{self, Base, Theme};
use crate::core::time::seconds;
use crate::core::window;
use crate::core::{Alignment::Center, Color, Element, Length::Fill};
use crate::core::{
Alignment::Center, Color, Element, Font, Length::Fill, Settings,
};
use crate::futures::Subscription;
use crate::program::Program;
use crate::runtime::Task;
use crate::runtime::font;
use crate::program::message;
use crate::runtime::task::{self, Task};
use crate::time_machine::TimeMachine;
use crate::widget::{
bottom_right, button, center, column, container, horizontal_space,
monospace, opaque, row, scrollable, stack, text, themer,
bottom_right, button, center, column, container, horizontal_space, opaque,
row, scrollable, stack, text, themer,
};
use std::fmt;
@ -55,6 +44,7 @@ pub struct Attach<P> {
impl<P> Program for Attach<P>
where
P: Program + 'static,
P::Message: std::fmt::Debug + message::MaybeClone,
{
type State = DevTools<P>;
type Message = Event<P>;
@ -66,21 +56,21 @@ where
P::name()
}
fn settings(&self) -> core::Settings {
fn settings(&self) -> Settings {
self.program.settings()
}
fn window(&self) -> Option<window::Settings> {
self.program.window()
}
fn boot(&self) -> (Self::State, Task<Self::Message>) {
let (state, boot) = self.program.boot();
let (devtools, task) = DevTools::new(state);
(
devtools,
Task::batch([
boot.map(Event::Program),
task.map(Event::Message),
font::load(icon::FONT).discard(),
]),
Task::batch([boot.map(Event::Program), task.map(Event::Message)]),
)
}
@ -130,7 +120,7 @@ where
state: P::State,
show_notification: bool,
time_machine: TimeMachine<P>,
mode: Mode<P>,
mode: Mode,
}
#[derive(Debug, Clone)]
@ -141,13 +131,10 @@ pub enum Message {
InstallComet,
Installing(comet::install::Result),
CancelSetup,
Toggle,
Tester(tester::Message),
}
enum Mode<P: Program> {
enum Mode {
Hidden,
Open { tester: Tester<P> },
Setup(Setup),
}
@ -164,6 +151,7 @@ enum Goal {
impl<P> DevTools<P>
where
P: Program + 'static,
P::Message: std::fmt::Debug + message::MaybeClone,
{
pub fn new(state: P::State) -> (Self, Task<Message>) {
(
@ -173,7 +161,7 @@ where
show_notification: true,
time_machine: TimeMachine::new(),
},
executor::spawn_blocking(|mut sender| {
task::blocking(|mut sender| {
thread::sleep(seconds(2));
let _ = sender.try_send(());
})
@ -193,21 +181,6 @@ where
Task::none()
}
Message::Toggle => {
match &self.mode {
Mode::Hidden => {
self.mode = Mode::Open {
tester: Tester::new(program),
};
}
Mode::Open { tester } if !tester.is_busy() => {
self.mode = Mode::Hidden;
}
Mode::Setup(_) | Mode::Open { .. } => {}
}
Task::none()
}
Message::ToggleComet => {
if let Mode::Setup(setup) = &self.mode {
if matches!(setup, Setup::Idle { .. }) {
@ -290,13 +263,6 @@ where
Task::none()
}
Message::Tester(message) => {
let Mode::Open { tester } = &mut self.mode else {
return Task::none();
};
tester.update(program, message).map(Event::Tester)
}
},
Event::Program(message) => {
self.time_machine.push(&message);
@ -328,13 +294,6 @@ where
Task::none()
}
Event::Tester(tick) => {
let Mode::Open { tester } = &mut self.mode else {
return Task::none();
};
tester.tick(program, tick).map(Event::Tester)
}
Event::Discard => Task::none(),
}
}
@ -347,23 +306,15 @@ where
let state = self.state();
let view = {
let view = || {
let theme = program.theme(state, window);
let view: Element<'_, _, Theme, _> =
themer(theme, program.view(&self.state, window)).into();
let theme = program.theme(state, window);
if self.time_machine.is_rewinding() {
view.map(|_| Event::Discard)
} else {
view.map(Event::Program)
}
};
let view: Element<'_, _, Theme, _> =
themer(theme, program.view(state, window)).into();
match &self.mode {
Mode::Open { tester } => {
tester.view(program, view, Event::Tester)
}
_ => view(),
if self.time_machine.is_rewinding() {
view.map(|_| Event::Discard)
} else {
view.map(Event::Program)
}
};
@ -408,28 +359,9 @@ where
})
});
let sidebar = if let Mode::Open { tester } = &self.mode {
let title = monospace("Developer Tools");
let tester = tester.controls().map(Message::Tester);
let tools = column![title, tester].spacing(10);
let sidebar = container(tools)
.padding(10)
.width(250)
.height(Fill)
.style(container::dark);
Some(Element::from(sidebar).map(Event::Message))
} else {
None
};
let content = row![view, sidebar];
themer(
theme,
stack![content]
stack![view]
.height(Fill)
.push_maybe(setup.map(opaque))
.push_maybe(notification.map(|notification| {
@ -451,14 +383,6 @@ where
let hotkeys =
futures::keyboard::on_key_press(|key, _modifiers| match key {
keyboard::Key::Named(keyboard::key::Named::F12) => {
Some(if cfg!(feature = "tester") {
Message::Toggle
} else {
Message::ToggleComet
})
}
#[cfg(feature = "tester")]
keyboard::Key::Named(keyboard::key::Named::F11) => {
Some(Message::ToggleComet)
}
_ => None,
@ -479,11 +403,7 @@ where
}
pub fn scale_factor(&self, program: &P, window: window::Id) -> f64 {
if let Mode::Open { .. } = &self.mode {
1.0
} else {
program.scale_factor(self.state(), window)
}
program.scale_factor(self.state(), window)
}
pub fn state(&self) -> &P::State {
@ -497,7 +417,6 @@ where
{
Message(Message),
Program(P::Message),
Tester(tester::Tick<P>),
Command(debug::Command),
Discard,
}
@ -505,34 +424,18 @@ where
impl<P> fmt::Debug for Event<P>
where
P: Program,
P::Message: std::fmt::Debug,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Message(message) => message.fmt(f),
Self::Program(message) => message.fmt(f),
Self::Tester(_) => f.write_str("Tester"),
Self::Command(command) => command.fmt(f),
Self::Discard => f.write_str("Discard"),
}
}
}
#[cfg(feature = "time-travel")]
impl<P> Clone for Event<P>
where
P: Program,
{
fn clone(&self) -> Self {
match self {
Self::Message(message) => Self::Message(message.clone()),
Self::Program(message) => Self::Program(message.clone()),
Self::Command(command) => Self::Command(*command),
Self::Tester(_) => Self::Discard, // Time traveling an emulator?!
Self::Discard => Self::Discard,
}
}
}
fn setup<Renderer>(goal: &Goal) -> Element<'_, Message, Theme, Renderer>
where
Renderer: program::Renderer + 'static,
@ -557,13 +460,14 @@ where
];
let command = container(
monospace(format!(
text!(
"cargo install --locked \\
--git https://github.com/iced-rs/comet.git \\
--rev {}",
comet::COMPATIBLE_REVISION
))
.size(14),
)
.size(14)
.font(Font::MONOSPACE),
)
.width(Fill)
.padding(5)
@ -630,9 +534,9 @@ where
text("Installing comet...").size(20),
container(
scrollable(
column(
logs.iter().map(|log| { monospace(log).size(12).into() }),
)
column(logs.iter().map(|log| {
text(log).size(12).font(Font::MONOSPACE).into()
}))
.spacing(3),
)
.spacing(10)
@ -653,7 +557,7 @@ fn inline_code<'a, Renderer>(
where
Renderer: program::Renderer + 'a,
{
container(monospace(code).size(12))
container(text(code).size(12).font(Font::MONOSPACE))
.style(|_theme| {
container::Style::default()
.background(Color::BLACK)

View file

@ -1,759 +0,0 @@
mod recorder;
use recorder::recorder;
use crate::Program;
use crate::core::Alignment::Center;
use crate::core::Length::Fill;
use crate::core::alignment::Horizontal::Right;
use crate::core::border;
use crate::core::mouse;
use crate::core::window;
use crate::core::{Element, Font, Size, Theme};
use crate::executor;
use crate::futures::futures::channel::mpsc;
use crate::icon;
use crate::program;
use crate::runtime::Task;
use crate::test::emulator;
use crate::test::ice;
use crate::test::instruction;
use crate::test::{Emulator, Ice, Instruction};
use crate::widget::{
button, center, column, combo_box, container, horizontal_space, monospace,
pick_list, row, scrollable, text, text_editor, text_input, themer,
};
pub struct Tester<P: Program> {
viewport: Size,
mode: emulator::Mode,
presets: combo_box::State<String>,
preset: Option<String>,
instructions: Vec<Instruction>,
state: State<P>,
edit: Option<text_editor::Content<P::Renderer>>,
}
enum State<P: Program> {
Idle,
Recording {
emulator: Emulator<P>,
},
Asserting {
state: P::State,
window: window::Id,
last_interaction: Option<instruction::Interaction>,
},
Playing {
emulator: Emulator<P>,
current: usize,
outcome: Outcome,
},
}
enum Outcome {
Running,
Failed,
Success,
}
#[derive(Debug, Clone)]
pub enum Message {
ChangeViewport(Size),
ModeSelected(emulator::Mode),
PresetSelected(String),
Record,
Stop,
Play,
Import,
Export,
Imported(Result<Ice, ice::ParseError>),
Edit,
Edited(text_editor::Action),
Confirm,
}
#[allow(missing_debug_implementations)]
pub enum Tick<P: Program> {
Tester(Message),
Program(P::Message),
Emulator(emulator::Event<P>),
Record(instruction::Interaction),
Assert(instruction::Interaction),
}
impl<P: Program + 'static> Tester<P> {
pub fn new(program: &P) -> Self {
Self {
mode: emulator::Mode::default(),
viewport: window::Settings::default().size,
presets: combo_box::State::new(
program
.presets()
.iter()
.map(program::Preset::name)
.map(str::to_owned)
.collect(),
),
preset: None,
instructions: Vec::new(),
state: State::Idle,
edit: None,
}
}
pub fn is_busy(&self) -> bool {
matches!(
self.state,
State::Recording { .. }
| State::Playing {
outcome: Outcome::Running,
..
}
)
}
pub fn update(&mut self, program: &P, message: Message) -> Task<Tick<P>> {
match message {
Message::ChangeViewport(viewport) => {
self.viewport = viewport;
Task::none()
}
Message::ModeSelected(mode) => {
self.mode = mode;
Task::none()
}
Message::PresetSelected(preset) => {
self.preset = Some(preset);
Task::none()
}
Message::Record => {
self.edit = None;
self.instructions.clear();
let (sender, receiver) = mpsc::channel(1);
let emulator = Emulator::with_preset(
sender,
program,
self.mode,
self.viewport,
self.preset(program),
);
self.state = State::Recording { emulator };
Task::run(receiver, Tick::Emulator)
}
Message::Stop => {
let State::Recording { emulator } =
std::mem::replace(&mut self.state, State::Idle)
else {
return Task::none();
};
while let Some(Instruction::Interact(
instruction::Interaction::Mouse(instruction::Mouse::Move(
_,
)),
)) = self.instructions.last()
{
let _ = self.instructions.pop();
}
let (state, window) = emulator.into_state();
self.state = State::Asserting {
state,
window,
last_interaction: None,
};
Task::none()
}
Message::Play => {
self.confirm();
let (sender, receiver) = mpsc::channel(1);
let emulator = Emulator::with_preset(
sender,
program,
self.mode,
self.viewport,
self.preset(program),
);
self.state = State::Playing {
emulator,
current: 0,
outcome: Outcome::Running,
};
Task::run(receiver, Tick::Emulator)
}
Message::Import => {
use std::fs;
let import = rfd::AsyncFileDialog::new()
.add_filter("ice", &["ice"])
.pick_file();
Task::future(import)
.and_then(|file| {
executor::spawn_blocking(move |mut sender| {
let _ = sender.try_send(Ice::parse(
&fs::read_to_string(file.path())
.unwrap_or_default(),
));
})
})
.map(Message::Imported)
.map(Tick::Tester)
}
Message::Export => {
use std::fs;
use std::thread;
self.confirm();
let ice = Ice {
viewport: self.viewport,
mode: self.mode,
preset: self.preset.clone(),
instructions: self.instructions.clone(),
};
let export = rfd::AsyncFileDialog::new()
.add_filter("ice", &["ice"])
.save_file();
Task::future(async move {
let Some(file) = export.await else {
return;
};
let _ = thread::spawn(move || {
fs::write(file.path(), ice.to_string())
});
})
.discard()
}
Message::Imported(Ok(ice)) => {
self.viewport = ice.viewport;
self.mode = ice.mode;
self.preset = ice.preset;
self.instructions = ice.instructions;
self.edit = None;
self.state = State::Idle;
Task::none()
}
Message::Edit => {
if self.is_busy() {
return Task::none();
}
self.edit = Some(text_editor::Content::with_text(
&self
.instructions
.iter()
.map(Instruction::to_string)
.collect::<Vec<_>>()
.join("\n"),
));
Task::none()
}
Message::Edited(action) => {
if let Some(edit) = &mut self.edit {
edit.perform(action);
}
Task::none()
}
Message::Confirm => {
self.confirm();
Task::none()
}
Message::Imported(Err(error)) => {
log::error!("{error}");
Task::none()
}
}
}
fn confirm(&mut self) {
let Some(edit) = &mut self.edit else {
return;
};
self.instructions = edit
.lines()
.filter(|line| !line.text.trim().is_empty())
.filter_map(|line| Instruction::parse(&line.text).ok())
.collect();
self.edit = None;
}
fn preset<'a>(
&self,
program: &'a P,
) -> Option<&'a program::Preset<P::State, P::Message>> {
self.preset.as_ref().and_then(|preset| {
program
.presets()
.iter()
.find(|candidate| candidate.name() == preset)
})
}
pub fn tick(&mut self, program: &P, tick: Tick<P>) -> Task<Tick<P>> {
match tick {
Tick::Tester(message) => self.update(program, message),
Tick::Program(message) => {
let State::Recording { emulator } = &mut self.state else {
return Task::none();
};
emulator.update(program, message);
Task::none()
}
Tick::Emulator(event) => {
match &mut self.state {
State::Recording { emulator } => {
if let emulator::Event::Action(action) = event {
emulator.perform(program, action);
}
}
State::Playing {
emulator,
current,
outcome,
} => match event {
emulator::Event::Action(action) => {
emulator.perform(program, action);
}
emulator::Event::Failed(_instruction) => {
*outcome = Outcome::Failed;
}
emulator::Event::Ready => {
*current += 1;
if let Some(instruction) =
self.instructions.get(*current - 1).cloned()
{
emulator.run(program, instruction);
}
if *current >= self.instructions.len() {
*outcome = Outcome::Success;
}
}
},
State::Idle | State::Asserting { .. } => {}
}
Task::none()
}
Tick::Record(interaction) => {
let mut interaction = Some(interaction);
while let Some(new_interaction) = interaction.take() {
if let Some(Instruction::Interact(last_interaction)) =
self.instructions.pop()
{
let (merged_interaction, new_interaction) =
last_interaction.merge(new_interaction);
if let Some(new_interaction) = new_interaction {
self.instructions.push(Instruction::Interact(
merged_interaction,
));
self.instructions
.push(Instruction::Interact(new_interaction));
} else {
interaction = Some(merged_interaction);
}
} else {
self.instructions
.push(Instruction::Interact(new_interaction));
}
}
Task::none()
}
Tick::Assert(interaction) => {
let State::Asserting {
last_interaction, ..
} = &mut self.state
else {
return Task::none();
};
*last_interaction =
if let Some(last_interaction) = last_interaction.take() {
let (merged, new) = last_interaction.merge(interaction);
Some(new.unwrap_or(merged))
} else {
Some(interaction)
};
let Some(interaction) = last_interaction.take() else {
return Task::none();
};
let instruction::Interaction::Mouse(
instruction::Mouse::Click {
button: mouse::Button::Left,
at: Some(instruction::Target::Text(text)),
},
) = interaction
else {
*last_interaction = Some(interaction);
return Task::none();
};
self.instructions.push(Instruction::Expect(
instruction::Expectation::Text(text),
));
Task::none()
}
}
}
pub fn view<'a, T: 'static>(
&'a self,
program: &P,
current: impl FnOnce() -> Element<'a, T, Theme, P::Renderer>,
emulate: impl Fn(Tick<P>) -> T + 'a,
) -> Element<'a, T, Theme, P::Renderer> {
let status = {
let (icon, label) = match &self.state {
State::Idle => (text(""), "Idle"),
State::Recording { .. } => (icon::record(), "Recording"),
State::Asserting { .. } => (icon::lightbulb(), "Asserting"),
State::Playing { outcome, .. } => match outcome {
Outcome::Running => (icon::play(), "Playing"),
Outcome::Failed => (icon::cancel(), "Failed"),
Outcome::Success => (icon::check(), "Success"),
},
};
container(row![icon.size(14), label].align_y(Center).spacing(8))
.style(|theme: &Theme| {
let palette = theme.extended_palette();
container::Style {
text_color: Some(match &self.state {
State::Idle => palette.background.strongest.color,
State::Recording { .. } => {
palette.danger.base.color
}
State::Asserting { .. } => {
palette.warning.base.color
}
State::Playing { outcome, .. } => match outcome {
Outcome::Running => theme.palette().primary,
Outcome::Failed => theme.palette().danger,
Outcome::Success => {
theme
.extended_palette()
.success
.strong
.color
}
},
}),
..container::Style::default()
}
})
};
let viewport = container(
scrollable(
container(match &self.state {
State::Idle => current(),
State::Recording { emulator } => {
let theme = emulator.theme(program);
let view = emulator.view(program).map(Tick::Program);
Element::from(
recorder(themer(theme, view))
.on_record(Tick::Record),
)
.map(emulate)
}
State::Asserting { state, window, .. } => {
let theme = program.theme(state, *window);
let view =
program.view(state, *window).map(Tick::Program);
Element::from(
recorder(themer(theme, view))
.on_record(Tick::Assert),
)
.map(emulate)
}
State::Playing { emulator, .. } => {
let theme = emulator.theme(program);
let view = emulator.view(program).map(Tick::Program);
Element::from(themer(theme, view)).map(emulate)
}
})
.width(self.viewport.width)
.height(self.viewport.height),
)
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
}),
)
.style(|theme: &Theme| {
let palette = theme.extended_palette();
container::Style {
border: border::width(2.0).color(match &self.state {
State::Idle => palette.background.strongest.color,
State::Recording { .. } => palette.danger.base.color,
State::Asserting { .. } => palette.warning.weak.color,
State::Playing { outcome, .. } => match outcome {
Outcome::Running => palette.primary.base.color,
Outcome::Failed => palette.danger.strong.color,
Outcome::Success => palette.success.strong.color,
},
}),
..container::Style::default()
}
})
.padding(10);
center(column![status, viewport].spacing(10).align_x(Right))
.padding(10)
.into()
}
pub fn controls(&self) -> Element<'_, Message, Theme, P::Renderer> {
let viewport = row![
text_input("Width", &self.viewport.width.to_string())
.size(14)
.on_input(|width| Message::ChangeViewport(Size {
width: width.parse().unwrap_or(self.viewport.width),
..self.viewport
})),
monospace("x").size(14),
text_input("Height", &self.viewport.height.to_string())
.size(14)
.on_input(|height| Message::ChangeViewport(Size {
height: height.parse().unwrap_or(self.viewport.height),
..self.viewport
})),
]
.spacing(10)
.align_y(Center);
let preset = combo_box(
&self.presets,
"Default",
self.preset.as_ref(),
Message::PresetSelected,
)
.size(14)
.width(Fill);
let mode = pick_list(
emulator::Mode::ALL,
Some(self.mode),
Message::ModeSelected,
)
.text_size(14)
.width(Fill);
let player = {
let instructions = if let Some(edit) = &self.edit {
text_editor(edit)
.size(12)
.height(Fill)
.font(Font::MONOSPACE)
.on_action(Message::Edited)
.into()
} else if self.instructions.is_empty() {
Element::from(center(
monospace("No instructions recorded yet!")
.size(14)
.width(Fill)
.center(),
))
} else {
scrollable(
column(self.instructions.iter().enumerate().map(
|(i, instruction)| {
monospace(instruction.to_string())
.wrapping(text::Wrapping::None) // TODO: Ellipsize?
.size(10)
.style(move |theme: &Theme| text::Style {
color: match &self.state {
State::Playing {
current,
outcome,
..
} => {
if *current == i + 1 {
Some(match outcome {
Outcome::Running => {
theme.palette().primary
}
Outcome::Failed => {
theme
.extended_palette()
.danger
.strong
.color
}
Outcome::Success => {
theme
.extended_palette()
.success
.strong
.color
}
})
} else if *current > i + 1 {
Some(
theme
.extended_palette()
.success
.strong
.color,
)
} else {
None
}
}
_ => None,
},
})
.into()
},
))
.spacing(5),
)
.width(Fill)
.height(Fill)
.spacing(5)
.into()
};
let control = |icon: text::Text<'static, _, _>| {
button(icon.size(14).width(Fill).height(Fill).center())
};
let play = control(icon::play()).on_press_maybe(
(!matches!(self.state, State::Recording { .. })
&& !self.instructions.is_empty())
.then_some(Message::Play),
);
let record = if let State::Recording { .. } = &self.state {
control(icon::stop())
.on_press(Message::Stop)
.style(button::success)
} else {
control(icon::record())
.on_press_maybe(
(!self.is_busy()).then_some(Message::Record),
)
.style(button::danger)
};
let import = control(icon::folder())
.on_press_maybe((!self.is_busy()).then_some(Message::Import))
.style(button::secondary);
let export = control(icon::floppy())
.on_press_maybe(
(!matches!(self.state, State::Recording { .. })
&& !self.instructions.is_empty())
.then_some(Message::Export),
)
.style(button::success);
let controls =
row![import, export, play, record].height(30).spacing(10);
column![instructions, controls].spacing(10).align_x(Center)
};
let edit = if self.is_busy() {
Element::from(horizontal_space())
} else if self.edit.is_none() {
button(icon::pencil().size(14))
.padding(0)
.on_press(Message::Edit)
.style(button::text)
.into()
} else {
button(icon::check().size(14))
.padding(0)
.on_press(Message::Confirm)
.style(button::text)
.into()
};
column![
labeled("Viewport", viewport),
labeled("Mode", mode),
labeled("Preset", preset),
labeled_with("Instructions", edit, player)
]
.spacing(10)
.into()
}
}
fn labeled<'a, Message, Renderer>(
fragment: impl text::IntoFragment<'a>,
content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Renderer: program::Renderer + 'a,
{
column![monospace(fragment).size(14), content.into()]
.spacing(5)
.into()
}
fn labeled_with<'a, Message, Renderer>(
fragment: impl text::IntoFragment<'a>,
control: impl Into<Element<'a, Message, Theme, Renderer>>,
content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Renderer: program::Renderer + 'a,
{
column![
row![
monospace(fragment).size(14),
horizontal_space(),
control.into()
]
.spacing(5)
.align_y(Center),
content.into()
]
.spacing(5)
.into()
}

View file

@ -1,49 +0,0 @@
use crate::Program;
use crate::core::{Element, Theme};
use crate::runtime::Task;
use crate::widget::horizontal_space;
use std::marker::PhantomData;
pub struct Tester<P: Program> {
_type: PhantomData<P::Message>,
}
#[derive(Debug, Clone)]
pub enum Message {}
#[allow(missing_debug_implementations)]
pub struct Tick<P: Program> {
_type: PhantomData<P::Message>,
}
impl<P: Program> Tester<P> {
pub fn new(_program: &P) -> Self {
Self { _type: PhantomData }
}
pub fn is_busy(&self) -> bool {
false
}
pub fn update(&mut self, _program: &P, _message: Message) -> Task<Tick<P>> {
Task::none()
}
pub fn tick(&mut self, _program: &P, _tick: Tick<P>) -> Task<Tick<P>> {
Task::none()
}
pub fn view<'a, T: 'static>(
&'a self,
_program: &P,
_current: impl FnOnce() -> Element<'a, T, Theme, P::Renderer>,
_emulate: impl Fn(Tick<P>) -> T + 'a,
) -> Element<'a, T, Theme, P::Renderer> {
horizontal_space().into()
}
pub fn controls(&self) -> Element<'_, Message, Theme, P::Renderer> {
horizontal_space().into()
}
}

View file

@ -1,490 +0,0 @@
use crate::core::layout;
use crate::core::mouse;
use crate::core::overlay;
use crate::core::renderer;
use crate::core::widget;
use crate::core::widget::operation;
use crate::core::widget::tree;
use crate::core::{
self, Clipboard, Element, Event, Layout, Length, Point, Rectangle, Shell,
Size, Theme, Vector, Widget,
};
use crate::test::Selector;
use crate::test::instruction::{Interaction, Mouse, Target};
use crate::test::selector::target;
pub fn recorder<'a, Message, Renderer>(
content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Recorder<'a, Message, Renderer> {
Recorder::new(content)
}
#[allow(missing_debug_implementations)]
pub struct Recorder<'a, Message, Renderer> {
content: Element<'a, Message, Theme, Renderer>,
on_record: Option<Box<dyn Fn(Interaction) -> Message + 'a>>,
has_overlay: bool,
}
impl<'a, Message, Renderer> Recorder<'a, Message, Renderer> {
pub fn new(
content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Self {
Self {
content: content.into(),
on_record: None,
has_overlay: false,
}
}
pub fn on_record(
mut self,
on_record: impl Fn(Interaction) -> Message + 'a,
) -> Self {
self.on_record = Some(Box::new(on_record));
self
}
}
struct State {
last_hovered: Option<Rectangle>,
last_hovered_overlay: Option<Rectangle>,
}
impl<Message, Renderer> Widget<Message, Theme, Renderer>
for Recorder<'_, Message, Renderer>
where
Renderer: core::Renderer,
{
fn tag(&self) -> tree::Tag {
tree::Tag::of::<State>()
}
fn state(&self) -> tree::State {
tree::State::new(State {
last_hovered: None,
last_hovered_overlay: None,
})
}
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(&self.content)]
}
fn diff(&self, tree: &mut tree::Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.content.as_widget().size_hint()
}
fn update(
&mut self,
tree: &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;
}
if !self.has_overlay
&& let Some(on_record) = &self.on_record
{
let state = tree.state.downcast_mut::<State>();
record(
event,
cursor,
shell,
layout.bounds(),
&mut state.last_hovered,
on_record,
|operation| {
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
operation,
);
},
);
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content.as_widget_mut().layout(
&mut tree.children[0],
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.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
let state = tree.state.downcast_ref::<State>();
let Some(last_hovered) = &state.last_hovered else {
return;
};
let palette = theme.palette();
renderer.with_layer(*viewport, |renderer| {
renderer.fill_quad(
renderer::Quad {
bounds: *last_hovered,
..renderer::Quad::default()
},
palette.primary.scale_alpha(0.7),
);
});
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
operation,
);
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut widget::Tree,
layout: Layout<'a>,
renderer: &Renderer,
_viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
self.has_overlay = false;
self.content
.as_widget_mut()
.overlay(
&mut tree.children[0],
layout,
renderer,
&layout.bounds(),
translation,
)
.map(|raw| {
self.has_overlay = true;
let state = tree.state.downcast_mut::<State>();
overlay::Element::new(Box::new(Overlay {
raw,
bounds: layout.bounds(),
last_hovered: &mut state.last_hovered_overlay,
on_record: self.on_record.as_deref(),
}))
})
}
}
impl<'a, Message, Renderer> From<Recorder<'a, Message, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Theme: 'a,
Renderer: core::Renderer + 'a,
{
fn from(recorder: Recorder<'a, Message, Renderer>) -> Self {
Element::new(recorder)
}
}
struct Overlay<'a, Message, Renderer> {
raw: overlay::Element<'a, Message, Theme, Renderer>,
bounds: Rectangle,
last_hovered: &'a mut Option<Rectangle>,
on_record: Option<&'a dyn Fn(Interaction) -> Message>,
}
impl<'a, Message, Renderer> core::Overlay<Message, Theme, Renderer>
for Overlay<'a, Message, Renderer>
where
Renderer: core::Renderer + 'a,
{
fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node {
self.raw.as_overlay_mut().layout(renderer, bounds)
}
fn draw(
&self,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
) {
self.raw
.as_overlay()
.draw(renderer, theme, style, layout, cursor);
let Some(last_hovered) = &self.last_hovered else {
return;
};
let palette = theme.palette();
renderer.with_layer(self.bounds, |renderer| {
renderer.fill_quad(
renderer::Quad {
bounds: *last_hovered,
..renderer::Quad::default()
},
palette.primary.scale_alpha(0.7),
);
});
}
fn operate(
&mut self,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
self.raw
.as_overlay_mut()
.operate(layout, renderer, operation);
}
fn update(
&mut self,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
) {
if shell.is_event_captured() {
return;
}
if let Some(on_event) = &self.on_record {
record(
event,
cursor,
shell,
self.bounds,
self.last_hovered,
on_event,
|operation| {
self.raw
.as_overlay_mut()
.operate(layout, renderer, operation);
},
);
}
self.raw
.as_overlay_mut()
.update(event, layout, cursor, renderer, clipboard, shell);
}
fn mouse_interaction(
&self,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
) -> mouse::Interaction {
self.raw
.as_overlay()
.mouse_interaction(layout, cursor, renderer)
}
fn overlay<'b>(
&'b mut self,
layout: Layout<'b>,
renderer: &Renderer,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
self.raw
.as_overlay_mut()
.overlay(layout, renderer)
.map(|raw| {
overlay::Element::new(Box::new(Overlay {
raw,
bounds: self.bounds,
last_hovered: self.last_hovered,
on_record: self.on_record,
}))
})
}
fn index(&self) -> f32 {
self.raw.as_overlay().index()
}
}
fn record<Message>(
event: &Event,
cursor: mouse::Cursor,
shell: &mut Shell<'_, Message>,
bounds: Rectangle,
last_hovered: &mut Option<Rectangle>,
on_record: impl Fn(Interaction) -> Message,
operate: impl FnMut(&mut dyn widget::Operation),
) {
if let Event::Mouse(_) = event
&& !cursor.is_over(bounds)
{
return;
}
let interaction =
if let Event::Mouse(mouse::Event::CursorMoved { position }) = event {
Interaction::from_event(&Event::Mouse(mouse::Event::CursorMoved {
position: *position - (bounds.position() - Point::ORIGIN),
}))
} else {
Interaction::from_event(event)
};
let Some(mut interaction) = interaction else {
return;
};
let Interaction::Mouse(
Mouse::Move(at)
| Mouse::Press { at: Some(at), .. }
| Mouse::Release { at: Some(at), .. }
| Mouse::Click { at: Some(at), .. },
) = &mut interaction
else {
shell.publish(on_record(interaction));
return;
};
let Target::Point(position) = *at else {
shell.publish(on_record(interaction));
return;
};
if let Some((content, visible_bounds)) =
find_text(position + (bounds.position() - Point::ORIGIN), operate)
{
*at = Target::Text(content);
*last_hovered = visible_bounds;
} else {
*last_hovered = None;
}
shell.publish(on_record(interaction));
}
fn find_text(
position: Point,
mut operate: impl FnMut(&mut dyn widget::Operation),
) -> Option<(String, Option<Rectangle>)> {
use widget::Operation;
let mut by_position = position.find_all();
operate(&mut operation::black_box(&mut by_position));
let operation::Outcome::Some(targets) = by_position.finish() else {
return None;
};
let (content, visible_bounds) =
targets.into_iter().rev().find_map(|target| {
if let target::Match::Text {
content,
visible_bounds,
..
}
| target::Match::TextInput {
content,
visible_bounds,
..
} = target
{
Some((content, visible_bounds))
} else {
None
}
})?;
let mut by_text = content.clone().find_all();
operate(&mut operation::black_box(&mut by_text));
let operation::Outcome::Some(texts) = by_text.finish() else {
return None;
};
if texts.len() > 1 {
return None;
}
Some((content, visible_bounds))
}

View file

@ -13,6 +13,7 @@ where
impl<P> TimeMachine<P>
where
P: Program,
P::Message: std::fmt::Debug + Clone,
{
pub fn new() -> Self {
Self {

View file

@ -1,13 +0,0 @@
pub use iced_widget::*;
use crate::core::Font;
use crate::program;
pub fn monospace<'a, Renderer>(
fragment: impl text::IntoFragment<'a>,
) -> Text<'a, Theme, Renderer>
where
Renderer: program::Renderer + 'a,
{
text(fragment).font(Font::MONOSPACE)
}