1use bevy::picking::hover::HoverMap;
63use bevy::picking::pointer::PointerId;
64use bevy::platform::collections::HashSet;
65use bevy::prelude::*;
66use bevy::ui::{ComputedNode, IsDefaultUiCamera, UiGlobalTransform, UiSystems};
67use std::time::Duration;
68
69use crate::bridge::{JsBridge, RNode};
70use crate::event::ReactEvents;
71use crate::message::ReactAppExt;
72use crate::plugin::PointerCapture;
73use crate::protocol::NodeId;
74use crate::reconcile::{OpApplyStats, climb};
75use crate::window::ui_viewport_size;
76use crate::{react_event, react_message};
77
78#[derive(Resource, Clone)]
96pub struct DevtoolsConfig {
97 pub enabled: bool,
100 pub toggle_key: KeyCode,
102 pub settings_path: Option<std::path::PathBuf>,
108}
109
110impl Default for DevtoolsConfig {
111 fn default() -> Self {
112 Self {
113 enabled: true,
114 toggle_key: KeyCode::F12,
115 settings_path: Some(std::path::PathBuf::from(".bevy-react-devtools.json")),
116 }
117 }
118}
119
120pub struct DevtoolsPlugin {
125 config: DevtoolsConfig,
126}
127
128impl DevtoolsPlugin {
129 pub fn new(config: DevtoolsConfig) -> Self {
130 Self { config }
131 }
132}
133
134impl Plugin for DevtoolsPlugin {
135 fn build(&self, app: &mut App) {
136 if !cfg!(debug_assertions) {
140 debug!("DevtoolsPlugin is inert in release builds");
141 return;
142 }
143 app.init_resource::<ButtonInput<KeyCode>>();
146 app.init_resource::<ButtonInput<MouseButton>>();
147 app.init_resource::<crate::layer::LayersRegistry>();
151 app.init_resource::<crate::layer::LayerMembership>();
152 crate::diag::arm_runtime();
156 let loaded = load_settings(self.config.settings_path.as_deref());
161 app.insert_resource(DevtoolsState {
162 show_selection_overlay: loaded.as_ref().is_none_or(|s| s.overlay),
163 ..Default::default()
164 })
165 .insert_resource(DevtoolsPersistence {
166 last_written: loaded.clone(),
167 loaded,
168 pending: None,
169 #[cfg(not(target_arch = "wasm32"))]
170 dirty_at: None,
171 debounce: Duration::from_secs(1),
172 })
173 .init_resource::<DevtoolsTimers>()
174 .insert_resource(self.config.clone())
175 .add_react_handler(on_open_message)
179 .add_react_handler(on_pick_message)
180 .add_react_handler(on_select_message)
181 .add_react_handler(on_highlight_message)
182 .add_react_handler(on_overlay_message)
183 .add_react_handler(on_panel_root_message)
184 .add_react_handler(on_dock_message)
185 .add_react_handler(on_settings_message)
186 .add_react_handler(on_layers_open_message)
187 .add_react_handler(on_console_open_message)
188 .add_react_handler(on_console_clear_message)
189 .add_systems(Startup, spawn_highlight_overlay)
192 .add_systems(
193 Update,
194 (
195 toggle_on_key,
196 send_window_size,
197 position_highlight,
198 apply_dock_reservation,
199 send_restore,
200 save_settings,
201 emit_runtime_warnings,
204 emit_console,
207 ),
208 )
209 .add_systems(Last, flush_settings_on_exit)
213 .add_systems(
217 Update,
218 drive_pick_mode
219 .in_set(crate::plugin::PointerCaptureSet)
220 .after(crate::reconcile::collect_pointer_events),
221 )
222 .add_systems(
223 PostUpdate,
224 (
225 mark_pre_layout
229 .after(UiSystems::Content)
230 .before(UiSystems::Layout),
231 mark_post_layout.after(UiSystems::PostLayout),
235 emit_batch_stats.after(mark_post_layout),
236 emit_layers
240 .after(crate::layer::sync_layer_geometry)
241 .after(crate::layer::resolve_layer_repaints),
244 ),
245 );
246 }
247}
248
249#[derive(Resource)]
252pub(crate) struct DevtoolsState {
253 pub open: bool,
255 pub pick: bool,
257 pub selected: Option<NodeId>,
259 pub tree_hover: Option<NodeId>,
261 pub pick_hover: Option<NodeId>,
263 pub show_selection_overlay: bool,
267 pub panel_root: Option<NodeId>,
271 pub dock_side: Option<DockSide>,
275 pub dock_width: f32,
277 pub layers_tab_open: bool,
280 pub console_tab_open: bool,
283 pub console_last_seq: Option<u64>,
288}
289
290#[derive(Clone, Copy, PartialEq, Eq, Debug)]
292pub(crate) enum DockSide {
293 Left,
294 Right,
295}
296
297impl Default for DevtoolsState {
298 fn default() -> Self {
299 Self {
300 open: false,
301 pick: false,
302 selected: None,
303 tree_hover: None,
304 pick_hover: None,
305 show_selection_overlay: true,
306 panel_root: None,
307 dock_side: None,
308 dock_width: 0.0,
309 layers_tab_open: false,
310 console_tab_open: false,
311 console_last_seq: None,
312 }
313 }
314}
315
316#[react_event(name = "devtools.toggle")]
322struct DevtoolsToggle {
323 open: bool,
324}
325
326#[react_event(name = "devtools.picked")]
328struct DevtoolsPicked {
329 id: NodeId,
330}
331
332#[react_event(name = "devtools.window")]
338struct DevtoolsWindow {
339 width: f32,
340 height: f32,
341}
342
343#[react_event(name = "devtools.batchStats")]
349struct DevtoolsBatchStats {
350 applied_count: u64,
352 last_ops: usize,
354 frame_wait_ms: f64,
357 pre_apply_ms: f64,
360 translate_ms: f64,
362 command_ms: f64,
364 layout_ms: f64,
366}
367
368#[react_event(name = "devtools.warning")]
376struct DevtoolsWarning {
377 id: Option<NodeId>,
379 kind: String,
381 value: String,
383 message: String,
385}
386
387#[react_event(name = "devtools.layers")]
398struct DevtoolsLayers {
399 layers: Vec<DevtoolsLayerRow>,
400}
401
402#[derive(serde::Serialize, ts_rs::TS, Debug, Clone, PartialEq)]
404struct DevtoolsLayerRow {
405 id: NodeId,
407 reasons: Vec<String>,
411 depth: u32,
413 node_count: u32,
416 rect: Option<DevtoolsLayerRect>,
419 repaints: u64,
422 filters: Vec<DevtoolsFilterEntry>,
426 backdrop_filters: Vec<DevtoolsFilterEntry>,
429}
430
431#[derive(serde::Serialize, ts_rs::TS, Debug, Clone, PartialEq)]
435struct DevtoolsFilterEntry {
436 name: String,
437 params: Vec<(String, Vec<f64>)>,
438}
439
440#[derive(serde::Serialize, ts_rs::TS, Debug, Clone, PartialEq)]
444struct DevtoolsLayerRect {
445 x: f32,
446 y: f32,
447 width: f32,
448 height: f32,
449 physical_width: u32,
450 physical_height: u32,
451}
452
453#[react_event(name = "devtools.console")]
460struct DevtoolsConsole {
461 entries: Vec<DevtoolsConsoleEntry>,
462}
463
464#[derive(serde::Serialize, ts_rs::TS, Debug, Clone, PartialEq)]
466struct DevtoolsConsoleEntry {
467 seq: u64,
469 time_ms: u64,
471 source: String,
473 level: String,
475 message: String,
476}
477
478#[react_message(name = "devtools.open")]
480struct DevtoolsOpenMessage {
481 open: bool,
482}
483
484#[react_message(name = "devtools.pick")]
486struct DevtoolsPickMessage {
487 on: bool,
488}
489
490#[react_message(name = "devtools.select")]
492struct DevtoolsSelectMessage {
493 id: Option<NodeId>,
494}
495
496#[react_message(name = "devtools.highlight")]
499struct DevtoolsHighlightMessage {
500 id: Option<NodeId>,
501}
502
503#[react_message(name = "devtools.overlay")]
506struct DevtoolsOverlayMessage {
507 on: bool,
508}
509
510#[react_message(name = "devtools.panelRoot")]
513struct DevtoolsPanelRootMessage {
514 id: Option<NodeId>,
515}
516
517#[react_message(name = "devtools.layersOpen")]
520struct DevtoolsLayersOpenMessage {
521 on: bool,
522}
523
524#[react_message(name = "devtools.consoleOpen")]
527struct DevtoolsConsoleOpenMessage {
528 on: bool,
529}
530
531#[react_message(name = "devtools.consoleClear")]
537struct DevtoolsConsoleClearMessage {}
538
539#[react_message(name = "devtools.dock")]
544struct DevtoolsDockMessage {
545 side: Option<String>,
546 width: f32,
547}
548
549fn on_open_message(msg: On<DevtoolsOpenMessage>, mut state: ResMut<DevtoolsState>) {
550 state.open = msg.event().open;
551 if !state.open {
552 exit_interactions(&mut state);
553 }
554}
555
556fn on_pick_message(msg: On<DevtoolsPickMessage>, mut state: ResMut<DevtoolsState>) {
557 state.pick = msg.event().on;
558 if !state.pick {
559 state.pick_hover = None;
560 }
561}
562
563fn on_select_message(msg: On<DevtoolsSelectMessage>, mut state: ResMut<DevtoolsState>) {
564 state.selected = msg.event().id;
565}
566
567fn on_highlight_message(msg: On<DevtoolsHighlightMessage>, mut state: ResMut<DevtoolsState>) {
568 state.tree_hover = msg.event().id;
569}
570
571fn on_overlay_message(msg: On<DevtoolsOverlayMessage>, mut state: ResMut<DevtoolsState>) {
572 state.show_selection_overlay = msg.event().on;
573}
574
575fn on_panel_root_message(msg: On<DevtoolsPanelRootMessage>, mut state: ResMut<DevtoolsState>) {
576 state.panel_root = msg.event().id;
577}
578
579fn on_layers_open_message(msg: On<DevtoolsLayersOpenMessage>, mut state: ResMut<DevtoolsState>) {
580 state.layers_tab_open = msg.event().on;
581}
582
583fn on_console_open_message(msg: On<DevtoolsConsoleOpenMessage>, mut state: ResMut<DevtoolsState>) {
584 state.console_tab_open = msg.event().on;
585 state.console_last_seq = None;
588}
589
590fn on_console_clear_message(_msg: On<DevtoolsConsoleClearMessage>) {
591 crate::console_log::clear();
592}
593
594fn on_dock_message(msg: On<DevtoolsDockMessage>, mut state: ResMut<DevtoolsState>) {
595 state.dock_side = match msg.event().side.as_deref() {
596 Some("left") => Some(DockSide::Left),
597 Some("right") => Some(DockSide::Right),
598 _ => None,
599 };
600 state.dock_width = msg.event().width.max(0.0);
601}
602
603#[react_message(name = "devtools.settings")]
619#[derive(serde::Serialize, Clone, PartialEq)]
620#[serde(default)]
621pub(crate) struct DevtoolsSettings {
622 open: bool,
624 tab: String,
628 mode: String,
629 width_frac: f32,
630 float_x_frac: f32,
631 float_y_frac: f32,
632 float_w_frac: f32,
633 float_h_frac: f32,
634 reserve: bool,
635 overlay: bool,
636 split: f32,
637}
638
639impl Default for DevtoolsSettings {
640 fn default() -> Self {
641 Self {
642 open: false,
643 tab: "nodes".into(),
644 mode: "right".into(),
645 width_frac: 0.3,
646 float_x_frac: 0.08,
647 float_y_frac: 0.1,
648 float_w_frac: 0.33,
649 float_h_frac: 0.7,
650 reserve: false,
651 overlay: true,
652 split: 260.0,
653 }
654 }
655}
656
657#[react_event(name = "devtools.restore")]
660struct DevtoolsRestore {
661 open: bool,
662 tab: String,
663 mode: String,
664 width_frac: f32,
665 float_x_frac: f32,
666 float_y_frac: f32,
667 float_w_frac: f32,
668 float_h_frac: f32,
669 reserve: bool,
670 overlay: bool,
671 split: f32,
672}
673
674impl From<&DevtoolsSettings> for DevtoolsRestore {
675 fn from(s: &DevtoolsSettings) -> Self {
676 Self {
677 open: s.open,
678 tab: s.tab.clone(),
679 mode: s.mode.clone(),
680 width_frac: s.width_frac,
681 float_x_frac: s.float_x_frac,
682 float_y_frac: s.float_y_frac,
683 float_w_frac: s.float_w_frac,
684 float_h_frac: s.float_h_frac,
685 reserve: s.reserve,
686 overlay: s.overlay,
687 split: s.split,
688 }
689 }
690}
691
692#[derive(Resource)]
695struct DevtoolsPersistence {
696 loaded: Option<DevtoolsSettings>,
697 pending: Option<DevtoolsSettings>,
698 last_written: Option<DevtoolsSettings>,
700 #[cfg(not(target_arch = "wasm32"))]
702 dirty_at: Option<std::time::Instant>,
703 debounce: Duration,
705}
706
707#[cfg_attr(target_arch = "wasm32", allow(unused_variables))]
710fn load_settings(path: Option<&std::path::Path>) -> Option<DevtoolsSettings> {
711 #[cfg(not(target_arch = "wasm32"))]
712 {
713 let text = std::fs::read_to_string(path?).ok()?;
714 serde_json::from_str(&text).ok()
715 }
716 #[cfg(target_arch = "wasm32")]
717 None
718}
719
720#[cfg(not(target_arch = "wasm32"))]
724fn write_pending(persist: &mut DevtoolsPersistence, path: &std::path::Path) {
725 persist.dirty_at = None;
726 let Some(pending) = persist.pending.clone() else {
727 return;
728 };
729 if persist.last_written.as_ref() == Some(&pending) {
730 return;
731 }
732 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
733 let _ = std::fs::create_dir_all(parent);
734 }
735 match serde_json::to_string_pretty(&pending) {
736 Ok(json) => match std::fs::write(path, json) {
737 Ok(()) => persist.last_written = Some(pending),
738 Err(e) => warn!("devtools: failed to write settings {}: {e}", path.display()),
739 },
740 Err(e) => warn!("devtools: failed to serialize settings: {e}"),
741 }
742}
743
744fn on_settings_message(
745 msg: On<DevtoolsSettings>,
746 #[cfg_attr(target_arch = "wasm32", allow(unused_mut, unused_variables))] mut persist: ResMut<
747 DevtoolsPersistence,
748 >,
749) {
750 #[cfg(not(target_arch = "wasm32"))]
751 {
752 persist.pending = Some(msg.event().clone());
753 persist.dirty_at = Some(std::time::Instant::now());
754 }
755 #[cfg(target_arch = "wasm32")]
756 let _ = msg;
757}
758
759fn send_restore(
768 persist: Res<DevtoolsPersistence>,
769 stats: Res<OpApplyStats>,
770 cameras: Query<&Camera, With<IsDefaultUiCamera>>,
771 windows: Query<&Window>,
772 mut state: ResMut<DevtoolsState>,
773 events: ReactEvents,
774 mut done: Local<bool>,
775) {
776 if *done {
777 return;
778 }
779 if stats.applied_count == 0 {
780 return;
781 }
782 *done = true;
783 if let Some(size) = ui_viewport_size(&cameras, &windows) {
784 events.send(&DevtoolsWindow {
785 width: size.x,
786 height: size.y,
787 });
788 }
789 let settings = persist.loaded.clone().unwrap_or_default();
790 events.send(&DevtoolsRestore::from(&settings));
791 if settings.open {
792 state.open = true;
793 events.send(&DevtoolsToggle { open: true });
794 }
795}
796
797fn send_window_size(
802 state: Res<DevtoolsState>,
803 stats: Res<OpApplyStats>,
804 cameras: Query<&Camera, With<IsDefaultUiCamera>>,
805 windows: Query<&Window>,
806 events: ReactEvents,
807 mut last: Local<Option<Vec2>>,
808) {
809 if stats.applied_count == 0 {
811 return;
812 }
813 if !state.open {
814 *last = None;
815 return;
816 }
817 let Some(size) = ui_viewport_size(&cameras, &windows) else {
818 return;
819 };
820 if *last != Some(size) {
821 *last = Some(size);
822 events.send(&DevtoolsWindow {
823 width: size.x,
824 height: size.y,
825 });
826 }
827}
828
829#[cfg_attr(target_arch = "wasm32", allow(unused_mut, unused_variables))]
832fn save_settings(mut persist: ResMut<DevtoolsPersistence>, config: Res<DevtoolsConfig>) {
833 #[cfg(not(target_arch = "wasm32"))]
834 {
835 let Some(path) = config.settings_path.clone() else {
836 return;
837 };
838 let debounce = persist.debounce;
839 if persist.dirty_at.is_some_and(|t| t.elapsed() >= debounce) {
840 write_pending(&mut persist, &path);
841 }
842 }
843}
844
845#[cfg_attr(target_arch = "wasm32", allow(unused_mut, unused_variables))]
848fn flush_settings_on_exit(
849 mut exits: MessageReader<AppExit>,
850 mut persist: ResMut<DevtoolsPersistence>,
851 config: Res<DevtoolsConfig>,
852) {
853 #[cfg(not(target_arch = "wasm32"))]
854 {
855 if exits.read().next().is_none() {
856 return;
857 }
858 let Some(path) = config.settings_path.clone() else {
859 return;
860 };
861 if persist.dirty_at.is_some() {
862 write_pending(&mut persist, &path);
863 }
864 }
865 #[cfg(target_arch = "wasm32")]
866 exits.clear();
867}
868
869fn exit_interactions(state: &mut DevtoolsState) {
871 state.pick = false;
872 state.tree_hover = None;
873 state.pick_hover = None;
874 state.layers_tab_open = false;
878 state.console_tab_open = false;
879 state.console_last_seq = None;
880}
881
882fn toggle_on_key(
884 keys: Res<ButtonInput<KeyCode>>,
885 cfg: Res<DevtoolsConfig>,
886 mut state: ResMut<DevtoolsState>,
887 events: ReactEvents,
888) {
889 if !keys.just_pressed(cfg.toggle_key) {
890 return;
891 }
892 state.open = !state.open;
893 if !state.open {
894 exit_interactions(&mut state);
895 }
896 events.send(&DevtoolsToggle { open: state.open });
897}
898
899fn apply_dock_reservation(
915 state: Res<DevtoolsState>,
916 windows: Query<&Window>,
917 mut root: Query<&mut Node, With<crate::plugin::UiRoot>>,
918) {
919 let Ok(mut node) = root.single_mut() else {
920 return;
921 };
922 let reserved = if state.open { state.dock_side } else { None };
923 let width = match windows.single() {
924 Ok(window) => state.dock_width.min(window.width() - 100.0).max(0.0),
925 Err(_) => state.dock_width,
926 };
927 let (left, right) = match reserved {
928 Some(DockSide::Left) => (Val::Px(width), Val::ZERO),
929 Some(DockSide::Right) => (Val::ZERO, Val::Px(width)),
930 None => (Val::ZERO, Val::ZERO),
931 };
932 if node.margin.left != left || node.margin.right != right {
933 node.margin.left = left;
934 node.margin.right = right;
935 }
936}
937
938#[derive(Resource, Default)]
942struct DevtoolsTimers {
943 #[cfg(not(target_arch = "wasm32"))]
945 pre_layout: Option<std::time::Instant>,
946 last_command: Duration,
949 last_layout: Duration,
951 seen_applied: u64,
953}
954
955#[cfg_attr(target_arch = "wasm32", allow(unused_mut, unused_variables))]
956fn mark_pre_layout(mut timers: ResMut<DevtoolsTimers>) {
957 #[cfg(not(target_arch = "wasm32"))]
958 {
959 timers.pre_layout = Some(std::time::Instant::now());
960 }
961}
962
963fn mark_post_layout(stats: Res<OpApplyStats>, mut timers: ResMut<DevtoolsTimers>) {
964 if stats.applied_count == timers.seen_applied {
967 return;
968 }
969 timers.seen_applied = stats.applied_count;
970 #[cfg(not(target_arch = "wasm32"))]
971 if let (Some(end), Some(pre)) = (stats.last_apply_end, timers.pre_layout) {
972 let (command, layout) = split_legs(end, pre, std::time::Instant::now());
973 timers.last_command = command;
974 timers.last_layout = layout;
975 }
976}
977
978#[cfg(not(target_arch = "wasm32"))]
981fn split_legs(
982 apply_end: std::time::Instant,
983 pre_layout: std::time::Instant,
984 post_layout: std::time::Instant,
985) -> (Duration, Duration) {
986 (
987 pre_layout.saturating_duration_since(apply_end),
988 post_layout.saturating_duration_since(pre_layout),
989 )
990}
991
992#[allow(clippy::too_many_arguments)]
1007fn drive_pick_mode(
1008 mut state: ResMut<DevtoolsState>,
1009 hover_map: Option<Res<HoverMap>>,
1010 mouse: Res<ButtonInput<MouseButton>>,
1011 capture: Option<ResMut<PointerCapture>>,
1012 bridge: Option<Res<JsBridge>>,
1013 rnodes: Query<&RNode>,
1014 child_of: Query<&ChildOf>,
1015 events: ReactEvents,
1016) {
1017 if !(state.open && state.pick) {
1018 return;
1019 }
1020 if let Some(mut capture) = capture {
1024 capture.over_ui = true;
1025 capture.wheel_captured = true;
1026 }
1027
1028 let panel_entity = state
1032 .panel_root
1033 .and_then(|id| bridge.as_ref().and_then(|b| b.nodes.get(&id).copied()));
1034
1035 let hovered = hover_map
1036 .as_deref()
1037 .and_then(|hover_map| hover_map.get(&PointerId::Mouse))
1038 .and_then(|hits| {
1039 let (&top, _) = hits
1049 .iter()
1050 .filter(|&(&entity, _)| climb(entity, &child_of, |e| rnodes.contains(e)).is_some())
1051 .min_by(|a, b| a.1.depth.total_cmp(&b.1.depth))?;
1052 if let Some(panel) = panel_entity
1054 && climb(top, &child_of, |e| e == panel).is_some()
1055 {
1056 return None;
1057 }
1058 let owner = climb(top, &child_of, |e| rnodes.contains(e))?;
1059 rnodes.get(owner).ok().map(|r| r.0)
1060 });
1061 state.pick_hover = hovered;
1062
1063 if mouse.just_pressed(MouseButton::Left)
1064 && let Some(id) = hovered
1065 {
1066 state.pick = false;
1067 state.pick_hover = None;
1068 state.selected = Some(id);
1069 events.send(&DevtoolsPicked { id });
1070 }
1071}
1072
1073#[derive(Component)]
1076struct DevtoolsHighlightOverlay;
1077
1078fn spawn_highlight_overlay(mut commands: Commands) {
1084 commands.spawn((
1085 DevtoolsHighlightOverlay,
1086 Node {
1087 position_type: PositionType::Absolute,
1088 display: Display::None,
1089 ..default()
1090 },
1091 BackgroundColor(Color::srgba(0.54, 0.71, 0.97, 0.30)),
1093 Outline {
1094 width: Val::Px(1.0),
1095 color: Color::srgba(0.54, 0.71, 0.97, 0.9),
1096 ..default()
1097 },
1098 GlobalZIndex(i32::MAX - 1),
1099 Pickable::IGNORE,
1100 ));
1101}
1102
1103fn position_highlight(
1109 state: Res<DevtoolsState>,
1110 bridge: Option<Res<JsBridge>>,
1113 targets: Query<(&ComputedNode, &UiGlobalTransform)>,
1114 mut overlay: Query<&mut Node, With<DevtoolsHighlightOverlay>>,
1115) {
1116 let Ok(mut node) = overlay.single_mut() else {
1117 return;
1118 };
1119 let Some(bridge) = bridge else {
1120 return;
1121 };
1122 let target = state
1123 .pick_hover
1124 .or(state.tree_hover)
1125 .or(state.selected.filter(|_| state.show_selection_overlay))
1128 .filter(|_| state.open);
1129 let rect = target
1130 .and_then(|id| bridge.nodes.get(&id))
1131 .and_then(|&e| targets.get(e).ok())
1132 .map(|(computed, transform)| {
1133 highlight_rect(
1134 computed.size,
1135 transform.translation,
1136 computed.inverse_scale_factor,
1137 )
1138 });
1139 match rect {
1142 Some((pos, size)) => {
1143 let (left, top) = (Val::Px(pos.x), Val::Px(pos.y));
1144 let (width, height) = (Val::Px(size.x), Val::Px(size.y));
1145 if node.display != Display::Flex
1146 || node.left != left
1147 || node.top != top
1148 || node.width != width
1149 || node.height != height
1150 {
1151 node.display = Display::Flex;
1152 node.left = left;
1153 node.top = top;
1154 node.width = width;
1155 node.height = height;
1156 }
1157 }
1158 None => {
1159 if node.display != Display::None {
1160 node.display = Display::None;
1161 }
1162 }
1163 }
1164}
1165
1166fn highlight_rect(physical_size: Vec2, physical_center: Vec2, inverse_scale: f32) -> (Vec2, Vec2) {
1170 let top_left = (physical_center - physical_size * 0.5) * inverse_scale;
1171 (top_left, physical_size * inverse_scale)
1172}
1173
1174fn emit_batch_stats(
1183 state: Res<DevtoolsState>,
1184 stats: Res<OpApplyStats>,
1185 timers: Res<DevtoolsTimers>,
1186 events: ReactEvents,
1187 mut seen: Local<u64>,
1188) {
1189 if stats.app_applied_count == *seen {
1190 return;
1191 }
1192 *seen = stats.app_applied_count;
1193 if !state.open {
1194 return;
1195 }
1196 events.send(&DevtoolsBatchStats {
1197 applied_count: stats.applied_count,
1198 last_ops: stats.last_ops,
1199 frame_wait_ms: stats.last_frame_wait.as_secs_f64() * 1000.0,
1200 pre_apply_ms: stats.last_pre_apply.as_secs_f64() * 1000.0,
1201 translate_ms: stats.last_translate.as_secs_f64() * 1000.0,
1202 command_ms: timers.last_command.as_secs_f64() * 1000.0,
1203 layout_ms: timers.last_layout.as_secs_f64() * 1000.0,
1204 });
1205}
1206
1207fn emit_console(mut state: ResMut<DevtoolsState>, events: ReactEvents) {
1215 if !(state.open && state.console_tab_open) {
1216 return;
1217 }
1218 let (entries, watermark) = crate::console_log::since(state.console_last_seq.unwrap_or(0));
1219 state.console_last_seq = Some(watermark);
1220 if entries.is_empty() {
1221 return;
1222 }
1223 events.send(&DevtoolsConsole {
1224 entries: entries
1225 .into_iter()
1226 .map(|e| DevtoolsConsoleEntry {
1227 seq: e.seq,
1228 time_ms: e.time_ms,
1229 source: e.source.as_str().into(),
1230 level: e.level.as_str().into(),
1231 message: e.message,
1232 })
1233 .collect(),
1234 });
1235}
1236
1237fn reason_labels(reasons: crate::layer::PromotionReasons) -> Vec<String> {
1242 let mut out = Vec::new();
1243 if reasons.0 & crate::layer::PromotionReasons::OPACITY != 0 {
1244 out.push("opacity".to_string());
1245 }
1246 if reasons.0 & crate::layer::PromotionReasons::FILTER != 0 {
1247 out.push("filter".to_string());
1248 }
1249 if reasons.0 & crate::layer::PromotionReasons::TRANSFORM3D != 0 {
1250 out.push("transform3d".to_string());
1251 }
1252 if reasons.0 & crate::layer::PromotionReasons::BACKDROP != 0 {
1253 out.push("backdrop".to_string());
1254 }
1255 if reasons.0 & crate::layer::PromotionReasons::FORCED != 0 {
1256 out.push("cache".to_string());
1257 }
1258 out
1259}
1260
1261fn round3(v: f32) -> f64 {
1270 (f64::from(v) * 1000.0).round() / 1000.0
1271}
1272
1273fn filter_entries(
1283 chain: &crate::filters::ResolvedFilterChain,
1284 input: Option<&crate::filters::FilterChain>,
1285) -> Vec<DevtoolsFilterEntry> {
1286 use crate::animations::ValueKind;
1287 let scale = if chain.scale > 0.0 { chain.scale } else { 1.0 };
1288 let mut out: Vec<DevtoolsFilterEntry> = Vec::new();
1289 let mut last_wire = None;
1290 for pass in &chain.passes {
1291 if last_wire == Some(pass.wire_index) {
1292 continue;
1293 }
1294 last_wire = Some(pass.wire_index);
1295 let name = input
1300 .and_then(|i| i.0.get(pass.wire_index as usize))
1301 .map(|u| u.name.clone())
1302 .unwrap_or_else(|| format!("#{}", pass.wire_index));
1303 let params = pass
1304 .layout
1305 .iter()
1306 .map(|slot| {
1307 let values = (slot.comp..(slot.comp + slot.len).min(4))
1308 .map(|comp| {
1309 let raw = pass.params.get(slot.vec).map_or(0.0, |v| v[comp]);
1310 round3(match slot.kind {
1311 ValueKind::Angle => raw.to_degrees(),
1312 ValueKind::Length => raw / scale,
1313 ValueKind::Scalar | ValueKind::Color => raw,
1314 })
1315 })
1316 .collect();
1317 (slot.name.to_string(), values)
1318 })
1319 .collect();
1320 out.push(DevtoolsFilterEntry { name, params });
1321 }
1322 out
1323}
1324
1325fn viewport_physical_size(
1328 cameras: &Query<&Camera, With<IsDefaultUiCamera>>,
1329 windows: &Query<&Window>,
1330) -> Option<UVec2> {
1331 if let Ok(camera) = cameras.single()
1332 && let Some(size) = camera.physical_viewport_size()
1333 {
1334 return Some(size);
1335 }
1336 windows.single().ok().map(|window| {
1337 UVec2::new(
1338 window.resolution.physical_width(),
1339 window.resolution.physical_height(),
1340 )
1341 })
1342}
1343
1344#[allow(clippy::too_many_arguments, clippy::type_complexity)]
1356fn emit_layers(
1357 state: Res<DevtoolsState>,
1358 registry: Res<crate::layer::LayersRegistry>,
1359 membership: Res<crate::layer::LayerMembership>,
1360 bridge: Option<Res<JsBridge>>,
1361 rnodes: Query<(), With<RNode>>,
1362 child_of: Query<&ChildOf>,
1363 computed: Query<&ComputedNode>,
1364 chains: Query<(
1365 Option<&crate::filters::ResolvedFilterChain>,
1366 Option<&crate::filters::FilterInput>,
1367 Option<&crate::filters::ResolvedBackdropChain>,
1368 Option<&crate::filters::BackdropInput>,
1369 )>,
1370 cameras: Query<&Camera, With<IsDefaultUiCamera>>,
1371 windows: Query<&Window>,
1372 events: ReactEvents,
1373 mut last: Local<Option<Vec<DevtoolsLayerRow>>>,
1374) {
1375 if !(state.open && state.layers_tab_open) {
1376 *last = None;
1377 return;
1378 }
1379 let Some(logical) = ui_viewport_size(&cameras, &windows) else {
1380 return;
1381 };
1382 let physical = viewport_physical_size(&cameras, &windows).unwrap_or(UVec2::new(
1383 logical.x.round() as u32,
1384 logical.y.round() as u32,
1385 ));
1386 let fallback_inverse_scale = if physical.x > 0 {
1389 logical.x / physical.x as f32
1390 } else {
1391 1.0
1392 };
1393 let panel_entity = state
1394 .panel_root
1395 .and_then(|id| bridge.as_ref().and_then(|b| b.nodes.get(&id).copied()));
1396
1397 let mut rows = Vec::with_capacity(registry.layers.len() + 1);
1398 rows.push(DevtoolsLayerRow {
1399 id: 0,
1400 reasons: vec!["base".to_string()],
1401 depth: 0,
1402 node_count: 0,
1403 rect: Some(DevtoolsLayerRect {
1404 x: 0.0,
1405 y: 0.0,
1406 width: logical.x,
1407 height: logical.y,
1408 physical_width: physical.x,
1409 physical_height: physical.y,
1410 }),
1411 repaints: 0,
1412 filters: Vec::new(),
1413 backdrop_filters: Vec::new(),
1414 });
1415 for meta in registry.layers.values() {
1416 if let Some(panel) = panel_entity
1417 && climb(meta.entity, &child_of, |e| e == panel).is_some()
1418 {
1419 continue;
1420 }
1421 let inverse_scale = computed
1422 .get(meta.entity)
1423 .map(|c| c.inverse_scale_factor)
1424 .unwrap_or(fallback_inverse_scale);
1425 let node_count = membership
1426 .node_to_layer
1427 .iter()
1428 .filter(|&(node, layer)| *layer == meta.entity && rnodes.contains(*node))
1429 .count() as u32;
1430 let (filters, backdrop_filters) = chains
1434 .get(meta.entity)
1435 .map(|(chain, input, bchain, binput)| {
1436 (
1437 chain
1438 .map(|c| filter_entries(c, input.map(|i| &i.0)))
1439 .unwrap_or_default(),
1440 bchain
1441 .map(|c| filter_entries(&c.0, binput.map(|i| &i.0)))
1442 .unwrap_or_default(),
1443 )
1444 })
1445 .unwrap_or_default();
1446 rows.push(DevtoolsLayerRow {
1447 id: meta.node,
1448 reasons: reason_labels(meta.reasons),
1449 depth: meta.depth,
1450 node_count,
1451 rect: meta.capture_rect.map(|r| DevtoolsLayerRect {
1455 x: r.min.x as f32 * inverse_scale,
1456 y: r.min.y as f32 * inverse_scale,
1457 width: r.width() as f32 * inverse_scale,
1458 height: r.height() as f32 * inverse_scale,
1459 physical_width: r.width().max(0) as u32,
1460 physical_height: r.height().max(0) as u32,
1461 }),
1462 repaints: meta.repaints,
1463 filters,
1464 backdrop_filters,
1465 });
1466 }
1467 rows.sort_by_key(|r| (r.depth, r.id));
1470 if last.as_ref() != Some(&rows) {
1471 events.send(&DevtoolsLayers {
1472 layers: rows.clone(),
1473 });
1474 *last = Some(rows);
1475 }
1476}
1477
1478fn emit_runtime_warnings(
1488 stats: Res<OpApplyStats>,
1489 events: ReactEvents,
1490 mut seen: Local<HashSet<u64>>,
1491 mut last_reset: Local<u64>,
1492) {
1493 if stats.reset_count != *last_reset {
1494 *last_reset = stats.reset_count;
1495 seen.clear();
1496 }
1497 if stats.applied_count == 0 {
1498 return;
1499 }
1500 for w in crate::diag::take_runtime_warnings() {
1501 let mut hasher = std::hash::DefaultHasher::new();
1502 std::hash::Hash::hash(&(w.node, w.kind, &w.value, &w.message), &mut hasher);
1503 if seen.insert(std::hash::Hasher::finish(&hasher)) {
1504 events.send(&DevtoolsWarning {
1505 id: w.node,
1506 kind: w.kind.to_string(),
1507 value: w.value,
1508 message: w.message,
1509 });
1510 }
1511 }
1512}
1513
1514#[cfg(test)]
1515mod tests {
1516 use super::*;
1517 use crate::bridge::{OutboundResource, RRoot};
1518 use crate::protocol::Outbound;
1519 use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel};
1520
1521 fn test_app(config: DevtoolsConfig) -> (App, UnboundedReceiver<Outbound>) {
1524 let mut app = App::new();
1525 app.insert_non_send(crate::diag::test_lock());
1534 app.add_plugins(MinimalPlugins);
1535 app.init_resource::<ButtonInput<KeyCode>>();
1536 app.init_resource::<ButtonInput<MouseButton>>();
1537 app.init_resource::<OpApplyStats>();
1538 let (tx, rx) = unbounded_channel::<Outbound>();
1539 app.insert_resource(OutboundResource(tx));
1540 app.add_plugins(DevtoolsPlugin::new(config));
1541 (app, rx)
1542 }
1543
1544 fn drain_events(rx: &mut UnboundedReceiver<Outbound>) -> Vec<(String, serde_json::Value)> {
1545 let mut out = Vec::new();
1546 while let Ok(msg) = rx.try_recv() {
1547 if let Outbound::Event { name, value } = msg {
1548 out.push((name, value));
1549 }
1550 }
1551 out
1552 }
1553
1554 #[test]
1558 fn pick_mode_claims_hover_and_wheel_channels() {
1559 let (mut app, _rx) = test_app(DevtoolsConfig::default());
1560 app.init_resource::<crate::PointerCapture>();
1561 {
1562 let mut state = app.world_mut().resource_mut::<DevtoolsState>();
1563 state.open = true;
1564 state.pick = true;
1565 }
1566 app.update();
1567
1568 let capture = app.world().resource::<crate::PointerCapture>();
1569 assert!(capture.over_ui, "pick mode must claim the hover channel");
1570 assert!(
1571 capture.wheel_captured,
1572 "pick mode must claim the wheel channel too"
1573 );
1574 }
1575
1576 #[test]
1577 fn toggle_key_flips_state_and_notifies_js() {
1578 let (mut app, mut rx) = test_app(DevtoolsConfig {
1579 toggle_key: KeyCode::F9,
1580 ..default()
1581 });
1582 app.update();
1583 assert!(drain_events(&mut rx).is_empty(), "no toggle before the key");
1584
1585 app.world_mut()
1586 .resource_mut::<ButtonInput<KeyCode>>()
1587 .press(KeyCode::F9);
1588 app.update();
1589 let events = drain_events(&mut rx);
1590 assert_eq!(
1591 events
1592 .iter()
1593 .find(|(name, _)| name == "devtools.toggle")
1594 .map(|(_, v)| v["open"].as_bool()),
1595 Some(Some(true)),
1596 "the configured key must open the panel and notify JS"
1597 );
1598 assert!(app.world().resource::<DevtoolsState>().open);
1599
1600 {
1602 let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
1603 keys.clear_just_pressed(KeyCode::F9);
1604 keys.release(KeyCode::F9);
1605 }
1606 app.update();
1607 app.world_mut()
1608 .resource_mut::<ButtonInput<KeyCode>>()
1609 .press(KeyCode::F9);
1610 app.update();
1611 let events = drain_events(&mut rx);
1612 assert_eq!(
1613 events
1614 .iter()
1615 .find(|(name, _)| name == "devtools.toggle")
1616 .map(|(_, v)| v["open"].as_bool()),
1617 Some(Some(false)),
1618 "pressing again must close the panel"
1619 );
1620 assert!(!app.world().resource::<DevtoolsState>().open);
1621 }
1622
1623 #[test]
1626 fn restored_open_opens_panel_after_first_batch() {
1627 let tmp = TempSettings::new("open");
1628 let settings = DevtoolsSettings {
1629 open: true,
1630 ..Default::default()
1631 };
1632 std::fs::write(&tmp.0, serde_json::to_string(&settings).unwrap()).unwrap();
1633 let (mut app, mut rx) = test_app(DevtoolsConfig {
1634 settings_path: Some(tmp.0.clone()),
1635 ..default()
1636 });
1637 app.update();
1638 assert!(
1639 drain_events(&mut rx).is_empty(),
1640 "must not reopen before the React app mounted"
1641 );
1642
1643 app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
1644 app.update();
1645 let events = drain_events(&mut rx);
1646 assert!(
1647 events
1648 .iter()
1649 .any(|(name, v)| name == "devtools.restore" && v["open"] == true),
1650 "the restore blob must carry the persisted open state"
1651 );
1652 assert!(
1653 events
1654 .iter()
1655 .any(|(name, v)| name == "devtools.toggle" && v["open"] == true),
1656 "a persisted open must reopen the panel once a batch has been applied"
1657 );
1658 assert!(app.world().resource::<DevtoolsState>().open);
1659
1660 app.update();
1661 assert!(
1662 drain_events(&mut rx)
1663 .iter()
1664 .all(|(name, _)| name != "devtools.toggle" && name != "devtools.restore"),
1665 "the restore-open must fire exactly once"
1666 );
1667 }
1668
1669 #[cfg(debug_assertions)]
1683 #[test]
1684 fn runtime_warnings_emit_once_and_reset_on_reload() {
1685 let (mut app, mut rx) = test_app(DevtoolsConfig {
1688 settings_path: None,
1689 ..default()
1690 });
1691 let _ = crate::diag::take_runtime_warnings();
1692 app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
1694
1695 let report = || {
1696 let _scope = crate::diag::node_scope(31337);
1697 crate::diag::report("color", "redd", "unrecognized color \"redd\"");
1698 };
1699 let mine = |events: &[(String, serde_json::Value)]| {
1700 events
1701 .iter()
1702 .filter(|(name, v)| name == "devtools.warning" && v["id"] == 31337)
1703 .count()
1704 };
1705
1706 report();
1707 app.update();
1708 let events = drain_events(&mut rx);
1709 assert_eq!(
1710 mine(&events),
1711 1,
1712 "first report ships (panel closed is fine)"
1713 );
1714 assert!(
1715 events.iter().any(|(name, v)| name == "devtools.warning"
1716 && v["kind"] == "color"
1717 && v["value"] == "redd"
1718 && v["message"].as_str().is_some_and(|m| m.contains("redd"))),
1719 "the event carries kind/value/message"
1720 );
1721
1722 report();
1723 app.update();
1724 assert_eq!(
1725 mine(&drain_events(&mut rx)),
1726 0,
1727 "an identical re-report is deduped"
1728 );
1729
1730 app.world_mut().resource_mut::<OpApplyStats>().reset_count += 1;
1731 report();
1732 app.update();
1733 assert_eq!(
1734 mine(&drain_events(&mut rx)),
1735 1,
1736 "a hot reload clears the dedup set so warnings re-flag"
1737 );
1738 }
1739
1740 #[test]
1749 fn js_warning_kind_table_covers_known_kinds() {
1750 let warnings_ts = include_str!("../../../js/src/devtools/warnings.ts");
1751 for kind in [
1752 "display",
1753 "boxSizing",
1754 "positionType",
1755 "overflow",
1756 "alignItems",
1757 "justifyItems",
1758 "alignSelf",
1759 "justifySelf",
1760 "alignContent",
1761 "justifyContent",
1762 "flexDirection",
1763 "flexWrap",
1764 "gridAutoFlow",
1765 "focusPolicy",
1766 "textAlign",
1767 "lineBreak",
1768 "fontSize",
1769 "fontWeight",
1770 "rect",
1771 "gridTrack",
1772 "gridPlacement",
1773 "borderColor",
1774 "filterParams",
1775 "filterUnknown",
1776 "filterBleed",
1777 "filterBinding",
1778 "backdropFilterParams",
1779 "backdropFilterUnknown",
1780 "backdropFilterBinding",
1781 "scrollbar",
1782 "styleBinding",
1783 "color",
1784 "fontFamily",
1785 "cursor",
1786 "lineHeight",
1787 "letterSpacing",
1788 "cache",
1789 ] {
1790 assert!(
1791 warnings_ts.contains(&format!("{kind}:"))
1792 || warnings_ts.contains(&format!("\"{kind}\":")),
1793 "js/src/devtools/warnings.ts KIND_FIELDS is missing kind \"{kind}\""
1794 );
1795 }
1796 }
1797
1798 #[test]
1801 fn reason_labels_cover_all_rules() {
1802 use crate::layer::PromotionReasons;
1803 let labels = |bits: u32| reason_labels(PromotionReasons(bits));
1804 assert_eq!(labels(PromotionReasons::OPACITY), ["opacity"]);
1805 assert_eq!(labels(PromotionReasons::FILTER), ["filter"]);
1806 assert_eq!(labels(PromotionReasons::TRANSFORM3D), ["transform3d"]);
1807 assert_eq!(labels(PromotionReasons::BACKDROP), ["backdrop"]);
1808 assert_eq!(labels(PromotionReasons::FORCED), ["cache"]);
1809 assert_eq!(
1810 labels(
1811 PromotionReasons::OPACITY
1812 | PromotionReasons::FILTER
1813 | PromotionReasons::TRANSFORM3D
1814 | PromotionReasons::BACKDROP
1815 | PromotionReasons::FORCED
1816 ),
1817 ["opacity", "filter", "transform3d", "backdrop", "cache"]
1818 );
1819 }
1820
1821 #[test]
1822 fn js_style_field_table_covers_every_style_field() {
1823 let fields_ts = include_str!("../../../js/src/devtools/fields.ts");
1824 macro_rules! check_fields {
1825 ($(($field:ident, $wire:literal, ($($group:tt)*), $overlay:ident)),* $(,)?) => {
1826 $(
1827 assert!(
1828 fields_ts.contains(concat!($wire, ":"))
1829 || fields_ts.contains(concat!("\"", $wire, "\":")),
1830 concat!(
1831 "js/src/devtools/fields.ts is missing style field \"",
1832 $wire,
1833 "\" — add it to STYLE_FIELDS with a category"
1834 )
1835 );
1836 )*
1837 };
1838 }
1839 crate::protocol::with_style_fields!(check_fields);
1840 }
1841
1842 fn pick_world(pressed: bool) -> (World, UnboundedReceiver<Outbound>) {
1847 let mut world = World::new();
1848 world.insert_resource(DevtoolsState {
1849 open: true,
1850 pick: true,
1851 ..Default::default()
1852 });
1853 let mut mouse = ButtonInput::<MouseButton>::default();
1854 if pressed {
1855 mouse.press(MouseButton::Left);
1856 }
1857 world.insert_resource(mouse);
1858 let (tx, rx) = unbounded_channel::<Outbound>();
1859 world.insert_resource(OutboundResource(tx));
1860 (world, rx)
1861 }
1862
1863 fn report_panel_root(world: &mut World, id: NodeId, panel_entity: Entity) {
1867 let (out_tx, out_rx) = unbounded_channel::<Outbound>();
1868 std::mem::forget(out_rx);
1869 let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<crate::protocol::Op>>();
1870 std::mem::forget(ops_tx);
1871 let root = world.spawn_empty().id();
1872 let mut bridge = JsBridge::new(ops_rx, out_tx, root);
1873 bridge.nodes.insert(id, panel_entity);
1874 world.insert_resource(bridge);
1875 world.resource_mut::<DevtoolsState>().panel_root = Some(id);
1876 }
1877
1878 fn set_mouse_hits(world: &mut World, hits: &[(Entity, f32)]) {
1880 use bevy::ecs::entity::EntityHashMap;
1881 use bevy::picking::backend::HitData;
1882 let mut hovered = EntityHashMap::default();
1883 for &(entity, depth) in hits {
1884 hovered.insert(entity, HitData::new(Entity::PLACEHOLDER, depth, None, None));
1885 }
1886 let mut hover_map = HoverMap::default();
1887 hover_map.insert(PointerId::Mouse, hovered);
1888 world.insert_resource(hover_map);
1889 }
1890
1891 #[test]
1896 fn pick_ignores_mesh_hits_and_takes_frontmost_ui_node() {
1897 use bevy::ecs::system::RunSystemOnce;
1898
1899 let (mut world, _rx) = pick_world(false);
1900 let mesh = world.spawn_empty().id(); let node = world.spawn(RNode(7)).id();
1902 let leaf = world.spawn(ChildOf(node)).id(); set_mouse_hits(&mut world, &[(mesh, 0.5), (leaf, 30.0)]);
1904
1905 world.run_system_once(drive_pick_mode).unwrap();
1906 assert_eq!(
1907 world.resource::<DevtoolsState>().pick_hover,
1908 Some(7),
1909 "the frontmost RNode-resolving hit must win; mesh hits are ignored"
1910 );
1911 }
1912
1913 #[test]
1916 fn pick_rejects_panel_hits() {
1917 use bevy::ecs::system::RunSystemOnce;
1918
1919 let (mut world, _rx) = pick_world(false);
1920 let panel_root = world.spawn((RRoot, RNode(100))).id();
1921 let panel_button = world.spawn((RNode(101), ChildOf(panel_root))).id();
1922 let app_node = world.spawn(RNode(7)).id();
1923 report_panel_root(&mut world, 100, panel_root);
1924 set_mouse_hits(&mut world, &[(panel_button, 1.0), (app_node, 5.0)]);
1925
1926 world.run_system_once(drive_pick_mode).unwrap();
1927 assert_eq!(
1928 world.resource::<DevtoolsState>().pick_hover,
1929 None,
1930 "a panel hit in front must block picking (no pick-through)"
1931 );
1932 }
1933
1934 #[test]
1938 fn pick_allows_nodes_under_app_roots() {
1939 use bevy::ecs::system::RunSystemOnce;
1940
1941 let (mut world, _rx) = pick_world(false);
1942 let panel_root = world.spawn((RRoot, RNode(100))).id();
1943 let app_root = world.spawn((RRoot, RNode(50))).id();
1944 let overlay_node = world.spawn((RNode(7), ChildOf(app_root))).id();
1945 report_panel_root(&mut world, 100, panel_root);
1946 set_mouse_hits(&mut world, &[(overlay_node, 5.0)]);
1947
1948 world.run_system_once(drive_pick_mode).unwrap();
1949 assert_eq!(
1950 world.resource::<DevtoolsState>().pick_hover,
1951 Some(7),
1952 "a node under an app <root> must be pickable"
1953 );
1954 }
1955
1956 #[test]
1959 fn pick_prefers_frontmost_app_hit_over_panel_behind() {
1960 use bevy::ecs::system::RunSystemOnce;
1961
1962 let (mut world, _rx) = pick_world(false);
1963 let panel_root = world.spawn((RRoot, RNode(100))).id();
1964 let panel_button = world.spawn((RNode(101), ChildOf(panel_root))).id();
1965 let app_node = world.spawn(RNode(7)).id();
1966 report_panel_root(&mut world, 100, panel_root);
1967 set_mouse_hits(&mut world, &[(panel_button, 5.0), (app_node, 1.0)]);
1968
1969 world.run_system_once(drive_pick_mode).unwrap();
1970 assert_eq!(
1971 world.resource::<DevtoolsState>().pick_hover,
1972 Some(7),
1973 "an app node in front of the panel must win"
1974 );
1975 }
1976
1977 #[test]
1980 fn pick_click_selects_and_notifies_js() {
1981 use bevy::ecs::system::RunSystemOnce;
1982
1983 let (mut world, mut rx) = pick_world(true);
1984 let app_node = world.spawn(RNode(7)).id();
1985 set_mouse_hits(&mut world, &[(app_node, 5.0)]);
1986
1987 world.run_system_once(drive_pick_mode).unwrap();
1988 let state = world.resource::<DevtoolsState>();
1989 assert_eq!(state.selected, Some(7));
1990 assert!(!state.pick, "a successful pick exits pick mode");
1991 match rx.try_recv().expect("a devtools.picked event") {
1992 Outbound::Event { name, value } => {
1993 assert_eq!(name, "devtools.picked");
1994 assert_eq!(value["id"], 7);
1995 }
1996 other => panic!("expected Outbound::Event, got {other:?}"),
1997 }
1998 }
1999
2000 #[test]
2004 fn dock_reservation_insets_uiroot_margin() {
2005 let (mut app, _rx) = test_app(DevtoolsConfig::default());
2006 let root = app
2007 .world_mut()
2008 .spawn((Node::default(), crate::plugin::UiRoot))
2009 .id();
2010 let margin = |app: &mut App| {
2011 let node = app.world().entity(root).get::<Node>().unwrap();
2012 (node.margin.left, node.margin.right)
2013 };
2014
2015 {
2016 let mut state = app.world_mut().resource_mut::<DevtoolsState>();
2017 state.open = true;
2018 state.dock_side = Some(DockSide::Right);
2019 state.dock_width = 300.0;
2020 }
2021 app.update();
2022 assert_eq!(margin(&mut app), (Val::ZERO, Val::Px(300.0)));
2023
2024 app.world_mut().resource_mut::<DevtoolsState>().dock_side = Some(DockSide::Left);
2025 app.update();
2026 assert_eq!(margin(&mut app), (Val::Px(300.0), Val::ZERO));
2027
2028 app.world_mut().resource_mut::<DevtoolsState>().open = false;
2030 app.update();
2031 assert_eq!(margin(&mut app), (Val::ZERO, Val::ZERO));
2032 }
2033
2034 #[test]
2038 fn dock_message_parses_side_and_clamps_width() {
2039 let (mut app, _rx) = test_app(DevtoolsConfig::default());
2040 let dock = |app: &mut App, side: Option<&str>, width: f32| {
2041 app.world_mut().trigger(DevtoolsDockMessage {
2042 side: side.map(String::from),
2043 width,
2044 });
2045 let state = app.world().resource::<DevtoolsState>();
2046 (state.dock_side, state.dock_width)
2047 };
2048
2049 assert_eq!(
2050 dock(&mut app, Some("left"), 320.0),
2051 (Some(DockSide::Left), 320.0)
2052 );
2053 assert_eq!(
2054 dock(&mut app, Some("right"), 280.0),
2055 (Some(DockSide::Right), 280.0)
2056 );
2057 assert_eq!(dock(&mut app, Some("bogus"), -5.0), (None, 0.0));
2058 assert_eq!(dock(&mut app, None, 380.0), (None, 380.0));
2059 }
2060
2061 #[test]
2065 fn batch_stats_skip_devtools_only_applies() {
2066 let (mut app, mut rx) = test_app(DevtoolsConfig {
2067 settings_path: None,
2068 ..default()
2069 });
2070 app.world_mut().resource_mut::<DevtoolsState>().open = true;
2071 let stats_events = |rx: &mut UnboundedReceiver<Outbound>| {
2072 drain_events(rx)
2073 .into_iter()
2074 .filter(|(name, _)| name == "devtools.batchStats")
2075 .count()
2076 };
2077
2078 {
2080 let mut stats = app.world_mut().resource_mut::<OpApplyStats>();
2081 stats.applied_count = 1;
2082 stats.app_applied_count = 0;
2083 }
2084 app.update();
2085 assert_eq!(
2086 stats_events(&mut rx),
2087 0,
2088 "the panel's own commits must not produce batch stats"
2089 );
2090
2091 {
2093 let mut stats = app.world_mut().resource_mut::<OpApplyStats>();
2094 stats.applied_count = 2;
2095 stats.app_applied_count = 1;
2096 }
2097 app.update();
2098 let stats: Vec<_> = drain_events(&mut rx)
2099 .into_iter()
2100 .filter(|(name, _)| name == "devtools.batchStats")
2101 .collect();
2102 assert_eq!(stats.len(), 1, "an app apply reports once");
2103 assert!(
2104 stats[0].1.get("frame_wait_ms").is_some(),
2105 "batch stats carry the frame-wait leg"
2106 );
2107 }
2108
2109 struct TempSettings(std::path::PathBuf);
2111 impl TempSettings {
2112 fn new(name: &str) -> Self {
2113 Self(std::env::temp_dir().join(format!(
2114 "bevy-react-devtools-{name}-{}.json",
2115 std::process::id()
2116 )))
2117 }
2118 }
2119 impl Drop for TempSettings {
2120 fn drop(&mut self) {
2121 let _ = std::fs::remove_file(&self.0);
2122 }
2123 }
2124
2125 fn sample_settings() -> DevtoolsSettings {
2126 DevtoolsSettings {
2127 open: false,
2128 tab: "layers".into(),
2129 mode: "float".into(),
2130 width_frac: 0.4,
2131 float_x_frac: 0.05,
2132 float_y_frac: 0.1,
2133 float_w_frac: 0.5,
2134 float_h_frac: 0.6,
2135 reserve: true,
2136 overlay: false,
2137 split: 200.0,
2138 }
2139 }
2140
2141 fn drain_restores(rx: &mut UnboundedReceiver<Outbound>) -> Vec<serde_json::Value> {
2142 drain_events(rx)
2143 .into_iter()
2144 .filter(|(name, _)| name == "devtools.restore")
2145 .map(|(_, v)| v)
2146 .collect()
2147 }
2148
2149 #[test]
2152 fn settings_file_seeds_overlay_and_restores_once() {
2153 let tmp = TempSettings::new("restore");
2154 std::fs::write(&tmp.0, serde_json::to_string(&sample_settings()).unwrap()).unwrap();
2155 let (mut app, mut rx) = test_app(DevtoolsConfig {
2156 settings_path: Some(tmp.0.clone()),
2157 ..default()
2158 });
2159
2160 assert!(
2161 !app.world()
2162 .resource::<DevtoolsState>()
2163 .show_selection_overlay,
2164 "the loaded overlay=false must seed the state at build"
2165 );
2166
2167 app.update();
2168 assert!(
2169 drain_restores(&mut rx).is_empty(),
2170 "no restore before the React app mounted"
2171 );
2172
2173 app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
2174 app.update();
2175 let restores = drain_restores(&mut rx);
2176 assert_eq!(restores.len(), 1, "exactly one restore after mount");
2177 assert_eq!(restores[0]["mode"], "float");
2178 assert_eq!(restores[0]["split"], 200.0);
2179 assert_eq!(restores[0]["overlay"], false);
2180
2181 app.update();
2182 assert!(drain_restores(&mut rx).is_empty(), "restore is one-shot");
2183 }
2184
2185 #[test]
2188 fn settings_message_writes_file_debounced() {
2189 let tmp = TempSettings::new("save");
2190 let (mut app, _rx) = test_app(DevtoolsConfig {
2191 settings_path: Some(tmp.0.clone()),
2192 ..default()
2193 });
2194 app.world_mut()
2195 .resource_mut::<DevtoolsPersistence>()
2196 .debounce = Duration::ZERO;
2197
2198 app.world_mut().trigger(sample_settings());
2199 app.update();
2200
2201 let written: DevtoolsSettings =
2202 serde_json::from_str(&std::fs::read_to_string(&tmp.0).unwrap()).unwrap();
2203 assert!(written == sample_settings(), "full round-trip");
2204
2205 let mtime = |p: &std::path::Path| std::fs::metadata(p).unwrap().modified().unwrap();
2207 let before = mtime(&tmp.0);
2208 app.world_mut().trigger(sample_settings());
2209 app.update();
2210 assert_eq!(mtime(&tmp.0), before, "identical settings skip the write");
2211 }
2212
2213 #[test]
2216 fn settings_flush_on_app_exit() {
2217 let tmp = TempSettings::new("flush");
2218 let (mut app, _rx) = test_app(DevtoolsConfig {
2219 settings_path: Some(tmp.0.clone()),
2220 ..default()
2221 });
2222 app.world_mut().trigger(sample_settings());
2224 app.update();
2225 assert!(!tmp.0.exists(), "still inside the debounce window");
2226
2227 app.world_mut().write_message(AppExit::Success);
2228 app.update();
2229 assert!(tmp.0.exists(), "AppExit must flush the pending settings");
2230 }
2231
2232 #[test]
2236 fn corrupt_or_disabled_settings_are_ignored() {
2237 let tmp = TempSettings::new("corrupt");
2238 std::fs::write(&tmp.0, "{ not json").unwrap();
2239 let (mut app, mut rx) = test_app(DevtoolsConfig {
2240 settings_path: Some(tmp.0.clone()),
2241 ..default()
2242 });
2243 assert!(
2244 app.world()
2245 .resource::<DevtoolsState>()
2246 .show_selection_overlay
2247 );
2248 app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
2249 app.update();
2250 let restores = drain_restores(&mut rx);
2251 assert_eq!(restores.len(), 1, "corrupt file → one restore, defaults");
2252 assert_eq!(restores[0]["mode"], "right");
2253 assert_eq!(restores[0]["open"], false);
2254 assert!(!app.world().resource::<DevtoolsState>().open);
2255 drop(app);
2258
2259 let (mut app, _rx) = test_app(DevtoolsConfig {
2260 settings_path: None,
2261 ..default()
2262 });
2263 app.world_mut()
2264 .resource_mut::<DevtoolsPersistence>()
2265 .debounce = Duration::ZERO;
2266 app.world_mut().trigger(sample_settings());
2267 app.update(); }
2269
2270 #[test]
2274 fn restore_defaults_sent_without_settings_file() {
2275 let (mut app, mut rx) = test_app(DevtoolsConfig {
2276 settings_path: None,
2277 ..default()
2278 });
2279 app.update();
2280 assert!(drain_restores(&mut rx).is_empty(), "not before mount");
2281
2282 app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
2283 app.update();
2284 let restores = drain_restores(&mut rx);
2285 assert_eq!(restores.len(), 1, "defaults restore exactly once");
2286 assert_eq!(restores[0]["open"], false);
2287 assert_eq!(restores[0]["mode"], "right");
2288
2289 app.update();
2290 assert!(drain_restores(&mut rx).is_empty(), "one-shot");
2291 }
2292
2293 #[test]
2297 fn legacy_pixel_settings_file_migrates() {
2298 let tmp = TempSettings::new("legacy");
2299 let legacy = serde_json::json!({
2300 "mode": "float",
2301 "width": 420.0,
2302 "float_x": 10.0,
2303 "float_y": 20.0,
2304 "float_w": 500.0,
2305 "float_h": 600.0,
2306 "reserve": true,
2307 "overlay": false,
2308 "split": 200.0,
2309 });
2310 std::fs::write(&tmp.0, legacy.to_string()).unwrap();
2311 let (mut app, mut rx) = test_app(DevtoolsConfig {
2312 settings_path: Some(tmp.0.clone()),
2313 ..default()
2314 });
2315 app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
2316 app.update();
2317 let restores = drain_restores(&mut rx);
2318 assert_eq!(restores.len(), 1, "a legacy file must still restore");
2319 assert_eq!(restores[0]["mode"], "float");
2320 assert_eq!(restores[0]["overlay"], false);
2321 assert_eq!(restores[0]["split"], 200.0);
2322 assert_eq!(restores[0]["open"], false, "no persisted open → closed");
2323 assert_eq!(restores[0]["tab"], "nodes", "no persisted tab → default");
2324 let frac = restores[0]["width_frac"].as_f64().expect("a number");
2325 assert!(
2326 (frac - f64::from(DevtoolsSettings::default().width_frac)).abs() < 1e-6,
2327 "stale pixel width is ignored; the fraction takes its default"
2328 );
2329 }
2330
2331 #[test]
2335 fn window_size_sent_on_open_and_resize() {
2336 use bevy::window::WindowResolution;
2337
2338 let (mut app, mut rx) = test_app(DevtoolsConfig {
2339 settings_path: None,
2340 ..default()
2341 });
2342 let window = app
2343 .world_mut()
2344 .spawn(Window {
2345 resolution: WindowResolution::new(800, 600),
2346 ..Default::default()
2347 })
2348 .id();
2349 let sizes = |rx: &mut UnboundedReceiver<Outbound>| {
2350 drain_events(rx)
2351 .into_iter()
2352 .filter(|(name, _)| name == "devtools.window")
2353 .map(|(_, v)| (v["width"].as_f64().unwrap(), v["height"].as_f64().unwrap()))
2354 .collect::<Vec<_>>()
2355 };
2356
2357 app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
2359 app.update();
2360 let restore_frame = sizes(&mut rx);
2361 assert_eq!(
2362 restore_frame,
2363 vec![(800.0, 600.0)],
2364 "the restore one-shot sends the size once, ahead of the blob"
2365 );
2366
2367 app.world_mut().resource_mut::<DevtoolsState>().open = true;
2369 app.update();
2370 assert_eq!(sizes(&mut rx), vec![(800.0, 600.0)], "sent on open");
2371 app.update();
2372 assert!(sizes(&mut rx).is_empty(), "idle frames are silent");
2373
2374 app.world_mut()
2376 .entity_mut(window)
2377 .get_mut::<Window>()
2378 .unwrap()
2379 .resolution = WindowResolution::new(1024, 768);
2380 app.update();
2381 assert_eq!(sizes(&mut rx), vec![(1024.0, 768.0)], "sent on resize");
2382
2383 app.world_mut().resource_mut::<DevtoolsState>().open = false;
2385 app.update();
2386 app.world_mut()
2387 .entity_mut(window)
2388 .get_mut::<Window>()
2389 .unwrap()
2390 .resolution = WindowResolution::new(640, 480);
2391 app.update();
2392 assert!(sizes(&mut rx).is_empty(), "closed: no size traffic");
2393 app.world_mut().resource_mut::<DevtoolsState>().open = true;
2394 app.update();
2395 assert_eq!(
2396 sizes(&mut rx),
2397 vec![(640.0, 480.0)],
2398 "reopen must catch up on a resize that happened while closed"
2399 );
2400 }
2401
2402 #[test]
2408 fn layers_emit_gated_and_diffed() {
2409 use crate::layer::{LayerMeta, LayersRegistry, PromotionReasons};
2410 use bevy::window::WindowResolution;
2411
2412 let (mut app, mut rx) = test_app(DevtoolsConfig {
2413 settings_path: None,
2414 ..default()
2415 });
2416 app.world_mut().spawn(Window {
2417 resolution: WindowResolution::new(800, 600),
2418 ..Default::default()
2419 });
2420 let entity = app.world_mut().spawn(RNode(7)).id();
2421 app.world_mut()
2422 .resource_mut::<LayersRegistry>()
2423 .layers
2424 .insert(
2425 7,
2426 LayerMeta {
2427 node: 7,
2428 entity,
2429 reasons: PromotionReasons(PromotionReasons::OPACITY),
2430 group_alpha: 0.5,
2431 capture_rect: Some(IRect::new(10, 10, 110, 60)),
2432 depth: 1,
2433 repaints: 0,
2434 cached: false,
2435 cache_policy: Default::default(),
2436 },
2437 );
2438 let payloads = |rx: &mut UnboundedReceiver<Outbound>| {
2439 drain_events(rx)
2440 .into_iter()
2441 .filter(|(name, _)| name == "devtools.layers")
2442 .map(|(_, v)| v)
2443 .collect::<Vec<_>>()
2444 };
2445
2446 app.update();
2447 assert!(
2448 payloads(&mut rx).is_empty(),
2449 "closed panel: no layer stream"
2450 );
2451
2452 {
2453 let mut state = app.world_mut().resource_mut::<DevtoolsState>();
2454 state.open = true;
2455 state.layers_tab_open = true;
2456 }
2457 app.update();
2458 let sent = payloads(&mut rx);
2459 assert_eq!(sent.len(), 1, "opening the tab sends one payload");
2460 let layers = sent[0]["layers"].as_array().expect("a layers array");
2461 assert_eq!(layers.len(), 2, "base row + one registry row");
2462 assert_eq!(layers[0]["id"], 0);
2463 assert_eq!(layers[0]["reasons"], serde_json::json!(["base"]));
2464 assert_eq!(layers[0]["depth"], 0);
2465 assert_eq!(layers[0]["rect"]["width"], 800.0);
2466 assert_eq!(layers[0]["rect"]["physical_height"], 600);
2467 assert_eq!(layers[1]["id"], 7);
2468 assert_eq!(layers[1]["reasons"], serde_json::json!(["opacity"]));
2469 assert_eq!(layers[1]["depth"], 1);
2470 assert_eq!(layers[1]["node_count"], 0, "empty membership map");
2471 assert_eq!(layers[1]["rect"]["x"], 10.0);
2472 assert_eq!(layers[1]["rect"]["y"], 10.0);
2473 assert_eq!(layers[1]["rect"]["width"], 100.0);
2474 assert_eq!(layers[1]["rect"]["height"], 50.0);
2475 assert_eq!(layers[1]["rect"]["physical_width"], 100);
2476 assert_eq!(
2477 layers[1]["filters"],
2478 serde_json::json!([]),
2479 "no resolved chain on the entity: empty filters"
2480 );
2481
2482 app.update();
2483 assert!(payloads(&mut rx).is_empty(), "idle frames are silent");
2484
2485 app.world_mut()
2489 .resource_mut::<LayersRegistry>()
2490 .layers
2491 .get_mut(&7)
2492 .unwrap()
2493 .capture_rect = Some(IRect::new(-20, -10, 80, 40));
2494 app.update();
2495 let sent = payloads(&mut rx);
2496 assert_eq!(sent.len(), 1, "a rect change re-emits");
2497 assert_eq!(sent[0]["layers"][1]["rect"]["x"], -20.0);
2498 assert_eq!(sent[0]["layers"][1]["rect"]["y"], -10.0);
2499 assert_eq!(sent[0]["layers"][1]["rect"]["width"], 100.0);
2500 assert_eq!(sent[0]["layers"][1]["rect"]["physical_height"], 50);
2501
2502 app.world_mut()
2504 .resource_mut::<LayersRegistry>()
2505 .layers
2506 .get_mut(&7)
2507 .unwrap()
2508 .capture_rect = None;
2509 app.update();
2510 let sent = payloads(&mut rx);
2511 assert_eq!(sent.len(), 1);
2512 assert_eq!(sent[0]["layers"].as_array().unwrap().len(), 2);
2513 assert!(
2514 sent[0]["layers"][1]["rect"].is_null(),
2515 "inactive layers stay listed with a null rect"
2516 );
2517
2518 app.world_mut()
2520 .resource_mut::<DevtoolsState>()
2521 .layers_tab_open = false;
2522 app.world_mut()
2523 .resource_mut::<LayersRegistry>()
2524 .layers
2525 .get_mut(&7)
2526 .unwrap()
2527 .capture_rect = Some(IRect::new(10, 10, 110, 60));
2528 app.update();
2529 assert!(payloads(&mut rx).is_empty(), "hidden tab: silence");
2530
2531 app.world_mut()
2534 .resource_mut::<DevtoolsState>()
2535 .layers_tab_open = true;
2536 app.update();
2537 assert_eq!(
2538 payloads(&mut rx).len(),
2539 1,
2540 "a re-shown tab gets a fresh full payload"
2541 );
2542 }
2543
2544 #[test]
2554 fn layers_emit_resolved_filter_chain() {
2555 use crate::animations::ValueKind;
2556 use crate::filters::{
2557 FilterChain, FilterInput, FilterUse, ParamSlot, ResolvedFilterChain, ResolvedFilterPass,
2558 };
2559 use crate::layer::{LayerMeta, LayersRegistry, PromotionReasons};
2560 use bevy::window::WindowResolution;
2561 use std::sync::Arc;
2562
2563 let slot =
2564 |name: &'static str, kind: ValueKind, vec: usize, comp: usize, len: usize| ParamSlot {
2565 name,
2566 kind,
2567 vec,
2568 comp,
2569 len,
2570 };
2571 let blur_layout: Arc<[ParamSlot]> =
2572 Arc::from(vec![slot("radius", ValueKind::Length, 0, 0, 1)]);
2573 let blur_pass = |dir: (f32, f32)| ResolvedFilterPass {
2576 shader: Handle::default(),
2577 params: vec![Vec4::new(8.5, dir.0, dir.1, 0.0)],
2578 layout: blur_layout.clone(),
2579 wire_index: 0,
2580 };
2581 let hue_pass = ResolvedFilterPass {
2582 shader: Handle::default(),
2583 params: vec![
2584 Vec4::new(std::f32::consts::FRAC_PI_2, 0.0, 0.0, 0.0),
2585 Vec4::new(1.0, 0.5, 0.0, 1.0),
2586 ],
2587 layout: Arc::from(vec![
2588 slot("angle", ValueKind::Angle, 0, 0, 1),
2589 slot("tint", ValueKind::Color, 1, 0, 4),
2590 ]),
2591 wire_index: 1,
2592 };
2593 let sepia_pass = ResolvedFilterPass {
2595 shader: Handle::default(),
2596 params: vec![Vec4::new(4.249_99, 0.0, 0.0, 0.0)],
2597 layout: Arc::from(vec![slot("amount", ValueKind::Scalar, 0, 0, 1)]),
2598 wire_index: 2,
2599 };
2600 let orphan_pass = ResolvedFilterPass {
2604 shader: Handle::default(),
2605 params: vec![Vec4::new(0.4, 0.0, 0.0, 0.0)],
2606 layout: Arc::from(vec![slot("x", ValueKind::Scalar, 0, 0, 1)]),
2607 wire_index: 3,
2608 };
2609 let wire = |name: &str| FilterUse {
2610 name: name.to_string(),
2611 params: serde_json::Map::new(),
2612 };
2613
2614 let (mut app, mut rx) = test_app(DevtoolsConfig {
2615 settings_path: None,
2616 ..default()
2617 });
2618 app.world_mut().spawn(Window {
2619 resolution: WindowResolution::new(800, 600),
2620 ..Default::default()
2621 });
2622 let entity = app
2623 .world_mut()
2624 .spawn((
2625 RNode(7),
2626 ResolvedFilterChain {
2627 passes: vec![
2628 blur_pass((1.0, 0.0)),
2629 blur_pass((0.0, 1.0)),
2630 hue_pass,
2631 sepia_pass,
2632 orphan_pass,
2633 ],
2634 outset_px: 26,
2635 always_dirty: false,
2636 version: 1,
2637 scale: 2.0,
2638 },
2639 FilterInput(FilterChain(vec![
2640 wire("blur"),
2641 wire("hueRotate"),
2642 wire("sepia"),
2643 ])),
2644 ))
2645 .id();
2646 app.world_mut()
2647 .resource_mut::<LayersRegistry>()
2648 .layers
2649 .insert(
2650 7,
2651 LayerMeta {
2652 node: 7,
2653 entity,
2654 reasons: PromotionReasons(PromotionReasons::FILTER),
2655 group_alpha: 1.0,
2656 capture_rect: Some(IRect::new(10, 10, 110, 60)),
2657 depth: 1,
2658 repaints: 0,
2659 cached: false,
2660 cache_policy: Default::default(),
2661 },
2662 );
2663 {
2664 let mut state = app.world_mut().resource_mut::<DevtoolsState>();
2665 state.open = true;
2666 state.layers_tab_open = true;
2667 }
2668 let payloads = |rx: &mut UnboundedReceiver<Outbound>| {
2669 drain_events(rx)
2670 .into_iter()
2671 .filter(|(name, _)| name == "devtools.layers")
2672 .map(|(_, v)| v)
2673 .collect::<Vec<_>>()
2674 };
2675
2676 app.update();
2677 let sent = payloads(&mut rx);
2678 assert_eq!(sent.len(), 1);
2679 assert_eq!(
2680 sent[0]["layers"][1]["filters"],
2681 serde_json::json!([
2682 { "name": "blur", "params": [["radius", [4.25]]] },
2683 { "name": "hueRotate",
2684 "params": [["angle", [90.0]], ["tint", [1.0, 0.5, 0.0, 1.0]]] },
2685 { "name": "sepia", "params": [["amount", [4.25]]] },
2686 { "name": "#3", "params": [["x", [0.4]]] },
2687 ]),
2688 "wire-grouped chain with display-unit, rounded params"
2689 );
2690
2691 app.world_mut()
2694 .entity_mut(entity)
2695 .get_mut::<ResolvedFilterChain>()
2696 .unwrap()
2697 .passes[0]
2698 .params[0]
2699 .x = 8.5008;
2700 app.update();
2701 assert!(
2702 payloads(&mut rx).is_empty(),
2703 "sub-0.001 display noise must not re-emit (rounding is the rate limiter)"
2704 );
2705
2706 app.world_mut()
2709 .entity_mut(entity)
2710 .get_mut::<ResolvedFilterChain>()
2711 .unwrap()
2712 .passes[0]
2713 .params[0]
2714 .x = 9.0;
2715 app.update();
2716 let sent = payloads(&mut rx);
2717 assert_eq!(sent.len(), 1, "a visible param change re-emits");
2718 assert_eq!(
2719 sent[0]["layers"][1]["filters"][0]["params"][0],
2720 serde_json::json!(["radius", [4.5]]),
2721 "live value in display units"
2722 );
2723 }
2724
2725 #[test]
2730 fn layers_open_message_flips_state_and_close_clears_it() {
2731 let (mut app, _rx) = test_app(DevtoolsConfig {
2732 settings_path: None,
2733 ..default()
2734 });
2735 app.world_mut()
2736 .trigger(DevtoolsLayersOpenMessage { on: true });
2737 app.world_mut()
2738 .trigger(DevtoolsConsoleOpenMessage { on: true });
2739 {
2740 let state = app.world().resource::<DevtoolsState>();
2741 assert!(state.layers_tab_open);
2742 assert!(state.console_tab_open);
2743 }
2744 app.world_mut()
2746 .resource_mut::<DevtoolsState>()
2747 .console_last_seq = Some(7);
2748
2749 app.world_mut().trigger(DevtoolsOpenMessage { open: false });
2750 let state = app.world().resource::<DevtoolsState>();
2751 assert!(!state.open);
2752 assert!(
2753 !state.layers_tab_open,
2754 "closing the panel must kill the layer stream"
2755 );
2756 assert!(
2757 !state.console_tab_open,
2758 "closing the panel must kill the console stream"
2759 );
2760 assert_eq!(
2761 state.console_last_seq, None,
2762 "the console watermark must reset so a reopen resends the backlog"
2763 );
2764 }
2765
2766 #[test]
2772 fn console_stream_backlog_then_increment() {
2773 use crate::console_log::{Level, Source};
2774
2775 let (mut app, mut rx) = test_app(DevtoolsConfig {
2776 settings_path: None,
2777 ..default()
2778 });
2779 let mine = |v: &serde_json::Value| -> Vec<String> {
2780 v["entries"]
2781 .as_array()
2782 .unwrap()
2783 .iter()
2784 .filter_map(|e| e["message"].as_str())
2785 .filter(|m| m.contains("cons-t1-"))
2786 .map(String::from)
2787 .collect()
2788 };
2789 let payloads = |rx: &mut UnboundedReceiver<Outbound>| {
2790 drain_events(rx)
2791 .into_iter()
2792 .filter(|(name, _)| name == "devtools.console")
2793 .map(|(_, v)| v)
2794 .collect::<Vec<_>>()
2795 };
2796
2797 crate::console_log::push(Source::Rust, Level::Warn, "cons-t1-a");
2798 crate::console_log::push(Source::Js, Level::Error, "cons-t1-b");
2799 app.update();
2800 assert!(
2801 payloads(&mut rx).iter().all(|v| mine(v).is_empty()),
2802 "closed panel: no console stream"
2803 );
2804
2805 app.world_mut().resource_mut::<DevtoolsState>().open = true;
2806 app.world_mut()
2807 .trigger(DevtoolsConsoleOpenMessage { on: true });
2808 app.update();
2809 let sent = payloads(&mut rx);
2810 let marked: Vec<String> = sent.iter().flat_map(&mine).collect();
2811 assert_eq!(
2812 marked,
2813 vec!["cons-t1-a".to_string(), "cons-t1-b".to_string()],
2814 "tab open sends the backlog, oldest first"
2815 );
2816 let entry = sent
2818 .iter()
2819 .flat_map(|v| v["entries"].as_array().unwrap().clone())
2820 .find(|e| e["message"] == "cons-t1-b")
2821 .unwrap();
2822 assert_eq!(entry["source"], "js");
2823 assert_eq!(entry["level"], "error");
2824 assert!(entry["seq"].as_u64().is_some());
2825 assert!(entry["time_ms"].as_u64().is_some());
2826
2827 crate::console_log::push(Source::Js, Level::Info, "cons-t1-c");
2828 app.update();
2829 let marked: Vec<String> = payloads(&mut rx).iter().flat_map(&mine).collect();
2830 assert_eq!(
2831 marked,
2832 vec!["cons-t1-c".to_string()],
2833 "later frames send only new entries"
2834 );
2835
2836 app.world_mut()
2839 .trigger(DevtoolsConsoleOpenMessage { on: false });
2840 app.update();
2841 assert!(payloads(&mut rx).iter().all(|v| mine(v).is_empty()));
2842 app.world_mut()
2843 .trigger(DevtoolsConsoleOpenMessage { on: true });
2844 app.update();
2845 let marked: Vec<String> = payloads(&mut rx).iter().flat_map(mine).collect();
2846 assert_eq!(
2847 marked,
2848 vec![
2849 "cons-t1-a".to_string(),
2850 "cons-t1-b".to_string(),
2851 "cons-t1-c".to_string()
2852 ],
2853 "a re-shown tab gets the full backlog again"
2854 );
2855 }
2856
2857 #[test]
2859 fn console_clear_message_empties_ring() {
2860 use crate::console_log::{Level, Source};
2861
2862 let (mut app, _rx) = test_app(DevtoolsConfig {
2863 settings_path: None,
2864 ..default()
2865 });
2866 crate::console_log::push(Source::Js, Level::Info, "cons-t2-before");
2867 let (_, watermark_before) = crate::console_log::since(0);
2868 app.world_mut().trigger(DevtoolsConsoleClearMessage {});
2869 let (entries, _) = crate::console_log::since(0);
2870 assert!(
2871 entries.iter().all(|e| !e.message.contains("cons-t2-")),
2872 "clear must drop our entry"
2873 );
2874 crate::console_log::push(Source::Js, Level::Info, "cons-t2-after");
2875 let (entries, _) = crate::console_log::since(0);
2876 let after = entries
2877 .iter()
2878 .find(|e| e.message == "cons-t2-after")
2879 .expect("post-clear pushes land");
2880 assert!(
2881 after.seq > watermark_before,
2882 "seq keeps counting across a clear"
2883 );
2884 }
2885
2886 #[test]
2887 fn highlight_rect_converts_physical_center_to_logical_top_left() {
2888 let (pos, size) = highlight_rect(Vec2::new(200.0, 100.0), Vec2::new(300.0, 150.0), 0.5);
2890 assert_eq!(pos, Vec2::new(100.0, 50.0));
2891 assert_eq!(size, Vec2::new(100.0, 50.0));
2892 }
2893
2894 #[test]
2895 fn split_legs_computes_command_and_layout() {
2896 let t0 = std::time::Instant::now();
2897 let t1 = t0 + Duration::from_millis(5);
2898 let t2 = t1 + Duration::from_millis(7);
2899 assert_eq!(
2900 split_legs(t0, t1, t2),
2901 (Duration::from_millis(5), Duration::from_millis(7))
2902 );
2903 assert_eq!(split_legs(t1, t0, t2), (Duration::ZERO, t2 - t0));
2905 }
2906}