rust_widgets 2.9.2

Pure Rust cross-platform native GUI library with hardware-adaptive rendering, 180 widgets, touch/gesture support, i18n, and SVG-pipeline-accurate output
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT

//! CSS file watcher — hot-reload a stylesheet into the global `StyleSheetManager`.
//!
//! # OS notification first, bounded poll as the fallback
//!
//! The watcher consumes the platform's environment revision
//! ([`crate::platform::Platform::notify_environment_changed`] →
//! [`crate::platform::profile::environment_generation`]): a host that receives a native settings
//! notification bumps it, and the next [`CssWatcher::poll`] reloads immediately instead of waiting
//! for the interval. The bounded poll is retained for the two cases a notification cannot cover:
//! a backend with no notification source, and a stylesheet edited in place (a file change is not an
//! environment change). In that fallback the poll still checks the modification time before
//! reading or parsing the file.
//!
//! # Why polling rather than a filesystem notification backend
//!
//! The i18n watcher uses `notify`, which needs a per-platform backend and hands
//! events to a callback on a background thread. A stylesheet reload has two extra
//! requirements that make polling the better fit here:
//!
//! * **The reload must be observable from the frame loop.** A stylesheet change
//!   invalidates rendered appearance, so the caller has to *know* a reload
//!   happened in order to request a repaint. A poll that reports "changed since
//!   last time" makes that a value in the caller's own loop instead of a
//!   cross-thread signal.
//! * **The file may be mid-write.** An editor that truncates and rewrites a file
//!   fires several notification events, and reloading on each one parses a
//!   partially written stylesheet. Reading on demand, and only when the
//!   modification time actually moved, reads a settled file in practice.
//!
//! # Example
//!
//! ```no_run
//! use rust_widgets::style::css_watcher::CssWatcher;
//!
//! let mut watcher = CssWatcher::new("theme.css", "main-theme");
//! watcher.set_poll_interval(500);
//! loop {
//!     if watcher.poll().unwrap_or(false) {
//!         // styles changed — request a repaint
//!     }
//!     std::thread::sleep(std::time::Duration::from_millis(16));
//! }
//! ```

#[cfg(not(alloc_frugal))]
use crate::compat::{format, Duration, String};
#[cfg(not(alloc_frugal))]
use std::path::{Path, PathBuf};
#[cfg(not(alloc_frugal))]
use std::time::SystemTime;

#[cfg(not(alloc_frugal))]
use super::stylesheet::global_stylesheet_manager;

/// Polls a CSS file and reloads it into the global stylesheet manager on change.
///
/// The watcher records the file's modification time as of the last successful
/// reload, so a poll reports `true` exactly once per observed change. A file that
/// does not exist yet is not an error: `poll` reports `false` and keeps waiting,
/// which is what lets a caller start watching before a generated stylesheet has
/// been written.
///
/// # Availability
///
/// Compiled only when `alloc_frugal` is **off** (i.e. outside the `mini` profile).
/// Watching a file needs `std::fs`, `std::io` and the `SystemTime` clock, none of
/// which exist under `mini`'s `core` + `alloc` build. This is the declared gate
/// rather than a design choice left open: a `mini` target has no filesystem to
/// watch, so the capability is absent rather than degraded.
#[cfg(not(alloc_frugal))]
#[derive(Debug)]
pub struct CssWatcher {
    /// The stylesheet's path on disk.
    path: PathBuf,
    /// The name the stylesheet is registered under in the manager. Registering by
    /// name (rather than appending) is what makes a reload *replace* the previous
    /// sheet instead of stacking a second copy of it.
    name: String,
    /// Minimum interval between filesystem checks, honoured by [`Self::poll`].
    poll_interval: Duration,
    /// When the last check ran. `None` means "never checked", so the first poll
    /// always checks.
    last_check: Option<SystemTime>,
    /// The file's modification time at the last successful reload. `None` means
    /// nothing has been loaded yet.
    last_modified: Option<SystemTime>,
    /// The environment revision observed at the last poll.
    ///
    /// A change means the host received an OS settings notification, which is a reason to reload
    /// now even inside the poll interval and even when the file's mtime has not moved (G-23).
    last_generation: u64,
    /// Priority the sheet is registered with.
    priority: u8,
}

#[cfg(not(alloc_frugal))]
impl CssWatcher {
    /// Creates a watcher for `path`, registering into the global manager as `name`.
    ///
    /// Does not read the file: call [`Self::reload`] to load it immediately, or let
    /// the first [`Self::poll`] do it.
    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,
            last_generation: 0,
            priority: 0,
        }
    }

    /// Sets the minimum interval between filesystem checks.
    ///
    /// A `poll_interval` of zero makes every call check. The default is 500 ms,
    /// which is short enough to feel immediate and long enough that a per-frame
    /// poll is not a per-frame `stat`.
    pub fn set_poll_interval(&mut self, millis: u64) {
        self.poll_interval = Duration::from_millis(millis);
    }

    /// Returns the configured poll interval in milliseconds.
    pub fn poll_interval(&self) -> u64 {
        self.poll_interval.as_millis() as u64
    }

    /// Sets the priority the sheet is registered under (higher wins).
    pub fn set_priority(&mut self, priority: u8) {
        self.priority = priority;
    }

    /// The path being watched.
    pub fn path(&self) -> &Path {
        &self.path
    }

    /// The name the stylesheet is registered under.
    pub fn name(&self) -> &str {
        &self.name
    }

    /// Checks the file and reloads the stylesheet if it changed.
    ///
    /// Returns `Ok(true)` when a reload happened, `Ok(false)` when there was
    /// nothing to do, and `Err` when the file exists but could not be read or
    /// parsed. A missing file is `Ok(false)`, not an error — see the type docs.
    ///
    /// The modification time is recorded *before* the read, so a write that lands
    /// during the read is detected by the next poll rather than being missed.
    pub fn poll(&mut self) -> Result<bool, String> {
        // 1. The notification path. A host that received a native environment notification bumped
        //    the shared revision; that is authoritative and bypasses the bounded poll. `0` means
        //    no notification source has reported anything, so the interval path below applies.
        let generation = crate::platform::profile::environment_generation();
        let notified =
            self.last_generation != 0 && generation != 0 && generation != self.last_generation;
        self.last_generation = generation;

        let now = SystemTime::now();
        if !notified {
            if let Some(last) = self.last_check {
                match now.duration_since(last) {
                    Ok(elapsed) if elapsed < self.poll_interval => return Ok(false),
                    // A clock that went backwards (an NTP correction, a suspend/resume)
                    // makes `duration_since` fail. Checking anyway is the safe reading:
                    // a redundant read is cheaper than a missed change.
                    _ => {}
                }
            }
        }
        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 => {
                // Not written yet. Keep the previous baseline so the first write is
                // still detected as a change.
                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())
        })?;
        // A notification reloads even when the mtime is unchanged: the caller still needs the
        // `Ok(true)` so it can repaint for the new appearance. The fallback poll keeps the mtime
        // guard, which is what makes an idle frame loop a `stat` and not a parse.
        if !notified && 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()
            )
        })?;

        // Validate before registering, so a syntactically broken stylesheet is
        // reported instead of replacing a working one with rules that fail to
        // parse on every later `apply_to`.
        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)
    }

    /// Reloads unconditionally, ignoring the modification time.
    ///
    /// Use this when the caller knows the file changed in a way the modification
    /// time does not reflect (a restored backup, or a filesystem with coarse
    /// timestamps).
    pub fn reload(&mut self) -> Result<(), String> {
        // Clear the baseline so the shared implementation cannot short-circuit.
        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()
            ))
        }
    }

    /// Whether the stylesheet has been loaded at least once.
    pub fn is_loaded(&self) -> bool {
        self.last_modified.is_some()
    }
}

#[cfg(all(test, not(alloc_frugal)))]
mod tests {
    use super::*;
    use crate::style::global_stylesheet_manager;

    /// A unique temporary path per test, so tests running in parallel do not share
    /// a file.
    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
    }

    /// Locks the global stylesheet registry for the duration of a test.
    ///
    /// Every test here registers into the process-wide manager, so without this
    /// they would race each other (and the other suites' sheet tests).
    fn guard() -> crate::compat::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());

        // The sheet is really in the manager: applying it to a style changes it.
        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);
        // No interval, so every poll checks the filesystem.
        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");
        // An unterminated declaration block: the parser rejects this.
        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");

        // Nothing was registered under this watcher's name, so a style it would
        // have set is untouched. Asserted by *absence*: the broken sheet sets a
        // colour no other sheet in the suite uses.
        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);
    }

    /// A host notification reloads immediately, even inside the poll interval and even when the
    /// file's mtime has not moved (G-23). The `Ok(true)` is what tells the frame loop to repaint
    /// for the new appearance; returning `false` would apply the change silently.
    #[test]
    fn an_environment_notification_bypasses_the_poll_interval() {
        let _guard = guard();
        let path = temp_path("notify");
        std::fs::write(&path, "Button { color: #445566; }").expect("write fixture");

        let name = format!("watcher-notify-{}", 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 loads and records the revision");

        // No file change at all: the notification itself is the reason to reload.
        crate::platform::profile::notify_environment_changed();
        assert_eq!(
            watcher.poll(),
            Ok(true),
            "an OS notification must not wait for the bounded poll"
        );

        global_stylesheet_manager().unregister(&name);
        cleanup(&path);
    }
}