Skip to main content

dotzuki_runner/
watch.rs

1//! File watching for `dotzuki run --watch`: [`ProjectWatcher`].
2//!
3//! Watches the project's data/gfx/scene directories recursively and reports
4//! changed `.scene`/`.json`/`.png`/`.tmx` files. Same notify version and
5//! poll-per-frame shape as `dotzuki_app::hot_reload::AssetWatcher` — it is not
6//! reused because its extension filter (`tmx/png/js`) lacks `.scene` and is
7//! not configurable.
8//!
9//! The watcher only *reports* paths; reload policy (which paths trigger a
10//! scene recompile or a map reload) lives in [`crate::game::RunnerGame`].
11
12use std::collections::HashSet;
13use std::path::{Path, PathBuf};
14use std::sync::mpsc;
15
16use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
17
18/// Extensions a reload can act on: DSL scenes, TMX maps, tileset PNGs and
19/// JSON data (map sidecars, `map.tmx.json`).
20const SUPPORTED_EXTENSIONS: &[&str] = &["scene", "json", "png", "tmx"];
21
22fn is_supported_file(path: &Path) -> bool {
23    path.extension()
24        .and_then(|ext| ext.to_str())
25        .is_some_and(|ext| SUPPORTED_EXTENSIONS.contains(&ext))
26}
27
28/// Watches project directories for changes to reloadable content files.
29///
30/// Poll-based interface: call [`poll_events`](Self::poll_events) each frame
31/// to collect the changes since the last poll. Duplicate events for the same
32/// file within one poll cycle are deduplicated.
33pub struct ProjectWatcher {
34    _watcher: RecommendedWatcher,
35    rx: mpsc::Receiver<Result<Event, notify::Error>>,
36    seen: HashSet<PathBuf>,
37}
38
39impl ProjectWatcher {
40    /// Watch every directory in `dirs` recursively. Missing directories are
41    /// skipped (with a log line); an error is returned only when the watcher
42    /// itself cannot be created or no directory exists to watch.
43    pub fn new(dirs: &[PathBuf]) -> Result<Self, String> {
44        let (tx, rx) = mpsc::channel();
45        let mut watcher = RecommendedWatcher::new(
46            move |res| {
47                let _ = tx.send(res);
48            },
49            Config::default(),
50        )
51        .map_err(|e| format!("failed to create file watcher: {e}"))?;
52
53        let mut watched_any = false;
54        for dir in dirs {
55            if dir.is_dir() {
56                watcher
57                    .watch(dir, RecursiveMode::Recursive)
58                    .map_err(|e| format!("failed to watch {}: {e}", dir.display()))?;
59                log::info!("hot-reload: watching {}", dir.display());
60                watched_any = true;
61            } else {
62                log::info!("hot-reload: skipping {} (not found)", dir.display());
63            }
64        }
65
66        if !watched_any {
67            return Err("no valid directories to watch".to_string());
68        }
69
70        Ok(Self {
71            _watcher: watcher,
72            rx,
73            seen: HashSet::new(),
74        })
75    }
76
77    /// Changed content files since the last call, deduplicated by path.
78    pub fn poll_events(&mut self) -> Vec<PathBuf> {
79        self.seen.clear();
80
81        while let Ok(Ok(event)) = self.rx.try_recv() {
82            match event.kind {
83                EventKind::Modify(_) | EventKind::Create(_) => {
84                    for path in event.paths {
85                        if is_supported_file(&path) {
86                            self.seen.insert(path);
87                        }
88                    }
89                }
90                _ => {}
91            }
92        }
93
94        self.seen.iter().cloned().collect()
95    }
96}