blinc_cn 0.6.0

Blinc Component Library - shadcn-style themed components built on blinc_layout primitives
//! Helpers for threading [`Reactive<T>`] values through cn widget
//! builders.
//!
//! cn widgets fall into two shapes, and the shape decides the
//! mechanism:
//!
//! * **Single visual property** (a colour, a radius, an opacity) —
//!   forward the `Reactive<T>` straight into the matching
//!   [`blinc_layout::div::Div`] setter, which registers a property
//!   binding. The signal patches `RenderProps` in place; no rebuild,
//!   no `compute_layout`.
//! * **Structural property** (`disabled`, `variant`, `checked`) — the
//!   value gates branches that pick different colours, borders,
//!   shadows, or FSM start states, so it can't be a single property
//!   write. Read the current value at build time and register the
//!   source signal via the widget's `Stateful::deps`, which rebuilds
//!   the subtree on change.
//!
//! [`current`] and [`dep_signal`] serve the second path: one reads the
//! build-time value, the other yields the [`SignalId`] to subscribe to.

use blinc_core::reactive::SignalId;
use blinc_layout::binding::Reactive;

/// Read the value a [`Reactive<T>`] holds right now.
///
/// Used by builders that branch on the value at build time. For
/// `Bound`/`Computed` this is the current signal read, which pairs with
/// a [`dep_signal`] subscription so the branch re-evaluates on change.
pub fn current<T>(r: &Reactive<T>) -> T
where
    T: Clone + Send + Default + 'static,
{
    match r {
        Reactive::Const(v) => v.clone(),
        Reactive::Bound(state) => state.try_get().unwrap_or_default(),
        Reactive::Computed(computed) => computed.try_get().unwrap_or_default(),
    }
}

/// The [`SignalId`] a structural prop should subscribe to, if any.
///
/// `Const` never changes, so it yields `None`. `Computed` is keyed by a
/// `DerivedId` rather than a `SignalId` and so can't drive `deps()`
/// directly; bind the underlying state instead, or use a property
/// binding where the shape allows one.
pub fn dep_signal<T: Clone + Send + 'static>(r: &Reactive<T>) -> Option<SignalId> {
    match r {
        Reactive::Bound(state) => Some(state.signal_id()),
        Reactive::Const(_) | Reactive::Computed(_) => None,
    }
}

/// Clone a [`Reactive<T>`] — the enum itself isn't `Clone` because it
/// carries type-erased handles, but every variant's payload is.
pub fn clone_reactive<T: Clone>(r: &Reactive<T>) -> Reactive<T> {
    match r {
        Reactive::Const(v) => Reactive::Const(v.clone()),
        Reactive::Bound(state) => Reactive::Bound(state.clone()),
        Reactive::Computed(computed) => Reactive::Computed(computed.clone()),
    }
}

/// An element that follows a reactive source.
///
/// The general form of [`reactive_text`], for content a property write can't
/// express -- an image source, anything that picks a different element. A
/// `Const` (or a `Computed`, which is read once) builds once with no wrapper.
/// A bound state gets a region keyed by its value: a change builds the
/// element for the new value in place and drops the old one, with nothing
/// around it rebuilt.
///
/// `build` MUST set everything the content needs. It runs after the
/// stylesheet pass has walked the tree, so it cannot rely on inheriting
/// from an ancestor's class; content that did would render unstyled until
/// some later event triggered another pass. See
/// `gotcha_stateful_content_loses_css_inheritance`.
pub fn reactive_node<F>(
    src: &Reactive<String>,
    build: F,
) -> Box<dyn blinc_layout::div::ElementBuilder>
where
    F: Fn(String) -> Box<dyn blinc_layout::div::ElementBuilder> + Send + Sync + 'static,
{
    use blinc_core::reactive::{ReactiveGraph, computed};

    let Reactive::Bound(state) = src else {
        return build(current(src));
    };
    let signal = state.signal();
    let value = computed(move |g: &ReactiveGraph| vec![g.get(signal).unwrap_or_default()]);
    // The region hugs its content and is centred in its parent: every
    // caller puts this content in a box and centres it, and `h_fit()` alone
    // would pin it to the top (it sets `align_self: Start`).
    Box::new(
        blinc_layout::div::div()
            .w_fit()
            .h_fit()
            .align_self_center()
            .for_each(&value, |v: &String| v.clone(), move |v: String| build(v)),
    )
}

/// A text node that follows a reactive source.
///
/// A signal or computed registers a text binding: a change replaces the
/// text in place and has it measured again, with no rebuild of anything
/// around it. A `Const` is a plain text.
///
/// `style` runs once, on the text as built, so the node is an ordinary one
/// that the stylesheet pass reaches like any other.
pub fn reactive_text<F>(
    src: &Reactive<String>,
    style: F,
) -> Box<dyn blinc_layout::div::ElementBuilder>
where
    F: FnOnce(blinc_layout::text::Text) -> blinc_layout::text::Text,
{
    Box::new(style(blinc_layout::text::Text::bound(clone_reactive(src))))
}

// =====================================================================
// Binding animated values to a bound bool
// =====================================================================

/// Signals already bound, so N widgets sharing one signal register one
/// effect rather than N. Keyed by raw `SignalId` plus a per-widget
/// discriminator, since two DIFFERENT widget kinds on the same signal
/// each need their own targets.
static BOUND: std::sync::Mutex<Option<std::collections::HashSet<(u64, u64)>>> =
    std::sync::Mutex::new(None);

/// One animated value and where it should sit when the bound bool is
/// true or false.
pub struct BoolTarget {
    pub anim: blinc_layout::motion::SharedAnimatedValue,
    pub on: f32,
    pub off: f32,
}

impl BoolTarget {
    /// The common case: 0 when false, `on` when true.
    pub fn to(anim: &blinc_layout::motion::SharedAnimatedValue, on: f32) -> Self {
        Self {
            anim: anim.clone(),
            on,
            off: 0.0,
        }
    }
}

/// Drive animated values from a bound `bool`, so a widget follows a
/// signal written anywhere rather than only its own toggle.
///
/// A widget that samples its state once at construction animates only
/// when something calls its own `set_*` method. Bound to a signal that
/// a switch, a button or an FSM action writes, it changes the signal
/// and stays put — which looks like the binding is ignored. Every
/// animated widget taking a `Signal<bool>` needs this, so it lives here
/// rather than being written per widget.
///
/// `kind` distinguishes widget types sharing a signal: a switch and a
/// collapsible bound to the same bool each want their own targets, but
/// two switches want one registration between them. Pass a constant per
/// widget kind.
///
/// The read inside the effect is what subscribes it; the graph tracks
/// reads, so it re-fires on every write to that signal.
pub fn bind_bool_targets(
    kind: u64,
    signal: blinc_core::reactive::Signal<bool>,
    targets: Vec<BoolTarget>,
) {
    {
        let mut guard = BOUND.lock().unwrap_or_else(|e| e.into_inner());
        let seen = guard.get_or_insert_with(std::collections::HashSet::new);
        if !seen.insert((signal.id().to_raw(), kind)) {
            return;
        }
    }

    // Registered once per signal, so it must not belong to the build that happened to be first:
    // the next fresh tree would dispose it, and nothing would register it again.
    blinc_core::owner::Owner::detached(|| {
        blinc_core::reactive::effect(move |graph| {
            // Read through the PASSED graph, not `State::get`. The first
            // run happens inside `create_effect` with the global graph
            // mutex already held, so the global path deadlocks on a
            // non-reentrant lock. The argument exists for this and tracks
            // the read the same way.
            let on = graph.get(signal).unwrap_or(false);
            for t in &targets {
                t.anim
                    .lock()
                    .unwrap()
                    .set_target(if on { t.on } else { t.off });
            }
        });
    });
}

/// Widget-kind discriminators for [`bind_bool_targets`].
pub mod bool_binding {
    pub const SWITCH: u64 = 1;
    pub const COLLAPSIBLE: u64 = 2;
}

/// A number a widget reads and writes, in whichever precision the
/// caller keeps it.
///
/// Widgets work in `f32`, because geometry and springs do. The signal
/// behind one need not: `number_input` keeps an `f64`, and a slider
/// bound to the same number has to *be* that number rather than a copy
/// narrowed on the way in. Accepting either precision means neither
/// side mirrors the other, so there is one value with one identity
/// instead of two that can drift.
#[derive(Clone, Copy)]
pub enum NumberValue {
    F32(blinc_core::reactive::Signal<f32>),
    F64(blinc_core::reactive::Signal<f64>),
}

impl NumberValue {
    /// The current number, narrowed to what a widget works in.
    pub fn get(&self) -> f32 {
        match self {
            Self::F32(s) => s.try_get().unwrap_or_default(),
            Self::F64(s) => s.try_get().unwrap_or_default() as f32,
        }
    }

    /// The current number as of `g`, so a computed reading it follows it.
    pub fn read(&self, g: &blinc_core::reactive::ReactiveGraph) -> f32 {
        match *self {
            Self::F32(s) => g.get(s).unwrap_or_default(),
            Self::F64(s) => g.get(s).unwrap_or_default() as f32,
        }
    }

    /// Write it back in the caller's precision.
    pub fn set(&self, value: f32) {
        match self {
            Self::F32(s) => s.set(value),
            Self::F64(s) => s.set(value as f64),
        }
    }

    /// What to subscribe to. One id either way, so a `deps` list or a
    /// persisted animation key built from it is stable.
    pub fn signal_id(&self) -> blinc_core::reactive::SignalId {
        match self {
            Self::F32(s) => s.id(),
            Self::F64(s) => s.id(),
        }
    }
}

impl From<blinc_core::reactive::Signal<f32>> for NumberValue {
    fn from(s: blinc_core::reactive::Signal<f32>) -> Self {
        Self::F32(s)
    }
}

impl From<blinc_core::reactive::Signal<f64>> for NumberValue {
    fn from(s: blinc_core::reactive::Signal<f64>) -> Self {
        Self::F64(s)
    }
}

/// Whether a reactive string is non-empty, following it when it is bound.
pub(crate) fn non_empty(src: &Reactive<String>) -> Reactive<bool> {
    match src {
        Reactive::Const(s) => Reactive::Const(!s.is_empty()),
        bound => {
            let bound = bound.clone();
            Reactive::Computed(blinc_core::reactive::computed(move |g| {
                !bound.read(g).unwrap_or_default().is_empty()
            }))
        }
    }
}

/// A field's message: its error while that is non-empty, else its
/// description, each following its source in place. `None` when it has
/// neither.
pub(crate) fn field_message(
    error: &Option<Reactive<String>>,
    description: &Option<Reactive<String>>,
    size: f32,
) -> Option<blinc_layout::div::Div> {
    use blinc_layout::text::Text;
    let text = |src: &Reactive<String>, class: &'static str| {
        let src = src.clone();
        move || Text::bound(src.clone()).class(class).size(size)
    };
    let row = blinc_layout::div::div().class("cn-field-message");
    match (error, description) {
        (None, None) => None,
        (None, Some(desc)) => Some(row.child(text(desc, "cn-field-description")())),
        (Some(err), None) => Some(row.show(non_empty(err), text(err, "cn-field-error"))),
        (Some(err), Some(desc)) => Some(row.show_or(
            non_empty(err),
            text(err, "cn-field-error"),
            text(desc, "cn-field-description"),
        )),
    }
}