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, plus the
451 /// canonical `"true"` / `"false"` / `"1"` / `"0"` string aliases that
452 /// [`crate::signals::Signals::set_bool`] used to write. Other variants
453 /// (numeric, colour, ...) yield `None` so callers can fall back to a default
454 /// rather than treating an unrelated value as `false`.
455 pub fn get_global_bool(&self, name: &str) -> Option<bool> {
456 let key = PropertyKey::Global(Arc::<str>::from(name));
457 match self.get(&key)? {
458 PropertyValue::Bool(b) => Some(*b),
459 PropertyValue::Str(s) => match s.as_ref() {
460 "true" | "1" => Some(true),
461 "false" | "0" => Some(false),
462 _ => None,
463 },
464 _ => None,
465 }
466 }
467
468 /// Writes a boolean as the canonical `"true"` / `"false"` string variant.
469 /// Mirrors [`crate::signals::Signals::set_bool`] semantics so downstream
470 /// readers that still consult string variants (`<if eq="true">` body
471 /// comparator) keep working post-migration.
472 pub fn set_global_bool(&mut self, name: &str, value: bool) -> bool {
473 self.set_global_str(name, if value { "true" } else { "false" })
474 }
475
476 /// Iterates the per-tick dirty queue and yields just the global property
477 /// names. Replaces direct iteration over the legacy `Signals::dirty` set.
478 pub fn dirty_global_names(&self) -> impl Iterator<Item = &str> {
479 self.dirty.iter().filter_map(|k| match k {
480 PropertyKey::Global(name) => Some(name.as_ref()),
481 PropertyKey::Entity(_, _) => None,
482 })
483 }
484}
485
486/// End-of-tick system that clears the [`PropertyStore`] dirty queue. Runs in
487/// [`crate::tick::TickStage::A11ySync`] after every consumer that peeks the
488/// queue (theme propagation, derivations, render-world frame dirty roll-up).
489///
490/// Without this the queue grows monotonically and `dirty_global_names` returns
491/// stale entries on subsequent ticks. The system is the wave-D replacement for
492/// `clear_signal_dirty` - same role, different backing store.
493pub fn clear_property_store_dirty(store: Option<ResMut<PropertyStore>>) {
494 if let Some(mut s) = store
495 && !s.dirty.is_empty()
496 {
497 s.clear_dirty();
498 }
499}
500
501// --- `Property<T>` typed handle ----------------------------------------------
502//
503// W7.x ergonomics: avoid stringifying typed signals across the Rust API. The
504// handle wraps a `PropertyKey` with a phantom `T` so `store.get` / `store.set`
505// round-trip through the existing `From`/`Into` conversions without bespoke
506// per-type helpers.
507//
508// Conversions reuse the `From<PropertyValue> for T` impls landed above, which
509// auto-derive `TryFrom<PropertyValue> for T` via the stdlib blanket impl with
510// `Error = Infallible`. That matches the lossy fallback the legacy scripts
511// already expect (e.g. reading an `I64` cell as `f64` coerces).
512
513/// Typed property handle. Wraps a [`PropertyKey`] with a phantom `T` so reads
514/// and writes never stringify.
515///
516/// Construct with [`Self::new`] (global key) or [`Self::entity`] (entity-scoped
517/// key). Round-trip through [`Self::get`] / [`Self::set`] against a
518/// [`PropertyStore`] borrow.
519///
520/// ```ignore
521/// use lumen_core::prelude::*;
522/// let count: Property<i64> = Property::new("count");
523/// let mut store = PropertyStore::default();
524/// count.set(&mut store, 42);
525/// assert_eq!(count.get(&store), Some(42));
526/// ```
527#[derive(Clone, Debug)]
528pub struct Property<T> {
529 key: PropertyKey,
530 _marker: PhantomData<fn() -> T>,
531}
532
533impl<T> Property<T>
534where
535 T: TryFrom<PropertyValue> + Into<PropertyValue> + Clone,
536{
537 /// Constructs a handle keyed on the global namespace (`PropertyKey::Global`).
538 pub fn new(name: impl Into<Arc<str>>) -> Self {
539 Self {
540 key: PropertyKey::Global(name.into()),
541 _marker: PhantomData,
542 }
543 }
544
545 /// Constructs a handle keyed on the entity-scoped namespace.
546 pub fn entity(e: Entity, name: impl Into<Arc<str>>) -> Self {
547 Self {
548 key: PropertyKey::Entity(e, name.into()),
549 _marker: PhantomData,
550 }
551 }
552
553 /// Borrows the underlying [`PropertyKey`]. Useful when the caller needs to
554 /// pass the key into raw [`PropertyStore`] APIs.
555 pub fn key(&self) -> &PropertyKey {
556 &self.key
557 }
558
559 /// Reads the typed value from `store`. Returns `None` when the key is
560 /// absent. When the stored cell is a variant `T` doesn't natively
561 /// represent, the conversion falls back to `T`'s `Default`-ish coercion
562 /// (matches the existing `From<PropertyValue> for T` semantics).
563 pub fn get(&self, store: &PropertyStore) -> Option<T> {
564 store
565 .get(&self.key)
566 .cloned()
567 .and_then(|v| T::try_from(v).ok())
568 }
569
570 /// Writes `value` into the cell, bumping the cell's generation and
571 /// appending to the dirty queue (subject to [`PropertyStore::freeze_notify`]).
572 pub fn set(&self, store: &mut PropertyStore, value: T) {
573 store.set(self.key.clone(), value.into());
574 }
575}
576
577// --- External typed-property bus ---------------------------------------------
578//
579// Round 4 typed-signal closure: cross-thread callers (the C-ABI crate, async tasks)
580// that want to write a *typed* `PropertyValue` (Int64/Float64/Bool/Color/Vec2)
581// directly into the `PropertyStore` use this channel. The existing
582// `lumen_core::signals::push_external_signal` path is `Str`-only -
583// `drain_external_signals` skips non-Str variants. The bus + drain below
584// land typed writes without round-tripping through `Signals`.
585
586static EXTERNAL_PROPERTY_TX: OnceLock<Sender<(PropertyKey, PropertyValue)>> = OnceLock::new();
587static EXTERNAL_PROPERTY_RX: OnceLock<Mutex<Receiver<(PropertyKey, PropertyValue)>>> =
588 OnceLock::new();
589
590fn init_external_property_channel() -> &'static Sender<(PropertyKey, PropertyValue)> {
591 EXTERNAL_PROPERTY_TX.get_or_init(|| {
592 let (tx, rx) = unbounded();
593 let _ = EXTERNAL_PROPERTY_RX.set(Mutex::new(rx));
594 tx
595 })
596}
597
598/// Idempotently initialises the cross-thread typed-property channel. Safe to call multiple times.
599pub fn init_external_properties() {
600 let _ = init_external_property_channel();
601}
602
603/// Sends a typed `(PropertyKey, PropertyValue)` write from any thread.
604///
605/// Picked up on the next tick by [`drain_external_properties`]; bypasses the
606/// legacy `Signals` mirror entirely so the receiving cell stores the typed
607/// variant (`I64`, `F64`, `Bool`, `Color`, ...) directly.
608///
609/// Returns `false` when the channel has disconnected.
610pub fn push_external_property(key: PropertyKey, value: PropertyValue) -> bool {
611 init_external_property_channel().send((key, value)).is_ok()
612}
613
614/// Snapshot the cells currently visible to the external bus by polling the
615/// channel non-destructively. Used by FFI typed reads that want to consult
616/// pending pre-run writes without owning a `PropertyStore`. The returned
617/// map is empty when the channel is unset or empty.
618///
619/// This is a best-effort accessor - concurrent senders may add entries
620/// after the snapshot returns. Read consistency across a single FFI call
621/// is sufficient for round-trip embedder scenarios (set N, read N).
622pub fn external_property_snapshot() -> HashMap<PropertyKey, PropertyValue> {
623 let Some(rx_lock) = EXTERNAL_PROPERTY_RX.get() else {
624 return HashMap::new();
625 };
626 let Ok(rx) = rx_lock.lock() else {
627 return HashMap::new();
628 };
629 // We can't peek non-destructively at a crossbeam receiver. Drain into
630 // a buffer, then re-send the entries back through the channel so the
631 // tick-side drain still sees them. Acceptable cost: one round-trip
632 // per FFI read while the embedder is in pre-run config mode.
633 let mut buf: Vec<(PropertyKey, PropertyValue)> = Vec::new();
634 while let Ok(entry) = rx.try_recv() {
635 buf.push(entry);
636 }
637 let tx = init_external_property_channel();
638 let mut snapshot = HashMap::new();
639 for (k, v) in &buf {
640 snapshot.insert(k.clone(), v.clone());
641 }
642 for entry in buf {
643 let _ = tx.send(entry);
644 }
645 snapshot
646}
647
648/// Returns `true` when the cross-thread typed-property channel currently
649/// holds undrained writes.
650///
651/// Non-destructive: peeks the receiver's queue length without consuming
652/// any entries. The window backend calls this after `App::tick()` so it
653/// can self-schedule a follow-up frame when a write is still sitting in
654/// the bus (e.g. a background thread pushed after this tick's
655/// [`drain_external_properties`] already ran) - otherwise the value would
656/// wait for the next unrelated OS event to wake the loop.
657///
658/// Returns `false` when the channel was never initialised, is empty, or
659/// its lock is poisoned.
660pub fn external_properties_pending() -> bool {
661 EXTERNAL_PROPERTY_RX
662 .get()
663 .and_then(|rx_lock| rx_lock.lock().ok().map(|rx| !rx.is_empty()))
664 .unwrap_or(false)
665}
666
667/// Per-tick system that drains every queued typed-property write into
668/// [`PropertyStore`]. Each entry calls [`PropertyStore::set`] so the cell
669/// gets the typed variant directly - no stringification, no `Signals`
670/// round-trip.
671///
672/// Pair with [`init_external_properties`] at startup; the runtime
673/// (`lumenc` or a custom embedder) registers this in
674/// [`crate::tick::TickStage::CommandDrain`] alongside
675/// [`crate::command::apply_property_commands`].
676pub fn drain_external_properties(mut store: ResMut<PropertyStore>) {
677 let Some(rx_lock) = EXTERNAL_PROPERTY_RX.get() else {
678 return;
679 };
680 let Ok(rx) = rx_lock.lock() else {
681 return;
682 };
683 while let Ok((key, value)) = rx.try_recv() {
684 store.set(key, value);
685 }
686}
687
688/// Second, in-`Systems`-stage drain of the external typed-property bus,
689/// for same-tick commit of main-thread script writes.
690///
691/// [`drain_external_properties`] is registered in
692/// [`crate::tick::TickStage::CommandDrain`], which is chained *before*
693/// [`crate::tick::TickStage::Systems`]. A main-thread script that writes a
694/// signal during event dispatch (`on_click` -> `signals.count.set(..)`)
695/// pushes onto the bus from inside a `Systems`-stage system, i.e. too late
696/// for that tick's CommandDrain drain - so without this the new value
697/// would sit in the bus until the *next* tick, adding a whole frame of
698/// input latency before a `bind="text:count"` reader reflects it.
699///
700/// The embedder registers this in `TickStage::Systems`, ordered *after*
701/// the script dispatch systems and *before* the reactive binding readers
702/// ([`crate::signals::apply_text_bindings`] et al.), so those writes land
703/// in [`PropertyStore`] on the very tick the click fired. Cross-thread
704/// writers keep using the same bus untouched; entries that happen to be
705/// queued here are simply committed a little earlier than CommandDrain
706/// would - strictly lower latency, never a correctness change. Distinct
707/// system type from [`drain_external_properties`] so registering both in
708/// the same schedule stays unambiguous.
709pub fn commit_external_properties(store: ResMut<PropertyStore>) {
710 drain_external_properties(store);
711}
712
713// --- Typed-property mirror cache (PropertyStore -> cross-thread view) ---------
714//
715// FFI typed-read accessors (lumen_signal_get_int64 / _float64 / _bool / _color
716// in the root `lumen` crate) run on any thread - `Res<PropertyStore>` requires the ECS
717// scheduler. `mirror_property_store_to_typed_cache` runs at TickStage end
718// and copies every globally-keyed typed cell into a process-wide
719// `Mutex<HashMap>` that the FFI consults from any thread. Round 6 closes the
720// loop: FFI reads now see PropertyStore writes from any source (script /
721// ECS / FFI), not only writes that flowed through the `push_external_property`
722// bus.
723
724static TYPED_PROPERTY_MIRROR: OnceLock<Mutex<HashMap<PropertyKey, PropertyValue>>> =
725 OnceLock::new();
726
727fn typed_property_mirror() -> &'static Mutex<HashMap<PropertyKey, PropertyValue>> {
728 TYPED_PROPERTY_MIRROR.get_or_init(|| Mutex::new(HashMap::new()))
729}
730
731/// Cross-thread snapshot of every typed [`PropertyStore`] cell mirrored
732/// by [`mirror_property_store_to_typed_cache`]. Returns the cache as
733/// owned data so callers can drop their lock immediately. FFI typed-read
734/// accessors consume this on their fallback path.
735pub fn typed_property_snapshot() -> HashMap<PropertyKey, PropertyValue> {
736 typed_property_mirror()
737 .lock()
738 .map(|m| m.clone())
739 .unwrap_or_default()
740}
741
742/// Per-tick system that reflects every typed cell in [`PropertyStore`] into
743/// the process-wide [`typed_property_snapshot`] cache. Pair with
744/// [`drain_external_properties`] in [`crate::tick::TickStage::A11ySync`]
745/// (or any late-tick stage) so the mirror sees writes that arrived this
746/// tick.
747///
748/// Strings and `Custom` variants are skipped - the FFI surface only
749/// exposes scalar accessors. Vec2 + Color flow through. The cache holds
750/// `PropertyValue` clones; consumers downcast at read time.
751pub fn mirror_property_store_to_typed_cache(store: Option<Res<PropertyStore>>) {
752 let Some(store) = store else {
753 return;
754 };
755 // Idle-tick fast path: nothing was written this tick, so the mirror
756 // already reflects the store. Every `set()` that changes a cell pushes
757 // its key onto the dirty queue, so an empty queue guarantees the cache
758 // is current - no lock, no rebuild. This system is ordered before
759 // `clear_property_store_dirty` so the queue is still populated here.
760 if store.dirty_peek().is_empty() {
761 return;
762 }
763 let Ok(mut cache) = typed_property_mirror().lock() else {
764 return;
765 };
766 // Update only the cells dirtied this tick rather than clearing and
767 // re-inserting the whole map every tick.
768 for key in store.dirty_peek() {
769 let Some(value) = store.get(key) else {
770 continue;
771 };
772 match value {
773 PropertyValue::I64(_)
774 | PropertyValue::F64(_)
775 | PropertyValue::Bool(_)
776 | PropertyValue::Color(_)
777 | PropertyValue::Vec2(_) => {
778 cache.insert(key.clone(), value.clone());
779 }
780 // Strings + Custom skipped - the FFI scalar accessors do not
781 // consume them. `bind-text` markup reads the string straight
782 // out of this store (`apply_text_bindings`), so it needs no
783 // mirror entry.
784 PropertyValue::Str(_) | PropertyValue::Custom(_) => {}
785 }
786 }
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792
793 #[test]
794 fn set_then_get_round_trips() {
795 let mut s = PropertyStore::default();
796 let k = PropertyKey::global("foo");
797 s.set(k.clone(), PropertyValue::I64(42));
798 assert!(matches!(s.get(&k), Some(PropertyValue::I64(42))));
799 }
800
801 #[test]
802 fn unchanged_set_does_not_dirty() {
803 let mut s = PropertyStore::default();
804 let k = PropertyKey::global("foo");
805 assert!(s.set(k.clone(), PropertyValue::I64(1)));
806 let _ = s.drain_dirty();
807 assert!(!s.set(k.clone(), PropertyValue::I64(1)));
808 assert!(s.drain_dirty().is_empty());
809 }
810
811 #[test]
812 fn external_bus_pending_reports_queued_writes() {
813 use std::sync::Arc;
814 // Resource-level guard for Fix 1's self-scheduling: a queued
815 // cross-thread / main-thread-script write must make
816 // `external_properties_pending()` read true, because that is the
817 // condition the window backend uses to re-arm the redraw. If this
818 // ever returned false with a write in the channel, the app would
819 // park with a stale frame until an unrelated OS event arrived.
820 //
821 // The channel is process-global, so we only assert the monotonic
822 // direction (a push makes it pending) to stay robust under the
823 // parallel test runner. `set(v) then get == v` after a manual
824 // drain confirms the write commits, mirroring the tick-side drain.
825 let k = PropertyKey::Global(Arc::<str>::from("bus_pending_probe_key"));
826 assert!(push_external_property(k.clone(), PropertyValue::I64(7)));
827 assert!(
828 external_properties_pending(),
829 "a queued external write must report pending so the backend re-arms the redraw"
830 );
831
832 // Commit the queued write via the same path the tick-side drain
833 // takes, and confirm it lands in the store (drains the channel so
834 // this probe key doesn't leak into sibling tests).
835 let mut store = PropertyStore::default();
836 if let Some(rx_lock) = EXTERNAL_PROPERTY_RX.get()
837 && let Ok(rx) = rx_lock.lock()
838 {
839 while let Ok((key, value)) = rx.try_recv() {
840 store.set(key, value);
841 }
842 }
843 assert!(matches!(store.get(&k), Some(PropertyValue::I64(7))));
844 }
845
846 #[test]
847 fn changed_set_appends_to_dirty() {
848 let mut s = PropertyStore::default();
849 let k = PropertyKey::global("foo");
850 s.set(k.clone(), PropertyValue::I64(1));
851 s.set(k.clone(), PropertyValue::I64(2));
852 let drained = s.drain_dirty();
853 assert_eq!(drained.len(), 2);
854 }
855
856 #[test]
857 fn freeze_then_thaw_skips_dirty() {
858 let mut s = PropertyStore::default();
859 let k = PropertyKey::global("foo");
860 s.freeze_notify();
861 s.set(k.clone(), PropertyValue::I64(1));
862 s.set(k.clone(), PropertyValue::I64(2));
863 assert!(s.drain_dirty().is_empty());
864 s.thaw_notify();
865 s.set(k.clone(), PropertyValue::I64(3));
866 assert_eq!(s.drain_dirty().len(), 1);
867 }
868
869 #[test]
870 fn generation_bumps_per_change() {
871 let mut s = PropertyStore::default();
872 let k = PropertyKey::global("foo");
873 s.set(k.clone(), PropertyValue::I64(1));
874 let g1 = s.cell(&k).unwrap().generation;
875 s.set(k.clone(), PropertyValue::I64(2));
876 let g2 = s.cell(&k).unwrap().generation;
877 assert_eq!(g2, g1 + 1);
878 }
879
880 #[test]
881 fn property_handle_round_trips_i64() {
882 let mut store = PropertyStore::default();
883 let count = Property::<i64>::new("count");
884 count.set(&mut store, 42);
885 assert_eq!(count.get(&store), Some(42));
886 count.set(&mut store, -7);
887 assert_eq!(count.get(&store), Some(-7));
888 }
889
890 #[test]
891 fn property_handle_round_trips_bool_and_f64() {
892 let mut store = PropertyStore::default();
893 let flag = Property::<bool>::new("flag");
894 flag.set(&mut store, true);
895 assert_eq!(flag.get(&store), Some(true));
896 let amount = Property::<f64>::new("amount");
897 amount.set(&mut store, 3.5);
898 assert_eq!(amount.get(&store), Some(3.5));
899 }
900
901 #[test]
902 fn property_handle_absent_key_returns_none() {
903 let store = PropertyStore::default();
904 let p = Property::<i64>::new("nope");
905 assert_eq!(p.get(&store), None);
906 }
907
908 #[test]
909 fn property_handle_exposes_key() {
910 let p = Property::<i64>::new("count");
911 match p.key() {
912 PropertyKey::Global(name) => assert_eq!(&**name, "count"),
913 _ => panic!("expected Global key"),
914 }
915 }
916
917 #[test]
918 fn entity_keys_disambiguate_from_global() {
919 let mut s = PropertyStore::default();
920 let g = PropertyKey::global("foo");
921 let e = PropertyKey::entity(Entity::from_raw_u32(1).unwrap(), "foo");
922 s.set(g.clone(), PropertyValue::I64(1));
923 s.set(e.clone(), PropertyValue::I64(2));
924 assert!(matches!(s.get(&g), Some(PropertyValue::I64(1))));
925 assert!(matches!(s.get(&e), Some(PropertyValue::I64(2))));
926 }
927}