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}