Skip to main content

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}