1use anyhow::{Context, Result};
7use notify::{Config, Event, PollWatcher, RecursiveMode, Watcher};
8use parking_lot::Mutex;
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::sync::mpsc::{Receiver, channel};
13use std::time::{Duration, Instant};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum ShaderType {
18 Background,
20 Cursor,
22}
23
24#[derive(Debug, Clone)]
26pub struct ShaderReloadEvent {
27 pub shader_type: ShaderType,
29 pub path: PathBuf,
31}
32
33pub struct ShaderWatcher {
35 _watcher: PollWatcher,
37 event_receiver: Receiver<ShaderReloadEvent>,
39 debounce_delay_ms: u64,
41}
42
43impl std::fmt::Debug for ShaderWatcher {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 f.debug_struct("ShaderWatcher")
46 .field("debounce_delay_ms", &self.debounce_delay_ms)
47 .finish_non_exhaustive()
48 }
49}
50
51impl ShaderWatcher {
52 pub fn new(
59 background_shader_path: Option<&Path>,
60 cursor_shader_path: Option<&Path>,
61 debounce_delay_ms: u64,
62 ) -> Result<Self> {
63 let (tx, rx) = channel();
64 let debounce_state: Arc<Mutex<HashMap<ShaderType, Instant>>> =
65 Arc::new(Mutex::new(HashMap::new()));
66
67 let mut filename_to_type: HashMap<std::ffi::OsString, (ShaderType, PathBuf)> =
71 HashMap::new();
72 let mut dirs_to_watch: HashMap<PathBuf, ()> = HashMap::new();
73
74 if let Some(path) = background_shader_path {
75 if !path.exists() {
76 anyhow::bail!("Background shader file not found: {}", path.display());
77 }
78 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
79 if let Some(filename) = canonical.file_name() {
80 filename_to_type.insert(
81 filename.to_os_string(),
82 (ShaderType::Background, canonical.clone()),
83 );
84 if let Some(parent) = canonical.parent() {
85 dirs_to_watch.insert(parent.to_path_buf(), ());
86 }
87 }
88 log::info!(
89 "Shader hot reload: watching background shader at {}",
90 canonical.display()
91 );
92 }
93 if let Some(path) = cursor_shader_path {
94 if !path.exists() {
95 anyhow::bail!("Cursor shader file not found: {}", path.display());
96 }
97 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
98 if let Some(filename) = canonical.file_name() {
99 filename_to_type.insert(
100 filename.to_os_string(),
101 (ShaderType::Cursor, canonical.clone()),
102 );
103 if let Some(parent) = canonical.parent() {
104 dirs_to_watch.insert(parent.to_path_buf(), ());
105 }
106 }
107 log::info!(
108 "Shader hot reload: watching cursor shader at {}",
109 canonical.display()
110 );
111 }
112
113 if filename_to_type.is_empty() {
114 anyhow::bail!("No shader paths provided for hot reload");
115 }
116
117 let filename_to_type = Arc::new(filename_to_type);
118 let debounce_delay = Duration::from_millis(debounce_delay_ms);
119 let debounce_state_clone = Arc::clone(&debounce_state);
120
121 let mut watcher = PollWatcher::new(
123 move |result: std::result::Result<Event, notify::Error>| {
124 if let Ok(event) = result {
125 log::debug!(
126 "File system event: {:?} for paths: {:?}",
127 event.kind,
128 event.paths
129 );
130
131 if !matches!(
133 event.kind,
134 notify::EventKind::Modify(_)
135 | notify::EventKind::Create(_)
136 | notify::EventKind::Remove(_)
137 ) {
138 log::trace!("Ignoring event kind: {:?}", event.kind);
139 return;
140 }
141
142 let filename_to_type = Arc::clone(&filename_to_type);
143 let debounce_state = Arc::clone(&debounce_state_clone);
144
145 for path in event.paths {
147 let Some(filename) = path.file_name() else {
149 log::trace!("Skipping path with no filename: {:?}", path);
150 continue;
151 };
152
153 let Some((shader_type, canonical_path)) =
154 filename_to_type.get(filename).cloned()
155 else {
156 log::trace!("Filename {:?} not in watch list", filename);
157 continue;
158 };
159
160 let should_send = {
162 let now = Instant::now();
163 let mut state = debounce_state.lock();
164 if let Some(last_event) = state.get(&shader_type) {
165 if now.duration_since(*last_event) < debounce_delay {
166 log::trace!("Debouncing shader reload for {:?}", shader_type);
167 false
168 } else {
169 state.insert(shader_type, now);
170 true
171 }
172 } else {
173 state.insert(shader_type, now);
174 true
175 }
176 };
177
178 if should_send {
179 let reload_event = ShaderReloadEvent {
180 shader_type,
181 path: canonical_path,
182 };
183 log::info!(
184 "Shader file changed: {:?} at {}",
185 shader_type,
186 reload_event.path.display()
187 );
188 if let Err(e) = tx.send(reload_event) {
189 log::error!("Failed to send shader reload event: {}", e);
190 }
191 }
192 }
193 }
194 },
195 Config::default().with_poll_interval(Duration::from_millis(100)),
196 )
197 .context("Failed to create file watcher")?;
198
199 for dir in dirs_to_watch.keys() {
201 watcher
202 .watch(dir, RecursiveMode::NonRecursive)
203 .with_context(|| format!("Failed to watch shader directory: {}", dir.display()))?;
204 log::debug!("Watching directory for shader changes: {}", dir.display());
205 }
206
207 Ok(Self {
208 _watcher: watcher,
209 event_receiver: rx,
210 debounce_delay_ms,
211 })
212 }
213
214 pub fn try_recv(&self) -> Option<ShaderReloadEvent> {
218 self.event_receiver.try_recv().ok()
219 }
220
221 pub fn debounce_delay_ms(&self) -> u64 {
223 self.debounce_delay_ms
224 }
225}
226
227pub struct ShaderWatcherBuilder {
229 background_shader_path: Option<PathBuf>,
230 cursor_shader_path: Option<PathBuf>,
231 debounce_delay_ms: u64,
232}
233
234impl ShaderWatcherBuilder {
235 pub fn new() -> Self {
237 Self {
238 background_shader_path: None,
239 cursor_shader_path: None,
240 debounce_delay_ms: 100,
241 }
242 }
243
244 pub fn background_shader(mut self, path: impl Into<PathBuf>) -> Self {
246 self.background_shader_path = Some(path.into());
247 self
248 }
249
250 pub fn cursor_shader(mut self, path: impl Into<PathBuf>) -> Self {
252 self.cursor_shader_path = Some(path.into());
253 self
254 }
255
256 pub fn debounce_delay_ms(mut self, delay_ms: u64) -> Self {
258 self.debounce_delay_ms = delay_ms;
259 self
260 }
261
262 pub fn build(self) -> Result<ShaderWatcher> {
264 ShaderWatcher::new(
265 self.background_shader_path.as_deref(),
266 self.cursor_shader_path.as_deref(),
267 self.debounce_delay_ms,
268 )
269 }
270}
271
272impl Default for ShaderWatcherBuilder {
273 fn default() -> Self {
274 Self::new()
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use std::fs;
282 use tempfile::TempDir;
283
284 #[test]
285 fn test_shader_type_equality() {
286 assert_eq!(ShaderType::Background, ShaderType::Background);
287 assert_eq!(ShaderType::Cursor, ShaderType::Cursor);
288 assert_ne!(ShaderType::Background, ShaderType::Cursor);
289 }
290
291 #[test]
292 fn test_shader_watcher_builder_default() {
293 let builder = ShaderWatcherBuilder::default();
294 assert!(builder.background_shader_path.is_none());
295 assert!(builder.cursor_shader_path.is_none());
296 assert_eq!(builder.debounce_delay_ms, 100);
297 }
298
299 #[test]
300 fn test_shader_watcher_builder_with_paths() {
301 let builder = ShaderWatcherBuilder::new()
302 .background_shader("/tmp/test.glsl")
303 .cursor_shader("/tmp/cursor.glsl")
304 .debounce_delay_ms(200);
305
306 assert_eq!(
307 builder.background_shader_path,
308 Some(PathBuf::from("/tmp/test.glsl"))
309 );
310 assert_eq!(
311 builder.cursor_shader_path,
312 Some(PathBuf::from("/tmp/cursor.glsl"))
313 );
314 assert_eq!(builder.debounce_delay_ms, 200);
315 }
316
317 #[test]
318 fn test_watcher_creation_with_valid_path() {
319 let temp_dir = TempDir::new().expect("Failed to create temp dir");
320 let shader_path = temp_dir.path().join("test.glsl");
321 fs::write(
322 &shader_path,
323 "void mainImage(out vec4 fragColor, in vec2 fragCoord) { fragColor = vec4(1.0); }",
324 )
325 .expect("Failed to write shader");
326
327 let result = ShaderWatcher::new(Some(&shader_path), None, 100);
328 assert!(result.is_ok());
329 }
330
331 #[test]
332 fn test_watcher_creation_no_paths_fails() {
333 let result = ShaderWatcher::new(None, None, 100);
334 assert!(result.is_err());
335 }
336
337 #[test]
338 fn test_try_recv_empty() {
339 let temp_dir = TempDir::new().expect("Failed to create temp dir");
340 let shader_path = temp_dir.path().join("test.glsl");
341 fs::write(
342 &shader_path,
343 "void mainImage(out vec4 fragColor, in vec2 fragCoord) { fragColor = vec4(1.0); }",
344 )
345 .expect("Failed to write shader");
346
347 let watcher =
348 ShaderWatcher::new(Some(&shader_path), None, 100).expect("Failed to create watcher");
349
350 assert!(watcher.try_recv().is_none());
352 }
353
354 #[test]
355 fn test_shader_reload_event_debug() {
356 let event = ShaderReloadEvent {
357 shader_type: ShaderType::Background,
358 path: PathBuf::from("/tmp/test.glsl"),
359 };
360 let debug_str = format!("{:?}", event);
361 assert!(debug_str.contains("Background"));
362 assert!(debug_str.contains("test.glsl"));
363 }
364
365 #[test]
366 fn test_file_change_triggers_event() {
367 let temp_dir = TempDir::new().expect("Failed to create temp dir");
368 let shader_path = temp_dir.path().join("test.glsl");
369 fs::write(
370 &shader_path,
371 "void mainImage(out vec4 fragColor, in vec2 fragCoord) { fragColor = vec4(1.0); }",
372 )
373 .expect("Failed to write shader");
374
375 let watcher =
376 ShaderWatcher::new(Some(&shader_path), None, 50).expect("Failed to create watcher");
377
378 std::thread::sleep(std::time::Duration::from_millis(100));
380
381 fs::write(
383 &shader_path,
384 "void mainImage(out vec4 fragColor, in vec2 fragCoord) { fragColor = vec4(0.5); }",
385 )
386 .expect("Failed to write shader");
387
388 std::thread::sleep(std::time::Duration::from_millis(200));
390
391 let event = watcher.try_recv();
393 if let Some(evt) = event {
395 assert_eq!(evt.shader_type, ShaderType::Background);
396 }
397 }
398}