lumen_core/property_store.rs
1//! Typed reactive property store with notify-on-write semantics.
2//!
3//! - [`PropertyStore`] is a [`Resource`] holding a [`HashMap`] of [`PropertyKey`] -> [`PropertyCell`].
4//! - [`PropertyStore::set`] writes the cell, bumps its generation, and records the key onto the per-tick dirty queue.
5//! - The per-tick dirty queue drives the observer systems: they read it with [`PropertyStore::dirty_peek`] each tick, and the end-of-tick `clear_property_store_dirty` system resets it. [`PropertyStore::drain_dirty`] is the consuming variant for callers that want the entries.
6//! - [`PropertyStore::freeze_notify`] / [`PropertyStore::thaw_notify`] gate the dirty queue across batched writes (e.g. `<for>` reconciler rewriting 1000 rows).
7//!
8//! The legacy [`crate::signals::Signals`] resource is a thin wrapper over this store keyed on `PropertyKey::Global(name)`.
9
10use crate::components::Color;
11use bevy_ecs::prelude::*;
12use crossbeam_channel::{Receiver, Sender, unbounded};
13use glam::Vec2;
14use smallvec::SmallVec;
15use std::any::Any;
16use std::collections::HashMap;
17use std::marker::PhantomData;
18use std::sync::{Arc, Mutex, OnceLock};
19
20/// Identifier for an observer registered against a [`PropertyKey`]. Newtype-wrapped `u64`.
21#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)]
22pub struct ListenerId(pub u64);
23
24/// Identifier for a one-way / two-way [`PropertyStore::bind`] binding. Newtype-wrapped `u64`.
25#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)]
26pub struct BindingId(pub u64);
27
28/// Stable key identifying a property cell.
29///
30/// - `Global(name)`: the formerly-`Signals[name]` namespace.
31/// - `Entity(e, name)`: an entity-scoped property (QML's `foo.text` analogue) - wave 1 wires it.
32#[derive(Clone, Debug)]
33pub enum PropertyKey {
34 /// Global, name-keyed property - replaces `Signals[name]`.
35 Global(Arc<str>),
36 /// Entity-scoped property; the name is the [`crate::traits::Bindable::NAME`] of the bindable component.
37 Entity(Entity, Arc<str>),
38}
39
40impl PartialEq for PropertyKey {
41 fn eq(&self, other: &Self) -> bool {
42 match (self, other) {
43 (PropertyKey::Global(a), PropertyKey::Global(b)) => **a == **b,
44 (PropertyKey::Entity(ea, na), PropertyKey::Entity(eb, nb)) => ea == eb && **na == **nb,
45 _ => false,
46 }
47 }
48}
49
50impl Eq for PropertyKey {}
51
52impl std::hash::Hash for PropertyKey {
53 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
54 match self {
55 PropertyKey::Global(name) => {
56 0u8.hash(state);
57 (**name).hash(state);
58 }
59 PropertyKey::Entity(e, name) => {
60 1u8.hash(state);
61 e.hash(state);
62 (**name).hash(state);
63 }
64 }
65 }
66}
67
68impl PropertyKey {
69 /// Returns a new `Global` key from any string-like input.
70 pub fn global(name: impl Into<Arc<str>>) -> Self {
71 PropertyKey::Global(name.into())
72 }
73
74 /// Returns a new `Entity`-scoped key.
75 pub fn entity(e: Entity, name: impl Into<Arc<str>>) -> Self {
76 PropertyKey::Entity(e, name.into())
77 }
78}
79
80/// Typed property value. The `Custom` variant covers Rust types not enumerated here.
81#[derive(Clone)]
82pub enum PropertyValue {
83 /// Boolean payload.
84 Bool(bool),
85 /// Signed 64-bit integer payload.
86 I64(i64),
87 /// 64-bit float payload.
88 F64(f64),
89 /// Shared string payload.
90 Str(Arc<str>),
91 /// RGBA color payload.
92 Color(Color),
93 /// 2-vector payload (typically a logical-pixel position or size).
94 Vec2(Vec2),
95 /// Escape hatch for types not covered by the enumerated variants. The inner `Arc` is shared cheaply across clones.
96 Custom(Arc<dyn Any + Send + Sync>),
97}
98
99impl std::fmt::Debug for PropertyValue {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 match self {
102 PropertyValue::Bool(b) => f.debug_tuple("Bool").field(b).finish(),
103 PropertyValue::I64(n) => f.debug_tuple("I64").field(n).finish(),
104 PropertyValue::F64(n) => f.debug_tuple("F64").field(n).finish(),
105 PropertyValue::Str(s) => f.debug_tuple("Str").field(s).finish(),
106 PropertyValue::Color(c) => f.debug_tuple("Color").field(c).finish(),
107 PropertyValue::Vec2(v) => f.debug_tuple("Vec2").field(v).finish(),
108 PropertyValue::Custom(_) => f.debug_tuple("Custom").field(&"<dyn Any>").finish(),
109 }
110 }
111}
112
113impl PropertyValue {
114 /// Structural equality across enumerated variants.
115 ///
116 /// `Custom` is treated as never-equal - there is no general equality across `dyn Any`. Treating it as always-changed
117 /// is the safe conservative choice for the dirty queue (a redundant notify is fine; a missed notify is not).
118 pub fn eq_value(&self, other: &Self) -> bool {
119 match (self, other) {
120 (PropertyValue::Bool(a), PropertyValue::Bool(b)) => a == b,
121 (PropertyValue::I64(a), PropertyValue::I64(b)) => a == b,
122 (PropertyValue::F64(a), PropertyValue::F64(b)) => a.to_bits() == b.to_bits(),
123 (PropertyValue::Str(a), PropertyValue::Str(b)) => **a == **b,
124 (PropertyValue::Color(a), PropertyValue::Color(b)) => a == b,
125 (PropertyValue::Vec2(a), PropertyValue::Vec2(b)) => a == b,
126 _ => false,
127 }
128 }
129}
130
131// --- Conversion impls so `Bindable::Value: Into<PropertyValue> + From<PropertyValue>` works for the common types.
132
133impl From<bool> for PropertyValue {
134 fn from(v: bool) -> Self {
135 PropertyValue::Bool(v)
136 }
137}
138impl From<i64> for PropertyValue {
139 fn from(v: i64) -> Self {
140 PropertyValue::I64(v)
141 }
142}
143impl From<f64> for PropertyValue {
144 fn from(v: f64) -> Self {
145 PropertyValue::F64(v)
146 }
147}
148impl From<f32> for PropertyValue {
149 fn from(v: f32) -> Self {
150 PropertyValue::F64(v as f64)
151 }
152}
153impl From<Arc<str>> for PropertyValue {
154 fn from(v: Arc<str>) -> Self {
155 PropertyValue::Str(v)
156 }
157}
158impl From<String> for PropertyValue {
159 fn from(v: String) -> Self {
160 PropertyValue::Str(v.into())
161 }
162}
163impl From<&str> for PropertyValue {
164 fn from(v: &str) -> Self {
165 PropertyValue::Str(v.into())
166 }
167}
168impl From<Color> for PropertyValue {
169 fn from(v: Color) -> Self {
170 PropertyValue::Color(v)
171 }
172}
173impl From<Vec2> for PropertyValue {
174 fn from(v: Vec2) -> Self {
175 PropertyValue::Vec2(v)
176 }
177}
178
179/// Inverse impls - used by [`crate::traits::Bindable::write`] to receive a typed value from the store.
180///
181/// These fall back to the type's `Default` when the stored value has the wrong variant. Callers who want strict typing
182/// should read the cell directly and pattern-match.
183impl From<PropertyValue> for bool {
184 fn from(v: PropertyValue) -> Self {
185 match v {
186 PropertyValue::Bool(b) => b,
187 PropertyValue::I64(n) => n != 0,
188 PropertyValue::Str(s) => matches!(&*s, "true" | "1"),
189 _ => false,
190 }
191 }
192}
193
194impl From<PropertyValue> for i64 {
195 fn from(v: PropertyValue) -> Self {
196 match v {
197 PropertyValue::I64(n) => n,
198 PropertyValue::F64(n) => n as i64,
199 PropertyValue::Bool(b) => b as i64,
200 PropertyValue::Str(s) => s.parse().unwrap_or_default(),
201 _ => 0,
202 }
203 }
204}
205
206impl From<PropertyValue> for f64 {
207 fn from(v: PropertyValue) -> Self {
208 match v {
209 PropertyValue::F64(n) => n,
210 PropertyValue::I64(n) => n as f64,
211 PropertyValue::Bool(b) => b as i64 as f64,
212 PropertyValue::Str(s) => s.parse().unwrap_or_default(),
213 _ => 0.0,
214 }
215 }
216}
217
218impl From<PropertyValue> for f32 {
219 fn from(v: PropertyValue) -> Self {
220 f64::from(v) as f32
221 }
222}
223
224impl From<PropertyValue> for Arc<str> {
225 fn from(v: PropertyValue) -> Self {
226 match v {
227 PropertyValue::Str(s) => s,
228 PropertyValue::Bool(b) => if b { "true" } else { "false" }.into(),
229 PropertyValue::I64(n) => n.to_string().into(),
230 PropertyValue::F64(n) => n.to_string().into(),
231 PropertyValue::Color(_) | PropertyValue::Vec2(_) | PropertyValue::Custom(_) => {
232 "".into()
233 }
234 }
235 }
236}
237
238impl From<PropertyValue> for String {
239 fn from(v: PropertyValue) -> Self {
240 Arc::<str>::from(v).to_string()
241 }
242}
243
244impl From<PropertyValue> for Color {
245 fn from(v: PropertyValue) -> Self {
246 match v {
247 PropertyValue::Color(c) => c,
248 _ => Color::default(),
249 }
250 }
251}
252
253impl From<PropertyValue> for Vec2 {
254 fn from(v: PropertyValue) -> Self {
255 match v {
256 PropertyValue::Vec2(v) => v,
257 _ => Vec2::ZERO,
258 }
259 }
260}
261
262/// One stored property: the typed value, optional binding source, registered listeners, and a monotonic generation counter.
263#[derive(Debug)]
264pub struct PropertyCell {
265 /// Current value.
266 pub value: PropertyValue,
267 /// Listener handles fanned out on each [`PropertyStore::set`].
268 pub listeners: SmallVec<[ListenerId; 4]>,
269 /// Optional one-way binding source. Populated by [`PropertyStore::bind`]; wave 1 wires the propagation system.
270 pub binding: Option<BindingId>,
271 /// Monotonic write counter; bumped on every successful `set`.
272 pub generation: u64,
273}
274
275/// Reactive typed property store. Replaces the legacy `Signals: HashMap<String, String>` with a notify-on-write store.
276///
277/// Read [`crate::signals::Signals`] for backward-compatible callers; new code should use this store directly.
278#[derive(Resource, Default, Debug)]
279pub struct PropertyStore {
280 /// All stored cells.
281 cells: HashMap<PropertyKey, PropertyCell>,
282 /// Per-tick dirty queue; drained by [`Self::drain_dirty`].
283 dirty: SmallVec<[PropertyKey; 16]>,
284 /// Depth counter for [`Self::freeze_notify`] / [`Self::thaw_notify`].
285 /// When non-zero, `set` still writes the cell but skips appending to `dirty`.
286 notify_freeze_depth: u32,
287 /// Monotonic listener id allocator.
288 next_listener: u64,
289 /// Monotonic binding id allocator.
290 next_binding: u64,
291}
292
293impl PropertyStore {
294 /// Writes `value` into the cell for `key`. Bumps the cell's generation and, unless [`Self::freeze_notify`] is active,
295 /// appends `key` to the dirty queue.
296 ///
297 /// Returns `true` when the stored value actually changed (semantically equal writes skip the dirty push - matches
298 /// GObject's `notify` behaviour).
299 pub fn set(&mut self, key: PropertyKey, value: PropertyValue) -> bool {
300 let changed = match self.cells.get(&key) {
301 Some(cell) => !cell.value.eq_value(&value),
302 None => true,
303 };
304 let cell = self
305 .cells
306 .entry(key.clone())
307 .or_insert_with(|| PropertyCell {
308 value: value.clone(),
309 listeners: SmallVec::new(),
310 binding: None,
311 generation: 0,
312 });
313 if changed {
314 cell.value = value;
315 cell.generation = cell.generation.wrapping_add(1);
316 if self.notify_freeze_depth == 0 {
317 self.dirty.push(key);
318 }
319 }
320 changed
321 }
322
323 /// Returns a shared reference to the cell's value, or `None` when the key is absent.
324 pub fn get(&self, key: &PropertyKey) -> Option<&PropertyValue> {
325 self.cells.get(key).map(|c| &c.value)
326 }
327
328 /// Returns a shared reference to the full cell, or `None` when the key is absent.
329 pub fn cell(&self, key: &PropertyKey) -> Option<&PropertyCell> {
330 self.cells.get(key)
331 }
332
333 /// Iterate over every `(key, value)` pair in storage, in arbitrary
334 /// order. Used by [`mirror_property_store_to_typed_cache`] to
335 /// snapshot the typed scalar cells into a process-wide cache that
336 /// FFI accessors read from any thread.
337 pub fn iter(&self) -> impl Iterator<Item = (&PropertyKey, &PropertyValue)> {
338 self.cells.iter().map(|(k, c)| (k, &c.value))
339 }
340
341 /// Returns `true` when `key` has a cell.
342 pub fn contains(&self, key: &PropertyKey) -> bool {
343 self.cells.contains_key(key)
344 }
345
346 /// Returns the number of listeners registered for `key` (currently always 0 - wave 1 wires the listener API).
347 pub fn listener_count(&self, key: &PropertyKey) -> usize {
348 self.cells.get(key).map(|c| c.listeners.len()).unwrap_or(0)
349 }
350
351 /// Allocates and registers a fresh listener id against `key`. The cell is auto-created with a [`PropertyValue::Bool(false)`] sentinel when absent;
352 /// the next `set` overwrites it.
353 pub fn add_listener(&mut self, key: PropertyKey) -> ListenerId {
354 self.next_listener = self.next_listener.wrapping_add(1);
355 let id = ListenerId(self.next_listener);
356 let cell = self.cells.entry(key).or_insert_with(|| PropertyCell {
357 value: PropertyValue::Bool(false),
358 listeners: SmallVec::new(),
359 binding: None,
360 generation: 0,
361 });
362 cell.listeners.push(id);
363 id
364 }
365
366 /// Registers a one-way binding from `src` to `dst`. Returns the [`BindingId`] stored on the destination cell.
367 /// Wave 1 wires the actual propagation system; this method only records the wiring intent today.
368 pub fn bind(&mut self, dst: PropertyKey, _src: PropertyKey) -> BindingId {
369 self.next_binding = self.next_binding.wrapping_add(1);
370 let id = BindingId(self.next_binding);
371 let cell = self.cells.entry(dst).or_insert_with(|| PropertyCell {
372 value: PropertyValue::Bool(false),
373 listeners: SmallVec::new(),
374 binding: None,
375 generation: 0,
376 });
377 cell.binding = Some(id);
378 id
379 }
380
381 /// Pushes the freeze counter so subsequent `set` calls write the cell without appending to `dirty`.
382 /// Pair with [`Self::thaw_notify`]; nested freeze/thaw is supported.
383 pub fn freeze_notify(&mut self) {
384 self.notify_freeze_depth = self.notify_freeze_depth.saturating_add(1);
385 }
386
387 /// Pops the freeze counter. When the depth reaches zero, no implicit `dirty` flush happens - callers that need to
388 /// fire deferred notifies should manually re-set the affected keys after thaw.
389 pub fn thaw_notify(&mut self) {
390 self.notify_freeze_depth = self.notify_freeze_depth.saturating_sub(1);
391 }
392
393 /// Drains the per-tick dirty queue, returning the dirtied keys. Downstream systems call this once per tick.
394 pub fn drain_dirty(&mut self) -> SmallVec<[PropertyKey; 16]> {
395 std::mem::take(&mut self.dirty)
396 }
397
398 /// Returns a snapshot of the current dirty queue without draining it.
399 pub fn dirty_peek(&self) -> &[PropertyKey] {
400 &self.dirty
401 }
402
403 /// Clear the per-tick dirty queue without consuming it. Sibling to [`Self::drain_dirty`]
404 /// for callers that only want the reset side-effect - used by the end-of-tick
405 /// `clear_property_store_dirty` system to drop accumulated entries after every consumer
406 /// (derivations, theme propagation, ...) has already peeked.
407 pub fn clear_dirty(&mut self) {
408 self.dirty.clear();
409 }
410
411 /// Number of stored cells. Useful for tests and devtools.
412 pub fn len(&self) -> usize {
413 self.cells.len()
414 }
415
416 /// Returns `true` when no cells are stored.
417 pub fn is_empty(&self) -> bool {
418 self.cells.is_empty()
419 }
420
421 // --- Wave-D ergonomic helpers (Global-keyed `Str` cells) -----------------
422 //
423 // These let migrating callers swap `signals.get(name)` for
424 // `store.get_global_str(name)` without authoring the `PropertyKey::Global` /
425 // `PropertyValue::Str` ceremony at every call site. They're additive - the
426 // raw [`Self::get`] / [`Self::set`] APIs continue to work and are still
427 // required for non-Global / non-Str use.
428
429 /// Reads a globally-keyed string cell, stringifying scalar variants on demand.
430 /// Returns `None` when the cell is absent.
431 ///
432 /// Coercion matches the [`From<PropertyValue> for Arc<str>`] impl above:
433 /// `Bool` -> `"true"` / `"false"`; `I64` / `F64` -> decimal repr; non-scalar
434 /// variants (`Color`, `Vec2`, `Custom`) yield `Some("")` - callers that
435 /// need the typed value should pattern-match the raw cell.
436 pub fn get_global_str(&self, name: &str) -> Option<Arc<str>> {
437 let key = PropertyKey::Global(Arc::<str>::from(name));
438 self.get(&key).cloned().map(Arc::<str>::from)
439 }
440
441 /// Writes a string into a globally-keyed cell. Convenience for the common
442 /// `set(PropertyKey::Global(name), PropertyValue::Str(value))` pattern.
443 pub fn set_global_str(&mut self, name: &str, value: impl Into<Arc<str>>) -> bool {
444 self.set(
445 PropertyKey::Global(Arc::<str>::from(name)),
446 PropertyValue::Str(value.into()),
447 )
448 }
449
450 /// Reads a globally-keyed boolean. Recognises `Bool` directly, and reads a
451 /// string cell through [`crate::signals::signal_as_bool`], which is the one
452 /// rule for the canonical `"true"` / `"false"` / `"1"` / `"0"` aliases
453 /// [`crate::signals::Signals::set_bool`] used to write. Other variants
454 /// (numeric, colour, ...) yield `None` so callers can fall back to a default
455 /// rather than treating an unrelated value as `false`.
456 pub fn get_global_bool(&self, name: &str) -> Option<bool> {
457 let key = PropertyKey::Global(Arc::<str>::from(name));
458 match self.get(&key)? {
459 PropertyValue::Bool(b) => Some(*b),
460 PropertyValue::Str(s) => crate::signals::signal_as_bool(s.as_ref()),
461 _ => None,
462 }
463 }
464
465 /// Writes a boolean as the canonical `"true"` / `"false"` string variant.
466 /// Mirrors [`crate::signals::Signals::set_bool`] semantics so downstream
467 /// readers that still consult string variants (`<if eq="true">` body
468 /// comparator) keep working post-migration.
469 pub fn set_global_bool(&mut self, name: &str, value: bool) -> bool {
470 self.set_global_str(name, if value { "true" } else { "false" })
471 }
472
473 /// Iterates the per-tick dirty queue and yields just the global property
474 /// names. Replaces direct iteration over the legacy `Signals::dirty` set.
475 pub fn dirty_global_names(&self) -> impl Iterator<Item = &str> {
476 self.dirty.iter().filter_map(|k| match k {
477 PropertyKey::Global(name) => Some(name.as_ref()),
478 PropertyKey::Entity(_, _) => None,
479 })
480 }
481}
482
483/// End-of-tick system that clears the [`PropertyStore`] dirty queue. Runs in
484/// [`crate::tick::TickStage::A11ySync`] after every consumer that peeks the
485/// queue (theme propagation, derivations, render-world frame dirty roll-up).
486///
487/// Without this the queue grows monotonically and `dirty_global_names` returns
488/// stale entries on subsequent ticks. The system is the wave-D replacement for
489/// `clear_signal_dirty` - same role, different backing store.
490pub fn clear_property_store_dirty(store: Option<ResMut<PropertyStore>>) {
491 if let Some(mut s) = store
492 && !s.dirty.is_empty()
493 {
494 s.clear_dirty();
495 }
496}
497
498// --- `Property<T>` typed handle ----------------------------------------------
499//
500// W7.x ergonomics: avoid stringifying typed signals across the Rust API. The
501// handle wraps a `PropertyKey` with a phantom `T` so `store.get` / `store.set`
502// round-trip through the existing `From`/`Into` conversions without bespoke
503// per-type helpers.
504//
505// Conversions reuse the `From<PropertyValue> for T` impls landed above, which
506// auto-derive `TryFrom<PropertyValue> for T` via the stdlib blanket impl with
507// `Error = Infallible`. That matches the lossy fallback the legacy scripts
508// already expect (e.g. reading an `I64` cell as `f64` coerces).
509
510/// Typed property handle. Wraps a [`PropertyKey`] with a phantom `T` so reads
511/// and writes never stringify.
512///
513/// Construct with [`Self::new`] (global key) or [`Self::entity`] (entity-scoped
514/// key). Round-trip through [`Self::get`] / [`Self::set`] against a
515/// [`PropertyStore`] borrow.
516///
517/// ```ignore
518/// use lumen_core::prelude::*;
519/// let count: Property<i64> = Property::new("count");
520/// let mut store = PropertyStore::default();
521/// count.set(&mut store, 42);
522/// assert_eq!(count.get(&store), Some(42));
523/// ```
524#[derive(Clone, Debug)]
525pub struct Property<T> {
526 key: PropertyKey,
527 _marker: PhantomData<fn() -> T>,
528}
529
530impl<T> Property<T>
531where
532 T: TryFrom<PropertyValue> + Into<PropertyValue> + Clone,
533{
534 /// Constructs a handle keyed on the global namespace (`PropertyKey::Global`).
535 pub fn new(name: impl Into<Arc<str>>) -> Self {
536 Self {
537 key: PropertyKey::Global(name.into()),
538 _marker: PhantomData,
539 }
540 }
541
542 /// Constructs a handle keyed on the entity-scoped namespace.
543 pub fn entity(e: Entity, name: impl Into<Arc<str>>) -> Self {
544 Self {
545 key: PropertyKey::Entity(e, name.into()),
546 _marker: PhantomData,
547 }
548 }
549
550 /// Borrows the underlying [`PropertyKey`]. Useful when the caller needs to
551 /// pass the key into raw [`PropertyStore`] APIs.
552 pub fn key(&self) -> &PropertyKey {
553 &self.key
554 }
555
556 /// Reads the typed value from `store`. Returns `None` when the key is
557 /// absent. When the stored cell is a variant `T` doesn't natively
558 /// represent, the conversion falls back to `T`'s `Default`-ish coercion
559 /// (matches the existing `From<PropertyValue> for T` semantics).
560 pub fn get(&self, store: &PropertyStore) -> Option<T> {
561 store
562 .get(&self.key)
563 .cloned()
564 .and_then(|v| T::try_from(v).ok())
565 }
566
567 /// Writes `value` into the cell, bumping the cell's generation and
568 /// appending to the dirty queue (subject to [`PropertyStore::freeze_notify`]).
569 pub fn set(&self, store: &mut PropertyStore, value: T) {
570 store.set(self.key.clone(), value.into());
571 }
572}
573
574// --- External typed-property bus ---------------------------------------------
575//
576// Round 4 typed-signal closure: cross-thread callers (the C-ABI crate, async tasks)
577// that want to write a *typed* `PropertyValue` (Int64/Float64/Bool/Color/Vec2)
578// directly into the `PropertyStore` use this channel. The existing
579// `lumen_core::signals::push_external_signal` path is `Str`-only -
580// `drain_external_signals` skips non-Str variants. The bus + drain below
581// land typed writes without round-tripping through `Signals`.
582
583static EXTERNAL_PROPERTY_TX: OnceLock<Sender<(PropertyKey, PropertyValue)>> = OnceLock::new();
584static EXTERNAL_PROPERTY_RX: OnceLock<Mutex<Receiver<(PropertyKey, PropertyValue)>>> =
585 OnceLock::new();
586
587fn init_external_property_channel() -> &'static Sender<(PropertyKey, PropertyValue)> {
588 EXTERNAL_PROPERTY_TX.get_or_init(|| {
589 let (tx, rx) = unbounded();
590 let _ = EXTERNAL_PROPERTY_RX.set(Mutex::new(rx));
591 tx
592 })
593}
594
595/// Idempotently initialises the cross-thread typed-property channel. Safe to call multiple times.
596pub fn init_external_properties() {
597 let _ = init_external_property_channel();
598}
599
600/// Sends a typed `(PropertyKey, PropertyValue)` write from any thread.
601///
602/// Picked up on the next tick by [`drain_external_properties`]; bypasses the
603/// legacy `Signals` mirror entirely so the receiving cell stores the typed
604/// variant (`I64`, `F64`, `Bool`, `Color`, ...) directly.
605///
606/// Returns `false` when the channel has disconnected.
607pub fn push_external_property(key: PropertyKey, value: PropertyValue) -> bool {
608 init_external_property_channel().send((key, value)).is_ok()
609}
610
611/// Snapshot the cells currently visible to the external bus by polling the
612/// channel non-destructively. Used by FFI typed reads that want to consult
613/// pending pre-run writes without owning a `PropertyStore`. The returned
614/// map is empty when the channel is unset or empty.
615///
616/// This is a best-effort accessor - concurrent senders may add entries
617/// after the snapshot returns. Read consistency across a single FFI call
618/// is sufficient for round-trip embedder scenarios (set N, read N).
619pub fn external_property_snapshot() -> HashMap<PropertyKey, PropertyValue> {
620 let Some(rx_lock) = EXTERNAL_PROPERTY_RX.get() else {
621 return HashMap::new();
622 };
623 let Ok(rx) = rx_lock.lock() else {
624 return HashMap::new();
625 };
626 // We can't peek non-destructively at a crossbeam receiver. Drain into
627 // a buffer, then re-send the entries back through the channel so the
628 // tick-side drain still sees them. Acceptable cost: one round-trip
629 // per FFI read while the embedder is in pre-run config mode.
630 let mut buf: Vec<(PropertyKey, PropertyValue)> = Vec::new();
631 while let Ok(entry) = rx.try_recv() {
632 buf.push(entry);
633 }
634 let tx = init_external_property_channel();
635 let mut snapshot = HashMap::new();
636 for (k, v) in &buf {
637 snapshot.insert(k.clone(), v.clone());
638 }
639 for entry in buf {
640 let _ = tx.send(entry);
641 }
642 snapshot
643}
644
645/// Returns `true` when the cross-thread typed-property channel currently
646/// holds undrained writes.
647///
648/// Non-destructive: peeks the receiver's queue length without consuming
649/// any entries. The window backend calls this after `App::tick()` so it
650/// can self-schedule a follow-up frame when a write is still sitting in
651/// the bus (e.g. a background thread pushed after this tick's
652/// [`drain_external_properties`] already ran) - otherwise the value would
653/// wait for the next unrelated OS event to wake the loop.
654///
655/// Returns `false` when the channel was never initialised, is empty, or
656/// its lock is poisoned.
657pub fn external_properties_pending() -> bool {
658 EXTERNAL_PROPERTY_RX
659 .get()
660 .and_then(|rx_lock| rx_lock.lock().ok().map(|rx| !rx.is_empty()))
661 .unwrap_or(false)
662}
663
664/// Empties the cross-thread typed-property channel, throwing away whatever it
665/// holds.
666///
667/// The bus is one channel per process, so a second app built in the same
668/// process inherits every write the first one left queued. A caller that runs
669/// several apps in sequence (a build rendering one page after another, a
670/// server rendering one request after another) calls this between them so each
671/// app starts from its own state. Pair it with
672/// [`crate::signals::discard_external_signals`], which empties the array half.
673pub fn discard_external_properties() {
674 let Some(rx_lock) = EXTERNAL_PROPERTY_RX.get() else {
675 return;
676 };
677 let Ok(rx) = rx_lock.lock() else {
678 return;
679 };
680 while rx.try_recv().is_ok() {}
681}
682
683/// Per-tick system that drains every queued typed-property write into
684/// [`PropertyStore`]. Each entry calls [`PropertyStore::set`] so the cell
685/// gets the typed variant directly - no stringification, no `Signals`
686/// round-trip.
687///
688/// Pair with [`init_external_properties`] at startup; the runtime
689/// (`lumenc` or a custom embedder) registers this in
690/// [`crate::tick::TickStage::CommandDrain`] alongside
691/// [`crate::command::apply_property_commands`].
692pub fn drain_external_properties(mut store: ResMut<PropertyStore>) {
693 let Some(rx_lock) = EXTERNAL_PROPERTY_RX.get() else {
694 return;
695 };
696 let Ok(rx) = rx_lock.lock() else {
697 return;
698 };
699 while let Ok((key, value)) = rx.try_recv() {
700 store.set(key, value);
701 }
702}
703
704/// Second, in-`Systems`-stage drain of the external typed-property bus,
705/// for same-tick commit of main-thread script writes.
706///
707/// [`drain_external_properties`] is registered in
708/// [`crate::tick::TickStage::CommandDrain`], which is chained *before*
709/// [`crate::tick::TickStage::Systems`]. A main-thread script that writes a
710/// signal during event dispatch (`on_click` -> `signals.count.set(..)`)
711/// pushes onto the bus from inside a `Systems`-stage system, i.e. too late
712/// for that tick's CommandDrain drain - so without this the new value
713/// would sit in the bus until the *next* tick, adding a whole frame of
714/// input latency before a `bind="text:count"` reader reflects it.
715///
716/// The embedder registers this in `TickStage::Systems`, ordered *after*
717/// the script dispatch systems and *before* the reactive binding readers
718/// ([`crate::signals::apply_text_bindings`] et al.), so those writes land
719/// in [`PropertyStore`] on the very tick the click fired. Cross-thread
720/// writers keep using the same bus untouched; entries that happen to be
721/// queued here are simply committed a little earlier than CommandDrain
722/// would - strictly lower latency, never a correctness change. Distinct
723/// system type from [`drain_external_properties`] so registering both in
724/// the same schedule stays unambiguous.
725pub fn commit_external_properties(store: ResMut<PropertyStore>) {
726 drain_external_properties(store);
727}
728
729// --- Typed-property mirror cache (PropertyStore -> cross-thread view) ---------
730//
731// FFI typed-read accessors (lumen_signal_get_int64 / _float64 / _bool / _color
732// in the root `lumen` crate) run on any thread - `Res<PropertyStore>` requires the ECS
733// scheduler. `mirror_property_store_to_typed_cache` runs at TickStage end
734// and copies every globally-keyed typed cell into a process-wide
735// `Mutex<HashMap>` that the FFI consults from any thread. Round 6 closes the
736// loop: FFI reads now see PropertyStore writes from any source (script /
737// ECS / FFI), not only writes that flowed through the `push_external_property`
738// bus.
739
740static TYPED_PROPERTY_MIRROR: OnceLock<Mutex<HashMap<PropertyKey, PropertyValue>>> =
741 OnceLock::new();
742
743fn typed_property_mirror() -> &'static Mutex<HashMap<PropertyKey, PropertyValue>> {
744 TYPED_PROPERTY_MIRROR.get_or_init(|| Mutex::new(HashMap::new()))
745}
746
747/// Cross-thread snapshot of every typed [`PropertyStore`] cell mirrored
748/// by [`mirror_property_store_to_typed_cache`]. Returns the cache as
749/// owned data so callers can drop their lock immediately. FFI typed-read
750/// accessors consume this on their fallback path.
751pub fn typed_property_snapshot() -> HashMap<PropertyKey, PropertyValue> {
752 typed_property_mirror()
753 .lock()
754 .map(|m| m.clone())
755 .unwrap_or_default()
756}
757
758/// Per-tick system that reflects every typed cell in [`PropertyStore`] into
759/// the process-wide [`typed_property_snapshot`] cache. Pair with
760/// [`drain_external_properties`] in [`crate::tick::TickStage::A11ySync`]
761/// (or any late-tick stage) so the mirror sees writes that arrived this
762/// tick.
763///
764/// Strings and `Custom` variants are skipped - the FFI surface only
765/// exposes scalar accessors. Vec2 + Color flow through. The cache holds
766/// `PropertyValue` clones; consumers downcast at read time.
767pub fn mirror_property_store_to_typed_cache(store: Option<Res<PropertyStore>>) {
768 let Some(store) = store else {
769 return;
770 };
771 // Idle-tick fast path: nothing was written this tick, so the mirror
772 // already reflects the store. Every `set()` that changes a cell pushes
773 // its key onto the dirty queue, so an empty queue guarantees the cache
774 // is current - no lock, no rebuild. This system is ordered before
775 // `clear_property_store_dirty` so the queue is still populated here.
776 if store.dirty_peek().is_empty() {
777 return;
778 }
779 let Ok(mut cache) = typed_property_mirror().lock() else {
780 return;
781 };
782 // Update only the cells dirtied this tick rather than clearing and
783 // re-inserting the whole map every tick.
784 for key in store.dirty_peek() {
785 let Some(value) = store.get(key) else {
786 continue;
787 };
788 match value {
789 PropertyValue::I64(_)
790 | PropertyValue::F64(_)
791 | PropertyValue::Bool(_)
792 | PropertyValue::Color(_)
793 | PropertyValue::Vec2(_) => {
794 cache.insert(key.clone(), value.clone());
795 }
796 // Strings + Custom skipped - the FFI scalar accessors do not
797 // consume them. `bind-text` markup reads the string straight
798 // out of this store (`apply_text_bindings`), so it needs no
799 // mirror entry.
800 PropertyValue::Str(_) | PropertyValue::Custom(_) => {}
801 }
802 }
803}
804
805#[cfg(test)]
806mod tests {
807 use super::*;
808
809 #[test]
810 fn set_then_get_round_trips() {
811 let mut s = PropertyStore::default();
812 let k = PropertyKey::global("foo");
813 s.set(k.clone(), PropertyValue::I64(42));
814 assert!(matches!(s.get(&k), Some(PropertyValue::I64(42))));
815 }
816
817 #[test]
818 fn unchanged_set_does_not_dirty() {
819 let mut s = PropertyStore::default();
820 let k = PropertyKey::global("foo");
821 assert!(s.set(k.clone(), PropertyValue::I64(1)));
822 let _ = s.drain_dirty();
823 assert!(!s.set(k.clone(), PropertyValue::I64(1)));
824 assert!(s.drain_dirty().is_empty());
825 }
826
827 #[test]
828 fn external_bus_pending_reports_queued_writes() {
829 use std::sync::Arc;
830 // Resource-level guard for Fix 1's self-scheduling: a queued
831 // cross-thread / main-thread-script write must make
832 // `external_properties_pending()` read true, because that is the
833 // condition the window backend uses to re-arm the redraw. If this
834 // ever returned false with a write in the channel, the app would
835 // park with a stale frame until an unrelated OS event arrived.
836 //
837 // The channel is process-global, so we only assert the monotonic
838 // direction (a push makes it pending) to stay robust under the
839 // parallel test runner. `set(v) then get == v` after a manual
840 // drain confirms the write commits, mirroring the tick-side drain.
841 let k = PropertyKey::Global(Arc::<str>::from("bus_pending_probe_key"));
842 assert!(push_external_property(k.clone(), PropertyValue::I64(7)));
843 assert!(
844 external_properties_pending(),
845 "a queued external write must report pending so the backend re-arms the redraw"
846 );
847
848 // Commit the queued write via the same path the tick-side drain
849 // takes, and confirm it lands in the store (drains the channel so
850 // this probe key doesn't leak into sibling tests).
851 let mut store = PropertyStore::default();
852 if let Some(rx_lock) = EXTERNAL_PROPERTY_RX.get()
853 && let Ok(rx) = rx_lock.lock()
854 {
855 while let Ok((key, value)) = rx.try_recv() {
856 store.set(key, value);
857 }
858 }
859 assert!(matches!(store.get(&k), Some(PropertyValue::I64(7))));
860 }
861
862 #[test]
863 fn changed_set_appends_to_dirty() {
864 let mut s = PropertyStore::default();
865 let k = PropertyKey::global("foo");
866 s.set(k.clone(), PropertyValue::I64(1));
867 s.set(k.clone(), PropertyValue::I64(2));
868 let drained = s.drain_dirty();
869 assert_eq!(drained.len(), 2);
870 }
871
872 #[test]
873 fn freeze_then_thaw_skips_dirty() {
874 let mut s = PropertyStore::default();
875 let k = PropertyKey::global("foo");
876 s.freeze_notify();
877 s.set(k.clone(), PropertyValue::I64(1));
878 s.set(k.clone(), PropertyValue::I64(2));
879 assert!(s.drain_dirty().is_empty());
880 s.thaw_notify();
881 s.set(k.clone(), PropertyValue::I64(3));
882 assert_eq!(s.drain_dirty().len(), 1);
883 }
884
885 #[test]
886 fn generation_bumps_per_change() {
887 let mut s = PropertyStore::default();
888 let k = PropertyKey::global("foo");
889 s.set(k.clone(), PropertyValue::I64(1));
890 let g1 = s.cell(&k).unwrap().generation;
891 s.set(k.clone(), PropertyValue::I64(2));
892 let g2 = s.cell(&k).unwrap().generation;
893 assert_eq!(g2, g1 + 1);
894 }
895
896 #[test]
897 fn property_handle_round_trips_i64() {
898 let mut store = PropertyStore::default();
899 let count = Property::<i64>::new("count");
900 count.set(&mut store, 42);
901 assert_eq!(count.get(&store), Some(42));
902 count.set(&mut store, -7);
903 assert_eq!(count.get(&store), Some(-7));
904 }
905
906 #[test]
907 fn property_handle_round_trips_bool_and_f64() {
908 let mut store = PropertyStore::default();
909 let flag = Property::<bool>::new("flag");
910 flag.set(&mut store, true);
911 assert_eq!(flag.get(&store), Some(true));
912 let amount = Property::<f64>::new("amount");
913 amount.set(&mut store, 3.5);
914 assert_eq!(amount.get(&store), Some(3.5));
915 }
916
917 #[test]
918 fn property_handle_absent_key_returns_none() {
919 let store = PropertyStore::default();
920 let p = Property::<i64>::new("nope");
921 assert_eq!(p.get(&store), None);
922 }
923
924 #[test]
925 fn property_handle_exposes_key() {
926 let p = Property::<i64>::new("count");
927 match p.key() {
928 PropertyKey::Global(name) => assert_eq!(&**name, "count"),
929 _ => panic!("expected Global key"),
930 }
931 }
932
933 #[test]
934 fn entity_keys_disambiguate_from_global() {
935 let mut s = PropertyStore::default();
936 let g = PropertyKey::global("foo");
937 let e = PropertyKey::entity(Entity::from_raw_u32(1).unwrap(), "foo");
938 s.set(g.clone(), PropertyValue::I64(1));
939 s.set(e.clone(), PropertyValue::I64(2));
940 assert!(matches!(s.get(&g), Some(PropertyValue::I64(1))));
941 assert!(matches!(s.get(&e), Some(PropertyValue::I64(2))));
942 }
943}