diff --git a/Cargo.lock b/Cargo.lock index d1e610ea..920dcf1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2453,6 +2453,7 @@ version = "0.14.0-dev" dependencies = [ "iced_beacon", "iced_core", + "iced_futures", "log", ] @@ -2463,6 +2464,7 @@ dependencies = [ "iced_debug", "iced_program", "iced_widget", + "log", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b6638e66..133c2362 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,8 @@ markdown = ["iced_widget/markdown"] lazy = ["iced_widget/lazy"] # Enables a debug view in native platforms (press F12) debug = ["iced_winit/debug", "iced_devtools"] +# Enables time-travel debugging (very experimental!) +time-travel = ["debug", "iced_devtools/time-travel"] # 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 diff --git a/beacon/src/client.rs b/beacon/src/client.rs index 1ca5bc8c..85f44eb8 100644 --- a/beacon/src/client.rs +++ b/beacon/src/client.rs @@ -1,12 +1,14 @@ +use crate::Error; use crate::core::time::{Duration, SystemTime}; use crate::span; use crate::theme; use semver::Version; use serde::{Deserialize, Serialize}; -use tokio::io::{self, AsyncWriteExt}; +use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; use tokio::net; -use tokio::sync::mpsc; +use tokio::sync::{Mutex, mpsc}; +use tokio::task; use tokio::time; use std::sync::Arc; @@ -17,7 +19,7 @@ pub const SERVER_ADDRESS: &str = "127.0.0.1:9167"; #[derive(Debug, Clone)] pub struct Client { - sender: mpsc::Sender, + sender: mpsc::Sender, is_connected: Arc, _handle: Arc>, } @@ -43,17 +45,17 @@ pub enum Event { ThemeChanged(theme::Palette), SpanStarted(span::Stage), SpanFinished(span::Stage, Duration), - MessageLogged(String), + MessageLogged { number: usize, message: String }, CommandsSpawned(usize), SubscriptionsTracked(usize), } impl Client { pub fn log(&self, event: Event) { - let _ = self.sender.try_send(Message::EventLogged { + let _ = self.sender.try_send(Action::Send(Message::EventLogged { at: SystemTime::now(), event, - }); + })); } pub fn is_connected(&self) -> bool { @@ -61,21 +63,28 @@ impl Client { } pub fn quit(&self) { - let _ = self.sender.try_send(Message::Quit { + let _ = self.sender.try_send(Action::Send(Message::Quit { at: SystemTime::now(), - }); + })); + } + + pub fn subscribe(&self) -> mpsc::Receiver { + let (sender, receiver) = mpsc::channel(100); + let _ = self.sender.try_send(Action::Forward(sender)); + + receiver } } #[must_use] pub fn connect(name: String) -> Client { - let (sender, receiver) = mpsc::channel(100); + let (sender, receiver) = mpsc::channel(10_000); let is_connected = Arc::new(AtomicBool::new(false)); let handle = { let is_connected = is_connected.clone(); - std::thread::spawn(move || run(name, is_connected.clone(), receiver)) + std::thread::spawn(move || run(name, is_connected, receiver)) }; Client { @@ -85,22 +94,42 @@ pub fn connect(name: String) -> Client { } } +enum Action { + Send(Message), + Forward(mpsc::Sender), +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum Command { + RewindTo { message: usize }, + GoLive, +} + #[tokio::main] async fn run( name: String, is_connected: Arc, - mut receiver: mpsc::Receiver, + mut receiver: mpsc::Receiver, ) { let version = semver::Version::parse(env!("CARGO_PKG_VERSION")) .expect("Parse package version"); + let command_sender = { + // Discard by default + let (sender, _receiver) = mpsc::channel(1); + + Arc::new(Mutex::new(sender)) + }; + loop { match _connect().await { - Ok(mut stream) => { + Ok(stream) => { is_connected.store(true, atomic::Ordering::Relaxed); + let (mut reader, mut writer) = stream.into_split(); + let _ = send( - &mut stream, + &mut writer, Message::Connected { at: SystemTime::now(), name: name.clone(), @@ -109,16 +138,48 @@ async fn run( ) .await; - while let Some(output) = receiver.recv().await { - match send(&mut stream, output).await { - Ok(()) => {} - Err(error) => { - if error.kind() != io::ErrorKind::BrokenPipe { - log::warn!( - "Error sending message to server: {error}" - ); + { + let command_sender = command_sender.clone(); + + drop(task::spawn(async move { + let mut buffer = Vec::new(); + + loop { + match receive(&mut reader, &mut buffer).await { + Ok(command) => { + let sender = command_sender.lock().await; + let _ = sender.send(command).await; + } + Err(Error::DecodingFailed(_)) => {} + Err(Error::IOFailed(_)) => break, } - break; + } + })) + }; + + while let Some(action) = receiver.recv().await { + match action { + Action::Send(message) => { + match send(&mut writer, message).await { + Ok(()) => {} + Err(error) => { + if error.kind() != io::ErrorKind::BrokenPipe + { + log::warn!( + "Error sending message to server: {error}" + ); + } + + is_connected.store( + false, + atomic::Ordering::Relaxed, + ); + break; + } + } + } + Action::Forward(sender) => { + *command_sender.lock().await = sender; } } } @@ -142,7 +203,7 @@ async fn _connect() -> Result { } async fn send( - stream: &mut net::TcpStream, + stream: &mut net::tcp::OwnedWriteHalf, message: Message, ) -> Result<(), io::Error> { let bytes = bincode::serialize(&message).expect("Encode input message"); @@ -154,3 +215,18 @@ async fn send( Ok(()) } + +async fn receive( + stream: &mut net::tcp::OwnedReadHalf, + buffer: &mut Vec, +) -> Result { + let size = stream.read_u64().await? as usize; + + if buffer.len() < size { + buffer.resize(size, 0); + } + + let _n = stream.read_exact(&mut buffer[..size]).await?; + + Ok(bincode::deserialize(buffer)?) +} diff --git a/beacon/src/error.rs b/beacon/src/error.rs new file mode 100644 index 00000000..032e75d5 --- /dev/null +++ b/beacon/src/error.rs @@ -0,0 +1,9 @@ +use std::io; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("input/output operation failed: {0}")] + IOFailed(#[from] io::Error), + #[error("decoding failed: {0}")] + DecodingFailed(#[from] Box), +} diff --git a/beacon/src/lib.rs b/beacon/src/lib.rs index 76df883a..a5291e32 100644 --- a/beacon/src/lib.rs +++ b/beacon/src/lib.rs @@ -4,6 +4,7 @@ pub use semver::Version; pub mod client; pub mod span; +mod error; mod stream; pub use client::Client; @@ -11,14 +12,44 @@ pub use span::Span; use crate::core::theme; use crate::core::time::{Duration, SystemTime}; +use crate::error::Error; use futures::{SinkExt, Stream}; -use tokio::io::{self, AsyncReadExt}; +use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; use tokio::net; +use tokio::sync::mpsc; +use tokio::task; + +#[derive(Debug, Clone)] +pub struct Connection { + commands: mpsc::Sender, +} + +impl Connection { + pub fn rewind_to<'a>( + &self, + message: usize, + ) -> impl Future + 'a { + let commands = self.commands.clone(); + + async move { + let _ = commands.send(client::Command::RewindTo { message }).await; + } + } + + pub fn go_live<'a>(&self) -> impl Future + 'a { + let commands = self.commands.clone(); + + async move { + let _ = commands.send(client::Command::GoLive).await; + } + } +} #[derive(Debug, Clone)] pub enum Event { Connected { + connection: Connection, at: SystemTime, name: String, version: Version, @@ -30,10 +61,6 @@ pub enum Event { at: SystemTime, palette: theme::Palette, }, - SubscriptionsTracked { - at: SystemTime, - amount_alive: usize, - }, SpanFinished { at: SystemTime, duration: Duration, @@ -53,7 +80,6 @@ impl Event { Self::Connected { at, .. } | Self::Disconnected { at, .. } | Self::ThemeChanged { at, .. } - | Self::SubscriptionsTracked { at, .. } | Self::SpanFinished { at, .. } | Self::QuitRequested { at } | Self::AlreadyRunning { at } => *at, @@ -86,18 +112,48 @@ pub fn run() -> impl Stream { }; loop { - let Ok((mut stream, _)) = server.accept().await else { + let Ok((stream, _)) = server.accept().await else { continue; }; - let _ = stream.set_nodelay(true); + let (mut reader, mut writer) = { + let _ = stream.set_nodelay(true); + stream.into_split() + }; + let (command_sender, mut command_receiver) = mpsc::channel(1); let mut last_message = String::new(); - let mut last_commands_spawned = 0; + let mut last_update_number = 0; + let mut last_tasks = 0; + let mut last_subscriptions = 0; let mut last_present_window = None; + drop(task::spawn(async move { + let mut last_message_number = None; + + while let Some(command) = command_receiver.recv().await { + match command { + client::Command::RewindTo { message } => { + if Some(message) == last_message_number { + continue; + } + + last_message_number = Some(message); + } + client::Command::GoLive => { + last_message_number = None; + } + } + + let _ = + send(&mut writer, command).await.inspect_err(|error| { + log::error!("Error when sending command: {error}") + }); + } + })); + loop { - match receive(&mut stream, &mut buffer).await { + match receive(&mut reader, &mut buffer).await { Ok(message) => { match message { client::Message::Connected { @@ -107,6 +163,9 @@ pub fn run() -> impl Stream { } => { let _ = output .send(Event::Connected { + connection: Connection { + commands: command_sender.clone(), + }, at, name, version, @@ -126,26 +185,25 @@ pub fn run() -> impl Stream { client::Event::SubscriptionsTracked( amount_alive, ) => { - let _ = output - .send(Event::SubscriptionsTracked { - at, - amount_alive, - }) - .await; + last_subscriptions = amount_alive; } - client::Event::MessageLogged(message) => { + client::Event::MessageLogged { + number, + message, + } => { + last_update_number = number; last_message = message; } client::Event::CommandsSpawned( commands, ) => { - last_commands_spawned = commands; + last_tasks = commands; } client::Event::SpanStarted( span::Stage::Update, ) => { last_message.clear(); - last_commands_spawned = 0; + last_tasks = 0; } client::Event::SpanStarted( span::Stage::Present(window), @@ -161,10 +219,12 @@ pub fn run() -> impl Stream { span::Stage::Boot => Span::Boot, span::Stage::Update => { Span::Update { + number: last_update_number, message: last_message .clone(), - commands_spawned: - last_commands_spawned, + tasks: last_tasks, + subscriptions: + last_subscriptions, } } span::Stage::View(window) => { @@ -246,7 +306,7 @@ pub fn run() -> impl Stream { } async fn receive( - stream: &mut net::TcpStream, + stream: &mut net::tcp::OwnedReadHalf, buffer: &mut Vec, ) -> Result { let size = stream.read_u64().await? as usize; @@ -260,14 +320,20 @@ async fn receive( Ok(bincode::deserialize(buffer)?) } +async fn send( + stream: &mut net::tcp::OwnedWriteHalf, + command: client::Command, +) -> Result<(), io::Error> { + let bytes = bincode::serialize(&command).expect("Encode input message"); + let size = bytes.len() as u64; + + stream.write_all(&size.to_be_bytes()).await?; + stream.write_all(&bytes).await?; + stream.flush().await?; + + Ok(()) +} + async fn delay() { tokio::time::sleep(Duration::from_secs(2)).await; } - -#[derive(Debug, thiserror::Error)] -enum Error { - #[error("input/output operation failed: {0}")] - IOFailed(#[from] io::Error), - #[error("decoding failed: {0}")] - DecodingFailed(#[from] Box), -} diff --git a/beacon/src/span.rs b/beacon/src/span.rs index d35f7b54..ff869e1e 100644 --- a/beacon/src/span.rs +++ b/beacon/src/span.rs @@ -6,8 +6,10 @@ use serde::{Deserialize, Serialize}; pub enum Span { Boot, Update { + number: usize, message: String, - commands_spawned: usize, + tasks: usize, + subscriptions: usize, }, View { window: window::Id, diff --git a/core/src/animation.rs b/core/src/animation.rs index 14cbb5c3..fa82c1dc 100644 --- a/core/src/animation.rs +++ b/core/src/animation.rs @@ -90,15 +90,17 @@ where self } - /// Transitions the [`Animation`] from its current state to the given new state. - pub fn go(mut self, new_state: T) -> Self { - self.go_mut(new_state); + /// Transitions the [`Animation`] from its current state to the given new state + /// at the given time. + pub fn go(mut self, new_state: T, at: Instant) -> Self { + self.go_mut(new_state, at); self } - /// Transitions the [`Animation`] from its current state to the given new state, by reference. - pub fn go_mut(&mut self, new_state: T) { - self.raw.transition(new_state, Instant::now()); + /// Transitions the [`Animation`] from its current state to the given new state + /// at the given time, by reference. + pub fn go_mut(&mut self, new_state: T, at: Instant) { + self.raw.transition(new_state, at); } /// Returns true if the [`Animation`] is currently in progress. diff --git a/core/src/renderer/null.rs b/core/src/renderer/null.rs index bf474b58..92fdd660 100644 --- a/core/src/renderer/null.rs +++ b/core/src/renderer/null.rs @@ -31,6 +31,7 @@ impl text::Renderer for () { type Paragraph = (); type Editor = (); + const MONOSPACE_FONT: Font = Font::MONOSPACE; const ICON_FONT: Font = Font::DEFAULT; const CHECKMARK_ICON: char = '0'; const ARROW_DOWN_ICON: char = '0'; diff --git a/core/src/text.rs b/core/src/text.rs index 79911b62..e2e75e58 100644 --- a/core/src/text.rs +++ b/core/src/text.rs @@ -232,6 +232,11 @@ pub trait Renderer: crate::Renderer { /// The [`Editor`] of this [`Renderer`]. type Editor: Editor + 'static; + /// A monospace font. + /// + /// It may be used by devtools. + const MONOSPACE_FONT: Self::Font; + /// The icon font of the backend. const ICON_FONT: Self::Font; diff --git a/core/src/theme.rs b/core/src/theme.rs index a0ec538b..0adf9ab2 100644 --- a/core/src/theme.rs +++ b/core/src/theme.rs @@ -5,6 +5,7 @@ pub use palette::Palette; use crate::Color; +use std::borrow::Cow; use std::fmt; use std::sync::Arc; @@ -87,14 +88,17 @@ impl Theme { ]; /// Creates a new custom [`Theme`] from the given [`Palette`]. - pub fn custom(name: String, palette: Palette) -> Self { + pub fn custom( + name: impl Into>, + palette: Palette, + ) -> Self { Self::custom_with_fn(name, palette, palette::Extended::generate) } /// Creates a new custom [`Theme`] from the given [`Palette`], with /// a custom generator of a [`palette::Extended`]. pub fn custom_with_fn( - name: String, + name: impl Into>, palette: Palette, generate: impl FnOnce(Palette) -> palette::Extended, ) -> Self { @@ -220,7 +224,7 @@ impl fmt::Display for Theme { /// A [`Theme`] with a customized [`Palette`]. #[derive(Debug, Clone, PartialEq)] pub struct Custom { - name: String, + name: Cow<'static, str>, palette: Palette, extended: palette::Extended, } @@ -234,12 +238,12 @@ impl Custom { /// Creates a [`Custom`] theme from the given [`Palette`] with /// a custom generator of a [`palette::Extended`]. pub fn with_fn( - name: String, + name: impl Into>, palette: Palette, generate: impl FnOnce(Palette) -> palette::Extended, ) -> Self { Self { - name, + name: name.into(), palette, extended: generate(palette), } diff --git a/debug/Cargo.toml b/debug/Cargo.toml index 1c5e7324..f6c7c843 100644 --- a/debug/Cargo.toml +++ b/debug/Cargo.toml @@ -15,6 +15,7 @@ enable = ["dep:iced_beacon"] [dependencies] iced_core.workspace = true +iced_futures.workspace = true log.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/debug/src/lib.rs b/debug/src/lib.rs index f3335c70..83a424d2 100644 --- a/debug/src/lib.rs +++ b/debug/src/lib.rs @@ -1,12 +1,12 @@ pub use iced_core as core; +pub use iced_futures as futures; use crate::core::theme; use crate::core::window; +use crate::futures::Subscription; pub use internal::Span; -use std::io; - #[derive(Debug, Clone, Copy)] pub enum Primitive { Quad, @@ -16,12 +16,26 @@ pub enum Primitive { Text, } +#[derive(Debug, Clone, Copy)] +pub enum Command { + RewindTo { message: usize }, + GoLive, +} + +pub fn enable() { + internal::enable(); +} + +pub fn disable() { + internal::disable(); +} + pub fn init(name: &str) { internal::init(name); } -pub fn toggle_comet() -> Result<(), io::Error> { - internal::toggle_comet() +pub fn quit() -> bool { + internal::quit() } pub fn theme_changed(f: impl FnOnce() -> Option) { @@ -84,25 +98,25 @@ pub fn time_with(name: impl Into, f: impl FnOnce() -> T) -> T { result } -pub fn skip_next_timing() { - internal::skip_next_timing(); +pub fn commands() -> Subscription { + internal::commands() } #[cfg(all(feature = "enable", not(target_arch = "wasm32")))] mod internal { - use crate::Primitive; use crate::core::theme; use crate::core::time::Instant; use crate::core::window; + use crate::futures::Subscription; + use crate::futures::futures::Stream; + use crate::{Command, Primitive}; use iced_beacon as beacon; use beacon::client::{self, Client}; use beacon::span; - use std::io; - use std::process; - use std::sync::atomic::{self, AtomicBool}; + use std::sync::atomic::{self, AtomicBool, AtomicUsize}; use std::sync::{LazyLock, RwLock}; pub fn init(name: &str) { @@ -111,25 +125,13 @@ mod internal { name.clone_into(&mut NAME.write().expect("Write application name")); } - pub fn toggle_comet() -> Result<(), io::Error> { + pub fn quit() -> bool { if BEACON.is_connected() { BEACON.quit(); - Ok(()) + true } else { - let _ = process::Command::new("iced_comet") - .stdin(process::Stdio::null()) - .stdout(process::Stdio::null()) - .stderr(process::Stdio::null()) - .spawn()?; - - if let Some(palette) = - LAST_PALETTE.read().expect("Read last palette").as_ref() - { - BEACON.log(client::Event::ThemeChanged(*palette)); - } - - Ok(()) + false } } @@ -141,18 +143,18 @@ mod internal { if LAST_PALETTE.read().expect("Read last palette").as_ref() != Some(&palette) { - BEACON.log(client::Event::ThemeChanged(palette)); + log(client::Event::ThemeChanged(palette)); *LAST_PALETTE.write().expect("Write last palette") = Some(palette); } } pub fn tasks_spawned(amount: usize) { - BEACON.log(client::Event::CommandsSpawned(amount)); + log(client::Event::CommandsSpawned(amount)); } pub fn subscriptions_tracked(amount: usize) { - BEACON.log(client::Event::SubscriptionsTracked(amount)); + log(client::Event::SubscriptionsTracked(amount)); } pub fn boot() -> Span { @@ -162,6 +164,8 @@ mod internal { pub fn update(message: &impl std::fmt::Debug) -> Span { let span = span(span::Stage::Update); + let number = LAST_UPDATE.fetch_add(1, atomic::Ordering::Relaxed); + let start = Instant::now(); let message = format!("{message:?}"); let elapsed = start.elapsed(); @@ -172,11 +176,13 @@ mod internal { ); } - BEACON.log(client::Event::MessageLogged(if message.len() > 49 { + let message = if message.len() > 49 { format!("{}...", &message[..49]) } else { message - })); + }; + + log(client::Event::MessageLogged { number, message }); span } @@ -213,12 +219,35 @@ mod internal { span(span::Stage::Custom(name.into())) } - pub fn skip_next_timing() { - SKIP_NEXT_SPAN.store(true, atomic::Ordering::Relaxed); + pub fn enable() { + ENABLED.store(true, atomic::Ordering::Relaxed); + } + + pub fn disable() { + ENABLED.store(false, atomic::Ordering::Relaxed); + } + + pub fn commands() -> Subscription { + fn listen_for_commands() -> impl Stream { + use crate::futures::futures::stream; + + stream::unfold(BEACON.subscribe(), async move |mut receiver| { + let command = match receiver.recv().await? { + client::Command::RewindTo { message } => { + Command::RewindTo { message } + } + client::Command::GoLive => Command::GoLive, + }; + + Some((command, receiver)) + }) + } + + Subscription::run(listen_for_commands) } fn span(span: span::Stage) -> Span { - BEACON.log(client::Event::SpanStarted(span.clone())); + log(client::Event::SpanStarted(span.clone())); Span { span, @@ -236,6 +265,12 @@ mod internal { } } + fn log(event: client::Event) { + if ENABLED.load(atomic::Ordering::Relaxed) { + BEACON.log(event); + } + } + #[derive(Debug)] pub struct Span { span: span::Stage, @@ -244,14 +279,7 @@ mod internal { impl Span { pub fn finish(self) { - if SKIP_NEXT_SPAN.fetch_and(false, atomic::Ordering::Relaxed) { - return; - } - - BEACON.log(client::Event::SpanFinished( - self.span, - self.start.elapsed(), - )); + log(client::Event::SpanFinished(self.span, self.start.elapsed())); } } @@ -260,22 +288,27 @@ mod internal { }); static NAME: RwLock = RwLock::new(String::new()); + static LAST_UPDATE: AtomicUsize = AtomicUsize::new(0); static LAST_PALETTE: RwLock> = RwLock::new(None); - static SKIP_NEXT_SPAN: AtomicBool = AtomicBool::new(false); + static ENABLED: AtomicBool = AtomicBool::new(true); } #[cfg(any(not(feature = "enable"), target_arch = "wasm32"))] mod internal { - use crate::Primitive; use crate::core::theme; use crate::core::window; + use crate::futures::Subscription; + use crate::{Command, Primitive}; use std::io; + pub fn enable() {} + pub fn disable() {} + pub fn init(_name: &str) {} - pub fn toggle_comet() -> Result<(), io::Error> { - Ok(()) + pub fn quit() -> bool { + false } pub fn theme_changed(_f: impl FnOnce() -> Option) {} @@ -324,7 +357,9 @@ mod internal { Span } - pub fn skip_next_timing() {} + pub fn commands() -> Subscription { + Subscription::none() + } #[derive(Debug)] pub struct Span; diff --git a/devtools/Cargo.toml b/devtools/Cargo.toml index df7e5012..04c3792b 100644 --- a/devtools/Cargo.toml +++ b/devtools/Cargo.toml @@ -13,7 +13,12 @@ rust-version.workspace = true [lints] workspace = true +[features] +time-travel = ["iced_program/time-travel"] + [dependencies] +iced_debug.workspace = true iced_program.workspace = true iced_widget.workspace = true -iced_debug.workspace = true + +log.workspace = true diff --git a/devtools/src/comet.rs b/devtools/src/comet.rs new file mode 100644 index 00000000..2372899f --- /dev/null +++ b/devtools/src/comet.rs @@ -0,0 +1,140 @@ +use crate::executor; +use crate::runtime::Task; + +use std::process; + +pub const COMPATIBLE_REVISION: &str = + "69dd2283886dccdaa1ee6e1c274af62f7250bc38"; + +pub fn launch() -> Task { + executor::try_spawn_blocking(|mut sender| { + let cargo_install = process::Command::new("cargo") + .args(["install", "--list"]) + .output()?; + + let installed_packages = String::from_utf8_lossy(&cargo_install.stdout); + + for line in installed_packages.lines() { + if !line.starts_with("iced_comet ") { + continue; + } + + let Some((_, revision)) = line.rsplit_once("?rev=") else { + return Err(launch::Error::Outdated { revision: None }); + }; + + let Some((revision, _)) = revision.rsplit_once("#") else { + return Err(launch::Error::Outdated { revision: None }); + }; + + if revision != COMPATIBLE_REVISION { + return Err(launch::Error::Outdated { + revision: Some(revision.to_owned()), + }); + } + + let _ = process::Command::new("iced_comet") + .stdin(process::Stdio::null()) + .stdout(process::Stdio::null()) + .stderr(process::Stdio::null()) + .spawn()?; + + let _ = sender.try_send(()); + return Ok(()); + } + + Err(launch::Error::NotFound) + }) +} + +pub fn install() -> Task { + executor::try_spawn_blocking(|mut sender| { + use std::io::{BufRead, BufReader}; + use std::process::{Command, Stdio}; + + let mut install = Command::new("cargo") + .args([ + "install", + "--locked", + "--git", + "https://github.com/iced-rs/comet.git", + "--rev", + COMPATIBLE_REVISION, + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn()?; + + let mut stderr = BufReader::new( + install.stderr.take().expect("stderr must be piped"), + ); + + let mut log = String::new(); + + while let Ok(n) = stderr.read_line(&mut log) { + if n == 0 { + let status = install.wait()?; + + if status.success() { + break; + } else { + return Err(install::Error::ProcessFailed(status)); + } + } + + let _ = sender.try_send(install::Event::Logged(log.clone())); + log.clear(); + } + + let _ = sender.try_send(install::Event::Finished); + + Ok(()) + }) +} + +pub mod launch { + use std::io; + use std::sync::Arc; + + pub type Result = std::result::Result<(), Error>; + + #[derive(Debug, Clone)] + pub enum Error { + NotFound, + Outdated { revision: Option }, + IoFailed(Arc), + } + + impl From for Error { + fn from(error: io::Error) -> Self { + Self::IoFailed(Arc::new(error)) + } + } +} + +pub mod install { + use std::io; + use std::process; + use std::sync::Arc; + + pub type Result = std::result::Result; + + #[derive(Debug, Clone)] + pub enum Event { + Logged(String), + Finished, + } + + #[derive(Debug, Clone)] + pub enum Error { + ProcessFailed(process::ExitStatus), + IoFailed(Arc), + } + + impl From for Error { + fn from(error: io::Error) -> Self { + Self::IoFailed(Arc::new(error)) + } + } +} diff --git a/devtools/src/executor.rs b/devtools/src/executor.rs index 5d7d5397..1e7317a2 100644 --- a/devtools/src/executor.rs +++ b/devtools/src/executor.rs @@ -1,4 +1,6 @@ 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; @@ -17,3 +19,25 @@ where 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 8d19140f..95ac3626 100644 --- a/devtools/src/lib.rs +++ b/devtools/src/lib.rs @@ -6,23 +6,26 @@ use iced_widget::core; use iced_widget::runtime; use iced_widget::runtime::futures; +mod comet; mod executor; +mod time_machine; +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::{Color, Element, Length::Fill}; +use crate::core::{Alignment::Center, Color, Element, Length::Fill}; use crate::futures::Subscription; use crate::program::Program; use crate::runtime::Task; +use crate::time_machine::TimeMachine; use crate::widget::{ bottom_right, button, center, column, container, horizontal_space, opaque, row, scrollable, stack, text, themer, }; use std::fmt; -use std::io; use std::thread; pub fn attach(program: impl Program + 'static) -> impl Program { @@ -115,15 +118,16 @@ where state: P::State, mode: Mode, show_notification: bool, + time_machine: TimeMachine

, } #[derive(Debug, Clone)] enum Message { HideNotification, ToggleComet, + CometLaunched(comet::launch::Result), InstallComet, - InstallationLogged(String), - InstallationFinished, + Installing(comet::install::Result), CancelSetup, } @@ -133,20 +137,26 @@ enum Mode { } enum Setup { - Idle, + Idle { goal: Goal }, Running { logs: Vec }, } +enum Goal { + Installation, + Update { revision: Option }, +} + impl

DevTools

where P: Program + 'static, { - pub fn new(state: P::State) -> (Self, Task) { + fn new(state: P::State) -> (Self, Task) { ( Self { state, mode: Mode::None, show_notification: true, + time_machine: TimeMachine::new(), }, executor::spawn_blocking(|mut sender| { thread::sleep(seconds(2)); @@ -156,11 +166,11 @@ where ) } - pub fn title(&self, program: &P, window: window::Id) -> String { + fn title(&self, program: &P, window: window::Id) -> String { program.title(&self.state, window) } - pub fn update(&mut self, program: &P, event: Event

) -> Task> { + fn update(&mut self, program: &P, event: Event

) -> Task> { match event { Event::Message(message) => match message { Message::HideNotification => { @@ -170,12 +180,34 @@ where } Message::ToggleComet => { if let Mode::Setup(setup) = &self.mode { - if matches!(setup, Setup::Idle) { + if matches!(setup, Setup::Idle { .. }) { self.mode = Mode::None; } - } else if let Err(error) = debug::toggle_comet() { - if error.kind() == io::ErrorKind::NotFound { - self.mode = Mode::Setup(Setup::Idle); + + Task::none() + } else if debug::quit() { + Task::none() + } else { + comet::launch() + .map(Message::CometLaunched) + .map(Event::Message) + } + } + Message::CometLaunched(Ok(())) => Task::none(), + Message::CometLaunched(Err(error)) => { + match error { + comet::launch::Error::NotFound => { + self.mode = Mode::Setup(Setup::Idle { + goal: Goal::Installation, + }); + } + comet::launch::Error::Outdated { revision } => { + self.mode = Mode::Setup(Setup::Idle { + goal: Goal::Update { revision }, + }); + } + comet::launch::Error::IoFailed(error) => { + log::error!("comet failed to run: {error}"); } } @@ -185,61 +217,42 @@ where self.mode = Mode::Setup(Setup::Running { logs: Vec::new() }); - executor::spawn_blocking(|mut sender| { - use std::io::{BufRead, BufReader}; - use std::process::{Command, Stdio}; + comet::install() + .map(Message::Installing) + .map(Event::Message) + } - let Ok(install) = Command::new("cargo") - .args([ - "install", - "--locked", - "--git", - "https://github.com/iced-rs/comet.git", - "--rev", - "eb114ba564a872acbd95e337d13e55f5f667b2f3", - ]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - else { - return; - }; + Message::Installing(Ok(installation)) => { + let Mode::Setup(Setup::Running { logs }) = &mut self.mode + else { + return Task::none(); + }; - let mut stderr = BufReader::new( - install.stderr.expect("stderr must be piped"), - ); - - let mut log = String::new(); - - while let Ok(n) = stderr.read_line(&mut log) { - if n == 0 { - break; - } - - let _ = sender.try_send( - Message::InstallationLogged(log.clone()), - ); - - log.clear(); + match installation { + comet::install::Event::Logged(log) => { + logs.push(log); + Task::none() + } + comet::install::Event::Finished => { + self.mode = Mode::None; + comet::launch().discard() } - - let _ = sender.try_send(Message::InstallationFinished); - }) - .map(Event::Message) - } - Message::InstallationLogged(log) => { - if let Mode::Setup(Setup::Running { logs }) = &mut self.mode - { - logs.push(log); } - - Task::none() } - Message::InstallationFinished => { - self.mode = Mode::None; + Message::Installing(Err(error)) => { + let Mode::Setup(Setup::Running { logs }) = &mut self.mode + else { + return Task::none(); + }; - let _ = debug::toggle_comet(); + match error { + comet::install::Error::ProcessFailed(status) => { + logs.push(format!("process failed with {status}")); + } + comet::install::Error::IoFailed(error) => { + logs.push(error.to_string()); + } + } Task::none() } @@ -250,23 +263,62 @@ where } }, Event::Program(message) => { - program.update(&mut self.state, message).map(Event::Program) + self.time_machine.push(&message); + + if self.time_machine.is_rewinding() { + debug::enable(); + } + + let span = debug::update(&message); + let task = program.update(&mut self.state, message); + debug::tasks_spawned(task.units()); + span.finish(); + + if self.time_machine.is_rewinding() { + debug::disable(); + } + + task.map(Event::Program) } + Event::Command(command) => { + match command { + debug::Command::RewindTo { message } => { + self.time_machine.rewind(program, message); + } + debug::Command::GoLive => { + self.time_machine.go_to_present(); + } + } + + Task::none() + } + Event::Discard => Task::none(), } } - pub fn view( + fn view( &self, program: &P, window: window::Id, ) -> Element<'_, Event

, P::Theme, P::Renderer> { - let view = program.view(&self.state, window).map(Event::Program); - let theme = program.theme(&self.state, window); + let state = self.state(); + + let view = { + let view = program.view(state, window); + + if self.time_machine.is_rewinding() { + view.map(|_| Event::Discard) + } else { + view.map(Event::Program) + } + }; + + let theme = program.theme(state, window); let derive_theme = move || { theme .palette() - .map(|palette| Theme::custom("DevTools".to_owned(), palette)) + .map(|palette| Theme::custom("iced devtools", palette)) .unwrap_or_default() }; @@ -274,68 +326,14 @@ where Mode::None => None, Mode::Setup(setup) => { let stage: Element<'_, _, Theme, P::Renderer> = match setup { - Setup::Idle => { - let controls = row![ - button(text("Cancel").center().width(Fill)) - .width(100) - .on_press(Message::CancelSetup) - .style(button::danger), - horizontal_space(), - button(text("Install").center().width(Fill)) - .width(100) - .on_press(Message::InstallComet) - .style(button::success), - ]; - - column![ - text("comet is not installed!").size(20), - "In order to display performance metrics, the \ - comet debugger must be installed in your system.", - "The comet debugger is an official companion tool \ - that helps you debug your iced applications.", - "Do you wish to install it with the following \ - command?", - container( - text( - "cargo install --locked \ - --git https://github.com/iced-rs/comet.git" - ) - .size(14) - ) - .width(Fill) - .padding(5) - .style(container::dark), - controls, - ] - .spacing(20) - .into() - } - Setup::Running { logs } => column![ - text("Installing comet...").size(20), - container( - scrollable( - column( - logs.iter() - .map(|log| text(log).size(12).into()), - ) - .spacing(3), - ) - .spacing(10) - .width(Fill) - .height(300) - .anchor_bottom(), - ) - .padding(10) - .style(container::dark) - ] - .spacing(20) - .into(), + Setup::Idle { goal } => self::setup(goal), + Setup::Running { logs } => installation(logs), }; let setup = center( container(stage) .padding(20) - .width(500) + .max_width(500) .style(container::bordered_box), ) .padding(10) @@ -363,14 +361,16 @@ where }); stack![view] + .height(Fill) .push_maybe(mode.map(opaque)) .push_maybe(notification) .into() } - pub fn subscription(&self, program: &P) -> Subscription> { + fn subscription(&self, program: &P) -> Subscription> { let subscription = program.subscription(&self.state).map(Event::Program); + debug::subscriptions_tracked(subscription.units()); let hotkeys = futures::keyboard::on_key_press(|key, _modifiers| match key { @@ -381,19 +381,25 @@ where }) .map(Event::Message); - Subscription::batch([subscription, hotkeys]) + let commands = debug::commands().map(Event::Command); + + Subscription::batch([subscription, hotkeys, commands]) } - pub fn theme(&self, program: &P, window: window::Id) -> P::Theme { - program.theme(&self.state, window) + fn theme(&self, program: &P, window: window::Id) -> P::Theme { + program.theme(self.state(), window) } - pub fn style(&self, program: &P, theme: &P::Theme) -> theme::Style { - program.style(&self.state, theme) + fn style(&self, program: &P, theme: &P::Theme) -> theme::Style { + program.style(self.state(), theme) } - pub fn scale_factor(&self, program: &P, window: window::Id) -> f64 { - program.scale_factor(&self.state, window) + fn scale_factor(&self, program: &P, window: window::Id) -> f64 { + program.scale_factor(self.state(), window) + } + + fn state(&self) -> &P::State { + self.time_machine.state().unwrap_or(&self.state) } } @@ -403,6 +409,8 @@ where { Message(Message), Program(P::Message), + Command(debug::Command), + Discard, } impl

fmt::Debug for Event

@@ -413,6 +421,154 @@ where match self { Self::Message(message) => message.fmt(f), Self::Program(message) => message.fmt(f), + 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::Discard => Self::Discard, + } + } +} + +fn setup(goal: &Goal) -> Element<'_, Message, Theme, Renderer> +where + Renderer: core::text::Renderer + 'static, +{ + let controls = row![ + button(text("Cancel").center().width(Fill)) + .width(100) + .on_press(Message::CancelSetup) + .style(button::danger), + horizontal_space(), + button( + text(match goal { + Goal::Installation => "Install", + Goal::Update { .. } => "Update", + }) + .center() + .width(Fill) + ) + .width(100) + .on_press(Message::InstallComet) + .style(button::success), + ]; + + let command = container( + text!( + "cargo install --locked \\ + --git https://github.com/iced-rs/comet.git \\ + --rev {}", + comet::COMPATIBLE_REVISION + ) + .size(14) + .font(Renderer::MONOSPACE_FONT), + ) + .width(Fill) + .padding(5) + .style(container::dark); + + Element::from(match goal { + Goal::Installation => column![ + text("comet is not installed!").size(20), + "In order to display performance \ + metrics, the comet debugger must \ + be installed in your system.", + "The comet debugger is an official \ + companion tool that helps you debug \ + your iced applications.", + column![ + "Do you wish to install it with the \ + following command?", + command + ] + .spacing(10), + controls, + ] + .spacing(20), + Goal::Update { revision } => { + let comparison = column![ + row![ + "Installed revision:", + horizontal_space(), + inline_code(revision.as_deref().unwrap_or("Unknown")) + ] + .align_y(Center), + row![ + "Compatible revision:", + horizontal_space(), + inline_code(comet::COMPATIBLE_REVISION), + ] + .align_y(Center) + ] + .spacing(5); + + column![ + text("comet is out of date!").size(20), + comparison, + column![ + "Do you wish to update it with the following \ + command?", + command + ] + .spacing(10), + controls, + ] + .spacing(20) + } + }) +} + +fn installation<'a, Renderer>( + logs: &'a [String], +) -> Element<'a, Message, Theme, Renderer> +where + Renderer: core::text::Renderer + 'a, +{ + column![ + text("Installing comet...").size(20), + container( + scrollable( + column(logs.iter().map(|log| { + text(log).size(12).font(Renderer::MONOSPACE_FONT).into() + }),) + .spacing(3), + ) + .spacing(10) + .width(Fill) + .height(300) + .anchor_bottom(), + ) + .padding(10) + .style(container::dark) + ] + .spacing(20) + .into() +} + +fn inline_code<'a, Renderer>( + code: impl text::IntoFragment<'a>, +) -> Element<'a, Message, Theme, Renderer> +where + Renderer: core::text::Renderer + 'a, +{ + container(text(code).font(Renderer::MONOSPACE_FONT).size(12)) + .style(|_theme| { + container::Style::default() + .background(Color::BLACK) + .border(border::rounded(2)) + }) + .padding([2, 4]) + .into() +} diff --git a/devtools/src/time_machine.rs b/devtools/src/time_machine.rs new file mode 100644 index 00000000..6d8b80ae --- /dev/null +++ b/devtools/src/time_machine.rs @@ -0,0 +1,88 @@ +use crate::Program; + +#[cfg(feature = "time-travel")] +pub struct TimeMachine

+where + P: Program, +{ + state: Option, + messages: Vec, +} + +#[cfg(feature = "time-travel")] +impl

TimeMachine

+where + P: Program, +{ + pub fn new() -> Self { + Self { + state: None, + messages: Vec::new(), + } + } + + pub fn is_rewinding(&self) -> bool { + self.state.is_some() + } + + pub fn push(&mut self, message: &P::Message) { + self.messages.push(message.clone()); + } + + pub fn rewind(&mut self, program: &P, message: usize) { + let (mut state, _) = program.boot(); + + if message < self.messages.len() { + // TODO: Run concurrently (?) + for message in &self.messages[0..message] { + let _ = program.update(&mut state, message.clone()); + } + } + + self.state = Some(state); + crate::debug::disable(); + } + + pub fn go_to_present(&mut self) { + self.state = None; + crate::debug::enable(); + } + + pub fn state(&self) -> Option<&P::State> { + self.state.as_ref() + } +} + +#[cfg(not(feature = "time-travel"))] +pub struct TimeMachine

+where + P: Program, +{ + _program: std::marker::PhantomData

, +} + +#[cfg(not(feature = "time-travel"))] +impl

TimeMachine

+where + P: Program, +{ + pub fn new() -> Self { + Self { + _program: std::marker::PhantomData, + } + } + + pub fn is_rewinding(&self) -> bool { + false + } + + pub fn push(&mut self, _message: &P::Message) {} + + pub fn rewind(&mut self, _program: &P, _message: usize) {} + + pub fn go_to_present(&mut self) {} + + pub fn state(&self) -> Option<&P::State> { + None + } +} diff --git a/examples/arc/src/main.rs b/examples/arc/src/main.rs index b83a36d8..3e80a5db 100644 --- a/examples/arc/src/main.rs +++ b/examples/arc/src/main.rs @@ -8,7 +8,7 @@ use iced::window; use iced::{Element, Fill, Point, Rectangle, Renderer, Subscription, Theme}; pub fn main() -> iced::Result { - iced::application(Arc::default, Arc::update, Arc::view) + iced::application(Arc::new, Arc::update, Arc::view) .subscription(Arc::subscription) .theme(|_| Theme::Dark) .run() @@ -25,6 +25,13 @@ enum Message { } impl Arc { + fn new() -> Self { + Arc { + start: Instant::now(), + cache: Cache::default(), + } + } + fn update(&mut self, _: Message) { self.cache.clear(); } @@ -38,15 +45,6 @@ impl Arc { } } -impl Default for Arc { - fn default() -> Self { - Arc { - start: Instant::now(), - cache: Cache::default(), - } - } -} - impl canvas::Program for Arc { type State = (); diff --git a/examples/clock/src/main.rs b/examples/clock/src/main.rs index f5f68eb4..b7811653 100644 --- a/examples/clock/src/main.rs +++ b/examples/clock/src/main.rs @@ -11,7 +11,7 @@ use iced::{ pub fn main() -> iced::Result { tracing_subscriber::fmt::init(); - iced::application(Clock::default, Clock::update, Clock::view) + iced::application(Clock::new, Clock::update, Clock::view) .subscription(Clock::subscription) .theme(Clock::theme) .run() @@ -28,6 +28,13 @@ enum Message { } impl Clock { + fn new() -> Self { + Self { + now: chrono::offset::Local::now(), + clock: Cache::default(), + } + } + fn update(&mut self, message: Message) { match message { Message::Tick(local_time) => { @@ -58,15 +65,6 @@ impl Clock { } } -impl Default for Clock { - fn default() -> Self { - Self { - now: chrono::offset::Local::now(), - clock: Cache::default(), - } - } -} - impl canvas::Program for Clock { type State = (); diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 709771c1..bbf4ac7d 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -21,14 +21,15 @@ use iced::{ use std::collections::HashMap; fn main() -> iced::Result { - iced::application(Gallery::new, Gallery::update, Gallery::view) - .window_size(( - Preview::WIDTH as f32 * 4.0, - Preview::HEIGHT as f32 * 2.5, - )) - .subscription(Gallery::subscription) - .theme(Gallery::theme) - .run() + iced::application::timed( + Gallery::new, + Gallery::update, + Gallery::subscription, + Gallery::view, + ) + .window_size((Preview::WIDTH as f32 * 4.0, Preview::HEIGHT as f32 * 2.5)) + .theme(Gallery::theme) + .run() } struct Gallery { @@ -48,7 +49,7 @@ enum Message { BlurhashDecoded(Id, civitai::Blurhash), Open(Id), Close, - Animate(Instant), + Animate, } impl Gallery { @@ -76,13 +77,15 @@ impl Gallery { || self.viewer.is_animating(self.now); if is_animating { - window::frames().map(Message::Animate) + window::frames().map(|_| Message::Animate) } else { Subscription::none() } } - pub fn update(&mut self, message: Message) -> Task { + pub fn update(&mut self, message: Message, now: Instant) -> Task { + self.now = now; + match message { Message::ImagesListed(Ok(images)) => { self.images = images; @@ -109,16 +112,16 @@ impl Gallery { ) } Message::ImageDownloaded(Ok(rgba)) => { - self.viewer.show(rgba); + self.viewer.show(rgba, self.now); Task::none() } Message::ThumbnailDownloaded(id, Ok(rgba)) => { let thumbnail = if let Some(preview) = self.previews.remove(&id) { - preview.load(rgba) + preview.load(rgba, self.now) } else { - Preview::ready(rgba) + Preview::ready(rgba, self.now) }; let _ = self.previews.insert(id, thumbnail); @@ -127,7 +130,7 @@ impl Gallery { } Message::ThumbnailHovered(id, is_hovered) => { if let Some(preview) = self.previews.get_mut(&id) { - preview.toggle_zoom(is_hovered); + preview.toggle_zoom(is_hovered, self.now); } Task::none() @@ -136,7 +139,7 @@ impl Gallery { if !self.previews.contains_key(&id) { let _ = self .previews - .insert(id, Preview::loading(blurhash.rgba)); + .insert(id, Preview::loading(blurhash.rgba, self.now)); } Task::none() @@ -151,7 +154,7 @@ impl Gallery { return Task::none(); }; - self.viewer.open(); + self.viewer.open(self.now); Task::perform( image.download(Size::Original), @@ -159,15 +162,11 @@ impl Gallery { ) } Message::Close => { - self.viewer.close(); - - Task::none() - } - Message::Animate(now) => { - self.now = now; + self.viewer.close(self.now); Task::none() } + Message::Animate => Task::none(), Message::ImagesListed(Err(error)) | Message::ImageDownloaded(Err(error)) | Message::ThumbnailDownloaded(_, Err(error)) => { @@ -293,13 +292,13 @@ impl Preview { const WIDTH: u32 = 320; const HEIGHT: u32 = 410; - fn loading(rgba: Rgba) -> Self { + fn loading(rgba: Rgba, now: Instant) -> Self { Self::Loading { blurhash: Blurhash { fade_in: Animation::new(false) .duration(milliseconds(700)) .easing(animation::Easing::EaseIn) - .go(true), + .go(true, now), handle: image::Handle::from_rgba( rgba.width, rgba.height, @@ -309,27 +308,27 @@ impl Preview { } } - fn ready(rgba: Rgba) -> Self { + fn ready(rgba: Rgba, now: Instant) -> Self { Self::Ready { blurhash: None, - thumbnail: Thumbnail::new(rgba), + thumbnail: Thumbnail::new(rgba, now), } } - fn load(self, rgba: Rgba) -> Self { + fn load(self, rgba: Rgba, now: Instant) -> Self { let Self::Loading { blurhash } = self else { return self; }; Self::Ready { blurhash: Some(blurhash), - thumbnail: Thumbnail::new(rgba), + thumbnail: Thumbnail::new(rgba, now), } } - fn toggle_zoom(&mut self, enabled: bool) { + fn toggle_zoom(&mut self, enabled: bool, now: Instant) { if let Self::Ready { thumbnail, .. } = self { - thumbnail.zoom.go_mut(enabled); + thumbnail.zoom.go_mut(enabled, now); } } @@ -357,14 +356,14 @@ impl Preview { } impl Thumbnail { - pub fn new(rgba: Rgba) -> Self { + pub fn new(rgba: Rgba, now: Instant) -> Self { Self { handle: image::Handle::from_rgba( rgba.width, rgba.height, rgba.pixels, ), - fade_in: Animation::new(false).slow().go(true), + fade_in: Animation::new(false).slow().go(true, now), zoom: Animation::new(false) .quick() .easing(animation::Easing::EaseInOut), @@ -391,24 +390,24 @@ impl Viewer { } } - fn open(&mut self) { + fn open(&mut self, now: Instant) { self.image = None; - self.background_fade_in.go_mut(true); + self.background_fade_in.go_mut(true, now); } - fn show(&mut self, rgba: Rgba) { + fn show(&mut self, rgba: Rgba, now: Instant) { self.image = Some(image::Handle::from_rgba( rgba.width, rgba.height, rgba.pixels, )); - self.background_fade_in.go_mut(true); - self.image_fade_in.go_mut(true); + self.background_fade_in.go_mut(true, now); + self.image_fade_in.go_mut(true, now); } - fn close(&mut self) { - self.background_fade_in.go_mut(false); - self.image_fade_in.go_mut(false); + fn close(&mut self, now: Instant) { + self.background_fade_in.go_mut(false, now); + self.image_fade_in.go_mut(false, now); } fn is_animating(&self, now: Instant) -> bool { diff --git a/examples/markdown/src/main.rs b/examples/markdown/src/main.rs index 99eb9ef8..d9a23b1b 100644 --- a/examples/markdown/src/main.rs +++ b/examples/markdown/src/main.rs @@ -18,11 +18,15 @@ use std::io; use std::sync::Arc; pub fn main() -> iced::Result { - iced::application(Markdown::new, Markdown::update, Markdown::view) - .font(icon::FONT) - .subscription(Markdown::subscription) - .theme(Markdown::theme) - .run() + iced::application::timed( + Markdown::new, + Markdown::update, + Markdown::subscription, + Markdown::view, + ) + .font(icon::FONT) + .theme(Markdown::theme) + .run() } struct Markdown { @@ -58,7 +62,7 @@ enum Message { ImageDownloaded(markdown::Url, Result), ToggleStream(bool), NextToken, - Animate(Instant), + Tick, } impl Markdown { @@ -78,7 +82,9 @@ impl Markdown { ) } - fn update(&mut self, message: Message) -> Task { + fn update(&mut self, message: Message, now: Instant) -> Task { + self.now = now; + match message { Message::Edit(action) => { let is_edit = action.is_edit(); @@ -119,7 +125,7 @@ impl Markdown { fade_in: Animation::new(false) .quick() .easing(animation::Easing::EaseInOut) - .go(true), + .go(true, self.now), }) .unwrap_or_else(Image::Errored), ); @@ -164,11 +170,7 @@ impl Markdown { Task::none() } - Message::Animate(now) => { - self.now = now; - - Task::none() - } + Message::Tick => Task::none(), } } @@ -230,7 +232,7 @@ impl Markdown { }); if is_animating { - window::frames().map(Message::Animate) + window::frames().map(|_| Message::Tick) } else { Subscription::none() } diff --git a/examples/multitouch/src/main.rs b/examples/multitouch/src/main.rs index 4f22f552..0db1c09a 100644 --- a/examples/multitouch/src/main.rs +++ b/examples/multitouch/src/main.rs @@ -23,7 +23,7 @@ struct Multitouch { fingers: HashMap, } -#[derive(Debug)] +#[derive(Debug, Clone)] enum Message { FingerPressed { id: touch::Finger, position: Point }, FingerLifted { id: touch::Finger }, diff --git a/examples/solar_system/src/main.rs b/examples/solar_system/src/main.rs index 07450309..90545e66 100644 --- a/examples/solar_system/src/main.rs +++ b/examples/solar_system/src/main.rs @@ -21,31 +21,36 @@ use std::time::Instant; pub fn main() -> iced::Result { tracing_subscriber::fmt::init(); - iced::application( - SolarSystem::default, + iced::application::timed( + SolarSystem::new, SolarSystem::update, + SolarSystem::subscription, SolarSystem::view, ) - .subscription(SolarSystem::subscription) .theme(SolarSystem::theme) .run() } -#[derive(Default)] struct SolarSystem { state: State, } #[derive(Debug, Clone, Copy)] enum Message { - Tick(Instant), + Tick, } impl SolarSystem { - fn update(&mut self, message: Message) { + fn new() -> Self { + Self { + state: State::new(), + } + } + + fn update(&mut self, message: Message, now: Instant) { match message { - Message::Tick(instant) => { - self.state.update(instant); + Message::Tick => { + self.state.update(now); } } } @@ -59,7 +64,7 @@ impl SolarSystem { } fn subscription(&self) -> Subscription { - window::frames().map(Message::Tick) + window::frames().map(|_| Message::Tick) } } @@ -105,6 +110,7 @@ impl State { } pub fn update(&mut self, now: Instant) { + self.start = self.start.min(now); self.now = now; self.system_cache.clear(); } @@ -202,9 +208,3 @@ impl canvas::Program for State { vec![background, system] } } - -impl Default for State { - fn default() -> Self { - Self::new() - } -} diff --git a/examples/the_matrix/src/main.rs b/examples/the_matrix/src/main.rs index ee10b0bc..2f520c27 100644 --- a/examples/the_matrix/src/main.rs +++ b/examples/the_matrix/src/main.rs @@ -1,5 +1,5 @@ use iced::mouse; -use iced::time::{self, Instant, milliseconds}; +use iced::time::{self, milliseconds}; use iced::widget::canvas; use iced::{ Color, Element, Fill, Font, Point, Rectangle, Renderer, Subscription, Theme, @@ -22,13 +22,13 @@ struct TheMatrix { #[derive(Debug, Clone, Copy)] enum Message { - Tick(Instant), + Tick, } impl TheMatrix { fn update(&mut self, message: Message) { match message { - Message::Tick(_now) => { + Message::Tick => { self.tick += 1; } } @@ -39,7 +39,7 @@ impl TheMatrix { } fn subscription(&self) -> Subscription { - time::every(milliseconds(50)).map(Message::Tick) + time::every(milliseconds(50)).map(|_| Message::Tick) } } diff --git a/examples/todos/Cargo.toml b/examples/todos/Cargo.toml index fd3433e6..5e16a2ac 100644 --- a/examples/todos/Cargo.toml +++ b/examples/todos/Cargo.toml @@ -7,7 +7,7 @@ publish = false [dependencies] iced.workspace = true -iced.features = ["tokio", "debug"] +iced.features = ["tokio", "debug", "time-travel"] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/futures/src/subscription.rs b/futures/src/subscription.rs index f799d5f8..e347e81f 100644 --- a/futures/src/subscription.rs +++ b/futures/src/subscription.rs @@ -285,6 +285,11 @@ impl Subscription { .collect(), } } + + /// Returns the amount of recipe units in this [`Subscription`]. + pub fn units(&self) -> usize { + self.recipes.len() + } } /// Creates a [`Subscription`] from a [`Recipe`] describing it. diff --git a/program/Cargo.toml b/program/Cargo.toml index 07880705..7aa6414d 100644 --- a/program/Cargo.toml +++ b/program/Cargo.toml @@ -10,9 +10,12 @@ categories.workspace = true keywords.workspace = true rust-version.workspace = true +[lints] +workspace = true + +[features] +time-travel = [] + [dependencies] iced_graphics.workspace = true iced_runtime.workspace = true - -[lints] -workspace = true diff --git a/program/src/lib.rs b/program/src/lib.rs index 7e5757de..e25cdb22 100644 --- a/program/src/lib.rs +++ b/program/src/lib.rs @@ -22,7 +22,7 @@ pub trait Program: Sized { type State; /// The message of the program. - type Message: Send + std::fmt::Debug + 'static; + type Message: Message + 'static; /// The theme of the program. type Theme: Default + theme::Base; @@ -642,3 +642,17 @@ 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/renderer/src/fallback.rs b/renderer/src/fallback.rs index 4cea1a15..7223f06f 100644 --- a/renderer/src/fallback.rs +++ b/renderer/src/fallback.rs @@ -84,6 +84,7 @@ where type Paragraph = A::Paragraph; type Editor = A::Editor; + const MONOSPACE_FONT: Self::Font = A::MONOSPACE_FONT; const ICON_FONT: Self::Font = A::ICON_FONT; const CHECKMARK_ICON: char = A::CHECKMARK_ICON; const ARROW_DOWN_ICON: char = A::ARROW_DOWN_ICON; diff --git a/src/application.rs b/src/application.rs index 09dd2647..a1846333 100644 --- a/src/application.rs +++ b/src/application.rs @@ -40,6 +40,10 @@ use crate::{ use std::borrow::Cow; +pub mod timed; + +pub use timed::timed; + /// Creates an iced [`Application`] given its boot, update, and view logic. /// /// # Example @@ -75,7 +79,7 @@ pub fn application( ) -> Application> where State: 'static, - Message: Send + std::fmt::Debug + 'static, + Message: program::Message + 'static, Theme: Default + theme::Base, Renderer: program::Renderer, { @@ -94,7 +98,7 @@ where impl Program for Instance where - Message: Send + std::fmt::Debug + 'static, + Message: program::Message + 'static, Theme: Default + theme::Base, Renderer: program::Renderer, Boot: self::Boot, diff --git a/src/application/timed.rs b/src/application/timed.rs new file mode 100644 index 00000000..e4a1d78f --- /dev/null +++ b/src/application/timed.rs @@ -0,0 +1,178 @@ +//! An [`Application`] that receives an [`Instant`] in update logic. +use crate::application::{Application, Boot, View}; +use crate::program; +use crate::theme; +use crate::time::Instant; +use crate::window; +use crate::{Element, Program, Settings, Subscription, Task}; + +/// Creates an [`Application`] with an `update` function that also +/// takes the [`Instant`] of each `Message`. +/// +/// This constructor is useful to create animated applications that +/// are _pure_ (e.g. without relying on side-effect calls like [`Instant::now`]). +/// +/// Purity is needed when you want your application to end up in the +/// same exact state given the same history of messages. This property +/// enables proper time traveling debugging with [`comet`]. +/// +/// [`comet`]: https://github.com/iced-rs/comet +pub fn timed( + boot: impl Boot, + update: impl Update, + subscription: impl Fn(&State) -> Subscription, + view: impl for<'a> View<'a, State, Message, Theme, Renderer>, +) -> Application< + impl Program, +> +where + State: 'static, + Message: program::Message + 'static, + Theme: Default + theme::Base + 'static, + Renderer: program::Renderer + 'static, +{ + use std::marker::PhantomData; + + struct Instance< + State, + Message, + Theme, + Renderer, + Boot, + Update, + Subscription, + View, + > { + boot: Boot, + update: Update, + subscription: Subscription, + view: View, + _state: PhantomData, + _message: PhantomData, + _theme: PhantomData, + _renderer: PhantomData, + } + + impl + Program + for Instance< + State, + Message, + Theme, + Renderer, + Boot, + Update, + Subscription, + View, + > + where + Message: program::Message + 'static, + Theme: Default + theme::Base + 'static, + Renderer: program::Renderer + 'static, + Boot: self::Boot, + Update: self::Update, + Subscription: Fn(&State) -> self::Subscription, + View: for<'a> self::View<'a, State, Message, Theme, Renderer>, + { + type State = State; + type Message = (Message, Instant); + type Theme = Theme; + type Renderer = Renderer; + type Executor = iced_futures::backend::default::Executor; + + fn name() -> &'static str { + let name = std::any::type_name::(); + + name.split("::").next().unwrap_or("a_cool_application") + } + + fn boot(&self) -> (State, Task) { + let (state, task) = self.boot.boot(); + + (state, task.map(|message| (message, Instant::now()))) + } + + fn update( + &self, + state: &mut Self::State, + (message, now): Self::Message, + ) -> Task { + self.update + .update(state, message, now) + .into() + .map(|message| (message, Instant::now())) + } + + fn view<'a>( + &self, + state: &'a Self::State, + _window: window::Id, + ) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> { + self.view + .view(state) + .into() + .map(|message| (message, Instant::now())) + } + + fn subscription( + &self, + state: &Self::State, + ) -> self::Subscription { + (self.subscription)(state).map(|message| (message, Instant::now())) + } + } + + Application { + raw: Instance { + boot, + update, + subscription, + view, + _state: PhantomData, + _message: PhantomData, + _theme: PhantomData, + _renderer: PhantomData, + }, + settings: Settings::default(), + window: window::Settings::default(), + } +} + +/// The update logic of some timed [`Application`]. +/// +/// This is like [`application::Update`](super::Update), +/// but it also takes an [`Instant`]. +pub trait Update { + /// Processes the message and updates the state of the [`Application`]. + fn update( + &self, + state: &mut State, + message: Message, + now: Instant, + ) -> impl Into>; +} + +impl Update for () { + fn update( + &self, + _state: &mut State, + _message: Message, + _now: Instant, + ) -> impl Into> { + } +} + +impl Update for T +where + T: Fn(&mut State, Message, Instant) -> C, + C: Into>, +{ + fn update( + &self, + state: &mut State, + message: Message, + now: Instant, + ) -> impl Into> { + self(state, message, now) + } +} diff --git a/src/daemon.rs b/src/daemon.rs index 8a356356..80271e73 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -25,7 +25,7 @@ pub fn daemon( ) -> Daemon> where State: 'static, - Message: Send + std::fmt::Debug + 'static, + Message: program::Message + 'static, Theme: Default + theme::Base, Renderer: program::Renderer, { @@ -44,7 +44,7 @@ where impl Program for Instance where - Message: Send + std::fmt::Debug + 'static, + Message: program::Message + 'static, Theme: Default + theme::Base, Renderer: program::Renderer, Boot: application::Boot, diff --git a/src/lib.rs b/src/lib.rs index b98dc4b9..e9ca3a04 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -343,7 +343,7 @@ //! use iced::window; //! use iced::{Size, Subscription}; //! -//! #[derive(Debug)] +//! #[derive(Debug, Clone)] //! enum Message { //! WindowResized(Size), //! } @@ -387,7 +387,7 @@ //! # pub fn update(&mut self, message: Message) -> Action { unimplemented!() } //! # pub fn view(&self) -> Element { unimplemented!() } //! # } -//! # #[derive(Debug)] +//! # #[derive(Debug, Clone)] //! # pub enum Message {} //! # pub enum Action { None, Run(Task), Chat(()) } //! # } @@ -399,7 +399,7 @@ //! # pub fn update(&mut self, message: Message) -> Task { unimplemented!() } //! # pub fn view(&self) -> Element { unimplemented!() } //! # } -//! # #[derive(Debug)] +//! # #[derive(Debug, Clone)] //! # pub enum Message {} //! # } //! use contacts::Contacts; @@ -535,7 +535,7 @@ pub use alignment::Vertical::{Bottom, Top}; pub mod debug { //! Debug your applications. - pub use iced_debug::{Span, skip_next_timing, time, time_with}; + pub use iced_debug::{Span, time, time_with}; } pub mod task { @@ -697,7 +697,7 @@ pub fn run( ) -> Result where State: Default + 'static, - Message: std::fmt::Debug + Send + 'static, + Message: program::Message + 'static, Theme: Default + theme::Base + 'static, Renderer: program::Renderer + 'static, { diff --git a/tiny_skia/src/lib.rs b/tiny_skia/src/lib.rs index a222e23c..8f343277 100644 --- a/tiny_skia/src/lib.rs +++ b/tiny_skia/src/lib.rs @@ -235,6 +235,7 @@ impl core::text::Renderer for Renderer { type Paragraph = Paragraph; type Editor = Editor; + const MONOSPACE_FONT: Font = Font::MONOSPACE; const ICON_FONT: Font = Font::with_name("Iced-Icons"); const CHECKMARK_ICON: char = '\u{f00c}'; const ARROW_DOWN_ICON: char = '\u{e800}'; diff --git a/wgpu/src/lib.rs b/wgpu/src/lib.rs index 3fe2fbbe..94ab81c4 100644 --- a/wgpu/src/lib.rs +++ b/wgpu/src/lib.rs @@ -643,6 +643,7 @@ impl core::text::Renderer for Renderer { type Paragraph = Paragraph; type Editor = Editor; + const MONOSPACE_FONT: Font = Font::MONOSPACE; const ICON_FONT: Font = Font::with_name("Iced-Icons"); const CHECKMARK_ICON: char = '\u{f00c}'; const ARROW_DOWN_ICON: char = '\u{e800}'; diff --git a/winit/src/lib.rs b/winit/src/lib.rs index 23bcc091..814d4f58 100644 --- a/winit/src/lib.rs +++ b/winit/src/lib.rs @@ -1069,10 +1069,7 @@ fn update( P::Theme: theme::Base, { for message in messages.drain(..) { - let update_span = debug::update(&message); let task = runtime.enter(|| program.update(message)); - debug::tasks_spawned(task.units()); - update_span.finish(); if let Some(stream) = runtime::task::into_stream(task) { runtime.run(stream); @@ -1082,7 +1079,6 @@ fn update( let subscription = runtime.enter(|| program.subscription()); let recipes = subscription::into_recipes(subscription.map(Action::Output)); - debug::subscriptions_tracked(recipes.len()); runtime.track(recipes); }