iced-yoda/devtools/src/tester.rs

703 lines
23 KiB
Rust
Raw Normal View History

2025-06-03 07:23:56 +02:00
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::window;
use crate::core::{Element, Event, Font, Size, Theme};
use crate::executor;
use crate::futures::Subscription;
2025-06-03 07:23:56 +02:00
use crate::futures::futures::channel::mpsc;
use crate::icon;
use crate::program;
use crate::runtime::Task;
use crate::test::emulator;
use crate::test::instruction;
use crate::test::{Emulator, Instruction};
use crate::widget::{
button, center, column, combo_box, container, horizontal_space, monospace,
pick_list, row, scrollable, text, text_editor, text_input, themer,
2025-06-03 07:23:56 +02:00
};
pub struct Tester<P: Program> {
viewport: Size,
mode: emulator::Mode,
presets: combo_box::State<String>,
preset: Option<String>,
2025-06-03 07:23:56 +02:00
instructions: Vec<Instruction>,
state: State<P>,
edit: Option<text_editor::Content<P::Renderer>>,
2025-06-03 07:23:56 +02:00
}
enum State<P: Program> {
Idle,
Recording {
state: P::State,
},
Ready {
state: P::State,
},
2025-06-03 07:23:56 +02:00
Playing {
emulator: Emulator<P>,
current: usize,
outcome: Outcome,
2025-06-03 07:23:56 +02:00
},
}
enum Outcome {
Running,
Failed,
Success,
}
2025-06-03 07:23:56 +02:00
#[derive(Debug, Clone)]
pub enum Message {
ChangeViewport(Size),
ModeSelected(emulator::Mode),
PresetSelected(String),
2025-06-03 07:23:56 +02:00
Record,
Stop,
Play,
Import,
Export,
Imported(Option<String>),
Edit,
Edited(text_editor::Action),
Confirm,
2025-06-03 07:23:56 +02:00
}
#[allow(missing_debug_implementations)]
pub enum Tick<P: Program> {
Tester(Message),
2025-06-03 07:23:56 +02:00
Program(P::Message),
Recorder(Event),
Emulator(emulator::Event<P>),
}
impl<P: Program + 'static> Tester<P> {
pub fn new(program: &P) -> Self {
2025-06-03 07:23:56 +02:00
Self {
mode: emulator::Mode::default(),
2025-06-03 07:23:56 +02:00
viewport: Size::new(512.0, 512.0),
presets: combo_box::State::new(
program
.presets()
.iter()
.map(program::Preset::name)
.map(str::to_owned)
.collect(),
),
preset: None,
2025-06-03 07:23:56 +02:00
instructions: Vec::new(),
state: State::Idle,
edit: None,
2025-06-03 07:23:56 +02:00
}
}
pub fn is_idle(&self) -> bool {
matches!(self.state, State::Idle)
}
2025-06-03 07:23:56 +02:00
pub fn is_busy(&self) -> bool {
matches!(
self.state,
State::Recording { .. }
| State::Playing {
outcome: Outcome::Running,
..
}
)
2025-06-03 07:23:56 +02:00
}
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()
}
2025-06-03 07:23:56 +02:00
Message::Record => {
self.edit = None;
2025-06-03 07:23:56 +02:00
self.instructions.clear();
let (state, task) = if let Some(preset) = self.preset(program) {
preset.boot()
} else {
program.boot()
};
2025-06-03 07:23:56 +02:00
self.state = State::Recording { state };
task.map(Tick::Program)
}
Message::Stop => {
let State::Recording { state } =
std::mem::replace(&mut self.state, State::Idle)
else {
return Task::none();
};
self.state = State::Ready { state };
2025-06-03 07:23:56 +02:00
Task::none()
}
Message::Play => {
self.confirm();
2025-06-03 07:23:56 +02:00
let (sender, receiver) = mpsc::channel(1);
let emulator = Emulator::with_preset(
sender,
program,
self.mode,
self.viewport,
self.preset(program),
);
2025-06-03 07:23:56 +02:00
self.state = State::Playing {
emulator,
current: 0,
outcome: Outcome::Running,
2025-06-03 07:23:56 +02:00
};
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(fs::read_to_string(file.path()).ok());
})
})
.map(Message::Imported)
.map(Tick::Tester)
}
Message::Export => {
use std::fs;
use std::thread;
self.confirm();
let test: Vec<_> = self
.instructions
.iter()
.map(Instruction::to_string)
.collect();
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(), test.join("\n"))
});
})
.discard()
}
Message::Imported(instructions) => {
let Some(instructions) = instructions else {
return Task::none();
};
let instructions: Result<Vec<_>, _> =
instructions.lines().map(Instruction::parse).collect();
match instructions {
Ok(instructions) => {
self.instructions = instructions;
self.edit = None;
}
Err(error) => {
log::error!("{error}");
}
}
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()
}
2025-06-03 07:23:56 +02:00
}
}
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)
})
}
2025-06-03 07:23:56 +02:00
pub fn tick(&mut self, program: &P, tick: Tick<P>) -> Task<Tick<P>> {
match tick {
Tick::Tester(message) => self.update(program, message),
2025-06-03 07:23:56 +02:00
Tick::Program(message) => {
let State::Recording { state } = &mut self.state else {
return Task::none();
};
program.update(state, message).map(Tick::Program)
}
Tick::Recorder(event) => {
let Some(interaction) =
instruction::Interaction::from_event(event)
else {
return Task::none();
};
if let Some(Instruction::Interact(last_interaction)) =
self.instructions.pop()
{
let (last_interaction, new_interaction) =
last_interaction.merge(interaction);
self.instructions
.push(Instruction::Interact(last_interaction));
if let Some(new_interaction) = new_interaction {
self.instructions
.push(Instruction::Interact(new_interaction));
}
} else {
self.instructions.push(Instruction::Interact(interaction));
}
Task::none()
}
Tick::Emulator(event) => {
let State::Playing {
emulator,
current,
outcome,
} = &mut self.state
2025-06-03 07:23:56 +02:00
else {
return Task::none();
};
match event {
emulator::Event::Action(action) => {
emulator.perform(program, action);
}
emulator::Event::Failed => {
*outcome = Outcome::Failed;
}
2025-06-03 07:23:56 +02:00
emulator::Event::Ready => {
if let Some(instruction) =
self.instructions.get(*current).cloned()
{
emulator.run(program, instruction);
*current += 1;
}
if *current >= self.instructions.len() {
*outcome = Outcome::Success;
}
2025-06-03 07:23:56 +02:00
}
}
Task::none()
}
}
}
pub fn subscription(&self, program: &P) -> Subscription<Tick<P>> {
match &self.state {
State::Idle | State::Playing { .. } | State::Ready { .. } => {
Subscription::none()
}
State::Recording { state } => {
program.subscription(state).map(Tick::Program)
}
}
}
2025-06-03 07:23:56 +02:00
pub fn view<'a, T: 'static>(
&'a self,
program: &P,
window: window::Id,
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::Ready { .. } => (icon::lightbulb(), "Ready"),
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::Ready { .. } => 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()
}
2025-06-03 07:23:56 +02:00
})
};
let viewport = container(
scrollable(
container(match &self.state {
State::Idle => current(),
State::Recording { state } => {
let theme = program.theme(state, window);
let view =
program.view(state, window).map(Tick::Program);
Element::from(
recorder(themer(theme, view))
.on_event(Tick::Recorder),
)
.map(emulate)
}
State::Ready { state } => {
let theme = program.theme(state, window);
let view =
program.view(state, window).map(Tick::Program);
Element::from(themer(theme, view)).map(emulate)
}
2025-06-03 07:23:56 +02:00
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::Ready { .. } => 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,
},
2025-06-03 07:23:56 +02:00
}),
..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),
2025-06-03 07:23:56 +02:00
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);
2025-06-03 07:23:56 +02:00
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() {
2025-06-03 07:23:56 +02:00
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())
.size(10)
.style(move |theme: &Theme| text::Style {
color: match &self.state {
State::Playing {
current,
outcome,
..
} => {
2025-06-03 07:23:56 +02:00
if *current == i {
Some(match outcome {
Outcome::Running => {
theme.palette().primary
}
Outcome::Failed => {
theme
.extended_palette()
.danger
.strong
.color
}
Outcome::Success => {
theme
.extended_palette()
.success
.strong
.color
}
})
2025-06-03 07:23:56 +02:00
} else if *current > i {
Some(
theme
.extended_palette()
.success
.strong
.color,
)
} else {
None
}
}
_ => None,
},
})
.into()
},
))
.spacing(5),
)
.width(Fill)
.height(Fill)
2025-06-03 07:23:56 +02:00
.spacing(5)
.into()
};
2025-06-03 07:23:56 +02:00
let control = |icon: text::Text<'static, _, _>| {
button(icon.size(14).width(Fill).height(Fill).center())
2025-06-03 07:23:56 +02:00
};
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)
2025-06-03 07:23:56 +02:00
};
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()
2025-06-03 07:23:56 +02:00
}
}
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()
}