plushie 0.7.0

Desktop GUI framework for Rust
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
//! Composite widget system for reusable, stateful components.
//!
//! A composite widget composes existing widgets (text, button, canvas,
//! etc.) with internal state and event interception.
//!
//! # `plushie::widget` vs `plushie::widget_sdk`
//!
//! `plushie::widget` provides the composite widget system for **app
//! authors**: reusable widgets built by composing other widgets, with
//! internal state and event interception. No new wire type; no
//! renderer changes needed.
//!
//! [`plushie::widget_sdk`](crate::widget_sdk) (re-export of
//! `plushie_widget_sdk`) is for **widget authors** implementing new
//! native widget types via the `PlushieWidget` trait. These are
//! widgets the renderer renders directly.
//!
//! From an app developer's perspective, both kinds of widgets look
//! and behave identically in use - the distinction is only about who
//! writes them.
//!
//! # Widget IDs must be unique across Widget types
//!
//! Each widget ID owns a single state slot typed by the concrete
//! `Widget::State`. Reusing an ID for two different `Widget` types
//! within the same view cycle is a programming error: the first
//! registration wins the state slot, the second mounts a downcast
//! against the wrong `TypeId` and panics.
//!
//! The runtime emits a `widget_id_type_collision` error-level
//! diagnostic (with both type names and the offending ID) and then
//! panics, because silently accepting a mismatched state would
//! produce nonsense behaviour that's much harder to debug than a
//! hard fail.
//!
//! # Defining a widget
//!
//! ```no_run
//! use plushie::prelude::*;
//! use plushie::widget::{Widget, EventResult};
//!
//! struct StarRating;
//!
//! #[derive(WidgetEvent)]
//! enum StarRatingEvent {
//!     Select(u64),
//! }
//!
//! #[derive(Default)]
//! struct StarState { _hover: Option<usize> }
//!
//! impl Widget for StarRating {
//!     type State = StarState;
//!     type Props = UntypedProps;
//!
//!     fn view(id: &str, _props: &UntypedProps, _state: &StarState) -> View {
//!         let mut row_view = row().id(id).spacing(4.0);
//!         for i in 0..5 {
//!             row_view = row_view.child(button(&format!("star-{i}"), "*"));
//!         }
//!         row_view.into()
//!     }
//!
//!     fn handle_event(event: &Event, _state: &mut StarState) -> EventResult {
//!         match event.widget_match() {
//!             Some(Click(id)) if id.starts_with("star-") => {
//!                 EventResult::emit_event(StarRatingEvent::Select(1))
//!             }
//!             _ => EventResult::Consumed,
//!         }
//!     }
//! }
//! ```
//!
//! # Using a widget in a view
//!
//! ```ignore
//! // Untyped API (any prop name/value):
//! fn view(model: &Self, widgets: &mut WidgetRegistrar) -> ViewList {
//!     window("main").child(
//!         WidgetView::<StarRating>::new("rating")
//!             .prop("rating", model.rating)
//!             .register(widgets)
//!     ).into()
//! }
//!
//! // Typed API (compile-time checked props):
//! fn view(model: &Self, widgets: &mut WidgetRegistrar) -> ViewList {
//!     window("main").child(
//!         WidgetView::<StarRating>::from_builder(
//!             StarRating::builder("rating").rating(model.rating).0
//!         ).register(widgets)
//!     ).into()
//! }
//! ```

use std::any::Any;
use std::collections::HashMap;

use plushie_core::protocol::TreeNode;
use plushie_core::tree_walk::{TreeTransform, WalkCtx};
use plushie_core::types::FromNode;
use serde_json::Value;

use crate::View;
use crate::event::Event;
use crate::subscription::Subscription;

// ---------------------------------------------------------------------------
// Widget trait
// ---------------------------------------------------------------------------

/// A reusable, stateful widget that composes other widgets.
///
/// State must implement `Default` for initial creation. No
/// serialization constraints: state is stored in memory as the
/// concrete Rust type using `Box<dyn Any>`.
///
/// Props are extracted from the tree node via [`FromNode`]. Use a
/// struct generated by `#[derive(WidgetProps)]` for typed access,
/// or [`UntypedProps`](crate::types::UntypedProps) to receive the raw `serde_json::Value`.
///
/// # Send / Sync
///
/// `Widget` is `Send + Sync` so the trait object can be shared
/// across threads. [`Widget::State`] is `Send + 'static` (not
/// `Sync`) because state is accessed exclusively by one session
/// thread: `&mut state` in `handle_event` and `&state` in `view`.
/// Concurrent access is never possible by design.
///
/// # ID uniqueness
///
/// Each widget ID owns one state slot, typed by `Self::State`.
/// Reusing the same ID for two different `Widget` types within a
/// view cycle is a programming error: the first registration wins
/// the slot, the second downcast fails. The runtime emits a
/// `widget_id_type_collision` diagnostic and panics to fail loudly
/// rather than silently corrupting state.
pub trait Widget: Send + Sync + 'static {
    /// Per-instance state persisted across renders.
    type State: Default + Send + 'static;

    /// Typed properties extracted from the tree node.
    ///
    /// Derive with `#[derive(WidgetProps)]` for automatic typed
    /// extraction. Use [`UntypedProps`](crate::types::UntypedProps) as a fallback for widgets
    /// that read props manually from the raw Value.
    type Props: FromNode;

    /// Build the widget's view tree from typed props and internal state.
    fn view(id: &str, props: &Self::Props, state: &Self::State) -> View;

    /// Handle an event from an internal child widget.
    fn handle_event(_event: &Event, _state: &mut Self::State) -> EventResult {
        EventResult::Ignored
    }

    /// Active subscriptions scoped to this widget instance.
    fn subscribe(_props: &Self::Props, _state: &Self::State) -> Vec<Subscription> {
        vec![]
    }

    /// Optional cache-key hash derived from props and state.
    ///
    /// When this returns `Some(hash)`, the runtime records the value
    /// alongside the widget's expanded view. On the next render, if
    /// the widget's cache key hashes to the same value, the cached
    /// expanded view is reused and [`Widget::view`] is not re-invoked.
    /// A widget that returns `None` (the default) is never cached; its
    /// `view()` runs every render.
    ///
    /// Use this for widgets whose output depends on a small number of
    /// inputs and whose view construction is expensive. For example, a
    /// markdown renderer might hash `(props.source, props.theme)` so
    /// the subtree is reused until either changes.
    ///
    /// The [`hash_cache_key`] helper takes any `Hash` value and returns
    /// a `u64` suitable for this method:
    ///
    /// ```ignore
    /// fn cache_key(props: &Self::Props, state: &Self::State) -> Option<u64> {
    ///     Some(plushie::widget::hash_cache_key(&(&props.source, &state.theme)))
    /// }
    /// ```
    fn cache_key(_props: &Self::Props, _state: &Self::State) -> Option<u64> {
        None
    }
}

/// Hash any `Hash` value into a `u64` suitable for [`Widget::cache_key`].
///
/// Uses [`std::collections::hash_map::DefaultHasher`], the same hasher
/// the [`crate::ui::memo`] helper uses to compute its deps hash.
pub fn hash_cache_key<T: std::hash::Hash + ?Sized>(value: &T) -> u64 {
    use std::collections::hash_map::DefaultHasher;
    use std::hash::Hasher;
    let mut hasher = DefaultHasher::new();
    value.hash(&mut hasher);
    hasher.finish()
}

// ---------------------------------------------------------------------------
// EventResult
// ---------------------------------------------------------------------------

/// The result of handling an event in a composite widget.
///
/// To update internal state, mutate the `&mut Self::State` argument
/// in `handle_event` directly and return `Consumed` (or `Emit` if
/// the event also emits upward). There is no separate `UpdateState`
/// variant: it would be redundant with mutating state and returning
/// `Consumed`.
#[derive(Debug)]
#[non_exhaustive]
pub enum EventResult {
    /// Emit a transformed event to the parent.
    Emit {
        /// Event family (e.g. `"star_rating:select"`).
        family: String,
        /// Event payload delivered to the parent.
        value: Value,
    },
    /// Event handled and suppressed.
    Consumed,
    /// Event not handled, pass to parent unchanged.
    Ignored,
}

impl EventResult {
    /// Create an Emit result from a family string and untyped value.
    pub fn emit(family: &str, value: impl Into<Value>) -> Self {
        plushie_core::EventType::assert_custom_family(family);

        Self::Emit {
            family: family.to_string(),
            value: value.into(),
        }
    }

    /// Create an Emit result from a typed widget event.
    ///
    /// The event's variant name becomes the family string (snake_case)
    /// and its payload is encoded via `PlushieType::wire_encode`.
    ///
    /// ```no_run
    /// use plushie::WidgetEvent;
    /// use plushie::widget::EventResult;
    ///
    /// #[derive(WidgetEvent)]
    /// enum MyEvent { Select(u64) }
    ///
    /// // emits family "select" with payload 5
    /// let _ = EventResult::emit_event(MyEvent::Select(5));
    /// ```
    pub fn emit_event(event: impl plushie_core::types::WidgetEventEncode) -> Self {
        let (family, value) = event.to_wire();

        Self::Emit {
            family: family.to_string(),
            value: serde_json::Value::from(value),
        }
    }
}

/// The result of widget interception, including context about which
/// widget intercepted the event and the remaining scope above it.
#[derive(Debug)]
pub struct Interception {
    /// What the widget decided to do with the event.
    pub result: EventResult,
    /// The ID of the composite widget that intercepted the event.
    pub widget_id: String,
    /// Scope chain above the intercepting widget (for Emit events).
    pub outer_scope: Vec<String>,
    /// The window the event originated from.
    pub window_id: String,
}

// ---------------------------------------------------------------------------
// WidgetView - placeholder builder for using widgets in views
// ---------------------------------------------------------------------------

/// A view placeholder for a composite widget.
///
/// When the view tree is expanded, the widget's `view()` method is
/// called with the stored props and the widget's persisted state.
///
/// Two construction paths:
///
/// - **Untyped**: `WidgetView::<W>::new("id").prop("key", value)`
/// - **Typed**: `WidgetView::<W>::from_builder(W::builder("id").field(v).0)`
///
/// Both paths end with `.register(widgets)` to produce a `View`.
pub struct WidgetView<W: Widget> {
    id: String,
    props: plushie_core::protocol::PropMap,
    _marker: std::marker::PhantomData<W>,
}

impl<W: Widget> WidgetView<W> {
    /// Create a widget placeholder with the given ID (untyped API).
    pub fn new(id: &str) -> Self {
        Self {
            id: id.to_string(),
            props: plushie_core::protocol::PropMap::new(),
            _marker: std::marker::PhantomData,
        }
    }

    /// Create a widget placeholder from a [`WidgetBuilder`](plushie_core::widget_builder::WidgetBuilder).
    ///
    /// Use this with the typed builder generated by `WidgetProps`:
    ///
    /// ```ignore
    /// WidgetView::<StarRating>::from_builder(
    ///     StarRating::builder("stars").rating(5).0
    /// ).register(widgets)
    /// ```
    pub fn from_builder(builder: plushie_core::WidgetBuilder) -> Self {
        Self {
            id: builder.id,
            props: builder.props,
            _marker: std::marker::PhantomData,
        }
    }

    /// Set a prop on the widget (untyped).
    pub fn prop(mut self, key: &str, value: impl Into<plushie_core::protocol::PropValue>) -> Self {
        self.props.insert(key, value.into());
        self
    }
}

impl<W: Widget> WidgetView<W> {
    /// Build the internal `__widget__` placeholder node.
    ///
    /// Useful when one composite widget wants to nest another from
    /// inside `Widget::view`, where `WidgetRegistrar` is not
    /// available but the app-level view already registered the
    /// nested widget's expander.
    pub fn placeholder(self) -> View {
        let mut props = self.props;
        props.insert("__widget__", plushie_core::protocol::PropValue::Bool(true));

        View::new(
            self.id,
            "__widget__",
            plushie_core::protocol::Props::from(props),
            vec![],
        )
    }

    /// Register the widget expander and produce a View placeholder.
    ///
    /// Call this inside `App::view` to place a composite widget in
    /// the view tree:
    ///
    /// ```ignore
    /// fn view(model: &Self::Model, widgets: &mut WidgetRegistrar) -> ViewList {
    ///     window("main").child(
    ///         WidgetView::<StarRating>::new("rating")
    ///             .prop("rating", model.rating)
    ///             .register(widgets)
    ///     ).into()
    /// }
    /// ```
    pub fn register(self, registrar: &mut WidgetRegistrar) -> View {
        let expander: Box<dyn DynWidgetExpander> =
            Box::new(WidgetExpander::<W>(std::marker::PhantomData));
        registrar.register(self.id.clone(), expander);
        self.placeholder()
    }
}

// ---------------------------------------------------------------------------
// Type-erased widget expansion (using Box<dyn Any> for state)
// ---------------------------------------------------------------------------

/// Type-erased interface for expanding widgets and handling events.
///
/// The `expand` and `subscribe` methods receive the full `TreeNode`
/// so the concrete `WidgetExpander<W>` can extract typed props via
/// `W::Props::from_node(node)`.
#[allow(dead_code)] // subscribe will be used when widget subscriptions are implemented
pub(crate) trait DynWidgetExpander: Send {
    fn expand(&self, id: &str, node: &TreeNode, state: &dyn Any) -> TreeNode;
    fn handle_event(&self, event: &Event, state: &mut dyn Any) -> EventResult;
    fn default_state(&self) -> Box<dyn Any + Send>;
    fn subscribe(&self, node: &TreeNode, state: &dyn Any) -> Vec<Subscription>;
    /// TypeId of the concrete `Widget::State` this expander owns.
    /// Used by [`WidgetStateStore`] to detect ID collisions between
    /// two different `Widget` types.
    fn state_type_id(&self) -> std::any::TypeId;
    /// Human-readable name of the concrete `Widget` type. Used only
    /// for diagnostic messages on ID collisions.
    fn widget_type_name(&self) -> &'static str;
    /// Ask the widget for its cache-key hash, if any. Returns `None`
    /// when the widget has not opted into view caching; the runtime
    /// then skips the cache entirely for this widget.
    fn cache_key(&self, node: &TreeNode, state: &dyn Any) -> Option<u64>;
}

struct WidgetExpander<W: Widget>(std::marker::PhantomData<W>);

impl<W: Widget> DynWidgetExpander for WidgetExpander<W> {
    fn expand(&self, id: &str, node: &TreeNode, state: &dyn Any) -> TreeNode {
        let state = state.downcast_ref::<W::State>().unwrap_or_else(|| {
            widget_type_mismatch_panic::<W>(id);
        });
        let props = W::Props::from_node(node);
        W::view(id, &props, state).into_tree_node()
    }

    fn handle_event(&self, event: &Event, state: &mut dyn Any) -> EventResult {
        let state = state.downcast_mut::<W::State>().unwrap_or_else(|| {
            widget_type_mismatch_panic::<W>("<event dispatch>");
        });
        W::handle_event(event, state)
    }

    fn default_state(&self) -> Box<dyn Any + Send> {
        Box::new(W::State::default())
    }

    fn subscribe(&self, node: &TreeNode, state: &dyn Any) -> Vec<Subscription> {
        let state = state.downcast_ref::<W::State>().unwrap_or_else(|| {
            widget_type_mismatch_panic::<W>(&node.id);
        });
        let props = W::Props::from_node(node);
        W::subscribe(&props, state)
    }

    fn state_type_id(&self) -> std::any::TypeId {
        std::any::TypeId::of::<W::State>()
    }

    fn widget_type_name(&self) -> &'static str {
        std::any::type_name::<W>()
    }

    fn cache_key(&self, node: &TreeNode, state: &dyn Any) -> Option<u64> {
        let state = state.downcast_ref::<W::State>().unwrap_or_else(|| {
            widget_type_mismatch_panic::<W>(&node.id);
        });
        let props = W::Props::from_node(node);
        W::cache_key(&props, state)
    }
}

/// Panic helper for TypeId downcast failures in the expander path.
///
/// Previously these sites used `expect("widget state type mismatch")`
/// which gave no actionable context. Collision detection at insertion
/// time (see [`WidgetStateStore::register_expander`]) is the primary
/// guard; this path only fires on truly unexpected state shape
/// mismatches and always names the concrete widget type.
fn widget_type_mismatch_panic<W: Widget>(id: &str) -> ! {
    panic!(
        "widget state type mismatch: expander for `{}` (id={id:?}) \
         received state of the wrong type. This should have been \
         caught at registration with a `widget_id_type_collision` \
         diagnostic; treat this panic as a bug in WidgetStateStore.",
        std::any::type_name::<W>(),
    )
}

// ---------------------------------------------------------------------------
// WidgetRegistrar
// ---------------------------------------------------------------------------

/// Collects widget expanders during `App::view()`.
///
/// Passed to `App::view()` so composite widgets can register their
/// type-erased expanders explicitly rather than through a thread-local.
#[derive(Default)]
pub struct WidgetRegistrar {
    expanders: HashMap<String, Box<dyn DynWidgetExpander>>,
}

impl WidgetRegistrar {
    /// Create an empty registrar.
    pub fn new() -> Self {
        Self::default()
    }

    /// Register a widget expander for the given ID.
    pub(crate) fn register(&mut self, id: String, expander: Box<dyn DynWidgetExpander>) {
        self.expanders.insert(id, expander);
    }

    /// Take all registered expanders (consumed by WidgetStateStore).
    pub(crate) fn take_all(self) -> HashMap<String, Box<dyn DynWidgetExpander>> {
        self.expanders
    }
}

// ---------------------------------------------------------------------------
// Widget state store
// ---------------------------------------------------------------------------

/// Stores per-widget-instance state and expanders.
///
/// State entries carry the `TypeId` of the concrete `Widget::State`
/// so we can detect ID collisions between two different `Widget`
/// types at registration time rather than crashing on a downcast
/// much later.
pub(crate) struct WidgetStateStore {
    states: HashMap<String, (std::any::TypeId, Box<dyn Any + Send>)>,
    expanders: HashMap<String, Box<dyn DynWidgetExpander>>,
}

impl WidgetStateStore {
    pub fn new() -> Self {
        Self {
            states: HashMap::new(),
            expanders: HashMap::new(),
        }
    }

    /// Insert or update a (id -> expander) mapping, initialising
    /// state on first registration.
    ///
    /// On collision between two different `Widget` types for the same
    /// ID, emits a `widget_id_type_collision` error-level diagnostic
    /// naming both types and panics. Silent acceptance would leave
    /// the downcast path to trip later with no actionable context.
    ///
    /// The emit uses the typed Diagnostic's Display output through
    /// `log::error!` so the log line stays consistent with other
    /// typed sites. Registration fires before any per-subtree wire
    /// channel exists, so there is no outgoing sink to route the
    /// diagnostic through; the panic that follows ensures the
    /// collision surfaces loudly regardless.
    pub(crate) fn register_expander(&mut self, id: String, expander: Box<dyn DynWidgetExpander>) {
        let incoming_type = expander.state_type_id();
        let incoming_name = expander.widget_type_name();
        if let Some((existing_type, _)) = self.states.get(&id) {
            if *existing_type != incoming_type {
                // Find the previously-registered widget's type name
                // via the expanders map for a clearer diagnostic.
                let existing_name = self
                    .expanders
                    .get(&id)
                    .map(|e| e.widget_type_name())
                    .unwrap_or("<unknown>");
                let diag = plushie_core::Diagnostic::WidgetIdTypeCollision {
                    id: id.clone(),
                    existing_type: existing_name.to_string(),
                    incoming_type: incoming_name.to_string(),
                };
                log::error!("{diag}");
                panic!(
                    "widget_id_type_collision: ID {id:?} was previously registered as \
                     `{existing_name}`; cannot reuse it for `{incoming_name}`. Pick a \
                     unique ID per composite widget type."
                );
            }
        } else {
            self.states
                .insert(id.clone(), (incoming_type, expander.default_state()));
        }
        self.expanders.insert(id, expander);
    }

    /// Replace `node` in place with the expansion of its `__widget__`
    /// placeholder. Iterates so widgets that return widgets keep
    /// unwinding until we hit a concrete widget type.
    ///
    /// An unrecognized placeholder (no expander registered for the
    /// node's ID) falls through to the fallback rewrite in
    /// [`Self::rewrite_unrecognized_placeholder`]. Rendering a stray
    /// `__widget__` node through iced produces a blank region; the
    /// fallback turns it into a visible "missing widget" container
    /// and emits a diagnostic so the app-level bug surfaces.
    /// Replace `node` in place with the expansion of its `__widget__`
    /// placeholder, consulting and updating the optional view-level
    /// cache. Iterates so widgets that return widgets keep unwinding
    /// until we hit a concrete widget type.
    ///
    /// A widget that returns `None` from [`Widget::cache_key`] bypasses
    /// the cache; widgets that return `Some(hash)` reuse their
    /// previous expansion when the hash is unchanged, skipping
    /// `view()` and any nested widget expansion within the cached
    /// subtree. Passing `None` for the cache disables caching entirely
    /// (used by tests and the no-cache transform constructor).
    pub(crate) fn expand_in_place(
        &self,
        node: &mut TreeNode,
        cache: Option<&mut crate::runtime::widget_view_cache::WidgetViewCache>,
    ) {
        // Fold into a local Option so the two call sites below share
        // one body. The outer caller owns the borrow; we just forward
        // lookups and writes through it.
        let mut cache = cache;
        while node.type_name == "__widget__" {
            if let Some(expander) = self.expanders.get(&node.id) {
                let (_type_id, state) = self.states.get(&node.id).expect("widget state missing");

                if let Some(cache_ref) = cache.as_deref_mut()
                    && let Some(key_hash) = expander.cache_key(node, state.as_ref())
                {
                    let widget_id = node.id.clone();
                    cache_ref.mark_live(&widget_id);
                    if let Some(cached_view) = cache_ref.get(&widget_id, key_hash) {
                        *node = cached_view.clone();
                        // The cached view's own type cannot be `__widget__`
                        // (it is already expanded), so the loop exits
                        // naturally on the next iteration.
                        continue;
                    }
                    let expanded = expander.expand(&widget_id, node, state.as_ref());
                    cache_ref.insert(widget_id, key_hash, expanded.clone());
                    *node = expanded;
                } else {
                    let expanded = expander.expand(&node.id, node, state.as_ref());
                    *node = expanded;
                }
            } else {
                Self::rewrite_unrecognized_placeholder(node);
                break;
            }
        }
    }

    /// Replace a stray `__widget__` placeholder with a visible
    /// container carrying a diagnostic breadcrumb.
    ///
    /// The breadcrumb lives in the `a11y.label` prop so screen readers
    /// surface the bug too; the same
    /// [`plushie_core::Diagnostic::UnrecognizedWidgetPlaceholder`] is
    /// emitted through the typed diagnostic channel so hosts observe
    /// the issue programmatically.
    fn rewrite_unrecognized_placeholder(node: &mut TreeNode) {
        let id = std::mem::take(&mut node.id);
        plushie_core::diagnostics::warn(plushie_core::Diagnostic::UnrecognizedWidgetPlaceholder {
            id: id.clone(),
        });
        let mut props = plushie_core::protocol::PropMap::new();
        props.insert(
            "a11y",
            plushie_core::protocol::PropValue::from(serde_json::json!({
                "label": format!("unregistered widget: {id}"),
                "role": "alert",
            })),
        );
        *node = TreeNode {
            id,
            type_name: "container".to_string(),
            props: plushie_core::protocol::Props::from(props),
            children: Vec::new(),
        };
    }

    /// Handle an event through widget interception.
    ///
    /// Walks the event's scope chain (innermost ancestor first) and
    /// gives each registered composite widget a chance to handle the
    /// event. Returns the result along with the interceptor's ID and
    /// the remaining scope above it.
    pub fn intercept_event(&mut self, event: &Event) -> Option<Interception> {
        let scoped_id = match event {
            Event::Widget(w) => &w.scoped_id,
            _ => return None,
        };

        let scope = &scoped_id.scope;
        let window_id = scoped_id.window_id.clone().unwrap_or_default();

        for (i, ancestor_id) in scope.iter().enumerate() {
            if let Some(expander) = self.expanders.get(ancestor_id) {
                // Invariant: every expander entry has a matching state
                // entry; `register_expander` installs both together and
                // the stale-cleanup path prunes them in lockstep. A
                // missing state slot here means the store's invariants
                // are violated, not a routine event that should be
                // dropped. The enclosing catch_unwind at the renderer
                // boundary (widget-sdk) keeps the session alive on
                // panic and surfaces the violation loudly.
                let (_type_id, state) = self.states.get_mut(ancestor_id).expect(
                    "widget state invariant: expander registered without matching state entry",
                );
                let result = expander.handle_event(event, state.as_mut());
                match result {
                    EventResult::Ignored => continue,
                    other => {
                        return Some(Interception {
                            result: other,
                            widget_id: ancestor_id.clone(),
                            // Remaining scope above the interceptor.
                            outer_scope: scope[i + 1..].to_vec(),
                            window_id: window_id.clone(),
                        });
                    }
                }
            }
        }

        None
    }
}

// ---------------------------------------------------------------------------
// ExpandWidgetsTransform
// ---------------------------------------------------------------------------

/// Tree transform that replaces `__widget__` placeholder nodes with the
/// views their registered expanders produce.
///
/// Runs before normalization so widgets' expanded subtrees participate
/// in scope-prefixing and a11y rewrites identically to authored nodes.
///
/// When a [`WidgetViewCache`][crate::runtime::widget_view_cache::WidgetViewCache]
/// is supplied, widgets that opt in via [`Widget::cache_key`] reuse
/// their previously-expanded subtree instead of re-running `view()`.
/// Widgets without a cache key (the default) always re-expand.
pub(crate) struct ExpandWidgetsTransform<'a> {
    store: &'a WidgetStateStore,
    cache: Option<&'a mut crate::runtime::widget_view_cache::WidgetViewCache>,
}

impl<'a> ExpandWidgetsTransform<'a> {
    pub(crate) fn with_cache(
        store: &'a WidgetStateStore,
        cache: Option<&'a mut crate::runtime::widget_view_cache::WidgetViewCache>,
    ) -> Self {
        Self { store, cache }
    }
}

impl TreeTransform for ExpandWidgetsTransform<'_> {
    fn enter(&mut self, node: &mut TreeNode, _ctx: &mut WalkCtx) {
        // Expand this node until it is no longer a `__widget__`
        // placeholder. The walker will descend into the expanded
        // children on its own, so nested widget placeholders inside
        // the expansion get picked up as the traversal continues.
        //
        // Fast path: apps with no composite widgets skip the work
        // entirely. `expand_in_place` already early-returns on the
        // first loop iteration in that case, but branching here
        // avoids the lookup on every node.
        if self.store.expanders.is_empty() {
            return;
        }
        self.store.expand_in_place(node, self.cache.as_deref_mut());
    }
}

/// Merge a freshly-collected [`WidgetRegistrar`] into a store without
/// kicking off a traversal. Used by the runtime to set up widget
/// state before driving expand + normalize in a combined walk.
pub(crate) fn register_expanders(store: &mut WidgetStateStore, registrar: WidgetRegistrar) {
    for (id, expander) in registrar.take_all() {
        store.register_expander(id, expander);
    }
}