noesis_bevy 0.15.0

Bevy plugin that drives the Noesis GUI Native SDK and renders its UIs into a Bevy frame via wgpu compositing.
Documentation
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
//! Per-view routed-event bridge: surface `BaseButton::Click` and
//! `UIElement::KeyDown` from named elements of a single [`crate::NoesisView`] as
//! both Bevy messages **and** Bevy `EntityEvent`s (observers).
//!
//! Add a [`NoesisClickWatch`] / [`NoesisKeyDownWatch`] component to the view's
//! camera entity listing the `x:Name`s to observe. The reconcile systems keep
//! each view's live subscription set in sync. A fired event surfaces two ways:
//!
//! * as a [`NoesisClicked`] / [`NoesisKeyDown`] **message** carrying the
//!   originating `view` entity (the original, pull-based API), and
//! * as a [`UiClicked`] / [`UiKeyDown`] **`EntityEvent`** targeting the watch
//!   entry's `target` entity (defaulting to the `view` entity), so an observer
//!   recovers the clicked entity via `On::event_target`.
//!
//! ```ignore
//! commands.entity(view).insert((
//!     NoesisClickWatch::new(["NewGameButton", "QuitButton"]),
//!     NoesisKeyDownWatch::new([KeyDownWatchEntry::new("CommandInput").swallow(Key::Return)]),
//! ));
//!
//! // Pull-based (messages):
//! fn on_click(mut clicks: MessageReader<NoesisClicked>) {
//!     for ev in clicks.read() { /* ev.view: Entity, ev.name: String */ }
//! }
//!
//! // Push-based (observer): the trigger target IS the panel entity.
//! fn observe_click(on: On<UiClicked>, panels: Query<&Health>) {
//!     if let Ok(hp) = panels.get(on.event_target()) { /* … */ }
//! }
//! ```
//!
//! Click/keydown callbacks fire on the main thread (during the view's
//! `View::update`); they push `(view, target, name[, key])` onto a small queue
//! that the `PreUpdate` drain turns into messages + triggered events the next
//! frame. The drain holds no Noesis borrow, so firing observers there is safe.

use std::sync::{Arc, Mutex};

use bevy::prelude::*;
pub use noesis_runtime::view::Key;

use crate::render::{NoesisRenderState, NoesisSet, ReapOnRemove, add_bridge_reap};

// ─────────────────────────────────────────────────────────────────────────────
// Click bridge
// ─────────────────────────────────────────────────────────────────────────────

/// Emitted when a watched element raises `BaseButton::Click`.
#[derive(Message, Debug, Clone)]
pub struct NoesisClicked {
    /// The [`NoesisView`](crate::NoesisView) entity whose element was clicked.
    pub view: Entity,
    /// `x:Name` of the element that raised the click.
    pub name: String,
}

/// Observer-facing twin of [`NoesisClicked`]: a click surfaced as an
/// `EntityEvent` whose target is the watch entry's `target` entity (the `view`
/// entity by default, or a per-row entity for templated list rows). Read the
/// target with `On::event_target`.
///
/// Fired via the global self-targeting `commands.trigger`, so a stale/despawned
/// target is safe: no entity-targeted observer exists for it.
#[derive(EntityEvent, Debug, Clone)]
pub struct UiClicked {
    /// Trigger target: the panel/view entity (named elements) or the row entity
    /// (templated list rows).
    pub entity: Entity,
    /// The [`NoesisView`](crate::NoesisView) entity the click originated in.
    pub view: Entity,
    /// `x:Name` of the clicked element, or the list control's `x:Name` for a
    /// templated row click (rows carry no name of their own).
    pub name: String,
}

/// One entry in [`NoesisClickWatch`]: an element `x:Name` plus the entity the
/// resulting [`UiClicked`] should target. `target` defaults to the view entity
/// (set it to redirect the observer at a different entity).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClickWatchEntry {
    /// `x:Name` of the element to subscribe a `Click` handler against.
    pub name: String,
    /// Entity the fired [`UiClicked`] targets; `None` → the view entity.
    pub target: Option<Entity>,
}

impl ClickWatchEntry {
    /// Watch `Click` on the element named `name`, targeting the view entity.
    pub fn new(name: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            target: None,
        }
    }

    /// Builder: target the fired [`UiClicked`] at `target` instead of the view.
    #[must_use]
    pub fn target(mut self, target: Entity) -> Self {
        self.target = Some(target);
        self
    }
}

/// Per-view component: elements to subscribe a `Click` handler against. Add to a
/// [`NoesisView`](crate::NoesisView) entity. Entries are diff-synced each frame:
/// adding installs a subscription, removing tears it down.
#[derive(Component, Clone, Default, Debug)]
pub struct NoesisClickWatch {
    /// Per-element watch entries (`x:Name` + optional [`UiClicked`] target).
    pub entries: Vec<ClickWatchEntry>,
}

impl NoesisClickWatch {
    /// Builds a watch over the given element `x:Name`s, each [`UiClicked`]
    /// targeting the view entity.
    pub fn new(names: impl IntoIterator<Item = impl Into<String>>) -> Self {
        Self {
            entries: names.into_iter().map(ClickWatchEntry::new).collect(),
        }
    }

    /// Builds a watch from explicit [`ClickWatchEntry`] values (to set per-entry
    /// [`UiClicked`] targets).
    pub fn from_entries(entries: impl IntoIterator<Item = ClickWatchEntry>) -> Self {
        Self {
            entries: entries.into_iter().collect(),
        }
    }

    /// Watch one more element by `x:Name`, its [`UiClicked`] targeting the view
    /// entity. Use [`ClickWatchEntry`] directly when you need a per-entry target.
    pub fn watch(&mut self, name: impl Into<String>) -> &mut Self {
        self.entries.push(ClickWatchEntry::new(name));
        self
    }

    /// Watch several more elements by `x:Name`, each [`UiClicked`] targeting the
    /// view entity.
    pub fn extend_names(
        &mut self,
        names: impl IntoIterator<Item = impl Into<String>>,
    ) -> &mut Self {
        self.entries
            .extend(names.into_iter().map(ClickWatchEntry::new));
        self
    }
}

/// Queue between the (main-thread) click callbacks and the drain system.
/// `Clone` is an `Arc` clone. Entries carry `(view, target, name)`.
#[derive(Resource, Clone, Default)]
pub struct SharedClickQueue(pub(crate) Arc<Mutex<Vec<(Entity, Entity, String)>>>);

impl SharedClickQueue {
    /// Push `(view, target, name)` from a click callback.
    pub(crate) fn push(&self, view: Entity, target: Entity, name: String) {
        self.0
            .lock()
            .expect("SharedClickQueue poisoned")
            .push((view, target, name));
    }

    fn drain(&self) -> Vec<(Entity, Entity, String)> {
        let mut guard = self.0.lock().expect("SharedClickQueue poisoned");
        if guard.is_empty() {
            Vec::new()
        } else {
            std::mem::take(&mut *guard)
        }
    }
}

/// Drain the click queue: write a [`NoesisClicked`] message **and** trigger a
/// [`UiClicked`] `EntityEvent` (one of each per click). Runs in `PreUpdate` with
/// no Noesis borrow held, so triggering observers here is safe.
#[allow(clippy::needless_pass_by_value)]
pub fn drain_click_queue(
    queue: Res<SharedClickQueue>,
    mut messages: MessageWriter<NoesisClicked>,
    mut commands: Commands,
) {
    for (view, target, name) in queue.drain() {
        messages.write(NoesisClicked {
            view,
            name: name.clone(),
        });
        commands.trigger(UiClicked {
            entity: target,
            view,
            name,
        });
    }
}

/// Reconcile every view's [`NoesisClickWatch`] against its live subscription set.
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn sync_click_subscriptions(
    views: Query<(Entity, &NoesisClickWatch)>,
    queue: Res<SharedClickQueue>,
    state: Option<NonSendMut<NoesisRenderState>>,
) {
    let Some(mut state) = state else {
        return;
    };
    for (entity, watch) in &views {
        state.sync_click_subscriptions_for(entity, &watch.entries, &queue);
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// KeyDown bridge
// ─────────────────────────────────────────────────────────────────────────────

/// Emitted when a watched element raises `UIElement::KeyDown`.
#[derive(Message, Debug, Clone)]
pub struct NoesisKeyDown {
    /// The [`NoesisView`](crate::NoesisView) entity whose element received the keydown.
    pub view: Entity,
    /// `x:Name` of the element.
    pub name: String,
    /// Pressed key, mapped to the safe [`Key`] mirror (unmapped ordinals → [`Key::None`]).
    pub key: Key,
}

/// Observer-facing twin of [`NoesisKeyDown`]: a keydown surfaced as an
/// `EntityEvent` whose target is the watch entry's `target` entity (the `view`
/// entity by default). Read the target with `On::event_target`.
#[derive(EntityEvent, Debug, Clone)]
pub struct UiKeyDown {
    /// Trigger target: the watch entry's `target` (the view entity by default).
    pub entity: Entity,
    /// The [`NoesisView`](crate::NoesisView) entity the keydown originated in.
    pub view: Entity,
    /// `x:Name` of the element that received the keydown.
    pub name: String,
    /// Pressed key, mapped to the safe [`Key`] mirror.
    pub key: Key,
}

/// One entry in [`NoesisKeyDownWatch`]: an element `x:Name`, the per-name swallow
/// set, and the entity the resulting [`UiKeyDown`] should target. Keys in
/// `swallow` are marked handled by the C++ trampoline, stopping further routing
/// (e.g. swallow `Return` so a submit doesn't append a newline). Empty by
/// default: every key propagates, none are swallowed. `target` defaults to the
/// view entity.
#[derive(Clone, Debug)]
pub struct KeyDownWatchEntry {
    /// `x:Name` of the element to watch for `UIElement::KeyDown`.
    pub name: String,
    /// Keys marked handled by the C++ trampoline, stopping further routing.
    pub swallow: Vec<Key>,
    /// Entity the fired [`UiKeyDown`] targets; `None` → the view entity.
    pub target: Option<Entity>,
}

impl KeyDownWatchEntry {
    /// Builds an entry watching `name`, with an empty swallow set, targeting the
    /// view entity.
    pub fn new(name: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            swallow: Vec::new(),
            target: None,
        }
    }

    /// Builder: append `key` to the swallow set.
    #[must_use]
    pub fn swallow(mut self, key: Key) -> Self {
        self.swallow.push(key);
        self
    }

    /// Builder: append every key in `keys` to the swallow set.
    #[must_use]
    pub fn swallow_all<I>(mut self, keys: I) -> Self
    where
        I: IntoIterator<Item = Key>,
    {
        self.swallow.extend(keys);
        self
    }

    /// Builder: target the fired [`UiKeyDown`] at `target` instead of the view.
    #[must_use]
    pub fn target(mut self, target: Entity) -> Self {
        self.target = Some(target);
        self
    }
}

/// Per-view component: `x:Name`s + per-name swallow sets to watch for
/// `UIElement::KeyDown`. Add to a [`NoesisView`](crate::NoesisView) entity.
#[derive(Component, Clone, Default, Debug)]
pub struct NoesisKeyDownWatch {
    /// Per-element watch entries, each pairing an `x:Name` with its swallow set.
    pub entries: Vec<KeyDownWatchEntry>,
}

impl NoesisKeyDownWatch {
    /// Builds a watch from the given [`KeyDownWatchEntry`] list.
    pub fn new(entries: impl IntoIterator<Item = KeyDownWatchEntry>) -> Self {
        Self {
            entries: entries.into_iter().collect(),
        }
    }
}

/// Queue between the (main-thread) keydown callbacks and the drain system.
/// Entries carry `(view, target, name, key)`.
#[derive(Resource, Clone, Default)]
pub struct SharedKeyDownQueue(pub(crate) Arc<Mutex<Vec<(Entity, Entity, String, Key)>>>);

impl SharedKeyDownQueue {
    /// Push `(view, target, name, key)` from a keydown callback.
    pub(crate) fn push(&self, view: Entity, target: Entity, name: String, key: Key) {
        self.0
            .lock()
            .expect("SharedKeyDownQueue poisoned")
            .push((view, target, name, key));
    }

    fn drain(&self) -> Vec<(Entity, Entity, String, Key)> {
        let mut guard = self.0.lock().expect("SharedKeyDownQueue poisoned");
        if guard.is_empty() {
            Vec::new()
        } else {
            std::mem::take(&mut *guard)
        }
    }
}

/// Drain the keydown queue: write a [`NoesisKeyDown`] message **and** trigger a
/// [`UiKeyDown`] `EntityEvent` (one of each per keydown).
#[allow(clippy::needless_pass_by_value)]
pub fn drain_keydown_queue(
    queue: Res<SharedKeyDownQueue>,
    mut messages: MessageWriter<NoesisKeyDown>,
    mut commands: Commands,
) {
    for (view, target, name, key) in queue.drain() {
        messages.write(NoesisKeyDown {
            view,
            name: name.clone(),
            key,
        });
        commands.trigger(UiKeyDown {
            entity: target,
            view,
            name,
            key,
        });
    }
}

/// Reconcile every view's [`NoesisKeyDownWatch`] against its live subscription set.
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn sync_keydown_subscriptions(
    views: Query<(Entity, &NoesisKeyDownWatch)>,
    queue: Res<SharedKeyDownQueue>,
    state: Option<NonSendMut<NoesisRenderState>>,
) {
    let Some(mut state) = state else {
        return;
    };
    for (entity, watch) in &views {
        state.sync_keydown_subscriptions_for(entity, &watch.entries, &queue);
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Plugin
// ─────────────────────────────────────────────────────────────────────────────

impl ReapOnRemove for NoesisClickWatch {
    fn reap(state: &mut NoesisRenderState, entity: Entity) {
        state.reap_click_watch_for(entity);
    }
}

impl ReapOnRemove for NoesisKeyDownWatch {
    fn reap(state: &mut NoesisRenderState, entity: Entity) {
        state.reap_keydown_watch_for(entity);
    }
}

/// Wires the per-view click + keydown bridges. Added transitively by
/// [`crate::NoesisPlugin`].
pub struct NoesisEventsPlugin;

impl Plugin for NoesisEventsPlugin {
    fn build(&self, app: &mut App) {
        app.add_message::<NoesisClicked>()
            .add_message::<NoesisKeyDown>()
            .insert_resource(SharedClickQueue::default())
            .insert_resource(SharedKeyDownQueue::default())
            .add_systems(PreUpdate, (drain_click_queue, drain_keydown_queue))
            .add_systems(
                PostUpdate,
                (sync_click_subscriptions, sync_keydown_subscriptions).in_set(NoesisSet::Apply),
            );
        add_bridge_reap::<NoesisClickWatch>(app);
        add_bridge_reap::<NoesisKeyDownWatch>(app);
    }
}

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

    #[test]
    fn shared_click_queue_drain_takes_all_and_resets() {
        let q = SharedClickQueue::default();
        let v = Entity::PLACEHOLDER;
        let t = Entity::PLACEHOLDER;
        q.push(v, t, "Alpha".into());
        q.push(v, t, "Beta".into());
        let drained = q.drain();
        assert_eq!(
            drained,
            vec![(v, t, "Alpha".to_string()), (v, t, "Beta".to_string())]
        );
        assert!(q.drain().is_empty());
    }

    #[test]
    fn click_watch_constructor_normalizes_into_entries() {
        let w = NoesisClickWatch::new(["a", "b", "c"]);
        let names: Vec<&str> = w.entries.iter().map(|e| e.name.as_str()).collect();
        assert_eq!(names, vec!["a", "b", "c"]);
        assert!(w.entries.iter().all(|e| e.target.is_none()));
    }

    #[test]
    fn click_watch_entry_target_builder() {
        let e = ClickWatchEntry::new("Row").target(Entity::PLACEHOLDER);
        assert_eq!(e.name, "Row");
        assert_eq!(e.target, Some(Entity::PLACEHOLDER));
    }

    #[test]
    fn keydown_entry_swallow_builder() {
        let e = KeyDownWatchEntry::new("Input").swallow(Key::Return);
        assert_eq!(e.name, "Input");
        assert_eq!(e.swallow, vec![Key::Return]);
        assert_eq!(e.target, None);
    }
}