From 73f5569f28ac6dfa64dedc54b5d2f9d176e2a951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Wed, 4 Jun 2025 19:17:11 +0200 Subject: [PATCH] Add `program::Preset` and `emulator::Mode` --- Cargo.lock | 1 + Cargo.toml | 6 ++- devtools/src/lib.rs | 2 +- devtools/src/tester.rs | 91 +++++++++++++++++++++++++++++++++---- devtools/src/tester/null.rs | 2 +- examples/todos/Cargo.toml | 4 ++ examples/todos/src/main.rs | 43 ++++++++++++++++-- program/Cargo.toml | 1 + program/src/lib.rs | 11 +++++ program/src/preset.rs | 42 +++++++++++++++++ src/application.rs | 41 +++++++++++++++++ src/application/timed.rs | 3 ++ src/lib.rs | 2 +- test/Cargo.toml | 2 + test/src/emulator.rs | 81 ++++++++++++++++++++++++++------- widget/src/combo_box.rs | 11 +++-- winit/Cargo.toml | 1 - 17 files changed, 305 insertions(+), 39 deletions(-) create mode 100644 program/src/preset.rs diff --git a/Cargo.lock b/Cargo.lock index 9cb1393b..38d0f64b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2400,6 +2400,7 @@ dependencies = [ "iced_devtools", "iced_futures", "iced_highlighter", + "iced_program", "iced_renderer", "iced_runtime", "iced_wgpu", diff --git a/Cargo.toml b/Cargo.toml index 2d9beb5f..dbfe4cc5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/devtools/src/lib.rs b/devtools/src/lib.rs index 3a663384..9a9123fb 100644 --- a/devtools/src/lib.rs +++ b/devtools/src/lib.rs @@ -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() => { diff --git a/devtools/src/tester.rs b/devtools/src/tester.rs index e5aa441b..5831a13f 100644 --- a/devtools/src/tester.rs +++ b/devtools/src/tester.rs @@ -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 { viewport: Size, + mode: emulator::Mode, + presets: combo_box::State, + preset: Option, instructions: Vec, state: State

, } @@ -42,6 +45,8 @@ enum State { #[derive(Debug, Clone)] pub enum Message { ChangeViewport(Size), + ModeSelected(emulator::Mode), + PresetSelected(String), Record, Stop, Play, @@ -55,9 +60,19 @@ pub enum Tick { } impl Tester

{ - 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 Tester

{ 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 Tester

{ } 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 Tester

{ } } + fn preset<'a>( + &self, + program: &'a P, + ) -> Option<&'a program::Preset> { + self.preset.as_ref().and_then(|preset| { + program + .presets() + .iter() + .find(|candidate| candidate.name() == preset) + }) + } + pub fn tick(&mut self, program: &P, tick: Tick

) -> Task> { match tick { Tick::Program(message) => { @@ -267,8 +316,25 @@ impl Tester

{ .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 Tester

{ .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() } } diff --git a/devtools/src/tester/null.rs b/devtools/src/tester/null.rs index 52d7ae4b..c2a4b4f3 100644 --- a/devtools/src/tester/null.rs +++ b/devtools/src/tester/null.rs @@ -20,7 +20,7 @@ pub struct Tick { } impl Tester

{ - pub fn new() -> Self { + pub fn new(_program: &P) -> Self { Self { _type: PhantomData } } diff --git a/examples/todos/Cargo.toml b/examples/todos/Cargo.toml index 5e16a2ac..9869a2c5 100644 --- a/examples/todos/Cargo.toml +++ b/examples/todos/Cargo.toml @@ -5,6 +5,10 @@ authors = ["Héctor Ramón Jiménez "] edition = "2024" publish = false +[features] +test = ["iced/test"] +tester = ["test", "iced/tester"] + [dependencies] iced.workspace = true iced.features = ["tokio", "debug", "time-travel"] diff --git a/examples/todos/src/main.rs b/examples/todos/src/main.rs index 8a4bc2eb..dc6dabbf 100644 --- a/examples/todos/src/main.rs +++ b/examples/todos/src/main.rs @@ -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> +{ + 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::*; diff --git a/program/Cargo.toml b/program/Cargo.toml index 7aa6414d..4b49f464 100644 --- a/program/Cargo.toml +++ b/program/Cargo.toml @@ -15,6 +15,7 @@ workspace = true [features] time-travel = [] +test = [] [dependencies] iced_graphics.workspace = true diff --git a/program/src/lib.rs b/program/src/lib.rs index c75b02a1..04583bd3 100644 --- a/program/src/lib.rs +++ b/program/src/lib.rs @@ -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] { + &[] + } } /// Decorates a [`Program`] with the given title function. diff --git a/program/src/preset.rs b/program/src/preset.rs new file mode 100644 index 00000000..863278ab --- /dev/null +++ b/program/src/preset.rs @@ -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 { + name: Cow<'static, str>, + boot: Box (State, Task)>, +} + +impl Preset { + /// Creates a new [`Preset`] with the given name and boot strategy. + pub fn new( + name: impl Into>, + boot: impl Fn() -> (State, Task) + '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) { + (self.boot)() + } +} + +impl fmt::Debug for Preset { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Preset") + .field("name", &self.name) + .finish_non_exhaustive() + } +} diff --git a/src/application.rs b/src/application.rs index 8c8917fc..459f3a68 100644 --- a/src/application.rs +++ b/src/application.rs @@ -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 { raw: P, settings: Settings, window: window::Settings, + + #[cfg(feature = "test")] + presets: Vec>, } impl Application

{ @@ -338,6 +346,8 @@ impl Application

{ }), settings: self.settings, window: self.window, + #[cfg(feature = "test")] + presets: self.presets, } } @@ -352,6 +362,8 @@ impl Application

{ raw: program::with_subscription(self.raw, f), settings: self.settings, window: self.window, + #[cfg(feature = "test")] + presets: self.presets, } } @@ -366,6 +378,8 @@ impl Application

{ 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 Application

{ raw: program::with_style(self.raw, f), settings: self.settings, window: self.window, + #[cfg(feature = "test")] + presets: self.presets, } } @@ -396,6 +412,8 @@ impl Application

{ }), settings: self.settings, window: self.window, + #[cfg(feature = "test")] + presets: self.presets, } } @@ -412,6 +430,24 @@ impl Application

{ raw: program::with_executor::(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>, + ) -> Self { + Self { + presets: presets.into_iter().collect(), + ..self } } } @@ -474,6 +510,11 @@ impl Program for Application

{ 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.presets + } } /// The logic to initialize the `State` of some [`Application`]. diff --git a/src/application/timed.rs b/src/application/timed.rs index 5ca45ec4..18eba2b5 100644 --- a/src/application/timed.rs +++ b/src/application/timed.rs @@ -138,6 +138,9 @@ where }, settings: Settings::default(), window: window::Settings::default(), + + #[cfg(feature = "test")] + presets: Vec::new(), } } diff --git a/src/lib.rs b/src/lib.rs index e9ca3a04..b77f0b21 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/test/Cargo.toml b/test/Cargo.toml index 132a913b..dd262ffc 100644 --- a/test/Cargo.toml +++ b/test/Cargo.toml @@ -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"] diff --git a/test/src/emulator.rs b/test/src/emulator.rs index 592b866b..98841e57 100644 --- a/test/src/emulator.rs +++ b/test/src/emulator.rs @@ -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 { state: P::State, runtime: Runtime>, Event

>, renderer: P::Renderer, + mode: Mode, size: Size, window: window::Id, cursor: mouse::Cursor, @@ -35,9 +39,20 @@ pub enum Event { impl Emulator

{ pub fn new( - program: &P, - size: Size, sender: mpsc::Sender>, + program: &P, + mode: Mode, + size: Size, + ) -> Emulator

{ + Self::with_preset(sender, program, mode, size, None) + } + + pub fn with_preset( + sender: mpsc::Sender>, + program: &P, + mode: Mode, + size: Size, + preset: Option<&program::Preset>, ) -> Emulator

{ use renderer::Headless; @@ -55,12 +70,18 @@ impl Emulator

{ .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 Emulator

{ cache: Some(user_interface::Cache::default()), }; - // TODO: Configurable emulator.wait_for(task); + emulator.resubscribe(program); emulator } @@ -106,9 +127,9 @@ impl Emulator

{ 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 Emulator

{ .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) { 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 Emulator

{ } } +#[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, } diff --git a/widget/src/combo_box.rs b/widget/src/combo_box.rs index 5bf01eac..27f3db56 100644 --- a/widget/src/combo_box.rs +++ b/widget/src/combo_box.rs @@ -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) -> Self { + let size = size.into(); + self.text_input = self.text_input.size(size); - self.size = Some(size); + self.size = Some(size.0); + self } diff --git a/winit/Cargo.toml b/winit/Cargo.toml index f2157978..8827d9bb 100644 --- a/winit/Cargo.toml +++ b/winit/Cargo.toml @@ -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"]