Add program::Preset and emulator::Mode

This commit is contained in:
Héctor Ramón Jiménez 2025-06-04 19:17:11 +02:00
parent 927d5b7cba
commit 73f5569f28
No known key found for this signature in database
GPG key ID: 7CC46565708259A7
17 changed files with 305 additions and 39 deletions

1
Cargo.lock generated
View file

@ -2400,6 +2400,7 @@ dependencies = [
"iced_devtools", "iced_devtools",
"iced_futures", "iced_futures",
"iced_highlighter", "iced_highlighter",
"iced_program",
"iced_renderer", "iced_renderer",
"iced_runtime", "iced_runtime",
"iced_wgpu", "iced_wgpu",

View file

@ -46,7 +46,9 @@ debug = ["iced_winit/debug", "iced_devtools"]
# Enables time-travel debugging (very experimental!) # Enables time-travel debugging (very experimental!)
time-travel = ["debug", "iced_devtools/time-travel"] time-travel = ["debug", "iced_devtools/time-travel"]
# Enables the tester developer tool for recording and playing tests (press F12) # Enables the tester developer tool for recording and playing tests (press F12)
tester = ["debug", "iced_devtools/tester"] tester = ["debug", "test", "iced_devtools/tester"]
# Enables testing features (e.g. application presets)
test = ["iced_program/test"]
# Enables the `thread-pool` futures executor as the `executor::Default` on native platforms # Enables the `thread-pool` futures executor as the `executor::Default` on native platforms
thread-pool = ["iced_futures/thread-pool"] thread-pool = ["iced_futures/thread-pool"]
# Enables `tokio` as the `executor::Default` on native platforms # Enables `tokio` as the `executor::Default` on native platforms
@ -80,10 +82,10 @@ sipper = ["iced_runtime/sipper"]
iced_debug.workspace = true iced_debug.workspace = true
iced_core.workspace = true iced_core.workspace = true
iced_futures.workspace = true iced_futures.workspace = true
iced_program.workspace = true
iced_renderer.workspace = true iced_renderer.workspace = true
iced_runtime.workspace = true iced_runtime.workspace = true
iced_widget.workspace = true iced_widget.workspace = true
iced_winit.features = ["program"]
iced_winit.workspace = true iced_winit.workspace = true
iced_devtools.workspace = true iced_devtools.workspace = true

View file

@ -197,7 +197,7 @@ where
match &self.mode { match &self.mode {
Mode::Hidden => { Mode::Hidden => {
self.mode = Mode::Open { self.mode = Mode::Open {
tester: Tester::new(), tester: Tester::new(program),
}; };
} }
Mode::Open { tester } if !tester.is_busy() => { Mode::Open { tester } if !tester.is_busy() => {

View file

@ -18,12 +18,15 @@ use crate::test::emulator;
use crate::test::instruction; use crate::test::instruction;
use crate::test::{Emulator, Instruction}; use crate::test::{Emulator, Instruction};
use crate::widget::{ use crate::widget::{
button, center, column, container, monospace, row, scrollable, text, button, center, column, combo_box, container, monospace, pick_list, row,
text_input, themer, scrollable, text, text_input, themer,
}; };
pub struct Tester<P: Program> { pub struct Tester<P: Program> {
viewport: Size, viewport: Size,
mode: emulator::Mode,
presets: combo_box::State<String>,
preset: Option<String>,
instructions: Vec<Instruction>, instructions: Vec<Instruction>,
state: State<P>, state: State<P>,
} }
@ -42,6 +45,8 @@ enum State<P: Program> {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Message { pub enum Message {
ChangeViewport(Size), ChangeViewport(Size),
ModeSelected(emulator::Mode),
PresetSelected(String),
Record, Record,
Stop, Stop,
Play, Play,
@ -55,9 +60,19 @@ pub enum Tick<P: Program> {
} }
impl<P: Program + 'static> Tester<P> { impl<P: Program + 'static> Tester<P> {
pub fn new() -> Self { pub fn new(program: &P) -> Self {
Self { Self {
mode: emulator::Mode::default(),
viewport: Size::new(512.0, 512.0), 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,
instructions: Vec::new(), instructions: Vec::new(),
state: State::Idle, state: State::Idle,
} }
@ -78,10 +93,25 @@ impl<P: Program + 'static> Tester<P> {
Task::none() Task::none()
} }
Message::ModeSelected(mode) => {
self.mode = mode;
Task::none()
}
Message::PresetSelected(preset) => {
self.preset = Some(preset);
Task::none()
}
Message::Record => { Message::Record => {
self.instructions.clear(); self.instructions.clear();
let (state, task) = program.boot(); let (state, task) = if let Some(preset) = self.preset(program) {
preset.boot()
} else {
program.boot()
};
self.state = State::Recording { state }; self.state = State::Recording { state };
task.map(Tick::Program) task.map(Tick::Program)
@ -93,7 +123,14 @@ impl<P: Program + 'static> Tester<P> {
} }
Message::Play => { Message::Play => {
let (sender, receiver) = mpsc::channel(1); let (sender, receiver) = mpsc::channel(1);
let emulator = Emulator::new(program, self.viewport, sender);
let emulator = Emulator::with_preset(
sender,
program,
self.mode,
self.viewport,
self.preset(program),
);
self.state = State::Playing { self.state = State::Playing {
emulator, emulator,
@ -105,6 +142,18 @@ impl<P: Program + 'static> Tester<P> {
} }
} }
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>> { pub fn tick(&mut self, program: &P, tick: Tick<P>) -> Task<Tick<P>> {
match tick { match tick {
Tick::Program(message) => { Tick::Program(message) => {
@ -267,8 +316,25 @@ impl<P: Program + 'static> Tester<P> {
.spacing(10) .spacing(10)
.align_y(Center); .align_y(Center);
let preset = combo_box(
&self.presets,
"Default Preset",
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 player = {
let events = container(if self.instructions.is_empty() { let instructions = container(if self.instructions.is_empty() {
Element::from(center( Element::from(center(
monospace("No instructions recorded yet!") monospace("No instructions recorded yet!")
.size(14) .size(14)
@ -337,12 +403,17 @@ impl<P: Program + 'static> Tester<P> {
.spacing(10) .spacing(10)
}; };
column![events, controls].spacing(10).align_x(Center) column![instructions, controls].spacing(10).align_x(Center)
}; };
column![labeled("Viewport", viewport), labeled("Tester", player)] column![
.spacing(10) labeled("Viewport", viewport),
.into() labeled("Mode", mode),
labeled("Preset", preset),
labeled("Instructions", player)
]
.spacing(10)
.into()
} }
} }

View file

@ -20,7 +20,7 @@ pub struct Tick<P: Program> {
} }
impl<P: Program> Tester<P> { impl<P: Program> Tester<P> {
pub fn new() -> Self { pub fn new(_program: &P) -> Self {
Self { _type: PhantomData } Self { _type: PhantomData }
} }

View file

@ -5,6 +5,10 @@ authors = ["Héctor Ramón Jiménez <hector0193@gmail.com>"]
edition = "2024" edition = "2024"
publish = false publish = false
[features]
test = ["iced/test"]
tester = ["test", "iced/tester"]
[dependencies] [dependencies]
iced.workspace = true iced.workspace = true
iced.features = ["tokio", "debug", "time-travel"] iced.features = ["tokio", "debug", "time-travel"]

View file

@ -15,12 +15,16 @@ pub fn main() -> iced::Result {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
iced::application(Todos::new, Todos::update, Todos::view) let todos = iced::application(Todos::new, Todos::update, Todos::view)
.subscription(Todos::subscription) .subscription(Todos::subscription)
.title(Todos::title) .title(Todos::title)
.font(Todos::ICON_FONT) .font(Todos::ICON_FONT)
.window_size((500.0, 800.0)) .window_size((500.0, 800.0));
.run()
#[cfg(feature = "test")]
let todos = todos.presets(presets());
todos.run()
} }
#[derive(Debug)] #[derive(Debug)]
@ -572,6 +576,39 @@ impl SavedState {
} }
} }
#[cfg(feature = "test")]
fn presets() -> impl Iterator<Item = iced::application::Preset<Todos, Message>>
{
use iced::application::Preset;
[
Preset::new("Empty", || {
(
Todos::Loading,
Command::done(Message::Loaded(Err(LoadError::File))),
)
}),
Preset::new("Basic", || {
(
Todos::Loaded(State {
input_value: "Bake an apple pie".to_owned(),
filter: Filter::All,
tasks: vec![Task {
id: Uuid::new_v4(),
description: "Create the universe".to_owned(),
completed: false,
state: TaskState::Idle,
}],
dirty: false,
saving: false,
}),
Command::none(),
)
}),
]
.into_iter()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View file

@ -15,6 +15,7 @@ workspace = true
[features] [features]
time-travel = [] time-travel = []
test = []
[dependencies] [dependencies]
iced_graphics.workspace = true iced_graphics.workspace = true

View file

@ -4,6 +4,12 @@ pub use iced_runtime as runtime;
pub use iced_runtime::core; pub use iced_runtime::core;
pub use iced_runtime::futures; pub use iced_runtime::futures;
#[cfg(feature = "test")]
mod preset;
#[cfg(feature = "test")]
pub use preset::Preset;
use crate::core::renderer; use crate::core::renderer;
use crate::core::text; use crate::core::text;
use crate::core::theme; use crate::core::theme;
@ -100,6 +106,11 @@ pub trait Program: Sized {
fn scale_factor(&self, _state: &Self::State, _window: window::Id) -> f64 { fn scale_factor(&self, _state: &Self::State, _window: window::Id) -> f64 {
1.0 1.0
} }
#[cfg(feature = "test")]
fn presets(&self) -> &[Preset<Self::State, Self::Message>] {
&[]
}
} }
/// Decorates a [`Program`] with the given title function. /// Decorates a [`Program`] with the given title function.

42
program/src/preset.rs Normal file
View file

@ -0,0 +1,42 @@
use crate::runtime::Task;
use std::borrow::Cow;
use std::fmt;
/// A specific boot strategy for a [`Program`].
pub struct Preset<State, Message> {
name: Cow<'static, str>,
boot: Box<dyn Fn() -> (State, Task<Message>)>,
}
impl<State, Message> Preset<State, Message> {
/// Creates a new [`Preset`] with the given name and boot strategy.
pub fn new(
name: impl Into<Cow<'static, str>>,
boot: impl Fn() -> (State, Task<Message>) + 'static,
) -> Self {
Self {
name: name.into(),
boot: Box::new(boot),
}
}
/// Returns the name of the [`Preset`].
pub fn name(&self) -> &str {
&self.name
}
/// Boots the [`Preset`], returning the initial [`Program`] state and
/// a [`Task`] for concurrent booting.
pub fn boot(&self) -> (State, Task<Message>) {
(self.boot)()
}
}
impl<State, Message> fmt::Debug for Preset<State, Message> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Preset")
.field("name", &self.name)
.finish_non_exhaustive()
}
}

View file

@ -42,6 +42,8 @@ use std::borrow::Cow;
pub mod timed; pub mod timed;
#[cfg(feature = "test")]
pub use program::Preset;
pub use timed::timed; pub use timed::timed;
/// Creates an iced [`Application`] given its boot, update, and view logic. /// Creates an iced [`Application`] given its boot, update, and view logic.
@ -154,6 +156,9 @@ where
}, },
settings: Settings::default(), settings: Settings::default(),
window: window::Settings::default(), window: window::Settings::default(),
#[cfg(feature = "test")]
presets: Vec::new(),
} }
} }
@ -169,6 +174,9 @@ pub struct Application<P: Program> {
raw: P, raw: P,
settings: Settings, settings: Settings,
window: window::Settings, window: window::Settings,
#[cfg(feature = "test")]
presets: Vec<Preset<P::State, P::Message>>,
} }
impl<P: Program> Application<P> { impl<P: Program> Application<P> {
@ -338,6 +346,8 @@ impl<P: Program> Application<P> {
}), }),
settings: self.settings, settings: self.settings,
window: self.window, window: self.window,
#[cfg(feature = "test")]
presets: self.presets,
} }
} }
@ -352,6 +362,8 @@ impl<P: Program> Application<P> {
raw: program::with_subscription(self.raw, f), raw: program::with_subscription(self.raw, f),
settings: self.settings, settings: self.settings,
window: self.window, window: self.window,
#[cfg(feature = "test")]
presets: self.presets,
} }
} }
@ -366,6 +378,8 @@ impl<P: Program> Application<P> {
raw: program::with_theme(self.raw, move |state, _window| f(state)), raw: program::with_theme(self.raw, move |state, _window| f(state)),
settings: self.settings, settings: self.settings,
window: self.window, window: self.window,
#[cfg(feature = "test")]
presets: self.presets,
} }
} }
@ -380,6 +394,8 @@ impl<P: Program> Application<P> {
raw: program::with_style(self.raw, f), raw: program::with_style(self.raw, f),
settings: self.settings, settings: self.settings,
window: self.window, window: self.window,
#[cfg(feature = "test")]
presets: self.presets,
} }
} }
@ -396,6 +412,8 @@ impl<P: Program> Application<P> {
}), }),
settings: self.settings, settings: self.settings,
window: self.window, window: self.window,
#[cfg(feature = "test")]
presets: self.presets,
} }
} }
@ -412,6 +430,24 @@ impl<P: Program> Application<P> {
raw: program::with_executor::<P, E>(self.raw), raw: program::with_executor::<P, E>(self.raw),
settings: self.settings, settings: self.settings,
window: self.window, window: self.window,
#[cfg(feature = "test")]
presets: self.presets,
}
}
/// Sets the boot presets of the [`Application`].
///
/// Presets can be used to override the default booting strategy
/// of your application during testing to create reproducible
/// environments.
#[cfg(feature = "test")]
pub fn presets(
self,
presets: impl IntoIterator<Item = Preset<P::State, P::Message>>,
) -> Self {
Self {
presets: presets.into_iter().collect(),
..self
} }
} }
} }
@ -474,6 +510,11 @@ impl<P: Program> Program for Application<P> {
fn scale_factor(&self, state: &Self::State, window: window::Id) -> f64 { fn scale_factor(&self, state: &Self::State, window: window::Id) -> f64 {
self.raw.scale_factor(state, window) self.raw.scale_factor(state, window)
} }
#[cfg(feature = "test")]
fn presets(&self) -> &[Preset<Self::State, Self::Message>] {
&self.presets
}
} }
/// The logic to initialize the `State` of some [`Application`]. /// The logic to initialize the `State` of some [`Application`].

View file

@ -138,6 +138,9 @@ where
}, },
settings: Settings::default(), settings: Settings::default(),
window: window::Settings::default(), window: window::Settings::default(),
#[cfg(feature = "test")]
presets: Vec::new(),
} }
} }

View file

@ -475,11 +475,11 @@
)] )]
#![cfg_attr(docsrs, feature(doc_auto_cfg))] #![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![cfg_attr(docsrs, feature(doc_cfg))] #![cfg_attr(docsrs, feature(doc_cfg))]
use iced_program as program;
use iced_widget::graphics; use iced_widget::graphics;
use iced_widget::renderer; use iced_widget::renderer;
use iced_winit as shell; use iced_winit as shell;
use iced_winit::core; use iced_winit::core;
use iced_winit::program;
use iced_winit::runtime; use iced_winit::runtime;
pub use iced_futures::futures; pub use iced_futures::futures;

View file

@ -15,7 +15,9 @@ workspace = true
[dependencies] [dependencies]
iced_runtime.workspace = true iced_runtime.workspace = true
iced_program.workspace = true iced_program.workspace = true
iced_program.features = ["test"]
iced_renderer.workspace = true iced_renderer.workspace = true
iced_renderer.features = ["fira-sans"] iced_renderer.features = ["fira-sans"]

View file

@ -2,9 +2,10 @@ use crate::Instruction;
use crate::core; use crate::core;
use crate::core::mouse; use crate::core::mouse;
use crate::core::renderer; use crate::core::renderer;
use crate::core::widget::operation; use crate::core::widget;
use crate::core::window; use crate::core::window;
use crate::core::{Element, Size}; use crate::core::{Element, Size};
use crate::program;
use crate::program::Program; use crate::program::Program;
use crate::runtime::futures::futures::StreamExt; use crate::runtime::futures::futures::StreamExt;
use crate::runtime::futures::futures::channel::mpsc; use crate::runtime::futures::futures::channel::mpsc;
@ -15,11 +16,14 @@ use crate::runtime::task;
use crate::runtime::user_interface; use crate::runtime::user_interface;
use crate::runtime::{Action, Task, UserInterface}; use crate::runtime::{Action, Task, UserInterface};
use std::fmt;
#[allow(missing_debug_implementations)] #[allow(missing_debug_implementations)]
pub struct Emulator<P: Program> { pub struct Emulator<P: Program> {
state: P::State, state: P::State,
runtime: Runtime<P::Executor, mpsc::Sender<Event<P>>, Event<P>>, runtime: Runtime<P::Executor, mpsc::Sender<Event<P>>, Event<P>>,
renderer: P::Renderer, renderer: P::Renderer,
mode: Mode,
size: Size, size: Size,
window: window::Id, window: window::Id,
cursor: mouse::Cursor, cursor: mouse::Cursor,
@ -35,9 +39,20 @@ pub enum Event<P: Program> {
impl<P: Program + 'static> Emulator<P> { impl<P: Program + 'static> Emulator<P> {
pub fn new( pub fn new(
program: &P,
size: Size,
sender: mpsc::Sender<Event<P>>, sender: mpsc::Sender<Event<P>>,
program: &P,
mode: Mode,
size: Size,
) -> Emulator<P> {
Self::with_preset(sender, program, mode, size, None)
}
pub fn with_preset(
sender: mpsc::Sender<Event<P>>,
program: &P,
mode: Mode,
size: Size,
preset: Option<&program::Preset<P::State, P::Message>>,
) -> Emulator<P> { ) -> Emulator<P> {
use renderer::Headless; use renderer::Headless;
@ -55,12 +70,18 @@ impl<P: Program + 'static> Emulator<P> {
.expect("Create emulator renderer"); .expect("Create emulator renderer");
let runtime = Runtime::new(executor, sender); let runtime = Runtime::new(executor, sender);
let (state, task) = program.boot();
let (state, task) = if let Some(preset) = preset {
preset.boot()
} else {
program.boot()
};
let mut emulator = Self { let mut emulator = Self {
state, state,
runtime, runtime,
renderer, renderer,
mode,
size, size,
clipboard: Clipboard { content: None }, clipboard: Clipboard { content: None },
cursor: mouse::Cursor::Unavailable, cursor: mouse::Cursor::Unavailable,
@ -68,8 +89,8 @@ impl<P: Program + 'static> Emulator<P> {
cache: Some(user_interface::Cache::default()), cache: Some(user_interface::Cache::default()),
}; };
// TODO: Configurable
emulator.wait_for(task); emulator.wait_for(task);
emulator.resubscribe(program);
emulator emulator
} }
@ -106,9 +127,9 @@ impl<P: Program + 'static> Emulator<P> {
user_interface.operate(&self.renderer, &mut current); user_interface.operate(&self.renderer, &mut current);
match current.finish() { match current.finish() {
operation::Outcome::None => {} widget::operation::Outcome::None => {}
operation::Outcome::Some(()) => {} widget::operation::Outcome::Some(()) => {}
operation::Outcome::Chain(next) => { widget::operation::Outcome::Chain(next) => {
operation = Some(next); operation = Some(next);
} }
} }
@ -174,20 +195,28 @@ impl<P: Program + 'static> Emulator<P> {
.map(|message| program.update(&mut self.state, message)), .map(|message| program.update(&mut self.state, message)),
); );
// TODO: Configurable
self.wait_for(task); self.wait_for(task);
self.resubscribe(program); self.resubscribe(program);
} }
pub fn wait_for(&mut self, task: Task<P::Message>) { pub fn wait_for(&mut self, task: Task<P::Message>) {
if let Some(stream) = task::into_stream(task) { if let Some(stream) = task::into_stream(task) {
self.runtime.run( match self.mode {
stream Mode::Patient => {
.map(Event::Action) self.runtime.run(
.chain(stream::once(async { Event::Ready })) stream
.boxed(), .map(Event::Action)
); .chain(stream::once(async { Event::Ready }))
.boxed(),
);
}
Mode::Impatient => {
self.runtime.run(stream.map(Event::Action).boxed());
self.runtime.send(Event::Ready);
}
}
} else {
self.runtime.send(Event::Ready);
} }
} }
@ -211,6 +240,26 @@ impl<P: Program + 'static> Emulator<P> {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Mode {
Patient,
#[default]
Impatient,
}
impl Mode {
pub const ALL: &[Self] = &[Self::Patient, Self::Impatient];
}
impl fmt::Display for Mode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Mode::Patient => f.write_str("Patient"),
Mode::Impatient => f.write_str("Impatient"),
}
}
}
struct Clipboard { struct Clipboard {
content: Option<String>, content: Option<String>,
} }

View file

@ -64,8 +64,8 @@ use crate::core::text;
use crate::core::time::Instant; use crate::core::time::Instant;
use crate::core::widget::{self, Widget}; use crate::core::widget::{self, Widget};
use crate::core::{ use crate::core::{
Clipboard, Element, Event, Length, Padding, Rectangle, Shell, Size, Theme, Clipboard, Element, Event, Length, Padding, Pixels, Rectangle, Shell, Size,
Vector, Theme, Vector,
}; };
use crate::overlay::menu; use crate::overlay::menu;
use crate::text::LineHeight; use crate::text::LineHeight;
@ -249,9 +249,12 @@ where
} }
/// Sets the text sixe of the [`ComboBox`]. /// Sets the text sixe of the [`ComboBox`].
pub fn size(mut self, size: f32) -> Self { pub fn size(mut self, size: impl Into<Pixels>) -> Self {
let size = size.into();
self.text_input = self.text_input.size(size); self.text_input = self.text_input.size(size);
self.size = Some(size); self.size = Some(size.0);
self self
} }

View file

@ -17,7 +17,6 @@ workspace = true
default = ["x11", "wayland", "wayland-dlopen", "wayland-csd-adwaita"] default = ["x11", "wayland", "wayland-dlopen", "wayland-csd-adwaita"]
debug = ["iced_debug/enable"] debug = ["iced_debug/enable"]
system = ["sysinfo"] system = ["sysinfo"]
program = []
x11 = ["winit/x11"] x11 = ["winit/x11"]
wayland = ["winit/wayland"] wayland = ["winit/wayland"]
wayland-dlopen = ["winit/wayland-dlopen"] wayland-dlopen = ["winit/wayland-dlopen"]