rust_widgets 2.8.4

Pure Rust cross-platform native GUI library with hardware-adaptive rendering, 180 widgets, touch/gesture support, i18n, and SVG-pipeline-accurate output
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
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT

//! The **published-name** event route for JSON-declared layouts.
//!
//! # Why this module exists (BLUE19 rule #101)
//!
//! `src/json/loader.rs` grew a second, independent event path: eight hard-coded keys
//! (`on_click`, `on_change`, `on_close`, `on_double_click`, `on_focus`, `on_blur`,
//! `on_selection_changed`, `on_value_changed`) that it matched by hand. The capability table
//! publishes **186** event names. Measured, the two sets do not intersect at the level that
//! matters: `on_click` is not a published name (`clicked` is), and adding a published event
//! never made the JSON side support it. Nothing covered that path — `grep -rn "on_click" tools/`
//! returned nothing — so it could drift silently, and it did.
//!
//! This module is the merge. A node declares handlers against the **published name**:
//!
//! ```json
//! { "button": { "text": "Go", "events": { "clicked": "on_go" } } }
//! ```
//!
//! # Division of labour between the two spellings
//!
//! The two keys are not duplicates of one concept, and keeping both is only defensible if each
//! has a job the other cannot do. The boundary is:
//!
//! | key | what it is | why it exists |
//! |---|---|---|
//! | `events: { "<published_name>": "<handler>" }` | the published route | a name the capability table validates; adding a published event makes it declarable with no loader edit |
//! | `on_*` | the compatibility route | the same handler reached through a **marker**: `on_close` means `Closed`, `on_selection_changed` means `SelectionChanged`. These are trigger *intents* the published table does not name, and `on_double_click`/`on_focus`/`on_blur` exist because a pointer-driven control routes them through a value callback that the published signal cannot address. |
//!
//! So: `events:` is the route a designer writes, and `on_*` is the route an existing hand-written
//! layout keeps. `tools/check_json_event_route.sh` asserts this boundary mechanically — every
//! `on_*` key the loader reads must carry a trigger marker, and every `events:` name must be a
//! name the capability publishes.

use crate::json::EventHandlerContext;
use crate::WidgetTriggerEvent;

/// Which event a declared handler is bound to.
///
/// # Why this is a *value* and not the published name alone
///
/// A layout declares a handler against a name. Two of those names are validated by the capability
/// table (`clicked`, `value_changed` — rule #101's published route), and two are **trigger
/// markers** that the loader's marker callback recognises (`Closed`, `SelectionChanged`), which
/// are not published names at all. Keeping the distinction in the data rather than in a `match`
/// is what lets the gate in `tools/check_json_event_route.py` check it — and it is what lets
/// [`JsonEventBinding::uses_value_callback`] answer "which of the two callback tables does this
/// reach?" without asking a live control.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JsonEventBinding {
    /// A name the capability table publishes. Wired to the control's own signal, so a control
    /// that gains a published event becomes declarable without touching the loader.
    Published {
        /// The published event name, spelled exactly as `connect_event` accepts it.
        name: &'static str,
        /// Whether the event's declared payload is non-empty.
        ///
        /// Read from the capability's [`EventSchema`](crate::widget::capability::EventSchema) at
        /// declaration time rather than assumed. A payload-free name travels the click callback and
        /// a payload-carrying one the value callback; binding a name to the wrong one is what made
        /// `"events": {"value_changed": "h"}` fire on a *press*.
        has_payload: bool,
        /// The trigger intent the published **name** names, independent of its payload.
        ///
        /// # Why the name decides this, not only the payload
        ///
        /// `has_payload` can only choose between "click" and "value". A payload-free name like
        /// `closed` or `double_clicked` is neither a click nor a value, yet the first revision
        /// derived `Clicked` from *any* payload-free name — so `events: {"closed": "h"}` bound the
        /// click callback and the handler ran on a **click** while being told the control had
        /// closed. The name carries the intent; the payload only decides which of the two
        /// callbacks a *routable* intent travels on.
        marker: JsonTriggerMarker,
    },
    /// A compatibility key whose meaning is a trigger *intent*, not a published name.
    Marker {
        /// The `on_*` key as it appears in the document.
        key: &'static str,
        /// The marker the handler receives.
        marker: JsonTriggerMarker,
    },
}

impl JsonEventBinding {
    /// Whether this binding must reach the **value** callback table rather than the click table.
    ///
    /// # The rule, in one place
    ///
    /// A published name's answer is its **marker's**: a value-bearing intent travels on
    /// `on_value_changed` and a pointer-driven one on `on_click`. Deriving it from the marker rather
    /// than the raw payload keeps the callback consistent with the trigger the handler is told
    /// about. A marker key carries its own answer for the same reason.
    ///
    /// This is the single place the choice is made, so the wiring and any test that asserts the
    /// wiring cannot disagree about it.
    pub fn uses_value_callback(self) -> bool {
        match self {
            Self::Published { marker, .. } => marker.uses_value_callback(),
            Self::Marker { marker, .. } => marker.uses_value_callback(),
        }
    }

    /// The marker the handler is told about.
    ///
    /// For a published name the marker was derived from the **name** (with the payload as a
    /// tie-breaker) when the binding was built, so it travels with the binding rather than being
    /// re-guessed here. A marker binding carries the marker it was declared with.
    pub fn marker(self) -> JsonTriggerMarker {
        match self {
            Self::Published { marker, .. } => marker,
            Self::Marker { marker, .. } => marker,
        }
    }

    /// The published event name when this is a published binding, for diagnostics.
    pub fn published_name(self) -> Option<&'static str> {
        match self {
            Self::Published { name, .. } => Some(name),
            Self::Marker { key, .. } => {
                let _ = key;
                None
            }
        }
    }
}

/// The trigger a JSON handler is told about.
///
/// A closed set, not a free string: the loader used to pass `WidgetTriggerKind` values it had
/// chosen per call site, and two keys with the same meaning could report different markers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JsonTriggerMarker {
    /// A left click / primary activation.
    Clicked,
    /// A second click within the double-click interval.
    DoubleClicked,
    /// The control gained focus.
    FocusGained,
    /// The control lost focus.
    FocusLost,
    /// The control's value changed.
    ValueChanged,
    /// The selected item changed. Distinct from `ValueChanged` because a selection is not a
    /// value: a list's selected row can change without its contents changing.
    SelectionChanged,
    /// The control was closed.
    Closed,
}

impl JsonTriggerMarker {
    /// Whether this marker's handler must be reached through the **value** callback table.
    ///
    /// # Why some markers share a kind
    ///
    /// `DoubleClicked`, `FocusGained` and `FocusLost` all report `Clicked` or `ValueChanged`
    /// because the routing callback they arrive through cannot carry a finer distinction: the
    /// pointer path only tells the loader "a press arrived" and the value path only "a value
    /// arrived". Stating that here — rather than at each call site — is what keeps two keys with
    /// the same limitation from appearing to behave differently.
    ///
    /// That same limitation is what decides the callback: a marker the pointer path produces
    /// (`Clicked`, `DoubleClicked`, `Closed`) travels on `on_click`, and one the value path
    /// produces (`ValueChanged`, `SelectionChanged`, both focus markers) on `on_value_changed`.
    /// Deriving it from [`Self::trigger_kind`] keeps the two answers from disagreeing, which is
    /// how `on_double_click` came to be wired to a callback that could never report a double
    /// click.
    pub fn uses_value_callback(self) -> bool {
        matches!(
            self.trigger_kind(),
            crate::platform::WidgetTriggerKind::ValueChanged
                | crate::platform::WidgetTriggerKind::SelectionChanged
        )
    }

    /// Whether this marker travels on the **close** callback (the widget's `closed` signal).
    ///
    /// # Why `Closed` now has a channel
    ///
    /// It used to have none: no backend produced `WidgetTriggerKind::Closed` through the handle
    /// layer, so [`Self::unroutable_reason`] refused it. [`BaseWidget::closed`](crate::widget::BaseWidget::closed)
    /// now exists and every closeable control emits it from its own `close`/`dismiss` path, so
    /// `Closed` has a real signal to reach and is bound rather than refused.
    pub fn uses_close_callback(self) -> bool {
        matches!(self, Self::Closed)
    }

    /// Why this marker cannot be routed to a live callback, or `None` when it can.
    ///
    /// # Why a marker is refused rather than approximated
    ///
    /// [`Self::uses_value_callback`] can only choose between two callbacks — the click one
    /// and the value one — so a marker whose real trigger is neither cannot be honoured. It
    /// used to be bound to whichever was nearer, which produced a handler that ran on the
    /// **wrong** event and was told the right one had happened:
    ///
    /// * `on_double_click` → the click callback, i.e. the *single*-click signal. The handler
    ///   fired on the first click and could never be reached by a double click.
    /// * `on_focus` / `on_blur` → the value callback, which fires when the control's *value*
    ///   changes, not when it gains or loses focus.
    ///
    /// `Closed` used to be on this list too ("no producer"). It is now routed through
    /// [`Self::uses_close_callback`] to the widget's `closed` signal, so it is no longer refused.
    ///
    /// Returning a reason instead of a guess is the same principle the crate applies
    /// elsewhere: a capability that is absent is reported, never fabricated.
    pub fn unroutable_reason(self) -> Option<&'static str> {
        match self {
            Self::DoubleClicked => Some(
                "`on_double_click` has no double-click signal to connect to; a control's click \
                 signal does not distinguish one click from two, so binding it there would fire \
                 the handler on a single click",
            ),
            Self::FocusGained | Self::FocusLost => Some(
                "`on_focus` / `on_blur` have no focus signal to connect to through this route; \
                 the value callback fires on a value change, not on focus",
            ),
            Self::Clicked | Self::ValueChanged | Self::SelectionChanged | Self::Closed => None,
        }
    }

    /// The `WidgetTriggerKind` this marker reports as.
    ///
    /// # Why some markers share a kind
    ///
    /// `DoubleClicked`, `FocusGained` and `FocusLost` all report `Clicked` or `ValueChanged`
    /// because the routing callback they arrive through cannot carry a finer distinction: the
    /// pointer path only tells the loader "a press arrived" and the value path only "a value
    /// arrived". Stating that here — rather than at each call site — is what keeps two keys with
    /// the same limitation from appearing to behave differently.
    pub fn trigger_kind(self) -> crate::platform::WidgetTriggerKind {
        use crate::platform::WidgetTriggerKind;
        match self {
            Self::Clicked | Self::DoubleClicked => WidgetTriggerKind::Clicked,
            Self::FocusGained | Self::FocusLost | Self::ValueChanged => {
                WidgetTriggerKind::ValueChanged
            }
            Self::SelectionChanged => WidgetTriggerKind::SelectionChanged,
            Self::Closed => WidgetTriggerKind::Closed,
        }
    }

    /// The trigger intent a **published event name** names, with `has_payload` as a tie-breaker.
    ///
    /// # Why the published route needs its own classifier
    ///
    /// A published name is free-form (`closed`, `double_clicked`, `dismissed`, `deselected`,
    /// `selection_changed`, `value_changed`, …), so the payload alone cannot say what it means:
    /// `closed` and `clicked` both carry no payload, yet one is a dismissal and the other an
    /// activation. Deriving the marker from the name keeps `events: {"closed": "h"}` from being
    /// bound to the click callback — which reported the *wrong* trigger to the handler and made a
    /// genuinely closed control indistinguishable from a clicked one.
    ///
    /// The mapping is deliberately conservative: a name whose intent this function does not
    /// recognise falls back to the payload question (`has_payload` → `ValueChanged`, else
    /// `Clicked`), which is exactly the previous behaviour and remains correct for the common
    /// `clicked` / `value_changed` cases. A name that *is* recognised as an intent with no live
    /// signal (`closed`, `double_clicked`, focus) then travels through
    /// [`Self::unroutable_reason`] and is refused rather than mis-bound.
    pub fn for_published_name(name: &str, has_payload: bool) -> Self {
        // Normalise before classifying, not only before matching the schema. The schema lookup uses
        // `normalize_key` (which strips `_`/`-`/space and lower-cases), so `selection-changed` and
        // `selection_changed` resolve to the *same* published name. Classifying the raw spelling
        // instead made the two disagree about intent: the underscore form matched
        // `contains("selection_changed")` and reported `SelectionChanged`, while the hyphen form fell
        // through to the payload question and reported `ValueChanged` — the same event with two
        // different trigger kinds depending on how the author spelled it (BLUE-issue E-24).
        let lower = crate::widget::capability::normalize_key(name);
        // Order matters: the most specific intents are checked before the generic suffixes, so
        // `double_clicked` is not swallowed by the `clicked` check.
        if lower.contains("doubleclick") {
            return Self::DoubleClicked;
        }
        if lower == "closed" || lower == "close" || lower.ends_with("closed") {
            return Self::Closed;
        }
        if lower.contains("focusgained") || lower == "focused" || lower == "focus" {
            return Self::FocusGained;
        }
        if lower.contains("focuslost") || lower == "blurred" || lower == "blur" {
            return Self::FocusLost;
        }
        if lower.contains("selectionchanged")
            || lower.contains("selectchanged")
            || lower.contains("selectedchanged")
        {
            return Self::SelectionChanged;
        }
        if has_payload {
            Self::ValueChanged
        } else {
            Self::Clicked
        }
    }
}

/// Every `on_*` key the loader reads, with the marker it means.
///
/// # The single list
///
/// The loader previously extracted these with two hand-written functions returning a six-tuple
/// that callers destructured positionally. A key added to one function and not the other was
/// wired into the wrong slot, which is why the table is now one array read by name.
pub const MARKER_KEYS: &[(&str, JsonTriggerMarker)] = &[
    ("on_click", JsonTriggerMarker::Clicked),
    ("on_change", JsonTriggerMarker::ValueChanged),
    ("on_close", JsonTriggerMarker::Closed),
    ("on_double_click", JsonTriggerMarker::DoubleClicked),
    ("on_focus", JsonTriggerMarker::FocusGained),
    ("on_blur", JsonTriggerMarker::FocusLost),
    ("on_selection_changed", JsonTriggerMarker::SelectionChanged),
    ("on_value_changed", JsonTriggerMarker::ValueChanged),
];

/// The JSON key under which published-name handlers are declared.
pub const EVENTS_KEY: &str = "events";

/// Every `on_*` key, for the loader's "this key is not a property" list.
///
/// Derived from [`MARKER_KEYS`] so the two cannot disagree about which keys the loader consumes.
pub fn marker_key_names() -> impl Iterator<Item = &'static str> {
    MARKER_KEYS.iter().map(|(key, _)| *key)
}

/// Whether `key` is an `on_*` event key rather than a state property.
pub fn is_marker_key(key: &str) -> bool {
    MARKER_KEYS.iter().any(|(candidate, _)| *candidate == key)
}

/// The marker `key` means, or `None` when it is not an event key.
pub fn marker_for_key(key: &str) -> Option<JsonTriggerMarker> {
    MARKER_KEYS.iter().find(|(candidate, _)| *candidate == key).map(|(_, marker)| *marker)
}

/// The binding a published event name declares, resolved against `widget_type`'s capability.
///
/// # Why the name is copied into a `&'static str`
///
/// The capability table's event names **are** `&'static str` (the table is a `static`, produced by
/// `events_of!` from a generated schema), so the name already outlives the JSON document and can be
/// stored by reference. A name the control does not publish cannot reach here: the caller checks
/// `control_publishes` first and reports the miss.
///
/// # Why the payload question is asked here
///
/// Which callback a published name reaches is decided by whether it carries a value, and the only
/// authority on that is the control's own [`EventSchema`](crate::widget::capability::EventSchema).
/// Asking here — once, at bind time — means the wiring never has to guess, and the answer travels
/// with the binding so a test can assert it without a live control.
pub fn json_event_binding(widget_type: &str, name: &str) -> JsonEventBinding {
    let factory = crate::widget::capability::WidgetFactory::new_with_defaults();
    let normalized = crate::widget::capability::normalize_key(name);
    let has_payload = factory
        .capability(widget_type)
        .and_then(|capability| {
            capability
                .events
                .iter()
                .find(|schema| crate::widget::capability::normalize_key(schema.name) == normalized)
        })
        .is_some_and(|schema| schema.payload.is_some());
    JsonEventBinding::Published {
        // The event's own spelling from the table, which is what `connect_event` accepts — a
        // document that wrote a differently-cased name still binds to the published one.
        name: published_event_name(widget_type, name).unwrap_or("clicked"),
        has_payload,
        // The name carries the intent; see `JsonTriggerMarker::for_published_name`. This is what
        // keeps `events: {"closed": …}` / `events: {"double_clicked": …}` from being bound to
        // the click callback while being told the wrong trigger had happened.
        marker: JsonTriggerMarker::for_published_name(name, has_payload),
    }
}

/// The published spelling of `name` on `widget_type`, or `None` when it is not published.
pub fn published_event_name(widget_type: &str, name: &str) -> Option<&'static str> {
    let factory = crate::widget::capability::WidgetFactory::new_with_defaults();
    let normalized = crate::widget::capability::normalize_key(name);
    factory.capability(widget_type).and_then(|capability| {
        capability
            .events
            .iter()
            .find(|schema| crate::widget::capability::normalize_key(schema.name) == normalized)
            .map(|schema| schema.name)
    })
}

/// The `on_*` key the marker table lists for `marker`, or `None` when it lists none.
///
/// The reverse of [`marker_for_key`]. Two markers can share a trigger kind (`on_click` and
/// `on_double_click` both report `Clicked`), so the forward direction is not invertible in general —
/// which is exactly why the key a caller *wrote* is carried through the binding rather than
/// re-derived from the kind.
pub fn marker_key_for(marker: JsonTriggerMarker) -> Option<&'static str> {
    MARKER_KEYS.iter().find(|(_, m)| *m == marker).map(|(key, _)| *key)
}

/// Builds the [`EventHandlerContext`] for a declared handler.
///
/// One constructor for both routes, so a `marker` binding and a published binding cannot report
/// different context shapes for the same trigger.
pub fn context_for(
    widget_id: crate::core::ObjectId,
    marker: JsonTriggerMarker,
) -> EventHandlerContext {
    EventHandlerContext::new(WidgetTriggerEvent { widget_id, kind: marker.trigger_kind() })
}

/// Builds the [`EventHandlerContext`] for a declared handler **with the event's payload**.
///
/// The payload-carrying counterpart of [`context_for`], used by the published-name route where the
/// control's dynamic signal delivered a [`CapabilityValue`](crate::widget::capability::CapabilityValue).
/// Keeping one constructor per shape (with and without a value) is what stops the two routes from
/// disagreeing about the trigger, because both still go through [`JsonTriggerMarker::trigger_kind`].
pub fn context_for_with_payload(
    widget_id: crate::core::ObjectId,
    marker: JsonTriggerMarker,
    payload: crate::widget::capability::CapabilityValue,
) -> EventHandlerContext {
    EventHandlerContext::new(WidgetTriggerEvent { widget_id, kind: marker.trigger_kind() })
        .with_payload(payload)
}

/// A handle to one dynamically-added JSON event binding, which can release just that binding.
///
/// # Why this exists (BLUE-issue E-22)
///
/// `JsonLoader::bind_one` returns a `bool` and drops the connection handle the published route
/// created, so a host that added a wire at run time had no way to remove it again without destroying
/// the control. A designer that replaces or deletes a single wire needs exactly that, so the binding
/// is now represented by a token whose `release` disconnects the one subscription it made.
///
/// Binding is **additive**: two tokens over the same declaration are two subscriptions and one
/// emission runs the handler twice. `release` is idempotent \u2014 a second call does nothing.
pub struct DynamicBinding {
    release: Option<alloc::boxed::Box<dyn FnOnce() + Send>>,
}

impl DynamicBinding {
    /// Wraps a release closure. Crate-internal: the loader is the only producer.
    pub(crate) fn new(release: alloc::boxed::Box<dyn FnOnce() + Send>) -> Self {
        Self { release: Some(release) }
    }

    /// Disconnects exactly the subscription this token represents. Safe to call more than once.
    pub fn release(&mut self) {
        if let Some(release) = self.release.take() {
            release();
        }
    }

    /// Whether this token still owns a live subscription (i.e. `release` has not been called).
    pub fn is_live(&self) -> bool {
        self.release.is_some()
    }
}

impl Drop for DynamicBinding {
    fn drop(&mut self) {
        // Dropping the token releases its binding, matching the `ConnectionScope` contract: a host
        // that forgets a token does not leak a subscription into a still-living control.
        self.release();
    }
}

impl core::fmt::Debug for DynamicBinding {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.debug_struct("DynamicBinding").field("live", &self.is_live()).finish()
    }
}

// ── Load-time binding ownership (BLUE-issue E-27) ──────────────────

// `JsonLoader::bind_one` wires a document's `events` and returns only a `bool`. It used to
// `core::mem::forget` its token so the subscription outlived the call — but the token owns a release
// closure that captures an `EventSignalRef`, which holds a strong reference to the control's signal.
// Forgetting it kept the signal (and therefore every slot on it, plus their captures) alive forever,
// even after the control was destroyed. The token is now **retained** here, keyed by the control it
// belongs to, and released when that control is torn down — so the subscription lives exactly as long
// as the control does, and nothing outlives it.

std::thread_local! {
    static LOADED_BINDINGS: core::cell::RefCell<
        crate::compat::HashMap<crate::core::ObjectId, alloc::vec::Vec<DynamicBinding>>,
    > = core::cell::RefCell::new(crate::compat::HashMap::new());
}

/// Retains a load-time binding token until its control is torn down.
///
/// Called by the loader's no-token `bind_one`; a caller that wants to release the wire itself uses
/// [`crate::json::JsonLoader::bind_one_releasable`] instead and owns the token.
pub(crate) fn retain_binding(widget_id: crate::core::ObjectId, binding: DynamicBinding) {
    LOADED_BINDINGS.with(|map| {
        map.borrow_mut().entry(widget_id).or_default().push(binding);
    });
}

/// Releases every load-time binding registered for `widget_id`.
///
/// Called when a node is torn down, so a destroyed control's subscriptions (and the signal references
/// they hold) are dropped with it rather than leaked. Idempotent: releasing an id with no bindings is
/// a no-op.
pub fn release_widget_bindings(widget_id: crate::core::ObjectId) {
    LOADED_BINDINGS.with(|map| {
        // `remove` drops the `Vec<DynamicBinding>`, and each `Drop` disconnects its subscription.
        let _ = map.borrow_mut().remove(&widget_id);
    });
}

/// How many load-time binding tokens are currently retained for `widget_id`. For tests.
pub fn retained_binding_count(widget_id: crate::core::ObjectId) -> usize {
    LOADED_BINDINGS.with(|map| map.borrow().get(&widget_id).map(Vec::len).unwrap_or(0))
}

/// Wires one **published** event (`events: { <name>: <handler> }`) to a live control.
///
/// # Why this is public rather than loader-private
///
/// The designer's generated program builds controls through its own `create_for` and never runs the
/// JSON loader (that is the point of generating code: the program links no parser). But the *wiring*
/// it must perform is exactly the loader's — validate the name against the control's capability,
/// choose the callback from the payload observation, and refuse a name that has no signal. Rule #98
/// makes that the library's job: a generator (or any host that learns its wiring at run time) must
/// not have to reimplement it, because a second implementation is a second set of rules that agrees
/// only until one is edited.
///
/// Before this existed the generator had **no** way to emit a wire at all, so a document declaring
/// `events: { clicked: "on_save" }` produced a program with no handler — silently, because the name
/// was validated at generation time and looked fine.
///
/// Returns whether the handler was bound. A name the control does not publish, or a marker whose
/// trigger has no signal (see [`JsonTriggerMarker::unroutable_reason`]), returns `false` and is
/// reported at `warn!`: the caller gets a fact rather than a binding that can never fire.
pub fn bind_published_event(
    widget_id: crate::core::ObjectId,
    widget_type: &str,
    name: &str,
    handler: &str,
) -> bool {
    if published_event_name(widget_type, name).is_none() {
        log::warn!(
            "[{widget_type}] `events.{name}` is not a published event; the binding is skipped, \
             so the handler `{handler}` would never run"
        );
        return false;
    }
    crate::json::bind_event_binding(
        widget_id,
        json_event_binding(widget_type, name),
        handler.to_owned(),
    )
}

/// Wires one **compatibility** key (`on_click`, `on_change`, …) to a live control.
///
/// See [`bind_published_event`] for why this is public. `key` must be one the marker table lists;
/// an unknown key is reported and refused rather than guessed at.
pub fn bind_marker_event(
    widget_id: crate::core::ObjectId,
    key: &str,
    marker: JsonTriggerMarker,
    handler: &str,
) -> bool {
    // The binding carries a `&'static str` because the key outlives the document: the marker table
    // is a `static`. A caller that passes a borrowed key is therefore resolved to the table's own
    // spelling rather than having its slice stored — which is also what makes a differently-cased
    // key bind to the canonical one instead of creating a second spelling that no other code reads.
    let Some(canonical) = MARKER_KEYS.iter().find(|(candidate, _)| *candidate == key) else {
        log::warn!(
            "`{key}` is not an `on_*` event key the marker table lists; the binding for \
             `{handler}` is skipped"
        );
        return false;
    };
    crate::json::bind_event_binding(
        widget_id,
        JsonEventBinding::Marker { key: canonical.0, marker },
        handler.to_owned(),
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn every_marker_key_resolves_to_its_marker() {
        for (key, marker) in MARKER_KEYS {
            assert_eq!(marker_for_key(key), Some(*marker), "`{key}` must resolve to its marker");
            assert!(is_marker_key(key));
        }
        assert_eq!(marker_for_key("on_click"), Some(JsonTriggerMarker::Clicked));
        assert_eq!(marker_for_key("on_close"), Some(JsonTriggerMarker::Closed));
    }

    #[test]
    fn a_non_event_key_is_not_a_marker_key() {
        for key in ["text", "width", "on_", "clicked", "events"] {
            assert!(!is_marker_key(key), "`{key}` is not an `on_*` event key");
            assert_eq!(marker_for_key(key), None);
        }
    }

    #[test]
    fn the_marker_key_list_has_no_duplicates() {
        let mut keys: Vec<&str> = marker_key_names().collect();
        let count = keys.len();
        keys.sort_unstable();
        keys.dedup();
        assert_eq!(keys.len(), count, "a duplicated key would be extracted twice");
    }

    #[test]
    fn markers_report_distinct_trigger_kinds() {
        // `SelectionChanged` and `ValueChanged` are different facts and must not collapse to one
        // trigger: a list whose selection moved did not change its value.
        assert_ne!(
            JsonTriggerMarker::SelectionChanged.trigger_kind(),
            JsonTriggerMarker::ValueChanged.trigger_kind()
        );
    }

    #[test]
    fn markers_that_share_a_callback_share_their_kind() {
        // The pointer path cannot distinguish one press from two, and the value path cannot
        // distinguish focus from a value change. Collapsing them is honest; inventing a
        // distinction the callback cannot carry would be a marker the handler can never observe.
        assert_eq!(
            JsonTriggerMarker::DoubleClicked.trigger_kind(),
            JsonTriggerMarker::Clicked.trigger_kind()
        );
        assert_eq!(
            JsonTriggerMarker::FocusGained.trigger_kind(),
            JsonTriggerMarker::ValueChanged.trigger_kind()
        );
        assert_eq!(
            JsonTriggerMarker::FocusLost.trigger_kind(),
            JsonTriggerMarker::ValueChanged.trigger_kind()
        );
    }

    /// A marker whose real trigger has **no** callback is refused, not bound to the nearer
    /// one. `trigger_kind` collapses `DoubleClicked` onto `Clicked` precisely because the
    /// route cannot carry the difference — so binding it to the click callback made the
    /// handler fire on a single click while being told a double click had happened.
    ///
    /// `Closed` is deliberately **not** in this list any more: it now has a real channel (the
    /// widget's `closed` signal, see [`JsonTriggerMarker::uses_close_callback`]), so it is bound
    /// rather than refused.
    #[test]
    fn a_marker_with_no_signal_to_reach_is_refused() {
        for marker in [
            JsonTriggerMarker::DoubleClicked,
            JsonTriggerMarker::FocusGained,
            JsonTriggerMarker::FocusLost,
        ] {
            let reason = marker.unroutable_reason();
            assert!(reason.is_some(), "{marker:?} must state why it cannot be routed");
            let reason = reason.expect("checked above");
            assert!(
                reason.contains("on_"),
                "{marker:?}'s reason must name the key an author wrote: {reason}"
            );
        }
    }

    /// The markers that *do* have a reachable signal stay routable, so the refusal cannot
    /// quietly grow to cover the working keys. `Closed` is here because its producer now exists.
    #[test]
    fn the_routable_markers_stay_routable() {
        assert!(JsonTriggerMarker::Clicked.unroutable_reason().is_none());
        assert!(JsonTriggerMarker::ValueChanged.unroutable_reason().is_none());
        assert!(JsonTriggerMarker::SelectionChanged.unroutable_reason().is_none());
        assert!(
            JsonTriggerMarker::Closed.unroutable_reason().is_none(),
            "`closed` has a real signal (the widget's `closed`), so it must be routable"
        );
    }

    /// `Closed` must be routed to the **close** channel, not the click or value one.
    #[test]
    fn closed_uses_the_close_channel_and_not_click_or_value() {
        assert!(JsonTriggerMarker::Closed.uses_close_callback());
        assert!(!JsonTriggerMarker::Closed.uses_value_callback(), "a close is not a value change");
        // The other routable markers are not close callbacks.
        assert!(!JsonTriggerMarker::Clicked.uses_close_callback());
        assert!(!JsonTriggerMarker::ValueChanged.uses_close_callback());
        assert!(!JsonTriggerMarker::SelectionChanged.uses_close_callback());
    }

    /// A published name's intent comes from the **name**, not only from its payload.
    ///
    /// Pins the defect: `json_event_binding` derived `Clicked` from *any* payload-free name, so
    /// `events: {"closed": …}` bound the click callback and the handler ran on a click while
    /// being told the control had closed. `closed` and `double_clicked` are payload-free and yet
    /// name intents the click signal cannot carry, so they must resolve to their own markers —
    /// which then travel through `unroutable_reason` and are refused.
    #[test]
    fn a_published_name_carries_its_own_intent() {
        // The genuine click stays a click.
        assert_eq!(
            JsonTriggerMarker::for_published_name("clicked", false),
            JsonTriggerMarker::Clicked
        );
        // A value-bearing name stays a value change.
        assert_eq!(
            JsonTriggerMarker::for_published_name("value_changed", true),
            JsonTriggerMarker::ValueChanged
        );
        // The non-click, payload-free intents are recognised...
        assert_eq!(
            JsonTriggerMarker::for_published_name("closed", false),
            JsonTriggerMarker::Closed
        );
        assert_eq!(
            JsonTriggerMarker::for_published_name("double_clicked", false),
            JsonTriggerMarker::DoubleClicked
        );
        // ...and each is routed through the channel its intent needs. `closed` now has a real
        // signal, so it is **routed** (not refused); `double_clicked` still has none, so it is
        // refused rather than mis-bound to the click callback.
        assert!(
            JsonTriggerMarker::for_published_name("closed", false).uses_close_callback(),
            "`closed` must route to the close channel"
        );
        assert!(
            JsonTriggerMarker::for_published_name("closed", false).unroutable_reason().is_none(),
            "`closed` has a producer now, so it must not be refused"
        );
        assert!(JsonTriggerMarker::for_published_name("double_clicked", false)
            .unroutable_reason()
            .is_some());
    }

    /// `double_clicked` must not be swallowed by the `clicked` substring check.
    #[test]
    fn the_double_click_check_precedes_the_click_check() {
        assert_ne!(
            JsonTriggerMarker::for_published_name("double_clicked", false),
            JsonTriggerMarker::Clicked
        );
    }

    /// A published selection event resolves to the selection marker, not a value change.
    #[test]
    fn a_published_selection_event_is_a_selection_change() {
        assert_eq!(
            JsonTriggerMarker::for_published_name("selection_changed", false),
            JsonTriggerMarker::SelectionChanged
        );
        assert_eq!(
            JsonTriggerMarker::for_published_name("selected_changed", true),
            JsonTriggerMarker::SelectionChanged
        );
    }

    /// Every accepted spelling of one published name resolves to the **same** marker.
    ///
    /// # The defect this pins (BLUE-issue E-24)
    ///
    /// `normalize_key` accepts `selection-changed`, `selection changed` and `SELECTIONCHANGED` as the
    /// same name, but the marker classifier compared the raw spelling, so the hyphen form fell
    /// through to the payload question and reported `ValueChanged` while the underscore form
    /// reported `SelectionChanged`. The same event therefore reported different trigger kinds — and
    /// so reached different callbacks — depending on how the author spelled the name.
    #[test]
    fn every_accepted_spelling_of_one_name_resolves_to_the_same_marker() {
        for spelling in [
            "selection_changed",
            "selection-changed",
            "selection changed",
            "SelectionChanged",
            "SELECTION_CHANGED",
        ] {
            assert_eq!(
                JsonTriggerMarker::for_published_name(spelling, false),
                JsonTriggerMarker::SelectionChanged,
                "`{spelling}` must classify as a selection change"
            );
        }

        for spelling in ["double_clicked", "double-clicked", "DoubleClicked"] {
            assert_eq!(
                JsonTriggerMarker::for_published_name(spelling, false),
                JsonTriggerMarker::DoubleClicked,
                "`{spelling}` must classify as a double click"
            );
        }

        for spelling in ["closed", "close", "_closed", "page_closed"] {
            assert_eq!(
                JsonTriggerMarker::for_published_name(spelling, false),
                JsonTriggerMarker::Closed,
                "`{spelling}` must classify as a close"
            );
        }
    }
}