Skip to main content

ratatui_style/
runtime.rs

1//! Runtime-overridable stylesheets.
2//!
3//! [`RuntimeStyle`] layers a base stylesheet (tagged [`Origin::Theme`]) with an
4//! optional CSS file loaded from the filesystem at runtime (tagged
5//! [`Origin::User`]). Because `Theme < User` in the cascade ordering, runtime
6//! rules override base rules at equal specificity — no special merge logic
7//! required.
8//!
9//! The base can come from two sources:
10//! - a **compile-time `&'static`** stylesheet produced by the
11//!   [`css!`](crate::css) macro ([`RuntimeStyle::new`]); or
12//! - an **owned, runtime-parsed** stylesheet wrapped in an `Arc`
13//!   ([`RuntimeStyle::from_owned`]), so purely runtime-driven theme loading
14//!   never needs to `Box::leak`.
15//!
16//! For live theming, [`RuntimeStyle::reload_if_changed`] watches a CSS file's
17//! mtime and re-parses it only when it changes — cheap to call every app tick.
18
19use std::path::Path;
20use std::sync::Arc;
21
22use crate::cascade::{ComputedStyle, ComputeScratch};
23use crate::error::{CssError, Result};
24use crate::media::MediaContext;
25use crate::node::StyledNode;
26use crate::stylesheet::{Origin, Stylesheet};
27
28/// Where the base (non-overridden) stylesheet of a [`RuntimeStyle`] comes from.
29///
30/// [`Static`](Self::Static) is the zero-cost path used by the
31/// [`css!`](crate::css) macro (a `&'static Stylesheet`). [`Owned`](Self::Owned)
32/// lets callers supply a runtime-parsed stylesheet via an `Arc`, so themes loaded
33/// from disk/config never need to leak memory.
34enum Base {
35    /// A compile-time embedded stylesheet (e.g. produced by the `css!` macro).
36    Static(&'static Stylesheet),
37    /// A runtime-parsed, refcounted stylesheet.
38    Owned(Arc<Stylesheet>),
39}
40
41/// A stylesheet layered from a base plus an optional runtime override.
42///
43/// Construct the base via either:
44/// - [`RuntimeStyle::new`] — wrap a compile-time `&'static Stylesheet`
45///   (typically from the [`css!`](crate::css) macro), or
46/// - [`RuntimeStyle::from_owned`] — wrap a runtime-parsed `Arc<Stylesheet>`
47///   (e.g. `RuntimeStyle::from_owned(Arc::new(Stylesheet::parse(&css)?))`),
48///   which avoids leaking memory for themes loaded purely at runtime.
49///
50/// Then optionally call [`RuntimeStyle::load_override`] (one-shot) or
51/// [`RuntimeStyle::reload_if_changed`] (mtime-based, tick-friendly) to apply a
52/// user-supplied CSS file. The merged sheet is recomputed only when the override
53/// changes, so [`RuntimeStyle::compute`] stays allocation-free.
54pub struct RuntimeStyle {
55    /// The base stylesheet (Origin::Theme), either static or owned.
56    base: Base,
57    /// The runtime override (Origin::User), if one is loaded.
58    runtime: Option<Stylesheet>,
59    /// The always-ready merged sheet: base cloned, optionally extended with
60    /// `runtime`. Owned so that [`Self::compute`] is zero-copy.
61    sheet: Stylesheet,
62    /// The mtime recorded for the override `path` the last time it was loaded.
63    /// Used by [`Self::reload_if_changed`] to skip unchanged files.
64    last_mtime: Option<std::time::SystemTime>,
65    /// The active terminal context used to gate `@media` rules during
66    /// [`compute`](Self::compute). Defaults to all-zero / no media info, in
67    /// which case media-gated rules with any condition do NOT match. Set per
68    /// frame via [`set_media`](Self::set_media) / [`with_media`](Self::with_media).
69    media: MediaContext,
70}
71
72impl RuntimeStyle {
73    /// Wrap a compile-time `&'static` embedded stylesheet with no runtime
74    /// override. This is the path used by the [`css!`](crate::css) macro and is
75    /// zero-cost (no allocation, no refcount).
76    pub fn new(embedded: &'static Stylesheet) -> Self {
77        Self {
78            base: Base::Static(embedded),
79            runtime: None,
80            sheet: embedded.clone(),
81            last_mtime: None,
82            media: MediaContext::default(),
83        }
84    }
85
86    /// Wrap a runtime-parsed, owned stylesheet with no runtime override.
87    ///
88    /// For apps that load their theme purely at runtime (from disk, config,
89    /// network, …) there is no compile-time `&'static` to borrow. This
90    /// constructor takes an `Arc<Stylesheet>` so the caller never needs to
91    /// `Box::leak`:
92    ///
93    /// ```no_run
94    /// # use std::sync::Arc;
95    /// # use ratatui_style::{RuntimeStyle, Stylesheet};
96    /// let css = "Button { color: red; }";
97    /// let style = RuntimeStyle::from_owned(Arc::new(Stylesheet::parse(css).unwrap()));
98    /// ```
99    pub fn from_owned(embedded: Arc<Stylesheet>) -> Self {
100        // Initialize the merged sheet from a clone of the base.
101        let sheet = embedded.as_ref().clone();
102        Self {
103            base: Base::Owned(embedded),
104            runtime: None,
105            last_mtime: None,
106            sheet,
107            media: MediaContext::default(),
108        }
109    }
110
111    /// Returns the base [`Stylesheet`], regardless of whether it is static or
112    /// owned.
113    fn base(&self) -> &Stylesheet {
114        match &self.base {
115            Base::Static(s) => s,
116            Base::Owned(s) => s,
117        }
118    }
119
120    /// Load (or reload) a runtime CSS override from `path`.
121    ///
122    /// If the file exists it is parsed and merged onto the base stylesheet;
123    /// its rules carry [`Origin::User`] and override the base [`Origin::Theme`]
124    /// rules at equal specificity. If the file does **not** exist, this is not
125    /// an error — the base stylesheet is used as-is and any previously loaded
126    /// override is cleared. Other I/O or parse failures are returned as
127    /// [`CssError`].
128    ///
129    /// This performs a full re-read and re-parse every call. For cheap,
130    /// mtime-gated reloading in an app tick, see [`Self::reload_if_changed`].
131    pub fn load_override(&mut self, path: &Path) -> Result<()> {
132        match std::fs::read_to_string(path) {
133            Ok(css) => {
134                let runtime = Stylesheet::parse_with_origin(&css, Origin::User)?;
135                // Rebuild the merged sheet from a clean clone of the base,
136                // then layer the runtime override on top.
137                let mut sheet = self.base().clone();
138                sheet.extend(&runtime);
139                self.runtime = Some(runtime);
140                self.sheet = sheet;
141                // Record the mtime so reload_if_changed can detect later edits.
142                self.last_mtime = current_mtime(path);
143                Ok(())
144            }
145            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
146                self.runtime = None;
147                self.sheet = self.base().clone();
148                self.last_mtime = None;
149                Ok(())
150            }
151            Err(e) => Err(CssError::io(format!(
152                "cannot read runtime CSS {}: {e}",
153                path.display()
154            ))),
155        }
156    }
157
158    /// Reload the override at `path` only if its mtime changed since the last
159    /// load; otherwise do nothing.
160    ///
161    /// Returns `true` when a reload actually happened (the file changed and was
162    /// re-parsed), or when an existing override was cleared because the file
163    /// disappeared (mirroring [`Self::load_override`]'s `NotFound` semantics).
164    /// Returns `false` when nothing changed.
165    ///
166    /// Call this from an app's event-loop tick to get "edit the theme file →
167    /// see it live" behavior without re-parsing every frame:
168    ///
169    /// ```no_run
170    /// # use std::path::Path;
171    /// # use std::sync::Arc;
172    /// # use ratatui_style::{RuntimeStyle, Stylesheet};
173    /// # let base = Arc::new(Stylesheet::parse("Root { color: red; }").unwrap());
174    /// # let mut style = RuntimeStyle::from_owned(base);
175    /// # let path = Path::new("/tmp/theme.css");
176    /// // in your tick / poll loop:
177    /// if style.reload_if_changed(path).unwrap() {
178    ///     // theme was updated — the next compute() reflects the new rules
179    /// }
180    /// ```
181    ///
182    /// **Degradation policy:** if the filesystem cannot report a modification
183    /// time for `path` (e.g. some network/FUSE mounts), this is treated as a
184    /// change — the file is reloaded and `true` is returned — so updates are
185    /// never silently dropped. `NotFound` still means "override removed".
186    pub fn reload_if_changed(&mut self, path: &Path) -> Result<bool> {
187        match std::fs::metadata(path) {
188            // File exists: compare mtime, reload only if changed.
189            Ok(meta) => {
190                let mtime = meta.modified();
191                match (mtime, self.last_mtime) {
192                    (Ok(m), Some(prev)) if m == prev => {
193                        // Unchanged — nothing to do.
194                        Ok(false)
195                    }
196                    // Different, unknown, or first load → reload. (Unknown mtime
197                    // degrades to "always reload" so we never miss an update.)
198                    _ => {
199                        self.load_override(path)?;
200                        Ok(true)
201                    }
202                }
203            }
204            // File gone: clear override iff we had one (matches load_override).
205            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
206                if self.has_override() {
207                    self.load_override(path)?;
208                    Ok(true)
209                } else {
210                    Ok(false)
211                }
212            }
213            Err(e) => Err(CssError::io(format!(
214                "cannot stat runtime CSS {}: {e}",
215                path.display()
216            ))),
217        }
218    }
219
220    /// Set the active [`MediaContext`] used to gate `@media` rules during
221    /// [`compute`](Self::compute) / [`compute_with`](Self::compute_with).
222    /// Returns `&mut Self` for chaining. Call this once per frame (e.g. after
223    /// reading the terminal size) before computing node styles, so width-/
224    /// color-conditional rules apply.
225    pub fn set_media(&mut self, media: MediaContext) -> &mut Self {
226        self.media = media;
227        self
228    }
229
230    /// Consuming builder form of [`set_media`](Self::set_media).
231    pub fn with_media(mut self, media: MediaContext) -> Self {
232        self.media = media;
233        self
234    }
235
236    /// The currently active [`MediaContext`].
237    pub fn media(&self) -> &MediaContext {
238        &self.media
239    }
240
241    /// Compute the resolved style for `node`, optionally inheriting from
242    /// `parent`. Delegates to the pre-merged sheet, so this is allocation-free.
243    ///
244    /// `@media` rules are gated against [`media`](Self::media); set it via
245    /// [`set_media`](Self::set_media) / [`with_media`](Self::with_media) so
246    /// width-/color-conditional rules apply.
247    pub fn compute(&self, node: &dyn StyledNode, parent: Option<&ComputedStyle>) -> ComputedStyle {
248        // Drive compute through the media-aware path so stored media context
249        // takes effect. Falls back to the no-scratch one-shot internally.
250        let mut scratch = ComputeScratch::new();
251        self.sheet.compute_with_media(node, parent, &mut scratch, &self.media)
252    }
253
254    /// Compute using a caller-provided [`ComputeScratch`], reused across calls.
255    ///
256    /// Delegates to [`Stylesheet::compute_with_media`] on the pre-merged sheet,
257    /// gating `@media` rules against [`media`](Self::media). Use this in the
258    /// draw loop alongside [`NodeRef`](crate::node::NodeRef) for a fully
259    /// allocation-free per-frame path.
260    pub fn compute_with(
261        &self,
262        node: &dyn StyledNode,
263        parent: Option<&ComputedStyle>,
264        scratch: &mut ComputeScratch,
265    ) -> ComputedStyle {
266        self.sheet.compute_with_media(node, parent, scratch, &self.media)
267    }
268
269    /// The base (compile-time or owned) stylesheet.
270    pub fn embedded(&self) -> &Stylesheet {
271        self.base()
272    }
273
274    /// The runtime override stylesheet, if one is loaded.
275    pub fn runtime(&self) -> Option<&Stylesheet> {
276        self.runtime.as_ref()
277    }
278
279    /// Whether a runtime override is currently active.
280    pub fn has_override(&self) -> bool {
281        self.runtime.is_some()
282    }
283}
284
285/// Read the modification time of `path`, returning `None` if unavailable.
286fn current_mtime(path: &Path) -> Option<std::time::SystemTime> {
287    std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
288}
289
290// Compile-time proof that RuntimeStyle stays Send + Sync: the `Arc<Stylesheet>`
291// base is Send+Sync (Stylesheet is Send+Sync), and the other fields are too.
292const _: () = {
293    const fn _assert_send_sync<T: Send + Sync>() {}
294    const _PROOF: () = _assert_send_sync::<RuntimeStyle>();
295};
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::node::NodeRef;
301    use std::sync::Arc;
302    use std::thread;
303    use std::time::Duration;
304
305    /// A unique temp CSS path for one test file.
306    fn temp_css(name: &str) -> std::path::PathBuf {
307        std::env::temp_dir().join(format!(
308            "rss-{}-{}.css",
309            std::process::id(),
310            name
311        ))
312    }
313
314    #[test]
315    fn owned_base_works() {
316        let base = Arc::new(Stylesheet::parse("Button { color: red; }").unwrap());
317        let style = RuntimeStyle::from_owned(base);
318
319        let node = NodeRef::new("Button");
320        let computed = style.compute(&node, None);
321        // color: red resolves to a non-Reset Color::Literal. Assert the
322        // resolved color is set (not Reset/default).
323        let color = computed.style.color.expect("color should be set");
324        assert!(
325            matches!(color, crate::color::Color::Literal(_)),
326            "owned base should set the button color to a literal, got {color:?}"
327        );
328    }
329
330    #[test]
331    fn owned_base_then_override() {
332        let path = temp_css("owned_base_then_override");
333        std::fs::write(&path, ".primary { background: blue; }").unwrap();
334
335        let base = Arc::new(Stylesheet::parse("Button { color: red; }").unwrap());
336        let mut style = RuntimeStyle::from_owned(base);
337        style.load_override(&path).unwrap();
338        assert!(style.has_override());
339
340        // A `.primary` Button: background comes from the override (blue),
341        // color comes from the base (red).
342        let node = NodeRef::new("Button").classes(&["primary"]);
343        let computed = style.compute(&node, None);
344        let color = computed.style.color.expect("base color (red) should apply");
345        assert!(
346            matches!(color, crate::color::Color::Literal(_)),
347            "base color should still apply, got {color:?}"
348        );
349        let bg = computed
350            .style
351            .background
352            .expect("override background (blue) should apply");
353        assert!(
354            matches!(bg, crate::color::Color::Literal(_)),
355            "override background should apply, got {bg:?}"
356        );
357
358        let _ = std::fs::remove_file(&path);
359    }
360
361    #[test]
362    fn reload_if_changed_no_change() {
363        let path = temp_css("reload_no_change");
364        std::fs::write(&path, "Button { color: red; }").unwrap();
365
366        let base = Arc::new(Stylesheet::parse("Root {}").unwrap());
367        let mut style = RuntimeStyle::from_owned(base);
368        style.load_override(&path).unwrap();
369
370        // Immediately re-check without any change: should be false.
371        let reloaded = style.reload_if_changed(&path).unwrap();
372        assert!(!reloaded, "no file change → should not reload");
373
374        let _ = std::fs::remove_file(&path);
375    }
376
377    #[test]
378    fn reload_if_changed_after_edit() {
379        let path = temp_css("reload_after_edit");
380        std::fs::write(&path, "Button { color: red; }").unwrap();
381
382        let base = Arc::new(Stylesheet::parse("Root {}").unwrap());
383        let mut style = RuntimeStyle::from_owned(base);
384        style.load_override(&path).unwrap();
385
386        let before = style
387            .compute(&NodeRef::new("Button"), None)
388            .style
389            .color
390            .expect("v1 sets color");
391
392        // Sleep to guarantee an observable mtime delta, then rewrite the file.
393        thread::sleep(Duration::from_millis(20));
394        std::fs::write(&path, "Button { color: blue; }").unwrap();
395
396        let reloaded = style.reload_if_changed(&path).unwrap();
397        assert!(reloaded, "file changed → should reload");
398
399        let after = style
400            .compute(&NodeRef::new("Button"), None)
401            .style
402            .color
403            .expect("v2 sets color");
404        assert_ne!(
405            before, after,
406            "the reloaded value should differ from the original"
407        );
408
409        let _ = std::fs::remove_file(&path);
410    }
411
412    #[test]
413    fn reload_if_changed_file_removed() {
414        let path = temp_css("reload_file_removed");
415        std::fs::write(&path, "Button { color: red; }").unwrap();
416
417        let base = Arc::new(Stylesheet::parse("Root {}").unwrap());
418        let mut style = RuntimeStyle::from_owned(base);
419        style.load_override(&path).unwrap();
420        assert!(style.has_override());
421
422        std::fs::remove_file(&path).unwrap();
423        let reloaded = style.reload_if_changed(&path).unwrap();
424        assert!(reloaded, "override file disappearing should clear the override");
425        assert!(!style.has_override());
426    }
427
428    // ---------------------------------------------------------------------
429    // @media queries
430    // ---------------------------------------------------------------------
431
432    #[test]
433    fn runtime_media_gated_rule_applies_when_context_matches() {
434        let base = Arc::new(
435            Stylesheet::parse("@media (min-width: 80) { Button { color: red; } }").unwrap(),
436        );
437        let style = RuntimeStyle::from_owned(base).with_media(crate::media::MediaContext {
438            cols: 100,
439            rows: 24,
440            ..Default::default()
441        });
442
443        let node = NodeRef::new("Button");
444        let computed = style.compute(&node, None);
445        assert_eq!(
446            computed.style.color,
447            Some(crate::color::Color::literal(ratatui::style::Color::Red))
448        );
449    }
450
451    #[test]
452    fn runtime_media_gated_rule_skipped_when_context_misses() {
453        let base = Arc::new(
454            Stylesheet::parse("@media (min-width: 80) { Button { color: red; } }").unwrap(),
455        );
456        // cols = 60 < 80 → rule does not apply.
457        let style = RuntimeStyle::from_owned(base).with_media(crate::media::MediaContext {
458            cols: 60,
459            ..Default::default()
460        });
461
462        let node = NodeRef::new("Button");
463        let computed = style.compute(&node, None);
464        assert_eq!(computed.style.color, None);
465    }
466
467    #[test]
468    fn runtime_set_media_updates_live() {
469        // set_media per "frame" should flip the gated rule on and off.
470        let base = Arc::new(
471            Stylesheet::parse("@media (min-width: 80) { Button { color: red; } }").unwrap(),
472        );
473        let mut style = RuntimeStyle::from_owned(base);
474
475        style.set_media(crate::media::MediaContext {
476            cols: 120,
477            ..Default::default()
478        });
479        let on = style.compute(&NodeRef::new("Button"), None);
480        assert!(on.style.color.is_some());
481
482        style.set_media(crate::media::MediaContext {
483            cols: 40,
484            ..Default::default()
485        });
486        let off = style.compute(&NodeRef::new("Button"), None);
487        assert!(off.style.color.is_none());
488    }
489}