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, ®istry)
.map(|compiled| (compiled, resolver.visited))
.map_err(|diags| {
DevMountError::Compile(guiduck_component_core::diagnostics::render(
&diags,
&source,
&path.display().to_string(),
))
})
}