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}