dev.rs raw

//! 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<WidgetId>,
    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<PathBuf>,
    cx: Rc<DynCx>,
    instance: Instantiated,
    events: mpsc::Receiver<notify::Result<notify::Event>>,
    // 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<Mutex<Option<Box<dyn Fn() + Send>>>>,
}

/// 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<dyn Fn() + Send>) {
        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<WidgetId>,
        path: impl Into<PathBuf>,
        spec: ComponentSpec,
    ) -> Result<Self, DevMountError> {
        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<Mutex<Option<Box<dyn Fn() + Send>>>> = Arc::new(Mutex::new(None));
        let handler_waker = Arc::clone(&waker);
        let mut watcher =
            notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
                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<DynCx> {
        &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<String> = 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<PathBuf> {
    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<PathBuf>), 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, &registry)
        .map(|compiled| (compiled, resolver.visited))
        .map_err(|diags| {
            DevMountError::Compile(guiduck_component_core::diagnostics::render(
                &diags,
                &source,
                &path.display().to_string(),
            ))
        })
}