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 [`settings::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//!
62//! [`OpApplyStats`]: crate::reconcile::OpApplyStats
63
64use bevy::prelude::*;
65use bevy::ui::UiSystems;
66
67use crate::message::ReactAppExt;
68use crate::protocol::NodeId;
69
70mod console;
71#[cfg(test)]
72mod js_tables;
73mod layers;
74mod panel;
75mod pick;
76mod settings;
77mod stats;
78#[cfg(test)]
79mod test_util;
80
81use console::{
82    emit_console, emit_runtime_warnings, on_console_clear_message, on_console_open_message,
83};
84use layers::{emit_layers, on_layers_open_message};
85use panel::{
86    apply_dock_reservation, on_dock_message, on_open_message, on_overlay_message,
87    on_panel_root_message, send_window_size, toggle_on_key,
88};
89use pick::{
90    drive_pick_mode, on_highlight_message, on_pick_message, on_select_message, position_highlight,
91    spawn_highlight_overlay,
92};
93use settings::{
94    DevtoolsPersistence, flush_settings_on_exit, load_settings, on_settings_message, save_settings,
95    send_restore,
96};
97use stats::{DevtoolsTimers, emit_batch_stats, mark_post_layout, mark_pre_layout};
98
99/// Devtools configuration, passed to
100/// [`ReactUiPlugin::devtools`](crate::ReactUiPlugin::devtools). Every field
101/// has a default (`DevtoolsConfig::default()` is exactly what an app gets
102/// without calling `.devtools(...)` at all), so construct it with
103/// struct-update syntax:
104///
105/// ```no_run
106/// # use bevy::prelude::*;
107/// # use bevy_react::{DevtoolsConfig, ReactUiPlugin};
108/// # let mut app = App::new();
109/// app.add_plugins(ReactUiPlugin::new("ui/dist/app.js").devtools(DevtoolsConfig {
110///     settings_path: Some(".config/devtools.json".into()),
111///     ..default()
112/// }));
113/// ```
114///
115/// Also a resource, so the toggle/persistence systems can read it.
116#[derive(Resource, Clone)]
117pub struct DevtoolsConfig {
118    /// Whether the devtools are available at all. Default: `true` (dev builds
119    /// only either way — release builds never run them).
120    pub enabled: bool,
121    /// The key that toggles the panel. Default: `F12`.
122    pub toggle_key: KeyCode,
123    /// Where the panel's layout settings (dock mode/width, float rect, the
124    /// reserve and overlay toggles, tree/inspector split, whether the panel
125    /// is open) persist across runs; `None` disables persistence. Default:
126    /// `.bevy-react-devtools.json` in the working directory. Native only —
127    /// on web the file is neither read nor written.
128    pub settings_path: Option<std::path::PathBuf>,
129}
130
131impl Default for DevtoolsConfig {
132    fn default() -> Self {
133        Self {
134            enabled: true,
135            toggle_key: KeyCode::F12,
136            settings_path: Some(std::path::PathBuf::from(".bevy-react-devtools.json")),
137        }
138    }
139}
140
141/// The Bevy side of the devtools inspector. See the [module docs](self).
142///
143/// Crate-internal: [`ReactUiPlugin`](crate::ReactUiPlugin) auto-registers it,
144/// built from the consumer's [`DevtoolsConfig`].
145pub struct DevtoolsPlugin {
146    config: DevtoolsConfig,
147}
148
149impl DevtoolsPlugin {
150    pub fn new(config: DevtoolsConfig) -> Self {
151        Self { config }
152    }
153}
154
155impl Plugin for DevtoolsPlugin {
156    fn build(&self, app: &mut App) {
157        // "Dev build only": the feature is a default feature, so this is the
158        // expected path for every consumer `--release` build — the plugin
159        // registers nothing. `debug!`, not `warn!`: release logs stay clean.
160        if !cfg!(debug_assertions) {
161            debug!("DevtoolsPlugin is inert in release builds");
162            return;
163        }
164        // The toggle/pick systems read `ButtonInput` resources; a headless app
165        // without `InputPlugin` (wiring-only tests) must not panic on them.
166        app.init_resource::<ButtonInput<KeyCode>>();
167        app.init_resource::<ButtonInput<MouseButton>>();
168        // `emit_layers` reads the layer registries; idempotent in the full app
169        // (`plugin.rs` inits them too), load-bearing for headless harnesses
170        // that build this plugin without `ReactUiPlugin`.
171        app.init_resource::<crate::layer::LayersRegistry>();
172        app.init_resource::<crate::layer::LayerMembership>();
173        // Start collecting apply-time invalid-value warnings (see
174        // `crate::diag`): armed for the app's whole lifetime, panel open or
175        // not, so warnings from the initial mount are waiting when it opens.
176        crate::diag::arm_runtime();
177        // Load persisted panel settings (native only; errors — missing file,
178        // corrupt JSON — mean fresh defaults). The overlay toggle seeds the
179        // Rust-side state immediately so highlight gating is correct before
180        // the JS panel wakes; the rest restores to JS via `send_restore`.
181        let loaded = load_settings(self.config.settings_path.as_deref());
182        app.insert_resource(DevtoolsState {
183            show_selection_overlay: loaded.as_ref().is_none_or(|s| s.overlay),
184            ..Default::default()
185        })
186        .insert_resource(DevtoolsPersistence::from_loaded(loaded))
187        .init_resource::<DevtoolsTimers>()
188        .insert_resource(self.config.clone())
189        // Panel → Bevy state sync. Registration is what routes the emits;
190        // none of this reaches an app's generated `bevy.ts` because the
191        // bindings exporter never adds this plugin.
192        .add_react_handler(on_open_message)
193        .add_react_handler(on_pick_message)
194        .add_react_handler(on_select_message)
195        .add_react_handler(on_highlight_message)
196        .add_react_handler(on_overlay_message)
197        .add_react_handler(on_panel_root_message)
198        .add_react_handler(on_dock_message)
199        .add_react_handler(on_settings_message)
200        .add_react_handler(on_layers_open_message)
201        .add_react_handler(on_console_open_message)
202        .add_react_handler(on_console_clear_message)
203        // Registered in the plugin's OWN tuples — `plugin.rs`'s Update tuple
204        // sits at Bevy's 20-arity cap.
205        .add_systems(Startup, spawn_highlight_overlay)
206        .add_systems(
207            Update,
208            (
209                toggle_on_key,
210                send_window_size,
211                position_highlight,
212                apply_dock_reservation,
213                send_restore,
214                save_settings,
215                // Entries produced later the same frame (e.g. hover restyles)
216                // simply drain next frame — ordering is deliberately loose.
217                emit_runtime_warnings,
218                // Same loose ordering: console-ring entries pushed later this
219                // frame drain next frame.
220                emit_console,
221            ),
222        )
223        // A quit right after a layout drag must not lose the change: flush
224        // pending settings on `AppExit`, which is written during `Update` —
225        // `Last` still runs on that final frame.
226        .add_systems(Last, flush_settings_on_exit)
227        // In the pointer-capture set, after the system that ASSIGNS
228        // `PointerCapture::over_ui` each frame, so pick mode's claim
229        // survives for world-input systems ordered `.after(PointerCaptureSet)`.
230        .add_systems(
231            Update,
232            drive_pick_mode
233                .in_set(crate::plugin::PointerCaptureSet)
234                .after(crate::reconcile::collect_pointer_events),
235        )
236        .add_systems(
237            PostUpdate,
238            (
239                // The markers bracket `UiSystems::Layout` exactly like the
240                // stress harness: `apply_js_ops` ran in `Update`, so
241                // `OpApplyStats` already reflects this frame's batch.
242                mark_pre_layout
243                    .after(UiSystems::Content)
244                    .before(UiSystems::Layout),
245                // After PostLayout so the layout leg covers the whole
246                // pipeline (taffy solve + computed transform/clip
247                // propagation), not just the Layout set.
248                mark_post_layout.after(UiSystems::PostLayout),
249                emit_batch_stats.after(mark_post_layout),
250                // After the layer geometry sync so the rects are this
251                // frame's; a no-op ordering in harnesses that don't schedule
252                // that system.
253                emit_layers
254                    .after(crate::layer::sync_layer_geometry)
255                    // Cache stats (`repaints`/`cached`) are stamped by the
256                    // repaint resolver.
257                    .after(crate::layer::resolve_layer_repaints),
258            ),
259        );
260    }
261}
262
263/// Live devtools state, written by the JS panel's messages (and the toggle key)
264/// and read by the highlight/pick systems.
265#[derive(Resource)]
266pub(crate) struct DevtoolsState {
267    /// Whether the panel is open. Gates stats emission and pick/highlight.
268    pub open: bool,
269    /// Whether pick mode ("click a node on screen to select it") is active.
270    pub pick: bool,
271    /// The node selected in the tree explorer.
272    pub selected: Option<NodeId>,
273    /// The node whose tree row the panel pointer is hovering.
274    pub tree_hover: Option<NodeId>,
275    /// The node under the window cursor while pick mode is active.
276    pub pick_hover: Option<NodeId>,
277    /// Whether the persistent selected-node overlay is shown (the panel's
278    /// "overlay" toggle). Momentary highlights (tree-row hover, pick-mode
279    /// hover) are always on.
280    pub show_selection_overlay: bool,
281    /// The panel's own `<root>` node id, reported by the JS panel on open
282    /// (`None` while closed). Pick mode rejects hits under exactly this root —
283    /// app `<root>` overlays stay pickable.
284    pub panel_root: Option<NodeId>,
285    /// Which window edge the panel reserves space on (`None` = the panel
286    /// overlays the app: reserve toggled off, floating, or closed). Reported
287    /// by the JS panel via `devtools.dock`.
288    pub dock_side: Option<DockSide>,
289    /// The reserved width in logical pixels (meaningful with `dock_side`).
290    pub dock_width: f32,
291    /// Whether the panel's Layers tab is currently shown (reported via
292    /// `devtools.layersOpen`). Gates the `devtools.layers` stream.
293    pub layers_tab_open: bool,
294    /// Whether the panel's Console tab is currently shown (reported via
295    /// `devtools.consoleOpen`). Gates the `devtools.console` stream.
296    pub console_tab_open: bool,
297    /// The console stream watermark: the highest [`crate::console_log`] seq
298    /// already sent. `None` = send the full backlog next frame. Lives in the
299    /// resource (not a `Local`) so the `consoleOpen` handler can reset it on
300    /// every flip — a same-frame close→open must never skip the backlog.
301    pub console_last_seq: Option<u64>,
302}
303
304/// The window edge a docked, space-reserving panel sits on.
305#[derive(Clone, Copy, PartialEq, Eq, Debug)]
306pub(crate) enum DockSide {
307    Left,
308    Right,
309}
310
311impl Default for DevtoolsState {
312    fn default() -> Self {
313        Self {
314            open: false,
315            pick: false,
316            selected: None,
317            tree_hover: None,
318            pick_hover: None,
319            show_selection_overlay: true,
320            panel_root: None,
321            dock_side: None,
322            dock_width: 0.0,
323            layers_tab_open: false,
324            console_tab_open: false,
325            console_last_seq: None,
326        }
327    }
328}