Skip to main content

lumen_core/
signals.rs

1//! Reactive named-value store backing `bind-text="..."` markup attributes and `<for each="...">` iteration.
2//!
3//! Wave-D status: [`Signals`] is now a thin **`#[deprecated]`** wrapper kept for
4//! external embedders that still hold `Res<Signals>` references. Internal lumen
5//! systems (`apply_text_bindings`, `apply_checked_bindings`, ...) read and write
6//! through [`PropertyStore`] directly - that's the canonical typed reactive
7//! store. The wrapper's `set` mirrors writes into [`PropertyStore`] via
8//! [`push_external_property`] so the next tick's
9//! [`crate::property_store::drain_external_properties`] pass commits them, and
10//! `get` reads from the local `HashMap` populated by mirror-back when an
11//! external write lands.
12//!
13//! - [`Signals`] holds scalar `String` values keyed by name (back-compat only).
14//! - [`ArraySignals`] holds ordered vectors of record-shaped maps keyed by name.
15//! - Scripts populate both via `signal_set` / `signal_array_set`, stringified at the boundary.
16//! - Each tick, the `Bind*` and reconciler systems pull from [`PropertyStore`]
17//!   (post wave-D) and copy into bound components or spawn/despawn `<for>` children.
18
19// The `Resource` derive below generates its own `impl Resource for Signals`
20// as a separate item that doesn't inherit the struct's `#[allow(deprecated)]`
21// (derive-macro output isn't nested under the annotated item's attributes),
22// so the module-level allow is needed to suppress the self-referential
23// deprecation warning on `Signals`'s own definition.
24#![allow(deprecated)]
25
26use crate::components::{
27    BindChecked, BindDisabled, BindScroll, BindText, BindTextLabels, BindValue, Disabled, ImeState,
28    SliderValue, TextContent, TextFormat, TextInput, Toggleable,
29};
30use crate::input::{EditCommitted, Focused, Scroll, ScrollOffset};
31use crate::property_store::{PropertyKey, PropertyStore, PropertyValue, push_external_property};
32use bevy_ecs::prelude::*;
33use crossbeam_channel::{Receiver, Sender, TryRecvError, unbounded};
34use std::collections::{HashMap, HashSet};
35use std::sync::{Arc, Mutex, OnceLock};
36
37/// Legacy reactive named-value store. **Deprecated** as of wave-D - internal lumen
38/// systems now read/write through [`PropertyStore`] keyed on [`PropertyKey::Global`].
39///
40/// The struct is retained as a thin wrapper so external embedders and FFI consumers that
41/// still hold `Res<Signals>` references compile and continue working: `set` writes through
42/// to [`PropertyStore`] via the cross-thread [`push_external_property`] bus AND keeps a
43/// local string copy for the legacy `&str`-returning `get` signature, and a per-tick
44/// mirror system back-fills writes that landed directly on [`PropertyStore`] so
45/// `signals.get(name)` keeps surfacing the latest value regardless of which side wrote it.
46///
47/// The `dirty` field is still populated by `Signals::set` for legacy derive-style callers,
48/// but new code should call [`PropertyStore::dirty_global_names`] instead.
49#[derive(Resource, Debug, Default, Clone)]
50#[deprecated(
51    since = "0.0.1",
52    note = "use lumen_core::property_store::PropertyStore instead - Signals is now a thin wrapper that mirrors writes through `push_external_property` and reads from the typed store. New systems should take Res<PropertyStore> / ResMut<PropertyStore>."
53)]
54#[allow(deprecated)]
55pub struct Signals {
56    /// Stringified reactive signal values keyed by name. Populated by [`Self::set`]
57    /// and by the per-tick [`mirror_property_store_globals_to_signals`] back-mirror
58    /// so legacy `&str`-returning `get` callers keep working post wave-D.
59    pub values: HashMap<String, String>,
60    /// Set of signal names whose value changed during this tick. Kept for the
61    /// `apply_derivations` legacy path; new derivation-style consumers should peek
62    /// [`PropertyStore::dirty_global_names`] instead.
63    pub dirty: HashSet<String>,
64}
65
66#[allow(deprecated)]
67impl Signals {
68    /// Sets the signal `name` to `value`. Writes through to [`PropertyStore`] via
69    /// the cross-thread [`push_external_property`] bus AND updates the local
70    /// `values` map so legacy `Signals::get` callers observe the write
71    /// synchronously. Records the name in [`Self::dirty`] when the value changed.
72    pub fn set(&mut self, name: impl Into<String>, value: impl Into<String>) {
73        let name = name.into();
74        let value = value.into();
75        let changed = match self.values.get(&name) {
76            Some(prev) => prev != &value,
77            None => true,
78        };
79        if changed {
80            self.dirty.insert(name.clone());
81            // Mirror to PropertyStore via the cross-thread bus. The drain runs once
82            // per tick in `TickStage::CommandDrain`; downstream systems that read
83            // PropertyStore (apply_text_bindings, derivations, etc.) see the write
84            // on the next tick boundary, matching the legacy `Signals` semantics.
85            push_external_property(
86                PropertyKey::Global(Arc::<str>::from(name.as_str())),
87                PropertyValue::Str(Arc::<str>::from(value.as_str())),
88            );
89        }
90        self.values.insert(name, value);
91    }
92
93    /// Returns the signal's value, or `None` when undefined.
94    pub fn get(&self, name: &str) -> Option<&str> {
95        self.values.get(name).map(String::as_str)
96    }
97
98    /// Typed convenience: stores `value` under `name` as `"true"` or
99    /// `"false"`. The underlying repr is still a [`String`] (the
100    /// reactive store is type-erased), but funnelling boolean writes
101    /// through this helper means call sites can't drift to `"True"`,
102    /// `"1"`, or other look-alike strings that the compiler's
103    /// `<if eq="true">` body comparator wouldn't recognise.
104    pub fn set_bool(&mut self, name: impl Into<String>, value: bool) {
105        self.set(name, if value { "true" } else { "false" });
106    }
107
108    /// Typed convenience read for boolean signals written via
109    /// [`Self::set_bool`]. Accepts the canonical `"true"` / `"false"`
110    /// pair plus the common `"1"` / `"0"` alias for FFI / Rhai
111    /// authors. Returns `None` when the signal is undefined OR carries
112    /// a non-boolean value - the caller can then fall back to a
113    /// default rather than treating an unrelated string as `false`.
114    pub fn get_bool(&self, name: &str) -> Option<bool> {
115        match self.get(name)? {
116            "true" | "1" => Some(true),
117            "false" | "0" => Some(false),
118            _ => None,
119        }
120    }
121}
122
123/// Per-tick mirror that copies the latest [`PropertyStore`] global string cells
124/// back into the legacy [`Signals`] map. Runs in [`crate::tick::TickStage::Systems`]
125/// after `drain_external_properties` so any write that landed on PropertyStore
126/// directly (typed setters, ECS-side writes, FFI typed pushes) is visible to
127/// readers that still consult `Res<Signals>`.
128///
129/// Only `PropertyValue::Str` cells are mirrored - boolean / numeric / colour cells
130/// stay typed in PropertyStore; legacy `Signals::get` would have returned the
131/// stringified repr anyway and direct callers should migrate to `PropertyStore`
132/// for the typed value.
133///
134/// No-op when either resource is absent.
135#[allow(deprecated)]
136pub fn mirror_property_store_globals_to_signals(
137    store: Option<Res<PropertyStore>>,
138    signals: Option<ResMut<Signals>>,
139) {
140    let (Some(store), Some(mut signals)) = (store, signals) else {
141        return;
142    };
143    if store.dirty_peek().is_empty() {
144        return;
145    }
146    for key in store.dirty_peek() {
147        if let PropertyKey::Global(name) = key
148            && let Some(PropertyValue::Str(value)) = store.get(key)
149        {
150            let name_str = name.as_ref();
151            let value_str = value.as_ref();
152            let prev = signals.values.get(name_str);
153            let changed = match prev {
154                Some(p) => p != value_str,
155                None => true,
156            };
157            if changed {
158                signals.dirty.insert(name_str.to_string());
159                signals
160                    .values
161                    .insert(name_str.to_string(), value_str.to_string());
162            }
163        }
164    }
165}
166
167/// Producer half of the W1.6 theme-notify path: on every
168/// `Changed<StyleManager>` tick, writes `"dark"` / `"light"` into
169/// [`PropertyStore`] under the `__theme__` global key based on
170/// [`crate::components::StyleManager::effective_dark`].
171///
172/// Post wave-D the write lands directly on [`PropertyStore`] (no Signals
173/// round-trip); [`apply_theme_signal_to_root_classes`] consumes it via
174/// `dirty_peek` on the next schedule step.
175pub fn style_manager_to_signal(
176    theme: Res<crate::components::StyleManager>,
177    store: Option<ResMut<PropertyStore>>,
178) {
179    if !theme.is_changed() {
180        return;
181    }
182    let Some(mut s) = store else {
183        return;
184    };
185    let val = if theme.effective_dark {
186        "dark"
187    } else {
188        "light"
189    };
190    let key = PropertyKey::Global(Arc::<str>::from("__theme__"));
191    let already = matches!(s.get(&key), Some(PropertyValue::Str(curr)) if curr.as_ref() == val);
192    if !already {
193        s.set(key, PropertyValue::Str(Arc::<str>::from(val)));
194    }
195}
196
197/// Pre-W4.6 alias retained as a thin wrapper so any external scheduler
198/// that explicitly named the system keeps compiling. New callers should
199/// use [`style_manager_to_signal`].
200#[deprecated(
201    since = "0.0.1",
202    note = "Renamed to `style_manager_to_signal` (W4.6); the underlying `StyleManager` carries `effective_dark` in place of the old `OsTheme.is_dark`."
203)]
204pub fn os_theme_to_signal(
205    theme: Res<crate::components::StyleManager>,
206    store: Option<ResMut<PropertyStore>>,
207) {
208    style_manager_to_signal(theme, store);
209}
210
211/// Consumer half of the W1.6 theme-notify path: reads
212/// [`PropertyStore::dirty_peek`] for `PropertyKey::Global("__theme__")` and
213/// applies `theme-light` / `theme-dark` to every root entity's
214/// [`crate::components::LumenClasses`].
215///
216/// Uses `dirty_peek` (non-destructive) so the wave-1
217/// [`crate::render_world::roll_up_frame_dirty`] can also observe the same
218/// notify entry on this tick. Skips the iteration entirely when the queue
219/// carries no theme write - no `Changed<LumenClasses>` bump on quiet ticks.
220#[allow(clippy::type_complexity)]
221pub fn apply_theme_signal_to_root_classes(
222    store: Option<Res<PropertyStore>>,
223    mut roots: Query<
224        &mut crate::components::LumenClasses,
225        bevy_ecs::query::Without<bevy_ecs::hierarchy::ChildOf>,
226    >,
227) {
228    let Some(store) = store else {
229        return;
230    };
231    let key: PropertyKey = PropertyKey::Global(Arc::<str>::from("__theme__"));
232    if !store.dirty_peek().iter().any(|k| k == &key) {
233        return;
234    }
235    let want_dark = matches!(store.get(&key), Some(PropertyValue::Str(s)) if s.as_ref() == "dark");
236    let (add, drop) = if want_dark {
237        ("theme-dark", "theme-light")
238    } else {
239        ("theme-light", "theme-dark")
240    };
241    for mut classes in &mut roots {
242        let has_add = classes.0.iter().any(|c| c.as_ref() == add);
243        let has_drop = classes.0.iter().any(|c| c.as_ref() == drop);
244        if has_add && !has_drop {
245            // Already in the target state - skip the borrow to avoid bumping `Changed<LumenClasses>`.
246            continue;
247        }
248        classes.0.retain(|c| c.as_ref() != drop);
249        if !has_add {
250            classes.0.push(add.into());
251        }
252    }
253}
254
255/// Legacy theme application path - superseded by the W1.6 split of
256/// [`style_manager_to_signal`] (producer) + [`apply_theme_signal_to_root_classes`]
257/// (consumer). Retained as a `#[deprecated]` thin wrapper so external
258/// callers that explicitly registered this system keep compiling; new
259/// registrations should use the split pair instead.
260#[deprecated(
261    note = "Use `style_manager_to_signal` + `apply_theme_signal_to_root_classes` instead. The split removes the joint `Res<StyleManager>` / `ResMut<Signals>` / `Query<&mut LumenClasses>` borrow and replaces the `&mut *classes` reborrow trick with notify-queue gating."
262)]
263#[allow(clippy::type_complexity)]
264pub fn apply_theme_class_to_root(
265    theme: Res<crate::components::StyleManager>,
266    store: Option<ResMut<PropertyStore>>,
267    mut roots: Query<
268        &mut crate::components::LumenClasses,
269        bevy_ecs::query::Without<bevy_ecs::hierarchy::ChildOf>,
270    >,
271) {
272    if !theme.is_changed() {
273        return;
274    }
275    let want_dark = theme.effective_dark;
276    let (add, drop) = if want_dark {
277        ("theme-dark", "theme-light")
278    } else {
279        ("theme-light", "theme-dark")
280    };
281    for mut classes in &mut roots {
282        let mut changed = false;
283        classes.0.retain(|c| {
284            if c.as_ref() == drop {
285                changed = true;
286                false
287            } else {
288                true
289            }
290        });
291        if !classes.0.iter().any(|c| c.as_ref() == add) {
292            classes.0.push(add.into());
293            changed = true;
294        }
295        if !changed {
296            let _ = &mut *classes;
297        }
298    }
299    if let Some(mut s) = store {
300        let val = if want_dark { "dark" } else { "light" };
301        let key = PropertyKey::Global(Arc::<str>::from("__theme__"));
302        let already = matches!(s.get(&key), Some(PropertyValue::Str(curr)) if curr.as_ref() == val);
303        if !already {
304            s.set(key, PropertyValue::Str(Arc::<str>::from(val)));
305        }
306    }
307}
308
309/// Clears the legacy [`Signals::dirty`] set at the end of [`crate::tick::TickStage::A11ySync`].
310/// Argument is `Option<ResMut<Signals>>` so the system no-ops when the [`Signals`] resource is absent.
311/// Post wave-D the canonical dirty queue lives on [`PropertyStore`] and is cleared by
312/// [`crate::property_store::clear_property_store_dirty`].
313#[allow(deprecated)]
314pub fn clear_signal_dirty(signals: Option<ResMut<Signals>>) {
315    if let Some(mut s) = signals
316        && !s.dirty.is_empty()
317    {
318        s.dirty.clear();
319    }
320}
321
322/// Copies `PropertyStore[Global(name)]` into [`TextContent`] for every [`BindText`] entity.
323/// Entities whose property has no entry keep their existing text.
324///
325/// A [`BindTextLabels`] on the same entity says what to show for a value
326/// instead of the value itself, which is how a closed `<dropdown>` reads
327/// its selected option's label while the signal holds that option's
328/// value. A value it does not name is written as it stands.
329///
330/// A [`TextFormat`] renders what is left for the app's locale, through
331/// the app's own [`crate::i18n::AppI18n`] when the world holds one and
332/// the process-wide [`crate::i18n::format`] hook otherwise. The per-app
333/// handle is what keeps a process running two Lumen apps from writing one
334/// app's numbers in the other's locale, the same reason the spawner reads
335/// it. Labels come first: a label is what the element says for a value,
336/// and only a value with nothing to say for it is a value to format. A
337/// format the app has no formatter for, or text that is not what the
338/// format expects, writes the text unchanged.
339///
340/// An app that changes locale while it runs re-renders every bound value
341/// too: a `<label format="number" bind-text="count"/>` would otherwise
342/// keep the old locale's grouping until the next write to `count`.
343///
344/// Editing-protection gate: an entity is skipped while an edit is in flight on
345/// it, meaning a focused [`TextInput`] or an active [`ImeState`] preedit.
346/// Overwriting `TextContent` mid-edit would race the keystroke / IME path in
347/// [`crate::input::route_ime_events`] and `lumen_input::type_into_focused`,
348/// wiping the user's in-progress typing or leaving the caret dangling past the
349/// new (shorter) string. Focus alone is not an edit: a focused element with no
350/// text buffer, such as the `<dropdown>` header the popup hands focus back to on
351/// close, has no keystroke to protect and keeps tracking its signal. A write the
352/// gate held back is applied when the field loses focus: the blur re-reads every
353/// binding, so a handler that clears the signal clears the field once the user
354/// leaves it, rather than leaving the typed text on screen. Enter in a
355/// single-line field ends the edit too ([`EditCommitted`]): the field takes the
356/// write it held back, and any write after it, while it keeps focus, so a
357/// submit handler that clears the signal clears the field. When
358/// `apply_text_bindings` does overwrite an entity, any co-resident
359/// [`TextInput.cursor`] is clamped to `<= new_text.len()` so the cursor cannot
360/// point past the buffer end.
361#[allow(clippy::type_complexity)]
362pub fn apply_text_bindings(
363    store: Res<PropertyStore>,
364    mut q: Query<
365        (
366            &BindText,
367            &mut TextContent,
368            Option<&mut TextInput>,
369            Option<&BindTextLabels>,
370            Option<&TextFormat>,
371            Option<&Focused>,
372            Has<EditCommitted>,
373        ),
374        Without<ImeState>,
375    >,
376    new_binds: Query<(), Added<BindText>>,
377    committed: Query<(), Added<EditCommitted>>,
378    mut blurred: bevy_ecs::lifecycle::RemovedComponents<Focused>,
379    i18n: Option<Res<crate::i18n::AppI18n>>,
380) {
381    // Idle-tick fast path: no signal changed this tick, so no bound
382    // `TextContent` can need refreshing. A `set()` that changes a cell
383    // always pushes onto the dirty queue (which isn't cleared until
384    // A11ySync, after this system), so an empty queue means every binding
385    // already reflects its source.
386    //
387    // The `new_binds` escape hatch is load-bearing: a reconciler (`<if>` /
388    // `<for>` / tab-panel mount) can spawn a fresh `BindText` entity on a
389    // tick where NO signal changed - e.g. switching to a tab whose panel
390    // was despawned. Its `TextContent` was seeded empty at spawn, and the
391    // dirty queue is empty, so without re-running here the new label would
392    // stay blank until the next unrelated signal write. `Added<BindText>`
393    // catches exactly those just-mounted rows; the full-loop re-scan below
394    // is idempotent (equal writes are skipped) so re-running it is safe.
395    //
396    // The third reason to run is the app's locale changing: every formatted
397    // value on screen was written for the locale before it. The resource is
398    // marked changed by the applier that switches it, and the rescan below
399    // is idempotent either way.
400    let locale_changed = i18n.as_ref().is_some_and(|i18n| i18n.is_changed());
401    // The fourth is a field losing focus. The gate below skips a focused text
402    // input, so a write that landed while the user was in it is still waiting;
403    // the dirty flag that carried it is long gone, so the blur is what lets
404    // the field catch up with its signal.
405    // Only a bound field leaving focus counts; focus moving between buttons
406    // has nothing held back.
407    let blur = blurred
408        .read()
409        .filter(|&e| q.get(e).is_ok_and(|row| row.2.is_some()))
410        .count()
411        > 0;
412    // The fifth is Enter committing a field's edit, which ends it the way a
413    // blur does while the field keeps focus.
414    if store.dirty_peek().is_empty()
415        && new_binds.is_empty()
416        && !locale_changed
417        && !blur
418        && committed.is_empty()
419    {
420        return;
421    }
422    for (bind, mut tc, input, labels, format, focused, committed) in &mut q {
423        // The edit in flight the gate protects: a focused text buffer, whose
424        // next keystroke would land on the text this write replaces. Enter
425        // ended it on a committed one.
426        if focused.is_some() && input.is_some() && !committed {
427            continue;
428        }
429        let key = PropertyKey::Global(Arc::<str>::from(bind.0.as_ref()));
430        // Stringify scalar variants via the existing `From<PropertyValue> for Arc<str>` impl
431        // - `Bool` -> `"true"` / `"false"`, `I64` / `F64` -> decimal - so a typed write to the
432        // store still reaches `bind-text` markup. Non-scalar variants stringify to "".
433        let Some(pv) = store.get(&key) else {
434            continue;
435        };
436        let value: Arc<str> = Arc::<str>::from(pv.clone());
437        // What the element says for this value, when it says something
438        // other than the value.
439        let labelled = labels.and_then(|labels| labels.label_for(value.as_ref()));
440        // Nothing named this value, so it is the value itself that goes
441        // on screen, and a `format` is what renders it for the locale.
442        let formatted = format
443            .filter(|_| labelled.is_none())
444            .and_then(|f| match i18n.as_ref() {
445                Some(i18n) => i18n.format(&f.0, value.as_ref()),
446                // No app resource: a bare world, or a host that installed
447                // only the process-wide hook.
448                None => crate::i18n::format(&f.0, value.as_ref()),
449            });
450        let value_str = formatted.as_deref().or(labelled).unwrap_or(value.as_ref());
451        if tc.0 != value_str {
452            tc.0 = value_str.to_string();
453            // Cursor / selection_anchor are raw byte offsets into TextContent;
454            // a signal write that shortens the buffer (or replaces it entirely)
455            // can leave them dangling past the new end. Clamp to a valid
456            // boundary so the next route_ime_events / type_into_focused call
457            // doesn't panic on `insert_str` / `drain`.
458            if let Some(mut input) = input {
459                // A field that keeps focus puts the caret after the new
460                // text, as setting a focused field's value does in a
461                // browser (and `QLineEdit::setText`), so the next keystroke
462                // extends it.
463                if focused.is_some() {
464                    input.cursor = tc.0.len();
465                    input.selection_anchor = None;
466                }
467                if input.cursor > tc.0.len() {
468                    input.cursor = tc.0.len();
469                }
470                if let Some(a) = input.selection_anchor
471                    && a > tc.0.len()
472                {
473                    input.selection_anchor = None;
474                }
475            }
476        }
477    }
478}
479
480/// Pushes the latest [`TextContent`] back into the matching [`PropertyStore`] entry
481/// for every entity carrying both [`BindText`] and [`crate::components::TextInput`].
482/// Filtered by `Changed<TextContent>` so the push only fires on user edits.
483#[allow(clippy::type_complexity)]
484pub fn push_textinput_to_signal(
485    mut store: ResMut<PropertyStore>,
486    q: Query<
487        (&BindText, Ref<TextContent>),
488        (
489            bevy_ecs::query::Changed<TextContent>,
490            bevy_ecs::prelude::With<crate::components::TextInput>,
491        ),
492    >,
493) {
494    for (bind, tc) in &q {
495        // Spawn default, not a user edit - see `push_toggle_to_signal`.
496        if tc.is_added() {
497            continue;
498        }
499        let key = PropertyKey::Global(Arc::<str>::from(bind.0.as_ref()));
500        let want = tc.0.as_str();
501        let stored: Option<Arc<str>> = store.get(&key).cloned().map(Arc::<str>::from);
502        if stored.as_deref() != Some(want) {
503            store.set(key, PropertyValue::Str(Arc::<str>::from(want)));
504        }
505    }
506}
507
508/// Copies `PropertyStore[Global(name)]` into [`Toggleable::checked`] for every [`BindChecked`] entity.
509/// Recognises `Bool` directly plus the canonical `"true"` / `"1"` string aliases for back-compat.
510pub fn apply_checked_bindings(
511    store: Res<PropertyStore>,
512    mut q: Query<(&BindChecked, &mut Toggleable)>,
513    new_binds: Query<(), Added<BindChecked>>,
514) {
515    // Idle-tick fast path - see `apply_text_bindings` (incl. the
516    // `new_binds` escape hatch for reconciler-mounted `<toggle bind-checked>`).
517    if store.dirty_peek().is_empty() && new_binds.is_empty() {
518        return;
519    }
520    for (bind, mut t) in &mut q {
521        let Some(want) = store.get_global_bool(&bind.0) else {
522            continue;
523        };
524        if t.checked != want {
525            t.checked = want;
526        }
527    }
528}
529
530/// Pushes [`Toggleable::checked`] back into the matching [`PropertyStore`] entry for every
531/// [`BindChecked`] entity with `Changed<Toggleable>`. Writes the canonical `"true"` / `"false"`
532/// string variant so the wave-D `<if eq="true">` body comparator and `Signals::get_bool`
533/// callers keep recognising it.
534/// Spawn-tick rows (`is_added()`) are skipped: the freshly-inserted component
535/// carries the widget's spawn default, not a user edit, and pushing it would
536/// clobber a script's `signal(name, default)` initial publish (authored markup
537/// attrs seed the store separately, if-absent, at spawn).
538pub fn push_toggle_to_signal(
539    mut store: ResMut<PropertyStore>,
540    q: Query<(&BindChecked, Ref<Toggleable>), bevy_ecs::query::Changed<Toggleable>>,
541) {
542    for (bind, t) in &q {
543        if t.is_added() {
544            continue;
545        }
546        let curr = store.get_global_bool(&bind.0);
547        if curr != Some(t.checked) {
548            store.set_global_bool(&bind.0, t.checked);
549        }
550    }
551}
552
553/// Copies `PropertyStore[Global(name)]` into the presence of the [`Disabled`]
554/// marker for every [`BindDisabled`] entity: truthy inserts, falsy removes.
555/// Recognises `Bool` plus the canonical `"true"` / `"1"` string aliases via
556/// `get_global_bool`. A missing signal leaves the entity untouched (its
557/// spawn-time `disabled` attribute keeps authority until the signal exists).
558///
559/// Downstream reactions - stripping `Hovered` / `Pressed` / focus and the
560/// `:disabled` style swap - key off the marker add/remove
561/// (`lumen_primitives::eject_interaction_on_disable` and
562/// `apply_state_visuals`).
563pub fn apply_disabled_bindings(
564    store: Res<PropertyStore>,
565    mut commands: Commands,
566    q: Query<(Entity, &BindDisabled, Has<Disabled>)>,
567    new_binds: Query<(), Added<BindDisabled>>,
568) {
569    // Idle-tick fast path - see `apply_text_bindings` (incl. the
570    // `new_binds` escape hatch for reconciler-mounted `bind-disabled`).
571    if store.dirty_peek().is_empty() && new_binds.is_empty() {
572        return;
573    }
574    for (entity, bind, is_disabled) in &q {
575        let Some(want) = store.get_global_bool(&bind.0) else {
576            continue;
577        };
578        if want != is_disabled {
579            if want {
580                commands.entity(entity).insert(Disabled);
581            } else {
582                commands.entity(entity).remove::<Disabled>();
583            }
584        }
585    }
586}
587
588/// Parses `PropertyStore[Global(name)]` as `f32` and writes it into [`SliderValue::value`]
589/// (clamped to `[min, max]`) for every [`BindValue`] entity. Unparseable values are skipped.
590pub fn apply_value_bindings(
591    store: Res<PropertyStore>,
592    mut q: Query<(&BindValue, &mut SliderValue)>,
593    new_binds: Query<(), Added<BindValue>>,
594) {
595    // Idle-tick fast path - see `apply_text_bindings` (incl. the
596    // `new_binds` escape hatch for reconciler-mounted `<slider bind-value>`).
597    if store.dirty_peek().is_empty() && new_binds.is_empty() {
598        return;
599    }
600    for (bind, mut sv) in &mut q {
601        let key = PropertyKey::Global(Arc::<str>::from(bind.0.as_str()));
602        let Some(pv) = store.get(&key) else {
603            continue;
604        };
605        let parsed: Option<f32> = match pv {
606            PropertyValue::F64(n) => Some(*n as f32),
607            PropertyValue::I64(n) => Some(*n as f32),
608            PropertyValue::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
609            PropertyValue::Str(s) => s.as_ref().parse::<f32>().ok(),
610            _ => None,
611        };
612        let Some(parsed) = parsed else {
613            continue;
614        };
615        let clamped = sv.clamp(parsed);
616        if (sv.value - clamped).abs() > f32::EPSILON {
617            sv.value = clamped;
618        }
619    }
620}
621
622/// Pushes [`SliderValue::value`] back into the matching [`PropertyStore`] entry for every
623/// [`BindValue`] entity with `Changed<SliderValue>`. Writes the stringified value so the
624/// existing `<if>` / interpolation paths keep working.
625pub fn push_slider_to_signal(
626    mut store: ResMut<PropertyStore>,
627    q: Query<(&BindValue, Ref<SliderValue>), bevy_ecs::query::Changed<SliderValue>>,
628) {
629    for (bind, sv) in &q {
630        // Spawn default, not a user edit - see `push_toggle_to_signal`.
631        if sv.is_added() {
632            continue;
633        }
634        let serialised = format!("{}", sv.value);
635        let stored: Option<Arc<str>> = store.get_global_str(&bind.0);
636        if stored.as_deref() != Some(serialised.as_str()) {
637            store.set_global_str(&bind.0, serialised);
638        }
639    }
640}
641
642/// Parses `PropertyStore[Global(name)]` as `f32` and writes it into the vertical
643/// [`ScrollOffset`] of every [`BindScroll`] + [`Scroll`] entity (W6 T6).
644///
645/// Reactive scroll control with NO per-frame script hook: the script writes the
646/// signal once; this dirty-gated reader (same fast-path + `Added` escape hatch as
647/// [`apply_value_bindings`]) applies it on the write tick. The raw value is
648/// written unclamped - `clamp_scroll_offsets` (`TickStage::A11ySync`, registered
649/// by `lumen-primitives`) clips it to the content extent the same way it clips
650/// user scrolling, so signal-driven and wheel-driven offsets share one clamp
651/// rule.
652///
653/// Applying a signal value also zeroes any in-flight fling velocity on the
654/// container: a reactive `scroll_to` must land exactly where the script said,
655/// not get dragged onward by leftover momentum.
656pub fn apply_scroll_bindings(
657    store: Res<PropertyStore>,
658    mut q: Query<(&BindScroll, &mut ScrollOffset, Option<&mut Scroll>)>,
659    new_binds: Query<(), Added<BindScroll>>,
660) {
661    // Idle-tick fast path - see `apply_text_bindings` (incl. the
662    // `new_binds` escape hatch for reconciler-mounted `<scroll bind-scroll>`).
663    if store.dirty_peek().is_empty() && new_binds.is_empty() {
664        return;
665    }
666    for (bind, mut off, scroll) in &mut q {
667        let key = PropertyKey::Global(Arc::<str>::from(bind.0.as_str()));
668        let Some(pv) = store.get(&key) else {
669            continue;
670        };
671        let parsed: Option<f32> = match pv {
672            PropertyValue::F64(n) => Some(*n as f32),
673            PropertyValue::I64(n) => Some(*n as f32),
674            PropertyValue::Str(s) => s.as_ref().parse::<f32>().ok(),
675            _ => None,
676        };
677        let Some(parsed) = parsed else {
678            continue;
679        };
680        if (off.0.y - parsed).abs() > f32::EPSILON {
681            off.0.y = parsed;
682            if let Some(mut scroll) = scroll {
683                scroll.velocity = glam::Vec2::ZERO;
684            }
685        }
686    }
687}
688
689/// Pushes the settled vertical [`ScrollOffset`] back into the matching
690/// [`PropertyStore`] entry for every [`BindScroll`] entity (W6 T6, the
691/// two-way half).
692///
693/// Throttle contract: not per-frame. A user drag / wheel fling mutates the
694/// offset every tick; pushing each intermediate value would spam the store
695/// (and re-run every derivation) at frame rate. Instead the system arms a
696/// pending entry while the offset keeps changing, and pushes once on
697/// settle: the first tick where the offset did not change and the
698/// container's fling velocity has slept. Spawn-tick rows (`is_added()`)
699/// are skipped - the freshly-inserted offset is the widget default, not a
700/// user scroll (same rule as [`push_toggle_to_signal`]). The value is
701/// stringified like [`push_slider_to_signal`] so `<if>` comparators and
702/// interpolation keep working; the equality check keeps the
703/// signal->offset->signal round trip from echoing.
704pub fn push_scroll_to_signal(
705    mut store: ResMut<PropertyStore>,
706    q: Query<(Entity, &BindScroll, Ref<ScrollOffset>, Option<&Scroll>)>,
707    mut removed: RemovedComponents<BindScroll>,
708    mut pending: Local<HashSet<Entity>>,
709) {
710    // Drop settle latches for despawned / unbound containers so the
711    // Local set can't grow unbounded under a reconciler that churns
712    // `<scroll bind-scroll>` subtrees.
713    for gone in removed.read() {
714        pending.remove(&gone);
715    }
716    for (entity, bind, off, scroll) in &q {
717        if off.is_added() {
718            continue;
719        }
720        if off.is_changed() {
721            // Still moving - arm (or keep) the settle latch, push nothing.
722            pending.insert(entity);
723            continue;
724        }
725        if !pending.contains(&entity) {
726            continue;
727        }
728        // Offset unchanged this tick; wait out any live fling so the
729        // settled value (post rubber-band / clamp) is what lands.
730        let velocity_live = scroll.is_some_and(|s| s.velocity.length_squared() > 1.0);
731        if velocity_live {
732            continue;
733        }
734        pending.remove(&entity);
735        let serialised = format!("{}", off.0.y);
736        let stored: Option<Arc<str>> = store.get_global_str(&bind.0);
737        if stored.as_deref() != Some(serialised.as_str()) {
738            store.set_global_str(&bind.0, serialised);
739        }
740    }
741}
742
743/// Whether a signal's stringified value counts as true.
744///
745/// Empty, `"false"` and `"0"` are false; anything else is true. This is the
746/// rule an `<if>` gate evaluates. Anything that decides the same branch
747/// somewhere else, such as a build step rendering it ahead of the runtime,
748/// applies this rule rather than one of its own.
749///
750/// A signal that was never written is false. Callers holding an
751/// `Option<&str>` spell that out with `is_some_and`, which keeps the missing
752/// case visible at the call site.
753pub fn signal_is_truthy(value: &str) -> bool {
754    !matches!(value, "" | "false" | "0")
755}
756
757/// The boolean a signal's stringified value states, when it states one.
758///
759/// Stricter than [`signal_is_truthy`] on purpose: a binding that drives a
760/// state a widget already has, such as `bind-checked` or `bind-disabled`,
761/// leaves that state alone unless the signal says which way it goes.
762/// `"anything"` is a value nobody meant as a boolean, so the widget keeps
763/// what its markup gave it rather than silently turning on.
764///
765/// [`crate::property_store::PropertyStore::get_global_bool`] reads a typed
766/// `Bool` cell directly and passes a string one through here, so a value that
767/// arrives as text decides the same way wherever it is read: in a running
768/// app, and in a build writing the page ahead of one.
769pub fn signal_as_bool(value: &str) -> Option<bool> {
770    match value {
771        "true" | "1" => Some(true),
772        "false" | "0" => Some(false),
773        _ => None,
774    }
775}
776
777/// One row of a reactive array; field name -> stringified value.
778pub type ArrayItem = HashMap<String, String>;
779
780/// Reactive array store paired with [`Signals`]; ordered vectors of [`ArrayItem`] keyed by name.
781/// Written through `ScriptCommand::SetArray`; the reconciler system spawns/despawns markup children to match the stored vector.
782///
783/// Wave-D follow-up: a future migration may collapse this into
784/// `PropertyValue::Custom(Arc<ArrayItems>)` so the typed property store becomes
785/// the single source of truth - left as a separate resource for now since the
786/// record-shaped data model doesn't map cleanly onto the scalar typed cells.
787///
788/// The second field records the arrays whose contents changed this tick, the
789/// array counterpart of the [`PropertyStore`] dirty queue: a derived signal
790/// that depends on an array reads it to know when to recompute. It is emptied
791/// at the end of every tick by [`clear_array_signal_changes`].
792#[derive(Resource, Debug, Default, Clone)]
793pub struct ArraySignals(pub HashMap<String, Vec<ArrayItem>>, HashSet<String>);
794
795impl ArraySignals {
796    /// Replaces the contents of the named array with `items`. A write that
797    /// changes the contents records the name in [`Self::changed`].
798    pub fn set(&mut self, name: impl Into<String>, items: Vec<ArrayItem>) {
799        let name = name.into();
800        if self.0.get(&name) != Some(&items) {
801            self.1.insert(name.clone());
802        }
803        self.0.insert(name, items);
804    }
805
806    /// Names of the arrays whose contents changed this tick.
807    pub fn changed(&self) -> impl Iterator<Item = &str> {
808        self.1.iter().map(String::as_str)
809    }
810
811    /// Returns the named array as a slice, or `None` when absent.
812    pub fn get(&self, name: &str) -> Option<&[ArrayItem]> {
813        self.0.get(name).map(Vec::as_slice)
814    }
815
816    /// No-op placeholder retained for forward-compatibility with an incremental-diff reconciler.
817    pub fn touch(&mut self, name: &str) {
818        let _ = name;
819    }
820}
821
822/// End-of-tick system that empties the [`ArraySignals`] change record, the
823/// array counterpart of [`crate::property_store::clear_property_store_dirty`].
824/// Bypasses change detection so clearing the record does not read as a write
825/// to the arrays themselves.
826pub fn clear_array_signal_changes(arrays: Option<ResMut<ArraySignals>>) {
827    if let Some(mut arrays) = arrays
828        && !arrays.1.is_empty()
829    {
830        arrays.bypass_change_detection().1.clear();
831    }
832}
833
834// ============================================================
835// External signal channel.
836//
837// Provides a thread-safe ingress for `push_external_signal`, `push_external_array`, and `push_external_clear`.
838// The runtime installs [`drain_external_signals`] as a per-tick system that applies enqueued mutations to [`ArraySignals`].
839// Scalar Signal writes route through [`push_external_property`] post wave-D so the cell lands in
840// [`PropertyStore`] directly; the legacy Array / Clear payloads stay on this channel until ArraySignals
841// itself migrates.
842// ============================================================
843
844/// One enqueued mutation applied by [`drain_external_signals`] on the next tick. Values are pre-stringified.
845#[derive(Debug, Clone)]
846pub enum ExternalMutation {
847    /// Overwrite a scalar signal.
848    Signal {
849        /// Signal name.
850        name: String,
851        /// Pre-formatted stringified value.
852        value: String,
853    },
854    /// Replace an array signal with new rows.
855    Array {
856        /// Signal name.
857        name: String,
858        /// New rows, each a flat `field -> value` map matching the `<for>` template placeholders.
859        items: Vec<ArrayItem>,
860    },
861    /// Clear the signal (scalar becomes empty string; array becomes empty vec).
862    Clear {
863        /// Signal name.
864        name: String,
865    },
866}
867
868static EXTERNAL_TX: OnceLock<Sender<ExternalMutation>> = OnceLock::new();
869static EXTERNAL_RX: OnceLock<Mutex<Receiver<ExternalMutation>>> = OnceLock::new();
870
871fn init_external_channel() -> &'static Sender<ExternalMutation> {
872    EXTERNAL_TX.get_or_init(|| {
873        let (tx, rx) = unbounded();
874        let _ = EXTERNAL_RX.set(Mutex::new(rx));
875        tx
876    })
877}
878
879/// Idempotently initialises the external signal channel. Safe to call multiple times.
880pub fn init_external_signals() {
881    let _ = init_external_channel();
882    // Also wire the typed-property channel so any caller that touches
883    // `init_external_signals` for the legacy path implicitly also gets
884    // the wave-D typed bus (FFI callers, async tasks).
885    crate::property_store::init_external_properties();
886}
887
888/// Sends a scalar signal write from any thread. Wave-D: routes through
889/// [`push_external_property`] so the value lands typed in [`PropertyStore`] under
890/// `PropertyKey::Global(name)` on the next tick.
891///
892/// Returns `false` when the channel has disconnected.
893pub fn push_external_signal(name: impl Into<String>, value: impl Into<String>) -> bool {
894    let name_str = name.into();
895    let value_str = value.into();
896    let key = PropertyKey::Global(Arc::<str>::from(name_str.as_str()));
897    let value = PropertyValue::Str(Arc::<str>::from(value_str.as_str()));
898    push_external_property(key, value)
899}
900
901/// Sends an array mutation from any thread. Returns `false` when the channel has disconnected.
902/// Array signals stay on the legacy [`ExternalMutation`] path until [`ArraySignals`] migrates to [`PropertyStore`].
903pub fn push_external_array(name: impl Into<String>, items: Vec<ArrayItem>) -> bool {
904    let tx = init_external_channel();
905    tx.send(ExternalMutation::Array {
906        name: name.into(),
907        items,
908    })
909    .is_ok()
910}
911
912/// Sends a clear mutation from any thread. Returns `false` when the channel has disconnected.
913/// The scalar half routes through [`push_external_property`] (empty string); the array half stays
914/// on the legacy channel.
915pub fn push_external_clear(name: impl Into<String>) -> bool {
916    let tx = init_external_channel();
917    let name_str = name.into();
918    // Scalar clear: empty string on PropertyStore.
919    let _ = push_external_property(
920        PropertyKey::Global(Arc::<str>::from(name_str.as_str())),
921        PropertyValue::Str(Arc::<str>::from("")),
922    );
923    // Array clear: legacy channel.
924    tx.send(ExternalMutation::Clear { name: name_str }).is_ok()
925}
926
927/// Empties the external array channel, throwing away whatever it holds.
928///
929/// The array half of [`crate::property_store::discard_external_properties`]:
930/// one process-global channel, so an app built after another one inherits the
931/// mutations the first left queued unless they are dropped between the two.
932pub fn discard_external_signals() {
933    let Some(rx_lock) = EXTERNAL_RX.get() else {
934        return;
935    };
936    let Ok(rx) = rx_lock.lock() else {
937        return;
938    };
939    while rx.try_recv().is_ok() {}
940}
941
942/// Per-tick system that drains queued external Array / Clear mutations into [`ArraySignals`].
943/// Scalar signal writes were routed through [`push_external_property`] in wave-D and are committed
944/// to [`PropertyStore`] by [`crate::property_store::drain_external_properties`]; only the legacy
945/// Array path remains here.
946///
947/// No-ops on an empty channel.
948pub fn drain_external_signals(mut arrays: ResMut<ArraySignals>) {
949    let Some(rx_lock) = EXTERNAL_RX.get() else {
950        return;
951    };
952    let Ok(rx) = rx_lock.lock() else {
953        return;
954    };
955    loop {
956        match rx.try_recv() {
957            Ok(ExternalMutation::Signal { name, value }) => {
958                // Pre wave-D callers may still hand us a stringified scalar write; bounce it
959                // through the typed bus so PropertyStore observes it on the next drain.
960                let _ = push_external_property(
961                    PropertyKey::Global(Arc::<str>::from(name.as_str())),
962                    PropertyValue::Str(Arc::<str>::from(value.as_str())),
963                );
964            }
965            Ok(ExternalMutation::Array { name, items }) => {
966                arrays.set(name, items);
967            }
968            Ok(ExternalMutation::Clear { name }) => {
969                arrays.set(name, Vec::new());
970            }
971            Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
972        }
973    }
974}
975
976#[cfg(test)]
977mod tests {
978    use super::{apply_text_bindings, signal_is_truthy};
979    use crate::components::{BindText, BindTextLabels, ImeState, TextContent, TextInput};
980    use crate::input::Focused;
981    use crate::property_store::{PropertyKey, PropertyStore, PropertyValue};
982    use bevy_ecs::prelude::*;
983    use bevy_ecs::system::RunSystemOnce;
984    use std::sync::Arc;
985
986    /// A world holding one signal and the binding system's store.
987    fn world_with_signal(name: &str, value: &str) -> World {
988        let mut world = World::new();
989        let mut store = PropertyStore::default();
990        store.set(
991            PropertyKey::Global(Arc::<str>::from(name)),
992            PropertyValue::Str(Arc::<str>::from(value)),
993        );
994        world.insert_resource(store);
995        world
996    }
997
998    fn write_signal(world: &mut World, name: &str, value: &str) {
999        world.resource_mut::<PropertyStore>().set(
1000            PropertyKey::Global(Arc::<str>::from(name)),
1001            PropertyValue::Str(Arc::<str>::from(value)),
1002        );
1003    }
1004
1005    fn text_of(world: &World, entity: Entity) -> String {
1006        world.get::<TextContent>(entity).unwrap().0.clone()
1007    }
1008
1009    #[test]
1010    fn a_focused_dropdown_header_still_follows_its_value_signal() {
1011        let mut world = world_with_signal("pick", "a");
1012        // The header the popup hands focus back to on close: bound text,
1013        // per-value labels, no text buffer.
1014        let header = world
1015            .spawn((
1016                BindText("pick".into()),
1017                BindTextLabels(vec![
1018                    ("a".into(), "Apple".into()),
1019                    ("b".into(), "Banana".into()),
1020                ]),
1021                TextContent(String::new()),
1022                Focused,
1023            ))
1024            .id();
1025        world.run_system_once(apply_text_bindings).unwrap();
1026        assert_eq!(text_of(&world, header), "Apple");
1027
1028        write_signal(&mut world, "pick", "b");
1029        world.run_system_once(apply_text_bindings).unwrap();
1030        assert_eq!(text_of(&world, header), "Banana");
1031    }
1032
1033    #[test]
1034    fn a_focused_text_input_keeps_what_the_user_typed() {
1035        let mut world = world_with_signal("name", "from signal");
1036        let input = world
1037            .spawn((
1038                BindText("name".into()),
1039                TextContent("half typed".into()),
1040                TextInput {
1041                    cursor: "half typed".len(),
1042                    ..Default::default()
1043                },
1044                Focused,
1045            ))
1046            .id();
1047        world.run_system_once(apply_text_bindings).unwrap();
1048        assert_eq!(text_of(&world, input), "half typed");
1049        assert_eq!(world.get::<TextInput>(input).unwrap().cursor, 10);
1050    }
1051
1052    #[test]
1053    fn a_write_held_back_by_focus_lands_when_the_field_blurs() {
1054        let mut world = world_with_signal("name", "hello");
1055        let input = world
1056            .spawn((
1057                BindText("name".into()),
1058                TextContent("hello".into()),
1059                TextInput {
1060                    cursor: 5,
1061                    ..Default::default()
1062                },
1063                Focused,
1064            ))
1065            .id();
1066        let mut schedule = Schedule::default();
1067        schedule.add_systems(apply_text_bindings);
1068        // A submit handler clears the signal while the field has focus.
1069        write_signal(&mut world, "name", "cleared");
1070        schedule.run(&mut world);
1071        assert_eq!(text_of(&world, input), "hello", "the edit in flight wins");
1072        // The write's tick ends; its dirty flag goes with it.
1073        world.resource_mut::<PropertyStore>().clear_dirty();
1074        schedule.run(&mut world);
1075        // The user tabs away.
1076        world.entity_mut(input).remove::<Focused>();
1077        schedule.run(&mut world);
1078        assert_eq!(
1079            text_of(&world, input),
1080            "cleared",
1081            "the write held back while the field had focus was dropped"
1082        );
1083    }
1084
1085    #[test]
1086    fn a_field_whose_edit_enter_committed_takes_writes_while_focused() {
1087        let mut world = world_with_signal("name", "hello");
1088        let input = world
1089            .spawn((
1090                BindText("name".into()),
1091                TextContent("hello".into()),
1092                TextInput {
1093                    cursor: 5,
1094                    ..Default::default()
1095                },
1096                Focused,
1097            ))
1098            .id();
1099        let mut schedule = Schedule::default();
1100        schedule.add_systems(apply_text_bindings);
1101        write_signal(&mut world, "name", "cleared");
1102        schedule.run(&mut world);
1103        assert_eq!(text_of(&world, input), "hello", "the edit in flight wins");
1104        world.resource_mut::<PropertyStore>().clear_dirty();
1105        // Enter commits the edit; the write held back lands with nothing
1106        // else written.
1107        world.entity_mut(input).insert(crate::input::EditCommitted);
1108        schedule.run(&mut world);
1109        assert_eq!(text_of(&world, input), "cleared");
1110        assert_eq!(
1111            world.get::<TextInput>(input).unwrap().cursor,
1112            7,
1113            "the caret follows the new text"
1114        );
1115    }
1116
1117    #[test]
1118    fn an_unfocused_text_input_takes_the_signal_and_clamps_its_cursor() {
1119        let mut world = world_with_signal("name", "ab");
1120        let input = world
1121            .spawn((
1122                BindText("name".into()),
1123                TextContent("longer text".into()),
1124                TextInput {
1125                    cursor: 11,
1126                    selection_anchor: Some(9),
1127                    ..Default::default()
1128                },
1129            ))
1130            .id();
1131        world.run_system_once(apply_text_bindings).unwrap();
1132        assert_eq!(text_of(&world, input), "ab");
1133        let state = world.get::<TextInput>(input).unwrap();
1134        assert_eq!(state.cursor, 2);
1135        assert_eq!(state.selection_anchor, None);
1136    }
1137
1138    #[test]
1139    fn an_ime_preedit_holds_the_binding_back() {
1140        let mut world = world_with_signal("name", "from signal");
1141        let input = world
1142            .spawn((
1143                BindText("name".into()),
1144                TextContent("composing".into()),
1145                ImeState {
1146                    preedit: "ka".into(),
1147                    cursor: 2,
1148                },
1149            ))
1150            .id();
1151        world.run_system_once(apply_text_bindings).unwrap();
1152        assert_eq!(text_of(&world, input), "composing");
1153    }
1154
1155    #[test]
1156    fn only_empty_false_and_zero_are_falsy() {
1157        for value in ["", "false", "0"] {
1158            assert!(!signal_is_truthy(value), "`{value}` should be falsy");
1159        }
1160        for value in ["true", "1", "yes", "no", "False", "0.0", "00", " "] {
1161            assert!(signal_is_truthy(value), "`{value}` should be truthy");
1162        }
1163    }
1164}