//! Hot reload: a dev-mode component mount that re-instantiates its widget //! subtree whenever its `.gdc` file — or any component file it transitively //! instantiates — changes on disk, preserving state signals whose name and //! type still match. use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::{Arc, Mutex, mpsc}; use guiduck_component_core::ir::Compiled; use guiduck_core::component::{ComponentSpec, DynCx, DynSignal, DynValue}; use guiduck_core::{WidgetId, WidgetTree}; use notify::Watcher; use crate::interpret::{self, Instantiated, signal_type}; /// A component mounted in dev mode: interpreted structure, compiled /// handlers, live reload. pub struct DevMount { path: PathBuf, parent: Option, spec: ComponentSpec, compiled: Compiled, /// Every file the current compilation read (the mounted component's own /// and its transitive instantiations); a change to any of them reloads. watched: Vec, cx: Rc, instance: Instantiated, events: mpsc::Receiver>, // Kept alive for the watch duration. _watcher: notify::RecommendedWatcher, /// Shared with the watcher thread's event handler, which pings it on /// every filesystem event so the UI event loop wakes and polls. waker: Arc>>>, } /// A dev mount is what a tree takes live structure *from*, so the shell polls /// it without the application arranging anything. impl guiduck_core::live::LiveReload for DevMount { fn poll(&mut self, tree: &mut WidgetTree) -> bool { DevMount::poll(self, tree) } fn set_waker(&mut self, wake: Box) { DevMount::set_waker(self, move || wake()); } } /// Errors constructing a dev mount. #[derive(Debug)] pub enum DevMountError { Io(std::io::Error), Compile(String), Watch(notify::Error), } impl std::fmt::Display for DevMountError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { DevMountError::Io(e) => write!(f, "cannot read component file: {e}"), DevMountError::Compile(rendered) => { write!(f, "component does not compile:\n{rendered}") } DevMountError::Watch(e) => write!(f, "cannot watch component file: {e}"), } } } impl std::error::Error for DevMountError {} impl DevMount { /// Read, compile, and mount `path`, and start watching it for changes. /// /// Components the file instantiates resolve through the spec's baked /// search path (the same one the typed build used), with the mounted /// file's own directory as a final fallback; every search directory is /// watched. pub fn new( tree: &mut WidgetTree, parent: Option, path: impl Into, spec: ComponentSpec, ) -> Result { let path: PathBuf = path.into(); let search_dirs = search_dirs(&path, &spec); let (compiled, watched) = compile_file(&path, &search_dirs, &spec.widget_path)?; let cx = Rc::new(interpret::make_dyn_cx(&compiled.root, &spec.props)); let instance = interpret::instantiate( tree, parent, &compiled.root, &cx, &spec, &compiled.components, ); let (sender, events) = mpsc::channel(); let waker: Arc>>> = Arc::new(Mutex::new(None)); let handler_waker = Arc::clone(&waker); let mut watcher = notify::recommended_watcher(move |event: notify::Result| { let _ = sender.send(event); if let Ok(guard) = handler_waker.lock() && let Some(wake) = guard.as_ref() { wake(); } }) .map_err(DevMountError::Watch)?; // Watch whole directories: editors typically replace files by // rename, which per-file watches lose track of. Directories that do // not exist (yet) are skipped rather than fatal — a resolvable set // of files never lived in them. for dir in &search_dirs { if dir.is_dir() { watcher .watch(dir, notify::RecursiveMode::NonRecursive) .map_err(DevMountError::Watch)?; } } Ok(Self { path, parent, spec, compiled, watched, cx, instance, events, _watcher: watcher, waker, }) } /// The mounted subtree's root widget. pub fn root(&self) -> WidgetId { self.instance.root } /// The live dynamic context (state signals persist across reloads). pub fn cx(&self) -> &Rc { &self.cx } /// Install a callback that wakes the UI event loop from the watcher's /// thread. Without one, reloads apply on the next natural wakeup /// (input, resize) instead of immediately. pub fn set_waker(&mut self, waker: impl Fn() + Send + 'static) { if let Ok(mut guard) = self.waker.lock() { *guard = Some(Box::new(waker)); } } /// Drain watcher events; if the component file changed, reload it. /// Returns true if a reload was applied. pub fn poll(&mut self, tree: &mut WidgetTree) -> bool { let mut relevant = false; while let Ok(event) = self.events.try_recv() { let Ok(event) = event else { continue }; // Only content changes count. Access events must be ignored: // the reload itself reads the file, so reacting to reads would // loop forever (reload → read → Access event → reload …). let is_change = matches!( event.kind, notify::EventKind::Create(_) | notify::EventKind::Modify(_) | notify::EventKind::Remove(_) ); if is_change && event.paths.iter().any(|p| { self.watched .iter() .any(|watched| p.file_name() == watched.file_name()) }) { relevant = true; } } if !relevant { return false; } self.reload_now(tree) } /// Recompile the file and rebuild the subtree unconditionally. Returns /// true if the reload was applied; a file that no longer compiles keeps /// the previous UI and reports the diagnostics to stderr. pub fn reload_now(&mut self, tree: &mut WidgetTree) -> bool { let search_dirs = search_dirs(&self.path, &self.spec); match compile_file(&self.path, &search_dirs, &self.spec.widget_path) { Ok((compiled, watched)) => { self.watched = watched; self.apply(tree, compiled); true } Err(e) => { // A broken save keeps the last good UI; the error goes to // the terminal the dev is watching. eprintln!("guiduck[dev]: reload failed, keeping previous UI\n{e}"); false } } } /// Rebuild the mounted subtree from a new compilation, preserving state /// signals whose name and type are unchanged — the top level by handle /// (the embedding may hold `cx()`), nested instances by value through /// their identity (component type plus `:key`/occurrence). fn apply(&mut self, tree: &mut WidgetTree, compiled: Compiled) { // Capture nested state before teardown (the signals die with the // instance scope), then tear down: binding effects first, widgets // after. let nested_state = interpret::snapshot_nested(&self.instance); let old_root = self.instance.root; // Where the component sat among its siblings. A rebuild appends, so // without this a reload would shuffle the component to the end of a // parent it shares with widgets the application inserted itself — // which the plain-Rust API explicitly invites. let slot = self .parent .and_then(|parent| tree.child_index(parent, old_root)); let old = std::mem::replace( &mut self.instance, Instantiated { root: old_root, scope: guiduck_core::signals::Scope::new(), nested: Vec::new(), }, ); old.scope.dispose(); tree.remove(old_root); // New dynamic context: keep matching signals (value preserved), // create the rest fresh from their inits. let mut cx = interpret::make_dyn_cx(&compiled.root, &self.spec.props); let mut carried: Vec<&str> = Vec::new(); for (name, new_signal) in cx.signals.iter_mut() { if let Some(old_signal) = self.cx.signals.get(name) && signal_type(old_signal) == signal_type(new_signal) { *new_signal = *old_signal; carried.push(name); } } // Old signals that were not carried over (removed or retyped) are // disposed, so reloads do not accumulate dead runtime nodes. Prop // signals are rebuilt from the spec's snapshot every reload, so the // old ones always go. let carried: Vec = carried.iter().map(|s| s.to_string()).collect(); for (name, old_signal) in self.cx.signals.iter() { if !carried.iter().any(|c| c == name) { dispose_dyn_signal(old_signal); } } for old_signal in self.cx.props.values() { dispose_dyn_signal(old_signal); } let cx = Rc::new(cx); // Rebuilding runs the component's mount hook again, and the hook seeds // state from outside — so a carried signal's value is captured here and // put back below. Preserving state across an edit is what hot reload // *is*; a hook that re-seeded over it would silently send the user back // to wherever the data started. A signal the edit newly introduced is // deliberately not captured, so it keeps what the hook (or its `:init`) // gave it. Nested instances have always worked this way, through // `restore_nested` below; this is the same rule at the top level. let preserved: Vec<(DynSignal, DynValue)> = carried .iter() .filter_map(|name| cx.signals.get(name)) .map(|signal| (signal.clone(), interpret::dyn_signal_value(signal))) .collect(); let fresh = interpret::instantiate( tree, self.parent, &compiled.root, &cx, &self.spec, &compiled.components, ); for (signal, value) in &preserved { interpret::restore_signal_value(signal, value); } if let (Some(parent), Some(slot)) = (self.parent, slot) { tree.move_child(parent, fresh.root, slot); } interpret::restore_nested(&fresh, &nested_state); let placeholder = std::mem::replace(&mut self.instance, fresh); placeholder.scope.dispose(); self.cx = cx; self.compiled = compiled; } } fn dispose_dyn_signal(signal: &guiduck_core::component::DynSignal) { use guiduck_core::component::DynSignal::*; match signal { I32(s) => s.dispose(), I64(s) => s.dispose(), F32(s) => s.dispose(), F64(s) => s.dispose(), Bool(s) => s.dispose(), Str(s) => s.dispose(), ListI32(s) => s.dispose(), ListI64(s) => s.dispose(), ListF32(s) => s.dispose(), ListF64(s) => s.dispose(), ListBool(s) => s.dispose(), ListStr(s) => s.dispose(), RecordList(s) => s.dispose(), RecordValue(s) => s.dispose(), } } /// The directories component references resolve against: the spec's baked /// search path (what the typed build used) with the mounted file's own /// directory appended as a fallback — which is also what keeps the /// copy-one-file-somewhere dev workflow alive when nothing bakes a path. fn search_dirs(path: &Path, spec: &ComponentSpec) -> Vec { let mut dirs = spec.search_path.clone(); let own = path.parent().unwrap_or(Path::new(".")).to_path_buf(); if !dirs.contains(&own) { dirs.push(own); } dirs } /// Read and compile the mounted file against the same vocabulary the typed /// build used: every `.gdw` manifest on the spec's baked widget path loads /// first, because the vocabulary decides what counts as a widget name at all. /// /// The manifests land in the watched set alongside the `.gdc` files, so /// editing one reloads exactly the way editing a component does — though a /// manifest that declares a *new* widget still needs a rebuild to get a /// factory, which the interpreter reports where the file can be seen. fn compile_file( path: &Path, search_dirs: &[PathBuf], widget_dirs: &[PathBuf], ) -> Result<(Compiled, Vec), DevMountError> { let source = std::fs::read_to_string(path).map_err(DevMountError::Io)?; let mut resolver = guiduck_component_core::resolve::SearchPathResolver::new(search_dirs.iter().cloned()); resolver.visited.push(path.to_path_buf()); let registry = guiduck_component_core::resolve::load_widgets(widget_dirs, &mut resolver.visited) .map_err(DevMountError::Compile)?; guiduck_component_core::compile_with_widgets(&source, &mut resolver, ®istry) .map(|compiled| (compiled, resolver.visited)) .map_err(|diags| { DevMountError::Compile(guiduck_component_core::diagnostics::render( &diags, &source, &path.display().to_string(), )) }) }