use notify::{Event, EventKind, RecursiveMode, Watcher};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::gfx::graphics_system::hot_reload_sources::*;
pub(super) fn spawn_watcher(
sources: &HotReloadSources,
flag: Arc<AtomicBool>,
) -> Option<notify::RecommendedWatcher> {
let HotReloadSources {
map,
color_lut,
environment_map,
meshes,
skinned_meshes,
procedural_meshes: _,
shader_stages,
world_jsonl_path,
} = sources;
let debounce = Duration::from_millis(150);
let last_fire = Mutex::new(Instant::now() - debounce);
let mut watcher = match notify::recommended_watcher(move |res: notify::Result<Event>| {
let event = match res {
Ok(e) => e,
Err(e) => {
tracing::debug!("asset hot-reload watcher error: {e}");
return;
}
};
let Some(kind) = classify_event(&event) else {
return;
};
let mut last = match last_fire.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
let now = Instant::now();
if now.duration_since(*last) < debounce {
return;
}
*last = now;
tracing::info!(
"asset hot-reload: detected change to {:?}, scheduling {kind:?} reload",
event.paths
);
signal(kind, &flag);
}) {
Ok(w) => w,
Err(e) => {
tracing::warn!("asset hot-reload: failed to create notify watcher: {e}");
return None;
}
};
let mut dirs: BTreeSet<PathBuf> = map.watch_dirs().into_iter().collect();
if let Some(lut) = color_lut
&& let Some(parent) = Path::new(&lut.resolved_path).parent()
&& !parent.as_os_str().is_empty()
{
dirs.insert(parent.to_path_buf());
}
if let Some(env_map) = environment_map
&& let Some(parent) = Path::new(&env_map.resolved_path).parent()
&& !parent.as_os_str().is_empty()
{
dirs.insert(parent.to_path_buf());
}
for dir in meshes.watch_dirs() {
dirs.insert(dir);
}
for dir in skinned_meshes.watch_dirs() {
dirs.insert(dir);
}
for dir in shader_stages.watch_dirs() {
dirs.insert(dir);
}
if let Some(path) = world_jsonl_path {
if let Some(parent) = Path::new(path).parent() {
let dir = if parent.as_os_str().is_empty() {
PathBuf::from(".")
} else {
parent.to_path_buf()
};
dirs.insert(dir);
}
dirs.extend(story_source_dirs(path));
}
let mut any_watched = false;
for dir in dirs {
match watcher.watch(&dir, RecursiveMode::NonRecursive) {
Ok(()) => {
tracing::info!(
"asset hot-reload: watching {} for asset source changes",
dir.display()
);
any_watched = true;
}
Err(e) => {
tracing::warn!(
"asset hot-reload: failed to watch {} ({}); assets sourced from \
that directory will need a manual `reload-assets` to refresh",
dir.display(),
e
);
}
}
}
if any_watched {
Some(watcher)
} else {
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ReloadKind {
ShaderStages,
World,
Stories,
Assets,
}
pub(super) fn classify_event(event: &Event) -> Option<ReloadKind> {
if !is_asset_event(event) {
return None;
}
let has_ext = |matches: fn(&str) -> bool| {
event.paths.iter().any(|p| {
p.extension()
.and_then(|e| e.to_str())
.map(matches)
.unwrap_or(false)
})
};
Some(if has_ext(is_shader_extension) {
ReloadKind::ShaderStages
} else if has_ext(|e| e.eq_ignore_ascii_case("jsonl")) {
ReloadKind::World
} else if has_ext(|e| e.eq_ignore_ascii_case("md")) {
ReloadKind::Stories
} else {
ReloadKind::Assets
})
}
fn signal(kind: ReloadKind, flag: &AtomicBool) {
match kind {
ReloadKind::ShaderStages => super::set_pending_shader_stages(),
ReloadKind::World => super::set_pending_world(),
ReloadKind::Stories => super::set_pending_stories(),
ReloadKind::Assets => {
flag.store(true, Ordering::SeqCst);
crate::app::dev_flags::set_pending_animations();
}
}
}
pub(super) fn story_source_dirs(world_jsonl_path: &str) -> BTreeSet<PathBuf> {
let mut dirs = BTreeSet::new();
let Ok(content) = std::fs::read_to_string(world_jsonl_path) else {
return dirs;
};
for line in content.lines() {
let Ok(entry) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if entry.get("type").and_then(|t| t.as_str()) != Some("StoryImport") {
continue;
}
let Some(source) = entry
.get("args")
.and_then(|a| a.get("source"))
.and_then(|s| s.as_str())
else {
continue;
};
let parent = Path::new(source).parent().unwrap_or(Path::new(""));
dirs.insert(if parent.as_os_str().is_empty() {
PathBuf::from(".")
} else {
parent.to_path_buf()
});
}
dirs
}
pub(super) fn is_asset_event(event: &Event) -> bool {
if !matches!(
event.kind,
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
) {
return false;
}
event.paths.iter().any(|p| {
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
matches!(
ext.to_ascii_lowercase().as_str(),
"png" | "jpg" | "jpeg" | "glb" | "gltf" | "bin" | "cube" | "hdr" | "jsonl" | "md"
) || is_shader_extension(ext)
})
}
pub(super) fn is_shader_extension(ext: &str) -> bool {
ext.eq_ignore_ascii_case("slang")
}