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//! - JS → Bevy messages: `devtools.open { open }`, `devtools.pick { on }`,
32//!   `devtools.select { id }`, `devtools.highlight { id }`,
33//!   `devtools.overlay { on }`, `devtools.panelRoot { id }`,
34//!   `devtools.dock { side, width }` (the panel's space reservation — see
35//!   [`apply_dock_reservation`]), `devtools.settings { … }` (the persisted
36//!   layout blob — see [`DevtoolsSettings`]).
37//! - Settings persistence: layout settings — including whether the panel was
38//!   open, so it reopens where you left it — round-trip through a JSON file
39//!   (default `.bevy-react-devtools.json` in the working directory —
40//!   [`DevtoolsConfig::settings_path`]).
41//!   The blob returns to the panel exactly once via `devtools.restore` —
42//!   **always**, with defaults when there is no (or a corrupt) file: the JS
43//!   recorder arms itself at install to capture the app's initial mount and
44//!   relies on that one deterministic signal to disarm when the panel is
45//!   staying closed (see `js/src/devtools/recorder.ts`).
46//!
47//! Render-time legs mirror the stress harness (`examples/stress/table_ops.rs`):
48//! `translate` (op → command queuing, from [`OpApplyStats`]), `command` (command
49//! execution + UI prepare/content), `layout` (taffy solve + post-layout
50//! propagation), bracketed around `UiSystems::Layout` in `PostUpdate`.
51
52use bevy::picking::hover::HoverMap;
53use bevy::picking::pointer::PointerId;
54use bevy::platform::collections::HashSet;
55use bevy::prelude::*;
56use bevy::ui::{ComputedNode, IsDefaultUiCamera, UiGlobalTransform, UiSystems};
57use std::time::Duration;
58
59use crate::bridge::{JsBridge, RNode};
60use crate::event::ReactEvents;
61use crate::message::ReactAppExt;
62use crate::plugin::PointerCapture;
63use crate::protocol::NodeId;
64use crate::reconcile::{OpApplyStats, climb};
65use crate::window::ui_viewport_size;
66use crate::{react_event, react_message};
67
68/// Devtools configuration, passed to
69/// [`ReactUiPlugin::devtools`](crate::ReactUiPlugin::devtools). Every field
70/// has a default (`DevtoolsConfig::default()` is exactly what an app gets
71/// without calling `.devtools(...)` at all), so construct it with
72/// struct-update syntax:
73///
74/// ```no_run
75/// # use bevy::prelude::*;
76/// # use bevy_react::{DevtoolsConfig, ReactUiPlugin};
77/// # let mut app = App::new();
78/// app.add_plugins(ReactUiPlugin::new("ui/dist/app.js").devtools(DevtoolsConfig {
79///     settings_path: Some(".config/devtools.json".into()),
80///     ..default()
81/// }));
82/// ```
83///
84/// Also a resource, so the toggle/persistence systems can read it.
85#[derive(Resource, Clone)]
86pub struct DevtoolsConfig {
87    /// Whether the devtools are available at all. Default: `true` (dev builds
88    /// only either way — release builds never run them).
89    pub enabled: bool,
90    /// The key that toggles the panel. Default: `F12`.
91    pub toggle_key: KeyCode,
92    /// Where the panel's layout settings (dock mode/width, float rect, the
93    /// reserve and overlay toggles, tree/inspector split, whether the panel
94    /// is open) persist across runs; `None` disables persistence. Default:
95    /// `.bevy-react-devtools.json` in the working directory. Native only —
96    /// on web the file is neither read nor written.
97    pub settings_path: Option<std::path::PathBuf>,
98}
99
100impl Default for DevtoolsConfig {
101    fn default() -> Self {
102        Self {
103            enabled: true,
104            toggle_key: KeyCode::F12,
105            settings_path: Some(std::path::PathBuf::from(".bevy-react-devtools.json")),
106        }
107    }
108}
109
110/// The Bevy side of the devtools inspector. See the [module docs](self).
111///
112/// Crate-internal: [`ReactUiPlugin`](crate::ReactUiPlugin) auto-registers it,
113/// built from the consumer's [`DevtoolsConfig`].
114pub struct DevtoolsPlugin {
115    config: DevtoolsConfig,
116}
117
118impl DevtoolsPlugin {
119    pub fn new(config: DevtoolsConfig) -> Self {
120        Self { config }
121    }
122}
123
124impl Plugin for DevtoolsPlugin {
125    fn build(&self, app: &mut App) {
126        // "Dev build only": the feature is a default feature, so this is the
127        // expected path for every consumer `--release` build — the plugin
128        // registers nothing. `debug!`, not `warn!`: release logs stay clean.
129        if !cfg!(debug_assertions) {
130            debug!("DevtoolsPlugin is inert in release builds");
131            return;
132        }
133        // The toggle/pick systems read `ButtonInput` resources; a headless app
134        // without `InputPlugin` (wiring-only tests) must not panic on them.
135        app.init_resource::<ButtonInput<KeyCode>>();
136        app.init_resource::<ButtonInput<MouseButton>>();
137        // Start collecting apply-time invalid-value warnings (see
138        // `crate::diag`): armed for the app's whole lifetime, panel open or
139        // not, so warnings from the initial mount are waiting when it opens.
140        crate::diag::arm_runtime();
141        // Load persisted panel settings (native only; errors — missing file,
142        // corrupt JSON — mean fresh defaults). The overlay toggle seeds the
143        // Rust-side state immediately so highlight gating is correct before
144        // the JS panel wakes; the rest restores to JS via `send_restore`.
145        let loaded = load_settings(self.config.settings_path.as_deref());
146        app.insert_resource(DevtoolsState {
147            show_selection_overlay: loaded.as_ref().is_none_or(|s| s.overlay),
148            ..Default::default()
149        })
150        .insert_resource(DevtoolsPersistence {
151            last_written: loaded.clone(),
152            loaded,
153            pending: None,
154            #[cfg(not(target_arch = "wasm32"))]
155            dirty_at: None,
156            debounce: Duration::from_secs(1),
157        })
158        .init_resource::<DevtoolsTimers>()
159        .insert_resource(self.config.clone())
160        // Panel → Bevy state sync. Registration is what routes the emits;
161        // none of this reaches an app's generated `bevy.ts` because the
162        // bindings exporter never adds this plugin.
163        .add_react_handler(on_open_message)
164        .add_react_handler(on_pick_message)
165        .add_react_handler(on_select_message)
166        .add_react_handler(on_highlight_message)
167        .add_react_handler(on_overlay_message)
168        .add_react_handler(on_panel_root_message)
169        .add_react_handler(on_dock_message)
170        .add_react_handler(on_settings_message)
171        // Registered in the plugin's OWN tuples — `plugin.rs`'s Update tuple
172        // sits at Bevy's 20-arity cap.
173        .add_systems(Startup, spawn_highlight_overlay)
174        .add_systems(
175            Update,
176            (
177                toggle_on_key,
178                send_window_size,
179                position_highlight,
180                apply_dock_reservation,
181                send_restore,
182                save_settings,
183                // Entries produced later the same frame (e.g. hover restyles)
184                // simply drain next frame — ordering is deliberately loose.
185                emit_runtime_warnings,
186            ),
187        )
188        // A quit right after a layout drag must not lose the change: flush
189        // pending settings on `AppExit`, which is written during `Update` —
190        // `Last` still runs on that final frame.
191        .add_systems(Last, flush_settings_on_exit)
192        // In the pointer-capture set, after the system that ASSIGNS
193        // `PointerCapture::over_ui` each frame, so pick mode's claim
194        // survives for world-input systems ordered `.after(PointerCaptureSet)`.
195        .add_systems(
196            Update,
197            drive_pick_mode
198                .in_set(crate::plugin::PointerCaptureSet)
199                .after(crate::reconcile::collect_pointer_events),
200        )
201        .add_systems(
202            PostUpdate,
203            (
204                // The markers bracket `UiSystems::Layout` exactly like the
205                // stress harness: `apply_js_ops` ran in `Update`, so
206                // `OpApplyStats` already reflects this frame's batch.
207                mark_pre_layout
208                    .after(UiSystems::Content)
209                    .before(UiSystems::Layout),
210                // After PostLayout so the layout leg covers the whole
211                // pipeline (taffy solve + computed transform/clip
212                // propagation), not just the Layout set.
213                mark_post_layout.after(UiSystems::PostLayout),
214                emit_batch_stats.after(mark_post_layout),
215            ),
216        );
217    }
218}
219
220/// Live devtools state, written by the JS panel's messages (and the toggle key)
221/// and read by the highlight/pick systems.
222#[derive(Resource)]
223pub(crate) struct DevtoolsState {
224    /// Whether the panel is open. Gates stats emission and pick/highlight.
225    pub open: bool,
226    /// Whether pick mode ("click a node on screen to select it") is active.
227    pub pick: bool,
228    /// The node selected in the tree explorer.
229    pub selected: Option<NodeId>,
230    /// The node whose tree row the panel pointer is hovering.
231    pub tree_hover: Option<NodeId>,
232    /// The node under the window cursor while pick mode is active.
233    pub pick_hover: Option<NodeId>,
234    /// Whether the persistent selected-node overlay is shown (the panel's
235    /// "overlay" toggle). Momentary highlights (tree-row hover, pick-mode
236    /// hover) are always on.
237    pub show_selection_overlay: bool,
238    /// The panel's own `<root>` node id, reported by the JS panel on open
239    /// (`None` while closed). Pick mode rejects hits under exactly this root —
240    /// app `<root>` overlays stay pickable.
241    pub panel_root: Option<NodeId>,
242    /// Which window edge the panel reserves space on (`None` = the panel
243    /// overlays the app: reserve toggled off, floating, or closed). Reported
244    /// by the JS panel via `devtools.dock`.
245    pub dock_side: Option<DockSide>,
246    /// The reserved width in logical pixels (meaningful with `dock_side`).
247    pub dock_width: f32,
248}
249
250/// The window edge a docked, space-reserving panel sits on.
251#[derive(Clone, Copy, PartialEq, Eq, Debug)]
252pub(crate) enum DockSide {
253    Left,
254    Right,
255}
256
257impl Default for DevtoolsState {
258    fn default() -> Self {
259        Self {
260            open: false,
261            pick: false,
262            selected: None,
263            tree_hover: None,
264            pick_hover: None,
265            show_selection_overlay: true,
266            panel_root: None,
267            dock_side: None,
268            dock_width: 0.0,
269        }
270    }
271}
272
273// --- Bridge bindings (see module docs; untyped on the JS side) -----------------
274
275/// Bevy → JS: the panel's open state changed Bevy-side (toggle key / auto-open).
276/// Carries the resulting state (not a bare "flip") so the panel mirrors Bevy
277/// instead of tracking parity.
278#[react_event(name = "devtools.toggle")]
279struct DevtoolsToggle {
280    open: bool,
281}
282
283/// Bevy → JS: pick mode clicked a node on screen — select it in the tree.
284#[react_event(name = "devtools.picked")]
285struct DevtoolsPicked {
286    id: NodeId,
287}
288
289/// Bevy → JS: the UI viewport's logical size. The panel's layout is
290/// proportional (fractions of the viewport), and JS can't see it on its own —
291/// sent once when the panel opens and on every size change while it stays open
292/// (see [`send_window_size`]; [`send_restore`] also sends it ahead of the
293/// restore blob so the restored fractions resolve against a real size).
294#[react_event(name = "devtools.window")]
295struct DevtoolsWindow {
296    width: f32,
297    height: f32,
298}
299
300/// Bevy → JS: render timings for one applied op batch. **Event-driven** — sent
301/// only on frames that applied a batch (while the panel is open), so an idle
302/// app produces zero devtools traffic. The JS recorder attaches these to the
303/// corresponding "ops" log entries. Timing legs are wall-clock ms; zero on web
304/// (no `std::time::Instant` on wasm).
305#[react_event(name = "devtools.batchStats")]
306struct DevtoolsBatchStats {
307    /// Op batches applied since startup (identifies the batch).
308    applied_count: u64,
309    /// Ops applied this frame (all queued flushes, coalesced).
310    last_ops: usize,
311    /// `op_flush` send → frame start: cross-frame queue wait (typically ~one
312    /// vsync; structural, excluded from the panel's totals).
313    frame_wait_ms: f64,
314    /// Frame start (or send, if later) → apply start: in-frame schedules
315    /// before the drain.
316    pre_apply_ms: f64,
317    /// Op → ECS-command translation (the `apply_js_ops` body).
318    translate_ms: f64,
319    /// Command execution (spawn/insert/hierarchy) + UI prepare/content.
320    command_ms: f64,
321    /// `UiSystems::Layout` + `PostLayout` (taffy + transform/clip propagation).
322    layout_ms: f64,
323}
324
325/// Bevy → JS: an invalid style/prop value fell back to a default at apply time
326/// (an unrecognized color, an unknown fontFamily/cursor, a bad text metric) —
327/// see [`crate::diag`]'s runtime sink. The panel's mirror matches `value`
328/// against the node's retained wire values to flag the offending inspector
329/// row. **Not** gated on the panel being open: warnings accumulate on the
330/// mirror so opening the panel later still shows them. (Decode-time warnings
331/// take the synchronous `op_take_decode_warnings` path instead — no event.)
332#[react_event(name = "devtools.warning")]
333struct DevtoolsWarning {
334    /// The affected node, when the parse site ran under a node scope.
335    id: Option<NodeId>,
336    /// The value's domain (`"color"`, `"fontFamily"`, `"cursor"`, …).
337    kind: String,
338    /// The raw offending wire value.
339    value: String,
340    /// The human-readable log message (shown under the flagged row).
341    message: String,
342}
343
344/// JS → Bevy: the panel opened or closed itself (close button, install sync).
345#[react_message(name = "devtools.open")]
346struct DevtoolsOpenMessage {
347    open: bool,
348}
349
350/// JS → Bevy: the panel's pick-mode button was toggled.
351#[react_message(name = "devtools.pick")]
352struct DevtoolsPickMessage {
353    on: bool,
354}
355
356/// JS → Bevy: a tree row was selected (or the selection cleared).
357#[react_message(name = "devtools.select")]
358struct DevtoolsSelectMessage {
359    id: Option<NodeId>,
360}
361
362/// JS → Bevy: a tree row is hovered (highlight that node on screen), or `null`
363/// on hover end.
364#[react_message(name = "devtools.highlight")]
365struct DevtoolsHighlightMessage {
366    id: Option<NodeId>,
367}
368
369/// JS → Bevy: the panel's "overlay" toggle — show/hide the persistent
370/// selected-node box.
371#[react_message(name = "devtools.overlay")]
372struct DevtoolsOverlayMessage {
373    on: bool,
374}
375
376/// JS → Bevy: the panel's own `<root>` node id (`None` when the panel closes).
377/// Sent on open so [`drive_pick_mode`] can reject exactly the panel.
378#[react_message(name = "devtools.panelRoot")]
379struct DevtoolsPanelRootMessage {
380    id: Option<NodeId>,
381}
382
383/// JS → Bevy: the panel's effective space reservation. `side: None` = no
384/// reservation (the reserve toggle is off, the panel floats, or it closed);
385/// otherwise the app UI is pushed off that edge by `width` logical pixels
386/// (see [`apply_dock_reservation`]).
387#[react_message(name = "devtools.dock")]
388struct DevtoolsDockMessage {
389    side: Option<String>,
390    width: f32,
391}
392
393fn on_open_message(msg: On<DevtoolsOpenMessage>, mut state: ResMut<DevtoolsState>) {
394    state.open = msg.event().open;
395    if !state.open {
396        exit_interactions(&mut state);
397    }
398}
399
400fn on_pick_message(msg: On<DevtoolsPickMessage>, mut state: ResMut<DevtoolsState>) {
401    state.pick = msg.event().on;
402    if !state.pick {
403        state.pick_hover = None;
404    }
405}
406
407fn on_select_message(msg: On<DevtoolsSelectMessage>, mut state: ResMut<DevtoolsState>) {
408    state.selected = msg.event().id;
409}
410
411fn on_highlight_message(msg: On<DevtoolsHighlightMessage>, mut state: ResMut<DevtoolsState>) {
412    state.tree_hover = msg.event().id;
413}
414
415fn on_overlay_message(msg: On<DevtoolsOverlayMessage>, mut state: ResMut<DevtoolsState>) {
416    state.show_selection_overlay = msg.event().on;
417}
418
419fn on_panel_root_message(msg: On<DevtoolsPanelRootMessage>, mut state: ResMut<DevtoolsState>) {
420    state.panel_root = msg.event().id;
421}
422
423fn on_dock_message(msg: On<DevtoolsDockMessage>, mut state: ResMut<DevtoolsState>) {
424    state.dock_side = match msg.event().side.as_deref() {
425        Some("left") => Some(DockSide::Left),
426        Some("right") => Some(DockSide::Right),
427        _ => None,
428    };
429    state.dock_width = msg.event().width.max(0.0);
430}
431
432// --- Settings persistence ------------------------------------------------------
433
434/// The panel's persisted layout settings. One flat shape wears three hats: the
435/// JS → Bevy `devtools.settings` message (sent on any layout change), the JSON
436/// settings file, and — via [`DevtoolsRestore`] — the Bevy → JS restore event.
437/// `mode` stays a loose string ("left" | "right" | "float"); JS validates on
438/// restore, so an old/hand-edited file can never wedge the panel.
439///
440/// Geometry is **proportional**: the `*_frac` fields are fractions of the
441/// window's logical size (docked width, float rect), so a resized window can
442/// never strand the panel off-screen; `split` stays panel-internal pixels.
443/// `#[serde(default)]` keeps old files loading: a pre-fraction file (pixel
444/// `width`/`float_x`… keys) still restores `mode`/`reserve`/`overlay`/`split`,
445/// while its stale pixel fields are ignored and the fractions take defaults.
446/// The defaults mirror the JS panel's initial state (`DevtoolsHost.tsx`).
447#[react_message(name = "devtools.settings")]
448#[derive(serde::Serialize, Clone, PartialEq)]
449#[serde(default)]
450pub(crate) struct DevtoolsSettings {
451    /// Whether the panel was open — persisted so it reopens on relaunch.
452    open: bool,
453    mode: String,
454    width_frac: f32,
455    float_x_frac: f32,
456    float_y_frac: f32,
457    float_w_frac: f32,
458    float_h_frac: f32,
459    reserve: bool,
460    overlay: bool,
461    split: f32,
462}
463
464impl Default for DevtoolsSettings {
465    fn default() -> Self {
466        Self {
467            open: false,
468            mode: "right".into(),
469            width_frac: 0.3,
470            float_x_frac: 0.08,
471            float_y_frac: 0.1,
472            float_w_frac: 0.33,
473            float_h_frac: 0.7,
474            reserve: false,
475            overlay: true,
476            split: 260.0,
477        }
478    }
479}
480
481/// Bevy → JS: the settings loaded from disk, sent once after the React app
482/// mounts (a struct can't wear both bridge macros, hence the twin).
483#[react_event(name = "devtools.restore")]
484struct DevtoolsRestore {
485    open: bool,
486    mode: String,
487    width_frac: f32,
488    float_x_frac: f32,
489    float_y_frac: f32,
490    float_w_frac: f32,
491    float_h_frac: f32,
492    reserve: bool,
493    overlay: bool,
494    split: f32,
495}
496
497impl From<&DevtoolsSettings> for DevtoolsRestore {
498    fn from(s: &DevtoolsSettings) -> Self {
499        Self {
500            open: s.open,
501            mode: s.mode.clone(),
502            width_frac: s.width_frac,
503            float_x_frac: s.float_x_frac,
504            float_y_frac: s.float_y_frac,
505            float_w_frac: s.float_w_frac,
506            float_h_frac: s.float_h_frac,
507            reserve: s.reserve,
508            overlay: s.overlay,
509            split: s.split,
510        }
511    }
512}
513
514/// Settings persistence state: what was loaded at startup (drives the one-shot
515/// restore), the latest blob from JS, and the debounced-write bookkeeping.
516#[derive(Resource)]
517struct DevtoolsPersistence {
518    loaded: Option<DevtoolsSettings>,
519    pending: Option<DevtoolsSettings>,
520    /// What the file currently holds — identical rewrites are skipped.
521    last_written: Option<DevtoolsSettings>,
522    /// When the pending blob last changed (native only; wasm never writes).
523    #[cfg(not(target_arch = "wasm32"))]
524    dirty_at: Option<std::time::Instant>,
525    /// Quiet time before a write; absorbs per-frame emits during drags.
526    debounce: Duration,
527}
528
529/// Read + parse the settings file. Any failure (no path, missing file, corrupt
530/// JSON) means fresh defaults. No-op on web.
531#[cfg_attr(target_arch = "wasm32", allow(unused_variables))]
532fn load_settings(path: Option<&std::path::Path>) -> Option<DevtoolsSettings> {
533    #[cfg(not(target_arch = "wasm32"))]
534    {
535        let text = std::fs::read_to_string(path?).ok()?;
536        serde_json::from_str(&text).ok()
537    }
538    #[cfg(target_arch = "wasm32")]
539    None
540}
541
542/// Write the pending blob if it differs from what the file holds. Failures
543/// warn once per change (the dirty stamp is cleared either way — no retry
544/// spam). Native only.
545#[cfg(not(target_arch = "wasm32"))]
546fn write_pending(persist: &mut DevtoolsPersistence, path: &std::path::Path) {
547    persist.dirty_at = None;
548    let Some(pending) = persist.pending.clone() else {
549        return;
550    };
551    if persist.last_written.as_ref() == Some(&pending) {
552        return;
553    }
554    if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
555        let _ = std::fs::create_dir_all(parent);
556    }
557    match serde_json::to_string_pretty(&pending) {
558        Ok(json) => match std::fs::write(path, json) {
559            Ok(()) => persist.last_written = Some(pending),
560            Err(e) => warn!("devtools: failed to write settings {}: {e}", path.display()),
561        },
562        Err(e) => warn!("devtools: failed to serialize settings: {e}"),
563    }
564}
565
566fn on_settings_message(
567    msg: On<DevtoolsSettings>,
568    #[cfg_attr(target_arch = "wasm32", allow(unused_mut, unused_variables))] mut persist: ResMut<
569        DevtoolsPersistence,
570    >,
571) {
572    #[cfg(not(target_arch = "wasm32"))]
573    {
574        persist.pending = Some(msg.event().clone());
575        persist.dirty_at = Some(std::time::Instant::now());
576    }
577    #[cfg(target_arch = "wasm32")]
578    let _ = msg;
579}
580
581/// Push the settings to the JS panel, once, after the React app has mounted
582/// (the first applied op batch — sending on frame one would race the isolate's
583/// listener registration). Sent **always**, with defaults when no file loaded:
584/// the JS recorder arms at install to capture the initial mount and disarms on
585/// a restore that says the panel stays closed, so every session must get
586/// exactly one restore. The window size goes first (same system, so ordering
587/// is guaranteed) — the restored fractions need it — and a persisted
588/// `open: true` reopens the panel here (the JS side mirrors the toggle).
589fn send_restore(
590    persist: Res<DevtoolsPersistence>,
591    stats: Res<OpApplyStats>,
592    cameras: Query<&Camera, With<IsDefaultUiCamera>>,
593    windows: Query<&Window>,
594    mut state: ResMut<DevtoolsState>,
595    events: ReactEvents,
596    mut done: Local<bool>,
597) {
598    if *done {
599        return;
600    }
601    if stats.applied_count == 0 {
602        return;
603    }
604    *done = true;
605    if let Some(size) = ui_viewport_size(&cameras, &windows) {
606        events.send(&DevtoolsWindow {
607            width: size.x,
608            height: size.y,
609        });
610    }
611    let settings = persist.loaded.clone().unwrap_or_default();
612    events.send(&DevtoolsRestore::from(&settings));
613    if settings.open {
614        state.open = true;
615        events.send(&DevtoolsToggle { open: true });
616    }
617}
618
619/// Stream the UI viewport's logical size to the panel: once when it opens and
620/// on every change while it stays open (the `Local` resets while closed, so a
621/// resize-while-closed is caught up on the next open). The panel's layout is
622/// proportional, and JS has no other way to see the viewport.
623fn send_window_size(
624    state: Res<DevtoolsState>,
625    stats: Res<OpApplyStats>,
626    cameras: Query<&Camera, With<IsDefaultUiCamera>>,
627    windows: Query<&Window>,
628    events: ReactEvents,
629    mut last: Local<Option<Vec2>>,
630) {
631    // Same first-batch gate as `send_restore`: no listener races.
632    if stats.applied_count == 0 {
633        return;
634    }
635    if !state.open {
636        *last = None;
637        return;
638    }
639    let Some(size) = ui_viewport_size(&cameras, &windows) else {
640        return;
641    };
642    if *last != Some(size) {
643        *last = Some(size);
644        events.send(&DevtoolsWindow {
645            width: size.x,
646            height: size.y,
647        });
648    }
649}
650
651/// Debounced settings write: JS emits on every layout change (per frame during
652/// a drag); the file is written once things go quiet.
653#[cfg_attr(target_arch = "wasm32", allow(unused_mut, unused_variables))]
654fn save_settings(mut persist: ResMut<DevtoolsPersistence>, config: Res<DevtoolsConfig>) {
655    #[cfg(not(target_arch = "wasm32"))]
656    {
657        let Some(path) = config.settings_path.clone() else {
658            return;
659        };
660        let debounce = persist.debounce;
661        if persist.dirty_at.is_some_and(|t| t.elapsed() >= debounce) {
662            write_pending(&mut persist, &path);
663        }
664    }
665}
666
667/// Flush pending settings when the app quits, so a layout change made moments
668/// before closing isn't lost to the debounce window.
669#[cfg_attr(target_arch = "wasm32", allow(unused_mut, unused_variables))]
670fn flush_settings_on_exit(
671    mut exits: MessageReader<AppExit>,
672    mut persist: ResMut<DevtoolsPersistence>,
673    config: Res<DevtoolsConfig>,
674) {
675    #[cfg(not(target_arch = "wasm32"))]
676    {
677        if exits.read().next().is_none() {
678            return;
679        }
680        let Some(path) = config.settings_path.clone() else {
681            return;
682        };
683        if persist.dirty_at.is_some() {
684            write_pending(&mut persist, &path);
685        }
686    }
687    #[cfg(target_arch = "wasm32")]
688    exits.clear();
689}
690
691/// Clear the transient interaction state that shouldn't outlive a closed panel.
692fn exit_interactions(state: &mut DevtoolsState) {
693    state.pick = false;
694    state.tree_hover = None;
695    state.pick_hover = None;
696}
697
698/// Flip the panel on the configured key and tell the JS panel the new state.
699fn toggle_on_key(
700    keys: Res<ButtonInput<KeyCode>>,
701    cfg: Res<DevtoolsConfig>,
702    mut state: ResMut<DevtoolsState>,
703    events: ReactEvents,
704) {
705    if !keys.just_pressed(cfg.toggle_key) {
706        return;
707    }
708    state.open = !state.open;
709    if !state.open {
710        exit_interactions(&mut state);
711    }
712    events.send(&DevtoolsToggle { open: state.open });
713}
714
715/// Reserve window space for a docked panel: inset the app's [`UiRoot`] margin
716/// on the reserved edge so the whole reconciler tree reflows beside the panel
717/// (the panel pushes the app aside rather than overlapping it), and release it
718/// whenever the reservation ends. Gated
719/// on `state.open`, so every close path (close button, toggle key) releases
720/// the space with no extra bookkeeping.
721///
722/// The margins are compared before writing — an unconditional `Node` deref-mut
723/// would re-run app layout every frame. The reserved width is clamped against
724/// the window so a huge panel can't push the app entirely off-screen
725/// (headless: no window, no clamp — fine for tests).
726///
727/// Known limitation: app-created `<root>` overlays are detached full-window
728/// trees (see `reconcile.rs` `root_base`), so they are not pushed — only the
729/// main tree under [`UiRoot`] is.
730fn apply_dock_reservation(
731    state: Res<DevtoolsState>,
732    windows: Query<&Window>,
733    mut root: Query<&mut Node, With<crate::plugin::UiRoot>>,
734) {
735    let Ok(mut node) = root.single_mut() else {
736        return;
737    };
738    let reserved = if state.open { state.dock_side } else { None };
739    let width = match windows.single() {
740        Ok(window) => state.dock_width.min(window.width() - 100.0).max(0.0),
741        Err(_) => state.dock_width,
742    };
743    let (left, right) = match reserved {
744        Some(DockSide::Left) => (Val::Px(width), Val::ZERO),
745        Some(DockSide::Right) => (Val::ZERO, Val::Px(width)),
746        None => (Val::ZERO, Val::ZERO),
747    };
748    if node.margin.left != left || node.margin.right != right {
749        node.margin.left = left;
750        node.margin.right = right;
751    }
752}
753
754/// Per-frame instants/durations splitting the post-translate cost into command
755/// execution and layout, exactly like the stress harness's `BenchTimers`.
756/// Updated only on frames a batch was applied.
757#[derive(Resource, Default)]
758struct DevtoolsTimers {
759    /// Stamped each frame just before `UiSystems::Layout` (native only).
760    #[cfg(not(target_arch = "wasm32"))]
761    pre_layout: Option<std::time::Instant>,
762    /// `pre_layout - apply_end`: command execution + UI prepare/content for the
763    /// most recent applied batch.
764    last_command: Duration,
765    /// `UiSystems::Layout` + `PostLayout` for the most recent applied batch.
766    last_layout: Duration,
767    /// The `applied_count` last recorded, to detect a fresh batch this frame.
768    seen_applied: u64,
769}
770
771#[cfg_attr(target_arch = "wasm32", allow(unused_mut, unused_variables))]
772fn mark_pre_layout(mut timers: ResMut<DevtoolsTimers>) {
773    #[cfg(not(target_arch = "wasm32"))]
774    {
775        timers.pre_layout = Some(std::time::Instant::now());
776    }
777}
778
779fn mark_post_layout(stats: Res<OpApplyStats>, mut timers: ResMut<DevtoolsTimers>) {
780    // Only meaningful on frames that applied a batch (its commands flush + lay
781    // out this same frame). Other frames leave the last values intact.
782    if stats.applied_count == timers.seen_applied {
783        return;
784    }
785    timers.seen_applied = stats.applied_count;
786    #[cfg(not(target_arch = "wasm32"))]
787    if let (Some(end), Some(pre)) = (stats.last_apply_end, timers.pre_layout) {
788        let (command, layout) = split_legs(end, pre, std::time::Instant::now());
789        timers.last_command = command;
790        timers.last_layout = layout;
791    }
792}
793
794/// Split "batch applied → layout done" into the command and layout legs.
795/// Saturating: system-order jitter must clamp to zero, never panic.
796#[cfg(not(target_arch = "wasm32"))]
797fn split_legs(
798    apply_end: std::time::Instant,
799    pre_layout: std::time::Instant,
800    post_layout: std::time::Instant,
801) -> (Duration, Duration) {
802    (
803        pre_layout.saturating_duration_since(apply_end),
804        post_layout.saturating_duration_since(pre_layout),
805    )
806}
807
808/// Pick mode ("inspect" cursor): while active, the topmost app node under the
809/// window cursor is hover-highlighted, and a left click selects it in the tree
810/// (exiting pick mode). Uses the picking `HoverMap` — the established pattern
811/// for UI hit-tests here (window-cursor `UiStack` walks can't see everything
812/// picking can) — and resolves hits the way surface picking does: climb from the
813/// topmost (min-depth) hit to the nearest `RNode` owner. Anything under the
814/// panel's own `<root>` (reported by the JS panel as
815/// [`DevtoolsState::panel_root`]) is rejected so the panel can never pick
816/// itself; app `<root>` overlays are ordinary pick targets. Only the mouse
817/// pointer is consulted: `<surface>` subtrees (in-world virtual pointer) are
818/// out of pick mode's scope.
819///
820/// Known limitation (documented): the picking click still reaches the app's own
821/// `onClick` handlers — pick mode does not suppress the click.
822#[allow(clippy::too_many_arguments)]
823fn drive_pick_mode(
824    mut state: ResMut<DevtoolsState>,
825    hover_map: Option<Res<HoverMap>>,
826    mouse: Res<ButtonInput<MouseButton>>,
827    capture: Option<ResMut<PointerCapture>>,
828    bridge: Option<Res<JsBridge>>,
829    rnodes: Query<&RNode>,
830    child_of: Query<&ChildOf>,
831    events: ReactEvents,
832) {
833    if !(state.open && state.pick) {
834        return;
835    }
836    // Claim the pointer for the whole pick session so world input (camera
837    // orbit/zoom) ignores the picking gestures.
838    if let Some(mut capture) = capture {
839        capture.over_ui = true;
840    }
841
842    // The panel's own root, resolved to its entity. `None` (not yet reported /
843    // no bridge) rejects nothing — pick mode is only reachable from an open
844    // panel, which reports its root on mount.
845    let panel_entity = state
846        .panel_root
847        .and_then(|id| bridge.as_ref().and_then(|b| b.nodes.get(&id).copied()));
848
849    let hovered = hover_map
850        .as_deref()
851        .and_then(|hover_map| hover_map.get(&PointerId::Mouse))
852        .and_then(|hits| {
853            // The Mouse hover map mixes backends: bevy_ui hits (stack-index
854            // depth) AND mesh-picking hits (ray distance in world units — the
855            // demos always have a 3D scene behind the UI). The scales aren't
856            // comparable, and a mesh often wins a raw `min_by(depth)`, which
857            // made picking look dead over the whole viewport. So: keep only
858            // hits that resolve to a reconciled UI node (climb to an `RNode`
859            // owner — this drops mesh hits but keeps panel nodes, so you still
860            // can't pick app nodes THROUGH the panel), take the frontmost of
861            // those, THEN apply the panel self-rejection.
862            let (&top, _) = hits
863                .iter()
864                .filter(|&(&entity, _)| climb(entity, &child_of, |e| rnodes.contains(e)).is_some())
865                .min_by(|a, b| a.1.depth.total_cmp(&b.1.depth))?;
866            // The panel can't pick itself (its nodes live under its own root).
867            if let Some(panel) = panel_entity
868                && climb(top, &child_of, |e| e == panel).is_some()
869            {
870                return None;
871            }
872            let owner = climb(top, &child_of, |e| rnodes.contains(e))?;
873            rnodes.get(owner).ok().map(|r| r.0)
874        });
875    state.pick_hover = hovered;
876
877    if mouse.just_pressed(MouseButton::Left)
878        && let Some(id) = hovered
879    {
880        state.pick = false;
881        state.pick_hover = None;
882        state.selected = Some(id);
883        events.send(&DevtoolsPicked { id });
884    }
885}
886
887/// Marks the single pre-spawned highlight overlay entity: the translucent box
888/// drawn over the node the devtools is hovering/selecting.
889#[derive(Component)]
890struct DevtoolsHighlightOverlay;
891
892/// Spawn the (hidden) highlight overlay once. A detached window-root node so it
893/// needs no parent; `GlobalZIndex(i32::MAX - 1)` floats it above the app but
894/// below the devtools panel's `<root>` (`i32::MAX`), and `Pickable::IGNORE`
895/// keeps it out of the `HoverMap` so pick mode can never pick the highlight box
896/// hovering under the cursor.
897fn spawn_highlight_overlay(mut commands: Commands) {
898    commands.spawn((
899        DevtoolsHighlightOverlay,
900        Node {
901            position_type: PositionType::Absolute,
902            display: Display::None,
903            ..default()
904        },
905        // Translucent blue fill + hairline.
906        BackgroundColor(Color::srgba(0.54, 0.71, 0.97, 0.30)),
907        Outline {
908            width: Val::Px(1.0),
909            color: Color::srgba(0.54, 0.71, 0.97, 0.9),
910            ..default()
911        },
912        GlobalZIndex(i32::MAX - 1),
913        Pickable::IGNORE,
914    ));
915}
916
917/// Move the highlight overlay over the current target each frame. Target
918/// priority: pick-mode hover, then a hovered tree row, then the selection.
919/// Rust-side on purpose: bounding boxes change every frame (layout, scroll,
920/// animation), and this is one query with zero bridge traffic — a React-side
921/// box would need per-frame geometry crossing the boundary.
922fn position_highlight(
923    state: Res<DevtoolsState>,
924    // `Option`: `JsBridge` is only inserted at `Startup` (see `OutboundResource`),
925    // and headless tests run this plugin without a JS runtime at all.
926    bridge: Option<Res<JsBridge>>,
927    targets: Query<(&ComputedNode, &UiGlobalTransform)>,
928    mut overlay: Query<&mut Node, With<DevtoolsHighlightOverlay>>,
929) {
930    let Ok(mut node) = overlay.single_mut() else {
931        return;
932    };
933    let Some(bridge) = bridge else {
934        return;
935    };
936    let target = state
937        .pick_hover
938        .or(state.tree_hover)
939        // The persistent selection box is gated by the panel's overlay toggle;
940        // the momentary hover highlights above are always on.
941        .or(state.selected.filter(|_| state.show_selection_overlay))
942        .filter(|_| state.open);
943    let rect = target
944        .and_then(|id| bridge.nodes.get(&id))
945        .and_then(|&e| targets.get(e).ok())
946        .map(|(computed, transform)| {
947            highlight_rect(
948                computed.size,
949                transform.translation,
950                computed.inverse_scale_factor,
951            )
952        });
953    // Write only on change: a `Node` mutation forces a bevy_ui relayout, so an
954    // idle overlay must not dirty itself every frame.
955    match rect {
956        Some((pos, size)) => {
957            let (left, top) = (Val::Px(pos.x), Val::Px(pos.y));
958            let (width, height) = (Val::Px(size.x), Val::Px(size.y));
959            if node.display != Display::Flex
960                || node.left != left
961                || node.top != top
962                || node.width != width
963                || node.height != height
964            {
965                node.display = Display::Flex;
966                node.left = left;
967                node.top = top;
968                node.width = width;
969                node.height = height;
970            }
971        }
972        None => {
973            if node.display != Display::None {
974                node.display = Display::None;
975            }
976        }
977    }
978}
979
980/// A node's window-space logical rect from its computed (physical) geometry:
981/// `UiGlobalTransform.translation` is the node's physical center, so top-left =
982/// center - size/2, all scaled to logical px by the inverse scale factor.
983fn highlight_rect(physical_size: Vec2, physical_center: Vec2, inverse_scale: f32) -> (Vec2, Vec2) {
984    let top_left = (physical_center - physical_size * 0.5) * inverse_scale;
985    (top_left, physical_size * inverse_scale)
986}
987
988/// Push one `devtools.batchStats` per applied APP op batch while the panel is
989/// open. Runs after `mark_post_layout`, so the command/layout legs for THIS
990/// frame's batch are already split. Frames that applied nothing send nothing —
991/// and neither do applies of the panel's OWN commits (`app_applied_count`
992/// unchanged): stats for those would make the panel repaint, producing the
993/// next batch, whose stats repaint it again… a self-observation loop at frame
994/// rate. The per-batch origin flags ([`crate::reconcile::FlushFlags`]) are
995/// what makes the distinction possible.
996fn emit_batch_stats(
997    state: Res<DevtoolsState>,
998    stats: Res<OpApplyStats>,
999    timers: Res<DevtoolsTimers>,
1000    events: ReactEvents,
1001    mut seen: Local<u64>,
1002) {
1003    if stats.app_applied_count == *seen {
1004        return;
1005    }
1006    *seen = stats.app_applied_count;
1007    if !state.open {
1008        return;
1009    }
1010    events.send(&DevtoolsBatchStats {
1011        applied_count: stats.applied_count,
1012        last_ops: stats.last_ops,
1013        frame_wait_ms: stats.last_frame_wait.as_secs_f64() * 1000.0,
1014        pre_apply_ms: stats.last_pre_apply.as_secs_f64() * 1000.0,
1015        translate_ms: stats.last_translate.as_secs_f64() * 1000.0,
1016        command_ms: timers.last_command.as_secs_f64() * 1000.0,
1017        layout_ms: timers.last_layout.as_secs_f64() * 1000.0,
1018    });
1019}
1020
1021/// Drain the [`crate::diag`] runtime sink and ship each **new** warning to JS
1022/// as a `devtools.warning` event. Deduped by a hash of the whole entry so the
1023/// hover/press restyle paths (which re-parse the same bad value on every flip)
1024/// can't spam; the set resets on [`OpApplyStats::reset_count`] (hot reload) so
1025/// a reloaded app's warnings flag again — the JS mirror was reset too. NOT
1026/// gated on the panel being open (always-on-in-dev: the mirror stores the
1027/// flags for whenever the panel opens); the `applied_count` gate only holds
1028/// entries back until the React app has mounted its listeners (same
1029/// listener-race guard as [`send_restore`] — entries stay queued, not lost).
1030fn emit_runtime_warnings(
1031    stats: Res<OpApplyStats>,
1032    events: ReactEvents,
1033    mut seen: Local<HashSet<u64>>,
1034    mut last_reset: Local<u64>,
1035) {
1036    if stats.reset_count != *last_reset {
1037        *last_reset = stats.reset_count;
1038        seen.clear();
1039    }
1040    if stats.applied_count == 0 {
1041        return;
1042    }
1043    for w in crate::diag::take_runtime_warnings() {
1044        let mut hasher = std::hash::DefaultHasher::new();
1045        std::hash::Hash::hash(&(w.node, w.kind, &w.value, &w.message), &mut hasher);
1046        if seen.insert(std::hash::Hasher::finish(&hasher)) {
1047            events.send(&DevtoolsWarning {
1048                id: w.node,
1049                kind: w.kind.to_string(),
1050                value: w.value,
1051                message: w.message,
1052            });
1053        }
1054    }
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059    use super::*;
1060    use crate::bridge::{OutboundResource, RRoot};
1061    use crate::protocol::Outbound;
1062    use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel};
1063
1064    /// Headless app with the toggle/auto-open systems and a drainable outbound
1065    /// channel (the same harness shape as `keyboard.rs`'s tests).
1066    fn test_app(config: DevtoolsConfig) -> (App, UnboundedReceiver<Outbound>) {
1067        let mut app = App::new();
1068        // Hold the diag test lock for the app's lifetime: `emit_runtime_warnings`
1069        // drains the process-global runtime sink every update, so concurrent
1070        // test apps would steal entries from each other (and from the
1071        // diag/ui_map sink tests). A non-send resource drops with the App.
1072        // CONSEQUENCE: one live test_app per test — `drop(app)` before
1073        // creating a second, or this lock self-deadlocks (std Mutex is not
1074        // reentrant). Don't take `diag::test_lock()` in a test using this
1075        // harness either.
1076        app.insert_non_send(crate::diag::test_lock());
1077        app.add_plugins(MinimalPlugins);
1078        app.init_resource::<ButtonInput<KeyCode>>();
1079        app.init_resource::<ButtonInput<MouseButton>>();
1080        app.init_resource::<OpApplyStats>();
1081        let (tx, rx) = unbounded_channel::<Outbound>();
1082        app.insert_resource(OutboundResource(tx));
1083        app.add_plugins(DevtoolsPlugin::new(config));
1084        (app, rx)
1085    }
1086
1087    fn drain_events(rx: &mut UnboundedReceiver<Outbound>) -> Vec<(String, serde_json::Value)> {
1088        let mut out = Vec::new();
1089        while let Ok(msg) = rx.try_recv() {
1090            if let Outbound::Event { name, value } = msg {
1091                out.push((name, value));
1092            }
1093        }
1094        out
1095    }
1096
1097    #[test]
1098    fn toggle_key_flips_state_and_notifies_js() {
1099        let (mut app, mut rx) = test_app(DevtoolsConfig {
1100            toggle_key: KeyCode::F9,
1101            ..default()
1102        });
1103        app.update();
1104        assert!(drain_events(&mut rx).is_empty(), "no toggle before the key");
1105
1106        app.world_mut()
1107            .resource_mut::<ButtonInput<KeyCode>>()
1108            .press(KeyCode::F9);
1109        app.update();
1110        let events = drain_events(&mut rx);
1111        assert_eq!(
1112            events
1113                .iter()
1114                .find(|(name, _)| name == "devtools.toggle")
1115                .map(|(_, v)| v["open"].as_bool()),
1116            Some(Some(true)),
1117            "the configured key must open the panel and notify JS"
1118        );
1119        assert!(app.world().resource::<DevtoolsState>().open);
1120
1121        // Release + press again closes it.
1122        {
1123            let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
1124            keys.clear_just_pressed(KeyCode::F9);
1125            keys.release(KeyCode::F9);
1126        }
1127        app.update();
1128        app.world_mut()
1129            .resource_mut::<ButtonInput<KeyCode>>()
1130            .press(KeyCode::F9);
1131        app.update();
1132        let events = drain_events(&mut rx);
1133        assert_eq!(
1134            events
1135                .iter()
1136                .find(|(name, _)| name == "devtools.toggle")
1137                .map(|(_, v)| v["open"].as_bool()),
1138            Some(Some(false)),
1139            "pressing again must close the panel"
1140        );
1141        assert!(!app.world().resource::<DevtoolsState>().open);
1142    }
1143
1144    /// A settings file persisted with `open: true` reopens the panel — but only
1145    /// after the React app mounted (the first applied batch), and exactly once.
1146    #[test]
1147    fn restored_open_opens_panel_after_first_batch() {
1148        let tmp = TempSettings::new("open");
1149        let settings = DevtoolsSettings {
1150            open: true,
1151            ..Default::default()
1152        };
1153        std::fs::write(&tmp.0, serde_json::to_string(&settings).unwrap()).unwrap();
1154        let (mut app, mut rx) = test_app(DevtoolsConfig {
1155            settings_path: Some(tmp.0.clone()),
1156            ..default()
1157        });
1158        app.update();
1159        assert!(
1160            drain_events(&mut rx).is_empty(),
1161            "must not reopen before the React app mounted"
1162        );
1163
1164        app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
1165        app.update();
1166        let events = drain_events(&mut rx);
1167        assert!(
1168            events
1169                .iter()
1170                .any(|(name, v)| name == "devtools.restore" && v["open"] == true),
1171            "the restore blob must carry the persisted open state"
1172        );
1173        assert!(
1174            events
1175                .iter()
1176                .any(|(name, v)| name == "devtools.toggle" && v["open"] == true),
1177            "a persisted open must reopen the panel once a batch has been applied"
1178        );
1179        assert!(app.world().resource::<DevtoolsState>().open);
1180
1181        app.update();
1182        assert!(
1183            drain_events(&mut rx)
1184                .iter()
1185                .all(|(name, _)| name != "devtools.toggle" && name != "devtools.restore"),
1186            "the restore-open must fire exactly once"
1187        );
1188    }
1189
1190    /// The JS editor validates against its own field table
1191    /// (`js/src/devtools/fields.ts`); assert it names every wire field of
1192    /// `protocol.rs`'s `with_style_fields!` table, so adding a `Style` field
1193    /// can't silently leave it un-editable in devtools. Matches the key either
1194    /// bare (`width:`) or quoted (`"width":`) — prettier decides which.
1195    /// camelCase wire names make the bare `name:` probe unambiguous (a missing
1196    /// `top` is never satisfied by `scrollTop:`).
1197    /// Runtime invalid-value warnings ship once per distinct entry as
1198    /// `devtools.warning` (hover restyles re-report the same bad value on
1199    /// every flip — the dedup set must swallow those), and re-ship after a
1200    /// hot reload (`reset_count` bump), matching the JS mirror's reset.
1201    /// Global-sink caveats: hold the diag test lock, and filter both drained
1202    /// warnings and emitted events by our own node id.
1203    #[cfg(debug_assertions)]
1204    #[test]
1205    fn runtime_warnings_emit_once_and_reset_on_reload() {
1206        // NOTE: the diag test lock is already held by `test_app`'s app —
1207        // taking it here too would deadlock.
1208        let (mut app, mut rx) = test_app(DevtoolsConfig {
1209            settings_path: None,
1210            ..default()
1211        });
1212        let _ = crate::diag::take_runtime_warnings();
1213        // The listener-race gate holds warnings until the app has mounted.
1214        app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
1215
1216        let report = || {
1217            let _scope = crate::diag::node_scope(31337);
1218            crate::diag::report("color", "redd", "unrecognized color \"redd\"");
1219        };
1220        let mine = |events: &[(String, serde_json::Value)]| {
1221            events
1222                .iter()
1223                .filter(|(name, v)| name == "devtools.warning" && v["id"] == 31337)
1224                .count()
1225        };
1226
1227        report();
1228        app.update();
1229        let events = drain_events(&mut rx);
1230        assert_eq!(
1231            mine(&events),
1232            1,
1233            "first report ships (panel closed is fine)"
1234        );
1235        assert!(
1236            events.iter().any(|(name, v)| name == "devtools.warning"
1237                && v["kind"] == "color"
1238                && v["value"] == "redd"
1239                && v["message"].as_str().is_some_and(|m| m.contains("redd"))),
1240            "the event carries kind/value/message"
1241        );
1242
1243        report();
1244        app.update();
1245        assert_eq!(
1246            mine(&drain_events(&mut rx)),
1247            0,
1248            "an identical re-report is deduped"
1249        );
1250
1251        app.world_mut().resource_mut::<OpApplyStats>().reset_count += 1;
1252        report();
1253        app.update();
1254        assert_eq!(
1255            mine(&drain_events(&mut rx)),
1256            1,
1257            "a hot reload clears the dedup set so warnings re-flag"
1258        );
1259    }
1260
1261    /// `warnings.ts`'s `KIND_FIELDS` must know every warning kind Rust emits,
1262    /// or that kind degrades to a broad all-style-fields value scan. Kind
1263    /// literals live at the `decode_warn` call sites (`protocol.rs`,
1264    /// `scrollbar.rs`, `animations/protocol.rs`) and the `diag::report` sites
1265    /// (`ui_map.rs`, `cursor.rs`); extend BOTH this list and the table when
1266    /// adding one. (`length`/`angle`/`time` are deliberately table-less —
1267    /// they're the broad-scan kinds.)
1268    #[test]
1269    fn js_warning_kind_table_covers_known_kinds() {
1270        let warnings_ts = include_str!("../../../js/src/devtools/warnings.ts");
1271        for kind in [
1272            "display",
1273            "boxSizing",
1274            "positionType",
1275            "overflow",
1276            "alignItems",
1277            "justifyItems",
1278            "alignSelf",
1279            "justifySelf",
1280            "alignContent",
1281            "justifyContent",
1282            "flexDirection",
1283            "flexWrap",
1284            "gridAutoFlow",
1285            "focusPolicy",
1286            "textAlign",
1287            "lineBreak",
1288            "fontSize",
1289            "fontWeight",
1290            "rect",
1291            "gridTrack",
1292            "gridPlacement",
1293            "borderColor",
1294            "scrollbar",
1295            "animatedStyle",
1296            "color",
1297            "fontFamily",
1298            "cursor",
1299            "lineHeight",
1300            "letterSpacing",
1301        ] {
1302            assert!(
1303                warnings_ts.contains(&format!("{kind}:"))
1304                    || warnings_ts.contains(&format!("\"{kind}\":")),
1305                "js/src/devtools/warnings.ts KIND_FIELDS is missing kind \"{kind}\""
1306            );
1307        }
1308    }
1309
1310    #[test]
1311    fn js_style_field_table_covers_every_style_field() {
1312        let fields_ts = include_str!("../../../js/src/devtools/fields.ts");
1313        macro_rules! check_fields {
1314            ($(($field:ident, $wire:literal, ($($group:tt)*), $overlay:ident)),* $(,)?) => {
1315                $(
1316                    assert!(
1317                        fields_ts.contains(concat!($wire, ":"))
1318                            || fields_ts.contains(concat!("\"", $wire, "\":")),
1319                        concat!(
1320                            "js/src/devtools/fields.ts is missing style field \"",
1321                            $wire,
1322                            "\" — add it to STYLE_FIELDS with a category"
1323                        )
1324                    );
1325                )*
1326            };
1327        }
1328        crate::protocol::with_style_fields!(check_fields);
1329    }
1330
1331    /// Build a bare world with everything `drive_pick_mode` needs (pick mode
1332    /// active). Returns the world + the outbound receiver (for asserting
1333    /// `devtools.picked`). Tests spawn their entities, then set the hover map
1334    /// with [`set_mouse_hits`].
1335    fn pick_world(pressed: bool) -> (World, UnboundedReceiver<Outbound>) {
1336        let mut world = World::new();
1337        world.insert_resource(DevtoolsState {
1338            open: true,
1339            pick: true,
1340            ..Default::default()
1341        });
1342        let mut mouse = ButtonInput::<MouseButton>::default();
1343        if pressed {
1344            mouse.press(MouseButton::Left);
1345        }
1346        world.insert_resource(mouse);
1347        let (tx, rx) = unbounded_channel::<Outbound>();
1348        world.insert_resource(OutboundResource(tx));
1349        (world, rx)
1350    }
1351
1352    /// Insert a `JsBridge` (channels kept alive, nothing reads them) mapping
1353    /// the given node ids to entities, and report `panel_root` to the state —
1354    /// the shape the JS panel produces via `devtools.panelRoot` on open.
1355    fn report_panel_root(world: &mut World, id: NodeId, panel_entity: Entity) {
1356        let (out_tx, out_rx) = unbounded_channel::<Outbound>();
1357        std::mem::forget(out_rx);
1358        let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<crate::protocol::Op>>();
1359        std::mem::forget(ops_tx);
1360        let root = world.spawn_empty().id();
1361        let mut bridge = JsBridge::new(ops_rx, out_tx, root);
1362        bridge.nodes.insert(id, panel_entity);
1363        world.insert_resource(bridge);
1364        world.resource_mut::<DevtoolsState>().panel_root = Some(id);
1365    }
1366
1367    /// Insert a Mouse `HoverMap` with the given `(entity, depth)` hits.
1368    fn set_mouse_hits(world: &mut World, hits: &[(Entity, f32)]) {
1369        use bevy::ecs::entity::EntityHashMap;
1370        use bevy::picking::backend::HitData;
1371        let mut hovered = EntityHashMap::default();
1372        for &(entity, depth) in hits {
1373            hovered.insert(entity, HitData::new(Entity::PLACEHOLDER, depth, None, None));
1374        }
1375        let mut hover_map = HoverMap::default();
1376        hover_map.insert(PointerId::Mouse, hovered);
1377        world.insert_resource(hover_map);
1378    }
1379
1380    /// A mesh-picking hit (ray distance, numerically "closer") must not shadow
1381    /// UI hits: the frontmost hit that resolves to an `RNode` wins. Regression:
1382    /// a raw `min_by(depth)` over the mixed hover map let 3D scene meshes win
1383    /// everywhere, making pick mode look dead.
1384    #[test]
1385    fn pick_ignores_mesh_hits_and_takes_frontmost_ui_node() {
1386        use bevy::ecs::system::RunSystemOnce;
1387
1388        let (mut world, _rx) = pick_world(false);
1389        let mesh = world.spawn_empty().id(); // no RNode — a scene mesh
1390        let node = world.spawn(RNode(7)).id();
1391        let leaf = world.spawn(ChildOf(node)).id(); // e.g. its text run
1392        set_mouse_hits(&mut world, &[(mesh, 0.5), (leaf, 30.0)]);
1393
1394        world.run_system_once(drive_pick_mode).unwrap();
1395        assert_eq!(
1396            world.resource::<DevtoolsState>().pick_hover,
1397            Some(7),
1398            "the frontmost RNode-resolving hit must win; mesh hits are ignored"
1399        );
1400    }
1401
1402    /// The panel can't pick itself: a frontmost hit under the REPORTED panel
1403    /// root yields no hover (and no pick-through to app nodes beneath it).
1404    #[test]
1405    fn pick_rejects_panel_hits() {
1406        use bevy::ecs::system::RunSystemOnce;
1407
1408        let (mut world, _rx) = pick_world(false);
1409        let panel_root = world.spawn((RRoot, RNode(100))).id();
1410        let panel_button = world.spawn((RNode(101), ChildOf(panel_root))).id();
1411        let app_node = world.spawn(RNode(7)).id();
1412        report_panel_root(&mut world, 100, panel_root);
1413        set_mouse_hits(&mut world, &[(panel_button, 1.0), (app_node, 5.0)]);
1414
1415        world.run_system_once(drive_pick_mode).unwrap();
1416        assert_eq!(
1417            world.resource::<DevtoolsState>().pick_hover,
1418            None,
1419            "a panel hit in front must block picking (no pick-through)"
1420        );
1421    }
1422
1423    /// Nodes under an APP `<root>` overlay are ordinary pick targets — only
1424    /// the panel's own reported root is rejected. Regression: rejecting any
1425    /// `RRoot` ancestor made every app overlay unpickable.
1426    #[test]
1427    fn pick_allows_nodes_under_app_roots() {
1428        use bevy::ecs::system::RunSystemOnce;
1429
1430        let (mut world, _rx) = pick_world(false);
1431        let panel_root = world.spawn((RRoot, RNode(100))).id();
1432        let app_root = world.spawn((RRoot, RNode(50))).id();
1433        let overlay_node = world.spawn((RNode(7), ChildOf(app_root))).id();
1434        report_panel_root(&mut world, 100, panel_root);
1435        set_mouse_hits(&mut world, &[(overlay_node, 5.0)]);
1436
1437        world.run_system_once(drive_pick_mode).unwrap();
1438        assert_eq!(
1439            world.resource::<DevtoolsState>().pick_hover,
1440            Some(7),
1441            "a node under an app <root> must be pickable"
1442        );
1443    }
1444
1445    /// The panel behind an app node doesn't block it: rejection applies only
1446    /// when the frontmost RNode-resolving hit is the panel's.
1447    #[test]
1448    fn pick_prefers_frontmost_app_hit_over_panel_behind() {
1449        use bevy::ecs::system::RunSystemOnce;
1450
1451        let (mut world, _rx) = pick_world(false);
1452        let panel_root = world.spawn((RRoot, RNode(100))).id();
1453        let panel_button = world.spawn((RNode(101), ChildOf(panel_root))).id();
1454        let app_node = world.spawn(RNode(7)).id();
1455        report_panel_root(&mut world, 100, panel_root);
1456        set_mouse_hits(&mut world, &[(panel_button, 5.0), (app_node, 1.0)]);
1457
1458        world.run_system_once(drive_pick_mode).unwrap();
1459        assert_eq!(
1460            world.resource::<DevtoolsState>().pick_hover,
1461            Some(7),
1462            "an app node in front of the panel must win"
1463        );
1464    }
1465
1466    /// A left click on a hovered app node selects it, exits pick mode, and
1467    /// reports `devtools.picked` to JS.
1468    #[test]
1469    fn pick_click_selects_and_notifies_js() {
1470        use bevy::ecs::system::RunSystemOnce;
1471
1472        let (mut world, mut rx) = pick_world(true);
1473        let app_node = world.spawn(RNode(7)).id();
1474        set_mouse_hits(&mut world, &[(app_node, 5.0)]);
1475
1476        world.run_system_once(drive_pick_mode).unwrap();
1477        let state = world.resource::<DevtoolsState>();
1478        assert_eq!(state.selected, Some(7));
1479        assert!(!state.pick, "a successful pick exits pick mode");
1480        match rx.try_recv().expect("a devtools.picked event") {
1481            Outbound::Event { name, value } => {
1482                assert_eq!(name, "devtools.picked");
1483                assert_eq!(value["id"], 7);
1484            }
1485            other => panic!("expected Outbound::Event, got {other:?}"),
1486        }
1487    }
1488
1489    /// An open panel with a dock side insets the app root's margin on that
1490    /// edge; flipping sides swaps the inset; closing releases it. No `Window`
1491    /// exists in the harness, so the width is unclamped.
1492    #[test]
1493    fn dock_reservation_insets_uiroot_margin() {
1494        let (mut app, _rx) = test_app(DevtoolsConfig::default());
1495        let root = app
1496            .world_mut()
1497            .spawn((Node::default(), crate::plugin::UiRoot))
1498            .id();
1499        let margin = |app: &mut App| {
1500            let node = app.world().entity(root).get::<Node>().unwrap();
1501            (node.margin.left, node.margin.right)
1502        };
1503
1504        {
1505            let mut state = app.world_mut().resource_mut::<DevtoolsState>();
1506            state.open = true;
1507            state.dock_side = Some(DockSide::Right);
1508            state.dock_width = 300.0;
1509        }
1510        app.update();
1511        assert_eq!(margin(&mut app), (Val::ZERO, Val::Px(300.0)));
1512
1513        app.world_mut().resource_mut::<DevtoolsState>().dock_side = Some(DockSide::Left);
1514        app.update();
1515        assert_eq!(margin(&mut app), (Val::Px(300.0), Val::ZERO));
1516
1517        // Any close path just flips `open`; the reservation releases for free.
1518        app.world_mut().resource_mut::<DevtoolsState>().open = false;
1519        app.update();
1520        assert_eq!(margin(&mut app), (Val::ZERO, Val::ZERO));
1521    }
1522
1523    /// The `devtools.dock` message maps its loose wire shape onto the state:
1524    /// known sides parse, anything else (or `None`) clears the reservation,
1525    /// and a negative width clamps to zero.
1526    #[test]
1527    fn dock_message_parses_side_and_clamps_width() {
1528        let (mut app, _rx) = test_app(DevtoolsConfig::default());
1529        let dock = |app: &mut App, side: Option<&str>, width: f32| {
1530            app.world_mut().trigger(DevtoolsDockMessage {
1531                side: side.map(String::from),
1532                width,
1533            });
1534            let state = app.world().resource::<DevtoolsState>();
1535            (state.dock_side, state.dock_width)
1536        };
1537
1538        assert_eq!(
1539            dock(&mut app, Some("left"), 320.0),
1540            (Some(DockSide::Left), 320.0)
1541        );
1542        assert_eq!(
1543            dock(&mut app, Some("right"), 280.0),
1544            (Some(DockSide::Right), 280.0)
1545        );
1546        assert_eq!(dock(&mut app, Some("bogus"), -5.0), (None, 0.0));
1547        assert_eq!(dock(&mut app, None, 380.0), (None, 380.0));
1548    }
1549
1550    /// Batch stats key off `app_applied_count`: an apply of the panel's own
1551    /// commits (only `applied_count` bumped) emits nothing, so the panel can't
1552    /// re-trigger itself; an app apply emits one event.
1553    #[test]
1554    fn batch_stats_skip_devtools_only_applies() {
1555        let (mut app, mut rx) = test_app(DevtoolsConfig {
1556            settings_path: None,
1557            ..default()
1558        });
1559        app.world_mut().resource_mut::<DevtoolsState>().open = true;
1560        let stats_events = |rx: &mut UnboundedReceiver<Outbound>| {
1561            drain_events(rx)
1562                .into_iter()
1563                .filter(|(name, _)| name == "devtools.batchStats")
1564                .count()
1565        };
1566
1567        // A devtools-only apply: the panel's own repaint. No stats.
1568        {
1569            let mut stats = app.world_mut().resource_mut::<OpApplyStats>();
1570            stats.applied_count = 1;
1571            stats.app_applied_count = 0;
1572        }
1573        app.update();
1574        assert_eq!(
1575            stats_events(&mut rx),
1576            0,
1577            "the panel's own commits must not produce batch stats"
1578        );
1579
1580        // An app apply: exactly one stats event, carrying both pre-apply legs.
1581        {
1582            let mut stats = app.world_mut().resource_mut::<OpApplyStats>();
1583            stats.applied_count = 2;
1584            stats.app_applied_count = 1;
1585        }
1586        app.update();
1587        let stats: Vec<_> = drain_events(&mut rx)
1588            .into_iter()
1589            .filter(|(name, _)| name == "devtools.batchStats")
1590            .collect();
1591        assert_eq!(stats.len(), 1, "an app apply reports once");
1592        assert!(
1593            stats[0].1.get("frame_wait_ms").is_some(),
1594            "batch stats carry the frame-wait leg"
1595        );
1596    }
1597
1598    /// A unique temp path per test; deleted on drop.
1599    struct TempSettings(std::path::PathBuf);
1600    impl TempSettings {
1601        fn new(name: &str) -> Self {
1602            Self(std::env::temp_dir().join(format!(
1603                "bevy-react-devtools-{name}-{}.json",
1604                std::process::id()
1605            )))
1606        }
1607    }
1608    impl Drop for TempSettings {
1609        fn drop(&mut self) {
1610            let _ = std::fs::remove_file(&self.0);
1611        }
1612    }
1613
1614    fn sample_settings() -> DevtoolsSettings {
1615        DevtoolsSettings {
1616            open: false,
1617            mode: "float".into(),
1618            width_frac: 0.4,
1619            float_x_frac: 0.05,
1620            float_y_frac: 0.1,
1621            float_w_frac: 0.5,
1622            float_h_frac: 0.6,
1623            reserve: true,
1624            overlay: false,
1625            split: 200.0,
1626        }
1627    }
1628
1629    fn drain_restores(rx: &mut UnboundedReceiver<Outbound>) -> Vec<serde_json::Value> {
1630        drain_events(rx)
1631            .into_iter()
1632            .filter(|(name, _)| name == "devtools.restore")
1633            .map(|(_, v)| v)
1634            .collect()
1635    }
1636
1637    /// A pre-existing settings file seeds the Rust-side overlay toggle at
1638    /// build, and restores to JS exactly once — after the first applied batch.
1639    #[test]
1640    fn settings_file_seeds_overlay_and_restores_once() {
1641        let tmp = TempSettings::new("restore");
1642        std::fs::write(&tmp.0, serde_json::to_string(&sample_settings()).unwrap()).unwrap();
1643        let (mut app, mut rx) = test_app(DevtoolsConfig {
1644            settings_path: Some(tmp.0.clone()),
1645            ..default()
1646        });
1647
1648        assert!(
1649            !app.world()
1650                .resource::<DevtoolsState>()
1651                .show_selection_overlay,
1652            "the loaded overlay=false must seed the state at build"
1653        );
1654
1655        app.update();
1656        assert!(
1657            drain_restores(&mut rx).is_empty(),
1658            "no restore before the React app mounted"
1659        );
1660
1661        app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
1662        app.update();
1663        let restores = drain_restores(&mut rx);
1664        assert_eq!(restores.len(), 1, "exactly one restore after mount");
1665        assert_eq!(restores[0]["mode"], "float");
1666        assert_eq!(restores[0]["split"], 200.0);
1667        assert_eq!(restores[0]["overlay"], false);
1668
1669        app.update();
1670        assert!(drain_restores(&mut rx).is_empty(), "restore is one-shot");
1671    }
1672
1673    /// A `devtools.settings` message round-trips to the file once the debounce
1674    /// elapses (zeroed here), and identical settings don't rewrite.
1675    #[test]
1676    fn settings_message_writes_file_debounced() {
1677        let tmp = TempSettings::new("save");
1678        let (mut app, _rx) = test_app(DevtoolsConfig {
1679            settings_path: Some(tmp.0.clone()),
1680            ..default()
1681        });
1682        app.world_mut()
1683            .resource_mut::<DevtoolsPersistence>()
1684            .debounce = Duration::ZERO;
1685
1686        app.world_mut().trigger(sample_settings());
1687        app.update();
1688
1689        let written: DevtoolsSettings =
1690            serde_json::from_str(&std::fs::read_to_string(&tmp.0).unwrap()).unwrap();
1691        assert!(written == sample_settings(), "full round-trip");
1692
1693        // An identical re-send must not rewrite (mtime unchanged).
1694        let mtime = |p: &std::path::Path| std::fs::metadata(p).unwrap().modified().unwrap();
1695        let before = mtime(&tmp.0);
1696        app.world_mut().trigger(sample_settings());
1697        app.update();
1698        assert_eq!(mtime(&tmp.0), before, "identical settings skip the write");
1699    }
1700
1701    /// `AppExit` flushes a still-debouncing change immediately (`Last` runs on
1702    /// the exit frame), so a drag right before quitting isn't lost.
1703    #[test]
1704    fn settings_flush_on_app_exit() {
1705        let tmp = TempSettings::new("flush");
1706        let (mut app, _rx) = test_app(DevtoolsConfig {
1707            settings_path: Some(tmp.0.clone()),
1708            ..default()
1709        });
1710        // Default 1s debounce: a normal update must NOT write yet.
1711        app.world_mut().trigger(sample_settings());
1712        app.update();
1713        assert!(!tmp.0.exists(), "still inside the debounce window");
1714
1715        app.world_mut().write_message(AppExit::Success);
1716        app.update();
1717        assert!(tmp.0.exists(), "AppExit must flush the pending settings");
1718    }
1719
1720    /// Corrupt files mean fresh defaults — but STILL exactly one restore (the
1721    /// JS recorder disarms on it; corrupt ≡ missing ≡ defaults).
1722    /// `no_settings_file()` disables writing entirely.
1723    #[test]
1724    fn corrupt_or_disabled_settings_are_ignored() {
1725        let tmp = TempSettings::new("corrupt");
1726        std::fs::write(&tmp.0, "{ not json").unwrap();
1727        let (mut app, mut rx) = test_app(DevtoolsConfig {
1728            settings_path: Some(tmp.0.clone()),
1729            ..default()
1730        });
1731        assert!(
1732            app.world()
1733                .resource::<DevtoolsState>()
1734                .show_selection_overlay
1735        );
1736        app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
1737        app.update();
1738        let restores = drain_restores(&mut rx);
1739        assert_eq!(restores.len(), 1, "corrupt file → one restore, defaults");
1740        assert_eq!(restores[0]["mode"], "right");
1741        assert_eq!(restores[0]["open"], false);
1742        assert!(!app.world().resource::<DevtoolsState>().open);
1743        // Release the first app (and its diag test lock — see `test_app`)
1744        // before building the second, or the lock self-deadlocks.
1745        drop(app);
1746
1747        let (mut app, _rx) = test_app(DevtoolsConfig {
1748            settings_path: None,
1749            ..default()
1750        });
1751        app.world_mut()
1752            .resource_mut::<DevtoolsPersistence>()
1753            .debounce = Duration::ZERO;
1754        app.world_mut().trigger(sample_settings());
1755        app.update(); // must not panic / write anywhere
1756    }
1757
1758    /// With no settings file at all, the restore (with defaults) is still sent
1759    /// exactly once after the first applied batch — the JS recorder's disarm
1760    /// signal must never be skipped.
1761    #[test]
1762    fn restore_defaults_sent_without_settings_file() {
1763        let (mut app, mut rx) = test_app(DevtoolsConfig {
1764            settings_path: None,
1765            ..default()
1766        });
1767        app.update();
1768        assert!(drain_restores(&mut rx).is_empty(), "not before mount");
1769
1770        app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
1771        app.update();
1772        let restores = drain_restores(&mut rx);
1773        assert_eq!(restores.len(), 1, "defaults restore exactly once");
1774        assert_eq!(restores[0]["open"], false);
1775        assert_eq!(restores[0]["mode"], "right");
1776
1777        app.update();
1778        assert!(drain_restores(&mut rx).is_empty(), "one-shot");
1779    }
1780
1781    /// A pre-fraction (pixel-unit) settings file still loads: the shared keys
1782    /// (`mode`/`reserve`/`overlay`/`split`) restore, the stale pixel fields are
1783    /// ignored as unknown keys, and the fraction fields take defaults.
1784    #[test]
1785    fn legacy_pixel_settings_file_migrates() {
1786        let tmp = TempSettings::new("legacy");
1787        let legacy = serde_json::json!({
1788            "mode": "float",
1789            "width": 420.0,
1790            "float_x": 10.0,
1791            "float_y": 20.0,
1792            "float_w": 500.0,
1793            "float_h": 600.0,
1794            "reserve": true,
1795            "overlay": false,
1796            "split": 200.0,
1797        });
1798        std::fs::write(&tmp.0, legacy.to_string()).unwrap();
1799        let (mut app, mut rx) = test_app(DevtoolsConfig {
1800            settings_path: Some(tmp.0.clone()),
1801            ..default()
1802        });
1803        app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
1804        app.update();
1805        let restores = drain_restores(&mut rx);
1806        assert_eq!(restores.len(), 1, "a legacy file must still restore");
1807        assert_eq!(restores[0]["mode"], "float");
1808        assert_eq!(restores[0]["overlay"], false);
1809        assert_eq!(restores[0]["split"], 200.0);
1810        assert_eq!(restores[0]["open"], false, "no persisted open → closed");
1811        let frac = restores[0]["width_frac"].as_f64().expect("a number");
1812        assert!(
1813            (frac - f64::from(DevtoolsSettings::default().width_frac)).abs() < 1e-6,
1814            "stale pixel width is ignored; the fraction takes its default"
1815        );
1816    }
1817
1818    /// The window's logical size streams to the panel: once on open, again on
1819    /// every change while open, and re-sent after a close → reopen (a resize
1820    /// while closed must be caught up).
1821    #[test]
1822    fn window_size_sent_on_open_and_resize() {
1823        use bevy::window::WindowResolution;
1824
1825        let (mut app, mut rx) = test_app(DevtoolsConfig {
1826            settings_path: None,
1827            ..default()
1828        });
1829        let window = app
1830            .world_mut()
1831            .spawn(Window {
1832                resolution: WindowResolution::new(800, 600),
1833                ..Default::default()
1834            })
1835            .id();
1836        let sizes = |rx: &mut UnboundedReceiver<Outbound>| {
1837            drain_events(rx)
1838                .into_iter()
1839                .filter(|(name, _)| name == "devtools.window")
1840                .map(|(_, v)| (v["width"].as_f64().unwrap(), v["height"].as_f64().unwrap()))
1841                .collect::<Vec<_>>()
1842        };
1843
1844        // Closed: nothing, even after mount (send_restore fires one — drain it).
1845        app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
1846        app.update();
1847        let restore_frame = sizes(&mut rx);
1848        assert_eq!(
1849            restore_frame,
1850            vec![(800.0, 600.0)],
1851            "the restore one-shot sends the size once, ahead of the blob"
1852        );
1853
1854        // Open: one size event; idle frames send nothing more.
1855        app.world_mut().resource_mut::<DevtoolsState>().open = true;
1856        app.update();
1857        assert_eq!(sizes(&mut rx), vec![(800.0, 600.0)], "sent on open");
1858        app.update();
1859        assert!(sizes(&mut rx).is_empty(), "idle frames are silent");
1860
1861        // Resize while open: exactly one update.
1862        app.world_mut()
1863            .entity_mut(window)
1864            .get_mut::<Window>()
1865            .unwrap()
1866            .resolution = WindowResolution::new(1024, 768);
1867        app.update();
1868        assert_eq!(sizes(&mut rx), vec![(1024.0, 768.0)], "sent on resize");
1869
1870        // Resize while closed → reopen catches up.
1871        app.world_mut().resource_mut::<DevtoolsState>().open = false;
1872        app.update();
1873        app.world_mut()
1874            .entity_mut(window)
1875            .get_mut::<Window>()
1876            .unwrap()
1877            .resolution = WindowResolution::new(640, 480);
1878        app.update();
1879        assert!(sizes(&mut rx).is_empty(), "closed: no size traffic");
1880        app.world_mut().resource_mut::<DevtoolsState>().open = true;
1881        app.update();
1882        assert_eq!(
1883            sizes(&mut rx),
1884            vec![(640.0, 480.0)],
1885            "reopen must catch up on a resize that happened while closed"
1886        );
1887    }
1888
1889    #[test]
1890    fn highlight_rect_converts_physical_center_to_logical_top_left() {
1891        // A 200×100 physical node centered at (300, 150) on a 2× display.
1892        let (pos, size) = highlight_rect(Vec2::new(200.0, 100.0), Vec2::new(300.0, 150.0), 0.5);
1893        assert_eq!(pos, Vec2::new(100.0, 50.0));
1894        assert_eq!(size, Vec2::new(100.0, 50.0));
1895    }
1896
1897    #[test]
1898    fn split_legs_computes_command_and_layout() {
1899        let t0 = std::time::Instant::now();
1900        let t1 = t0 + Duration::from_millis(5);
1901        let t2 = t1 + Duration::from_millis(7);
1902        assert_eq!(
1903            split_legs(t0, t1, t2),
1904            (Duration::from_millis(5), Duration::from_millis(7))
1905        );
1906        // Out-of-order instants (system jitter) clamp to zero, never panic.
1907        assert_eq!(split_legs(t1, t0, t2), (Duration::ZERO, t2 - t0));
1908    }
1909}