Merge pull request #3000 from iced-rs/feature/hot-reloading

Hot Reloading
This commit is contained in:
Héctor 2025-06-24 17:48:48 +02:00 committed by GitHub
commit c952ea8485
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 297 additions and 45 deletions

85
Cargo.lock generated
View file

@ -519,6 +519,26 @@ dependencies = [
"serde",
]
[[package]]
name = "bincode"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740"
dependencies = [
"bincode_derive",
"serde",
"unty",
]
[[package]]
name = "bincode_derive"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09"
dependencies = [
"virtue",
]
[[package]]
name = "bit-set"
version = "0.8.0"
@ -702,6 +722,17 @@ dependencies = [
"wayland-client",
]
[[package]]
name = "cargo-hot-protocol"
version = "0.1.0"
source = "git+https://github.com/hecrj/cargo-hot.git?rev=b8dc518b8640928178a501257e353b73bc06cf47#b8dc518b8640928178a501257e353b73bc06cf47"
dependencies = [
"anyhow",
"bincode 2.0.1",
"log",
"subsecond",
]
[[package]]
name = "cast"
version = "0.3.0"
@ -2419,7 +2450,7 @@ dependencies = [
name = "iced_beacon"
version = "0.14.0-dev"
dependencies = [
"bincode",
"bincode 1.3.3",
"futures",
"iced_core",
"log",
@ -2451,6 +2482,7 @@ dependencies = [
name = "iced_debug"
version = "0.14.0-dev"
dependencies = [
"cargo-hot-protocol",
"iced_beacon",
"iced_core",
"iced_futures",
@ -3244,6 +3276,15 @@ version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "memfd"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2cffa4ad52c6f791f4f8b15f0c05f9824b2ced1160e88cc393d64fff9a8ac64"
dependencies = [
"rustix 0.38.44",
]
[[package]]
name = "memmap2"
version = "0.9.5"
@ -5406,6 +5447,34 @@ dependencies = [
"rayon",
]
[[package]]
name = "subsecond"
version = "0.7.0-alpha.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c5b40acd555d02d9a0b5bf4080dbf2cd085d5e2eb2ae7851cb14b9bf5af15c"
dependencies = [
"js-sys",
"libc",
"libloading",
"memfd",
"memmap2",
"serde",
"subsecond-types",
"thiserror 2.0.12",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "subsecond-types"
version = "0.7.0-alpha.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bedadae58a56e137ac970c38c44bff38cee24400fef64c37d5a188a065b1ec1f"
dependencies = [
"serde",
]
[[package]]
name = "subtle"
version = "2.6.1"
@ -5483,7 +5552,7 @@ version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "874dcfa363995604333cf947ae9f751ca3af4522c60886774c4963943b4746b1"
dependencies = [
"bincode",
"bincode 1.3.3",
"bitflags 1.3.2",
"flate2",
"fnv",
@ -6169,6 +6238,12 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "unty"
version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
[[package]]
name = "url"
version = "2.5.4"
@ -6288,6 +6363,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "virtue"
version = "0.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1"
[[package]]
name = "visible_bounds"
version = "0.1.0"

View file

@ -41,10 +41,12 @@ qr_code = ["iced_widget/qr_code"]
markdown = ["iced_widget/markdown"]
# Enables lazy widgets
lazy = ["iced_widget/lazy"]
# Enables a debug view in native platforms (press F12)
# Enables debug metrics 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 hot reloading (very experimental!)
hot = ["debug", "iced_debug/hot"]
# 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
@ -167,6 +169,7 @@ bincode = "1.3"
bitflags = "2.0"
bytemuck = { version = "1.0", features = ["derive"] }
bytes = "1.6"
cargo-hot = { package = "cargo-hot-protocol", git = "https://github.com/hecrj/cargo-hot.git", rev = "b8dc518b8640928178a501257e353b73bc06cf47" }
cosmic-text = "0.14"
dark-light = "2.0"
futures = { version = "0.3", default-features = false }

View file

@ -12,6 +12,7 @@ keywords.workspace = true
[features]
enable = ["dep:iced_beacon"]
hot = ["enable", "dep:cargo-hot"]
[dependencies]
iced_core.workspace = true
@ -21,3 +22,6 @@ log.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
iced_beacon.workspace = true
iced_beacon.optional = true
cargo-hot.workspace = true
cargo-hot.optional = true

View file

@ -39,6 +39,7 @@ pub fn disable() {
pub fn init(metadata: Metadata) {
internal::init(metadata);
hot::init();
}
pub fn quit() -> bool {
@ -113,6 +114,18 @@ pub fn commands() -> Subscription<Command> {
internal::commands()
}
pub fn hot<O>(f: impl FnOnce() -> O) -> O {
hot::call(f)
}
pub fn on_hotpatch(f: impl Fn() + Send + Sync + 'static) {
hot::on_hotpatch(f)
}
pub fn is_stale() -> bool {
hot::is_stale()
}
#[cfg(all(feature = "enable", not(target_arch = "wasm32")))]
mod internal {
use crate::core::theme;
@ -399,3 +412,83 @@ mod internal {
pub fn finish(self) {}
}
}
#[cfg(feature = "hot")]
mod hot {
use std::collections::BTreeSet;
use std::sync::atomic::{self, AtomicBool};
use std::sync::{Arc, Mutex, OnceLock};
static IS_STALE: AtomicBool = AtomicBool::new(false);
static HOT_FUNCTIONS_PENDING: Mutex<BTreeSet<u64>> =
Mutex::new(BTreeSet::new());
static HOT_FUNCTIONS: OnceLock<BTreeSet<u64>> = OnceLock::new();
pub fn init() {
cargo_hot::connect();
cargo_hot::subsecond::register_handler(Arc::new(|| {
if HOT_FUNCTIONS.get().is_none() {
HOT_FUNCTIONS
.set(std::mem::take(
&mut HOT_FUNCTIONS_PENDING
.lock()
.expect("Lock hot functions"),
))
.expect("Set hot functions");
}
IS_STALE.store(false, atomic::Ordering::Relaxed);
}));
}
pub fn call<O>(f: impl FnOnce() -> O) -> O {
let mut f = Some(f);
// The `move` here is important. Hotpatching will not work
// otherwise.
let mut f = cargo_hot::subsecond::HotFn::current(move || {
f.take().expect("Hot function is stale")()
});
let address = f.ptr_address().0;
if let Some(hot_functions) = HOT_FUNCTIONS.get() {
if hot_functions.contains(&address) {
IS_STALE.store(true, atomic::Ordering::Relaxed);
}
} else {
let _ = HOT_FUNCTIONS_PENDING
.lock()
.expect("Lock hot functions")
.insert(address);
}
f.call(())
}
pub fn on_hotpatch(f: impl Fn() + Send + Sync + 'static) {
cargo_hot::subsecond::register_handler(Arc::new(f));
}
pub fn is_stale() -> bool {
IS_STALE.load(atomic::Ordering::Relaxed)
}
}
#[cfg(not(feature = "hot"))]
mod hot {
pub fn init() {}
pub fn call<O>(f: impl FnOnce() -> O) -> O {
f()
}
pub fn on_hotpatch(_f: impl Fn() + Send + Sync + 'static) {}
pub fn is_stale() -> bool {
false
}
}

View file

@ -343,21 +343,30 @@ where
themer(derive_theme(), Element::from(mode).map(Event::Message))
});
let notification = self.show_notification.then(|| {
themer(
derive_theme(),
bottom_right(opaque(
container(text("Press F12 to open debug metrics"))
.padding(10)
.style(container::dark),
)),
)
});
let notification = self
.show_notification
.then(|| text("Press F12 to open debug metrics"))
.or_else(|| {
debug::is_stale().then(|| {
text(
"Types have changed. Restart to re-enable hotpatching.",
)
})
});
stack![view]
.height(Fill)
.push_maybe(mode.map(opaque))
.push_maybe(notification)
.push_maybe(notification.map(|notification| {
themer(
derive_theme(),
bottom_right(opaque(
container(notification)
.padding(10)
.style(container::dark),
)),
)
}))
.into()
}

View file

@ -56,6 +56,9 @@ pub enum Action<T> {
/// Run a system action.
System(system::Action),
/// Recreate all user interfaces and redraw all windows.
Reload,
/// Exits the runtime.
///
/// This will normally close any application windows and
@ -79,6 +82,7 @@ impl<T> Action<T> {
Action::Clipboard(action) => Err(Action::Clipboard(action)),
Action::Window(action) => Err(Action::Window(action)),
Action::System(action) => Err(Action::System(action)),
Action::Reload => Err(Action::Reload),
Action::Exit => Err(Action::Exit),
}
}
@ -102,6 +106,7 @@ where
}
Action::Window(_) => write!(f, "Action::Window"),
Action::System(action) => write!(f, "Action::System({action:?})"),
Action::Reload => write!(f, "Action::Reload"),
Action::Exit => write!(f, "Action::Exit"),
}
}

View file

@ -38,6 +38,8 @@ use crate::{
Element, Executor, Font, Result, Settings, Size, Subscription, Task,
};
use iced_debug as debug;
use std::borrow::Cow;
pub mod timed;
@ -126,7 +128,7 @@ where
state: &mut Self::State,
message: Self::Message,
) -> Task<Self::Message> {
self.update.update(state, message)
debug::hot(|| self.update.update(state, message))
}
fn view<'a>(
@ -134,7 +136,7 @@ where
state: &'a Self::State,
_window: window::Id,
) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> {
self.view.view(state)
debug::hot(|| self.view.view(state))
}
}
@ -327,7 +329,7 @@ impl<P: Program> Application<P> {
> {
Application {
raw: program::with_title(self.raw, move |state, _window| {
title.title(state)
debug::hot(|| title.title(state))
}),
settings: self.settings,
window: self.window,
@ -342,7 +344,9 @@ impl<P: Program> Application<P> {
impl Program<State = P::State, Message = P::Message, Theme = P::Theme>,
> {
Application {
raw: program::with_subscription(self.raw, f),
raw: program::with_subscription(self.raw, move |state| {
debug::hot(|| f(state))
}),
settings: self.settings,
window: self.window,
}
@ -356,7 +360,9 @@ impl<P: Program> Application<P> {
impl Program<State = P::State, Message = P::Message, Theme = P::Theme>,
> {
Application {
raw: program::with_theme(self.raw, move |state, _window| f(state)),
raw: program::with_theme(self.raw, move |state, _window| {
debug::hot(|| f(state))
}),
settings: self.settings,
window: self.window,
}
@ -370,7 +376,9 @@ impl<P: Program> Application<P> {
impl Program<State = P::State, Message = P::Message, Theme = P::Theme>,
> {
Application {
raw: program::with_style(self.raw, f),
raw: program::with_style(self.raw, move |state, theme| {
debug::hot(|| f(state, theme))
}),
settings: self.settings,
window: self.window,
}
@ -385,7 +393,7 @@ impl<P: Program> Application<P> {
> {
Application {
raw: program::with_scale_factor(self.raw, move |state, _window| {
f(state)
debug::hot(|| f(state))
}),
settings: self.settings,
window: self.window,

View file

@ -6,6 +6,8 @@ use crate::time::Instant;
use crate::window;
use crate::{Element, Program, Settings, Subscription, Task};
use iced_debug as debug;
/// Creates an [`Application`] with an `update` function that also
/// takes the [`Instant`] of each `Message`.
///
@ -97,10 +99,12 @@ where
state: &mut Self::State,
(message, now): Self::Message,
) -> Task<Self::Message> {
self.update
.update(state, message, now)
.into()
.map(|message| (message, Instant::now()))
debug::hot(move || {
self.update
.update(state, message, now)
.into()
.map(|message| (message, Instant::now()))
})
}
fn view<'a>(
@ -108,16 +112,21 @@ where
state: &'a Self::State,
_window: window::Id,
) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> {
self.view
.view(state)
.map(|message| (message, Instant::now()))
debug::hot(|| {
self.view
.view(state)
.map(|message| (message, Instant::now()))
})
}
fn subscription(
&self,
state: &Self::State,
) -> self::Subscription<Self::Message> {
(self.subscription)(state).map(|message| (message, Instant::now()))
debug::hot(|| {
(self.subscription)(state)
.map(|message| (message, Instant::now()))
})
}
}

View file

@ -6,6 +6,8 @@ use crate::theme;
use crate::window;
use crate::{Element, Executor, Font, Result, Settings, Subscription, Task};
use iced_debug as debug;
use std::borrow::Cow;
/// Creates an iced [`Daemon`] given its boot, update, and view logic.
@ -72,7 +74,7 @@ where
state: &mut Self::State,
message: Self::Message,
) -> Task<Self::Message> {
self.update.update(state, message)
debug::hot(|| self.update.update(state, message))
}
fn view<'a>(
@ -80,7 +82,7 @@ where
state: &'a Self::State,
window: window::Id,
) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> {
self.view.view(state, window)
debug::hot(|| self.view.view(state, window))
}
}
@ -176,7 +178,7 @@ impl<P: Program> Daemon<P> {
> {
Daemon {
raw: program::with_title(self.raw, move |state, window| {
title.title(state, window)
debug::hot(|| title.title(state, window))
}),
settings: self.settings,
}
@ -190,7 +192,9 @@ impl<P: Program> Daemon<P> {
impl Program<State = P::State, Message = P::Message, Theme = P::Theme>,
> {
Daemon {
raw: program::with_subscription(self.raw, f),
raw: program::with_subscription(self.raw, move |state| {
debug::hot(|| f(state))
}),
settings: self.settings,
}
}
@ -203,7 +207,9 @@ impl<P: Program> Daemon<P> {
impl Program<State = P::State, Message = P::Message, Theme = P::Theme>,
> {
Daemon {
raw: program::with_theme(self.raw, f),
raw: program::with_theme(self.raw, move |state, window| {
debug::hot(|| f(state, window))
}),
settings: self.settings,
}
}
@ -216,7 +222,9 @@ impl<P: Program> Daemon<P> {
impl Program<State = P::State, Message = P::Message, Theme = P::Theme>,
> {
Daemon {
raw: program::with_style(self.raw, f),
raw: program::with_style(self.raw, move |state, theme| {
debug::hot(|| f(state, theme))
}),
settings: self.settings,
}
}
@ -229,7 +237,9 @@ impl<P: Program> Daemon<P> {
impl Program<State = P::State, Message = P::Message, Theme = P::Theme>,
> {
Daemon {
raw: program::with_scale_factor(self.raw, f),
raw: program::with_scale_factor(self.raw, move |state, window| {
debug::hot(|| f(state, window))
}),
settings: self.settings,
}
}

View file

@ -85,6 +85,15 @@ where
let (proxy, worker) = Proxy::new(event_loop.create_proxy());
#[cfg(feature = "debug")]
{
let proxy = proxy.clone();
debug::on_hotpatch(move || {
proxy.send_action(Action::Reload);
});
}
let mut runtime = {
let executor =
P::Executor::new().map_err(Error::ExecutorCreationFailed)?;
@ -527,7 +536,7 @@ async fn run_instance<P>(
let create_compositor = {
let window = window.clone();
let mut proxy = proxy.clone();
let proxy = proxy.clone();
let default_fonts = default_fonts.clone();
async move {
@ -1075,9 +1084,9 @@ fn update<P: Program, E: Executor>(
runtime.track(recipes);
}
fn run_action<P, C>(
fn run_action<'a, P, C>(
action: Action<P::Message>,
program: &program::Instance<P>,
program: &'a program::Instance<P>,
compositor: &mut Option<C>,
events: &mut Vec<(window::Id, core::Event)>,
messages: &mut Vec<P::Message>,
@ -1085,7 +1094,7 @@ fn run_action<P, C>(
control_sender: &mut mpsc::UnboundedSender<Control>,
interfaces: &mut FxHashMap<
window::Id,
UserInterface<'_, P::Message, P::Theme, P::Renderer>,
UserInterface<'a, P::Message, P::Theme, P::Renderer>,
>,
window_manager: &mut WindowManager<P, C>,
ui_caches: &mut FxHashMap<window::Id, user_interface::Cache>,
@ -1437,6 +1446,29 @@ fn run_action<P, C>(
let _ = channel.send(Ok(()));
}
}
Action::Reload => {
for (id, window) in window_manager.iter_mut() {
let Some(ui) = interfaces.remove(&id) else {
continue;
};
let cache = ui.into_cache();
let size = window.size();
let _ = interfaces.insert(
id,
build_user_interface(
program,
cache,
&mut window.renderer,
size,
id,
),
);
window.raw.request_redraw();
}
}
Action::Exit => {
control_sender
.start_send(Control::Exit)

View file

@ -77,7 +77,7 @@ impl<T: 'static> Proxy<T> {
///
/// Note: This skips the backpressure mechanism with an unbounded
/// channel. Use sparingly!
pub fn send(&mut self, value: T)
pub fn send(&self, value: T)
where
T: std::fmt::Debug,
{
@ -88,13 +88,11 @@ impl<T: 'static> Proxy<T> {
///
/// Note: This skips the backpressure mechanism with an unbounded
/// channel. Use sparingly!
pub fn send_action(&mut self, action: Action<T>)
pub fn send_action(&self, action: Action<T>)
where
T: std::fmt::Debug,
{
self.raw
.send_event(action)
.expect("Send message to event loop");
let _ = self.raw.send_event(action);
}
/// Frees an amount of slots for additional messages to be queued in