From 4f7444bddfffac27dbd5e56d9748f698629fc7c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Fri, 29 Aug 2025 08:39:44 +0200 Subject: [PATCH] Move `tester` to a new `iced_tester` subcrate --- Cargo.lock | 13 +- Cargo.toml | 9 +- devtools/Cargo.toml | 9 +- devtools/src/comet.rs | 7 +- devtools/src/executor.rs | 43 ---- devtools/src/lib.rs | 170 ++++------------ devtools/src/tester/null.rs | 49 ----- devtools/src/time_machine.rs | 1 + devtools/src/widget.rs | 13 -- examples/todos/src/main.rs | 2 +- examples/todos/tests/carl_sagan.ice | 2 +- program/Cargo.toml | 1 + program/src/lib.rs | 46 +++-- program/src/message.rs | 33 ++++ runtime/src/task.rs | 45 +++++ src/application.rs | 40 ++-- src/application/timed.rs | 8 +- src/daemon.rs | 21 +- src/lib.rs | 4 +- test/src/lib.rs | 8 +- tester/Cargo.toml | 20 ++ .../fonts/iced_tester-icons.toml | 0 .../fonts/iced_tester-icons.ttf | Bin {devtools => tester}/src/icon.rs | 2 +- devtools/src/tester.rs => tester/src/lib.rs | 185 +++++++++++++----- .../src/tester => tester/src}/recorder.rs | 0 winit/src/lib.rs | 11 +- winit/src/proxy.rs | 5 +- 28 files changed, 392 insertions(+), 355 deletions(-) delete mode 100644 devtools/src/executor.rs delete mode 100644 devtools/src/tester/null.rs delete mode 100644 devtools/src/widget.rs create mode 100644 program/src/message.rs create mode 100644 tester/Cargo.toml rename devtools/fonts/iced_devtools-icons.toml => tester/fonts/iced_tester-icons.toml (100%) rename devtools/fonts/iced_devtools-icons.ttf => tester/fonts/iced_tester-icons.ttf (100%) rename {devtools => tester}/src/icon.rs (96%) rename devtools/src/tester.rs => tester/src/lib.rs (84%) rename {devtools/src/tester => tester/src}/recorder.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 786e115c..d455278b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2492,6 +2492,7 @@ dependencies = [ "iced_highlighter", "iced_renderer", "iced_runtime", + "iced_tester", "iced_wgpu", "iced_widget", "iced_winit", @@ -2548,10 +2549,8 @@ version = "0.14.0-dev" dependencies = [ "iced_debug", "iced_program", - "iced_test", "iced_widget", "log", - "rfd 0.15.4", ] [[package]] @@ -2650,6 +2649,16 @@ dependencies = [ "thiserror 2.0.16", ] +[[package]] +name = "iced_tester" +version = "0.14.0-dev" +dependencies = [ + "iced_test", + "iced_widget", + "log", + "rfd 0.15.4", +] + [[package]] name = "iced_tiny_skia" version = "0.14.0-dev" diff --git a/Cargo.toml b/Cargo.toml index 0a8ac2a6..a363b5c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,13 +42,13 @@ markdown = ["iced_widget/markdown"] # Enables lazy widgets lazy = ["iced_widget/lazy"] # Enables debug metrics in native platforms (press F12) -debug = ["iced_winit/debug", "iced_devtools"] +debug = ["iced_winit/debug", "dep:iced_devtools"] # Enables time-travel debugging (very experimental!) time-travel = ["debug", "iced_devtools/time-travel"] # Enables hot reloading (very experimental!) hot = ["debug", "iced_debug/hot"] # Enables the tester developer tool for recording and playing tests (press F12) -tester = ["debug", "iced_devtools/tester"] +tester = ["dep:iced_tester"] # 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 @@ -92,6 +92,9 @@ iced_winit.workspace = true iced_devtools.workspace = true iced_devtools.optional = true +iced_tester.workspace = true +iced_tester.optional = true + iced_highlighter.workspace = true iced_highlighter.optional = true @@ -133,6 +136,7 @@ members = [ "runtime", "selector", "test", + "tester", "tiny_skia", "wgpu", "widget", @@ -165,6 +169,7 @@ iced_renderer = { version = "0.14.0-dev", path = "renderer" } iced_runtime = { version = "0.14.0-dev", path = "runtime" } iced_selector = { version = "0.14.0-dev", path = "selector" } iced_test = { version = "0.14.0-dev", path = "test" } +iced_tester = { version = "0.14.0-dev", path = "tester" } iced_tiny_skia = { version = "0.14.0-dev", path = "tiny_skia" } iced_wgpu = { version = "0.14.0-dev", path = "wgpu" } iced_widget = { version = "0.14.0-dev", path = "widget" } diff --git a/devtools/Cargo.toml b/devtools/Cargo.toml index 8e50978b..f9923410 100644 --- a/devtools/Cargo.toml +++ b/devtools/Cargo.toml @@ -15,16 +15,11 @@ workspace = true [features] time-travel = ["iced_program/time-travel"] -tester = ["dep:iced_test", "dep:rfd"] [dependencies] iced_debug.workspace = true -iced_program.workspace = true iced_widget.workspace = true log.workspace = true -iced_test.workspace = true -iced_test.optional = true - -rfd.workspace = true -rfd.optional = true +iced_program.workspace = true +iced_program.features = ["debug"] diff --git a/devtools/src/comet.rs b/devtools/src/comet.rs index d5ba0802..d6975c26 100644 --- a/devtools/src/comet.rs +++ b/devtools/src/comet.rs @@ -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 { - 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 { } pub fn install() -> Task { - executor::try_spawn_blocking(|mut sender| { + task::try_blocking(|mut sender| { use std::io::{BufRead, BufReader}; use std::process::{Command, Stdio}; diff --git a/devtools/src/executor.rs b/devtools/src/executor.rs deleted file mode 100644 index 1e7317a2..00000000 --- a/devtools/src/executor.rs +++ /dev/null @@ -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( - f: impl FnOnce(mpsc::Sender) + Send + 'static, -) -> Task -where - T: Send + 'static, -{ - let (sender, receiver) = mpsc::channel(1); - - let _ = thread::spawn(move || { - f(sender); - }); - - Task::stream(receiver) -} - -pub fn try_spawn_blocking( - f: impl FnOnce(mpsc::Sender) -> Result<(), E> + Send + 'static, -) -> Task> -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()), - )) -} diff --git a/devtools/src/lib.rs b/devtools/src/lib.rs index 18208a7a..b859341c 100644 --- a/devtools/src/lib.rs +++ b/devtools/src/lib.rs @@ -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

{ impl

Program for Attach

where P: Program + 'static, + P::Message: std::fmt::Debug + message::MaybeClone, { type State = DevTools

; type Message = Event

; @@ -66,21 +56,21 @@ where P::name() } - fn settings(&self) -> core::Settings { + fn settings(&self) -> Settings { self.program.settings() } + fn window(&self) -> Option { + self.program.window() + } + fn boot(&self) -> (Self::State, Task) { 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

, - mode: Mode

, + mode: Mode, } #[derive(Debug, Clone)] @@ -141,13 +131,10 @@ pub enum Message { InstallComet, Installing(comet::install::Result), CancelSetup, - Toggle, - Tester(tester::Message), } -enum Mode { +enum Mode { Hidden, - Open { tester: Tester

}, Setup(Setup), } @@ -164,6 +151,7 @@ enum Goal { impl

DevTools

where P: Program + 'static, + P::Message: std::fmt::Debug + message::MaybeClone, { pub fn new(state: P::State) -> (Self, Task) { ( @@ -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

), Command(debug::Command), Discard, } @@ -505,34 +424,18 @@ where impl

fmt::Debug for Event

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

Clone for Event

-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(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) diff --git a/devtools/src/tester/null.rs b/devtools/src/tester/null.rs deleted file mode 100644 index 96b1dbfa..00000000 --- a/devtools/src/tester/null.rs +++ /dev/null @@ -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 { - _type: PhantomData, -} - -#[derive(Debug, Clone)] -pub enum Message {} - -#[allow(missing_debug_implementations)] -pub struct Tick { - _type: PhantomData, -} - -impl Tester

{ - 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> { - Task::none() - } - - pub fn tick(&mut self, _program: &P, _tick: Tick

) -> Task> { - 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

) -> T + 'a, - ) -> Element<'a, T, Theme, P::Renderer> { - horizontal_space().into() - } - - pub fn controls(&self) -> Element<'_, Message, Theme, P::Renderer> { - horizontal_space().into() - } -} diff --git a/devtools/src/time_machine.rs b/devtools/src/time_machine.rs index f999c70e..3c8c18a7 100644 --- a/devtools/src/time_machine.rs +++ b/devtools/src/time_machine.rs @@ -13,6 +13,7 @@ where impl

TimeMachine

where P: Program, + P::Message: std::fmt::Debug + Clone, { pub fn new() -> Self { Self { diff --git a/devtools/src/widget.rs b/devtools/src/widget.rs deleted file mode 100644 index ef247508..00000000 --- a/devtools/src/widget.rs +++ /dev/null @@ -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) -} diff --git a/examples/todos/src/main.rs b/examples/todos/src/main.rs index 0f7c1009..2113c93d 100644 --- a/examples/todos/src/main.rs +++ b/examples/todos/src/main.rs @@ -19,7 +19,7 @@ pub fn main() -> iced::Result { application().run() } -fn application() -> Application { +fn application() -> Application> { iced::application(Todos::new, Todos::update, Todos::view) .subscription(Todos::subscription) .title(Todos::title) diff --git a/examples/todos/tests/carl_sagan.ice b/examples/todos/tests/carl_sagan.ice index cfad7a60..cb9b91ed 100644 --- a/examples/todos/tests/carl_sagan.ice +++ b/examples/todos/tests/carl_sagan.ice @@ -1,4 +1,4 @@ -viewport: 512x768 +viewport: 500x800 mode: Impatient preset: Empty ----- diff --git a/program/Cargo.toml b/program/Cargo.toml index 7aa6414d..76106c25 100644 --- a/program/Cargo.toml +++ b/program/Cargo.toml @@ -14,6 +14,7 @@ rust-version.workspace = true workspace = true [features] +debug = [] time-travel = [] [dependencies] diff --git a/program/src/lib.rs b/program/src/lib.rs index c20afdfd..0e473c3a 100644 --- a/program/src/lib.rs +++ b/program/src/lib.rs @@ -4,6 +4,8 @@ pub use iced_runtime as runtime; pub use iced_runtime::core; pub use iced_runtime::futures; +pub mod message; + mod preset; pub use preset::Preset; @@ -13,7 +15,7 @@ use crate::core::text; use crate::core::theme; use crate::core::window; use crate::core::{Element, Font, Settings}; -use crate::futures::{Executor, Subscription}; +use crate::futures::{Executor, MaybeSend, Subscription}; use crate::graphics::compositor; use crate::runtime::Task; @@ -27,7 +29,7 @@ pub trait Program: Sized { type State; /// The message of the program. - type Message: Message + 'static; + type Message: MaybeSend + 'static; /// The theme of the program. type Theme: Default + theme::Base; @@ -43,6 +45,8 @@ pub trait Program: Sized { fn settings(&self) -> Settings; + fn window(&self) -> Option; + fn boot(&self) -> (Self::State, Task); fn update( @@ -143,6 +147,10 @@ pub fn with_title( self.program.settings() } + fn window(&self) -> Option { + self.program.window() + } + fn boot(&self) -> (Self::State, Task) { self.program.boot() } @@ -229,6 +237,10 @@ pub fn with_subscription( self.program.settings() } + fn window(&self) -> Option { + self.program.window() + } + fn boot(&self) -> (Self::State, Task) { self.program.boot() } @@ -316,6 +328,10 @@ pub fn with_theme( self.program.settings() } + fn window(&self) -> Option { + self.program.window() + } + fn boot(&self) -> (Self::State, Task) { self.program.boot() } @@ -399,6 +415,10 @@ pub fn with_style( self.program.settings() } + fn window(&self) -> Option { + self.program.window() + } + fn boot(&self) -> (Self::State, Task) { self.program.boot() } @@ -478,6 +498,10 @@ pub fn with_scale_factor( self.program.settings() } + fn window(&self) -> Option { + self.program.window() + } + fn boot(&self) -> (Self::State, Task) { self.program.boot() } @@ -565,6 +589,10 @@ pub fn with_executor( self.program.settings() } + fn window(&self) -> Option { + self.program.window() + } + fn boot(&self) -> (Self::State, Task) { self.program.boot() } @@ -683,17 +711,3 @@ impl Instance

{ self.program.scale_factor(&self.state, window) } } - -/// A trait alias for the [`Message`](Program::Message) of a [`Program`]. -#[cfg(feature = "time-travel")] -pub trait Message: Send + std::fmt::Debug + Clone {} - -#[cfg(feature = "time-travel")] -impl Message for T {} - -/// A trait alias for the [`Message`](Program::Message) of a [`Program`]. -#[cfg(not(feature = "time-travel"))] -pub trait Message: Send + std::fmt::Debug {} - -#[cfg(not(feature = "time-travel"))] -impl Message for T {} diff --git a/program/src/message.rs b/program/src/message.rs new file mode 100644 index 00000000..15dfafed --- /dev/null +++ b/program/src/message.rs @@ -0,0 +1,33 @@ +//! Traits for the message type of a [`Program`](crate::Program). + +/// A trait alias for [`Clone`], but only when the `time-travel` +/// feature is enabled. +#[cfg(feature = "time-travel")] +pub trait MaybeClone: Clone {} + +#[cfg(feature = "time-travel")] +impl MaybeClone for T where T: Clone {} + +/// A trait alias for [`Clone`], but only when the `time-travel` +/// feature is enabled. +#[cfg(not(feature = "time-travel"))] +pub trait MaybeClone {} + +#[cfg(not(feature = "time-travel"))] +impl MaybeClone for T {} + +/// A trait alias for [`Debug`](std::fmt::Debug), but only when the +/// `debug` feature is enabled. +#[cfg(feature = "debug")] +pub trait MaybeDebug: std::fmt::Debug {} + +#[cfg(feature = "debug")] +impl MaybeDebug for T where T: std::fmt::Debug {} + +/// A trait alias for [`Debug`](std::fmt::Debug), but only when the +/// `debug` feature is enabled. +#[cfg(not(feature = "debug"))] +pub trait MaybeDebug {} + +#[cfg(not(feature = "debug"))] +impl MaybeDebug for T {} diff --git a/runtime/src/task.rs b/runtime/src/task.rs index 34ee1df7..7ac9befe 100644 --- a/runtime/src/task.rs +++ b/runtime/src/task.rs @@ -9,6 +9,7 @@ use crate::futures::{BoxStream, MaybeSend, boxed_stream}; use std::convert::Infallible; use std::sync::Arc; +use std::thread; #[cfg(feature = "sipper")] #[doc(no_inline)] @@ -466,3 +467,47 @@ pub fn effect(action: impl Into>) -> Task { pub fn into_stream(task: Task) -> Option>> { task.stream } + +/// Creates a new [`Task`] that will run the given closure in a new thread. +/// +/// Any data sent by the closure through the [`mpsc::Sender`] will be produced +/// by the [`Task`]. +pub fn blocking(f: impl FnOnce(mpsc::Sender) + Send + 'static) -> Task +where + T: Send + 'static, +{ + let (sender, receiver) = mpsc::channel(1); + + let _ = thread::spawn(move || { + f(sender); + }); + + Task::stream(receiver) +} + +/// Creates a new [`Task`] that will run the given closure that can fail in a new +/// thread. +/// +/// Any data sent by the closure through the [`mpsc::Sender`] will be produced +/// by the [`Task`]. +pub fn try_blocking( + f: impl FnOnce(mpsc::Sender) -> Result<(), E> + Send + 'static, +) -> Task> +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()), + )) +} diff --git a/src/application.rs b/src/application.rs index 1792502f..38d95f79 100644 --- a/src/application.rs +++ b/src/application.rs @@ -30,12 +30,14 @@ //! ] //! } //! ``` +use crate::message; use crate::program::{self, Program}; use crate::shell; use crate::theme; use crate::window; use crate::{ - Element, Executor, Font, Preset, Result, Settings, Size, Subscription, Task, + Element, Executor, Font, MaybeSend, Preset, Result, Settings, Size, + Subscription, Task, }; use iced_debug as debug; @@ -81,7 +83,7 @@ pub fn application( ) -> Application> where State: 'static, - Message: program::Message + 'static, + Message: MaybeSend + 'static, Theme: Default + theme::Base, Renderer: program::Renderer, { @@ -100,7 +102,7 @@ where impl Program for Instance where - Message: program::Message + 'static, + Message: MaybeSend + 'static, Theme: Default + theme::Base, Renderer: program::Renderer, Boot: self::Boot, @@ -142,6 +144,10 @@ where fn settings(&self) -> Settings { Settings::default() } + + fn window(&self) -> Option { + Some(window::Settings::default()) + } } Application { @@ -180,25 +186,25 @@ impl Application

{ pub fn run(self) -> Result where Self: 'static, + P::Message: message::MaybeDebug + message::MaybeClone, { - let settings = self.settings.clone(); - let window = self.window.clone(); + #[cfg(feature = "debug")] + iced_debug::init(iced_debug::Metadata { + name: P::name(), + theme: None, + can_time_travel: cfg!(feature = "time-travel"), + }); - #[cfg(all(feature = "debug", not(target_arch = "wasm32")))] - let program = { - iced_debug::init(iced_debug::Metadata { - name: P::name(), - theme: None, - can_time_travel: cfg!(feature = "time-travel"), - }); + #[cfg(feature = "tester")] + let program = iced_tester::attach(self); - iced_devtools::attach(self) - }; + #[cfg(all(feature = "debug", not(feature = "tester")))] + let program = iced_devtools::attach(self); #[cfg(any(not(feature = "debug"), target_arch = "wasm32"))] let program = self; - Ok(shell::run(program, settings, Some(window))?) + Ok(shell::run(program)?) } /// Sets the [`Settings`] that will be used to run the [`Application`]. @@ -456,6 +462,10 @@ impl Program for Application

{ self.settings.clone() } + fn window(&self) -> Option { + Some(self.window.clone()) + } + fn boot(&self) -> (Self::State, Task) { self.raw.boot() } diff --git a/src/application/timed.rs b/src/application/timed.rs index 02574b3a..ade98a18 100644 --- a/src/application/timed.rs +++ b/src/application/timed.rs @@ -29,7 +29,7 @@ pub fn timed( > where State: 'static, - Message: program::Message + 'static, + Message: Send + 'static, Theme: Default + theme::Base + 'static, Renderer: program::Renderer + 'static, { @@ -68,7 +68,7 @@ where View, > where - Message: program::Message + 'static, + Message: Send + 'static, Theme: Default + theme::Base + 'static, Renderer: program::Renderer + 'static, Boot: self::Boot, @@ -92,6 +92,10 @@ where Settings::default() } + fn window(&self) -> Option { + Some(window::Settings::default()) + } + fn boot(&self) -> (State, Task) { let (state, task) = self.boot.boot(); diff --git a/src/daemon.rs b/src/daemon.rs index a961eed5..c848d8bd 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1,11 +1,13 @@ //! Create and run daemons that run in the background. use crate::application; +use crate::message; use crate::program::{self, Program}; use crate::shell; use crate::theme; use crate::window; use crate::{ - Element, Executor, Font, Preset, Result, Settings, Subscription, Task, + Element, Executor, Font, MaybeSend, Preset, Result, Settings, Subscription, + Task, }; use iced_debug as debug; @@ -29,7 +31,7 @@ pub fn daemon( ) -> Daemon> where State: 'static, - Message: program::Message + 'static, + Message: MaybeSend + 'static, Theme: Default + theme::Base, Renderer: program::Renderer, { @@ -48,7 +50,7 @@ where impl Program for Instance where - Message: program::Message + 'static, + Message: MaybeSend + 'static, Theme: Default + theme::Base, Renderer: program::Renderer, Boot: application::Boot, @@ -71,6 +73,10 @@ where Settings::default() } + fn window(&self) -> Option { + None + } + fn boot(&self) -> (Self::State, Task) { self.boot.boot() } @@ -126,9 +132,8 @@ impl Daemon

{ pub fn run(self) -> Result where Self: 'static, + P::Message: message::MaybeDebug + message::MaybeClone, { - let settings = self.settings.clone(); - #[cfg(all(feature = "debug", not(target_arch = "wasm32")))] let program = { iced_debug::init(iced_debug::Metadata { @@ -143,7 +148,7 @@ impl Daemon

{ #[cfg(any(not(feature = "debug"), target_arch = "wasm32"))] let program = self; - Ok(shell::run(program, settings, None)?) + Ok(shell::run(program)?) } /// Sets the [`Settings`] that will be used to run the [`Daemon`]. @@ -298,6 +303,10 @@ impl Program for Daemon

{ self.settings.clone() } + fn window(&self) -> Option { + None + } + fn boot(&self) -> (Self::State, Task) { self.raw.boot() } diff --git a/src/lib.rs b/src/lib.rs index 29dffb1a..e4cc8807 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -526,7 +526,9 @@ pub use crate::core::{ Rotation, Settings, Shadow, Size, Theme, Transformation, Vector, never, }; pub use crate::program::Preset; +pub use crate::program::message; pub use crate::runtime::exit; +pub use crate::runtime::futures::MaybeSend; pub use iced_futures::Subscription; pub use Alignment::Center; @@ -696,7 +698,7 @@ pub fn run( ) -> Result where State: Default + 'static, - Message: program::Message + 'static, + Message: MaybeSend + message::MaybeDebug + message::MaybeClone + 'static, Theme: Default + theme::Base + 'static, Renderer: program::Renderer + 'static, { diff --git a/test/src/lib.rs b/test/src/lib.rs index 5f4f61dd..1cf8d81f 100644 --- a/test/src/lib.rs +++ b/test/src/lib.rs @@ -84,10 +84,10 @@ //! //! [the classical counter interface]: https://book.iced.rs/architecture.html#dissecting-an-interface #![allow(missing_docs)] -use iced_program as program; -use iced_renderer as renderer; -use iced_runtime as runtime; -use iced_runtime::core; +pub use iced_program as program; +pub use iced_renderer as renderer; +pub use iced_runtime as runtime; +pub use iced_runtime::core; pub use iced_selector as selector; diff --git a/tester/Cargo.toml b/tester/Cargo.toml new file mode 100644 index 00000000..4f97bac3 --- /dev/null +++ b/tester/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "iced_tester" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +categories.workspace = true +keywords.workspace = true +rust-version.workspace = true + +[dependencies] +iced_test.workspace = true +iced_widget.workspace = true +log.workspace = true +rfd.workspace = true + +[lints] +workspace = true diff --git a/devtools/fonts/iced_devtools-icons.toml b/tester/fonts/iced_tester-icons.toml similarity index 100% rename from devtools/fonts/iced_devtools-icons.toml rename to tester/fonts/iced_tester-icons.toml diff --git a/devtools/fonts/iced_devtools-icons.ttf b/tester/fonts/iced_tester-icons.ttf similarity index 100% rename from devtools/fonts/iced_devtools-icons.ttf rename to tester/fonts/iced_tester-icons.ttf diff --git a/devtools/src/icon.rs b/tester/src/icon.rs similarity index 96% rename from devtools/src/icon.rs rename to tester/src/icon.rs index 811b1d23..343f942f 100644 --- a/devtools/src/icon.rs +++ b/tester/src/icon.rs @@ -3,7 +3,7 @@ use crate::core::Font; use crate::program; use crate::widget::{Text, text}; -pub const FONT: &[u8] = include_bytes!("../fonts/iced_devtools-icons.ttf"); +pub const FONT: &[u8] = include_bytes!("../fonts/iced_tester-icons.ttf"); pub fn cancel<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer> where diff --git a/devtools/src/tester.rs b/tester/src/lib.rs similarity index 84% rename from devtools/src/tester.rs rename to tester/src/lib.rs index b177d0cc..8e7593d6 100644 --- a/devtools/src/tester.rs +++ b/tester/src/lib.rs @@ -1,29 +1,97 @@ +//! Record, edit, and run end-to-end tests for your iced applications. +#![allow(missing_docs)] +pub use iced_test as test; +pub use iced_test::core; +pub use iced_test::program; +pub use iced_test::runtime; +pub use iced_test::runtime::futures; +pub use iced_widget as widget; + +mod icon; 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::core::{Element, Font, Settings, Size, Theme}; use crate::futures::futures::channel::mpsc; -use crate::icon; -use crate::program; -use crate::runtime::Task; +use crate::program::Program; +use crate::runtime::task::{self, 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, + button, center, column, combo_box, container, horizontal_space, pick_list, + row, scrollable, text, text_editor, text_input, themer, }; +/// Attaches a [`Tester`] to the given [`Program`]. +pub fn attach(program: P) -> Attach

{ + Attach { program } +} + +/// A [`Program`] with a [`Tester`] attached to it. +#[derive(Debug)] +pub struct Attach

{ + /// The original [`Program`] attatched to the [`Tester`]. + pub program: P, +} + +impl

Program for Attach

+where + P: Program + 'static, +{ + type State = Tester

; + type Message = Tick

; + type Theme = Theme; + type Renderer = P::Renderer; + type Executor = P::Executor; + + fn name() -> &'static str { + P::name() + } + + fn settings(&self) -> Settings { + let mut settings = self.program.settings(); + settings.fonts.push(icon::FONT.into()); + settings + } + + fn window(&self) -> Option { + self.program.window().map(|window| window::Settings { + size: window.size + Size::new(300.0, 80.0), + ..window + }) + } + + fn boot(&self) -> (Self::State, Task) { + (Tester::new(&self.program), Task::none()) + } + + fn update( + &self, + state: &mut Self::State, + message: Self::Message, + ) -> Task { + state.tick(&self.program, message) + } + + fn view<'a>( + &self, + state: &'a Self::State, + window: window::Id, + ) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> { + state.view(&self.program, window) + } +} + +#[allow(missing_debug_implementations)] pub struct Tester { viewport: Size, mode: emulator::Mode, @@ -35,7 +103,10 @@ pub struct Tester { } enum State { - Idle, + Empty, + Idle { + state: P::State, + }, Recording { emulator: Emulator

, }, @@ -84,9 +155,12 @@ pub enum Tick { impl Tester

{ pub fn new(program: &P) -> Self { + let (state, _) = program.boot(); + let window = program.window().unwrap_or_default(); + Self { mode: emulator::Mode::default(), - viewport: window::Settings::default().size, + viewport: window.size, presets: combo_box::State::new( program .presets() @@ -97,7 +171,7 @@ impl Tester

{ ), preset: None, instructions: Vec::new(), - state: State::Idle, + state: State::Idle { state }, edit: None, } } @@ -150,7 +224,7 @@ impl Tester

{ } Message::Stop => { let State::Recording { emulator } = - std::mem::replace(&mut self.state, State::Idle) + std::mem::replace(&mut self.state, State::Empty) else { return Task::none(); }; @@ -204,7 +278,7 @@ impl Tester

{ Task::future(import) .and_then(|file| { - executor::spawn_blocking(move |mut sender| { + task::blocking(move |mut sender| { let _ = sender.try_send(Ice::parse( &fs::read_to_string(file.path()) .unwrap_or_default(), @@ -248,7 +322,13 @@ impl Tester

{ self.preset = ice.preset; self.instructions = ice.instructions; self.edit = None; - self.state = State::Idle; + + let (state, _) = self + .preset(program) + .map(program::Preset::boot) + .unwrap_or_else(|| program.boot()); + + self.state = State::Idle { state }; Task::none() } @@ -358,7 +438,9 @@ impl Tester

{ } } }, - State::Idle | State::Asserting { .. } => {} + State::Empty + | State::Idle { .. } + | State::Asserting { .. } => {} } Task::none() @@ -432,15 +514,14 @@ impl Tester

{ } } - pub fn view<'a, T: 'static>( + pub fn view<'a>( &'a self, program: &P, - current: impl FnOnce() -> Element<'a, T, Theme, P::Renderer>, - emulate: impl Fn(Tick

) -> T + 'a, - ) -> Element<'a, T, Theme, P::Renderer> { + window: window::Id, + ) -> Element<'a, Tick

, Theme, P::Renderer> { let status = { let (icon, label) = match &self.state { - State::Idle => (text(""), "Idle"), + State::Empty | State::Idle { .. } => (text(""), "Idle"), State::Recording { .. } => (icon::record(), "Recording"), State::Asserting { .. } => (icon::lightbulb(), "Asserting"), State::Playing { outcome, .. } => match outcome { @@ -456,7 +537,9 @@ impl Tester

{ container::Style { text_color: Some(match &self.state { - State::Idle => palette.background.strongest.color, + State::Empty | State::Idle { .. } => { + palette.background.strongest.color + } State::Recording { .. } => { palette.danger.base.color } @@ -483,33 +566,34 @@ impl Tester

{ let viewport = container( scrollable( container(match &self.state { - State::Idle => current(), + State::Empty => horizontal_space().into(), + State::Idle { state } => Element::from(themer( + program.theme(state, window), + program.view(state, window), + )) + .map(Tick::Program), 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) + recorder(themer(theme, view)) + .on_record(Tick::Record) + .into() } 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) + recorder(themer(theme, view)) + .on_record(Tick::Assert) + .into() } State::Playing { emulator, .. } => { let theme = emulator.theme(program); let view = emulator.view(program).map(Tick::Program); - Element::from(themer(theme, view)).map(emulate) + themer(theme, view).into() } }) .width(self.viewport.width) @@ -525,7 +609,9 @@ impl Tester

{ container::Style { border: border::width(2.0).color(match &self.state { - State::Idle => palette.background.strongest.color, + State::Empty | State::Idle { .. } => { + palette.background.strongest.color + } State::Recording { .. } => palette.danger.base.color, State::Asserting { .. } => palette.warning.weak.color, State::Playing { outcome, .. } => match outcome { @@ -539,9 +625,15 @@ impl Tester

{ }) .padding(10); - center(column![status, viewport].spacing(10).align_x(Right)) - .padding(10) - .into() + row![ + center(column![status, viewport].spacing(10).align_x(Right)) + .padding(10), + container(self.controls().map(Tick::Tester)) + .width(250) + .padding(10) + .style(container::dark) + ] + .into() } pub fn controls(&self) -> Element<'_, Message, Theme, P::Renderer> { @@ -552,7 +644,7 @@ impl Tester

{ width: width.parse().unwrap_or(self.viewport.width), ..self.viewport })), - monospace("x").size(14), + text("x").size(14).font(Font::MONOSPACE), text_input("Height", &self.viewport.height.to_string()) .size(14) .on_input(|height| Message::ChangeViewport(Size { @@ -590,8 +682,9 @@ impl Tester

{ .into() } else if self.instructions.is_empty() { Element::from(center( - monospace("No instructions recorded yet!") + text("No instructions recorded yet!") .size(14) + .font(Font::MONOSPACE) .width(Fill) .center(), )) @@ -599,9 +692,10 @@ impl Tester

{ scrollable( column(self.instructions.iter().enumerate().map( |(i, instruction)| { - monospace(instruction.to_string()) + text(instruction.to_string()) .wrapping(text::Wrapping::None) // TODO: Ellipsize? .size(10) + .font(Font::MONOSPACE) .style(move |theme: &Theme| text::Style { color: match &self.state { State::Playing { @@ -730,9 +824,12 @@ where Message: 'a, Renderer: program::Renderer + 'a, { - column![monospace(fragment).size(14), content.into()] - .spacing(5) - .into() + column![ + text(fragment).size(14).font(Font::MONOSPACE), + content.into() + ] + .spacing(5) + .into() } fn labeled_with<'a, Message, Renderer>( @@ -746,7 +843,7 @@ where { column![ row![ - monospace(fragment).size(14), + text(fragment).size(14).font(Font::MONOSPACE), horizontal_space(), control.into() ] diff --git a/devtools/src/tester/recorder.rs b/tester/src/recorder.rs similarity index 100% rename from devtools/src/tester/recorder.rs rename to tester/src/recorder.rs diff --git a/winit/src/lib.rs b/winit/src/lib.rs index 0ad2c507..459f7215 100644 --- a/winit/src/lib.rs +++ b/winit/src/lib.rs @@ -45,7 +45,7 @@ use crate::core::renderer; use crate::core::theme; use crate::core::time::Instant; use crate::core::widget::operation; -use crate::core::{Point, Settings, Size}; +use crate::core::{Point, Size}; use crate::futures::futures::channel::mpsc; use crate::futures::futures::channel::oneshot; use crate::futures::futures::task; @@ -66,11 +66,7 @@ use std::slice; use std::sync::Arc; /// Runs a [`Program`] with the provided settings. -pub fn run

( - program: P, - settings: Settings, - window_settings: Option, -) -> Result<(), Error> +pub fn run

(program: P) -> Result<(), Error> where P: Program + 'static, P::Theme: theme::Base, @@ -78,6 +74,8 @@ where use winit::event_loop::EventLoop; let boot_span = debug::boot(); + let settings = program.settings(); + let window_settings = program.window(); let graphics_settings = settings.clone().into(); let event_loop = EventLoop::with_user_event() @@ -169,7 +167,6 @@ where impl winit::application::ApplicationHandler> for Runner where - Message: std::fmt::Debug, F: Future, { fn resumed( diff --git a/winit/src/proxy.rs b/winit/src/proxy.rs index dcfcacff..ef3ecec7 100644 --- a/winit/src/proxy.rs +++ b/winit/src/proxy.rs @@ -88,10 +88,7 @@ impl Proxy { /// /// Note: This skips the backpressure mechanism with an unbounded /// channel. Use sparingly! - pub fn send_action(&self, action: Action) - where - T: std::fmt::Debug, - { + pub fn send_action(&self, action: Action) { let _ = self.raw.send_event(action); }