use bevy::picking::hover::HoverMap;
use bevy::picking::pointer::PointerId;
use bevy::platform::collections::HashSet;
use bevy::prelude::*;
use bevy::ui::{ComputedNode, IsDefaultUiCamera, UiGlobalTransform, UiSystems};
use std::time::Duration;
use crate::bridge::{JsBridge, RNode};
use crate::event::ReactEvents;
use crate::message::ReactAppExt;
use crate::plugin::PointerCapture;
use crate::protocol::NodeId;
use crate::reconcile::{OpApplyStats, climb};
use crate::window::ui_viewport_size;
use crate::{react_event, react_message};
#[derive(Resource, Clone)]
pub struct DevtoolsConfig {
pub enabled: bool,
pub toggle_key: KeyCode,
pub settings_path: Option<std::path::PathBuf>,
}
impl Default for DevtoolsConfig {
fn default() -> Self {
Self {
enabled: true,
toggle_key: KeyCode::F12,
settings_path: Some(std::path::PathBuf::from(".bevy-react-devtools.json")),
}
}
}
pub struct DevtoolsPlugin {
config: DevtoolsConfig,
}
impl DevtoolsPlugin {
pub fn new(config: DevtoolsConfig) -> Self {
Self { config }
}
}
impl Plugin for DevtoolsPlugin {
fn build(&self, app: &mut App) {
if !cfg!(debug_assertions) {
debug!("DevtoolsPlugin is inert in release builds");
return;
}
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<ButtonInput<MouseButton>>();
crate::diag::arm_runtime();
let loaded = load_settings(self.config.settings_path.as_deref());
app.insert_resource(DevtoolsState {
show_selection_overlay: loaded.as_ref().is_none_or(|s| s.overlay),
..Default::default()
})
.insert_resource(DevtoolsPersistence {
last_written: loaded.clone(),
loaded,
pending: None,
#[cfg(not(target_arch = "wasm32"))]
dirty_at: None,
debounce: Duration::from_secs(1),
})
.init_resource::<DevtoolsTimers>()
.insert_resource(self.config.clone())
.add_react_handler(on_open_message)
.add_react_handler(on_pick_message)
.add_react_handler(on_select_message)
.add_react_handler(on_highlight_message)
.add_react_handler(on_overlay_message)
.add_react_handler(on_panel_root_message)
.add_react_handler(on_dock_message)
.add_react_handler(on_settings_message)
.add_systems(Startup, spawn_highlight_overlay)
.add_systems(
Update,
(
toggle_on_key,
send_window_size,
position_highlight,
apply_dock_reservation,
send_restore,
save_settings,
emit_runtime_warnings,
),
)
.add_systems(Last, flush_settings_on_exit)
.add_systems(
Update,
drive_pick_mode
.in_set(crate::plugin::PointerCaptureSet)
.after(crate::reconcile::collect_pointer_events),
)
.add_systems(
PostUpdate,
(
mark_pre_layout
.after(UiSystems::Content)
.before(UiSystems::Layout),
mark_post_layout.after(UiSystems::PostLayout),
emit_batch_stats.after(mark_post_layout),
),
);
}
}
#[derive(Resource)]
pub(crate) struct DevtoolsState {
pub open: bool,
pub pick: bool,
pub selected: Option<NodeId>,
pub tree_hover: Option<NodeId>,
pub pick_hover: Option<NodeId>,
pub show_selection_overlay: bool,
pub panel_root: Option<NodeId>,
pub dock_side: Option<DockSide>,
pub dock_width: f32,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum DockSide {
Left,
Right,
}
impl Default for DevtoolsState {
fn default() -> Self {
Self {
open: false,
pick: false,
selected: None,
tree_hover: None,
pick_hover: None,
show_selection_overlay: true,
panel_root: None,
dock_side: None,
dock_width: 0.0,
}
}
}
#[react_event(name = "devtools.toggle")]
struct DevtoolsToggle {
open: bool,
}
#[react_event(name = "devtools.picked")]
struct DevtoolsPicked {
id: NodeId,
}
#[react_event(name = "devtools.window")]
struct DevtoolsWindow {
width: f32,
height: f32,
}
#[react_event(name = "devtools.batchStats")]
struct DevtoolsBatchStats {
applied_count: u64,
last_ops: usize,
frame_wait_ms: f64,
pre_apply_ms: f64,
translate_ms: f64,
command_ms: f64,
layout_ms: f64,
}
#[react_event(name = "devtools.warning")]
struct DevtoolsWarning {
id: Option<NodeId>,
kind: String,
value: String,
message: String,
}
#[react_message(name = "devtools.open")]
struct DevtoolsOpenMessage {
open: bool,
}
#[react_message(name = "devtools.pick")]
struct DevtoolsPickMessage {
on: bool,
}
#[react_message(name = "devtools.select")]
struct DevtoolsSelectMessage {
id: Option<NodeId>,
}
#[react_message(name = "devtools.highlight")]
struct DevtoolsHighlightMessage {
id: Option<NodeId>,
}
#[react_message(name = "devtools.overlay")]
struct DevtoolsOverlayMessage {
on: bool,
}
#[react_message(name = "devtools.panelRoot")]
struct DevtoolsPanelRootMessage {
id: Option<NodeId>,
}
#[react_message(name = "devtools.dock")]
struct DevtoolsDockMessage {
side: Option<String>,
width: f32,
}
fn on_open_message(msg: On<DevtoolsOpenMessage>, mut state: ResMut<DevtoolsState>) {
state.open = msg.event().open;
if !state.open {
exit_interactions(&mut state);
}
}
fn on_pick_message(msg: On<DevtoolsPickMessage>, mut state: ResMut<DevtoolsState>) {
state.pick = msg.event().on;
if !state.pick {
state.pick_hover = None;
}
}
fn on_select_message(msg: On<DevtoolsSelectMessage>, mut state: ResMut<DevtoolsState>) {
state.selected = msg.event().id;
}
fn on_highlight_message(msg: On<DevtoolsHighlightMessage>, mut state: ResMut<DevtoolsState>) {
state.tree_hover = msg.event().id;
}
fn on_overlay_message(msg: On<DevtoolsOverlayMessage>, mut state: ResMut<DevtoolsState>) {
state.show_selection_overlay = msg.event().on;
}
fn on_panel_root_message(msg: On<DevtoolsPanelRootMessage>, mut state: ResMut<DevtoolsState>) {
state.panel_root = msg.event().id;
}
fn on_dock_message(msg: On<DevtoolsDockMessage>, mut state: ResMut<DevtoolsState>) {
state.dock_side = match msg.event().side.as_deref() {
Some("left") => Some(DockSide::Left),
Some("right") => Some(DockSide::Right),
_ => None,
};
state.dock_width = msg.event().width.max(0.0);
}
#[react_message(name = "devtools.settings")]
#[derive(serde::Serialize, Clone, PartialEq)]
#[serde(default)]
pub(crate) struct DevtoolsSettings {
open: bool,
mode: String,
width_frac: f32,
float_x_frac: f32,
float_y_frac: f32,
float_w_frac: f32,
float_h_frac: f32,
reserve: bool,
overlay: bool,
split: f32,
}
impl Default for DevtoolsSettings {
fn default() -> Self {
Self {
open: false,
mode: "right".into(),
width_frac: 0.3,
float_x_frac: 0.08,
float_y_frac: 0.1,
float_w_frac: 0.33,
float_h_frac: 0.7,
reserve: false,
overlay: true,
split: 260.0,
}
}
}
#[react_event(name = "devtools.restore")]
struct DevtoolsRestore {
open: bool,
mode: String,
width_frac: f32,
float_x_frac: f32,
float_y_frac: f32,
float_w_frac: f32,
float_h_frac: f32,
reserve: bool,
overlay: bool,
split: f32,
}
impl From<&DevtoolsSettings> for DevtoolsRestore {
fn from(s: &DevtoolsSettings) -> Self {
Self {
open: s.open,
mode: s.mode.clone(),
width_frac: s.width_frac,
float_x_frac: s.float_x_frac,
float_y_frac: s.float_y_frac,
float_w_frac: s.float_w_frac,
float_h_frac: s.float_h_frac,
reserve: s.reserve,
overlay: s.overlay,
split: s.split,
}
}
}
#[derive(Resource)]
struct DevtoolsPersistence {
loaded: Option<DevtoolsSettings>,
pending: Option<DevtoolsSettings>,
last_written: Option<DevtoolsSettings>,
#[cfg(not(target_arch = "wasm32"))]
dirty_at: Option<std::time::Instant>,
debounce: Duration,
}
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))]
fn load_settings(path: Option<&std::path::Path>) -> Option<DevtoolsSettings> {
#[cfg(not(target_arch = "wasm32"))]
{
let text = std::fs::read_to_string(path?).ok()?;
serde_json::from_str(&text).ok()
}
#[cfg(target_arch = "wasm32")]
None
}
#[cfg(not(target_arch = "wasm32"))]
fn write_pending(persist: &mut DevtoolsPersistence, path: &std::path::Path) {
persist.dirty_at = None;
let Some(pending) = persist.pending.clone() else {
return;
};
if persist.last_written.as_ref() == Some(&pending) {
return;
}
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
let _ = std::fs::create_dir_all(parent);
}
match serde_json::to_string_pretty(&pending) {
Ok(json) => match std::fs::write(path, json) {
Ok(()) => persist.last_written = Some(pending),
Err(e) => warn!("devtools: failed to write settings {}: {e}", path.display()),
},
Err(e) => warn!("devtools: failed to serialize settings: {e}"),
}
}
fn on_settings_message(
msg: On<DevtoolsSettings>,
#[cfg_attr(target_arch = "wasm32", allow(unused_mut, unused_variables))] mut persist: ResMut<
DevtoolsPersistence,
>,
) {
#[cfg(not(target_arch = "wasm32"))]
{
persist.pending = Some(msg.event().clone());
persist.dirty_at = Some(std::time::Instant::now());
}
#[cfg(target_arch = "wasm32")]
let _ = msg;
}
fn send_restore(
persist: Res<DevtoolsPersistence>,
stats: Res<OpApplyStats>,
cameras: Query<&Camera, With<IsDefaultUiCamera>>,
windows: Query<&Window>,
mut state: ResMut<DevtoolsState>,
events: ReactEvents,
mut done: Local<bool>,
) {
if *done {
return;
}
if stats.applied_count == 0 {
return;
}
*done = true;
if let Some(size) = ui_viewport_size(&cameras, &windows) {
events.send(&DevtoolsWindow {
width: size.x,
height: size.y,
});
}
let settings = persist.loaded.clone().unwrap_or_default();
events.send(&DevtoolsRestore::from(&settings));
if settings.open {
state.open = true;
events.send(&DevtoolsToggle { open: true });
}
}
fn send_window_size(
state: Res<DevtoolsState>,
stats: Res<OpApplyStats>,
cameras: Query<&Camera, With<IsDefaultUiCamera>>,
windows: Query<&Window>,
events: ReactEvents,
mut last: Local<Option<Vec2>>,
) {
if stats.applied_count == 0 {
return;
}
if !state.open {
*last = None;
return;
}
let Some(size) = ui_viewport_size(&cameras, &windows) else {
return;
};
if *last != Some(size) {
*last = Some(size);
events.send(&DevtoolsWindow {
width: size.x,
height: size.y,
});
}
}
#[cfg_attr(target_arch = "wasm32", allow(unused_mut, unused_variables))]
fn save_settings(mut persist: ResMut<DevtoolsPersistence>, config: Res<DevtoolsConfig>) {
#[cfg(not(target_arch = "wasm32"))]
{
let Some(path) = config.settings_path.clone() else {
return;
};
let debounce = persist.debounce;
if persist.dirty_at.is_some_and(|t| t.elapsed() >= debounce) {
write_pending(&mut persist, &path);
}
}
}
#[cfg_attr(target_arch = "wasm32", allow(unused_mut, unused_variables))]
fn flush_settings_on_exit(
mut exits: MessageReader<AppExit>,
mut persist: ResMut<DevtoolsPersistence>,
config: Res<DevtoolsConfig>,
) {
#[cfg(not(target_arch = "wasm32"))]
{
if exits.read().next().is_none() {
return;
}
let Some(path) = config.settings_path.clone() else {
return;
};
if persist.dirty_at.is_some() {
write_pending(&mut persist, &path);
}
}
#[cfg(target_arch = "wasm32")]
exits.clear();
}
fn exit_interactions(state: &mut DevtoolsState) {
state.pick = false;
state.tree_hover = None;
state.pick_hover = None;
}
fn toggle_on_key(
keys: Res<ButtonInput<KeyCode>>,
cfg: Res<DevtoolsConfig>,
mut state: ResMut<DevtoolsState>,
events: ReactEvents,
) {
if !keys.just_pressed(cfg.toggle_key) {
return;
}
state.open = !state.open;
if !state.open {
exit_interactions(&mut state);
}
events.send(&DevtoolsToggle { open: state.open });
}
fn apply_dock_reservation(
state: Res<DevtoolsState>,
windows: Query<&Window>,
mut root: Query<&mut Node, With<crate::plugin::UiRoot>>,
) {
let Ok(mut node) = root.single_mut() else {
return;
};
let reserved = if state.open { state.dock_side } else { None };
let width = match windows.single() {
Ok(window) => state.dock_width.min(window.width() - 100.0).max(0.0),
Err(_) => state.dock_width,
};
let (left, right) = match reserved {
Some(DockSide::Left) => (Val::Px(width), Val::ZERO),
Some(DockSide::Right) => (Val::ZERO, Val::Px(width)),
None => (Val::ZERO, Val::ZERO),
};
if node.margin.left != left || node.margin.right != right {
node.margin.left = left;
node.margin.right = right;
}
}
#[derive(Resource, Default)]
struct DevtoolsTimers {
#[cfg(not(target_arch = "wasm32"))]
pre_layout: Option<std::time::Instant>,
last_command: Duration,
last_layout: Duration,
seen_applied: u64,
}
#[cfg_attr(target_arch = "wasm32", allow(unused_mut, unused_variables))]
fn mark_pre_layout(mut timers: ResMut<DevtoolsTimers>) {
#[cfg(not(target_arch = "wasm32"))]
{
timers.pre_layout = Some(std::time::Instant::now());
}
}
fn mark_post_layout(stats: Res<OpApplyStats>, mut timers: ResMut<DevtoolsTimers>) {
if stats.applied_count == timers.seen_applied {
return;
}
timers.seen_applied = stats.applied_count;
#[cfg(not(target_arch = "wasm32"))]
if let (Some(end), Some(pre)) = (stats.last_apply_end, timers.pre_layout) {
let (command, layout) = split_legs(end, pre, std::time::Instant::now());
timers.last_command = command;
timers.last_layout = layout;
}
}
#[cfg(not(target_arch = "wasm32"))]
fn split_legs(
apply_end: std::time::Instant,
pre_layout: std::time::Instant,
post_layout: std::time::Instant,
) -> (Duration, Duration) {
(
pre_layout.saturating_duration_since(apply_end),
post_layout.saturating_duration_since(pre_layout),
)
}
#[allow(clippy::too_many_arguments)]
fn drive_pick_mode(
mut state: ResMut<DevtoolsState>,
hover_map: Option<Res<HoverMap>>,
mouse: Res<ButtonInput<MouseButton>>,
capture: Option<ResMut<PointerCapture>>,
bridge: Option<Res<JsBridge>>,
rnodes: Query<&RNode>,
child_of: Query<&ChildOf>,
events: ReactEvents,
) {
if !(state.open && state.pick) {
return;
}
if let Some(mut capture) = capture {
capture.over_ui = true;
}
let panel_entity = state
.panel_root
.and_then(|id| bridge.as_ref().and_then(|b| b.nodes.get(&id).copied()));
let hovered = hover_map
.as_deref()
.and_then(|hover_map| hover_map.get(&PointerId::Mouse))
.and_then(|hits| {
let (&top, _) = hits
.iter()
.filter(|&(&entity, _)| climb(entity, &child_of, |e| rnodes.contains(e)).is_some())
.min_by(|a, b| a.1.depth.total_cmp(&b.1.depth))?;
if let Some(panel) = panel_entity
&& climb(top, &child_of, |e| e == panel).is_some()
{
return None;
}
let owner = climb(top, &child_of, |e| rnodes.contains(e))?;
rnodes.get(owner).ok().map(|r| r.0)
});
state.pick_hover = hovered;
if mouse.just_pressed(MouseButton::Left)
&& let Some(id) = hovered
{
state.pick = false;
state.pick_hover = None;
state.selected = Some(id);
events.send(&DevtoolsPicked { id });
}
}
#[derive(Component)]
struct DevtoolsHighlightOverlay;
fn spawn_highlight_overlay(mut commands: Commands) {
commands.spawn((
DevtoolsHighlightOverlay,
Node {
position_type: PositionType::Absolute,
display: Display::None,
..default()
},
BackgroundColor(Color::srgba(0.54, 0.71, 0.97, 0.30)),
Outline {
width: Val::Px(1.0),
color: Color::srgba(0.54, 0.71, 0.97, 0.9),
..default()
},
GlobalZIndex(i32::MAX - 1),
Pickable::IGNORE,
));
}
fn position_highlight(
state: Res<DevtoolsState>,
bridge: Option<Res<JsBridge>>,
targets: Query<(&ComputedNode, &UiGlobalTransform)>,
mut overlay: Query<&mut Node, With<DevtoolsHighlightOverlay>>,
) {
let Ok(mut node) = overlay.single_mut() else {
return;
};
let Some(bridge) = bridge else {
return;
};
let target = state
.pick_hover
.or(state.tree_hover)
.or(state.selected.filter(|_| state.show_selection_overlay))
.filter(|_| state.open);
let rect = target
.and_then(|id| bridge.nodes.get(&id))
.and_then(|&e| targets.get(e).ok())
.map(|(computed, transform)| {
highlight_rect(
computed.size,
transform.translation,
computed.inverse_scale_factor,
)
});
match rect {
Some((pos, size)) => {
let (left, top) = (Val::Px(pos.x), Val::Px(pos.y));
let (width, height) = (Val::Px(size.x), Val::Px(size.y));
if node.display != Display::Flex
|| node.left != left
|| node.top != top
|| node.width != width
|| node.height != height
{
node.display = Display::Flex;
node.left = left;
node.top = top;
node.width = width;
node.height = height;
}
}
None => {
if node.display != Display::None {
node.display = Display::None;
}
}
}
}
fn highlight_rect(physical_size: Vec2, physical_center: Vec2, inverse_scale: f32) -> (Vec2, Vec2) {
let top_left = (physical_center - physical_size * 0.5) * inverse_scale;
(top_left, physical_size * inverse_scale)
}
fn emit_batch_stats(
state: Res<DevtoolsState>,
stats: Res<OpApplyStats>,
timers: Res<DevtoolsTimers>,
events: ReactEvents,
mut seen: Local<u64>,
) {
if stats.app_applied_count == *seen {
return;
}
*seen = stats.app_applied_count;
if !state.open {
return;
}
events.send(&DevtoolsBatchStats {
applied_count: stats.applied_count,
last_ops: stats.last_ops,
frame_wait_ms: stats.last_frame_wait.as_secs_f64() * 1000.0,
pre_apply_ms: stats.last_pre_apply.as_secs_f64() * 1000.0,
translate_ms: stats.last_translate.as_secs_f64() * 1000.0,
command_ms: timers.last_command.as_secs_f64() * 1000.0,
layout_ms: timers.last_layout.as_secs_f64() * 1000.0,
});
}
fn emit_runtime_warnings(
stats: Res<OpApplyStats>,
events: ReactEvents,
mut seen: Local<HashSet<u64>>,
mut last_reset: Local<u64>,
) {
if stats.reset_count != *last_reset {
*last_reset = stats.reset_count;
seen.clear();
}
if stats.applied_count == 0 {
return;
}
for w in crate::diag::take_runtime_warnings() {
let mut hasher = std::hash::DefaultHasher::new();
std::hash::Hash::hash(&(w.node, w.kind, &w.value, &w.message), &mut hasher);
if seen.insert(std::hash::Hasher::finish(&hasher)) {
events.send(&DevtoolsWarning {
id: w.node,
kind: w.kind.to_string(),
value: w.value,
message: w.message,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bridge::{OutboundResource, RRoot};
use crate::protocol::Outbound;
use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel};
fn test_app(config: DevtoolsConfig) -> (App, UnboundedReceiver<Outbound>) {
let mut app = App::new();
app.insert_non_send(crate::diag::test_lock());
app.add_plugins(MinimalPlugins);
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<ButtonInput<MouseButton>>();
app.init_resource::<OpApplyStats>();
let (tx, rx) = unbounded_channel::<Outbound>();
app.insert_resource(OutboundResource(tx));
app.add_plugins(DevtoolsPlugin::new(config));
(app, rx)
}
fn drain_events(rx: &mut UnboundedReceiver<Outbound>) -> Vec<(String, serde_json::Value)> {
let mut out = Vec::new();
while let Ok(msg) = rx.try_recv() {
if let Outbound::Event { name, value } = msg {
out.push((name, value));
}
}
out
}
#[test]
fn toggle_key_flips_state_and_notifies_js() {
let (mut app, mut rx) = test_app(DevtoolsConfig {
toggle_key: KeyCode::F9,
..default()
});
app.update();
assert!(drain_events(&mut rx).is_empty(), "no toggle before the key");
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::F9);
app.update();
let events = drain_events(&mut rx);
assert_eq!(
events
.iter()
.find(|(name, _)| name == "devtools.toggle")
.map(|(_, v)| v["open"].as_bool()),
Some(Some(true)),
"the configured key must open the panel and notify JS"
);
assert!(app.world().resource::<DevtoolsState>().open);
{
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.clear_just_pressed(KeyCode::F9);
keys.release(KeyCode::F9);
}
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::F9);
app.update();
let events = drain_events(&mut rx);
assert_eq!(
events
.iter()
.find(|(name, _)| name == "devtools.toggle")
.map(|(_, v)| v["open"].as_bool()),
Some(Some(false)),
"pressing again must close the panel"
);
assert!(!app.world().resource::<DevtoolsState>().open);
}
#[test]
fn restored_open_opens_panel_after_first_batch() {
let tmp = TempSettings::new("open");
let settings = DevtoolsSettings {
open: true,
..Default::default()
};
std::fs::write(&tmp.0, serde_json::to_string(&settings).unwrap()).unwrap();
let (mut app, mut rx) = test_app(DevtoolsConfig {
settings_path: Some(tmp.0.clone()),
..default()
});
app.update();
assert!(
drain_events(&mut rx).is_empty(),
"must not reopen before the React app mounted"
);
app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
app.update();
let events = drain_events(&mut rx);
assert!(
events
.iter()
.any(|(name, v)| name == "devtools.restore" && v["open"] == true),
"the restore blob must carry the persisted open state"
);
assert!(
events
.iter()
.any(|(name, v)| name == "devtools.toggle" && v["open"] == true),
"a persisted open must reopen the panel once a batch has been applied"
);
assert!(app.world().resource::<DevtoolsState>().open);
app.update();
assert!(
drain_events(&mut rx)
.iter()
.all(|(name, _)| name != "devtools.toggle" && name != "devtools.restore"),
"the restore-open must fire exactly once"
);
}
#[cfg(debug_assertions)]
#[test]
fn runtime_warnings_emit_once_and_reset_on_reload() {
let (mut app, mut rx) = test_app(DevtoolsConfig {
settings_path: None,
..default()
});
let _ = crate::diag::take_runtime_warnings();
app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
let report = || {
let _scope = crate::diag::node_scope(31337);
crate::diag::report("color", "redd", "unrecognized color \"redd\"");
};
let mine = |events: &[(String, serde_json::Value)]| {
events
.iter()
.filter(|(name, v)| name == "devtools.warning" && v["id"] == 31337)
.count()
};
report();
app.update();
let events = drain_events(&mut rx);
assert_eq!(
mine(&events),
1,
"first report ships (panel closed is fine)"
);
assert!(
events.iter().any(|(name, v)| name == "devtools.warning"
&& v["kind"] == "color"
&& v["value"] == "redd"
&& v["message"].as_str().is_some_and(|m| m.contains("redd"))),
"the event carries kind/value/message"
);
report();
app.update();
assert_eq!(
mine(&drain_events(&mut rx)),
0,
"an identical re-report is deduped"
);
app.world_mut().resource_mut::<OpApplyStats>().reset_count += 1;
report();
app.update();
assert_eq!(
mine(&drain_events(&mut rx)),
1,
"a hot reload clears the dedup set so warnings re-flag"
);
}
#[test]
fn js_warning_kind_table_covers_known_kinds() {
let warnings_ts = include_str!("../../../js/src/devtools/warnings.ts");
for kind in [
"display",
"boxSizing",
"positionType",
"overflow",
"alignItems",
"justifyItems",
"alignSelf",
"justifySelf",
"alignContent",
"justifyContent",
"flexDirection",
"flexWrap",
"gridAutoFlow",
"focusPolicy",
"textAlign",
"lineBreak",
"fontSize",
"fontWeight",
"rect",
"gridTrack",
"gridPlacement",
"borderColor",
"scrollbar",
"animatedStyle",
"color",
"fontFamily",
"cursor",
"lineHeight",
"letterSpacing",
] {
assert!(
warnings_ts.contains(&format!("{kind}:"))
|| warnings_ts.contains(&format!("\"{kind}\":")),
"js/src/devtools/warnings.ts KIND_FIELDS is missing kind \"{kind}\""
);
}
}
#[test]
fn js_style_field_table_covers_every_style_field() {
let fields_ts = include_str!("../../../js/src/devtools/fields.ts");
macro_rules! check_fields {
($(($field:ident, $wire:literal, ($($group:tt)*), $overlay:ident)),* $(,)?) => {
$(
assert!(
fields_ts.contains(concat!($wire, ":"))
|| fields_ts.contains(concat!("\"", $wire, "\":")),
concat!(
"js/src/devtools/fields.ts is missing style field \"",
$wire,
"\" — add it to STYLE_FIELDS with a category"
)
);
)*
};
}
crate::protocol::with_style_fields!(check_fields);
}
fn pick_world(pressed: bool) -> (World, UnboundedReceiver<Outbound>) {
let mut world = World::new();
world.insert_resource(DevtoolsState {
open: true,
pick: true,
..Default::default()
});
let mut mouse = ButtonInput::<MouseButton>::default();
if pressed {
mouse.press(MouseButton::Left);
}
world.insert_resource(mouse);
let (tx, rx) = unbounded_channel::<Outbound>();
world.insert_resource(OutboundResource(tx));
(world, rx)
}
fn report_panel_root(world: &mut World, id: NodeId, panel_entity: Entity) {
let (out_tx, out_rx) = unbounded_channel::<Outbound>();
std::mem::forget(out_rx);
let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<crate::protocol::Op>>();
std::mem::forget(ops_tx);
let root = world.spawn_empty().id();
let mut bridge = JsBridge::new(ops_rx, out_tx, root);
bridge.nodes.insert(id, panel_entity);
world.insert_resource(bridge);
world.resource_mut::<DevtoolsState>().panel_root = Some(id);
}
fn set_mouse_hits(world: &mut World, hits: &[(Entity, f32)]) {
use bevy::ecs::entity::EntityHashMap;
use bevy::picking::backend::HitData;
let mut hovered = EntityHashMap::default();
for &(entity, depth) in hits {
hovered.insert(entity, HitData::new(Entity::PLACEHOLDER, depth, None, None));
}
let mut hover_map = HoverMap::default();
hover_map.insert(PointerId::Mouse, hovered);
world.insert_resource(hover_map);
}
#[test]
fn pick_ignores_mesh_hits_and_takes_frontmost_ui_node() {
use bevy::ecs::system::RunSystemOnce;
let (mut world, _rx) = pick_world(false);
let mesh = world.spawn_empty().id(); let node = world.spawn(RNode(7)).id();
let leaf = world.spawn(ChildOf(node)).id(); set_mouse_hits(&mut world, &[(mesh, 0.5), (leaf, 30.0)]);
world.run_system_once(drive_pick_mode).unwrap();
assert_eq!(
world.resource::<DevtoolsState>().pick_hover,
Some(7),
"the frontmost RNode-resolving hit must win; mesh hits are ignored"
);
}
#[test]
fn pick_rejects_panel_hits() {
use bevy::ecs::system::RunSystemOnce;
let (mut world, _rx) = pick_world(false);
let panel_root = world.spawn((RRoot, RNode(100))).id();
let panel_button = world.spawn((RNode(101), ChildOf(panel_root))).id();
let app_node = world.spawn(RNode(7)).id();
report_panel_root(&mut world, 100, panel_root);
set_mouse_hits(&mut world, &[(panel_button, 1.0), (app_node, 5.0)]);
world.run_system_once(drive_pick_mode).unwrap();
assert_eq!(
world.resource::<DevtoolsState>().pick_hover,
None,
"a panel hit in front must block picking (no pick-through)"
);
}
#[test]
fn pick_allows_nodes_under_app_roots() {
use bevy::ecs::system::RunSystemOnce;
let (mut world, _rx) = pick_world(false);
let panel_root = world.spawn((RRoot, RNode(100))).id();
let app_root = world.spawn((RRoot, RNode(50))).id();
let overlay_node = world.spawn((RNode(7), ChildOf(app_root))).id();
report_panel_root(&mut world, 100, panel_root);
set_mouse_hits(&mut world, &[(overlay_node, 5.0)]);
world.run_system_once(drive_pick_mode).unwrap();
assert_eq!(
world.resource::<DevtoolsState>().pick_hover,
Some(7),
"a node under an app <root> must be pickable"
);
}
#[test]
fn pick_prefers_frontmost_app_hit_over_panel_behind() {
use bevy::ecs::system::RunSystemOnce;
let (mut world, _rx) = pick_world(false);
let panel_root = world.spawn((RRoot, RNode(100))).id();
let panel_button = world.spawn((RNode(101), ChildOf(panel_root))).id();
let app_node = world.spawn(RNode(7)).id();
report_panel_root(&mut world, 100, panel_root);
set_mouse_hits(&mut world, &[(panel_button, 5.0), (app_node, 1.0)]);
world.run_system_once(drive_pick_mode).unwrap();
assert_eq!(
world.resource::<DevtoolsState>().pick_hover,
Some(7),
"an app node in front of the panel must win"
);
}
#[test]
fn pick_click_selects_and_notifies_js() {
use bevy::ecs::system::RunSystemOnce;
let (mut world, mut rx) = pick_world(true);
let app_node = world.spawn(RNode(7)).id();
set_mouse_hits(&mut world, &[(app_node, 5.0)]);
world.run_system_once(drive_pick_mode).unwrap();
let state = world.resource::<DevtoolsState>();
assert_eq!(state.selected, Some(7));
assert!(!state.pick, "a successful pick exits pick mode");
match rx.try_recv().expect("a devtools.picked event") {
Outbound::Event { name, value } => {
assert_eq!(name, "devtools.picked");
assert_eq!(value["id"], 7);
}
other => panic!("expected Outbound::Event, got {other:?}"),
}
}
#[test]
fn dock_reservation_insets_uiroot_margin() {
let (mut app, _rx) = test_app(DevtoolsConfig::default());
let root = app
.world_mut()
.spawn((Node::default(), crate::plugin::UiRoot))
.id();
let margin = |app: &mut App| {
let node = app.world().entity(root).get::<Node>().unwrap();
(node.margin.left, node.margin.right)
};
{
let mut state = app.world_mut().resource_mut::<DevtoolsState>();
state.open = true;
state.dock_side = Some(DockSide::Right);
state.dock_width = 300.0;
}
app.update();
assert_eq!(margin(&mut app), (Val::ZERO, Val::Px(300.0)));
app.world_mut().resource_mut::<DevtoolsState>().dock_side = Some(DockSide::Left);
app.update();
assert_eq!(margin(&mut app), (Val::Px(300.0), Val::ZERO));
app.world_mut().resource_mut::<DevtoolsState>().open = false;
app.update();
assert_eq!(margin(&mut app), (Val::ZERO, Val::ZERO));
}
#[test]
fn dock_message_parses_side_and_clamps_width() {
let (mut app, _rx) = test_app(DevtoolsConfig::default());
let dock = |app: &mut App, side: Option<&str>, width: f32| {
app.world_mut().trigger(DevtoolsDockMessage {
side: side.map(String::from),
width,
});
let state = app.world().resource::<DevtoolsState>();
(state.dock_side, state.dock_width)
};
assert_eq!(
dock(&mut app, Some("left"), 320.0),
(Some(DockSide::Left), 320.0)
);
assert_eq!(
dock(&mut app, Some("right"), 280.0),
(Some(DockSide::Right), 280.0)
);
assert_eq!(dock(&mut app, Some("bogus"), -5.0), (None, 0.0));
assert_eq!(dock(&mut app, None, 380.0), (None, 380.0));
}
#[test]
fn batch_stats_skip_devtools_only_applies() {
let (mut app, mut rx) = test_app(DevtoolsConfig {
settings_path: None,
..default()
});
app.world_mut().resource_mut::<DevtoolsState>().open = true;
let stats_events = |rx: &mut UnboundedReceiver<Outbound>| {
drain_events(rx)
.into_iter()
.filter(|(name, _)| name == "devtools.batchStats")
.count()
};
{
let mut stats = app.world_mut().resource_mut::<OpApplyStats>();
stats.applied_count = 1;
stats.app_applied_count = 0;
}
app.update();
assert_eq!(
stats_events(&mut rx),
0,
"the panel's own commits must not produce batch stats"
);
{
let mut stats = app.world_mut().resource_mut::<OpApplyStats>();
stats.applied_count = 2;
stats.app_applied_count = 1;
}
app.update();
let stats: Vec<_> = drain_events(&mut rx)
.into_iter()
.filter(|(name, _)| name == "devtools.batchStats")
.collect();
assert_eq!(stats.len(), 1, "an app apply reports once");
assert!(
stats[0].1.get("frame_wait_ms").is_some(),
"batch stats carry the frame-wait leg"
);
}
struct TempSettings(std::path::PathBuf);
impl TempSettings {
fn new(name: &str) -> Self {
Self(std::env::temp_dir().join(format!(
"bevy-react-devtools-{name}-{}.json",
std::process::id()
)))
}
}
impl Drop for TempSettings {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
fn sample_settings() -> DevtoolsSettings {
DevtoolsSettings {
open: false,
mode: "float".into(),
width_frac: 0.4,
float_x_frac: 0.05,
float_y_frac: 0.1,
float_w_frac: 0.5,
float_h_frac: 0.6,
reserve: true,
overlay: false,
split: 200.0,
}
}
fn drain_restores(rx: &mut UnboundedReceiver<Outbound>) -> Vec<serde_json::Value> {
drain_events(rx)
.into_iter()
.filter(|(name, _)| name == "devtools.restore")
.map(|(_, v)| v)
.collect()
}
#[test]
fn settings_file_seeds_overlay_and_restores_once() {
let tmp = TempSettings::new("restore");
std::fs::write(&tmp.0, serde_json::to_string(&sample_settings()).unwrap()).unwrap();
let (mut app, mut rx) = test_app(DevtoolsConfig {
settings_path: Some(tmp.0.clone()),
..default()
});
assert!(
!app.world()
.resource::<DevtoolsState>()
.show_selection_overlay,
"the loaded overlay=false must seed the state at build"
);
app.update();
assert!(
drain_restores(&mut rx).is_empty(),
"no restore before the React app mounted"
);
app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
app.update();
let restores = drain_restores(&mut rx);
assert_eq!(restores.len(), 1, "exactly one restore after mount");
assert_eq!(restores[0]["mode"], "float");
assert_eq!(restores[0]["split"], 200.0);
assert_eq!(restores[0]["overlay"], false);
app.update();
assert!(drain_restores(&mut rx).is_empty(), "restore is one-shot");
}
#[test]
fn settings_message_writes_file_debounced() {
let tmp = TempSettings::new("save");
let (mut app, _rx) = test_app(DevtoolsConfig {
settings_path: Some(tmp.0.clone()),
..default()
});
app.world_mut()
.resource_mut::<DevtoolsPersistence>()
.debounce = Duration::ZERO;
app.world_mut().trigger(sample_settings());
app.update();
let written: DevtoolsSettings =
serde_json::from_str(&std::fs::read_to_string(&tmp.0).unwrap()).unwrap();
assert!(written == sample_settings(), "full round-trip");
let mtime = |p: &std::path::Path| std::fs::metadata(p).unwrap().modified().unwrap();
let before = mtime(&tmp.0);
app.world_mut().trigger(sample_settings());
app.update();
assert_eq!(mtime(&tmp.0), before, "identical settings skip the write");
}
#[test]
fn settings_flush_on_app_exit() {
let tmp = TempSettings::new("flush");
let (mut app, _rx) = test_app(DevtoolsConfig {
settings_path: Some(tmp.0.clone()),
..default()
});
app.world_mut().trigger(sample_settings());
app.update();
assert!(!tmp.0.exists(), "still inside the debounce window");
app.world_mut().write_message(AppExit::Success);
app.update();
assert!(tmp.0.exists(), "AppExit must flush the pending settings");
}
#[test]
fn corrupt_or_disabled_settings_are_ignored() {
let tmp = TempSettings::new("corrupt");
std::fs::write(&tmp.0, "{ not json").unwrap();
let (mut app, mut rx) = test_app(DevtoolsConfig {
settings_path: Some(tmp.0.clone()),
..default()
});
assert!(
app.world()
.resource::<DevtoolsState>()
.show_selection_overlay
);
app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
app.update();
let restores = drain_restores(&mut rx);
assert_eq!(restores.len(), 1, "corrupt file → one restore, defaults");
assert_eq!(restores[0]["mode"], "right");
assert_eq!(restores[0]["open"], false);
assert!(!app.world().resource::<DevtoolsState>().open);
drop(app);
let (mut app, _rx) = test_app(DevtoolsConfig {
settings_path: None,
..default()
});
app.world_mut()
.resource_mut::<DevtoolsPersistence>()
.debounce = Duration::ZERO;
app.world_mut().trigger(sample_settings());
app.update(); }
#[test]
fn restore_defaults_sent_without_settings_file() {
let (mut app, mut rx) = test_app(DevtoolsConfig {
settings_path: None,
..default()
});
app.update();
assert!(drain_restores(&mut rx).is_empty(), "not before mount");
app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
app.update();
let restores = drain_restores(&mut rx);
assert_eq!(restores.len(), 1, "defaults restore exactly once");
assert_eq!(restores[0]["open"], false);
assert_eq!(restores[0]["mode"], "right");
app.update();
assert!(drain_restores(&mut rx).is_empty(), "one-shot");
}
#[test]
fn legacy_pixel_settings_file_migrates() {
let tmp = TempSettings::new("legacy");
let legacy = serde_json::json!({
"mode": "float",
"width": 420.0,
"float_x": 10.0,
"float_y": 20.0,
"float_w": 500.0,
"float_h": 600.0,
"reserve": true,
"overlay": false,
"split": 200.0,
});
std::fs::write(&tmp.0, legacy.to_string()).unwrap();
let (mut app, mut rx) = test_app(DevtoolsConfig {
settings_path: Some(tmp.0.clone()),
..default()
});
app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
app.update();
let restores = drain_restores(&mut rx);
assert_eq!(restores.len(), 1, "a legacy file must still restore");
assert_eq!(restores[0]["mode"], "float");
assert_eq!(restores[0]["overlay"], false);
assert_eq!(restores[0]["split"], 200.0);
assert_eq!(restores[0]["open"], false, "no persisted open → closed");
let frac = restores[0]["width_frac"].as_f64().expect("a number");
assert!(
(frac - f64::from(DevtoolsSettings::default().width_frac)).abs() < 1e-6,
"stale pixel width is ignored; the fraction takes its default"
);
}
#[test]
fn window_size_sent_on_open_and_resize() {
use bevy::window::WindowResolution;
let (mut app, mut rx) = test_app(DevtoolsConfig {
settings_path: None,
..default()
});
let window = app
.world_mut()
.spawn(Window {
resolution: WindowResolution::new(800, 600),
..Default::default()
})
.id();
let sizes = |rx: &mut UnboundedReceiver<Outbound>| {
drain_events(rx)
.into_iter()
.filter(|(name, _)| name == "devtools.window")
.map(|(_, v)| (v["width"].as_f64().unwrap(), v["height"].as_f64().unwrap()))
.collect::<Vec<_>>()
};
app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
app.update();
let restore_frame = sizes(&mut rx);
assert_eq!(
restore_frame,
vec![(800.0, 600.0)],
"the restore one-shot sends the size once, ahead of the blob"
);
app.world_mut().resource_mut::<DevtoolsState>().open = true;
app.update();
assert_eq!(sizes(&mut rx), vec![(800.0, 600.0)], "sent on open");
app.update();
assert!(sizes(&mut rx).is_empty(), "idle frames are silent");
app.world_mut()
.entity_mut(window)
.get_mut::<Window>()
.unwrap()
.resolution = WindowResolution::new(1024, 768);
app.update();
assert_eq!(sizes(&mut rx), vec![(1024.0, 768.0)], "sent on resize");
app.world_mut().resource_mut::<DevtoolsState>().open = false;
app.update();
app.world_mut()
.entity_mut(window)
.get_mut::<Window>()
.unwrap()
.resolution = WindowResolution::new(640, 480);
app.update();
assert!(sizes(&mut rx).is_empty(), "closed: no size traffic");
app.world_mut().resource_mut::<DevtoolsState>().open = true;
app.update();
assert_eq!(
sizes(&mut rx),
vec![(640.0, 480.0)],
"reopen must catch up on a resize that happened while closed"
);
}
#[test]
fn highlight_rect_converts_physical_center_to_logical_top_left() {
let (pos, size) = highlight_rect(Vec2::new(200.0, 100.0), Vec2::new(300.0, 150.0), 0.5);
assert_eq!(pos, Vec2::new(100.0, 50.0));
assert_eq!(size, Vec2::new(100.0, 50.0));
}
#[test]
fn split_legs_computes_command_and_layout() {
let t0 = std::time::Instant::now();
let t1 = t0 + Duration::from_millis(5);
let t2 = t1 + Duration::from_millis(7);
assert_eq!(
split_legs(t0, t1, t2),
(Duration::from_millis(5), Duration::from_millis(7))
);
assert_eq!(split_legs(t1, t0, t2), (Duration::ZERO, t2 - t0));
}
}