use std::path::Path;
use std::sync::Arc;
use crate::cascade::{ComputedStyle, ComputeScratch};
use crate::error::{CssError, Result};
use crate::media::MediaContext;
use crate::node::StyledNode;
use crate::stylesheet::{Origin, Stylesheet};
enum Base {
Static(&'static Stylesheet),
Owned(Arc<Stylesheet>),
}
pub struct RuntimeStyle {
base: Base,
runtime: Option<Stylesheet>,
sheet: Stylesheet,
last_mtime: Option<std::time::SystemTime>,
media: MediaContext,
}
impl RuntimeStyle {
pub fn new(embedded: &'static Stylesheet) -> Self {
Self {
base: Base::Static(embedded),
runtime: None,
sheet: embedded.clone(),
last_mtime: None,
media: MediaContext::default(),
}
}
pub fn from_owned(embedded: Arc<Stylesheet>) -> Self {
let sheet = embedded.as_ref().clone();
Self {
base: Base::Owned(embedded),
runtime: None,
last_mtime: None,
sheet,
media: MediaContext::default(),
}
}
fn base(&self) -> &Stylesheet {
match &self.base {
Base::Static(s) => s,
Base::Owned(s) => s,
}
}
pub fn load_override(&mut self, path: &Path) -> Result<()> {
match std::fs::read_to_string(path) {
Ok(css) => {
let runtime = Stylesheet::parse_with_origin(&css, Origin::User)?;
let mut sheet = self.base().clone();
sheet.extend(&runtime);
self.runtime = Some(runtime);
self.sheet = sheet;
self.last_mtime = current_mtime(path);
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
self.runtime = None;
self.sheet = self.base().clone();
self.last_mtime = None;
Ok(())
}
Err(e) => Err(CssError::io(format!(
"cannot read runtime CSS {}: {e}",
path.display()
))),
}
}
pub fn reload_if_changed(&mut self, path: &Path) -> Result<bool> {
match std::fs::metadata(path) {
Ok(meta) => {
let mtime = meta.modified();
match (mtime, self.last_mtime) {
(Ok(m), Some(prev)) if m == prev => {
Ok(false)
}
_ => {
self.load_override(path)?;
Ok(true)
}
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if self.has_override() {
self.load_override(path)?;
Ok(true)
} else {
Ok(false)
}
}
Err(e) => Err(CssError::io(format!(
"cannot stat runtime CSS {}: {e}",
path.display()
))),
}
}
pub fn set_media(&mut self, media: MediaContext) -> &mut Self {
self.media = media;
self
}
pub fn with_media(mut self, media: MediaContext) -> Self {
self.media = media;
self
}
pub fn media(&self) -> &MediaContext {
&self.media
}
pub fn compute(&self, node: &dyn StyledNode, parent: Option<&ComputedStyle>) -> ComputedStyle {
let mut scratch = ComputeScratch::new();
self.sheet.compute_with_media(node, parent, &mut scratch, &self.media)
}
pub fn compute_with(
&self,
node: &dyn StyledNode,
parent: Option<&ComputedStyle>,
scratch: &mut ComputeScratch,
) -> ComputedStyle {
self.sheet.compute_with_media(node, parent, scratch, &self.media)
}
pub fn embedded(&self) -> &Stylesheet {
self.base()
}
pub fn runtime(&self) -> Option<&Stylesheet> {
self.runtime.as_ref()
}
pub fn has_override(&self) -> bool {
self.runtime.is_some()
}
}
fn current_mtime(path: &Path) -> Option<std::time::SystemTime> {
std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
}
const _: () = {
const fn _assert_send_sync<T: Send + Sync>() {}
const _PROOF: () = _assert_send_sync::<RuntimeStyle>();
};
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeRef;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
fn temp_css(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"rss-{}-{}.css",
std::process::id(),
name
))
}
#[test]
fn owned_base_works() {
let base = Arc::new(Stylesheet::parse("Button { color: red; }").unwrap());
let style = RuntimeStyle::from_owned(base);
let node = NodeRef::new("Button");
let computed = style.compute(&node, None);
let color = computed.style.color.expect("color should be set");
assert!(
matches!(color, crate::color::Color::Literal(_)),
"owned base should set the button color to a literal, got {color:?}"
);
}
#[test]
fn owned_base_then_override() {
let path = temp_css("owned_base_then_override");
std::fs::write(&path, ".primary { background: blue; }").unwrap();
let base = Arc::new(Stylesheet::parse("Button { color: red; }").unwrap());
let mut style = RuntimeStyle::from_owned(base);
style.load_override(&path).unwrap();
assert!(style.has_override());
let node = NodeRef::new("Button").classes(&["primary"]);
let computed = style.compute(&node, None);
let color = computed.style.color.expect("base color (red) should apply");
assert!(
matches!(color, crate::color::Color::Literal(_)),
"base color should still apply, got {color:?}"
);
let bg = computed
.style
.background
.expect("override background (blue) should apply");
assert!(
matches!(bg, crate::color::Color::Literal(_)),
"override background should apply, got {bg:?}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn reload_if_changed_no_change() {
let path = temp_css("reload_no_change");
std::fs::write(&path, "Button { color: red; }").unwrap();
let base = Arc::new(Stylesheet::parse("Root {}").unwrap());
let mut style = RuntimeStyle::from_owned(base);
style.load_override(&path).unwrap();
let reloaded = style.reload_if_changed(&path).unwrap();
assert!(!reloaded, "no file change → should not reload");
let _ = std::fs::remove_file(&path);
}
#[test]
fn reload_if_changed_after_edit() {
let path = temp_css("reload_after_edit");
std::fs::write(&path, "Button { color: red; }").unwrap();
let base = Arc::new(Stylesheet::parse("Root {}").unwrap());
let mut style = RuntimeStyle::from_owned(base);
style.load_override(&path).unwrap();
let before = style
.compute(&NodeRef::new("Button"), None)
.style
.color
.expect("v1 sets color");
thread::sleep(Duration::from_millis(20));
std::fs::write(&path, "Button { color: blue; }").unwrap();
let reloaded = style.reload_if_changed(&path).unwrap();
assert!(reloaded, "file changed → should reload");
let after = style
.compute(&NodeRef::new("Button"), None)
.style
.color
.expect("v2 sets color");
assert_ne!(
before, after,
"the reloaded value should differ from the original"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn reload_if_changed_file_removed() {
let path = temp_css("reload_file_removed");
std::fs::write(&path, "Button { color: red; }").unwrap();
let base = Arc::new(Stylesheet::parse("Root {}").unwrap());
let mut style = RuntimeStyle::from_owned(base);
style.load_override(&path).unwrap();
assert!(style.has_override());
std::fs::remove_file(&path).unwrap();
let reloaded = style.reload_if_changed(&path).unwrap();
assert!(reloaded, "override file disappearing should clear the override");
assert!(!style.has_override());
}
#[test]
fn runtime_media_gated_rule_applies_when_context_matches() {
let base = Arc::new(
Stylesheet::parse("@media (min-width: 80) { Button { color: red; } }").unwrap(),
);
let style = RuntimeStyle::from_owned(base).with_media(crate::media::MediaContext {
cols: 100,
rows: 24,
..Default::default()
});
let node = NodeRef::new("Button");
let computed = style.compute(&node, None);
assert_eq!(
computed.style.color,
Some(crate::color::Color::literal(ratatui::style::Color::Red))
);
}
#[test]
fn runtime_media_gated_rule_skipped_when_context_misses() {
let base = Arc::new(
Stylesheet::parse("@media (min-width: 80) { Button { color: red; } }").unwrap(),
);
let style = RuntimeStyle::from_owned(base).with_media(crate::media::MediaContext {
cols: 60,
..Default::default()
});
let node = NodeRef::new("Button");
let computed = style.compute(&node, None);
assert_eq!(computed.style.color, None);
}
#[test]
fn runtime_set_media_updates_live() {
let base = Arc::new(
Stylesheet::parse("@media (min-width: 80) { Button { color: red; } }").unwrap(),
);
let mut style = RuntimeStyle::from_owned(base);
style.set_media(crate::media::MediaContext {
cols: 120,
..Default::default()
});
let on = style.compute(&NodeRef::new("Button"), None);
assert!(on.style.color.is_some());
style.set_media(crate::media::MediaContext {
cols: 40,
..Default::default()
});
let off = style.compute(&NodeRef::new("Button"), None);
assert!(off.style.color.is_none());
}
}