Skip to main content

bevy_react/
devtools.rs

1//! The devtools inspector: a React panel (shipped inside the
2//! bevy-react JS runtime, rendered into a detached `<root>` overlay) backed by
3//! this Bevy-side plugin. The panel gives a live nodes explorer, two-way node
4//! selection (tree → on-screen highlight, screen → tree pick mode), transient
5//! inline prop/style editing, stats + render timings, and a bridge-message log.
6//!
7//! [`DevtoolsPlugin`] is crate-internal: [`ReactUiPlugin`](crate::ReactUiPlugin)
8//! auto-registers it, and consumers configure it through
9//! [`ReactUiPlugin::devtools`](crate::ReactUiPlugin::devtools), which takes a
10//! [`DevtoolsConfig`] (every field defaulted, including `enabled`).
11//!
12//! The module only exists behind the `devtools` cargo feature (a default
13//! feature — release builds compile it out with `default-features = false`),
14//! and the plugin is inert in `--release` builds even when compiled in. The JS
15//! half lives in `js/src/devtools/` and is stripped from production bundles.
16//!
17//! ## Bridge channels
18//!
19//! All devtools traffic is deliberately **untyped on the JS side** (hand-written
20//! mirror types in `js/src/devtools/api.ts`), so nothing here appears in an
21//! app's generated `bevy.ts` — the `--export-bindings` exporter never adds this
22//! plugin. Rust still uses the typed macros:
23//!
24//! - Bevy → JS events: `devtools.toggle { open }`, `devtools.batchStats { … }`
25//!   (event-driven — one per applied APP op batch while open; an idle app
26//!   sends nothing, and the panel's own repaints are excluded via the
27//!   per-batch origin flags so the panel never observes itself),
28//!   `devtools.picked { id }`, `devtools.window { width, height }` (the UI
29//!   viewport's logical size — once when the panel opens and on every resize
30//!   while it is open; the panel's layout is proportional, so JS needs it),
31//!   `devtools.layers { layers }` (the current layer set — the implicit base
32//!   layer plus every [`crate::layer::LayersRegistry`] row; streamed only
33//!   while the panel's Layers tab is active and diffed against the last
34//!   payload, so an idle app sends nothing),
35//!   `devtools.console { entries }` (the [`crate::console_log`] ring — JS
36//!   console output, diag messages, JS-runtime failures; the full backlog
37//!   when the Console tab opens, then increments while it stays open).
38//! - JS → Bevy messages: `devtools.open { open }`, `devtools.pick { on }`,
39//!   `devtools.select { id }`, `devtools.highlight { id }`,
40//!   `devtools.overlay { on }`, `devtools.panelRoot { id }`,
41//!   `devtools.layersOpen { on }` (the Layers tab was shown/hidden — gates
42//!   the layer stream), `devtools.consoleOpen { on }` (likewise for the
43//!   console stream), `devtools.consoleClear {}` (empty the console ring),
44//!   `devtools.dock { side, width }` (the panel's space reservation — see
45//!   [`apply_dock_reservation`]), `devtools.settings { … }` (the persisted
46//!   layout blob — see [`DevtoolsSettings`]).
47//! - Settings persistence: layout settings — including whether the panel was
48//!   open, so it reopens where you left it — round-trip through a JSON file
49//!   (default `.bevy-react-devtools.json` in the working directory —
50//!   [`DevtoolsConfig::settings_path`]).
51//!   The blob returns to the panel exactly once via `devtools.restore` —
52//!   **always**, with defaults when there is no (or a corrupt) file: the JS
53//!   recorder arms itself at install to capture the app's initial mount and
54//!   relies on that one deterministic signal to disarm when the panel is
55//!   staying closed (see `js/src/devtools/recorder.ts`).
56//!
57//! Render-time legs mirror the stress harness (`examples/stress/table_ops.rs`):
58//! `translate` (op → command queuing, from [`OpApplyStats`]), `command` (command
59//! execution + UI prepare/content), `layout` (taffy solve + post-layout
60//! propagation), bracketed around `UiSystems::Layout` in `PostUpdate`.
61
62use bevy::picking::hover::HoverMap;
63use bevy::picking::pointer::PointerId;
64use bevy::platform::collections::HashSet;
65use bevy::prelude::*;
66use bevy::ui::{ComputedNode, IsDefaultUiCamera, UiGlobalTransform, UiSystems};
67use std::time::Duration;
68
69use crate::bridge::{JsBridge, RNode};
70use crate::event::ReactEvents;
71use crate::message::ReactAppExt;
72use crate::plugin::PointerCapture;
73use crate::protocol::NodeId;
74use crate::reconcile::{OpApplyStats, climb};
75use crate::window::ui_viewport_size;
76use crate::{react_event, react_message};
77
78/// Devtools configuration, passed to
79/// [`ReactUiPlugin::devtools`](crate::ReactUiPlugin::devtools). Every field
80/// has a default (`DevtoolsConfig::default()` is exactly what an app gets
81/// without calling `.devtools(...)` at all), so construct it with
82/// struct-update syntax:
83///
84/// ```no_run
85/// # use bevy::prelude::*;
86/// # use bevy_react::{DevtoolsConfig, ReactUiPlugin};
87/// # let mut app = App::new();
88/// app.add_plugins(ReactUiPlugin::new("ui/dist/app.js").devtools(DevtoolsConfig {
89///     settings_path: Some(".config/devtools.json".into()),
90///     ..default()
91/// }));
92/// ```
93///
94/// Also a resource, so the toggle/persistence systems can read it.
95#[derive(Resource, Clone)]
96pub struct DevtoolsConfig {
97    /// Whether the devtools are available at all. Default: `true` (dev builds
98    /// only either way — release builds never run them).
99    pub enabled: bool,
100    /// The key that toggles the panel. Default: `F12`.
101    pub toggle_key: KeyCode,
102    /// Where the panel's layout settings (dock mode/width, float rect, the
103    /// reserve and overlay toggles, tree/inspector split, whether the panel
104    /// is open) persist across runs; `None` disables persistence. Default:
105    /// `.bevy-react-devtools.json` in the working directory. Native only —
106    /// on web the file is neither read nor written.
107    pub settings_path: Option<std::path::PathBuf>,
108}
109
110impl Default for DevtoolsConfig {
111    fn default() -> Self {
112        Self {
113            enabled: true,
114            toggle_key: KeyCode::F12,
115            settings_path: Some(std::path::PathBuf::from(".bevy-react-devtools.json")),
116        }
117    }
118}
119
120/// The Bevy side of the devtools inspector. See the [module docs](self).
121///
122/// Crate-internal: [`ReactUiPlugin`](crate::ReactUiPlugin) auto-registers it,
123/// built from the consumer's [`DevtoolsConfig`].
124pub struct DevtoolsPlugin {
125    config: DevtoolsConfig,
126}
127
128impl DevtoolsPlugin {
129    pub fn new(config: DevtoolsConfig) -> Self {
130        Self { config }
131    }
132}
133
134impl Plugin for DevtoolsPlugin {
135    fn build(&self, app: &mut App) {
136        // "Dev build only": the feature is a default feature, so this is the
137        // expected path for every consumer `--release` build — the plugin
138        // registers nothing. `debug!`, not `warn!`: release logs stay clean.
139        if !cfg!(debug_assertions) {
140            debug!("DevtoolsPlugin is inert in release builds");
141            return;
142        }
143        // The toggle/pick systems read `ButtonInput` resources; a headless app
144        // without `InputPlugin` (wiring-only tests) must not panic on them.
145        app.init_resource::<ButtonInput<KeyCode>>();
146        app.init_resource::<ButtonInput<MouseButton>>();
147        // `emit_layers` reads the layer registries; idempotent in the full app
148        // (`plugin.rs` inits them too), load-bearing for headless harnesses
149        // that build this plugin without `ReactUiPlugin`.
150        app.init_resource::<crate::layer::LayersRegistry>();
151        app.init_resource::<crate::layer::LayerMembership>();
152        // Start collecting apply-time invalid-value warnings (see
153        // `crate::diag`): armed for the app's whole lifetime, panel open or
154        // not, so warnings from the initial mount are waiting when it opens.
155        crate::diag::arm_runtime();
156        // Load persisted panel settings (native only; errors — missing file,
157        // corrupt JSON — mean fresh defaults). The overlay toggle seeds the
158        // Rust-side state immediately so highlight gating is correct before
159        // the JS panel wakes; the rest restores to JS via `send_restore`.
160        let loaded = load_settings(self.config.settings_path.as_deref());
161        app.insert_resource(DevtoolsState {
162            show_selection_overlay: loaded.as_ref().is_none_or(|s| s.overlay),
163            ..Default::default()
164        })
165        .insert_resource(DevtoolsPersistence {
166            last_written: loaded.clone(),
167            loaded,
168            pending: None,
169            #[cfg(not(target_arch = "wasm32"))]
170            dirty_at: None,
171            debounce: Duration::from_secs(1),
172        })
173        .init_resource::<DevtoolsTimers>()
174        .insert_resource(self.config.clone())
175        // Panel → Bevy state sync. Registration is what routes the emits;
176        // none of this reaches an app's generated `bevy.ts` because the
177        // bindings exporter never adds this plugin.
178        .add_react_handler(on_open_message)
179        .add_react_handler(on_pick_message)
180        .add_react_handler(on_select_message)
181        .add_react_handler(on_highlight_message)
182        .add_react_handler(on_overlay_message)
183        .add_react_handler(on_panel_root_message)
184        .add_react_handler(on_dock_message)
185        .add_react_handler(on_settings_message)
186        .add_react_handler(on_layers_open_message)
187        .add_react_handler(on_console_open_message)
188        .add_react_handler(on_console_clear_message)
189        // Registered in the plugin's OWN tuples — `plugin.rs`'s Update tuple
190        // sits at Bevy's 20-arity cap.
191        .add_systems(Startup, spawn_highlight_overlay)
192        .add_systems(
193            Update,
194            (
195                toggle_on_key,
196                send_window_size,
197                position_highlight,
198                apply_dock_reservation,
199                send_restore,
200                save_settings,
201                // Entries produced later the same frame (e.g. hover restyles)
202                // simply drain next frame — ordering is deliberately loose.
203                emit_runtime_warnings,
204                // Same loose ordering: console-ring entries pushed later this
205                // frame drain next frame.
206                emit_console,
207            ),
208        )
209        // A quit right after a layout drag must not lose the change: flush
210        // pending settings on `AppExit`, which is written during `Update` —
211        // `Last` still runs on that final frame.
212        .add_systems(Last, flush_settings_on_exit)
213        // In the pointer-capture set, after the system that ASSIGNS
214        // `PointerCapture::over_ui` each frame, so pick mode's claim
215        // survives for world-input systems ordered `.after(PointerCaptureSet)`.
216        .add_systems(
217            Update,
218            drive_pick_mode
219                .in_set(crate::plugin::PointerCaptureSet)
220                .after(crate::reconcile::collect_pointer_events),
221        )
222        .add_systems(
223            PostUpdate,
224            (
225                // The markers bracket `UiSystems::Layout` exactly like the
226                // stress harness: `apply_js_ops` ran in `Update`, so
227                // `OpApplyStats` already reflects this frame's batch.
228                mark_pre_layout
229                    .after(UiSystems::Content)
230                    .before(UiSystems::Layout),
231                // After PostLayout so the layout leg covers the whole
232                // pipeline (taffy solve + computed transform/clip
233                // propagation), not just the Layout set.
234                mark_post_layout.after(UiSystems::PostLayout),
235                emit_batch_stats.after(mark_post_layout),
236                // After the layer geometry sync so the rects are this
237                // frame's; a no-op ordering in harnesses that don't schedule
238                // that system.
239                emit_layers
240                    .after(crate::layer::sync_layer_geometry)
241                    // Cache stats (`repaints`/`cached`) are stamped by the
242                    // repaint resolver.
243                    .after(crate::layer::resolve_layer_repaints),
244            ),
245        );
246    }
247}
248
249/// Live devtools state, written by the JS panel's messages (and the toggle key)
250/// and read by the highlight/pick systems.
251#[derive(Resource)]
252pub(crate) struct DevtoolsState {
253    /// Whether the panel is open. Gates stats emission and pick/highlight.
254    pub open: bool,
255    /// Whether pick mode ("click a node on screen to select it") is active.
256    pub pick: bool,
257    /// The node selected in the tree explorer.
258    pub selected: Option<NodeId>,
259    /// The node whose tree row the panel pointer is hovering.
260    pub tree_hover: Option<NodeId>,
261    /// The node under the window cursor while pick mode is active.
262    pub pick_hover: Option<NodeId>,
263    /// Whether the persistent selected-node overlay is shown (the panel's
264    /// "overlay" toggle). Momentary highlights (tree-row hover, pick-mode
265    /// hover) are always on.
266    pub show_selection_overlay: bool,
267    /// The panel's own `<root>` node id, reported by the JS panel on open
268    /// (`None` while closed). Pick mode rejects hits under exactly this root —
269    /// app `<root>` overlays stay pickable.
270    pub panel_root: Option<NodeId>,
271    /// Which window edge the panel reserves space on (`None` = the panel
272    /// overlays the app: reserve toggled off, floating, or closed). Reported
273    /// by the JS panel via `devtools.dock`.
274    pub dock_side: Option<DockSide>,
275    /// The reserved width in logical pixels (meaningful with `dock_side`).
276    pub dock_width: f32,
277    /// Whether the panel's Layers tab is currently shown (reported via
278    /// `devtools.layersOpen`). Gates the `devtools.layers` stream.
279    pub layers_tab_open: bool,
280    /// Whether the panel's Console tab is currently shown (reported via
281    /// `devtools.consoleOpen`). Gates the `devtools.console` stream.
282    pub console_tab_open: bool,
283    /// The console stream watermark: the highest [`crate::console_log`] seq
284    /// already sent. `None` = send the full backlog next frame. Lives in the
285    /// resource (not a `Local`) so the `consoleOpen` handler can reset it on
286    /// every flip — a same-frame close→open must never skip the backlog.
287    pub console_last_seq: Option<u64>,
288}
289
290/// The window edge a docked, space-reserving panel sits on.
291#[derive(Clone, Copy, PartialEq, Eq, Debug)]
292pub(crate) enum DockSide {
293    Left,
294    Right,
295}
296
297impl Default for DevtoolsState {
298    fn default() -> Self {
299        Self {
300            open: false,
301            pick: false,
302            selected: None,
303            tree_hover: None,
304            pick_hover: None,
305            show_selection_overlay: true,
306            panel_root: None,
307            dock_side: None,
308            dock_width: 0.0,
309            layers_tab_open: false,
310            console_tab_open: false,
311            console_last_seq: None,
312        }
313    }
314}
315
316// --- Bridge bindings (see module docs; untyped on the JS side) -----------------
317
318/// Bevy → JS: the panel's open state changed Bevy-side (toggle key / auto-open).
319/// Carries the resulting state (not a bare "flip") so the panel mirrors Bevy
320/// instead of tracking parity.
321#[react_event(name = "devtools.toggle")]
322struct DevtoolsToggle {
323    open: bool,
324}
325
326/// Bevy → JS: pick mode clicked a node on screen — select it in the tree.
327#[react_event(name = "devtools.picked")]
328struct DevtoolsPicked {
329    id: NodeId,
330}
331
332/// Bevy → JS: the UI viewport's logical size. The panel's layout is
333/// proportional (fractions of the viewport), and JS can't see it on its own —
334/// sent once when the panel opens and on every size change while it stays open
335/// (see [`send_window_size`]; [`send_restore`] also sends it ahead of the
336/// restore blob so the restored fractions resolve against a real size).
337#[react_event(name = "devtools.window")]
338struct DevtoolsWindow {
339    width: f32,
340    height: f32,
341}
342
343/// Bevy → JS: render timings for one applied op batch. **Event-driven** — sent
344/// only on frames that applied a batch (while the panel is open), so an idle
345/// app produces zero devtools traffic. The JS recorder attaches these to the
346/// corresponding "ops" log entries. Timing legs are wall-clock ms; zero on web
347/// (no `std::time::Instant` on wasm).
348#[react_event(name = "devtools.batchStats")]
349struct DevtoolsBatchStats {
350    /// Op batches applied since startup (identifies the batch).
351    applied_count: u64,
352    /// Ops applied this frame (all queued flushes, coalesced).
353    last_ops: usize,
354    /// `op_flush` send → frame start: cross-frame queue wait (typically ~one
355    /// vsync; structural, excluded from the panel's totals).
356    frame_wait_ms: f64,
357    /// Frame start (or send, if later) → apply start: in-frame schedules
358    /// before the drain.
359    pre_apply_ms: f64,
360    /// Op → ECS-command translation (the `apply_js_ops` body).
361    translate_ms: f64,
362    /// Command execution (spawn/insert/hierarchy) + UI prepare/content.
363    command_ms: f64,
364    /// `UiSystems::Layout` + `PostLayout` (taffy + transform/clip propagation).
365    layout_ms: f64,
366}
367
368/// Bevy → JS: an invalid style/prop value fell back to a default at apply time
369/// (an unrecognized color, an unknown fontFamily/cursor, a bad text metric) —
370/// see [`crate::diag`]'s runtime sink. The panel's mirror matches `value`
371/// against the node's retained wire values to flag the offending inspector
372/// row. **Not** gated on the panel being open: warnings accumulate on the
373/// mirror so opening the panel later still shows them. (Decode-time warnings
374/// take the synchronous `op_take_decode_warnings` path instead — no event.)
375#[react_event(name = "devtools.warning")]
376struct DevtoolsWarning {
377    /// The affected node, when the parse site ran under a node scope.
378    id: Option<NodeId>,
379    /// The value's domain (`"color"`, `"fontFamily"`, `"cursor"`, …).
380    kind: String,
381    /// The raw offending wire value.
382    value: String,
383    /// The human-readable log message (shown under the flagged row).
384    message: String,
385}
386
387/// Bevy → JS: the current layer set — the implicit base layer plus every
388/// promoted layer in [`crate::layer::LayersRegistry`] — for the panel's
389/// Layers tab. Streamed by [`emit_layers`] only while the panel is open on
390/// that tab, and diffed against the last payload so an idle app sends
391/// nothing. Deliberately excludes the live group alpha: it changes every
392/// frame during a fade, which would defeat the diff gate. Filter params are
393/// the opposite call — they ARE included live (rounded; see
394/// [`filter_entries`]): watching what a filter animation is doing is the
395/// point of the chain display, so a running param animation streams while
396/// the tab is open.
397#[react_event(name = "devtools.layers")]
398struct DevtoolsLayers {
399    layers: Vec<DevtoolsLayerRow>,
400}
401
402/// One layer in a `devtools.layers` payload.
403#[derive(serde::Serialize, ts_rs::TS, Debug, Clone, PartialEq)]
404struct DevtoolsLayerRow {
405    /// The layer root's wire node id (`0` for the base layer).
406    id: NodeId,
407    /// Human-readable promotion reason labels (`["base"]` for the base layer,
408    /// today otherwise only `["opacity"]`). Opaque strings JS displays
409    /// verbatim — no JS-side table to keep in sync; see [`reason_labels`].
410    reasons: Vec<String>,
411    /// Nesting depth: 0 = base, 1 = top-level layer, 2 = layer in a layer, …
412    depth: u32,
413    /// Reconciled nodes ([`RNode`]) in the layer's capture subtree; 0 for the
414    /// base layer and for inactive layers (membership skips them).
415    node_count: u32,
416    /// Window-space logical rect; `None` while the layer is inactive
417    /// (zero-sized, hidden, or not laid out yet).
418    rect: Option<DevtoolsLayerRect>,
419    /// Frames that re-captured this layer since promotion (cache misses).
420    /// Always `0` for the base layer (it has no capture to cache).
421    repaints: u64,
422    /// The layer's resolved `filter` chain, one entry per wire filter with
423    /// live display-unit param values (see [`filter_entries`]). Empty for the
424    /// base layer and for unfiltered layers.
425    filters: Vec<DevtoolsFilterEntry>,
426    /// The layer's resolved `backdropFilter` chain, same shape and liveness
427    /// as [`Self::filters`] — the panel renders it as a second chain line.
428    backdrop_filters: Vec<DevtoolsFilterEntry>,
429}
430
431/// One wire filter in a layer's resolved chain: the wire name plus each
432/// param's live display values (`(slot name, values)` — multi-component
433/// params carry several). Built by [`filter_entries`].
434#[derive(serde::Serialize, ts_rs::TS, Debug, Clone, PartialEq)]
435struct DevtoolsFilterEntry {
436    name: String,
437    params: Vec<(String, Vec<f64>)>,
438}
439
440/// A layer rect: logical (CSS) px in window space — the same space as
441/// `devtools.window` — plus the physical capture dims so the panel can
442/// estimate texture memory (`physical_width * physical_height * 4`).
443#[derive(serde::Serialize, ts_rs::TS, Debug, Clone, PartialEq)]
444struct DevtoolsLayerRect {
445    x: f32,
446    y: f32,
447    width: f32,
448    height: f32,
449    physical_width: u32,
450    physical_height: u32,
451}
452
453/// Bevy → JS: console entries from the [`crate::console_log`] ring — JS
454/// `console.*` output, [`crate::diag`] messages, and JS-runtime failures.
455/// Streamed by [`emit_console`] only while the panel is open on the Console
456/// tab: the full ring backlog on tab open, then only-new entries per frame.
457/// Native-only content (the web host has no console shim — the ring is
458/// stubbed on wasm).
459#[react_event(name = "devtools.console")]
460struct DevtoolsConsole {
461    entries: Vec<DevtoolsConsoleEntry>,
462}
463
464/// One console row (oldest → newest within a batch).
465#[derive(serde::Serialize, ts_rs::TS, Debug, Clone, PartialEq)]
466struct DevtoolsConsoleEntry {
467    /// Process-monotonic id (never reused, survives clears).
468    seq: u64,
469    /// Wall-clock epoch milliseconds.
470    time_ms: u64,
471    /// `"js"` | `"rust"`.
472    source: String,
473    /// `"debug"` | `"info"` | `"warn"` | `"error"`.
474    level: String,
475    message: String,
476}
477
478/// JS → Bevy: the panel opened or closed itself (close button, install sync).
479#[react_message(name = "devtools.open")]
480struct DevtoolsOpenMessage {
481    open: bool,
482}
483
484/// JS → Bevy: the panel's pick-mode button was toggled.
485#[react_message(name = "devtools.pick")]
486struct DevtoolsPickMessage {
487    on: bool,
488}
489
490/// JS → Bevy: a tree row was selected (or the selection cleared).
491#[react_message(name = "devtools.select")]
492struct DevtoolsSelectMessage {
493    id: Option<NodeId>,
494}
495
496/// JS → Bevy: a tree row is hovered (highlight that node on screen), or `null`
497/// on hover end.
498#[react_message(name = "devtools.highlight")]
499struct DevtoolsHighlightMessage {
500    id: Option<NodeId>,
501}
502
503/// JS → Bevy: the panel's "overlay" toggle — show/hide the persistent
504/// selected-node box.
505#[react_message(name = "devtools.overlay")]
506struct DevtoolsOverlayMessage {
507    on: bool,
508}
509
510/// JS → Bevy: the panel's own `<root>` node id (`None` when the panel closes).
511/// Sent on open so [`drive_pick_mode`] can reject exactly the panel.
512#[react_message(name = "devtools.panelRoot")]
513struct DevtoolsPanelRootMessage {
514    id: Option<NodeId>,
515}
516
517/// JS → Bevy: the panel's Layers tab was shown/hidden (mount/unmount of the
518/// Layers panel — tab switch, panel close, F12). Gates [`emit_layers`].
519#[react_message(name = "devtools.layersOpen")]
520struct DevtoolsLayersOpenMessage {
521    on: bool,
522}
523
524/// JS → Bevy: the panel's Console tab was shown/hidden (mount/unmount of the
525/// Console panel). Gates [`emit_console`].
526#[react_message(name = "devtools.consoleOpen")]
527struct DevtoolsConsoleOpenMessage {
528    on: bool,
529}
530
531/// JS → Bevy: the Console tab's clear button — empty the console ring. The
532/// panel clears its local list immediately; entries logged between the click
533/// and this message arriving may show once in the panel yet miss a later
534/// backlog (browser-console parity — seq monotonicity keeps the stream
535/// watermark consistent either way).
536#[react_message(name = "devtools.consoleClear")]
537struct DevtoolsConsoleClearMessage {}
538
539/// JS → Bevy: the panel's effective space reservation. `side: None` = no
540/// reservation (the reserve toggle is off, the panel floats, or it closed);
541/// otherwise the app UI is pushed off that edge by `width` logical pixels
542/// (see [`apply_dock_reservation`]).
543#[react_message(name = "devtools.dock")]
544struct DevtoolsDockMessage {
545    side: Option<String>,
546    width: f32,
547}
548
549fn on_open_message(msg: On<DevtoolsOpenMessage>, mut state: ResMut<DevtoolsState>) {
550    state.open = msg.event().open;
551    if !state.open {
552        exit_interactions(&mut state);
553    }
554}
555
556fn on_pick_message(msg: On<DevtoolsPickMessage>, mut state: ResMut<DevtoolsState>) {
557    state.pick = msg.event().on;
558    if !state.pick {
559        state.pick_hover = None;
560    }
561}
562
563fn on_select_message(msg: On<DevtoolsSelectMessage>, mut state: ResMut<DevtoolsState>) {
564    state.selected = msg.event().id;
565}
566
567fn on_highlight_message(msg: On<DevtoolsHighlightMessage>, mut state: ResMut<DevtoolsState>) {
568    state.tree_hover = msg.event().id;
569}
570
571fn on_overlay_message(msg: On<DevtoolsOverlayMessage>, mut state: ResMut<DevtoolsState>) {
572    state.show_selection_overlay = msg.event().on;
573}
574
575fn on_panel_root_message(msg: On<DevtoolsPanelRootMessage>, mut state: ResMut<DevtoolsState>) {
576    state.panel_root = msg.event().id;
577}
578
579fn on_layers_open_message(msg: On<DevtoolsLayersOpenMessage>, mut state: ResMut<DevtoolsState>) {
580    state.layers_tab_open = msg.event().on;
581}
582
583fn on_console_open_message(msg: On<DevtoolsConsoleOpenMessage>, mut state: ResMut<DevtoolsState>) {
584    state.console_tab_open = msg.event().on;
585    // Reset the watermark on EVERY flip: a fresh open always gets the full
586    // backlog, even when the close and reopen land in the same frame.
587    state.console_last_seq = None;
588}
589
590fn on_console_clear_message(_msg: On<DevtoolsConsoleClearMessage>) {
591    crate::console_log::clear();
592}
593
594fn on_dock_message(msg: On<DevtoolsDockMessage>, mut state: ResMut<DevtoolsState>) {
595    state.dock_side = match msg.event().side.as_deref() {
596        Some("left") => Some(DockSide::Left),
597        Some("right") => Some(DockSide::Right),
598        _ => None,
599    };
600    state.dock_width = msg.event().width.max(0.0);
601}
602
603// --- Settings persistence ------------------------------------------------------
604
605/// The panel's persisted layout settings. One flat shape wears three hats: the
606/// JS → Bevy `devtools.settings` message (sent on any layout change), the JSON
607/// settings file, and — via [`DevtoolsRestore`] — the Bevy → JS restore event.
608/// `mode` stays a loose string ("left" | "right" | "float"); JS validates on
609/// restore, so an old/hand-edited file can never wedge the panel.
610///
611/// Geometry is **proportional**: the `*_frac` fields are fractions of the
612/// window's logical size (docked width, float rect), so a resized window can
613/// never strand the panel off-screen; `split` stays panel-internal pixels.
614/// `#[serde(default)]` keeps old files loading: a pre-fraction file (pixel
615/// `width`/`float_x`… keys) still restores `mode`/`reserve`/`overlay`/`split`,
616/// while its stale pixel fields are ignored and the fractions take defaults.
617/// The defaults mirror the JS panel's initial state (`DevtoolsHost.tsx`).
618#[react_message(name = "devtools.settings")]
619#[derive(serde::Serialize, Clone, PartialEq)]
620#[serde(default)]
621pub(crate) struct DevtoolsSettings {
622    /// Whether the panel was open — persisted so it reopens on relaunch.
623    open: bool,
624    /// The active tab ("nodes" | "layers" | "console" | "bridge") — persisted
625    /// so the panel reopens where you left it. Loose string; JS validates on
626    /// restore, unknown values fall back to the default tab.
627    tab: String,
628    mode: String,
629    width_frac: f32,
630    float_x_frac: f32,
631    float_y_frac: f32,
632    float_w_frac: f32,
633    float_h_frac: f32,
634    reserve: bool,
635    overlay: bool,
636    split: f32,
637}
638
639impl Default for DevtoolsSettings {
640    fn default() -> Self {
641        Self {
642            open: false,
643            tab: "nodes".into(),
644            mode: "right".into(),
645            width_frac: 0.3,
646            float_x_frac: 0.08,
647            float_y_frac: 0.1,
648            float_w_frac: 0.33,
649            float_h_frac: 0.7,
650            reserve: false,
651            overlay: true,
652            split: 260.0,
653        }
654    }
655}
656
657/// Bevy → JS: the settings loaded from disk, sent once after the React app
658/// mounts (a struct can't wear both bridge macros, hence the twin).
659#[react_event(name = "devtools.restore")]
660struct DevtoolsRestore {
661    open: bool,
662    tab: String,
663    mode: String,
664    width_frac: f32,
665    float_x_frac: f32,
666    float_y_frac: f32,
667    float_w_frac: f32,
668    float_h_frac: f32,
669    reserve: bool,
670    overlay: bool,
671    split: f32,
672}
673
674impl From<&DevtoolsSettings> for DevtoolsRestore {
675    fn from(s: &DevtoolsSettings) -> Self {
676        Self {
677            open: s.open,
678            tab: s.tab.clone(),
679            mode: s.mode.clone(),
680            width_frac: s.width_frac,
681            float_x_frac: s.float_x_frac,
682            float_y_frac: s.float_y_frac,
683            float_w_frac: s.float_w_frac,
684            float_h_frac: s.float_h_frac,
685            reserve: s.reserve,
686            overlay: s.overlay,
687            split: s.split,
688        }
689    }
690}
691
692/// Settings persistence state: what was loaded at startup (drives the one-shot
693/// restore), the latest blob from JS, and the debounced-write bookkeeping.
694#[derive(Resource)]
695struct DevtoolsPersistence {
696    loaded: Option<DevtoolsSettings>,
697    pending: Option<DevtoolsSettings>,
698    /// What the file currently holds — identical rewrites are skipped.
699    last_written: Option<DevtoolsSettings>,
700    /// When the pending blob last changed (native only; wasm never writes).
701    #[cfg(not(target_arch = "wasm32"))]
702    dirty_at: Option<std::time::Instant>,
703    /// Quiet time before a write; absorbs per-frame emits during drags.
704    debounce: Duration,
705}
706
707/// Read + parse the settings file. Any failure (no path, missing file, corrupt
708/// JSON) means fresh defaults. No-op on web.
709#[cfg_attr(target_arch = "wasm32", allow(unused_variables))]
710fn load_settings(path: Option<&std::path::Path>) -> Option<DevtoolsSettings> {
711    #[cfg(not(target_arch = "wasm32"))]
712    {
713        let text = std::fs::read_to_string(path?).ok()?;
714        serde_json::from_str(&text).ok()
715    }
716    #[cfg(target_arch = "wasm32")]
717    None
718}
719
720/// Write the pending blob if it differs from what the file holds. Failures
721/// warn once per change (the dirty stamp is cleared either way — no retry
722/// spam). Native only.
723#[cfg(not(target_arch = "wasm32"))]
724fn write_pending(persist: &mut DevtoolsPersistence, path: &std::path::Path) {
725    persist.dirty_at = None;
726    let Some(pending) = persist.pending.clone() else {
727        return;
728    };
729    if persist.last_written.as_ref() == Some(&pending) {
730        return;
731    }
732    if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
733        let _ = std::fs::create_dir_all(parent);
734    }
735    match serde_json::to_string_pretty(&pending) {
736        Ok(json) => match std::fs::write(path, json) {
737            Ok(()) => persist.last_written = Some(pending),
738            Err(e) => warn!("devtools: failed to write settings {}: {e}", path.display()),
739        },
740        Err(e) => warn!("devtools: failed to serialize settings: {e}"),
741    }
742}
743
744fn on_settings_message(
745    msg: On<DevtoolsSettings>,
746    #[cfg_attr(target_arch = "wasm32", allow(unused_mut, unused_variables))] mut persist: ResMut<
747        DevtoolsPersistence,
748    >,
749) {
750    #[cfg(not(target_arch = "wasm32"))]
751    {
752        persist.pending = Some(msg.event().clone());
753        persist.dirty_at = Some(std::time::Instant::now());
754    }
755    #[cfg(target_arch = "wasm32")]
756    let _ = msg;
757}
758
759/// Push the settings to the JS panel, once, after the React app has mounted
760/// (the first applied op batch — sending on frame one would race the isolate's
761/// listener registration). Sent **always**, with defaults when no file loaded:
762/// the JS recorder arms at install to capture the initial mount and disarms on
763/// a restore that says the panel stays closed, so every session must get
764/// exactly one restore. The window size goes first (same system, so ordering
765/// is guaranteed) — the restored fractions need it — and a persisted
766/// `open: true` reopens the panel here (the JS side mirrors the toggle).
767fn send_restore(
768    persist: Res<DevtoolsPersistence>,
769    stats: Res<OpApplyStats>,
770    cameras: Query<&Camera, With<IsDefaultUiCamera>>,
771    windows: Query<&Window>,
772    mut state: ResMut<DevtoolsState>,
773    events: ReactEvents,
774    mut done: Local<bool>,
775) {
776    if *done {
777        return;
778    }
779    if stats.applied_count == 0 {
780        return;
781    }
782    *done = true;
783    if let Some(size) = ui_viewport_size(&cameras, &windows) {
784        events.send(&DevtoolsWindow {
785            width: size.x,
786            height: size.y,
787        });
788    }
789    let settings = persist.loaded.clone().unwrap_or_default();
790    events.send(&DevtoolsRestore::from(&settings));
791    if settings.open {
792        state.open = true;
793        events.send(&DevtoolsToggle { open: true });
794    }
795}
796
797/// Stream the UI viewport's logical size to the panel: once when it opens and
798/// on every change while it stays open (the `Local` resets while closed, so a
799/// resize-while-closed is caught up on the next open). The panel's layout is
800/// proportional, and JS has no other way to see the viewport.
801fn send_window_size(
802    state: Res<DevtoolsState>,
803    stats: Res<OpApplyStats>,
804    cameras: Query<&Camera, With<IsDefaultUiCamera>>,
805    windows: Query<&Window>,
806    events: ReactEvents,
807    mut last: Local<Option<Vec2>>,
808) {
809    // Same first-batch gate as `send_restore`: no listener races.
810    if stats.applied_count == 0 {
811        return;
812    }
813    if !state.open {
814        *last = None;
815        return;
816    }
817    let Some(size) = ui_viewport_size(&cameras, &windows) else {
818        return;
819    };
820    if *last != Some(size) {
821        *last = Some(size);
822        events.send(&DevtoolsWindow {
823            width: size.x,
824            height: size.y,
825        });
826    }
827}
828
829/// Debounced settings write: JS emits on every layout change (per frame during
830/// a drag); the file is written once things go quiet.
831#[cfg_attr(target_arch = "wasm32", allow(unused_mut, unused_variables))]
832fn save_settings(mut persist: ResMut<DevtoolsPersistence>, config: Res<DevtoolsConfig>) {
833    #[cfg(not(target_arch = "wasm32"))]
834    {
835        let Some(path) = config.settings_path.clone() else {
836            return;
837        };
838        let debounce = persist.debounce;
839        if persist.dirty_at.is_some_and(|t| t.elapsed() >= debounce) {
840            write_pending(&mut persist, &path);
841        }
842    }
843}
844
845/// Flush pending settings when the app quits, so a layout change made moments
846/// before closing isn't lost to the debounce window.
847#[cfg_attr(target_arch = "wasm32", allow(unused_mut, unused_variables))]
848fn flush_settings_on_exit(
849    mut exits: MessageReader<AppExit>,
850    mut persist: ResMut<DevtoolsPersistence>,
851    config: Res<DevtoolsConfig>,
852) {
853    #[cfg(not(target_arch = "wasm32"))]
854    {
855        if exits.read().next().is_none() {
856            return;
857        }
858        let Some(path) = config.settings_path.clone() else {
859            return;
860        };
861        if persist.dirty_at.is_some() {
862            write_pending(&mut persist, &path);
863        }
864    }
865    #[cfg(target_arch = "wasm32")]
866    exits.clear();
867}
868
869/// Clear the transient interaction state that shouldn't outlive a closed panel.
870fn exit_interactions(state: &mut DevtoolsState) {
871    state.pick = false;
872    state.tree_hover = None;
873    state.pick_hover = None;
874    // Belt-and-braces: the JS panel also reports `layersOpen: false` /
875    // `consoleOpen: false` when those tabs unmount, but every close path must
876    // kill the streams even if the message is lost.
877    state.layers_tab_open = false;
878    state.console_tab_open = false;
879    state.console_last_seq = None;
880}
881
882/// Flip the panel on the configured key and tell the JS panel the new state.
883fn toggle_on_key(
884    keys: Res<ButtonInput<KeyCode>>,
885    cfg: Res<DevtoolsConfig>,
886    mut state: ResMut<DevtoolsState>,
887    events: ReactEvents,
888) {
889    if !keys.just_pressed(cfg.toggle_key) {
890        return;
891    }
892    state.open = !state.open;
893    if !state.open {
894        exit_interactions(&mut state);
895    }
896    events.send(&DevtoolsToggle { open: state.open });
897}
898
899/// Reserve window space for a docked panel: inset the app's [`UiRoot`] margin
900/// on the reserved edge so the whole reconciler tree reflows beside the panel
901/// (the panel pushes the app aside rather than overlapping it), and release it
902/// whenever the reservation ends. Gated
903/// on `state.open`, so every close path (close button, toggle key) releases
904/// the space with no extra bookkeeping.
905///
906/// The margins are compared before writing — an unconditional `Node` deref-mut
907/// would re-run app layout every frame. The reserved width is clamped against
908/// the window so a huge panel can't push the app entirely off-screen
909/// (headless: no window, no clamp — fine for tests).
910///
911/// Known limitation: app-created `<root>` overlays are detached full-window
912/// trees (see `reconcile.rs` `root_base`), so they are not pushed — only the
913/// main tree under [`UiRoot`] is.
914fn apply_dock_reservation(
915    state: Res<DevtoolsState>,
916    windows: Query<&Window>,
917    mut root: Query<&mut Node, With<crate::plugin::UiRoot>>,
918) {
919    let Ok(mut node) = root.single_mut() else {
920        return;
921    };
922    let reserved = if state.open { state.dock_side } else { None };
923    let width = match windows.single() {
924        Ok(window) => state.dock_width.min(window.width() - 100.0).max(0.0),
925        Err(_) => state.dock_width,
926    };
927    let (left, right) = match reserved {
928        Some(DockSide::Left) => (Val::Px(width), Val::ZERO),
929        Some(DockSide::Right) => (Val::ZERO, Val::Px(width)),
930        None => (Val::ZERO, Val::ZERO),
931    };
932    if node.margin.left != left || node.margin.right != right {
933        node.margin.left = left;
934        node.margin.right = right;
935    }
936}
937
938/// Per-frame instants/durations splitting the post-translate cost into command
939/// execution and layout, exactly like the stress harness's `BenchTimers`.
940/// Updated only on frames a batch was applied.
941#[derive(Resource, Default)]
942struct DevtoolsTimers {
943    /// Stamped each frame just before `UiSystems::Layout` (native only).
944    #[cfg(not(target_arch = "wasm32"))]
945    pre_layout: Option<std::time::Instant>,
946    /// `pre_layout - apply_end`: command execution + UI prepare/content for the
947    /// most recent applied batch.
948    last_command: Duration,
949    /// `UiSystems::Layout` + `PostLayout` for the most recent applied batch.
950    last_layout: Duration,
951    /// The `applied_count` last recorded, to detect a fresh batch this frame.
952    seen_applied: u64,
953}
954
955#[cfg_attr(target_arch = "wasm32", allow(unused_mut, unused_variables))]
956fn mark_pre_layout(mut timers: ResMut<DevtoolsTimers>) {
957    #[cfg(not(target_arch = "wasm32"))]
958    {
959        timers.pre_layout = Some(std::time::Instant::now());
960    }
961}
962
963fn mark_post_layout(stats: Res<OpApplyStats>, mut timers: ResMut<DevtoolsTimers>) {
964    // Only meaningful on frames that applied a batch (its commands flush + lay
965    // out this same frame). Other frames leave the last values intact.
966    if stats.applied_count == timers.seen_applied {
967        return;
968    }
969    timers.seen_applied = stats.applied_count;
970    #[cfg(not(target_arch = "wasm32"))]
971    if let (Some(end), Some(pre)) = (stats.last_apply_end, timers.pre_layout) {
972        let (command, layout) = split_legs(end, pre, std::time::Instant::now());
973        timers.last_command = command;
974        timers.last_layout = layout;
975    }
976}
977
978/// Split "batch applied → layout done" into the command and layout legs.
979/// Saturating: system-order jitter must clamp to zero, never panic.
980#[cfg(not(target_arch = "wasm32"))]
981fn split_legs(
982    apply_end: std::time::Instant,
983    pre_layout: std::time::Instant,
984    post_layout: std::time::Instant,
985) -> (Duration, Duration) {
986    (
987        pre_layout.saturating_duration_since(apply_end),
988        post_layout.saturating_duration_since(pre_layout),
989    )
990}
991
992/// Pick mode ("inspect" cursor): while active, the topmost app node under the
993/// window cursor is hover-highlighted, and a left click selects it in the tree
994/// (exiting pick mode). Uses the picking `HoverMap` — the established pattern
995/// for UI hit-tests here (window-cursor `UiStack` walks can't see everything
996/// picking can) — and resolves hits the way surface picking does: climb from the
997/// topmost (min-depth) hit to the nearest `RNode` owner. Anything under the
998/// panel's own `<root>` (reported by the JS panel as
999/// [`DevtoolsState::panel_root`]) is rejected so the panel can never pick
1000/// itself; app `<root>` overlays are ordinary pick targets. Only the mouse
1001/// pointer is consulted: `<surface>` subtrees (in-world virtual pointer) are
1002/// out of pick mode's scope.
1003///
1004/// Known limitation (documented): the picking click still reaches the app's own
1005/// `onClick` handlers — pick mode does not suppress the click.
1006#[allow(clippy::too_many_arguments)]
1007fn drive_pick_mode(
1008    mut state: ResMut<DevtoolsState>,
1009    hover_map: Option<Res<HoverMap>>,
1010    mouse: Res<ButtonInput<MouseButton>>,
1011    capture: Option<ResMut<PointerCapture>>,
1012    bridge: Option<Res<JsBridge>>,
1013    rnodes: Query<&RNode>,
1014    child_of: Query<&ChildOf>,
1015    events: ReactEvents,
1016) {
1017    if !(state.open && state.pick) {
1018        return;
1019    }
1020    // Claim the pointer for the whole pick session so world input (camera
1021    // orbit/zoom) ignores the picking gestures — both channels: hover
1022    // (`over_ui` blocks drags/presses) and wheel (`wheel_captured` blocks zoom).
1023    if let Some(mut capture) = capture {
1024        capture.over_ui = true;
1025        capture.wheel_captured = true;
1026    }
1027
1028    // The panel's own root, resolved to its entity. `None` (not yet reported /
1029    // no bridge) rejects nothing — pick mode is only reachable from an open
1030    // panel, which reports its root on mount.
1031    let panel_entity = state
1032        .panel_root
1033        .and_then(|id| bridge.as_ref().and_then(|b| b.nodes.get(&id).copied()));
1034
1035    let hovered = hover_map
1036        .as_deref()
1037        .and_then(|hover_map| hover_map.get(&PointerId::Mouse))
1038        .and_then(|hits| {
1039            // The Mouse hover map mixes backends: bevy_ui hits (stack-index
1040            // depth) AND mesh-picking hits (ray distance in world units — the
1041            // demos always have a 3D scene behind the UI). The scales aren't
1042            // comparable, and a mesh often wins a raw `min_by(depth)`, which
1043            // made picking look dead over the whole viewport. So: keep only
1044            // hits that resolve to a reconciled UI node (climb to an `RNode`
1045            // owner — this drops mesh hits but keeps panel nodes, so you still
1046            // can't pick app nodes THROUGH the panel), take the frontmost of
1047            // those, THEN apply the panel self-rejection.
1048            let (&top, _) = hits
1049                .iter()
1050                .filter(|&(&entity, _)| climb(entity, &child_of, |e| rnodes.contains(e)).is_some())
1051                .min_by(|a, b| a.1.depth.total_cmp(&b.1.depth))?;
1052            // The panel can't pick itself (its nodes live under its own root).
1053            if let Some(panel) = panel_entity
1054                && climb(top, &child_of, |e| e == panel).is_some()
1055            {
1056                return None;
1057            }
1058            let owner = climb(top, &child_of, |e| rnodes.contains(e))?;
1059            rnodes.get(owner).ok().map(|r| r.0)
1060        });
1061    state.pick_hover = hovered;
1062
1063    if mouse.just_pressed(MouseButton::Left)
1064        && let Some(id) = hovered
1065    {
1066        state.pick = false;
1067        state.pick_hover = None;
1068        state.selected = Some(id);
1069        events.send(&DevtoolsPicked { id });
1070    }
1071}
1072
1073/// Marks the single pre-spawned highlight overlay entity: the translucent box
1074/// drawn over the node the devtools is hovering/selecting.
1075#[derive(Component)]
1076struct DevtoolsHighlightOverlay;
1077
1078/// Spawn the (hidden) highlight overlay once. A detached window-root node so it
1079/// needs no parent; `GlobalZIndex(i32::MAX - 1)` floats it above the app but
1080/// below the devtools panel's `<root>` (`i32::MAX`), and `Pickable::IGNORE`
1081/// keeps it out of the `HoverMap` so pick mode can never pick the highlight box
1082/// hovering under the cursor.
1083fn spawn_highlight_overlay(mut commands: Commands) {
1084    commands.spawn((
1085        DevtoolsHighlightOverlay,
1086        Node {
1087            position_type: PositionType::Absolute,
1088            display: Display::None,
1089            ..default()
1090        },
1091        // Translucent blue fill + hairline.
1092        BackgroundColor(Color::srgba(0.54, 0.71, 0.97, 0.30)),
1093        Outline {
1094            width: Val::Px(1.0),
1095            color: Color::srgba(0.54, 0.71, 0.97, 0.9),
1096            ..default()
1097        },
1098        GlobalZIndex(i32::MAX - 1),
1099        Pickable::IGNORE,
1100    ));
1101}
1102
1103/// Move the highlight overlay over the current target each frame. Target
1104/// priority: pick-mode hover, then a hovered tree row, then the selection.
1105/// Rust-side on purpose: bounding boxes change every frame (layout, scroll,
1106/// animation), and this is one query with zero bridge traffic — a React-side
1107/// box would need per-frame geometry crossing the boundary.
1108fn position_highlight(
1109    state: Res<DevtoolsState>,
1110    // `Option`: `JsBridge` is only inserted at `Startup` (see `OutboundResource`),
1111    // and headless tests run this plugin without a JS runtime at all.
1112    bridge: Option<Res<JsBridge>>,
1113    targets: Query<(&ComputedNode, &UiGlobalTransform)>,
1114    mut overlay: Query<&mut Node, With<DevtoolsHighlightOverlay>>,
1115) {
1116    let Ok(mut node) = overlay.single_mut() else {
1117        return;
1118    };
1119    let Some(bridge) = bridge else {
1120        return;
1121    };
1122    let target = state
1123        .pick_hover
1124        .or(state.tree_hover)
1125        // The persistent selection box is gated by the panel's overlay toggle;
1126        // the momentary hover highlights above are always on.
1127        .or(state.selected.filter(|_| state.show_selection_overlay))
1128        .filter(|_| state.open);
1129    let rect = target
1130        .and_then(|id| bridge.nodes.get(&id))
1131        .and_then(|&e| targets.get(e).ok())
1132        .map(|(computed, transform)| {
1133            highlight_rect(
1134                computed.size,
1135                transform.translation,
1136                computed.inverse_scale_factor,
1137            )
1138        });
1139    // Write only on change: a `Node` mutation forces a bevy_ui relayout, so an
1140    // idle overlay must not dirty itself every frame.
1141    match rect {
1142        Some((pos, size)) => {
1143            let (left, top) = (Val::Px(pos.x), Val::Px(pos.y));
1144            let (width, height) = (Val::Px(size.x), Val::Px(size.y));
1145            if node.display != Display::Flex
1146                || node.left != left
1147                || node.top != top
1148                || node.width != width
1149                || node.height != height
1150            {
1151                node.display = Display::Flex;
1152                node.left = left;
1153                node.top = top;
1154                node.width = width;
1155                node.height = height;
1156            }
1157        }
1158        None => {
1159            if node.display != Display::None {
1160                node.display = Display::None;
1161            }
1162        }
1163    }
1164}
1165
1166/// A node's window-space logical rect from its computed (physical) geometry:
1167/// `UiGlobalTransform.translation` is the node's physical center, so top-left =
1168/// center - size/2, all scaled to logical px by the inverse scale factor.
1169fn highlight_rect(physical_size: Vec2, physical_center: Vec2, inverse_scale: f32) -> (Vec2, Vec2) {
1170    let top_left = (physical_center - physical_size * 0.5) * inverse_scale;
1171    (top_left, physical_size * inverse_scale)
1172}
1173
1174/// Push one `devtools.batchStats` per applied APP op batch while the panel is
1175/// open. Runs after `mark_post_layout`, so the command/layout legs for THIS
1176/// frame's batch are already split. Frames that applied nothing send nothing —
1177/// and neither do applies of the panel's OWN commits (`app_applied_count`
1178/// unchanged): stats for those would make the panel repaint, producing the
1179/// next batch, whose stats repaint it again… a self-observation loop at frame
1180/// rate. The per-batch origin flags ([`crate::reconcile::FlushFlags`]) are
1181/// what makes the distinction possible.
1182fn emit_batch_stats(
1183    state: Res<DevtoolsState>,
1184    stats: Res<OpApplyStats>,
1185    timers: Res<DevtoolsTimers>,
1186    events: ReactEvents,
1187    mut seen: Local<u64>,
1188) {
1189    if stats.app_applied_count == *seen {
1190        return;
1191    }
1192    *seen = stats.app_applied_count;
1193    if !state.open {
1194        return;
1195    }
1196    events.send(&DevtoolsBatchStats {
1197        applied_count: stats.applied_count,
1198        last_ops: stats.last_ops,
1199        frame_wait_ms: stats.last_frame_wait.as_secs_f64() * 1000.0,
1200        pre_apply_ms: stats.last_pre_apply.as_secs_f64() * 1000.0,
1201        translate_ms: stats.last_translate.as_secs_f64() * 1000.0,
1202        command_ms: timers.last_command.as_secs_f64() * 1000.0,
1203        layout_ms: timers.last_layout.as_secs_f64() * 1000.0,
1204    });
1205}
1206
1207/// Stream the [`crate::console_log`] ring to the panel while it is open on
1208/// the Console tab: the full backlog right after the tab opens (watermark
1209/// `None`, reset by [`on_console_open_message`]), then only entries newer
1210/// than the watermark. Listener race is safe by construction — the gate flag
1211/// only flips via a JS message the panel sends *after* subscribing. No
1212/// self-observation loop: an emit re-renders the panel, but rendering logs
1213/// nothing.
1214fn emit_console(mut state: ResMut<DevtoolsState>, events: ReactEvents) {
1215    if !(state.open && state.console_tab_open) {
1216        return;
1217    }
1218    let (entries, watermark) = crate::console_log::since(state.console_last_seq.unwrap_or(0));
1219    state.console_last_seq = Some(watermark);
1220    if entries.is_empty() {
1221        return;
1222    }
1223    events.send(&DevtoolsConsole {
1224        entries: entries
1225            .into_iter()
1226            .map(|e| DevtoolsConsoleEntry {
1227                seq: e.seq,
1228                time_ms: e.time_ms,
1229                source: e.source.as_str().into(),
1230                level: e.level.as_str().into(),
1231                message: e.message,
1232            })
1233            .collect(),
1234    });
1235}
1236
1237/// Human labels for promotion-reason bits. The labels are opaque strings the
1238/// JS panel displays verbatim (forward-compat by design — no JS-side table to
1239/// keep in sync); extend this when a new promotion rule lands in
1240/// [`crate::layer::PromotionReasons`].
1241fn reason_labels(reasons: crate::layer::PromotionReasons) -> Vec<String> {
1242    let mut out = Vec::new();
1243    if reasons.0 & crate::layer::PromotionReasons::OPACITY != 0 {
1244        out.push("opacity".to_string());
1245    }
1246    if reasons.0 & crate::layer::PromotionReasons::FILTER != 0 {
1247        out.push("filter".to_string());
1248    }
1249    if reasons.0 & crate::layer::PromotionReasons::TRANSFORM3D != 0 {
1250        out.push("transform3d".to_string());
1251    }
1252    if reasons.0 & crate::layer::PromotionReasons::BACKDROP != 0 {
1253        out.push("backdrop".to_string());
1254    }
1255    if reasons.0 & crate::layer::PromotionReasons::FORCED != 0 {
1256        out.push("cache".to_string());
1257    }
1258    out
1259}
1260
1261/// Round to 3 decimals for display. Load-bearing twice over: the diff gate in
1262/// [`emit_layers`] is exact row equality, so this rounding IS the rate
1263/// limiter — sub-0.001 f32 noise from a running animation never re-emits,
1264/// while any visible param change does (per frame while the tab is open,
1265/// which is the intended live view). And it rounds **in f64**, returning f64:
1266/// rounding in f32 and widening afterwards would resurrect the noise on the
1267/// JSON wire (`0.4f32 as f64` prints `0.4000000059604645`; the f64-rounded
1268/// value prints `0.4`).
1269fn round3(v: f32) -> f64 {
1270    (f64::from(v) * 1000.0).round() / 1000.0
1271}
1272
1273/// Flatten a layer's [`crate::filters::ResolvedFilterChain`] into display
1274/// entries — one per **wire** filter, not per render pass: a multi-pass
1275/// filter (blur's H+V) expands into consecutive passes sharing a
1276/// `wire_index`, and the first pass of each group carries the shared display
1277/// params (blur's direction components are unnamed in the layout, so they
1278/// never show). Params are unpacked via the pass layout into the wire's
1279/// units: angles pack as radians → shown in degrees, `Length` slots are
1280/// stored **physical** px (the resolver's upload rewrite) → divided by
1281/// `chain.scale` back to logical px, scalars and color components as-is.
1282fn filter_entries(
1283    chain: &crate::filters::ResolvedFilterChain,
1284    input: Option<&crate::filters::FilterChain>,
1285) -> Vec<DevtoolsFilterEntry> {
1286    use crate::animations::ValueKind;
1287    let scale = if chain.scale > 0.0 { chain.scale } else { 1.0 };
1288    let mut out: Vec<DevtoolsFilterEntry> = Vec::new();
1289    let mut last_wire = None;
1290    for pass in &chain.passes {
1291        if last_wire == Some(pass.wire_index) {
1292            continue;
1293        }
1294        last_wire = Some(pass.wire_index);
1295        // wire_index → name via the wire-chain mirror on the same entity
1296        // (invalid entries skipped by the resolver keep their index gap, so
1297        // positions line up). Defensive fallback — a mirror momentarily out
1298        // of step must not panic or mislabel: show the raw index.
1299        let name = input
1300            .and_then(|i| i.0.get(pass.wire_index as usize))
1301            .map(|u| u.name.clone())
1302            .unwrap_or_else(|| format!("#{}", pass.wire_index));
1303        let params = pass
1304            .layout
1305            .iter()
1306            .map(|slot| {
1307                let values = (slot.comp..(slot.comp + slot.len).min(4))
1308                    .map(|comp| {
1309                        let raw = pass.params.get(slot.vec).map_or(0.0, |v| v[comp]);
1310                        round3(match slot.kind {
1311                            ValueKind::Angle => raw.to_degrees(),
1312                            ValueKind::Length => raw / scale,
1313                            ValueKind::Scalar | ValueKind::Color => raw,
1314                        })
1315                    })
1316                    .collect();
1317                (slot.name.to_string(), values)
1318            })
1319            .collect();
1320        out.push(DevtoolsFilterEntry { name, params });
1321    }
1322    out
1323}
1324
1325/// Physical-pixel twin of [`ui_viewport_size`]: the default UI camera's
1326/// physical viewport, falling back to the window's physical resolution.
1327fn viewport_physical_size(
1328    cameras: &Query<&Camera, With<IsDefaultUiCamera>>,
1329    windows: &Query<&Window>,
1330) -> Option<UVec2> {
1331    if let Ok(camera) = cameras.single()
1332        && let Some(size) = camera.physical_viewport_size()
1333    {
1334        return Some(size);
1335    }
1336    windows.single().ok().map(|window| {
1337        UVec2::new(
1338            window.resolution.physical_width(),
1339            window.resolution.physical_height(),
1340        )
1341    })
1342}
1343
1344/// Push `devtools.layers` — the base layer plus every
1345/// [`crate::layer::LayersRegistry`] row — while the panel is open on the
1346/// Layers tab. Runs in `PostUpdate` after
1347/// [`crate::layer::sync_layer_geometry`] so the rects are this frame's
1348/// layout. Diffed against a `Local` snapshot (the [`send_window_size`]
1349/// pattern): idle apps send nothing, and the snapshot resets while the tab is
1350/// hidden so a re-shown tab always gets a fresh full payload. Rows under the
1351/// panel's own `<root>` are skipped — a promoted panel node would otherwise
1352/// repaint the panel with its own payload, whose layout change produces the
1353/// next payload (the same self-observation loop [`emit_batch_stats`] guards
1354/// against).
1355#[allow(clippy::too_many_arguments, clippy::type_complexity)]
1356fn emit_layers(
1357    state: Res<DevtoolsState>,
1358    registry: Res<crate::layer::LayersRegistry>,
1359    membership: Res<crate::layer::LayerMembership>,
1360    bridge: Option<Res<JsBridge>>,
1361    rnodes: Query<(), With<RNode>>,
1362    child_of: Query<&ChildOf>,
1363    computed: Query<&ComputedNode>,
1364    chains: Query<(
1365        Option<&crate::filters::ResolvedFilterChain>,
1366        Option<&crate::filters::FilterInput>,
1367        Option<&crate::filters::ResolvedBackdropChain>,
1368        Option<&crate::filters::BackdropInput>,
1369    )>,
1370    cameras: Query<&Camera, With<IsDefaultUiCamera>>,
1371    windows: Query<&Window>,
1372    events: ReactEvents,
1373    mut last: Local<Option<Vec<DevtoolsLayerRow>>>,
1374) {
1375    if !(state.open && state.layers_tab_open) {
1376        *last = None;
1377        return;
1378    }
1379    let Some(logical) = ui_viewport_size(&cameras, &windows) else {
1380        return;
1381    };
1382    let physical = viewport_physical_size(&cameras, &windows).unwrap_or(UVec2::new(
1383        logical.x.round() as u32,
1384        logical.y.round() as u32,
1385    ));
1386    // For registry entities missing `ComputedNode` (never laid out): derive
1387    // the scale from the viewport instead.
1388    let fallback_inverse_scale = if physical.x > 0 {
1389        logical.x / physical.x as f32
1390    } else {
1391        1.0
1392    };
1393    let panel_entity = state
1394        .panel_root
1395        .and_then(|id| bridge.as_ref().and_then(|b| b.nodes.get(&id).copied()));
1396
1397    let mut rows = Vec::with_capacity(registry.layers.len() + 1);
1398    rows.push(DevtoolsLayerRow {
1399        id: 0,
1400        reasons: vec!["base".to_string()],
1401        depth: 0,
1402        node_count: 0,
1403        rect: Some(DevtoolsLayerRect {
1404            x: 0.0,
1405            y: 0.0,
1406            width: logical.x,
1407            height: logical.y,
1408            physical_width: physical.x,
1409            physical_height: physical.y,
1410        }),
1411        repaints: 0,
1412        filters: Vec::new(),
1413        backdrop_filters: Vec::new(),
1414    });
1415    for meta in registry.layers.values() {
1416        if let Some(panel) = panel_entity
1417            && climb(meta.entity, &child_of, |e| e == panel).is_some()
1418        {
1419            continue;
1420        }
1421        let inverse_scale = computed
1422            .get(meta.entity)
1423            .map(|c| c.inverse_scale_factor)
1424            .unwrap_or(fallback_inverse_scale);
1425        let node_count = membership
1426            .node_to_layer
1427            .iter()
1428            .filter(|&(node, layer)| *layer == meta.entity && rnodes.contains(*node))
1429            .count() as u32;
1430        // The resolved chain + wire mirror live on the layer entity — read
1431        // them directly rather than mirroring them into `LayerMeta` (which
1432        // would duplicate live state the filter systems already maintain).
1433        let (filters, backdrop_filters) = chains
1434            .get(meta.entity)
1435            .map(|(chain, input, bchain, binput)| {
1436                (
1437                    chain
1438                        .map(|c| filter_entries(c, input.map(|i| &i.0)))
1439                        .unwrap_or_default(),
1440                    bchain
1441                        .map(|c| filter_entries(&c.0, binput.map(|i| &i.0)))
1442                        .unwrap_or_default(),
1443                )
1444            })
1445            .unwrap_or_default();
1446        rows.push(DevtoolsLayerRow {
1447            id: meta.node,
1448            reasons: reason_labels(meta.reasons),
1449            depth: meta.depth,
1450            node_count,
1451            // The registry rect min is signed (filter outset routinely pushes
1452            // it negative near the viewport edge); width/height stay positive
1453            // by construction.
1454            rect: meta.capture_rect.map(|r| DevtoolsLayerRect {
1455                x: r.min.x as f32 * inverse_scale,
1456                y: r.min.y as f32 * inverse_scale,
1457                width: r.width() as f32 * inverse_scale,
1458                height: r.height() as f32 * inverse_scale,
1459                physical_width: r.width().max(0) as u32,
1460                physical_height: r.height().max(0) as u32,
1461            }),
1462            repaints: meta.repaints,
1463            filters,
1464            backdrop_filters,
1465        });
1466    }
1467    // Deterministic order for the diff AND the panel's back-to-front paint:
1468    // base first, then by nesting depth, ties by id.
1469    rows.sort_by_key(|r| (r.depth, r.id));
1470    if last.as_ref() != Some(&rows) {
1471        events.send(&DevtoolsLayers {
1472            layers: rows.clone(),
1473        });
1474        *last = Some(rows);
1475    }
1476}
1477
1478/// Drain the [`crate::diag`] runtime sink and ship each **new** warning to JS
1479/// as a `devtools.warning` event. Deduped by a hash of the whole entry so the
1480/// hover/press restyle paths (which re-parse the same bad value on every flip)
1481/// can't spam; the set resets on [`OpApplyStats::reset_count`] (hot reload) so
1482/// a reloaded app's warnings flag again — the JS mirror was reset too. NOT
1483/// gated on the panel being open (always-on-in-dev: the mirror stores the
1484/// flags for whenever the panel opens); the `applied_count` gate only holds
1485/// entries back until the React app has mounted its listeners (same
1486/// listener-race guard as [`send_restore`] — entries stay queued, not lost).
1487fn emit_runtime_warnings(
1488    stats: Res<OpApplyStats>,
1489    events: ReactEvents,
1490    mut seen: Local<HashSet<u64>>,
1491    mut last_reset: Local<u64>,
1492) {
1493    if stats.reset_count != *last_reset {
1494        *last_reset = stats.reset_count;
1495        seen.clear();
1496    }
1497    if stats.applied_count == 0 {
1498        return;
1499    }
1500    for w in crate::diag::take_runtime_warnings() {
1501        let mut hasher = std::hash::DefaultHasher::new();
1502        std::hash::Hash::hash(&(w.node, w.kind, &w.value, &w.message), &mut hasher);
1503        if seen.insert(std::hash::Hasher::finish(&hasher)) {
1504            events.send(&DevtoolsWarning {
1505                id: w.node,
1506                kind: w.kind.to_string(),
1507                value: w.value,
1508                message: w.message,
1509            });
1510        }
1511    }
1512}
1513
1514#[cfg(test)]
1515mod tests {
1516    use super::*;
1517    use crate::bridge::{OutboundResource, RRoot};
1518    use crate::protocol::Outbound;
1519    use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel};
1520
1521    /// Headless app with the toggle/auto-open systems and a drainable outbound
1522    /// channel (the same harness shape as `keyboard.rs`'s tests).
1523    fn test_app(config: DevtoolsConfig) -> (App, UnboundedReceiver<Outbound>) {
1524        let mut app = App::new();
1525        // Hold the diag test lock for the app's lifetime: `emit_runtime_warnings`
1526        // drains the process-global runtime sink every update, so concurrent
1527        // test apps would steal entries from each other (and from the
1528        // diag/ui_map sink tests). A non-send resource drops with the App.
1529        // CONSEQUENCE: one live test_app per test — `drop(app)` before
1530        // creating a second, or this lock self-deadlocks (std Mutex is not
1531        // reentrant). Don't take `diag::test_lock()` in a test using this
1532        // harness either.
1533        app.insert_non_send(crate::diag::test_lock());
1534        app.add_plugins(MinimalPlugins);
1535        app.init_resource::<ButtonInput<KeyCode>>();
1536        app.init_resource::<ButtonInput<MouseButton>>();
1537        app.init_resource::<OpApplyStats>();
1538        let (tx, rx) = unbounded_channel::<Outbound>();
1539        app.insert_resource(OutboundResource(tx));
1540        app.add_plugins(DevtoolsPlugin::new(config));
1541        (app, rx)
1542    }
1543
1544    fn drain_events(rx: &mut UnboundedReceiver<Outbound>) -> Vec<(String, serde_json::Value)> {
1545        let mut out = Vec::new();
1546        while let Ok(msg) = rx.try_recv() {
1547            if let Outbound::Event { name, value } = msg {
1548                out.push((name, value));
1549            }
1550        }
1551        out
1552    }
1553
1554    /// An active pick session owns the pointer completely: it must claim the
1555    /// hover channel (`over_ui` — no world drags start) AND the wheel channel
1556    /// (`wheel_captured` — no world zoom), every frame it is active.
1557    #[test]
1558    fn pick_mode_claims_hover_and_wheel_channels() {
1559        let (mut app, _rx) = test_app(DevtoolsConfig::default());
1560        app.init_resource::<crate::PointerCapture>();
1561        {
1562            let mut state = app.world_mut().resource_mut::<DevtoolsState>();
1563            state.open = true;
1564            state.pick = true;
1565        }
1566        app.update();
1567
1568        let capture = app.world().resource::<crate::PointerCapture>();
1569        assert!(capture.over_ui, "pick mode must claim the hover channel");
1570        assert!(
1571            capture.wheel_captured,
1572            "pick mode must claim the wheel channel too"
1573        );
1574    }
1575
1576    #[test]
1577    fn toggle_key_flips_state_and_notifies_js() {
1578        let (mut app, mut rx) = test_app(DevtoolsConfig {
1579            toggle_key: KeyCode::F9,
1580            ..default()
1581        });
1582        app.update();
1583        assert!(drain_events(&mut rx).is_empty(), "no toggle before the key");
1584
1585        app.world_mut()
1586            .resource_mut::<ButtonInput<KeyCode>>()
1587            .press(KeyCode::F9);
1588        app.update();
1589        let events = drain_events(&mut rx);
1590        assert_eq!(
1591            events
1592                .iter()
1593                .find(|(name, _)| name == "devtools.toggle")
1594                .map(|(_, v)| v["open"].as_bool()),
1595            Some(Some(true)),
1596            "the configured key must open the panel and notify JS"
1597        );
1598        assert!(app.world().resource::<DevtoolsState>().open);
1599
1600        // Release + press again closes it.
1601        {
1602            let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
1603            keys.clear_just_pressed(KeyCode::F9);
1604            keys.release(KeyCode::F9);
1605        }
1606        app.update();
1607        app.world_mut()
1608            .resource_mut::<ButtonInput<KeyCode>>()
1609            .press(KeyCode::F9);
1610        app.update();
1611        let events = drain_events(&mut rx);
1612        assert_eq!(
1613            events
1614                .iter()
1615                .find(|(name, _)| name == "devtools.toggle")
1616                .map(|(_, v)| v["open"].as_bool()),
1617            Some(Some(false)),
1618            "pressing again must close the panel"
1619        );
1620        assert!(!app.world().resource::<DevtoolsState>().open);
1621    }
1622
1623    /// A settings file persisted with `open: true` reopens the panel — but only
1624    /// after the React app mounted (the first applied batch), and exactly once.
1625    #[test]
1626    fn restored_open_opens_panel_after_first_batch() {
1627        let tmp = TempSettings::new("open");
1628        let settings = DevtoolsSettings {
1629            open: true,
1630            ..Default::default()
1631        };
1632        std::fs::write(&tmp.0, serde_json::to_string(&settings).unwrap()).unwrap();
1633        let (mut app, mut rx) = test_app(DevtoolsConfig {
1634            settings_path: Some(tmp.0.clone()),
1635            ..default()
1636        });
1637        app.update();
1638        assert!(
1639            drain_events(&mut rx).is_empty(),
1640            "must not reopen before the React app mounted"
1641        );
1642
1643        app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
1644        app.update();
1645        let events = drain_events(&mut rx);
1646        assert!(
1647            events
1648                .iter()
1649                .any(|(name, v)| name == "devtools.restore" && v["open"] == true),
1650            "the restore blob must carry the persisted open state"
1651        );
1652        assert!(
1653            events
1654                .iter()
1655                .any(|(name, v)| name == "devtools.toggle" && v["open"] == true),
1656            "a persisted open must reopen the panel once a batch has been applied"
1657        );
1658        assert!(app.world().resource::<DevtoolsState>().open);
1659
1660        app.update();
1661        assert!(
1662            drain_events(&mut rx)
1663                .iter()
1664                .all(|(name, _)| name != "devtools.toggle" && name != "devtools.restore"),
1665            "the restore-open must fire exactly once"
1666        );
1667    }
1668
1669    /// The JS editor validates against its own field table
1670    /// (`js/src/devtools/fields.ts`); assert it names every wire field of
1671    /// `protocol.rs`'s `with_style_fields!` table, so adding a `Style` field
1672    /// can't silently leave it un-editable in devtools. Matches the key either
1673    /// bare (`width:`) or quoted (`"width":`) — prettier decides which.
1674    /// camelCase wire names make the bare `name:` probe unambiguous (a missing
1675    /// `top` is never satisfied by `scrollTop:`).
1676    /// Runtime invalid-value warnings ship once per distinct entry as
1677    /// `devtools.warning` (hover restyles re-report the same bad value on
1678    /// every flip — the dedup set must swallow those), and re-ship after a
1679    /// hot reload (`reset_count` bump), matching the JS mirror's reset.
1680    /// Global-sink caveats: hold the diag test lock, and filter both drained
1681    /// warnings and emitted events by our own node id.
1682    #[cfg(debug_assertions)]
1683    #[test]
1684    fn runtime_warnings_emit_once_and_reset_on_reload() {
1685        // NOTE: the diag test lock is already held by `test_app`'s app —
1686        // taking it here too would deadlock.
1687        let (mut app, mut rx) = test_app(DevtoolsConfig {
1688            settings_path: None,
1689            ..default()
1690        });
1691        let _ = crate::diag::take_runtime_warnings();
1692        // The listener-race gate holds warnings until the app has mounted.
1693        app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
1694
1695        let report = || {
1696            let _scope = crate::diag::node_scope(31337);
1697            crate::diag::report("color", "redd", "unrecognized color \"redd\"");
1698        };
1699        let mine = |events: &[(String, serde_json::Value)]| {
1700            events
1701                .iter()
1702                .filter(|(name, v)| name == "devtools.warning" && v["id"] == 31337)
1703                .count()
1704        };
1705
1706        report();
1707        app.update();
1708        let events = drain_events(&mut rx);
1709        assert_eq!(
1710            mine(&events),
1711            1,
1712            "first report ships (panel closed is fine)"
1713        );
1714        assert!(
1715            events.iter().any(|(name, v)| name == "devtools.warning"
1716                && v["kind"] == "color"
1717                && v["value"] == "redd"
1718                && v["message"].as_str().is_some_and(|m| m.contains("redd"))),
1719            "the event carries kind/value/message"
1720        );
1721
1722        report();
1723        app.update();
1724        assert_eq!(
1725            mine(&drain_events(&mut rx)),
1726            0,
1727            "an identical re-report is deduped"
1728        );
1729
1730        app.world_mut().resource_mut::<OpApplyStats>().reset_count += 1;
1731        report();
1732        app.update();
1733        assert_eq!(
1734            mine(&drain_events(&mut rx)),
1735            1,
1736            "a hot reload clears the dedup set so warnings re-flag"
1737        );
1738    }
1739
1740    /// `warnings.ts`'s `KIND_FIELDS` must know every warning kind Rust emits,
1741    /// or that kind degrades to a broad all-style-fields value scan. Kind
1742    /// literals live at the `decode_warn` call sites (`protocol.rs`,
1743    /// `scrollbar.rs`, `animations/protocol.rs`) and the `diag::report` sites
1744    /// (`ui_map.rs`, `cursor.rs`, `filters.rs`, `layer.rs`,
1745    /// `animations/mod.rs`); extend BOTH this list and the table when
1746    /// adding one. (`length`/`angle`/`time` are deliberately table-less —
1747    /// they're the broad-scan kinds.)
1748    #[test]
1749    fn js_warning_kind_table_covers_known_kinds() {
1750        let warnings_ts = include_str!("../../../js/src/devtools/warnings.ts");
1751        for kind in [
1752            "display",
1753            "boxSizing",
1754            "positionType",
1755            "overflow",
1756            "alignItems",
1757            "justifyItems",
1758            "alignSelf",
1759            "justifySelf",
1760            "alignContent",
1761            "justifyContent",
1762            "flexDirection",
1763            "flexWrap",
1764            "gridAutoFlow",
1765            "focusPolicy",
1766            "textAlign",
1767            "lineBreak",
1768            "fontSize",
1769            "fontWeight",
1770            "rect",
1771            "gridTrack",
1772            "gridPlacement",
1773            "borderColor",
1774            "filterParams",
1775            "filterUnknown",
1776            "filterBleed",
1777            "filterBinding",
1778            "backdropFilterParams",
1779            "backdropFilterUnknown",
1780            "backdropFilterBinding",
1781            "scrollbar",
1782            "styleBinding",
1783            "color",
1784            "fontFamily",
1785            "cursor",
1786            "lineHeight",
1787            "letterSpacing",
1788            "cache",
1789        ] {
1790            assert!(
1791                warnings_ts.contains(&format!("{kind}:"))
1792                    || warnings_ts.contains(&format!("\"{kind}\":")),
1793                "js/src/devtools/warnings.ts KIND_FIELDS is missing kind \"{kind}\""
1794            );
1795        }
1796    }
1797
1798    /// Every live promotion rule has a [`reason_labels`] entry — extend this
1799    /// when a new [`crate::layer::PromotionReasons`] bit lands.
1800    #[test]
1801    fn reason_labels_cover_all_rules() {
1802        use crate::layer::PromotionReasons;
1803        let labels = |bits: u32| reason_labels(PromotionReasons(bits));
1804        assert_eq!(labels(PromotionReasons::OPACITY), ["opacity"]);
1805        assert_eq!(labels(PromotionReasons::FILTER), ["filter"]);
1806        assert_eq!(labels(PromotionReasons::TRANSFORM3D), ["transform3d"]);
1807        assert_eq!(labels(PromotionReasons::BACKDROP), ["backdrop"]);
1808        assert_eq!(labels(PromotionReasons::FORCED), ["cache"]);
1809        assert_eq!(
1810            labels(
1811                PromotionReasons::OPACITY
1812                    | PromotionReasons::FILTER
1813                    | PromotionReasons::TRANSFORM3D
1814                    | PromotionReasons::BACKDROP
1815                    | PromotionReasons::FORCED
1816            ),
1817            ["opacity", "filter", "transform3d", "backdrop", "cache"]
1818        );
1819    }
1820
1821    #[test]
1822    fn js_style_field_table_covers_every_style_field() {
1823        let fields_ts = include_str!("../../../js/src/devtools/fields.ts");
1824        macro_rules! check_fields {
1825            ($(($field:ident, $wire:literal, ($($group:tt)*), $overlay:ident)),* $(,)?) => {
1826                $(
1827                    assert!(
1828                        fields_ts.contains(concat!($wire, ":"))
1829                            || fields_ts.contains(concat!("\"", $wire, "\":")),
1830                        concat!(
1831                            "js/src/devtools/fields.ts is missing style field \"",
1832                            $wire,
1833                            "\" — add it to STYLE_FIELDS with a category"
1834                        )
1835                    );
1836                )*
1837            };
1838        }
1839        crate::protocol::with_style_fields!(check_fields);
1840    }
1841
1842    /// Build a bare world with everything `drive_pick_mode` needs (pick mode
1843    /// active). Returns the world + the outbound receiver (for asserting
1844    /// `devtools.picked`). Tests spawn their entities, then set the hover map
1845    /// with [`set_mouse_hits`].
1846    fn pick_world(pressed: bool) -> (World, UnboundedReceiver<Outbound>) {
1847        let mut world = World::new();
1848        world.insert_resource(DevtoolsState {
1849            open: true,
1850            pick: true,
1851            ..Default::default()
1852        });
1853        let mut mouse = ButtonInput::<MouseButton>::default();
1854        if pressed {
1855            mouse.press(MouseButton::Left);
1856        }
1857        world.insert_resource(mouse);
1858        let (tx, rx) = unbounded_channel::<Outbound>();
1859        world.insert_resource(OutboundResource(tx));
1860        (world, rx)
1861    }
1862
1863    /// Insert a `JsBridge` (channels kept alive, nothing reads them) mapping
1864    /// the given node ids to entities, and report `panel_root` to the state —
1865    /// the shape the JS panel produces via `devtools.panelRoot` on open.
1866    fn report_panel_root(world: &mut World, id: NodeId, panel_entity: Entity) {
1867        let (out_tx, out_rx) = unbounded_channel::<Outbound>();
1868        std::mem::forget(out_rx);
1869        let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<crate::protocol::Op>>();
1870        std::mem::forget(ops_tx);
1871        let root = world.spawn_empty().id();
1872        let mut bridge = JsBridge::new(ops_rx, out_tx, root);
1873        bridge.nodes.insert(id, panel_entity);
1874        world.insert_resource(bridge);
1875        world.resource_mut::<DevtoolsState>().panel_root = Some(id);
1876    }
1877
1878    /// Insert a Mouse `HoverMap` with the given `(entity, depth)` hits.
1879    fn set_mouse_hits(world: &mut World, hits: &[(Entity, f32)]) {
1880        use bevy::ecs::entity::EntityHashMap;
1881        use bevy::picking::backend::HitData;
1882        let mut hovered = EntityHashMap::default();
1883        for &(entity, depth) in hits {
1884            hovered.insert(entity, HitData::new(Entity::PLACEHOLDER, depth, None, None));
1885        }
1886        let mut hover_map = HoverMap::default();
1887        hover_map.insert(PointerId::Mouse, hovered);
1888        world.insert_resource(hover_map);
1889    }
1890
1891    /// A mesh-picking hit (ray distance, numerically "closer") must not shadow
1892    /// UI hits: the frontmost hit that resolves to an `RNode` wins. Regression:
1893    /// a raw `min_by(depth)` over the mixed hover map let 3D scene meshes win
1894    /// everywhere, making pick mode look dead.
1895    #[test]
1896    fn pick_ignores_mesh_hits_and_takes_frontmost_ui_node() {
1897        use bevy::ecs::system::RunSystemOnce;
1898
1899        let (mut world, _rx) = pick_world(false);
1900        let mesh = world.spawn_empty().id(); // no RNode — a scene mesh
1901        let node = world.spawn(RNode(7)).id();
1902        let leaf = world.spawn(ChildOf(node)).id(); // e.g. its text run
1903        set_mouse_hits(&mut world, &[(mesh, 0.5), (leaf, 30.0)]);
1904
1905        world.run_system_once(drive_pick_mode).unwrap();
1906        assert_eq!(
1907            world.resource::<DevtoolsState>().pick_hover,
1908            Some(7),
1909            "the frontmost RNode-resolving hit must win; mesh hits are ignored"
1910        );
1911    }
1912
1913    /// The panel can't pick itself: a frontmost hit under the REPORTED panel
1914    /// root yields no hover (and no pick-through to app nodes beneath it).
1915    #[test]
1916    fn pick_rejects_panel_hits() {
1917        use bevy::ecs::system::RunSystemOnce;
1918
1919        let (mut world, _rx) = pick_world(false);
1920        let panel_root = world.spawn((RRoot, RNode(100))).id();
1921        let panel_button = world.spawn((RNode(101), ChildOf(panel_root))).id();
1922        let app_node = world.spawn(RNode(7)).id();
1923        report_panel_root(&mut world, 100, panel_root);
1924        set_mouse_hits(&mut world, &[(panel_button, 1.0), (app_node, 5.0)]);
1925
1926        world.run_system_once(drive_pick_mode).unwrap();
1927        assert_eq!(
1928            world.resource::<DevtoolsState>().pick_hover,
1929            None,
1930            "a panel hit in front must block picking (no pick-through)"
1931        );
1932    }
1933
1934    /// Nodes under an APP `<root>` overlay are ordinary pick targets — only
1935    /// the panel's own reported root is rejected. Regression: rejecting any
1936    /// `RRoot` ancestor made every app overlay unpickable.
1937    #[test]
1938    fn pick_allows_nodes_under_app_roots() {
1939        use bevy::ecs::system::RunSystemOnce;
1940
1941        let (mut world, _rx) = pick_world(false);
1942        let panel_root = world.spawn((RRoot, RNode(100))).id();
1943        let app_root = world.spawn((RRoot, RNode(50))).id();
1944        let overlay_node = world.spawn((RNode(7), ChildOf(app_root))).id();
1945        report_panel_root(&mut world, 100, panel_root);
1946        set_mouse_hits(&mut world, &[(overlay_node, 5.0)]);
1947
1948        world.run_system_once(drive_pick_mode).unwrap();
1949        assert_eq!(
1950            world.resource::<DevtoolsState>().pick_hover,
1951            Some(7),
1952            "a node under an app <root> must be pickable"
1953        );
1954    }
1955
1956    /// The panel behind an app node doesn't block it: rejection applies only
1957    /// when the frontmost RNode-resolving hit is the panel's.
1958    #[test]
1959    fn pick_prefers_frontmost_app_hit_over_panel_behind() {
1960        use bevy::ecs::system::RunSystemOnce;
1961
1962        let (mut world, _rx) = pick_world(false);
1963        let panel_root = world.spawn((RRoot, RNode(100))).id();
1964        let panel_button = world.spawn((RNode(101), ChildOf(panel_root))).id();
1965        let app_node = world.spawn(RNode(7)).id();
1966        report_panel_root(&mut world, 100, panel_root);
1967        set_mouse_hits(&mut world, &[(panel_button, 5.0), (app_node, 1.0)]);
1968
1969        world.run_system_once(drive_pick_mode).unwrap();
1970        assert_eq!(
1971            world.resource::<DevtoolsState>().pick_hover,
1972            Some(7),
1973            "an app node in front of the panel must win"
1974        );
1975    }
1976
1977    /// A left click on a hovered app node selects it, exits pick mode, and
1978    /// reports `devtools.picked` to JS.
1979    #[test]
1980    fn pick_click_selects_and_notifies_js() {
1981        use bevy::ecs::system::RunSystemOnce;
1982
1983        let (mut world, mut rx) = pick_world(true);
1984        let app_node = world.spawn(RNode(7)).id();
1985        set_mouse_hits(&mut world, &[(app_node, 5.0)]);
1986
1987        world.run_system_once(drive_pick_mode).unwrap();
1988        let state = world.resource::<DevtoolsState>();
1989        assert_eq!(state.selected, Some(7));
1990        assert!(!state.pick, "a successful pick exits pick mode");
1991        match rx.try_recv().expect("a devtools.picked event") {
1992            Outbound::Event { name, value } => {
1993                assert_eq!(name, "devtools.picked");
1994                assert_eq!(value["id"], 7);
1995            }
1996            other => panic!("expected Outbound::Event, got {other:?}"),
1997        }
1998    }
1999
2000    /// An open panel with a dock side insets the app root's margin on that
2001    /// edge; flipping sides swaps the inset; closing releases it. No `Window`
2002    /// exists in the harness, so the width is unclamped.
2003    #[test]
2004    fn dock_reservation_insets_uiroot_margin() {
2005        let (mut app, _rx) = test_app(DevtoolsConfig::default());
2006        let root = app
2007            .world_mut()
2008            .spawn((Node::default(), crate::plugin::UiRoot))
2009            .id();
2010        let margin = |app: &mut App| {
2011            let node = app.world().entity(root).get::<Node>().unwrap();
2012            (node.margin.left, node.margin.right)
2013        };
2014
2015        {
2016            let mut state = app.world_mut().resource_mut::<DevtoolsState>();
2017            state.open = true;
2018            state.dock_side = Some(DockSide::Right);
2019            state.dock_width = 300.0;
2020        }
2021        app.update();
2022        assert_eq!(margin(&mut app), (Val::ZERO, Val::Px(300.0)));
2023
2024        app.world_mut().resource_mut::<DevtoolsState>().dock_side = Some(DockSide::Left);
2025        app.update();
2026        assert_eq!(margin(&mut app), (Val::Px(300.0), Val::ZERO));
2027
2028        // Any close path just flips `open`; the reservation releases for free.
2029        app.world_mut().resource_mut::<DevtoolsState>().open = false;
2030        app.update();
2031        assert_eq!(margin(&mut app), (Val::ZERO, Val::ZERO));
2032    }
2033
2034    /// The `devtools.dock` message maps its loose wire shape onto the state:
2035    /// known sides parse, anything else (or `None`) clears the reservation,
2036    /// and a negative width clamps to zero.
2037    #[test]
2038    fn dock_message_parses_side_and_clamps_width() {
2039        let (mut app, _rx) = test_app(DevtoolsConfig::default());
2040        let dock = |app: &mut App, side: Option<&str>, width: f32| {
2041            app.world_mut().trigger(DevtoolsDockMessage {
2042                side: side.map(String::from),
2043                width,
2044            });
2045            let state = app.world().resource::<DevtoolsState>();
2046            (state.dock_side, state.dock_width)
2047        };
2048
2049        assert_eq!(
2050            dock(&mut app, Some("left"), 320.0),
2051            (Some(DockSide::Left), 320.0)
2052        );
2053        assert_eq!(
2054            dock(&mut app, Some("right"), 280.0),
2055            (Some(DockSide::Right), 280.0)
2056        );
2057        assert_eq!(dock(&mut app, Some("bogus"), -5.0), (None, 0.0));
2058        assert_eq!(dock(&mut app, None, 380.0), (None, 380.0));
2059    }
2060
2061    /// Batch stats key off `app_applied_count`: an apply of the panel's own
2062    /// commits (only `applied_count` bumped) emits nothing, so the panel can't
2063    /// re-trigger itself; an app apply emits one event.
2064    #[test]
2065    fn batch_stats_skip_devtools_only_applies() {
2066        let (mut app, mut rx) = test_app(DevtoolsConfig {
2067            settings_path: None,
2068            ..default()
2069        });
2070        app.world_mut().resource_mut::<DevtoolsState>().open = true;
2071        let stats_events = |rx: &mut UnboundedReceiver<Outbound>| {
2072            drain_events(rx)
2073                .into_iter()
2074                .filter(|(name, _)| name == "devtools.batchStats")
2075                .count()
2076        };
2077
2078        // A devtools-only apply: the panel's own repaint. No stats.
2079        {
2080            let mut stats = app.world_mut().resource_mut::<OpApplyStats>();
2081            stats.applied_count = 1;
2082            stats.app_applied_count = 0;
2083        }
2084        app.update();
2085        assert_eq!(
2086            stats_events(&mut rx),
2087            0,
2088            "the panel's own commits must not produce batch stats"
2089        );
2090
2091        // An app apply: exactly one stats event, carrying both pre-apply legs.
2092        {
2093            let mut stats = app.world_mut().resource_mut::<OpApplyStats>();
2094            stats.applied_count = 2;
2095            stats.app_applied_count = 1;
2096        }
2097        app.update();
2098        let stats: Vec<_> = drain_events(&mut rx)
2099            .into_iter()
2100            .filter(|(name, _)| name == "devtools.batchStats")
2101            .collect();
2102        assert_eq!(stats.len(), 1, "an app apply reports once");
2103        assert!(
2104            stats[0].1.get("frame_wait_ms").is_some(),
2105            "batch stats carry the frame-wait leg"
2106        );
2107    }
2108
2109    /// A unique temp path per test; deleted on drop.
2110    struct TempSettings(std::path::PathBuf);
2111    impl TempSettings {
2112        fn new(name: &str) -> Self {
2113            Self(std::env::temp_dir().join(format!(
2114                "bevy-react-devtools-{name}-{}.json",
2115                std::process::id()
2116            )))
2117        }
2118    }
2119    impl Drop for TempSettings {
2120        fn drop(&mut self) {
2121            let _ = std::fs::remove_file(&self.0);
2122        }
2123    }
2124
2125    fn sample_settings() -> DevtoolsSettings {
2126        DevtoolsSettings {
2127            open: false,
2128            tab: "layers".into(),
2129            mode: "float".into(),
2130            width_frac: 0.4,
2131            float_x_frac: 0.05,
2132            float_y_frac: 0.1,
2133            float_w_frac: 0.5,
2134            float_h_frac: 0.6,
2135            reserve: true,
2136            overlay: false,
2137            split: 200.0,
2138        }
2139    }
2140
2141    fn drain_restores(rx: &mut UnboundedReceiver<Outbound>) -> Vec<serde_json::Value> {
2142        drain_events(rx)
2143            .into_iter()
2144            .filter(|(name, _)| name == "devtools.restore")
2145            .map(|(_, v)| v)
2146            .collect()
2147    }
2148
2149    /// A pre-existing settings file seeds the Rust-side overlay toggle at
2150    /// build, and restores to JS exactly once — after the first applied batch.
2151    #[test]
2152    fn settings_file_seeds_overlay_and_restores_once() {
2153        let tmp = TempSettings::new("restore");
2154        std::fs::write(&tmp.0, serde_json::to_string(&sample_settings()).unwrap()).unwrap();
2155        let (mut app, mut rx) = test_app(DevtoolsConfig {
2156            settings_path: Some(tmp.0.clone()),
2157            ..default()
2158        });
2159
2160        assert!(
2161            !app.world()
2162                .resource::<DevtoolsState>()
2163                .show_selection_overlay,
2164            "the loaded overlay=false must seed the state at build"
2165        );
2166
2167        app.update();
2168        assert!(
2169            drain_restores(&mut rx).is_empty(),
2170            "no restore before the React app mounted"
2171        );
2172
2173        app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
2174        app.update();
2175        let restores = drain_restores(&mut rx);
2176        assert_eq!(restores.len(), 1, "exactly one restore after mount");
2177        assert_eq!(restores[0]["mode"], "float");
2178        assert_eq!(restores[0]["split"], 200.0);
2179        assert_eq!(restores[0]["overlay"], false);
2180
2181        app.update();
2182        assert!(drain_restores(&mut rx).is_empty(), "restore is one-shot");
2183    }
2184
2185    /// A `devtools.settings` message round-trips to the file once the debounce
2186    /// elapses (zeroed here), and identical settings don't rewrite.
2187    #[test]
2188    fn settings_message_writes_file_debounced() {
2189        let tmp = TempSettings::new("save");
2190        let (mut app, _rx) = test_app(DevtoolsConfig {
2191            settings_path: Some(tmp.0.clone()),
2192            ..default()
2193        });
2194        app.world_mut()
2195            .resource_mut::<DevtoolsPersistence>()
2196            .debounce = Duration::ZERO;
2197
2198        app.world_mut().trigger(sample_settings());
2199        app.update();
2200
2201        let written: DevtoolsSettings =
2202            serde_json::from_str(&std::fs::read_to_string(&tmp.0).unwrap()).unwrap();
2203        assert!(written == sample_settings(), "full round-trip");
2204
2205        // An identical re-send must not rewrite (mtime unchanged).
2206        let mtime = |p: &std::path::Path| std::fs::metadata(p).unwrap().modified().unwrap();
2207        let before = mtime(&tmp.0);
2208        app.world_mut().trigger(sample_settings());
2209        app.update();
2210        assert_eq!(mtime(&tmp.0), before, "identical settings skip the write");
2211    }
2212
2213    /// `AppExit` flushes a still-debouncing change immediately (`Last` runs on
2214    /// the exit frame), so a drag right before quitting isn't lost.
2215    #[test]
2216    fn settings_flush_on_app_exit() {
2217        let tmp = TempSettings::new("flush");
2218        let (mut app, _rx) = test_app(DevtoolsConfig {
2219            settings_path: Some(tmp.0.clone()),
2220            ..default()
2221        });
2222        // Default 1s debounce: a normal update must NOT write yet.
2223        app.world_mut().trigger(sample_settings());
2224        app.update();
2225        assert!(!tmp.0.exists(), "still inside the debounce window");
2226
2227        app.world_mut().write_message(AppExit::Success);
2228        app.update();
2229        assert!(tmp.0.exists(), "AppExit must flush the pending settings");
2230    }
2231
2232    /// Corrupt files mean fresh defaults — but STILL exactly one restore (the
2233    /// JS recorder disarms on it; corrupt ≡ missing ≡ defaults).
2234    /// `no_settings_file()` disables writing entirely.
2235    #[test]
2236    fn corrupt_or_disabled_settings_are_ignored() {
2237        let tmp = TempSettings::new("corrupt");
2238        std::fs::write(&tmp.0, "{ not json").unwrap();
2239        let (mut app, mut rx) = test_app(DevtoolsConfig {
2240            settings_path: Some(tmp.0.clone()),
2241            ..default()
2242        });
2243        assert!(
2244            app.world()
2245                .resource::<DevtoolsState>()
2246                .show_selection_overlay
2247        );
2248        app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
2249        app.update();
2250        let restores = drain_restores(&mut rx);
2251        assert_eq!(restores.len(), 1, "corrupt file → one restore, defaults");
2252        assert_eq!(restores[0]["mode"], "right");
2253        assert_eq!(restores[0]["open"], false);
2254        assert!(!app.world().resource::<DevtoolsState>().open);
2255        // Release the first app (and its diag test lock — see `test_app`)
2256        // before building the second, or the lock self-deadlocks.
2257        drop(app);
2258
2259        let (mut app, _rx) = test_app(DevtoolsConfig {
2260            settings_path: None,
2261            ..default()
2262        });
2263        app.world_mut()
2264            .resource_mut::<DevtoolsPersistence>()
2265            .debounce = Duration::ZERO;
2266        app.world_mut().trigger(sample_settings());
2267        app.update(); // must not panic / write anywhere
2268    }
2269
2270    /// With no settings file at all, the restore (with defaults) is still sent
2271    /// exactly once after the first applied batch — the JS recorder's disarm
2272    /// signal must never be skipped.
2273    #[test]
2274    fn restore_defaults_sent_without_settings_file() {
2275        let (mut app, mut rx) = test_app(DevtoolsConfig {
2276            settings_path: None,
2277            ..default()
2278        });
2279        app.update();
2280        assert!(drain_restores(&mut rx).is_empty(), "not before mount");
2281
2282        app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
2283        app.update();
2284        let restores = drain_restores(&mut rx);
2285        assert_eq!(restores.len(), 1, "defaults restore exactly once");
2286        assert_eq!(restores[0]["open"], false);
2287        assert_eq!(restores[0]["mode"], "right");
2288
2289        app.update();
2290        assert!(drain_restores(&mut rx).is_empty(), "one-shot");
2291    }
2292
2293    /// A pre-fraction (pixel-unit) settings file still loads: the shared keys
2294    /// (`mode`/`reserve`/`overlay`/`split`) restore, the stale pixel fields are
2295    /// ignored as unknown keys, and the fraction fields take defaults.
2296    #[test]
2297    fn legacy_pixel_settings_file_migrates() {
2298        let tmp = TempSettings::new("legacy");
2299        let legacy = serde_json::json!({
2300            "mode": "float",
2301            "width": 420.0,
2302            "float_x": 10.0,
2303            "float_y": 20.0,
2304            "float_w": 500.0,
2305            "float_h": 600.0,
2306            "reserve": true,
2307            "overlay": false,
2308            "split": 200.0,
2309        });
2310        std::fs::write(&tmp.0, legacy.to_string()).unwrap();
2311        let (mut app, mut rx) = test_app(DevtoolsConfig {
2312            settings_path: Some(tmp.0.clone()),
2313            ..default()
2314        });
2315        app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
2316        app.update();
2317        let restores = drain_restores(&mut rx);
2318        assert_eq!(restores.len(), 1, "a legacy file must still restore");
2319        assert_eq!(restores[0]["mode"], "float");
2320        assert_eq!(restores[0]["overlay"], false);
2321        assert_eq!(restores[0]["split"], 200.0);
2322        assert_eq!(restores[0]["open"], false, "no persisted open → closed");
2323        assert_eq!(restores[0]["tab"], "nodes", "no persisted tab → default");
2324        let frac = restores[0]["width_frac"].as_f64().expect("a number");
2325        assert!(
2326            (frac - f64::from(DevtoolsSettings::default().width_frac)).abs() < 1e-6,
2327            "stale pixel width is ignored; the fraction takes its default"
2328        );
2329    }
2330
2331    /// The window's logical size streams to the panel: once on open, again on
2332    /// every change while open, and re-sent after a close → reopen (a resize
2333    /// while closed must be caught up).
2334    #[test]
2335    fn window_size_sent_on_open_and_resize() {
2336        use bevy::window::WindowResolution;
2337
2338        let (mut app, mut rx) = test_app(DevtoolsConfig {
2339            settings_path: None,
2340            ..default()
2341        });
2342        let window = app
2343            .world_mut()
2344            .spawn(Window {
2345                resolution: WindowResolution::new(800, 600),
2346                ..Default::default()
2347            })
2348            .id();
2349        let sizes = |rx: &mut UnboundedReceiver<Outbound>| {
2350            drain_events(rx)
2351                .into_iter()
2352                .filter(|(name, _)| name == "devtools.window")
2353                .map(|(_, v)| (v["width"].as_f64().unwrap(), v["height"].as_f64().unwrap()))
2354                .collect::<Vec<_>>()
2355        };
2356
2357        // Closed: nothing, even after mount (send_restore fires one — drain it).
2358        app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
2359        app.update();
2360        let restore_frame = sizes(&mut rx);
2361        assert_eq!(
2362            restore_frame,
2363            vec![(800.0, 600.0)],
2364            "the restore one-shot sends the size once, ahead of the blob"
2365        );
2366
2367        // Open: one size event; idle frames send nothing more.
2368        app.world_mut().resource_mut::<DevtoolsState>().open = true;
2369        app.update();
2370        assert_eq!(sizes(&mut rx), vec![(800.0, 600.0)], "sent on open");
2371        app.update();
2372        assert!(sizes(&mut rx).is_empty(), "idle frames are silent");
2373
2374        // Resize while open: exactly one update.
2375        app.world_mut()
2376            .entity_mut(window)
2377            .get_mut::<Window>()
2378            .unwrap()
2379            .resolution = WindowResolution::new(1024, 768);
2380        app.update();
2381        assert_eq!(sizes(&mut rx), vec![(1024.0, 768.0)], "sent on resize");
2382
2383        // Resize while closed → reopen catches up.
2384        app.world_mut().resource_mut::<DevtoolsState>().open = false;
2385        app.update();
2386        app.world_mut()
2387            .entity_mut(window)
2388            .get_mut::<Window>()
2389            .unwrap()
2390            .resolution = WindowResolution::new(640, 480);
2391        app.update();
2392        assert!(sizes(&mut rx).is_empty(), "closed: no size traffic");
2393        app.world_mut().resource_mut::<DevtoolsState>().open = true;
2394        app.update();
2395        assert_eq!(
2396            sizes(&mut rx),
2397            vec![(640.0, 480.0)],
2398            "reopen must catch up on a resize that happened while closed"
2399        );
2400    }
2401
2402    /// The layer stream is gated on `open && layers_tab_open`, carries the
2403    /// synthesized base row plus every registry row (logical rect + physical
2404    /// dims), stays silent while nothing changes, re-emits on geometry
2405    /// changes, keeps inactive layers listed with a null rect, and resends
2406    /// the full payload after the tab is re-shown (the `Local` reset).
2407    #[test]
2408    fn layers_emit_gated_and_diffed() {
2409        use crate::layer::{LayerMeta, LayersRegistry, PromotionReasons};
2410        use bevy::window::WindowResolution;
2411
2412        let (mut app, mut rx) = test_app(DevtoolsConfig {
2413            settings_path: None,
2414            ..default()
2415        });
2416        app.world_mut().spawn(Window {
2417            resolution: WindowResolution::new(800, 600),
2418            ..Default::default()
2419        });
2420        let entity = app.world_mut().spawn(RNode(7)).id();
2421        app.world_mut()
2422            .resource_mut::<LayersRegistry>()
2423            .layers
2424            .insert(
2425                7,
2426                LayerMeta {
2427                    node: 7,
2428                    entity,
2429                    reasons: PromotionReasons(PromotionReasons::OPACITY),
2430                    group_alpha: 0.5,
2431                    capture_rect: Some(IRect::new(10, 10, 110, 60)),
2432                    depth: 1,
2433                    repaints: 0,
2434                    cached: false,
2435                    cache_policy: Default::default(),
2436                },
2437            );
2438        let payloads = |rx: &mut UnboundedReceiver<Outbound>| {
2439            drain_events(rx)
2440                .into_iter()
2441                .filter(|(name, _)| name == "devtools.layers")
2442                .map(|(_, v)| v)
2443                .collect::<Vec<_>>()
2444        };
2445
2446        app.update();
2447        assert!(
2448            payloads(&mut rx).is_empty(),
2449            "closed panel: no layer stream"
2450        );
2451
2452        {
2453            let mut state = app.world_mut().resource_mut::<DevtoolsState>();
2454            state.open = true;
2455            state.layers_tab_open = true;
2456        }
2457        app.update();
2458        let sent = payloads(&mut rx);
2459        assert_eq!(sent.len(), 1, "opening the tab sends one payload");
2460        let layers = sent[0]["layers"].as_array().expect("a layers array");
2461        assert_eq!(layers.len(), 2, "base row + one registry row");
2462        assert_eq!(layers[0]["id"], 0);
2463        assert_eq!(layers[0]["reasons"], serde_json::json!(["base"]));
2464        assert_eq!(layers[0]["depth"], 0);
2465        assert_eq!(layers[0]["rect"]["width"], 800.0);
2466        assert_eq!(layers[0]["rect"]["physical_height"], 600);
2467        assert_eq!(layers[1]["id"], 7);
2468        assert_eq!(layers[1]["reasons"], serde_json::json!(["opacity"]));
2469        assert_eq!(layers[1]["depth"], 1);
2470        assert_eq!(layers[1]["node_count"], 0, "empty membership map");
2471        assert_eq!(layers[1]["rect"]["x"], 10.0);
2472        assert_eq!(layers[1]["rect"]["y"], 10.0);
2473        assert_eq!(layers[1]["rect"]["width"], 100.0);
2474        assert_eq!(layers[1]["rect"]["height"], 50.0);
2475        assert_eq!(layers[1]["rect"]["physical_width"], 100);
2476        assert_eq!(
2477            layers[1]["filters"],
2478            serde_json::json!([]),
2479            "no resolved chain on the entity: empty filters"
2480        );
2481
2482        app.update();
2483        assert!(payloads(&mut rx).is_empty(), "idle frames are silent");
2484
2485        // A geometry change re-emits once — with a NEGATIVE min (filter
2486        // outset near the viewport edge): the signed registry rect must
2487        // report it truthfully, not clamp to 0.
2488        app.world_mut()
2489            .resource_mut::<LayersRegistry>()
2490            .layers
2491            .get_mut(&7)
2492            .unwrap()
2493            .capture_rect = Some(IRect::new(-20, -10, 80, 40));
2494        app.update();
2495        let sent = payloads(&mut rx);
2496        assert_eq!(sent.len(), 1, "a rect change re-emits");
2497        assert_eq!(sent[0]["layers"][1]["rect"]["x"], -20.0);
2498        assert_eq!(sent[0]["layers"][1]["rect"]["y"], -10.0);
2499        assert_eq!(sent[0]["layers"][1]["rect"]["width"], 100.0);
2500        assert_eq!(sent[0]["layers"][1]["rect"]["physical_height"], 50);
2501
2502        // Inactive: still listed, rect null.
2503        app.world_mut()
2504            .resource_mut::<LayersRegistry>()
2505            .layers
2506            .get_mut(&7)
2507            .unwrap()
2508            .capture_rect = None;
2509        app.update();
2510        let sent = payloads(&mut rx);
2511        assert_eq!(sent.len(), 1);
2512        assert_eq!(sent[0]["layers"].as_array().unwrap().len(), 2);
2513        assert!(
2514            sent[0]["layers"][1]["rect"].is_null(),
2515            "inactive layers stay listed with a null rect"
2516        );
2517
2518        // Hiding the tab silences the stream even while the registry mutates.
2519        app.world_mut()
2520            .resource_mut::<DevtoolsState>()
2521            .layers_tab_open = false;
2522        app.world_mut()
2523            .resource_mut::<LayersRegistry>()
2524            .layers
2525            .get_mut(&7)
2526            .unwrap()
2527            .capture_rect = Some(IRect::new(10, 10, 110, 60));
2528        app.update();
2529        assert!(payloads(&mut rx).is_empty(), "hidden tab: silence");
2530
2531        // Re-showing the tab resends the full payload (Local reset), even
2532        // though it equals a previously-sent one.
2533        app.world_mut()
2534            .resource_mut::<DevtoolsState>()
2535            .layers_tab_open = true;
2536        app.update();
2537        assert_eq!(
2538            payloads(&mut rx).len(),
2539            1,
2540            "a re-shown tab gets a fresh full payload"
2541        );
2542    }
2543
2544    /// A filtered layer's row carries its resolved chain: one entry per WIRE
2545    /// filter (blur's H+V passes group into one), names joined from the
2546    /// `FilterInput` mirror by `wire_index` (with the defensive `#<i>`
2547    /// fallback when the mirror is short), params unpacked per layout slot
2548    /// into display units — angles in degrees, `Length` slots back to
2549    /// logical px (they are stored physical; here `scale: 2`), colors as 4
2550    /// components — and rounded to 3 decimals. The rounding doubles as the
2551    /// stream's rate limiter: a sub-0.001 display-value wiggle must NOT
2552    /// re-emit, a visible change must.
2553    #[test]
2554    fn layers_emit_resolved_filter_chain() {
2555        use crate::animations::ValueKind;
2556        use crate::filters::{
2557            FilterChain, FilterInput, FilterUse, ParamSlot, ResolvedFilterChain, ResolvedFilterPass,
2558        };
2559        use crate::layer::{LayerMeta, LayersRegistry, PromotionReasons};
2560        use bevy::window::WindowResolution;
2561        use std::sync::Arc;
2562
2563        let slot =
2564            |name: &'static str, kind: ValueKind, vec: usize, comp: usize, len: usize| ParamSlot {
2565                name,
2566                kind,
2567                vec,
2568                comp,
2569                len,
2570            };
2571        let blur_layout: Arc<[ParamSlot]> =
2572            Arc::from(vec![slot("radius", ValueKind::Length, 0, 0, 1)]);
2573        // Physical radius 8.5 at scale 2 → logical 4.25. The direction
2574        // components (y/z) are unnamed in the layout, so they never display.
2575        let blur_pass = |dir: (f32, f32)| ResolvedFilterPass {
2576            shader: Handle::default(),
2577            params: vec![Vec4::new(8.5, dir.0, dir.1, 0.0)],
2578            layout: blur_layout.clone(),
2579            wire_index: 0,
2580        };
2581        let hue_pass = ResolvedFilterPass {
2582            shader: Handle::default(),
2583            params: vec![
2584                Vec4::new(std::f32::consts::FRAC_PI_2, 0.0, 0.0, 0.0),
2585                Vec4::new(1.0, 0.5, 0.0, 1.0),
2586            ],
2587            layout: Arc::from(vec![
2588                slot("angle", ValueKind::Angle, 0, 0, 1),
2589                slot("tint", ValueKind::Color, 1, 0, 4),
2590            ]),
2591            wire_index: 1,
2592        };
2593        // 4.24999 pins the rounding: → 4.25 on the wire.
2594        let sepia_pass = ResolvedFilterPass {
2595            shader: Handle::default(),
2596            params: vec![Vec4::new(4.249_99, 0.0, 0.0, 0.0)],
2597            layout: Arc::from(vec![slot("amount", ValueKind::Scalar, 0, 0, 1)]),
2598            wire_index: 2,
2599        };
2600        // wire_index 3 has NO FilterInput entry → the `#3` fallback. 0.4 also
2601        // pins the f64 rounding path: 0.4f32 widened naively is
2602        // 0.4000000059604645 on the JSON wire — it must arrive as 0.4.
2603        let orphan_pass = ResolvedFilterPass {
2604            shader: Handle::default(),
2605            params: vec![Vec4::new(0.4, 0.0, 0.0, 0.0)],
2606            layout: Arc::from(vec![slot("x", ValueKind::Scalar, 0, 0, 1)]),
2607            wire_index: 3,
2608        };
2609        let wire = |name: &str| FilterUse {
2610            name: name.to_string(),
2611            params: serde_json::Map::new(),
2612        };
2613
2614        let (mut app, mut rx) = test_app(DevtoolsConfig {
2615            settings_path: None,
2616            ..default()
2617        });
2618        app.world_mut().spawn(Window {
2619            resolution: WindowResolution::new(800, 600),
2620            ..Default::default()
2621        });
2622        let entity = app
2623            .world_mut()
2624            .spawn((
2625                RNode(7),
2626                ResolvedFilterChain {
2627                    passes: vec![
2628                        blur_pass((1.0, 0.0)),
2629                        blur_pass((0.0, 1.0)),
2630                        hue_pass,
2631                        sepia_pass,
2632                        orphan_pass,
2633                    ],
2634                    outset_px: 26,
2635                    always_dirty: false,
2636                    version: 1,
2637                    scale: 2.0,
2638                },
2639                FilterInput(FilterChain(vec![
2640                    wire("blur"),
2641                    wire("hueRotate"),
2642                    wire("sepia"),
2643                ])),
2644            ))
2645            .id();
2646        app.world_mut()
2647            .resource_mut::<LayersRegistry>()
2648            .layers
2649            .insert(
2650                7,
2651                LayerMeta {
2652                    node: 7,
2653                    entity,
2654                    reasons: PromotionReasons(PromotionReasons::FILTER),
2655                    group_alpha: 1.0,
2656                    capture_rect: Some(IRect::new(10, 10, 110, 60)),
2657                    depth: 1,
2658                    repaints: 0,
2659                    cached: false,
2660                    cache_policy: Default::default(),
2661                },
2662            );
2663        {
2664            let mut state = app.world_mut().resource_mut::<DevtoolsState>();
2665            state.open = true;
2666            state.layers_tab_open = true;
2667        }
2668        let payloads = |rx: &mut UnboundedReceiver<Outbound>| {
2669            drain_events(rx)
2670                .into_iter()
2671                .filter(|(name, _)| name == "devtools.layers")
2672                .map(|(_, v)| v)
2673                .collect::<Vec<_>>()
2674        };
2675
2676        app.update();
2677        let sent = payloads(&mut rx);
2678        assert_eq!(sent.len(), 1);
2679        assert_eq!(
2680            sent[0]["layers"][1]["filters"],
2681            serde_json::json!([
2682                { "name": "blur", "params": [["radius", [4.25]]] },
2683                { "name": "hueRotate",
2684                  "params": [["angle", [90.0]], ["tint", [1.0, 0.5, 0.0, 1.0]]] },
2685                { "name": "sepia", "params": [["amount", [4.25]]] },
2686                { "name": "#3", "params": [["x", [0.4]]] },
2687            ]),
2688            "wire-grouped chain with display-unit, rounded params"
2689        );
2690
2691        // Sub-rounding noise: physical 8.5008 → logical 4.2504 → rounds to
2692        // the same 4.25 → the diff gate stays quiet.
2693        app.world_mut()
2694            .entity_mut(entity)
2695            .get_mut::<ResolvedFilterChain>()
2696            .unwrap()
2697            .passes[0]
2698            .params[0]
2699            .x = 8.5008;
2700        app.update();
2701        assert!(
2702            payloads(&mut rx).is_empty(),
2703            "sub-0.001 display noise must not re-emit (rounding is the rate limiter)"
2704        );
2705
2706        // A visible change re-emits with the live value: physical 9 →
2707        // logical 4.5 (dyadic on purpose — exact through the f32→JSON path).
2708        app.world_mut()
2709            .entity_mut(entity)
2710            .get_mut::<ResolvedFilterChain>()
2711            .unwrap()
2712            .passes[0]
2713            .params[0]
2714            .x = 9.0;
2715        app.update();
2716        let sent = payloads(&mut rx);
2717        assert_eq!(sent.len(), 1, "a visible param change re-emits");
2718        assert_eq!(
2719            sent[0]["layers"][1]["filters"][0]["params"][0],
2720            serde_json::json!(["radius", [4.5]]),
2721            "live value in display units"
2722        );
2723    }
2724
2725    /// The `devtools.layersOpen`/`devtools.consoleOpen` messages flip their
2726    /// state flags, and every panel-close path clears them (via
2727    /// `exit_interactions`) so a lost unmount message can't leave a stream
2728    /// armed.
2729    #[test]
2730    fn layers_open_message_flips_state_and_close_clears_it() {
2731        let (mut app, _rx) = test_app(DevtoolsConfig {
2732            settings_path: None,
2733            ..default()
2734        });
2735        app.world_mut()
2736            .trigger(DevtoolsLayersOpenMessage { on: true });
2737        app.world_mut()
2738            .trigger(DevtoolsConsoleOpenMessage { on: true });
2739        {
2740            let state = app.world().resource::<DevtoolsState>();
2741            assert!(state.layers_tab_open);
2742            assert!(state.console_tab_open);
2743        }
2744        // Simulate a streamed watermark, then close.
2745        app.world_mut()
2746            .resource_mut::<DevtoolsState>()
2747            .console_last_seq = Some(7);
2748
2749        app.world_mut().trigger(DevtoolsOpenMessage { open: false });
2750        let state = app.world().resource::<DevtoolsState>();
2751        assert!(!state.open);
2752        assert!(
2753            !state.layers_tab_open,
2754            "closing the panel must kill the layer stream"
2755        );
2756        assert!(
2757            !state.console_tab_open,
2758            "closing the panel must kill the console stream"
2759        );
2760        assert_eq!(
2761            state.console_last_seq, None,
2762            "the console watermark must reset so a reopen resends the backlog"
2763        );
2764    }
2765
2766    /// The console stream: silent while closed; full backlog on tab open;
2767    /// increments only afterwards; backlog resent after an off→on flip (the
2768    /// handler resets the watermark). Global-ring discipline: `test_app`
2769    /// holds the diag test lock; assertions filter by unique markers and
2770    /// never assume the ring holds ONLY our entries.
2771    #[test]
2772    fn console_stream_backlog_then_increment() {
2773        use crate::console_log::{Level, Source};
2774
2775        let (mut app, mut rx) = test_app(DevtoolsConfig {
2776            settings_path: None,
2777            ..default()
2778        });
2779        let mine = |v: &serde_json::Value| -> Vec<String> {
2780            v["entries"]
2781                .as_array()
2782                .unwrap()
2783                .iter()
2784                .filter_map(|e| e["message"].as_str())
2785                .filter(|m| m.contains("cons-t1-"))
2786                .map(String::from)
2787                .collect()
2788        };
2789        let payloads = |rx: &mut UnboundedReceiver<Outbound>| {
2790            drain_events(rx)
2791                .into_iter()
2792                .filter(|(name, _)| name == "devtools.console")
2793                .map(|(_, v)| v)
2794                .collect::<Vec<_>>()
2795        };
2796
2797        crate::console_log::push(Source::Rust, Level::Warn, "cons-t1-a");
2798        crate::console_log::push(Source::Js, Level::Error, "cons-t1-b");
2799        app.update();
2800        assert!(
2801            payloads(&mut rx).iter().all(|v| mine(v).is_empty()),
2802            "closed panel: no console stream"
2803        );
2804
2805        app.world_mut().resource_mut::<DevtoolsState>().open = true;
2806        app.world_mut()
2807            .trigger(DevtoolsConsoleOpenMessage { on: true });
2808        app.update();
2809        let sent = payloads(&mut rx);
2810        let marked: Vec<String> = sent.iter().flat_map(&mine).collect();
2811        assert_eq!(
2812            marked,
2813            vec!["cons-t1-a".to_string(), "cons-t1-b".to_string()],
2814            "tab open sends the backlog, oldest first"
2815        );
2816        // Field shape spot-check on our own entry.
2817        let entry = sent
2818            .iter()
2819            .flat_map(|v| v["entries"].as_array().unwrap().clone())
2820            .find(|e| e["message"] == "cons-t1-b")
2821            .unwrap();
2822        assert_eq!(entry["source"], "js");
2823        assert_eq!(entry["level"], "error");
2824        assert!(entry["seq"].as_u64().is_some());
2825        assert!(entry["time_ms"].as_u64().is_some());
2826
2827        crate::console_log::push(Source::Js, Level::Info, "cons-t1-c");
2828        app.update();
2829        let marked: Vec<String> = payloads(&mut rx).iter().flat_map(&mine).collect();
2830        assert_eq!(
2831            marked,
2832            vec!["cons-t1-c".to_string()],
2833            "later frames send only new entries"
2834        );
2835
2836        // Off → on: the handler resets the watermark, so the full backlog
2837        // (all three markers) is resent.
2838        app.world_mut()
2839            .trigger(DevtoolsConsoleOpenMessage { on: false });
2840        app.update();
2841        assert!(payloads(&mut rx).iter().all(|v| mine(v).is_empty()));
2842        app.world_mut()
2843            .trigger(DevtoolsConsoleOpenMessage { on: true });
2844        app.update();
2845        let marked: Vec<String> = payloads(&mut rx).iter().flat_map(mine).collect();
2846        assert_eq!(
2847            marked,
2848            vec![
2849                "cons-t1-a".to_string(),
2850                "cons-t1-b".to_string(),
2851                "cons-t1-c".to_string()
2852            ],
2853            "a re-shown tab gets the full backlog again"
2854        );
2855    }
2856
2857    /// `devtools.consoleClear` empties the ring (seq keeps counting).
2858    #[test]
2859    fn console_clear_message_empties_ring() {
2860        use crate::console_log::{Level, Source};
2861
2862        let (mut app, _rx) = test_app(DevtoolsConfig {
2863            settings_path: None,
2864            ..default()
2865        });
2866        crate::console_log::push(Source::Js, Level::Info, "cons-t2-before");
2867        let (_, watermark_before) = crate::console_log::since(0);
2868        app.world_mut().trigger(DevtoolsConsoleClearMessage {});
2869        let (entries, _) = crate::console_log::since(0);
2870        assert!(
2871            entries.iter().all(|e| !e.message.contains("cons-t2-")),
2872            "clear must drop our entry"
2873        );
2874        crate::console_log::push(Source::Js, Level::Info, "cons-t2-after");
2875        let (entries, _) = crate::console_log::since(0);
2876        let after = entries
2877            .iter()
2878            .find(|e| e.message == "cons-t2-after")
2879            .expect("post-clear pushes land");
2880        assert!(
2881            after.seq > watermark_before,
2882            "seq keeps counting across a clear"
2883        );
2884    }
2885
2886    #[test]
2887    fn highlight_rect_converts_physical_center_to_logical_top_left() {
2888        // A 200×100 physical node centered at (300, 150) on a 2× display.
2889        let (pos, size) = highlight_rect(Vec2::new(200.0, 100.0), Vec2::new(300.0, 150.0), 0.5);
2890        assert_eq!(pos, Vec2::new(100.0, 50.0));
2891        assert_eq!(size, Vec2::new(100.0, 50.0));
2892    }
2893
2894    #[test]
2895    fn split_legs_computes_command_and_layout() {
2896        let t0 = std::time::Instant::now();
2897        let t1 = t0 + Duration::from_millis(5);
2898        let t2 = t1 + Duration::from_millis(7);
2899        assert_eq!(
2900            split_legs(t0, t1, t2),
2901            (Duration::from_millis(5), Duration::from_millis(7))
2902        );
2903        // Out-of-order instants (system jitter) clamp to zero, never panic.
2904        assert_eq!(split_legs(t1, t0, t2), (Duration::ZERO, t2 - t0));
2905    }
2906}