guido 0.12.0

A reactive Rust GUI library using wgpu for Wayland layer shell widgets
Documentation
//! Debug-build diagnostic for signal reads that cannot be reactive.
//!
//! A tracked read (`get`/`with`) registers the *current* reactive scope as a
//! subscriber. With no scope active there is nobody to register, so the value
//! is a snapshot: whatever it produced is frozen into whatever it was used
//! for. That is the single most common way to write a UI that silently stops
//! updating —
//!
//! ```no_run
//! # use guido::prelude::*;
//! # let count = create_signal(0);
//! text(format!("{}", count.get()));          // snapshot: never updates
//! text(move || format!("{}", count.get()));  // reactive: the closure re-runs
//! ```
//!
//! Rust cannot express this at compile time: the two lines differ only in
//! where the read happens, and a type that carried that information would
//! have to poison every signature it touches (Leptos shipped `cx: Scope`
//! everywhere and removed it again). So the check is a debug-build warning at
//! the read site, with `#[track_caller]` giving the exact file and line, one
//! warning per call site, and nothing at all in release builds.
//!
//! Snapshots are legitimate in plenty of places — an event handler wants the
//! value at click time, not a subscription — so guido marks those regions as
//! [`snapshot_zone`]s and the check stays quiet inside them.

/// Run `f` in a region where snapshot reads are the intended semantics.
///
/// Suppresses the debug-build "read without a reactive scope" warning for
/// everything `f` does. guido wraps its own callback regions (event dispatch,
/// service tasks, animation completions) in one of these; apps need it only
/// for their own callback machinery — for a single read, `get_untracked()`
/// says the same thing more locally.
///
/// In release builds this is just a call to `f`.
pub fn snapshot_zone<R>(f: impl FnOnce() -> R) -> R {
    #[cfg(debug_assertions)]
    {
        imp::DEPTH.with(|d| d.set(d.get() + 1));
        let _guard = crate::reactive::guard::defer(|| {
            imp::DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
        });
        f()
    }
    #[cfg(not(debug_assertions))]
    {
        f()
    }
}

/// Report a tracked read that had no reactive scope to register with.
///
/// Called from the read itself, so `#[track_caller]` resolves to the user's
/// line. No-op in release builds and inside a [`snapshot_zone`].
#[cfg_attr(debug_assertions, track_caller)]
#[inline]
pub(crate) fn check_reactive_scope() {
    #[cfg(debug_assertions)]
    imp::check();
}

/// Report that a declared value was not a number arithmetic can carry on with,
/// and was read as if it had not been declared.
///
/// Once per call site — which names the property, because each is resolved by
/// its own accessor — rather than once per process, so a rotation and a
/// background that both go bad both say so. A property that silently stops
/// following its signal is the hardest failure in this library to find by
/// looking, and it is silent by construction: the picture is *correct*, it is
/// just not the picture the signal asked for.
///
/// Debug builds only, which is where Flutter puts the same assertion.
pub(crate) fn non_finite_value(id: crate::tree::WidgetId, property: &'static str) {
    #[cfg(debug_assertions)]
    imp::non_finite(id, property);
    #[cfg(not(debug_assertions))]
    let _ = (id, property);
}

/// Report that a clock was read outside the pass that sets it, so what came
/// back is the wall clock rather than the moment that was asked for.
///
/// `read` is the getter that was called and `owner` is the pass that would have
/// set it; the message names the other clock too, because reaching for the
/// wrong one and reaching for the right one at the wrong time look identical
/// from the call site and the fix is different.
///
/// Debug builds only, and the fallback stays either way: a widget asking the
/// time between passes gets a usable number rather than a panic on somebody's
/// bar (#265).
#[track_caller]
pub(crate) fn clock_outside_its_pass(read: &'static str, owner: &'static str, other: &'static str) {
    #[cfg(debug_assertions)]
    imp::clock_outside_its_pass(read, owner, other);
    #[cfg(not(debug_assertions))]
    let _ = (read, owner, other);
}

#[cfg(debug_assertions)]
mod imp {
    use std::cell::{Cell, RefCell};

    use rustc_hash::FxHashSet;

    thread_local! {
        /// Nesting depth of `snapshot_zone`.
        pub(super) static DEPTH: Cell<u32> = const { Cell::new(0) };
        /// Call sites already reported, so a read in a hot path warns once.
        static REPORTED: RefCell<FxHashSet<(&'static str, u32, u32)>> =
            RefCell::new(FxHashSet::default());
        /// Number of reports emitted — the hook the diagnostic's own tests use.
        pub(super) static REPORTS: Cell<u64> = const { Cell::new(0) };
    }

    #[track_caller]
    pub(super) fn check() {
        if DEPTH.with(|d| d.get()) > 0 {
            return;
        }
        if crate::reactive::invalidation::widget_tracking_active()
            || crate::reactive::runtime::effect_tracking_active()
        {
            return;
        }

        let loc = std::panic::Location::caller();
        let key = (loc.file(), loc.line(), loc.column());
        let first_time = REPORTED.with(|r| r.borrow_mut().insert(key));
        if !first_time {
            return;
        }

        report(format!(
            "{}:{}:{}: signal read with no reactive scope — this value is a \
             snapshot and will not update. Pass a closure instead (e.g. \
             `move || …` rather than the value it computes), or use \
             `get_untracked()`/`with_untracked()` if a snapshot is what you \
             meant. (debug builds only)",
            loc.file(),
            loc.line(),
            loc.column(),
        ));
    }

    /// Say it once, wherever it can be heard.
    ///
    /// The audience for these is whoever just wrote their first guido app, who
    /// quite likely has no logger installed yet — with the log crate
    /// uninitialised `max_level` is Off and a `warn!` would go nowhere, so say
    /// it on stderr instead. Shared, so a second diagnostic cannot forget it.
    fn report(message: String) {
        REPORTS.with(|c| c.set(c.get() + 1));
        if log::max_level() == log::LevelFilter::Off {
            eprintln!("guido: {message}");
        } else {
            log::warn!("{message}");
        }
    }

    thread_local! {
        /// Its own set, so this warning's rate limit is not spent by the one
        /// above it — the last attempt at this shared one process-wide limit
        /// with every other caller and went quiet for the wrong reasons.
        ///
        /// Keyed on the widget and the property rather than the call site,
        /// which is what makes the message's own words true: it names a widget,
        /// so a second widget with the same bad property has to be able to say
        /// so too.
        static NON_FINITE: RefCell<FxHashSet<(crate::tree::WidgetId, &'static str)>> =
            RefCell::new(FxHashSet::default());
    }

    pub(super) fn non_finite(id: crate::tree::WidgetId, property: &'static str) {
        if !NON_FINITE.with(|r| r.borrow_mut().insert((id, property))) {
            return;
        }
        report(format!(
            "widget {id:?}: `{property}` resolved to a value that is not a \
             finite number, so it was read as if it had not been declared. A \
             division by a measured zero is the usual cause. (debug builds only)"
        ));
    }

    #[track_caller]
    pub(super) fn clock_outside_its_pass(
        read: &'static str,
        owner: &'static str,
        other: &'static str,
    ) {
        let loc = std::panic::Location::caller();
        if !CLOCKS.with(|r| {
            r.borrow_mut()
                .insert((loc.file(), loc.line(), loc.column()))
        }) {
            return;
        }
        report(format!(
            "{}:{}:{}: `{read}()` was read outside {owner}, so it answered \
             with the wall clock rather than the moment asked for. Either \
             the read belongs inside {owner}, or the question was \
             `{other}()`. (debug builds only)",
            loc.file(),
            loc.line(),
            loc.column(),
        ));
    }

    thread_local! {
        /// Its own set, for the reason `NON_FINITE` has its own: a rate limit
        /// shared with another diagnostic goes quiet for the wrong reasons.
        static CLOCKS: RefCell<FxHashSet<(&'static str, u32, u32)>> =
            RefCell::new(FxHashSet::default());
    }

    /// Forget every reported call site (used by `reset_reactive`).
    pub(crate) fn reset() {
        CLOCKS.with(|r| r.borrow_mut().clear());
        NON_FINITE.with(|r| r.borrow_mut().clear());
        REPORTED.with(|r| r.borrow_mut().clear());
        DEPTH.with(|d| d.set(0));
        REPORTS.with(|c| c.set(0));
    }
}

#[cfg(debug_assertions)]
pub(crate) fn reset() {
    imp::reset();
}

#[cfg(not(debug_assertions))]
pub(crate) fn reset() {}

/// Number of snapshot reads reported so far on this thread (debug builds).
#[cfg(all(debug_assertions, test))]
pub(crate) fn report_count() -> u64 {
    imp::REPORTS.with(|c| c.get())
}

/// Debug-only, like the diagnostic they are about: with the assertions off
/// there is no counter to read, so `report_count` — gated `all(debug_assertions,
/// test)` since it was written — does not exist and these tests do not compile.
/// The gate belongs on both or neither, and the two later callers already
/// carry it (`widgets::diagnostic_audit`, the animation characterization).
#[cfg(all(test, debug_assertions))]
mod tests {
    use super::*;
    use crate::jobs::JobType;
    use crate::reactive::invalidation::with_signal_tracking;
    use crate::reactive::{create_signal, create_stored};
    use crate::tree::WidgetId;

    fn reports_of(f: impl FnOnce()) -> u64 {
        let before = report_count();
        f();
        report_count() - before
    }

    /// The mistake this exists for: reading a signal while building a widget
    /// tree, with nothing to subscribe to.
    #[test]
    fn a_read_with_no_scope_is_reported() {
        let count = create_signal(0);
        assert_eq!(
            reports_of(|| {
                let _ = count.get();
            }),
            1
        );
    }

    /// Every legitimate shape must stay silent, or the warning becomes noise
    /// and nobody reads it.
    #[test]
    fn legitimate_reads_are_not_reported() {
        let count = create_signal(0);
        let stored = create_stored(7);
        let wid = WidgetId::from_u64(9001);

        // Inside a widget scope: layout/paint/reconcile register a subscriber
        assert_eq!(
            reports_of(|| with_signal_tracking(wid, JobType::Paint, || {
                let _ = count.get();
            })),
            0
        );
        // Explicit snapshot
        assert_eq!(
            reports_of(|| {
                let _ = count.get_untracked();
            }),
            0
        );
        assert_eq!(reports_of(|| count.with_untracked(|_| ())), 0);
        // Inside a region guido marks as callback-like
        assert_eq!(
            reports_of(|| snapshot_zone(|| {
                let _ = count.get();
            })),
            0
        );
        // A stored value cannot change, so reading it is never a missed
        // subscription — component props default to these
        assert_eq!(
            reports_of(|| {
                let _ = stored.get();
            }),
            0
        );
        // Effects establish their own tracking
        assert_eq!(
            reports_of(|| crate::reactive::create_effect(move || {
                let _ = count.get();
            })),
            0
        );
    }

    /// One report per call site, however hot the path.
    #[test]
    fn a_call_site_is_reported_once() {
        let count = create_signal(0);
        assert_eq!(
            reports_of(|| {
                for _ in 0..50 {
                    let _ = count.get();
                }
            }),
            1
        );
    }
}