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}