use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use super::stylesheet::global_stylesheet_manager;
#[derive(Debug)]
pub struct CssWatcher {
path: PathBuf,
name: String,
poll_interval: Duration,
last_check: Option<SystemTime>,
last_modified: Option<SystemTime>,
priority: u8,
}
impl CssWatcher {
pub fn new(path: impl Into<PathBuf>, name: impl Into<String>) -> Self {
Self {
path: path.into(),
name: name.into(),
poll_interval: Duration::from_millis(500),
last_check: None,
last_modified: None,
priority: 0,
}
}
pub fn set_poll_interval(&mut self, millis: u64) {
self.poll_interval = Duration::from_millis(millis);
}
pub fn poll_interval(&self) -> u64 {
self.poll_interval.as_millis() as u64
}
pub fn set_priority(&mut self, priority: u8) {
self.priority = priority;
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn name(&self) -> &str {
&self.name
}
pub fn poll(&mut self) -> Result<bool, String> {
let now = SystemTime::now();
if let Some(last) = self.last_check {
match now.duration_since(last) {
Ok(elapsed) if elapsed < self.poll_interval => return Ok(false),
_ => {}
}
}
self.last_check = Some(now);
let metadata = match std::fs::metadata(&self.path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(false);
}
Err(error) => {
return Err(format!(
"cannot stat stylesheet '{}': {error} (check that the path is readable)",
self.path.display()
));
}
};
let modified = metadata.modified().map_err(|error| {
format!("cannot read the modification time of '{}': {error}", self.path.display())
})?;
if self.last_modified == Some(modified) {
return Ok(false);
}
let css = std::fs::read_to_string(&self.path).map_err(|error| {
format!(
"cannot read stylesheet '{}': {error} (it may be mid-write; the next poll will \
retry)",
self.path.display()
)
})?;
super::css::CssParser::parse(&css).map_err(|error| {
format!("stylesheet '{}' is not valid CSS: {error}", self.path.display())
})?;
global_stylesheet_manager().register(&self.name, &css, self.priority);
self.last_modified = Some(modified);
Ok(true)
}
pub fn reload(&mut self) -> Result<(), String> {
self.last_modified = None;
self.last_check = None;
if self.poll()? {
Ok(())
} else {
Err(format!(
"stylesheet '{}' could not be loaded: it does not exist",
self.path.display()
))
}
}
pub fn is_loaded(&self) -> bool {
self.last_modified.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::global_stylesheet_manager;
fn temp_path(tag: &str) -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!("rw-css-watcher-{tag}-{}.css", std::process::id()));
path
}
fn guard() -> std::sync::MutexGuard<'static, ()> {
crate::style::stylesheet_test_guard()
}
fn cleanup(path: &Path) {
let _ = std::fs::remove_file(path);
}
#[test]
fn defaults_are_documented_values() {
let _guard = guard();
let watcher = CssWatcher::new("theme.css", "main");
assert_eq!(watcher.poll_interval(), 500);
assert_eq!(watcher.name(), "main");
assert_eq!(watcher.path(), Path::new("theme.css"));
assert!(!watcher.is_loaded(), "a new watcher has loaded nothing");
}
#[test]
fn a_missing_file_is_not_an_error() {
let _guard = guard();
let mut watcher = CssWatcher::new(temp_path("missing"), "missing-sheet");
assert_eq!(watcher.poll(), Ok(false), "an absent file must not fail the poll");
assert!(!watcher.is_loaded());
}
#[test]
fn reload_registers_the_stylesheet_in_the_global_manager() {
let _guard = guard();
let path = temp_path("load");
std::fs::write(&path, "Button { background-color: #102030; }").expect("write fixture");
let name = format!("watcher-load-{}", std::process::id());
let mut watcher = CssWatcher::new(&path, &name);
watcher.reload().expect("the fixture is valid CSS");
assert!(watcher.is_loaded());
let mut style = crate::style::WidgetStyle::default();
global_stylesheet_manager()
.apply_to("Button", None, None, None, &mut style)
.expect("apply the loaded sheet");
assert_eq!(style.background_color, Some(crate::core::Color::rgb(0x10, 0x20, 0x30)));
global_stylesheet_manager().unregister(&name);
cleanup(&path);
}
#[test]
fn poll_reports_a_change_exactly_once() {
let _guard = guard();
let path = temp_path("once");
std::fs::write(&path, "Button { color: #111111; }").expect("write fixture");
let name = format!("watcher-once-{}", std::process::id());
let mut watcher = CssWatcher::new(&path, &name);
watcher.set_poll_interval(0);
assert_eq!(watcher.poll(), Ok(true), "the first poll loads the file");
assert_eq!(watcher.poll(), Ok(false), "an unchanged file is not reloaded");
assert_eq!(watcher.poll(), Ok(false));
global_stylesheet_manager().unregister(&name);
cleanup(&path);
}
#[test]
fn a_broken_stylesheet_is_reported_and_not_registered() {
let _guard = guard();
let path = temp_path("broken");
std::fs::write(&path, "Button { color: #111111; ").expect("write fixture");
let name = format!("watcher-broken-{}", std::process::id());
let mut watcher = CssWatcher::new(&path, &name);
let error = watcher.reload().expect_err("invalid CSS must be reported");
assert!(error.contains("not valid CSS"), "{error}");
assert!(!watcher.is_loaded(), "a rejected stylesheet must not count as loaded");
let mut style = crate::style::WidgetStyle::default();
global_stylesheet_manager()
.apply_to("Button", None, Some(&name), None, &mut style)
.expect("apply");
assert_ne!(
style.background_color,
Some(crate::core::Color::rgb(0x11, 0x11, 0x11)),
"the rejected stylesheet must not have been registered"
);
cleanup(&path);
}
#[test]
fn a_file_created_after_the_watcher_starts_is_picked_up() {
let _guard = guard();
let path = temp_path("late");
cleanup(&path);
let name = format!("watcher-late-{}", std::process::id());
let mut watcher = CssWatcher::new(&path, &name);
watcher.set_poll_interval(0);
assert_eq!(watcher.poll(), Ok(false), "nothing to read yet");
std::fs::write(&path, "Label { color: #123456; }").expect("write fixture");
assert_eq!(watcher.poll(), Ok(true), "the file appearing is the change");
global_stylesheet_manager().unregister(&name);
cleanup(&path);
}
#[test]
fn poll_interval_suppresses_checks_until_it_elapses() {
let _guard = guard();
let path = temp_path("interval");
std::fs::write(&path, "Button { color: #222222; }").expect("write fixture");
let name = format!("watcher-interval-{}", std::process::id());
let mut watcher = CssWatcher::new(&path, &name);
watcher.set_poll_interval(60_000);
assert_eq!(watcher.poll(), Ok(true), "the first poll always checks");
std::fs::write(&path, "Button { color: #333333; }").expect("rewrite fixture");
assert_eq!(watcher.poll(), Ok(false), "a change inside the interval is deferred, not lost");
global_stylesheet_manager().unregister(&name);
cleanup(&path);
}
}