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>>();
app.init_resource::<crate::layer::LayersRegistry>();
app.init_resource::<crate::layer::LayerMembership>();
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_react_handler(on_layers_open_message)
.add_react_handler(on_console_open_message)
.add_react_handler(on_console_clear_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,
emit_console,
),
)
.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),
emit_layers
.after(crate::layer::sync_layer_geometry)
.after(crate::layer::resolve_layer_repaints),
),
);
}
}
#[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,
pub layers_tab_open: bool,
pub console_tab_open: bool,
pub console_last_seq: Option<u64>,
}
#[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,
layers_tab_open: false,
console_tab_open: false,
console_last_seq: None,
}
}
}
#[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_event(name = "devtools.layers")]
struct DevtoolsLayers {
layers: Vec<DevtoolsLayerRow>,
}
#[derive(serde::Serialize, ts_rs::TS, Debug, Clone, PartialEq)]
struct DevtoolsLayerRow {
id: NodeId,
reasons: Vec<String>,
depth: u32,
node_count: u32,
rect: Option<DevtoolsLayerRect>,
repaints: u64,
filters: Vec<DevtoolsFilterEntry>,
backdrop_filters: Vec<DevtoolsFilterEntry>,
}
#[derive(serde::Serialize, ts_rs::TS, Debug, Clone, PartialEq)]
struct DevtoolsFilterEntry {
name: String,
params: Vec<(String, Vec<f64>)>,
}
#[derive(serde::Serialize, ts_rs::TS, Debug, Clone, PartialEq)]
struct DevtoolsLayerRect {
x: f32,
y: f32,
width: f32,
height: f32,
physical_width: u32,
physical_height: u32,
}
#[react_event(name = "devtools.console")]
struct DevtoolsConsole {
entries: Vec<DevtoolsConsoleEntry>,
}
#[derive(serde::Serialize, ts_rs::TS, Debug, Clone, PartialEq)]
struct DevtoolsConsoleEntry {
seq: u64,
time_ms: u64,
source: String,
level: 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.layersOpen")]
struct DevtoolsLayersOpenMessage {
on: bool,
}
#[react_message(name = "devtools.consoleOpen")]
struct DevtoolsConsoleOpenMessage {
on: bool,
}
#[react_message(name = "devtools.consoleClear")]
struct DevtoolsConsoleClearMessage {}
#[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_layers_open_message(msg: On<DevtoolsLayersOpenMessage>, mut state: ResMut<DevtoolsState>) {
state.layers_tab_open = msg.event().on;
}
fn on_console_open_message(msg: On<DevtoolsConsoleOpenMessage>, mut state: ResMut<DevtoolsState>) {
state.console_tab_open = msg.event().on;
state.console_last_seq = None;
}
fn on_console_clear_message(_msg: On<DevtoolsConsoleClearMessage>) {
crate::console_log::clear();
}
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,
tab: String,
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,
tab: "nodes".into(),
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,
tab: String,
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,
tab: s.tab.clone(),
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;
state.layers_tab_open = false;
state.console_tab_open = false;
state.console_last_seq = 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;
capture.wheel_captured = 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_console(mut state: ResMut<DevtoolsState>, events: ReactEvents) {
if !(state.open && state.console_tab_open) {
return;
}
let (entries, watermark) = crate::console_log::since(state.console_last_seq.unwrap_or(0));
state.console_last_seq = Some(watermark);
if entries.is_empty() {
return;
}
events.send(&DevtoolsConsole {
entries: entries
.into_iter()
.map(|e| DevtoolsConsoleEntry {
seq: e.seq,
time_ms: e.time_ms,
source: e.source.as_str().into(),
level: e.level.as_str().into(),
message: e.message,
})
.collect(),
});
}
fn reason_labels(reasons: crate::layer::PromotionReasons) -> Vec<String> {
let mut out = Vec::new();
if reasons.0 & crate::layer::PromotionReasons::OPACITY != 0 {
out.push("opacity".to_string());
}
if reasons.0 & crate::layer::PromotionReasons::FILTER != 0 {
out.push("filter".to_string());
}
if reasons.0 & crate::layer::PromotionReasons::TRANSFORM3D != 0 {
out.push("transform3d".to_string());
}
if reasons.0 & crate::layer::PromotionReasons::BACKDROP != 0 {
out.push("backdrop".to_string());
}
if reasons.0 & crate::layer::PromotionReasons::FORCED != 0 {
out.push("cache".to_string());
}
out
}
fn round3(v: f32) -> f64 {
(f64::from(v) * 1000.0).round() / 1000.0
}
fn filter_entries(
chain: &crate::filters::ResolvedFilterChain,
input: Option<&crate::filters::FilterChain>,
) -> Vec<DevtoolsFilterEntry> {
use crate::animations::ValueKind;
let scale = if chain.scale > 0.0 { chain.scale } else { 1.0 };
let mut out: Vec<DevtoolsFilterEntry> = Vec::new();
let mut last_wire = None;
for pass in &chain.passes {
if last_wire == Some(pass.wire_index) {
continue;
}
last_wire = Some(pass.wire_index);
let name = input
.and_then(|i| i.0.get(pass.wire_index as usize))
.map(|u| u.name.clone())
.unwrap_or_else(|| format!("#{}", pass.wire_index));
let params = pass
.layout
.iter()
.map(|slot| {
let values = (slot.comp..(slot.comp + slot.len).min(4))
.map(|comp| {
let raw = pass.params.get(slot.vec).map_or(0.0, |v| v[comp]);
round3(match slot.kind {
ValueKind::Angle => raw.to_degrees(),
ValueKind::Length => raw / scale,
ValueKind::Scalar | ValueKind::Color => raw,
})
})
.collect();
(slot.name.to_string(), values)
})
.collect();
out.push(DevtoolsFilterEntry { name, params });
}
out
}
fn viewport_physical_size(
cameras: &Query<&Camera, With<IsDefaultUiCamera>>,
windows: &Query<&Window>,
) -> Option<UVec2> {
if let Ok(camera) = cameras.single()
&& let Some(size) = camera.physical_viewport_size()
{
return Some(size);
}
windows.single().ok().map(|window| {
UVec2::new(
window.resolution.physical_width(),
window.resolution.physical_height(),
)
})
}
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn emit_layers(
state: Res<DevtoolsState>,
registry: Res<crate::layer::LayersRegistry>,
membership: Res<crate::layer::LayerMembership>,
bridge: Option<Res<JsBridge>>,
rnodes: Query<(), With<RNode>>,
child_of: Query<&ChildOf>,
computed: Query<&ComputedNode>,
chains: Query<(
Option<&crate::filters::ResolvedFilterChain>,
Option<&crate::filters::FilterInput>,
Option<&crate::filters::ResolvedBackdropChain>,
Option<&crate::filters::BackdropInput>,
)>,
cameras: Query<&Camera, With<IsDefaultUiCamera>>,
windows: Query<&Window>,
events: ReactEvents,
mut last: Local<Option<Vec<DevtoolsLayerRow>>>,
) {
if !(state.open && state.layers_tab_open) {
*last = None;
return;
}
let Some(logical) = ui_viewport_size(&cameras, &windows) else {
return;
};
let physical = viewport_physical_size(&cameras, &windows).unwrap_or(UVec2::new(
logical.x.round() as u32,
logical.y.round() as u32,
));
let fallback_inverse_scale = if physical.x > 0 {
logical.x / physical.x as f32
} else {
1.0
};
let panel_entity = state
.panel_root
.and_then(|id| bridge.as_ref().and_then(|b| b.nodes.get(&id).copied()));
let mut rows = Vec::with_capacity(registry.layers.len() + 1);
rows.push(DevtoolsLayerRow {
id: 0,
reasons: vec!["base".to_string()],
depth: 0,
node_count: 0,
rect: Some(DevtoolsLayerRect {
x: 0.0,
y: 0.0,
width: logical.x,
height: logical.y,
physical_width: physical.x,
physical_height: physical.y,
}),
repaints: 0,
filters: Vec::new(),
backdrop_filters: Vec::new(),
});
for meta in registry.layers.values() {
if let Some(panel) = panel_entity
&& climb(meta.entity, &child_of, |e| e == panel).is_some()
{
continue;
}
let inverse_scale = computed
.get(meta.entity)
.map(|c| c.inverse_scale_factor)
.unwrap_or(fallback_inverse_scale);
let node_count = membership
.node_to_layer
.iter()
.filter(|&(node, layer)| *layer == meta.entity && rnodes.contains(*node))
.count() as u32;
let (filters, backdrop_filters) = chains
.get(meta.entity)
.map(|(chain, input, bchain, binput)| {
(
chain
.map(|c| filter_entries(c, input.map(|i| &i.0)))
.unwrap_or_default(),
bchain
.map(|c| filter_entries(&c.0, binput.map(|i| &i.0)))
.unwrap_or_default(),
)
})
.unwrap_or_default();
rows.push(DevtoolsLayerRow {
id: meta.node,
reasons: reason_labels(meta.reasons),
depth: meta.depth,
node_count,
rect: meta.capture_rect.map(|r| DevtoolsLayerRect {
x: r.min.x as f32 * inverse_scale,
y: r.min.y as f32 * inverse_scale,
width: r.width() as f32 * inverse_scale,
height: r.height() as f32 * inverse_scale,
physical_width: r.width().max(0) as u32,
physical_height: r.height().max(0) as u32,
}),
repaints: meta.repaints,
filters,
backdrop_filters,
});
}
rows.sort_by_key(|r| (r.depth, r.id));
if last.as_ref() != Some(&rows) {
events.send(&DevtoolsLayers {
layers: rows.clone(),
});
*last = Some(rows);
}
}
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 pick_mode_claims_hover_and_wheel_channels() {
let (mut app, _rx) = test_app(DevtoolsConfig::default());
app.init_resource::<crate::PointerCapture>();
{
let mut state = app.world_mut().resource_mut::<DevtoolsState>();
state.open = true;
state.pick = true;
}
app.update();
let capture = app.world().resource::<crate::PointerCapture>();
assert!(capture.over_ui, "pick mode must claim the hover channel");
assert!(
capture.wheel_captured,
"pick mode must claim the wheel channel too"
);
}
#[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",
"filterParams",
"filterUnknown",
"filterBleed",
"filterBinding",
"backdropFilterParams",
"backdropFilterUnknown",
"backdropFilterBinding",
"scrollbar",
"styleBinding",
"color",
"fontFamily",
"cursor",
"lineHeight",
"letterSpacing",
"cache",
] {
assert!(
warnings_ts.contains(&format!("{kind}:"))
|| warnings_ts.contains(&format!("\"{kind}\":")),
"js/src/devtools/warnings.ts KIND_FIELDS is missing kind \"{kind}\""
);
}
}
#[test]
fn reason_labels_cover_all_rules() {
use crate::layer::PromotionReasons;
let labels = |bits: u32| reason_labels(PromotionReasons(bits));
assert_eq!(labels(PromotionReasons::OPACITY), ["opacity"]);
assert_eq!(labels(PromotionReasons::FILTER), ["filter"]);
assert_eq!(labels(PromotionReasons::TRANSFORM3D), ["transform3d"]);
assert_eq!(labels(PromotionReasons::BACKDROP), ["backdrop"]);
assert_eq!(labels(PromotionReasons::FORCED), ["cache"]);
assert_eq!(
labels(
PromotionReasons::OPACITY
| PromotionReasons::FILTER
| PromotionReasons::TRANSFORM3D
| PromotionReasons::BACKDROP
| PromotionReasons::FORCED
),
["opacity", "filter", "transform3d", "backdrop", "cache"]
);
}
#[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,
tab: "layers".into(),
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");
assert_eq!(restores[0]["tab"], "nodes", "no persisted tab → default");
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 layers_emit_gated_and_diffed() {
use crate::layer::{LayerMeta, LayersRegistry, PromotionReasons};
use bevy::window::WindowResolution;
let (mut app, mut rx) = test_app(DevtoolsConfig {
settings_path: None,
..default()
});
app.world_mut().spawn(Window {
resolution: WindowResolution::new(800, 600),
..Default::default()
});
let entity = app.world_mut().spawn(RNode(7)).id();
app.world_mut()
.resource_mut::<LayersRegistry>()
.layers
.insert(
7,
LayerMeta {
node: 7,
entity,
reasons: PromotionReasons(PromotionReasons::OPACITY),
group_alpha: 0.5,
capture_rect: Some(IRect::new(10, 10, 110, 60)),
depth: 1,
repaints: 0,
cached: false,
cache_policy: Default::default(),
},
);
let payloads = |rx: &mut UnboundedReceiver<Outbound>| {
drain_events(rx)
.into_iter()
.filter(|(name, _)| name == "devtools.layers")
.map(|(_, v)| v)
.collect::<Vec<_>>()
};
app.update();
assert!(
payloads(&mut rx).is_empty(),
"closed panel: no layer stream"
);
{
let mut state = app.world_mut().resource_mut::<DevtoolsState>();
state.open = true;
state.layers_tab_open = true;
}
app.update();
let sent = payloads(&mut rx);
assert_eq!(sent.len(), 1, "opening the tab sends one payload");
let layers = sent[0]["layers"].as_array().expect("a layers array");
assert_eq!(layers.len(), 2, "base row + one registry row");
assert_eq!(layers[0]["id"], 0);
assert_eq!(layers[0]["reasons"], serde_json::json!(["base"]));
assert_eq!(layers[0]["depth"], 0);
assert_eq!(layers[0]["rect"]["width"], 800.0);
assert_eq!(layers[0]["rect"]["physical_height"], 600);
assert_eq!(layers[1]["id"], 7);
assert_eq!(layers[1]["reasons"], serde_json::json!(["opacity"]));
assert_eq!(layers[1]["depth"], 1);
assert_eq!(layers[1]["node_count"], 0, "empty membership map");
assert_eq!(layers[1]["rect"]["x"], 10.0);
assert_eq!(layers[1]["rect"]["y"], 10.0);
assert_eq!(layers[1]["rect"]["width"], 100.0);
assert_eq!(layers[1]["rect"]["height"], 50.0);
assert_eq!(layers[1]["rect"]["physical_width"], 100);
assert_eq!(
layers[1]["filters"],
serde_json::json!([]),
"no resolved chain on the entity: empty filters"
);
app.update();
assert!(payloads(&mut rx).is_empty(), "idle frames are silent");
app.world_mut()
.resource_mut::<LayersRegistry>()
.layers
.get_mut(&7)
.unwrap()
.capture_rect = Some(IRect::new(-20, -10, 80, 40));
app.update();
let sent = payloads(&mut rx);
assert_eq!(sent.len(), 1, "a rect change re-emits");
assert_eq!(sent[0]["layers"][1]["rect"]["x"], -20.0);
assert_eq!(sent[0]["layers"][1]["rect"]["y"], -10.0);
assert_eq!(sent[0]["layers"][1]["rect"]["width"], 100.0);
assert_eq!(sent[0]["layers"][1]["rect"]["physical_height"], 50);
app.world_mut()
.resource_mut::<LayersRegistry>()
.layers
.get_mut(&7)
.unwrap()
.capture_rect = None;
app.update();
let sent = payloads(&mut rx);
assert_eq!(sent.len(), 1);
assert_eq!(sent[0]["layers"].as_array().unwrap().len(), 2);
assert!(
sent[0]["layers"][1]["rect"].is_null(),
"inactive layers stay listed with a null rect"
);
app.world_mut()
.resource_mut::<DevtoolsState>()
.layers_tab_open = false;
app.world_mut()
.resource_mut::<LayersRegistry>()
.layers
.get_mut(&7)
.unwrap()
.capture_rect = Some(IRect::new(10, 10, 110, 60));
app.update();
assert!(payloads(&mut rx).is_empty(), "hidden tab: silence");
app.world_mut()
.resource_mut::<DevtoolsState>()
.layers_tab_open = true;
app.update();
assert_eq!(
payloads(&mut rx).len(),
1,
"a re-shown tab gets a fresh full payload"
);
}
#[test]
fn layers_emit_resolved_filter_chain() {
use crate::animations::ValueKind;
use crate::filters::{
FilterChain, FilterInput, FilterUse, ParamSlot, ResolvedFilterChain, ResolvedFilterPass,
};
use crate::layer::{LayerMeta, LayersRegistry, PromotionReasons};
use bevy::window::WindowResolution;
use std::sync::Arc;
let slot =
|name: &'static str, kind: ValueKind, vec: usize, comp: usize, len: usize| ParamSlot {
name,
kind,
vec,
comp,
len,
};
let blur_layout: Arc<[ParamSlot]> =
Arc::from(vec![slot("radius", ValueKind::Length, 0, 0, 1)]);
let blur_pass = |dir: (f32, f32)| ResolvedFilterPass {
shader: Handle::default(),
params: vec![Vec4::new(8.5, dir.0, dir.1, 0.0)],
layout: blur_layout.clone(),
wire_index: 0,
};
let hue_pass = ResolvedFilterPass {
shader: Handle::default(),
params: vec![
Vec4::new(std::f32::consts::FRAC_PI_2, 0.0, 0.0, 0.0),
Vec4::new(1.0, 0.5, 0.0, 1.0),
],
layout: Arc::from(vec![
slot("angle", ValueKind::Angle, 0, 0, 1),
slot("tint", ValueKind::Color, 1, 0, 4),
]),
wire_index: 1,
};
let sepia_pass = ResolvedFilterPass {
shader: Handle::default(),
params: vec![Vec4::new(4.249_99, 0.0, 0.0, 0.0)],
layout: Arc::from(vec![slot("amount", ValueKind::Scalar, 0, 0, 1)]),
wire_index: 2,
};
let orphan_pass = ResolvedFilterPass {
shader: Handle::default(),
params: vec![Vec4::new(0.4, 0.0, 0.0, 0.0)],
layout: Arc::from(vec![slot("x", ValueKind::Scalar, 0, 0, 1)]),
wire_index: 3,
};
let wire = |name: &str| FilterUse {
name: name.to_string(),
params: serde_json::Map::new(),
};
let (mut app, mut rx) = test_app(DevtoolsConfig {
settings_path: None,
..default()
});
app.world_mut().spawn(Window {
resolution: WindowResolution::new(800, 600),
..Default::default()
});
let entity = app
.world_mut()
.spawn((
RNode(7),
ResolvedFilterChain {
passes: vec![
blur_pass((1.0, 0.0)),
blur_pass((0.0, 1.0)),
hue_pass,
sepia_pass,
orphan_pass,
],
outset_px: 26,
always_dirty: false,
version: 1,
scale: 2.0,
},
FilterInput(FilterChain(vec![
wire("blur"),
wire("hueRotate"),
wire("sepia"),
])),
))
.id();
app.world_mut()
.resource_mut::<LayersRegistry>()
.layers
.insert(
7,
LayerMeta {
node: 7,
entity,
reasons: PromotionReasons(PromotionReasons::FILTER),
group_alpha: 1.0,
capture_rect: Some(IRect::new(10, 10, 110, 60)),
depth: 1,
repaints: 0,
cached: false,
cache_policy: Default::default(),
},
);
{
let mut state = app.world_mut().resource_mut::<DevtoolsState>();
state.open = true;
state.layers_tab_open = true;
}
let payloads = |rx: &mut UnboundedReceiver<Outbound>| {
drain_events(rx)
.into_iter()
.filter(|(name, _)| name == "devtools.layers")
.map(|(_, v)| v)
.collect::<Vec<_>>()
};
app.update();
let sent = payloads(&mut rx);
assert_eq!(sent.len(), 1);
assert_eq!(
sent[0]["layers"][1]["filters"],
serde_json::json!([
{ "name": "blur", "params": [["radius", [4.25]]] },
{ "name": "hueRotate",
"params": [["angle", [90.0]], ["tint", [1.0, 0.5, 0.0, 1.0]]] },
{ "name": "sepia", "params": [["amount", [4.25]]] },
{ "name": "#3", "params": [["x", [0.4]]] },
]),
"wire-grouped chain with display-unit, rounded params"
);
app.world_mut()
.entity_mut(entity)
.get_mut::<ResolvedFilterChain>()
.unwrap()
.passes[0]
.params[0]
.x = 8.5008;
app.update();
assert!(
payloads(&mut rx).is_empty(),
"sub-0.001 display noise must not re-emit (rounding is the rate limiter)"
);
app.world_mut()
.entity_mut(entity)
.get_mut::<ResolvedFilterChain>()
.unwrap()
.passes[0]
.params[0]
.x = 9.0;
app.update();
let sent = payloads(&mut rx);
assert_eq!(sent.len(), 1, "a visible param change re-emits");
assert_eq!(
sent[0]["layers"][1]["filters"][0]["params"][0],
serde_json::json!(["radius", [4.5]]),
"live value in display units"
);
}
#[test]
fn layers_open_message_flips_state_and_close_clears_it() {
let (mut app, _rx) = test_app(DevtoolsConfig {
settings_path: None,
..default()
});
app.world_mut()
.trigger(DevtoolsLayersOpenMessage { on: true });
app.world_mut()
.trigger(DevtoolsConsoleOpenMessage { on: true });
{
let state = app.world().resource::<DevtoolsState>();
assert!(state.layers_tab_open);
assert!(state.console_tab_open);
}
app.world_mut()
.resource_mut::<DevtoolsState>()
.console_last_seq = Some(7);
app.world_mut().trigger(DevtoolsOpenMessage { open: false });
let state = app.world().resource::<DevtoolsState>();
assert!(!state.open);
assert!(
!state.layers_tab_open,
"closing the panel must kill the layer stream"
);
assert!(
!state.console_tab_open,
"closing the panel must kill the console stream"
);
assert_eq!(
state.console_last_seq, None,
"the console watermark must reset so a reopen resends the backlog"
);
}
#[test]
fn console_stream_backlog_then_increment() {
use crate::console_log::{Level, Source};
let (mut app, mut rx) = test_app(DevtoolsConfig {
settings_path: None,
..default()
});
let mine = |v: &serde_json::Value| -> Vec<String> {
v["entries"]
.as_array()
.unwrap()
.iter()
.filter_map(|e| e["message"].as_str())
.filter(|m| m.contains("cons-t1-"))
.map(String::from)
.collect()
};
let payloads = |rx: &mut UnboundedReceiver<Outbound>| {
drain_events(rx)
.into_iter()
.filter(|(name, _)| name == "devtools.console")
.map(|(_, v)| v)
.collect::<Vec<_>>()
};
crate::console_log::push(Source::Rust, Level::Warn, "cons-t1-a");
crate::console_log::push(Source::Js, Level::Error, "cons-t1-b");
app.update();
assert!(
payloads(&mut rx).iter().all(|v| mine(v).is_empty()),
"closed panel: no console stream"
);
app.world_mut().resource_mut::<DevtoolsState>().open = true;
app.world_mut()
.trigger(DevtoolsConsoleOpenMessage { on: true });
app.update();
let sent = payloads(&mut rx);
let marked: Vec<String> = sent.iter().flat_map(&mine).collect();
assert_eq!(
marked,
vec!["cons-t1-a".to_string(), "cons-t1-b".to_string()],
"tab open sends the backlog, oldest first"
);
let entry = sent
.iter()
.flat_map(|v| v["entries"].as_array().unwrap().clone())
.find(|e| e["message"] == "cons-t1-b")
.unwrap();
assert_eq!(entry["source"], "js");
assert_eq!(entry["level"], "error");
assert!(entry["seq"].as_u64().is_some());
assert!(entry["time_ms"].as_u64().is_some());
crate::console_log::push(Source::Js, Level::Info, "cons-t1-c");
app.update();
let marked: Vec<String> = payloads(&mut rx).iter().flat_map(&mine).collect();
assert_eq!(
marked,
vec!["cons-t1-c".to_string()],
"later frames send only new entries"
);
app.world_mut()
.trigger(DevtoolsConsoleOpenMessage { on: false });
app.update();
assert!(payloads(&mut rx).iter().all(|v| mine(v).is_empty()));
app.world_mut()
.trigger(DevtoolsConsoleOpenMessage { on: true });
app.update();
let marked: Vec<String> = payloads(&mut rx).iter().flat_map(mine).collect();
assert_eq!(
marked,
vec![
"cons-t1-a".to_string(),
"cons-t1-b".to_string(),
"cons-t1-c".to_string()
],
"a re-shown tab gets the full backlog again"
);
}
#[test]
fn console_clear_message_empties_ring() {
use crate::console_log::{Level, Source};
let (mut app, _rx) = test_app(DevtoolsConfig {
settings_path: None,
..default()
});
crate::console_log::push(Source::Js, Level::Info, "cons-t2-before");
let (_, watermark_before) = crate::console_log::since(0);
app.world_mut().trigger(DevtoolsConsoleClearMessage {});
let (entries, _) = crate::console_log::since(0);
assert!(
entries.iter().all(|e| !e.message.contains("cons-t2-")),
"clear must drop our entry"
);
crate::console_log::push(Source::Js, Level::Info, "cons-t2-after");
let (entries, _) = crate::console_log::since(0);
let after = entries
.iter()
.find(|e| e.message == "cons-t2-after")
.expect("post-clear pushes land");
assert!(
after.seq > watermark_before,
"seq keeps counting across a clear"
);
}
#[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));
}
}