Skip to main content

hot_reload/
hot_reload.rs

1//! Edit a running SwiftUI app's screen in a text editor and watch it change.
2//!
3//! No recompile, no relaunch, no Swift toolchain — the process keeps running
4//! and the view tree is rebuilt from the file each time it changes. A SwiftUI
5//! app cannot do this: its view types are fixed when the app is compiled, and
6//! changing a `VStack { … }` means building a new binary.
7//!
8//! ```sh
9//! # Writes a starter file, opens a window, and watches it.
10//! cargo run -p swiftui-native --example hot_reload
11//!
12//! # …then, in another terminal, edit it and the window follows:
13//! $EDITOR target/hot_reload.ui
14//! ```
15//!
16//! `--frames` is the same thing without a window: it applies a series of edits
17//! and writes a PNG after each, which is what makes the behaviour testable
18//! rather than only demonstrable.
19//!
20//! ```sh
21//! cargo run -p swiftui-native --example hot_reload -- --frames
22//! ```
23
24// As `dynamic_ui`: the iOS `main` watches and runs, and the offscreen frame
25// writer the macOS one offers has no caller there.
26#![allow(dead_code)]
27
28use std::sync::atomic::{AtomicU64, Ordering};
29use std::sync::{Mutex, OnceLock};
30use std::time::{Duration, SystemTime};
31use swiftui_native::prelude::*;
32
33#[path = "common/spec.rs"]
34mod spec;
35
36/// Where the watched file lives. Under `target/` so the repository is not
37/// dirtied by running the example.
38fn path() -> String {
39    std::env::args()
40        .skip(1)
41        .find(|a| !a.starts_with("--"))
42        .unwrap_or_else(|| format!("{}/../../target/hot_reload.ui", env!("CARGO_MANIFEST_DIR")))
43}
44
45const STARTER: &str = r#"// Edit this file and save. The window follows — and the
46// controls below keep their values across the reload.
47// Names: vstack hstack zstack text spacer divider button color image
48//        rect circle capsule progress gauge group_box list scroll form label
49
50vstack spacing=14 pad=24 align=leading {
51  text "Hot reload" style=large_title bold
52  text "Change this file; the app does not restart." color=secondary size=13
53  divider
54  hstack spacing=10 {
55    circle color=green frame=12x12
56    text "watching" size=13
57    spacer
58    text "edit me" color=blue size=13
59  }
60
61  // `@name` binds a control to a slot that outlives the view tree. Change
62  // these, then edit anything above, and they keep what you set.
63  toggle "Dark mode" @dark
64  stepper "Copies" @copies min=0 max=20
65  slider @level min=0 max=100
66
67  spacer
68}
69"#;
70
71/// What the screen is built from, and what the last attempt did.
72///
73/// `last_good` is the point. A file being edited is invalid most of the time —
74/// half a brace, a string that is not closed yet — and a hot reloader that
75/// replaced the screen with an error message every time would be unusable. So
76/// the last version that *parsed* stays on screen, and the error is shown over
77/// it. Losing the screen is how you lose your place.
78#[derive(Default)]
79struct Reloader {
80    last_good: String,
81    error: Option<String>,
82    generation: u64,
83    /// How long the last reload took, end to end.
84    micros: u64,
85}
86
87fn state_cell() -> &'static Mutex<Reloader> {
88    static CELL: OnceLock<Mutex<Reloader>> = OnceLock::new();
89    CELL.get_or_init(|| {
90        Mutex::new(Reloader { last_good: STARTER.to_string(), ..Default::default() })
91    })
92}
93
94static GENERATION: AtomicU64 = AtomicU64::new(0);
95
96fn body() -> View {
97    let (src, error, generation, micros) = {
98        let g = state_cell().lock().expect("state");
99        (g.last_good.clone(), g.error.clone(), g.generation, g.micros)
100    };
101
102    // The last version that parsed. It cannot fail — it parsed before — but a
103    // spec that builds views from slots could in principle, so the fallback is
104    // still handled rather than unwrapped.
105    let screen = spec::parse(&src)
106        .and_then(|nodes| spec::build(&nodes))
107        .unwrap_or_else(|e| text(&e).fg_system(SystemColor::Red).padding(20.0));
108
109    vstack_spacing(&[screen, status(error.as_deref(), generation, micros)], 0.0)
110}
111
112/// A strip along the bottom: which generation is on screen, how long the last
113/// reload took, what the slots hold, and the error if there is one.
114///
115/// In the window rather than the terminal because that is where you are
116/// looking. Watching a reloader work from a log means watching two things.
117fn status(error: Option<&str>, generation: u64, micros: u64) -> View {
118    let slots = spec::slot_summary();
119    let slot_line = if slots.is_empty() {
120        String::new()
121    } else {
122        slots
123            .iter()
124            .map(|(k, v)| format!("{k}={v}"))
125            .collect::<Vec<_>>()
126            .join("  ")
127    };
128
129    // The error row is *always* present, empty when there is nothing wrong.
130    // An error that appears and disappears would otherwise reflow the screen
131    // above it on every save — which looks exactly like the reloader having
132    // replaced the view, and makes "did the screen survive this?" impossible
133    // to answer by looking.
134    let rows = vec![
135        divider(),
136        hstack_spacing(
137            &[
138                circle_shape()
139                    .fill_system(if error.is_some() {
140                        SystemColor::Orange
141                    } else {
142                        SystemColor::Clear
143                    })
144                    .frame(8.0, 8.0),
145                text(error.unwrap_or(""))
146                    .font_size(11.0)
147                    .fg_system(SystemColor::Orange),
148                spacer(),
149            ],
150            6.0,
151        )
152        .padding(6.0)
153        .frame(348.0, 24.0),
154        hstack_spacing(
155            &[
156                circle_shape()
157                    .fill_system(if error.is_some() {
158                        SystemColor::Orange
159                    } else {
160                        SystemColor::Green
161                    })
162                    .frame(8.0, 8.0),
163                text(&format!("gen {generation}"))
164                    .font_size(11.0)
165                    .fg_system(SystemColor::Secondary),
166                // Microseconds below a millisecond: a reload of a short spec
167                // really is that fast, and "0.0 ms" reads as "not measured".
168                text(&if micros < 1000 {
169                    format!("{micros} \u{b5}s")
170                } else {
171                    format!("{:.1} ms", micros as f64 / 1000.0)
172                })
173                .font_size(11.0)
174                .fg_system(SystemColor::Secondary),
175                spacer(),
176                text(&slot_line).font_size(11.0).fg_system(SystemColor::Secondary),
177            ],
178            8.0,
179        )
180        .padding(6.0)
181        .frame(348.0, 24.0),
182    ];
183    vstack_spacing(&rows, 0.0)
184}
185
186fn content_view() -> swiftui_native::ViewHandle {
187    swiftui_native::state::root_view(|_| body().into_handle())
188}
189
190/// Read the file, parse it, and keep it if it parsed.
191///
192/// Returns whether the screen should be rebuilt. Three things make this more
193/// than a read:
194///
195/// * The text is **parsed here**, not in the body. A file that does not parse
196///   leaves `last_good` alone, so the screen survives being edited.
197/// * A failed *read* is not a failed reload. Editors save atomically — write a
198///   temporary file, rename it over the target — so for a moment the path may
199///   not exist. Treating that as "no change" and waiting is right; treating it
200///   as an error would flash a message on every save in vim or VS Code.
201/// * Identical text is not a change. `touch` and some editors rewrite a file
202///   byte for byte, and rebuilding for that would discard nothing but would
203///   log a reload that did not happen.
204fn reload(p: &str) -> bool {
205    let started = std::time::Instant::now();
206    let Ok(text) = std::fs::read_to_string(p) else { return false };
207
208    let mut g = state_cell().lock().expect("state");
209    if g.last_good == text && g.error.is_none() {
210        return false;
211    }
212    match spec::parse(&text).and_then(|nodes| spec::build(&nodes)) {
213        Ok(_) => {
214            g.last_good = text;
215            g.error = None;
216        }
217        Err(e) => {
218            // Same error as last time means the file has not usefully changed;
219            // saying so again would be noise.
220            if g.error.as_deref() == Some(e.as_str()) {
221                return false;
222            }
223            g.error = Some(e);
224        }
225    }
226    g.generation += 1;
227    g.micros = started.elapsed().as_micros() as u64;
228    GENERATION.store(g.generation, Ordering::Relaxed);
229    true
230}
231
232/// How long to wait after a change before reading, so a half-written file is
233/// not parsed. Long enough to cover an editor's write, short enough that a
234/// person does not notice.
235const SETTLE: Duration = Duration::from_millis(60);
236
237/// Watch the file on a background thread.
238///
239/// Polling rather than FSEvents: the trigger is a person saving a file, so the
240/// interval only has to beat their reaction time, and a callback-based watcher
241/// would be more machinery than this example is about. What it does need to get
242/// right is *what counts as a change* —
243///
244/// * **Size as well as mtime.** A write inside the same coarse mtime tick — an
245///   editor that saves twice quickly, or a script — changes the size, and
246///   watching mtime alone would miss it.
247/// * **Disappearance.** An atomic save unlinks the old file; the watcher waits
248///   for it to come back rather than giving up on the path.
249/// * **Settling.** A change is acted on `SETTLE` later, so a file caught
250///   mid-write is read once it is whole rather than parsed in two halves.
251///
252/// `state::refresh` has to run on the main thread, so this only raises a flag.
253fn watch(p: String) {
254    std::thread::spawn(move || {
255        let mut seen: Option<(SystemTime, u64)> = None;
256        let mut settle_at: Option<std::time::Instant> = None;
257        loop {
258            let stamp = std::fs::metadata(&p)
259                .and_then(|m| Ok((m.modified()?, m.len())))
260                .ok();
261            match (stamp, seen) {
262                // Gone for the moment, as during an atomic save: wait.
263                (None, _) => {}
264                (Some(now), Some(before)) if now == before => {}
265                (Some(now), _) => {
266                    seen = Some(now);
267                    settle_at = Some(std::time::Instant::now() + SETTLE);
268                }
269            }
270            if let Some(at) = settle_at {
271                if std::time::Instant::now() >= at {
272                    settle_at = None;
273                    if reload(&p) {
274                        PENDING.store(true, Ordering::Relaxed);
275                    }
276                }
277            }
278            std::thread::sleep(Duration::from_millis(30));
279        }
280    });
281}
282
283static PENDING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
284
285/// Apply a series of edits with no window, writing a PNG after each.
286///
287/// The point of the example is that the *same process* renders different
288/// screens as the file changes, so each frame here is written after a real
289/// write-and-reload rather than by calling the builder with different text.
290fn frames() {
291    let p = path();
292    let edits: &[(&str, &str)] = &[
293        ("1_start", STARTER),
294        (
295            "2_recoloured",
296            r#"vstack spacing=14 pad=24 align=leading {
297  text "Hot reload" style=large_title bold color=blue
298  text "Same process. Second version." color=secondary size=13
299  divider
300  hstack spacing=10 {
301    circle color=orange frame=12x12
302    text "reloaded once" size=13
303    spacer
304  }
305  progress 0.6
306  spacer
307}"#,
308        ),
309        (
310            "3_restructured",
311            r#"// A different shape entirely: the Swift type changes with it.
312zstack align=center frame=320x220 {
313  color #24303f frame=300x200 corner=18
314  vstack spacing=12 pad=20 align=leading {
315    text "Third version" style=title2 bold color=white
316    text "No recompile happened between these frames." color=white size=12 opacity=0.75
317    spacer
318    gauge 0.8 label="done"
319  }
320}"#,
321        ),
322        ("4_broken", "vstack { text \"unclosed"),
323    ];
324
325    for (name, text_src) in edits {
326        std::fs::write(&p, text_src).expect("write spec");
327        // Through the same path the watcher uses, so the frame proves the
328        // reload, not just the builder.
329        reload(&p);
330        let handle = content_view();
331        let out = format!("{}/../../target/hot_reload_{name}.png", env!("CARGO_MANIFEST_DIR"));
332        let ok = swiftui_native::render::render(&handle, 360, 400).write_png(&out);
333        let built = body().into_handle();
334        println!(
335            "{name:16} {}",
336            swiftui_native::diag::type_name(swiftui_native::views::meta_of(&built))
337                .chars()
338                .take(72)
339                .collect::<String>()
340        );
341        println!("{:16} {}", "", if ok { out } else { "render failed".into() });
342    }
343    // Leave the starter behind so a following run with a window has something
344    // valid to open, rather than the deliberately broken last frame.
345    std::fs::write(&p, STARTER).expect("restore spec");
346}
347
348/// Open the window, edit the file underneath it, and capture the *window*
349/// after each edit.
350///
351/// `--frames` renders offscreen, which shows the builder produces different
352/// pictures. This shows the stronger thing: a window that is already on screen
353/// changing because a file changed. The captures come from the live hosting
354/// view, so nothing here could pass by rendering a fresh tree on the side.
355#[cfg(target_os = "macos")]
356fn drive() {
357    let p = path();
358    std::fs::write(&p, STARTER).expect("write starter");
359    reload(&p);
360    watch(p.clone());
361
362    let window = swiftui_native::Window::new(&content_view(), "Hot reload", 360.0, 420.0);
363    let shot = |n: &str| {
364        let out = format!("{}/../../target/hot_reload_live_{n}.png", env!("CARGO_MANIFEST_DIR"));
365        let r = swiftui_native::render::render_hosting(window.hosting(), 360, 420);
366        r.write_png(&out);
367        (out, r)
368    };
369
370    // Pixels differing *above* the status strip.
371    //
372    // The strip reports the generation, so it changes on every reload whether
373    // or not the screen did — comparing whole frames would make "the screen
374    // survived a file that does not parse" indistinguishable from "the screen
375    // changed".
376    fn screen_diff(
377        a: &swiftui_native::render::Rendered,
378        b: &swiftui_native::render::Rendered,
379    ) -> u32 {
380        if a.width != b.width || a.height != b.height {
381            return u32::MAX;
382        }
383        // Measured, not estimated: with a 420-point capture the strip's own
384        // pixels start at row 294, so everything from 290 down is excluded.
385        // Guessing 90 left part of the strip in the comparison and reported a
386        // screen that had not moved as having moved.
387        const SCREEN_ROWS: u32 = 290;
388        let keep = (SCREEN_ROWS.min(a.height) * a.width * 4) as usize;
389        a.pixels[..keep]
390            .chunks_exact(4)
391            .zip(b.pixels[..keep].chunks_exact(4))
392            .filter(|(x, y)| x != y)
393            .count() as u32
394    }
395
396    // Let the window settle before the first capture, or it is caught blank.
397    swiftui_native::render::pump_events(1.0);
398    let (first_path, first) = shot("1_start");
399
400    // A user changes the toggle. Everything after this checks it stays
401    // changed, which is what separates reloading from restarting.
402    spec::set_flag("dark", true);
403    swiftui_native::state::refresh();
404    window.set_root(&content_view());
405    swiftui_native::render::pump_events(0.4);
406
407    /// One edit, and whether the screen should follow it.
408    struct Edit {
409        name: &'static str,
410        src: &'static str,
411        /// `false` for a spec that does not parse: the screen must *not*
412        /// change, because the last good one has to stay put.
413        expect_change: bool,
414    }
415
416    let edits = &[
417        Edit {
418            name: "2_edited",
419            src: "vstack spacing=10 pad=20 align=leading {\n  \
420                  text \"Edited while running\" style=title bold color=red\n  \
421                  text \"the process never restarted\" color=secondary size=12\n  \
422                  toggle \"Dark mode\" @dark\n  \
423                  gauge 0.9 label=\"reloaded\"\n}",
424            expect_change: true,
425        },
426        Edit {
427            name: "3_restructured",
428            src: "zstack align=center frame=300x200 {\n  \
429                  color #223344 frame=280x180 corner=14\n  \
430                  text \"a different shape entirely\" color=white size=14\n}",
431            expect_change: true,
432        },
433        Edit {
434            // Mid-edit: a brace that is never closed. The screen must survive.
435            name: "4_broken",
436            src: "zstack align=center frame=300x200 {\n  color #223344",
437            expect_change: false,
438        },
439    ];
440
441    let mut shots = vec![(first_path, first)];
442    let mut ok = true;
443    for e in edits {
444        std::fs::write(&p, e.src).expect("write edit");
445        let deadline = std::time::Instant::now() + Duration::from_secs(4);
446        while !PENDING.load(Ordering::Relaxed) && std::time::Instant::now() < deadline {
447            swiftui_native::render::pump_events(0.05);
448        }
449        if !PENDING.swap(false, Ordering::Relaxed) {
450            eprintln!("{}: the watcher never fired", e.name);
451            std::process::exit(1);
452        }
453        swiftui_native::state::refresh();
454        window.set_root(&content_view());
455        swiftui_native::render::pump_events(0.6);
456        let (path_now, shot_now) = shot(e.name);
457
458        let (prev_path, prev) = shots.last().expect("a previous shot");
459        let d = screen_diff(prev, &shot_now);
460        println!("{prev_path}\n  -> {path_now}\n     {d} pixels differ above the status strip");
461        match (e.expect_change, d) {
462            (true, 0) => {
463                eprintln!("     the window did not follow the edit");
464                ok = false;
465            }
466            (false, n) if n > 0 => {
467                eprintln!("     a file that does not parse replaced the screen");
468                ok = false;
469            }
470            _ => {}
471        }
472        shots.push((path_now, shot_now));
473    }
474
475    // …and the toggle is still on, after every reload and a broken file.
476    let dark = spec::slot_summary().into_iter().find(|(k, _)| k == "dark").map(|(_, v)| v);
477    println!("slot `dark` after every reload: {dark:?}");
478    if dark.as_deref() != Some("true") {
479        eprintln!("  a reload reset the slot");
480        ok = false;
481    }
482
483    std::fs::write(&p, STARTER).expect("restore spec");
484    if !ok {
485        std::process::exit(1);
486    }
487    println!("followed every valid edit, survived an invalid one, kept its state");
488}
489
490#[cfg(target_os = "macos")]
491fn main() {
492    let p = path();
493    if std::env::args().any(|a| a == "--frames") {
494        return frames();
495    }
496    if std::env::args().any(|a| a == "--drive") {
497        return drive();
498    }
499
500    if !std::path::Path::new(&p).exists() {
501        std::fs::write(&p, STARTER).expect("write starter");
502        eprintln!("wrote {p}");
503    }
504    reload(&p);
505    watch(p.clone());
506    eprintln!("watching {p} — edit it and save");
507
508    // `Window::run` enters AppKit's loop and never comes back, which leaves
509    // nowhere to notice the file changed. Pumping the loop in slices instead
510    // keeps the window live *and* gives this thread a turn between them —
511    // `state::refresh` has to happen on the main thread, so the watcher only
512    // raises a flag and the work is done here.
513    let window = swiftui_native::Window::new(&content_view(), "Hot reload", 360.0, 420.0);
514    loop {
515        swiftui_native::render::pump_events(0.05);
516        if PENDING.swap(false, Ordering::Relaxed) {
517            // Two mechanisms, because the screen may be either shape: the
518            // synthesised root re-evaluates its body on `refresh`, and
519            // `set_root` hands SwiftUI a fresh tree to diff against. The first
520            // alone leaves a structural change — a `VStack` becoming a
521            // `ZStack` — with nothing to diff.
522            swiftui_native::state::refresh();
523            window.set_root(&content_view());
524            eprintln!("reloaded (generation {})", GENERATION.load(Ordering::Relaxed));
525        }
526    }
527}
528
529#[cfg(not(target_os = "macos"))]
530fn main() {
531    let p = path();
532    reload(&p);
533    watch(p);
534    swiftui_native::app::run(content_view)
535}