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_futures",
"iced_highlighter",
"iced_program",
"iced_renderer",
"iced_runtime",
"iced_wgpu",

View file

@ -46,7 +46,9 @@ debug = ["iced_winit/debug", "iced_devtools"]
# Enables time-travel debugging (very experimental!)
time-travel = ["debug", "iced_devtools/time-travel"]
# 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
thread-pool = ["iced_futures/thread-pool"]
# Enables `tokio` as the `executor::Default` on native platforms
@ -80,10 +82,10 @@ sipper = ["iced_runtime/sipper"]
iced_debug.workspace = true
iced_core.workspace = true
iced_futures.workspace = true
iced_program.workspace = true
iced_renderer.workspace = true
iced_runtime.workspace = true
iced_widget.workspace = true
iced_winit.features = ["program"]
iced_winit.workspace = true
iced_devtools.workspace = true

View file

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

View file

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

View file

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

View file

@ -15,12 +15,16 @@ pub fn main() -> iced::Result {
#[cfg(not(target_arch = "wasm32"))]
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)
.title(Todos::title)
.font(Todos::ICON_FONT)
.window_size((500.0, 800.0))
.run()
.window_size((500.0, 800.0));
#[cfg(feature = "test")]
let todos = todos.presets(presets());
todos.run()
}
#[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)]
mod tests {
use super::*;

View file

@ -15,6 +15,7 @@ workspace = true
[features]
time-travel = []
test = []
[dependencies]
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::futures;
#[cfg(feature = "test")]
mod preset;
#[cfg(feature = "test")]
pub use preset::Preset;
use crate::core::renderer;
use crate::core::text;
use crate::core::theme;
@ -100,6 +106,11 @@ pub trait Program: Sized {
fn scale_factor(&self, _state: &Self::State, _window: window::Id) -> f64 {
1.0
}
#[cfg(feature = "test")]
fn presets(&self) -> &[Preset<Self::State, Self::Message>] {
&[]
}
}
/// 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;
#[cfg(feature = "test")]
pub use program::Preset;
pub use timed::timed;
/// Creates an iced [`Application`] given its boot, update, and view logic.
@ -154,6 +156,9 @@ where
},
settings: Settings::default(),
window: window::Settings::default(),
#[cfg(feature = "test")]
presets: Vec::new(),
}
}
@ -169,6 +174,9 @@ pub struct Application<P: Program> {
raw: P,
settings: Settings,
window: window::Settings,
#[cfg(feature = "test")]
presets: Vec<Preset<P::State, P::Message>>,
}
impl<P: Program> Application<P> {
@ -338,6 +346,8 @@ impl<P: Program> Application<P> {
}),
settings: self.settings,
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),
settings: self.settings,
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)),
settings: self.settings,
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),
settings: self.settings,
window: self.window,
#[cfg(feature = "test")]
presets: self.presets,
}
}
@ -396,6 +412,8 @@ impl<P: Program> Application<P> {
}),
settings: self.settings,
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),
settings: self.settings,
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 {
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`].

View file

@ -138,6 +138,9 @@ where
},
settings: 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_cfg))]
use iced_program as program;
use iced_widget::graphics;
use iced_widget::renderer;
use iced_winit as shell;
use iced_winit::core;
use iced_winit::program;
use iced_winit::runtime;
pub use iced_futures::futures;

View file

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

View file

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

View file

@ -64,8 +64,8 @@ use crate::core::text;
use crate::core::time::Instant;
use crate::core::widget::{self, Widget};
use crate::core::{
Clipboard, Element, Event, Length, Padding, Rectangle, Shell, Size, Theme,
Vector,
Clipboard, Element, Event, Length, Padding, Pixels, Rectangle, Shell, Size,
Theme, Vector,
};
use crate::overlay::menu;
use crate::text::LineHeight;
@ -249,9 +249,12 @@ where
}
/// 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.size = Some(size);
self.size = Some(size.0);
self
}

View file

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