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, BindValue, Disabled, ImeState, SliderValue,
28 TextContent, TextInput, Toggleable,
29};
30use crate::input::{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/// Editing-protection gate: entities currently carrying [`Focused`] or an active
326/// [`ImeState`] preedit are skipped - overwriting `TextContent` mid-edit would
327/// race the keystroke / IME path in [`crate::input::route_ime_events`] and
328/// `lumen_input::type_into_focused`, wiping the user's in-progress typing or
329/// leaving the caret dangling past the new (shorter) string. When `apply_text_bindings`
330/// does overwrite an unfocused entity, any co-resident [`TextInput.cursor`] is
331/// clamped to `<= new_text.len()` so the cursor cannot point past the buffer end.
332#[allow(clippy::type_complexity)]
333pub fn apply_text_bindings(
334 store: Res<PropertyStore>,
335 mut q: Query<
336 (&BindText, &mut TextContent, Option<&mut TextInput>),
337 (Without<Focused>, Without<ImeState>),
338 >,
339 new_binds: Query<(), Added<BindText>>,
340) {
341 // Idle-tick fast path: no signal changed this tick, so no bound
342 // `TextContent` can need refreshing. A `set()` that changes a cell
343 // always pushes onto the dirty queue (which isn't cleared until
344 // A11ySync, after this system), so an empty queue means every binding
345 // already reflects its source.
346 //
347 // The `new_binds` escape hatch is load-bearing: a reconciler (`<if>` /
348 // `<for>` / tab-panel mount) can spawn a fresh `BindText` entity on a
349 // tick where NO signal changed - e.g. switching to a tab whose panel
350 // was despawned. Its `TextContent` was seeded empty at spawn, and the
351 // dirty queue is empty, so without re-running here the new label would
352 // stay blank until the next unrelated signal write. `Added<BindText>`
353 // catches exactly those just-mounted rows; the full-loop re-scan below
354 // is idempotent (equal writes are skipped) so re-running it is safe.
355 if store.dirty_peek().is_empty() && new_binds.is_empty() {
356 return;
357 }
358 for (bind, mut tc, input) in &mut q {
359 let key = PropertyKey::Global(Arc::<str>::from(bind.0.as_ref()));
360 // Stringify scalar variants via the existing `From<PropertyValue> for Arc<str>` impl
361 // - `Bool` -> `"true"` / `"false"`, `I64` / `F64` -> decimal - so a typed write to the
362 // store still reaches `bind-text` markup. Non-scalar variants stringify to "".
363 let Some(pv) = store.get(&key) else {
364 continue;
365 };
366 let value: Arc<str> = Arc::<str>::from(pv.clone());
367 let value_str = value.as_ref();
368 if tc.0 != value_str {
369 tc.0 = value_str.to_string();
370 // Cursor / selection_anchor are raw byte offsets into TextContent;
371 // a signal write that shortens the buffer (or replaces it entirely)
372 // can leave them dangling past the new end. Clamp to a valid
373 // boundary so the next route_ime_events / type_into_focused call
374 // doesn't panic on `insert_str` / `drain`.
375 if let Some(mut input) = input {
376 if input.cursor > tc.0.len() {
377 input.cursor = tc.0.len();
378 }
379 if let Some(a) = input.selection_anchor
380 && a > tc.0.len()
381 {
382 input.selection_anchor = None;
383 }
384 }
385 }
386 }
387}
388
389/// Pushes the latest [`TextContent`] back into the matching [`PropertyStore`] entry
390/// for every entity carrying both [`BindText`] and [`crate::components::TextInput`].
391/// Filtered by `Changed<TextContent>` so the push only fires on user edits.
392#[allow(clippy::type_complexity)]
393pub fn push_textinput_to_signal(
394 mut store: ResMut<PropertyStore>,
395 q: Query<
396 (&BindText, Ref<TextContent>),
397 (
398 bevy_ecs::query::Changed<TextContent>,
399 bevy_ecs::prelude::With<crate::components::TextInput>,
400 ),
401 >,
402) {
403 for (bind, tc) in &q {
404 // Spawn default, not a user edit - see `push_toggle_to_signal`.
405 if tc.is_added() {
406 continue;
407 }
408 let key = PropertyKey::Global(Arc::<str>::from(bind.0.as_ref()));
409 let want = tc.0.as_str();
410 let stored: Option<Arc<str>> = store.get(&key).cloned().map(Arc::<str>::from);
411 if stored.as_deref() != Some(want) {
412 store.set(key, PropertyValue::Str(Arc::<str>::from(want)));
413 }
414 }
415}
416
417/// Copies `PropertyStore[Global(name)]` into [`Toggleable::checked`] for every [`BindChecked`] entity.
418/// Recognises `Bool` directly plus the canonical `"true"` / `"1"` string aliases for back-compat.
419pub fn apply_checked_bindings(
420 store: Res<PropertyStore>,
421 mut q: Query<(&BindChecked, &mut Toggleable)>,
422 new_binds: Query<(), Added<BindChecked>>,
423) {
424 // Idle-tick fast path - see `apply_text_bindings` (incl. the
425 // `new_binds` escape hatch for reconciler-mounted `<toggle bind-checked>`).
426 if store.dirty_peek().is_empty() && new_binds.is_empty() {
427 return;
428 }
429 for (bind, mut t) in &mut q {
430 let Some(want) = store.get_global_bool(&bind.0) else {
431 continue;
432 };
433 if t.checked != want {
434 t.checked = want;
435 }
436 }
437}
438
439/// Pushes [`Toggleable::checked`] back into the matching [`PropertyStore`] entry for every
440/// [`BindChecked`] entity with `Changed<Toggleable>`. Writes the canonical `"true"` / `"false"`
441/// string variant so the wave-D `<if eq="true">` body comparator and `Signals::get_bool`
442/// callers keep recognising it.
443/// Spawn-tick rows (`is_added()`) are skipped: the freshly-inserted component
444/// carries the widget's spawn default, not a user edit, and pushing it would
445/// clobber a script's `signal(name, default)` initial publish (authored markup
446/// attrs seed the store separately, if-absent, at spawn).
447pub fn push_toggle_to_signal(
448 mut store: ResMut<PropertyStore>,
449 q: Query<(&BindChecked, Ref<Toggleable>), bevy_ecs::query::Changed<Toggleable>>,
450) {
451 for (bind, t) in &q {
452 if t.is_added() {
453 continue;
454 }
455 let curr = store.get_global_bool(&bind.0);
456 if curr != Some(t.checked) {
457 store.set_global_bool(&bind.0, t.checked);
458 }
459 }
460}
461
462/// Copies `PropertyStore[Global(name)]` into the presence of the [`Disabled`]
463/// marker for every [`BindDisabled`] entity: truthy inserts, falsy removes.
464/// Recognises `Bool` plus the canonical `"true"` / `"1"` string aliases via
465/// `get_global_bool`. A missing signal leaves the entity untouched (its
466/// spawn-time `disabled` attribute keeps authority until the signal exists).
467///
468/// Downstream reactions - stripping `Hovered` / `Pressed` / focus and the
469/// `:disabled` style swap - key off the marker add/remove
470/// (`lumen_primitives::eject_interaction_on_disable` and
471/// `apply_state_visuals`).
472pub fn apply_disabled_bindings(
473 store: Res<PropertyStore>,
474 mut commands: Commands,
475 q: Query<(Entity, &BindDisabled, Has<Disabled>)>,
476 new_binds: Query<(), Added<BindDisabled>>,
477) {
478 // Idle-tick fast path - see `apply_text_bindings` (incl. the
479 // `new_binds` escape hatch for reconciler-mounted `bind-disabled`).
480 if store.dirty_peek().is_empty() && new_binds.is_empty() {
481 return;
482 }
483 for (entity, bind, is_disabled) in &q {
484 let Some(want) = store.get_global_bool(&bind.0) else {
485 continue;
486 };
487 if want != is_disabled {
488 if want {
489 commands.entity(entity).insert(Disabled);
490 } else {
491 commands.entity(entity).remove::<Disabled>();
492 }
493 }
494 }
495}
496
497/// Parses `PropertyStore[Global(name)]` as `f32` and writes it into [`SliderValue::value`]
498/// (clamped to `[min, max]`) for every [`BindValue`] entity. Unparseable values are skipped.
499pub fn apply_value_bindings(
500 store: Res<PropertyStore>,
501 mut q: Query<(&BindValue, &mut SliderValue)>,
502 new_binds: Query<(), Added<BindValue>>,
503) {
504 // Idle-tick fast path - see `apply_text_bindings` (incl. the
505 // `new_binds` escape hatch for reconciler-mounted `<slider bind-value>`).
506 if store.dirty_peek().is_empty() && new_binds.is_empty() {
507 return;
508 }
509 for (bind, mut sv) in &mut q {
510 let key = PropertyKey::Global(Arc::<str>::from(bind.0.as_str()));
511 let Some(pv) = store.get(&key) else {
512 continue;
513 };
514 let parsed: Option<f32> = match pv {
515 PropertyValue::F64(n) => Some(*n as f32),
516 PropertyValue::I64(n) => Some(*n as f32),
517 PropertyValue::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
518 PropertyValue::Str(s) => s.as_ref().parse::<f32>().ok(),
519 _ => None,
520 };
521 let Some(parsed) = parsed else {
522 continue;
523 };
524 let clamped = parsed.clamp(sv.min.min(sv.max), sv.min.max(sv.max));
525 if (sv.value - clamped).abs() > f32::EPSILON {
526 sv.value = clamped;
527 }
528 }
529}
530
531/// Pushes [`SliderValue::value`] back into the matching [`PropertyStore`] entry for every
532/// [`BindValue`] entity with `Changed<SliderValue>`. Writes the stringified value so the
533/// existing `<if>` / interpolation paths keep working.
534pub fn push_slider_to_signal(
535 mut store: ResMut<PropertyStore>,
536 q: Query<(&BindValue, Ref<SliderValue>), bevy_ecs::query::Changed<SliderValue>>,
537) {
538 for (bind, sv) in &q {
539 // Spawn default, not a user edit - see `push_toggle_to_signal`.
540 if sv.is_added() {
541 continue;
542 }
543 let serialised = format!("{}", sv.value);
544 let stored: Option<Arc<str>> = store.get_global_str(&bind.0);
545 if stored.as_deref() != Some(serialised.as_str()) {
546 store.set_global_str(&bind.0, serialised);
547 }
548 }
549}
550
551/// Parses `PropertyStore[Global(name)]` as `f32` and writes it into the vertical
552/// [`ScrollOffset`] of every [`BindScroll`] + [`Scroll`] entity (W6 T6).
553///
554/// Reactive scroll control with NO per-frame script hook: the script writes the
555/// signal once; this dirty-gated reader (same fast-path + `Added` escape hatch as
556/// [`apply_value_bindings`]) applies it on the write tick. The raw value is
557/// written unclamped - `clamp_scroll_offsets` (`TickStage::A11ySync`, registered
558/// by `lumen-primitives`) clips it to the content extent the same way it clips
559/// user scrolling, so signal-driven and wheel-driven offsets share one clamp
560/// rule.
561///
562/// Applying a signal value also zeroes any in-flight fling velocity on the
563/// container: a reactive `scroll_to` must land exactly where the script said,
564/// not get dragged onward by leftover momentum.
565pub fn apply_scroll_bindings(
566 store: Res<PropertyStore>,
567 mut q: Query<(&BindScroll, &mut ScrollOffset, Option<&mut Scroll>)>,
568 new_binds: Query<(), Added<BindScroll>>,
569) {
570 // Idle-tick fast path - see `apply_text_bindings` (incl. the
571 // `new_binds` escape hatch for reconciler-mounted `<scroll bind-scroll>`).
572 if store.dirty_peek().is_empty() && new_binds.is_empty() {
573 return;
574 }
575 for (bind, mut off, scroll) in &mut q {
576 let key = PropertyKey::Global(Arc::<str>::from(bind.0.as_str()));
577 let Some(pv) = store.get(&key) else {
578 continue;
579 };
580 let parsed: Option<f32> = match pv {
581 PropertyValue::F64(n) => Some(*n as f32),
582 PropertyValue::I64(n) => Some(*n as f32),
583 PropertyValue::Str(s) => s.as_ref().parse::<f32>().ok(),
584 _ => None,
585 };
586 let Some(parsed) = parsed else {
587 continue;
588 };
589 if (off.0.y - parsed).abs() > f32::EPSILON {
590 off.0.y = parsed;
591 if let Some(mut scroll) = scroll {
592 scroll.velocity = glam::Vec2::ZERO;
593 }
594 }
595 }
596}
597
598/// Pushes the settled vertical [`ScrollOffset`] back into the matching
599/// [`PropertyStore`] entry for every [`BindScroll`] entity (W6 T6, the
600/// two-way half).
601///
602/// Throttle contract: not per-frame. A user drag / wheel fling mutates the
603/// offset every tick; pushing each intermediate value would spam the store
604/// (and re-run every derivation) at frame rate. Instead the system arms a
605/// pending entry while the offset keeps changing, and pushes once on
606/// settle: the first tick where the offset did not change and the
607/// container's fling velocity has slept. Spawn-tick rows (`is_added()`)
608/// are skipped - the freshly-inserted offset is the widget default, not a
609/// user scroll (same rule as [`push_toggle_to_signal`]). The value is
610/// stringified like [`push_slider_to_signal`] so `<if>` comparators and
611/// interpolation keep working; the equality check keeps the
612/// signal->offset->signal round trip from echoing.
613pub fn push_scroll_to_signal(
614 mut store: ResMut<PropertyStore>,
615 q: Query<(Entity, &BindScroll, Ref<ScrollOffset>, Option<&Scroll>)>,
616 mut removed: RemovedComponents<BindScroll>,
617 mut pending: Local<HashSet<Entity>>,
618) {
619 // Drop settle latches for despawned / unbound containers so the
620 // Local set can't grow unbounded under a reconciler that churns
621 // `<scroll bind-scroll>` subtrees.
622 for gone in removed.read() {
623 pending.remove(&gone);
624 }
625 for (entity, bind, off, scroll) in &q {
626 if off.is_added() {
627 continue;
628 }
629 if off.is_changed() {
630 // Still moving - arm (or keep) the settle latch, push nothing.
631 pending.insert(entity);
632 continue;
633 }
634 if !pending.contains(&entity) {
635 continue;
636 }
637 // Offset unchanged this tick; wait out any live fling so the
638 // settled value (post rubber-band / clamp) is what lands.
639 let velocity_live = scroll.is_some_and(|s| s.velocity.length_squared() > 1.0);
640 if velocity_live {
641 continue;
642 }
643 pending.remove(&entity);
644 let serialised = format!("{}", off.0.y);
645 let stored: Option<Arc<str>> = store.get_global_str(&bind.0);
646 if stored.as_deref() != Some(serialised.as_str()) {
647 store.set_global_str(&bind.0, serialised);
648 }
649 }
650}
651
652/// One row of a reactive array; field name -> stringified value.
653pub type ArrayItem = HashMap<String, String>;
654
655/// Reactive array store paired with [`Signals`]; ordered vectors of [`ArrayItem`] keyed by name.
656/// Written through `ScriptCommand::SetArray`; the reconciler system spawns/despawns markup children to match the stored vector.
657///
658/// Wave-D follow-up: a future migration may collapse this into
659/// `PropertyValue::Custom(Arc<ArrayItems>)` so the typed property store becomes
660/// the single source of truth - left as a separate resource for now since the
661/// record-shaped data model doesn't map cleanly onto the scalar typed cells.
662#[derive(Resource, Debug, Default, Clone)]
663pub struct ArraySignals(pub HashMap<String, Vec<ArrayItem>>);
664
665impl ArraySignals {
666 /// Replaces the contents of the named array with `items`.
667 pub fn set(&mut self, name: impl Into<String>, items: Vec<ArrayItem>) {
668 self.0.insert(name.into(), items);
669 }
670
671 /// Returns the named array as a slice, or `None` when absent.
672 pub fn get(&self, name: &str) -> Option<&[ArrayItem]> {
673 self.0.get(name).map(Vec::as_slice)
674 }
675
676 /// No-op placeholder retained for forward-compatibility with an incremental-diff reconciler.
677 pub fn touch(&mut self, name: &str) {
678 let _ = name;
679 }
680}
681
682// ============================================================
683// External signal channel.
684//
685// Provides a thread-safe ingress for `push_external_signal`, `push_external_array`, and `push_external_clear`.
686// The runtime installs [`drain_external_signals`] as a per-tick system that applies enqueued mutations to [`ArraySignals`].
687// Scalar Signal writes route through [`push_external_property`] post wave-D so the cell lands in
688// [`PropertyStore`] directly; the legacy Array / Clear payloads stay on this channel until ArraySignals
689// itself migrates.
690// ============================================================
691
692/// One enqueued mutation applied by [`drain_external_signals`] on the next tick. Values are pre-stringified.
693#[derive(Debug, Clone)]
694pub enum ExternalMutation {
695 /// Overwrite a scalar signal.
696 Signal {
697 /// Signal name.
698 name: String,
699 /// Pre-formatted stringified value.
700 value: String,
701 },
702 /// Replace an array signal with new rows.
703 Array {
704 /// Signal name.
705 name: String,
706 /// New rows, each a flat `field -> value` map matching the `<for>` template placeholders.
707 items: Vec<ArrayItem>,
708 },
709 /// Clear the signal (scalar becomes empty string; array becomes empty vec).
710 Clear {
711 /// Signal name.
712 name: String,
713 },
714}
715
716static EXTERNAL_TX: OnceLock<Sender<ExternalMutation>> = OnceLock::new();
717static EXTERNAL_RX: OnceLock<Mutex<Receiver<ExternalMutation>>> = OnceLock::new();
718
719fn init_external_channel() -> &'static Sender<ExternalMutation> {
720 EXTERNAL_TX.get_or_init(|| {
721 let (tx, rx) = unbounded();
722 let _ = EXTERNAL_RX.set(Mutex::new(rx));
723 tx
724 })
725}
726
727/// Idempotently initialises the external signal channel. Safe to call multiple times.
728pub fn init_external_signals() {
729 let _ = init_external_channel();
730 // Also wire the typed-property channel so any caller that touches
731 // `init_external_signals` for the legacy path implicitly also gets
732 // the wave-D typed bus (FFI callers, async tasks).
733 crate::property_store::init_external_properties();
734}
735
736/// Sends a scalar signal write from any thread. Wave-D: routes through
737/// [`push_external_property`] so the value lands typed in [`PropertyStore`] under
738/// `PropertyKey::Global(name)` on the next tick.
739///
740/// Returns `false` when the channel has disconnected.
741pub fn push_external_signal(name: impl Into<String>, value: impl Into<String>) -> bool {
742 let name_str = name.into();
743 let value_str = value.into();
744 let key = PropertyKey::Global(Arc::<str>::from(name_str.as_str()));
745 let value = PropertyValue::Str(Arc::<str>::from(value_str.as_str()));
746 push_external_property(key, value)
747}
748
749/// Sends an array mutation from any thread. Returns `false` when the channel has disconnected.
750/// Array signals stay on the legacy [`ExternalMutation`] path until [`ArraySignals`] migrates to [`PropertyStore`].
751pub fn push_external_array(name: impl Into<String>, items: Vec<ArrayItem>) -> bool {
752 let tx = init_external_channel();
753 tx.send(ExternalMutation::Array {
754 name: name.into(),
755 items,
756 })
757 .is_ok()
758}
759
760/// Sends a clear mutation from any thread. Returns `false` when the channel has disconnected.
761/// The scalar half routes through [`push_external_property`] (empty string); the array half stays
762/// on the legacy channel.
763pub fn push_external_clear(name: impl Into<String>) -> bool {
764 let tx = init_external_channel();
765 let name_str = name.into();
766 // Scalar clear: empty string on PropertyStore.
767 let _ = push_external_property(
768 PropertyKey::Global(Arc::<str>::from(name_str.as_str())),
769 PropertyValue::Str(Arc::<str>::from("")),
770 );
771 // Array clear: legacy channel.
772 tx.send(ExternalMutation::Clear { name: name_str }).is_ok()
773}
774
775/// Per-tick system that drains queued external Array / Clear mutations into [`ArraySignals`].
776/// Scalar signal writes were routed through [`push_external_property`] in wave-D and are committed
777/// to [`PropertyStore`] by [`crate::property_store::drain_external_properties`]; only the legacy
778/// Array path remains here.
779///
780/// No-ops on an empty channel.
781pub fn drain_external_signals(mut arrays: ResMut<ArraySignals>) {
782 let Some(rx_lock) = EXTERNAL_RX.get() else {
783 return;
784 };
785 let Ok(rx) = rx_lock.lock() else {
786 return;
787 };
788 loop {
789 match rx.try_recv() {
790 Ok(ExternalMutation::Signal { name, value }) => {
791 // Pre wave-D callers may still hand us a stringified scalar write; bounce it
792 // through the typed bus so PropertyStore observes it on the next drain.
793 let _ = push_external_property(
794 PropertyKey::Global(Arc::<str>::from(name.as_str())),
795 PropertyValue::Str(Arc::<str>::from(value.as_str())),
796 );
797 }
798 Ok(ExternalMutation::Array { name, items }) => {
799 arrays.set(name, items);
800 }
801 Ok(ExternalMutation::Clear { name }) => {
802 arrays.set(name, Vec::new());
803 }
804 Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
805 }
806 }
807}