1use bevy::picking::hover::HoverMap;
53use bevy::picking::pointer::PointerId;
54use bevy::platform::collections::HashSet;
55use bevy::prelude::*;
56use bevy::ui::{ComputedNode, IsDefaultUiCamera, UiGlobalTransform, UiSystems};
57use std::time::Duration;
58
59use crate::bridge::{JsBridge, RNode};
60use crate::event::ReactEvents;
61use crate::message::ReactAppExt;
62use crate::plugin::PointerCapture;
63use crate::protocol::NodeId;
64use crate::reconcile::{OpApplyStats, climb};
65use crate::window::ui_viewport_size;
66use crate::{react_event, react_message};
67
68#[derive(Resource, Clone)]
86pub struct DevtoolsConfig {
87 pub enabled: bool,
90 pub toggle_key: KeyCode,
92 pub settings_path: Option<std::path::PathBuf>,
98}
99
100impl Default for DevtoolsConfig {
101 fn default() -> Self {
102 Self {
103 enabled: true,
104 toggle_key: KeyCode::F12,
105 settings_path: Some(std::path::PathBuf::from(".bevy-react-devtools.json")),
106 }
107 }
108}
109
110pub struct DevtoolsPlugin {
115 config: DevtoolsConfig,
116}
117
118impl DevtoolsPlugin {
119 pub fn new(config: DevtoolsConfig) -> Self {
120 Self { config }
121 }
122}
123
124impl Plugin for DevtoolsPlugin {
125 fn build(&self, app: &mut App) {
126 if !cfg!(debug_assertions) {
130 debug!("DevtoolsPlugin is inert in release builds");
131 return;
132 }
133 app.init_resource::<ButtonInput<KeyCode>>();
136 app.init_resource::<ButtonInput<MouseButton>>();
137 crate::diag::arm_runtime();
141 let loaded = load_settings(self.config.settings_path.as_deref());
146 app.insert_resource(DevtoolsState {
147 show_selection_overlay: loaded.as_ref().is_none_or(|s| s.overlay),
148 ..Default::default()
149 })
150 .insert_resource(DevtoolsPersistence {
151 last_written: loaded.clone(),
152 loaded,
153 pending: None,
154 #[cfg(not(target_arch = "wasm32"))]
155 dirty_at: None,
156 debounce: Duration::from_secs(1),
157 })
158 .init_resource::<DevtoolsTimers>()
159 .insert_resource(self.config.clone())
160 .add_react_handler(on_open_message)
164 .add_react_handler(on_pick_message)
165 .add_react_handler(on_select_message)
166 .add_react_handler(on_highlight_message)
167 .add_react_handler(on_overlay_message)
168 .add_react_handler(on_panel_root_message)
169 .add_react_handler(on_dock_message)
170 .add_react_handler(on_settings_message)
171 .add_systems(Startup, spawn_highlight_overlay)
174 .add_systems(
175 Update,
176 (
177 toggle_on_key,
178 send_window_size,
179 position_highlight,
180 apply_dock_reservation,
181 send_restore,
182 save_settings,
183 emit_runtime_warnings,
186 ),
187 )
188 .add_systems(Last, flush_settings_on_exit)
192 .add_systems(
196 Update,
197 drive_pick_mode
198 .in_set(crate::plugin::PointerCaptureSet)
199 .after(crate::reconcile::collect_pointer_events),
200 )
201 .add_systems(
202 PostUpdate,
203 (
204 mark_pre_layout
208 .after(UiSystems::Content)
209 .before(UiSystems::Layout),
210 mark_post_layout.after(UiSystems::PostLayout),
214 emit_batch_stats.after(mark_post_layout),
215 ),
216 );
217 }
218}
219
220#[derive(Resource)]
223pub(crate) struct DevtoolsState {
224 pub open: bool,
226 pub pick: bool,
228 pub selected: Option<NodeId>,
230 pub tree_hover: Option<NodeId>,
232 pub pick_hover: Option<NodeId>,
234 pub show_selection_overlay: bool,
238 pub panel_root: Option<NodeId>,
242 pub dock_side: Option<DockSide>,
246 pub dock_width: f32,
248}
249
250#[derive(Clone, Copy, PartialEq, Eq, Debug)]
252pub(crate) enum DockSide {
253 Left,
254 Right,
255}
256
257impl Default for DevtoolsState {
258 fn default() -> Self {
259 Self {
260 open: false,
261 pick: false,
262 selected: None,
263 tree_hover: None,
264 pick_hover: None,
265 show_selection_overlay: true,
266 panel_root: None,
267 dock_side: None,
268 dock_width: 0.0,
269 }
270 }
271}
272
273#[react_event(name = "devtools.toggle")]
279struct DevtoolsToggle {
280 open: bool,
281}
282
283#[react_event(name = "devtools.picked")]
285struct DevtoolsPicked {
286 id: NodeId,
287}
288
289#[react_event(name = "devtools.window")]
295struct DevtoolsWindow {
296 width: f32,
297 height: f32,
298}
299
300#[react_event(name = "devtools.batchStats")]
306struct DevtoolsBatchStats {
307 applied_count: u64,
309 last_ops: usize,
311 frame_wait_ms: f64,
314 pre_apply_ms: f64,
317 translate_ms: f64,
319 command_ms: f64,
321 layout_ms: f64,
323}
324
325#[react_event(name = "devtools.warning")]
333struct DevtoolsWarning {
334 id: Option<NodeId>,
336 kind: String,
338 value: String,
340 message: String,
342}
343
344#[react_message(name = "devtools.open")]
346struct DevtoolsOpenMessage {
347 open: bool,
348}
349
350#[react_message(name = "devtools.pick")]
352struct DevtoolsPickMessage {
353 on: bool,
354}
355
356#[react_message(name = "devtools.select")]
358struct DevtoolsSelectMessage {
359 id: Option<NodeId>,
360}
361
362#[react_message(name = "devtools.highlight")]
365struct DevtoolsHighlightMessage {
366 id: Option<NodeId>,
367}
368
369#[react_message(name = "devtools.overlay")]
372struct DevtoolsOverlayMessage {
373 on: bool,
374}
375
376#[react_message(name = "devtools.panelRoot")]
379struct DevtoolsPanelRootMessage {
380 id: Option<NodeId>,
381}
382
383#[react_message(name = "devtools.dock")]
388struct DevtoolsDockMessage {
389 side: Option<String>,
390 width: f32,
391}
392
393fn on_open_message(msg: On<DevtoolsOpenMessage>, mut state: ResMut<DevtoolsState>) {
394 state.open = msg.event().open;
395 if !state.open {
396 exit_interactions(&mut state);
397 }
398}
399
400fn on_pick_message(msg: On<DevtoolsPickMessage>, mut state: ResMut<DevtoolsState>) {
401 state.pick = msg.event().on;
402 if !state.pick {
403 state.pick_hover = None;
404 }
405}
406
407fn on_select_message(msg: On<DevtoolsSelectMessage>, mut state: ResMut<DevtoolsState>) {
408 state.selected = msg.event().id;
409}
410
411fn on_highlight_message(msg: On<DevtoolsHighlightMessage>, mut state: ResMut<DevtoolsState>) {
412 state.tree_hover = msg.event().id;
413}
414
415fn on_overlay_message(msg: On<DevtoolsOverlayMessage>, mut state: ResMut<DevtoolsState>) {
416 state.show_selection_overlay = msg.event().on;
417}
418
419fn on_panel_root_message(msg: On<DevtoolsPanelRootMessage>, mut state: ResMut<DevtoolsState>) {
420 state.panel_root = msg.event().id;
421}
422
423fn on_dock_message(msg: On<DevtoolsDockMessage>, mut state: ResMut<DevtoolsState>) {
424 state.dock_side = match msg.event().side.as_deref() {
425 Some("left") => Some(DockSide::Left),
426 Some("right") => Some(DockSide::Right),
427 _ => None,
428 };
429 state.dock_width = msg.event().width.max(0.0);
430}
431
432#[react_message(name = "devtools.settings")]
448#[derive(serde::Serialize, Clone, PartialEq)]
449#[serde(default)]
450pub(crate) struct DevtoolsSettings {
451 open: bool,
453 mode: String,
454 width_frac: f32,
455 float_x_frac: f32,
456 float_y_frac: f32,
457 float_w_frac: f32,
458 float_h_frac: f32,
459 reserve: bool,
460 overlay: bool,
461 split: f32,
462}
463
464impl Default for DevtoolsSettings {
465 fn default() -> Self {
466 Self {
467 open: false,
468 mode: "right".into(),
469 width_frac: 0.3,
470 float_x_frac: 0.08,
471 float_y_frac: 0.1,
472 float_w_frac: 0.33,
473 float_h_frac: 0.7,
474 reserve: false,
475 overlay: true,
476 split: 260.0,
477 }
478 }
479}
480
481#[react_event(name = "devtools.restore")]
484struct DevtoolsRestore {
485 open: bool,
486 mode: String,
487 width_frac: f32,
488 float_x_frac: f32,
489 float_y_frac: f32,
490 float_w_frac: f32,
491 float_h_frac: f32,
492 reserve: bool,
493 overlay: bool,
494 split: f32,
495}
496
497impl From<&DevtoolsSettings> for DevtoolsRestore {
498 fn from(s: &DevtoolsSettings) -> Self {
499 Self {
500 open: s.open,
501 mode: s.mode.clone(),
502 width_frac: s.width_frac,
503 float_x_frac: s.float_x_frac,
504 float_y_frac: s.float_y_frac,
505 float_w_frac: s.float_w_frac,
506 float_h_frac: s.float_h_frac,
507 reserve: s.reserve,
508 overlay: s.overlay,
509 split: s.split,
510 }
511 }
512}
513
514#[derive(Resource)]
517struct DevtoolsPersistence {
518 loaded: Option<DevtoolsSettings>,
519 pending: Option<DevtoolsSettings>,
520 last_written: Option<DevtoolsSettings>,
522 #[cfg(not(target_arch = "wasm32"))]
524 dirty_at: Option<std::time::Instant>,
525 debounce: Duration,
527}
528
529#[cfg_attr(target_arch = "wasm32", allow(unused_variables))]
532fn load_settings(path: Option<&std::path::Path>) -> Option<DevtoolsSettings> {
533 #[cfg(not(target_arch = "wasm32"))]
534 {
535 let text = std::fs::read_to_string(path?).ok()?;
536 serde_json::from_str(&text).ok()
537 }
538 #[cfg(target_arch = "wasm32")]
539 None
540}
541
542#[cfg(not(target_arch = "wasm32"))]
546fn write_pending(persist: &mut DevtoolsPersistence, path: &std::path::Path) {
547 persist.dirty_at = None;
548 let Some(pending) = persist.pending.clone() else {
549 return;
550 };
551 if persist.last_written.as_ref() == Some(&pending) {
552 return;
553 }
554 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
555 let _ = std::fs::create_dir_all(parent);
556 }
557 match serde_json::to_string_pretty(&pending) {
558 Ok(json) => match std::fs::write(path, json) {
559 Ok(()) => persist.last_written = Some(pending),
560 Err(e) => warn!("devtools: failed to write settings {}: {e}", path.display()),
561 },
562 Err(e) => warn!("devtools: failed to serialize settings: {e}"),
563 }
564}
565
566fn on_settings_message(
567 msg: On<DevtoolsSettings>,
568 #[cfg_attr(target_arch = "wasm32", allow(unused_mut, unused_variables))] mut persist: ResMut<
569 DevtoolsPersistence,
570 >,
571) {
572 #[cfg(not(target_arch = "wasm32"))]
573 {
574 persist.pending = Some(msg.event().clone());
575 persist.dirty_at = Some(std::time::Instant::now());
576 }
577 #[cfg(target_arch = "wasm32")]
578 let _ = msg;
579}
580
581fn send_restore(
590 persist: Res<DevtoolsPersistence>,
591 stats: Res<OpApplyStats>,
592 cameras: Query<&Camera, With<IsDefaultUiCamera>>,
593 windows: Query<&Window>,
594 mut state: ResMut<DevtoolsState>,
595 events: ReactEvents,
596 mut done: Local<bool>,
597) {
598 if *done {
599 return;
600 }
601 if stats.applied_count == 0 {
602 return;
603 }
604 *done = true;
605 if let Some(size) = ui_viewport_size(&cameras, &windows) {
606 events.send(&DevtoolsWindow {
607 width: size.x,
608 height: size.y,
609 });
610 }
611 let settings = persist.loaded.clone().unwrap_or_default();
612 events.send(&DevtoolsRestore::from(&settings));
613 if settings.open {
614 state.open = true;
615 events.send(&DevtoolsToggle { open: true });
616 }
617}
618
619fn send_window_size(
624 state: Res<DevtoolsState>,
625 stats: Res<OpApplyStats>,
626 cameras: Query<&Camera, With<IsDefaultUiCamera>>,
627 windows: Query<&Window>,
628 events: ReactEvents,
629 mut last: Local<Option<Vec2>>,
630) {
631 if stats.applied_count == 0 {
633 return;
634 }
635 if !state.open {
636 *last = None;
637 return;
638 }
639 let Some(size) = ui_viewport_size(&cameras, &windows) else {
640 return;
641 };
642 if *last != Some(size) {
643 *last = Some(size);
644 events.send(&DevtoolsWindow {
645 width: size.x,
646 height: size.y,
647 });
648 }
649}
650
651#[cfg_attr(target_arch = "wasm32", allow(unused_mut, unused_variables))]
654fn save_settings(mut persist: ResMut<DevtoolsPersistence>, config: Res<DevtoolsConfig>) {
655 #[cfg(not(target_arch = "wasm32"))]
656 {
657 let Some(path) = config.settings_path.clone() else {
658 return;
659 };
660 let debounce = persist.debounce;
661 if persist.dirty_at.is_some_and(|t| t.elapsed() >= debounce) {
662 write_pending(&mut persist, &path);
663 }
664 }
665}
666
667#[cfg_attr(target_arch = "wasm32", allow(unused_mut, unused_variables))]
670fn flush_settings_on_exit(
671 mut exits: MessageReader<AppExit>,
672 mut persist: ResMut<DevtoolsPersistence>,
673 config: Res<DevtoolsConfig>,
674) {
675 #[cfg(not(target_arch = "wasm32"))]
676 {
677 if exits.read().next().is_none() {
678 return;
679 }
680 let Some(path) = config.settings_path.clone() else {
681 return;
682 };
683 if persist.dirty_at.is_some() {
684 write_pending(&mut persist, &path);
685 }
686 }
687 #[cfg(target_arch = "wasm32")]
688 exits.clear();
689}
690
691fn exit_interactions(state: &mut DevtoolsState) {
693 state.pick = false;
694 state.tree_hover = None;
695 state.pick_hover = None;
696}
697
698fn toggle_on_key(
700 keys: Res<ButtonInput<KeyCode>>,
701 cfg: Res<DevtoolsConfig>,
702 mut state: ResMut<DevtoolsState>,
703 events: ReactEvents,
704) {
705 if !keys.just_pressed(cfg.toggle_key) {
706 return;
707 }
708 state.open = !state.open;
709 if !state.open {
710 exit_interactions(&mut state);
711 }
712 events.send(&DevtoolsToggle { open: state.open });
713}
714
715fn apply_dock_reservation(
731 state: Res<DevtoolsState>,
732 windows: Query<&Window>,
733 mut root: Query<&mut Node, With<crate::plugin::UiRoot>>,
734) {
735 let Ok(mut node) = root.single_mut() else {
736 return;
737 };
738 let reserved = if state.open { state.dock_side } else { None };
739 let width = match windows.single() {
740 Ok(window) => state.dock_width.min(window.width() - 100.0).max(0.0),
741 Err(_) => state.dock_width,
742 };
743 let (left, right) = match reserved {
744 Some(DockSide::Left) => (Val::Px(width), Val::ZERO),
745 Some(DockSide::Right) => (Val::ZERO, Val::Px(width)),
746 None => (Val::ZERO, Val::ZERO),
747 };
748 if node.margin.left != left || node.margin.right != right {
749 node.margin.left = left;
750 node.margin.right = right;
751 }
752}
753
754#[derive(Resource, Default)]
758struct DevtoolsTimers {
759 #[cfg(not(target_arch = "wasm32"))]
761 pre_layout: Option<std::time::Instant>,
762 last_command: Duration,
765 last_layout: Duration,
767 seen_applied: u64,
769}
770
771#[cfg_attr(target_arch = "wasm32", allow(unused_mut, unused_variables))]
772fn mark_pre_layout(mut timers: ResMut<DevtoolsTimers>) {
773 #[cfg(not(target_arch = "wasm32"))]
774 {
775 timers.pre_layout = Some(std::time::Instant::now());
776 }
777}
778
779fn mark_post_layout(stats: Res<OpApplyStats>, mut timers: ResMut<DevtoolsTimers>) {
780 if stats.applied_count == timers.seen_applied {
783 return;
784 }
785 timers.seen_applied = stats.applied_count;
786 #[cfg(not(target_arch = "wasm32"))]
787 if let (Some(end), Some(pre)) = (stats.last_apply_end, timers.pre_layout) {
788 let (command, layout) = split_legs(end, pre, std::time::Instant::now());
789 timers.last_command = command;
790 timers.last_layout = layout;
791 }
792}
793
794#[cfg(not(target_arch = "wasm32"))]
797fn split_legs(
798 apply_end: std::time::Instant,
799 pre_layout: std::time::Instant,
800 post_layout: std::time::Instant,
801) -> (Duration, Duration) {
802 (
803 pre_layout.saturating_duration_since(apply_end),
804 post_layout.saturating_duration_since(pre_layout),
805 )
806}
807
808#[allow(clippy::too_many_arguments)]
823fn drive_pick_mode(
824 mut state: ResMut<DevtoolsState>,
825 hover_map: Option<Res<HoverMap>>,
826 mouse: Res<ButtonInput<MouseButton>>,
827 capture: Option<ResMut<PointerCapture>>,
828 bridge: Option<Res<JsBridge>>,
829 rnodes: Query<&RNode>,
830 child_of: Query<&ChildOf>,
831 events: ReactEvents,
832) {
833 if !(state.open && state.pick) {
834 return;
835 }
836 if let Some(mut capture) = capture {
839 capture.over_ui = true;
840 }
841
842 let panel_entity = state
846 .panel_root
847 .and_then(|id| bridge.as_ref().and_then(|b| b.nodes.get(&id).copied()));
848
849 let hovered = hover_map
850 .as_deref()
851 .and_then(|hover_map| hover_map.get(&PointerId::Mouse))
852 .and_then(|hits| {
853 let (&top, _) = hits
863 .iter()
864 .filter(|&(&entity, _)| climb(entity, &child_of, |e| rnodes.contains(e)).is_some())
865 .min_by(|a, b| a.1.depth.total_cmp(&b.1.depth))?;
866 if let Some(panel) = panel_entity
868 && climb(top, &child_of, |e| e == panel).is_some()
869 {
870 return None;
871 }
872 let owner = climb(top, &child_of, |e| rnodes.contains(e))?;
873 rnodes.get(owner).ok().map(|r| r.0)
874 });
875 state.pick_hover = hovered;
876
877 if mouse.just_pressed(MouseButton::Left)
878 && let Some(id) = hovered
879 {
880 state.pick = false;
881 state.pick_hover = None;
882 state.selected = Some(id);
883 events.send(&DevtoolsPicked { id });
884 }
885}
886
887#[derive(Component)]
890struct DevtoolsHighlightOverlay;
891
892fn spawn_highlight_overlay(mut commands: Commands) {
898 commands.spawn((
899 DevtoolsHighlightOverlay,
900 Node {
901 position_type: PositionType::Absolute,
902 display: Display::None,
903 ..default()
904 },
905 BackgroundColor(Color::srgba(0.54, 0.71, 0.97, 0.30)),
907 Outline {
908 width: Val::Px(1.0),
909 color: Color::srgba(0.54, 0.71, 0.97, 0.9),
910 ..default()
911 },
912 GlobalZIndex(i32::MAX - 1),
913 Pickable::IGNORE,
914 ));
915}
916
917fn position_highlight(
923 state: Res<DevtoolsState>,
924 bridge: Option<Res<JsBridge>>,
927 targets: Query<(&ComputedNode, &UiGlobalTransform)>,
928 mut overlay: Query<&mut Node, With<DevtoolsHighlightOverlay>>,
929) {
930 let Ok(mut node) = overlay.single_mut() else {
931 return;
932 };
933 let Some(bridge) = bridge else {
934 return;
935 };
936 let target = state
937 .pick_hover
938 .or(state.tree_hover)
939 .or(state.selected.filter(|_| state.show_selection_overlay))
942 .filter(|_| state.open);
943 let rect = target
944 .and_then(|id| bridge.nodes.get(&id))
945 .and_then(|&e| targets.get(e).ok())
946 .map(|(computed, transform)| {
947 highlight_rect(
948 computed.size,
949 transform.translation,
950 computed.inverse_scale_factor,
951 )
952 });
953 match rect {
956 Some((pos, size)) => {
957 let (left, top) = (Val::Px(pos.x), Val::Px(pos.y));
958 let (width, height) = (Val::Px(size.x), Val::Px(size.y));
959 if node.display != Display::Flex
960 || node.left != left
961 || node.top != top
962 || node.width != width
963 || node.height != height
964 {
965 node.display = Display::Flex;
966 node.left = left;
967 node.top = top;
968 node.width = width;
969 node.height = height;
970 }
971 }
972 None => {
973 if node.display != Display::None {
974 node.display = Display::None;
975 }
976 }
977 }
978}
979
980fn highlight_rect(physical_size: Vec2, physical_center: Vec2, inverse_scale: f32) -> (Vec2, Vec2) {
984 let top_left = (physical_center - physical_size * 0.5) * inverse_scale;
985 (top_left, physical_size * inverse_scale)
986}
987
988fn emit_batch_stats(
997 state: Res<DevtoolsState>,
998 stats: Res<OpApplyStats>,
999 timers: Res<DevtoolsTimers>,
1000 events: ReactEvents,
1001 mut seen: Local<u64>,
1002) {
1003 if stats.app_applied_count == *seen {
1004 return;
1005 }
1006 *seen = stats.app_applied_count;
1007 if !state.open {
1008 return;
1009 }
1010 events.send(&DevtoolsBatchStats {
1011 applied_count: stats.applied_count,
1012 last_ops: stats.last_ops,
1013 frame_wait_ms: stats.last_frame_wait.as_secs_f64() * 1000.0,
1014 pre_apply_ms: stats.last_pre_apply.as_secs_f64() * 1000.0,
1015 translate_ms: stats.last_translate.as_secs_f64() * 1000.0,
1016 command_ms: timers.last_command.as_secs_f64() * 1000.0,
1017 layout_ms: timers.last_layout.as_secs_f64() * 1000.0,
1018 });
1019}
1020
1021fn emit_runtime_warnings(
1031 stats: Res<OpApplyStats>,
1032 events: ReactEvents,
1033 mut seen: Local<HashSet<u64>>,
1034 mut last_reset: Local<u64>,
1035) {
1036 if stats.reset_count != *last_reset {
1037 *last_reset = stats.reset_count;
1038 seen.clear();
1039 }
1040 if stats.applied_count == 0 {
1041 return;
1042 }
1043 for w in crate::diag::take_runtime_warnings() {
1044 let mut hasher = std::hash::DefaultHasher::new();
1045 std::hash::Hash::hash(&(w.node, w.kind, &w.value, &w.message), &mut hasher);
1046 if seen.insert(std::hash::Hasher::finish(&hasher)) {
1047 events.send(&DevtoolsWarning {
1048 id: w.node,
1049 kind: w.kind.to_string(),
1050 value: w.value,
1051 message: w.message,
1052 });
1053 }
1054 }
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059 use super::*;
1060 use crate::bridge::{OutboundResource, RRoot};
1061 use crate::protocol::Outbound;
1062 use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel};
1063
1064 fn test_app(config: DevtoolsConfig) -> (App, UnboundedReceiver<Outbound>) {
1067 let mut app = App::new();
1068 app.insert_non_send(crate::diag::test_lock());
1077 app.add_plugins(MinimalPlugins);
1078 app.init_resource::<ButtonInput<KeyCode>>();
1079 app.init_resource::<ButtonInput<MouseButton>>();
1080 app.init_resource::<OpApplyStats>();
1081 let (tx, rx) = unbounded_channel::<Outbound>();
1082 app.insert_resource(OutboundResource(tx));
1083 app.add_plugins(DevtoolsPlugin::new(config));
1084 (app, rx)
1085 }
1086
1087 fn drain_events(rx: &mut UnboundedReceiver<Outbound>) -> Vec<(String, serde_json::Value)> {
1088 let mut out = Vec::new();
1089 while let Ok(msg) = rx.try_recv() {
1090 if let Outbound::Event { name, value } = msg {
1091 out.push((name, value));
1092 }
1093 }
1094 out
1095 }
1096
1097 #[test]
1098 fn toggle_key_flips_state_and_notifies_js() {
1099 let (mut app, mut rx) = test_app(DevtoolsConfig {
1100 toggle_key: KeyCode::F9,
1101 ..default()
1102 });
1103 app.update();
1104 assert!(drain_events(&mut rx).is_empty(), "no toggle before the key");
1105
1106 app.world_mut()
1107 .resource_mut::<ButtonInput<KeyCode>>()
1108 .press(KeyCode::F9);
1109 app.update();
1110 let events = drain_events(&mut rx);
1111 assert_eq!(
1112 events
1113 .iter()
1114 .find(|(name, _)| name == "devtools.toggle")
1115 .map(|(_, v)| v["open"].as_bool()),
1116 Some(Some(true)),
1117 "the configured key must open the panel and notify JS"
1118 );
1119 assert!(app.world().resource::<DevtoolsState>().open);
1120
1121 {
1123 let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
1124 keys.clear_just_pressed(KeyCode::F9);
1125 keys.release(KeyCode::F9);
1126 }
1127 app.update();
1128 app.world_mut()
1129 .resource_mut::<ButtonInput<KeyCode>>()
1130 .press(KeyCode::F9);
1131 app.update();
1132 let events = drain_events(&mut rx);
1133 assert_eq!(
1134 events
1135 .iter()
1136 .find(|(name, _)| name == "devtools.toggle")
1137 .map(|(_, v)| v["open"].as_bool()),
1138 Some(Some(false)),
1139 "pressing again must close the panel"
1140 );
1141 assert!(!app.world().resource::<DevtoolsState>().open);
1142 }
1143
1144 #[test]
1147 fn restored_open_opens_panel_after_first_batch() {
1148 let tmp = TempSettings::new("open");
1149 let settings = DevtoolsSettings {
1150 open: true,
1151 ..Default::default()
1152 };
1153 std::fs::write(&tmp.0, serde_json::to_string(&settings).unwrap()).unwrap();
1154 let (mut app, mut rx) = test_app(DevtoolsConfig {
1155 settings_path: Some(tmp.0.clone()),
1156 ..default()
1157 });
1158 app.update();
1159 assert!(
1160 drain_events(&mut rx).is_empty(),
1161 "must not reopen before the React app mounted"
1162 );
1163
1164 app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
1165 app.update();
1166 let events = drain_events(&mut rx);
1167 assert!(
1168 events
1169 .iter()
1170 .any(|(name, v)| name == "devtools.restore" && v["open"] == true),
1171 "the restore blob must carry the persisted open state"
1172 );
1173 assert!(
1174 events
1175 .iter()
1176 .any(|(name, v)| name == "devtools.toggle" && v["open"] == true),
1177 "a persisted open must reopen the panel once a batch has been applied"
1178 );
1179 assert!(app.world().resource::<DevtoolsState>().open);
1180
1181 app.update();
1182 assert!(
1183 drain_events(&mut rx)
1184 .iter()
1185 .all(|(name, _)| name != "devtools.toggle" && name != "devtools.restore"),
1186 "the restore-open must fire exactly once"
1187 );
1188 }
1189
1190 #[cfg(debug_assertions)]
1204 #[test]
1205 fn runtime_warnings_emit_once_and_reset_on_reload() {
1206 let (mut app, mut rx) = test_app(DevtoolsConfig {
1209 settings_path: None,
1210 ..default()
1211 });
1212 let _ = crate::diag::take_runtime_warnings();
1213 app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
1215
1216 let report = || {
1217 let _scope = crate::diag::node_scope(31337);
1218 crate::diag::report("color", "redd", "unrecognized color \"redd\"");
1219 };
1220 let mine = |events: &[(String, serde_json::Value)]| {
1221 events
1222 .iter()
1223 .filter(|(name, v)| name == "devtools.warning" && v["id"] == 31337)
1224 .count()
1225 };
1226
1227 report();
1228 app.update();
1229 let events = drain_events(&mut rx);
1230 assert_eq!(
1231 mine(&events),
1232 1,
1233 "first report ships (panel closed is fine)"
1234 );
1235 assert!(
1236 events.iter().any(|(name, v)| name == "devtools.warning"
1237 && v["kind"] == "color"
1238 && v["value"] == "redd"
1239 && v["message"].as_str().is_some_and(|m| m.contains("redd"))),
1240 "the event carries kind/value/message"
1241 );
1242
1243 report();
1244 app.update();
1245 assert_eq!(
1246 mine(&drain_events(&mut rx)),
1247 0,
1248 "an identical re-report is deduped"
1249 );
1250
1251 app.world_mut().resource_mut::<OpApplyStats>().reset_count += 1;
1252 report();
1253 app.update();
1254 assert_eq!(
1255 mine(&drain_events(&mut rx)),
1256 1,
1257 "a hot reload clears the dedup set so warnings re-flag"
1258 );
1259 }
1260
1261 #[test]
1269 fn js_warning_kind_table_covers_known_kinds() {
1270 let warnings_ts = include_str!("../../../js/src/devtools/warnings.ts");
1271 for kind in [
1272 "display",
1273 "boxSizing",
1274 "positionType",
1275 "overflow",
1276 "alignItems",
1277 "justifyItems",
1278 "alignSelf",
1279 "justifySelf",
1280 "alignContent",
1281 "justifyContent",
1282 "flexDirection",
1283 "flexWrap",
1284 "gridAutoFlow",
1285 "focusPolicy",
1286 "textAlign",
1287 "lineBreak",
1288 "fontSize",
1289 "fontWeight",
1290 "rect",
1291 "gridTrack",
1292 "gridPlacement",
1293 "borderColor",
1294 "scrollbar",
1295 "animatedStyle",
1296 "color",
1297 "fontFamily",
1298 "cursor",
1299 "lineHeight",
1300 "letterSpacing",
1301 ] {
1302 assert!(
1303 warnings_ts.contains(&format!("{kind}:"))
1304 || warnings_ts.contains(&format!("\"{kind}\":")),
1305 "js/src/devtools/warnings.ts KIND_FIELDS is missing kind \"{kind}\""
1306 );
1307 }
1308 }
1309
1310 #[test]
1311 fn js_style_field_table_covers_every_style_field() {
1312 let fields_ts = include_str!("../../../js/src/devtools/fields.ts");
1313 macro_rules! check_fields {
1314 ($(($field:ident, $wire:literal, ($($group:tt)*), $overlay:ident)),* $(,)?) => {
1315 $(
1316 assert!(
1317 fields_ts.contains(concat!($wire, ":"))
1318 || fields_ts.contains(concat!("\"", $wire, "\":")),
1319 concat!(
1320 "js/src/devtools/fields.ts is missing style field \"",
1321 $wire,
1322 "\" — add it to STYLE_FIELDS with a category"
1323 )
1324 );
1325 )*
1326 };
1327 }
1328 crate::protocol::with_style_fields!(check_fields);
1329 }
1330
1331 fn pick_world(pressed: bool) -> (World, UnboundedReceiver<Outbound>) {
1336 let mut world = World::new();
1337 world.insert_resource(DevtoolsState {
1338 open: true,
1339 pick: true,
1340 ..Default::default()
1341 });
1342 let mut mouse = ButtonInput::<MouseButton>::default();
1343 if pressed {
1344 mouse.press(MouseButton::Left);
1345 }
1346 world.insert_resource(mouse);
1347 let (tx, rx) = unbounded_channel::<Outbound>();
1348 world.insert_resource(OutboundResource(tx));
1349 (world, rx)
1350 }
1351
1352 fn report_panel_root(world: &mut World, id: NodeId, panel_entity: Entity) {
1356 let (out_tx, out_rx) = unbounded_channel::<Outbound>();
1357 std::mem::forget(out_rx);
1358 let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<crate::protocol::Op>>();
1359 std::mem::forget(ops_tx);
1360 let root = world.spawn_empty().id();
1361 let mut bridge = JsBridge::new(ops_rx, out_tx, root);
1362 bridge.nodes.insert(id, panel_entity);
1363 world.insert_resource(bridge);
1364 world.resource_mut::<DevtoolsState>().panel_root = Some(id);
1365 }
1366
1367 fn set_mouse_hits(world: &mut World, hits: &[(Entity, f32)]) {
1369 use bevy::ecs::entity::EntityHashMap;
1370 use bevy::picking::backend::HitData;
1371 let mut hovered = EntityHashMap::default();
1372 for &(entity, depth) in hits {
1373 hovered.insert(entity, HitData::new(Entity::PLACEHOLDER, depth, None, None));
1374 }
1375 let mut hover_map = HoverMap::default();
1376 hover_map.insert(PointerId::Mouse, hovered);
1377 world.insert_resource(hover_map);
1378 }
1379
1380 #[test]
1385 fn pick_ignores_mesh_hits_and_takes_frontmost_ui_node() {
1386 use bevy::ecs::system::RunSystemOnce;
1387
1388 let (mut world, _rx) = pick_world(false);
1389 let mesh = world.spawn_empty().id(); let node = world.spawn(RNode(7)).id();
1391 let leaf = world.spawn(ChildOf(node)).id(); set_mouse_hits(&mut world, &[(mesh, 0.5), (leaf, 30.0)]);
1393
1394 world.run_system_once(drive_pick_mode).unwrap();
1395 assert_eq!(
1396 world.resource::<DevtoolsState>().pick_hover,
1397 Some(7),
1398 "the frontmost RNode-resolving hit must win; mesh hits are ignored"
1399 );
1400 }
1401
1402 #[test]
1405 fn pick_rejects_panel_hits() {
1406 use bevy::ecs::system::RunSystemOnce;
1407
1408 let (mut world, _rx) = pick_world(false);
1409 let panel_root = world.spawn((RRoot, RNode(100))).id();
1410 let panel_button = world.spawn((RNode(101), ChildOf(panel_root))).id();
1411 let app_node = world.spawn(RNode(7)).id();
1412 report_panel_root(&mut world, 100, panel_root);
1413 set_mouse_hits(&mut world, &[(panel_button, 1.0), (app_node, 5.0)]);
1414
1415 world.run_system_once(drive_pick_mode).unwrap();
1416 assert_eq!(
1417 world.resource::<DevtoolsState>().pick_hover,
1418 None,
1419 "a panel hit in front must block picking (no pick-through)"
1420 );
1421 }
1422
1423 #[test]
1427 fn pick_allows_nodes_under_app_roots() {
1428 use bevy::ecs::system::RunSystemOnce;
1429
1430 let (mut world, _rx) = pick_world(false);
1431 let panel_root = world.spawn((RRoot, RNode(100))).id();
1432 let app_root = world.spawn((RRoot, RNode(50))).id();
1433 let overlay_node = world.spawn((RNode(7), ChildOf(app_root))).id();
1434 report_panel_root(&mut world, 100, panel_root);
1435 set_mouse_hits(&mut world, &[(overlay_node, 5.0)]);
1436
1437 world.run_system_once(drive_pick_mode).unwrap();
1438 assert_eq!(
1439 world.resource::<DevtoolsState>().pick_hover,
1440 Some(7),
1441 "a node under an app <root> must be pickable"
1442 );
1443 }
1444
1445 #[test]
1448 fn pick_prefers_frontmost_app_hit_over_panel_behind() {
1449 use bevy::ecs::system::RunSystemOnce;
1450
1451 let (mut world, _rx) = pick_world(false);
1452 let panel_root = world.spawn((RRoot, RNode(100))).id();
1453 let panel_button = world.spawn((RNode(101), ChildOf(panel_root))).id();
1454 let app_node = world.spawn(RNode(7)).id();
1455 report_panel_root(&mut world, 100, panel_root);
1456 set_mouse_hits(&mut world, &[(panel_button, 5.0), (app_node, 1.0)]);
1457
1458 world.run_system_once(drive_pick_mode).unwrap();
1459 assert_eq!(
1460 world.resource::<DevtoolsState>().pick_hover,
1461 Some(7),
1462 "an app node in front of the panel must win"
1463 );
1464 }
1465
1466 #[test]
1469 fn pick_click_selects_and_notifies_js() {
1470 use bevy::ecs::system::RunSystemOnce;
1471
1472 let (mut world, mut rx) = pick_world(true);
1473 let app_node = world.spawn(RNode(7)).id();
1474 set_mouse_hits(&mut world, &[(app_node, 5.0)]);
1475
1476 world.run_system_once(drive_pick_mode).unwrap();
1477 let state = world.resource::<DevtoolsState>();
1478 assert_eq!(state.selected, Some(7));
1479 assert!(!state.pick, "a successful pick exits pick mode");
1480 match rx.try_recv().expect("a devtools.picked event") {
1481 Outbound::Event { name, value } => {
1482 assert_eq!(name, "devtools.picked");
1483 assert_eq!(value["id"], 7);
1484 }
1485 other => panic!("expected Outbound::Event, got {other:?}"),
1486 }
1487 }
1488
1489 #[test]
1493 fn dock_reservation_insets_uiroot_margin() {
1494 let (mut app, _rx) = test_app(DevtoolsConfig::default());
1495 let root = app
1496 .world_mut()
1497 .spawn((Node::default(), crate::plugin::UiRoot))
1498 .id();
1499 let margin = |app: &mut App| {
1500 let node = app.world().entity(root).get::<Node>().unwrap();
1501 (node.margin.left, node.margin.right)
1502 };
1503
1504 {
1505 let mut state = app.world_mut().resource_mut::<DevtoolsState>();
1506 state.open = true;
1507 state.dock_side = Some(DockSide::Right);
1508 state.dock_width = 300.0;
1509 }
1510 app.update();
1511 assert_eq!(margin(&mut app), (Val::ZERO, Val::Px(300.0)));
1512
1513 app.world_mut().resource_mut::<DevtoolsState>().dock_side = Some(DockSide::Left);
1514 app.update();
1515 assert_eq!(margin(&mut app), (Val::Px(300.0), Val::ZERO));
1516
1517 app.world_mut().resource_mut::<DevtoolsState>().open = false;
1519 app.update();
1520 assert_eq!(margin(&mut app), (Val::ZERO, Val::ZERO));
1521 }
1522
1523 #[test]
1527 fn dock_message_parses_side_and_clamps_width() {
1528 let (mut app, _rx) = test_app(DevtoolsConfig::default());
1529 let dock = |app: &mut App, side: Option<&str>, width: f32| {
1530 app.world_mut().trigger(DevtoolsDockMessage {
1531 side: side.map(String::from),
1532 width,
1533 });
1534 let state = app.world().resource::<DevtoolsState>();
1535 (state.dock_side, state.dock_width)
1536 };
1537
1538 assert_eq!(
1539 dock(&mut app, Some("left"), 320.0),
1540 (Some(DockSide::Left), 320.0)
1541 );
1542 assert_eq!(
1543 dock(&mut app, Some("right"), 280.0),
1544 (Some(DockSide::Right), 280.0)
1545 );
1546 assert_eq!(dock(&mut app, Some("bogus"), -5.0), (None, 0.0));
1547 assert_eq!(dock(&mut app, None, 380.0), (None, 380.0));
1548 }
1549
1550 #[test]
1554 fn batch_stats_skip_devtools_only_applies() {
1555 let (mut app, mut rx) = test_app(DevtoolsConfig {
1556 settings_path: None,
1557 ..default()
1558 });
1559 app.world_mut().resource_mut::<DevtoolsState>().open = true;
1560 let stats_events = |rx: &mut UnboundedReceiver<Outbound>| {
1561 drain_events(rx)
1562 .into_iter()
1563 .filter(|(name, _)| name == "devtools.batchStats")
1564 .count()
1565 };
1566
1567 {
1569 let mut stats = app.world_mut().resource_mut::<OpApplyStats>();
1570 stats.applied_count = 1;
1571 stats.app_applied_count = 0;
1572 }
1573 app.update();
1574 assert_eq!(
1575 stats_events(&mut rx),
1576 0,
1577 "the panel's own commits must not produce batch stats"
1578 );
1579
1580 {
1582 let mut stats = app.world_mut().resource_mut::<OpApplyStats>();
1583 stats.applied_count = 2;
1584 stats.app_applied_count = 1;
1585 }
1586 app.update();
1587 let stats: Vec<_> = drain_events(&mut rx)
1588 .into_iter()
1589 .filter(|(name, _)| name == "devtools.batchStats")
1590 .collect();
1591 assert_eq!(stats.len(), 1, "an app apply reports once");
1592 assert!(
1593 stats[0].1.get("frame_wait_ms").is_some(),
1594 "batch stats carry the frame-wait leg"
1595 );
1596 }
1597
1598 struct TempSettings(std::path::PathBuf);
1600 impl TempSettings {
1601 fn new(name: &str) -> Self {
1602 Self(std::env::temp_dir().join(format!(
1603 "bevy-react-devtools-{name}-{}.json",
1604 std::process::id()
1605 )))
1606 }
1607 }
1608 impl Drop for TempSettings {
1609 fn drop(&mut self) {
1610 let _ = std::fs::remove_file(&self.0);
1611 }
1612 }
1613
1614 fn sample_settings() -> DevtoolsSettings {
1615 DevtoolsSettings {
1616 open: false,
1617 mode: "float".into(),
1618 width_frac: 0.4,
1619 float_x_frac: 0.05,
1620 float_y_frac: 0.1,
1621 float_w_frac: 0.5,
1622 float_h_frac: 0.6,
1623 reserve: true,
1624 overlay: false,
1625 split: 200.0,
1626 }
1627 }
1628
1629 fn drain_restores(rx: &mut UnboundedReceiver<Outbound>) -> Vec<serde_json::Value> {
1630 drain_events(rx)
1631 .into_iter()
1632 .filter(|(name, _)| name == "devtools.restore")
1633 .map(|(_, v)| v)
1634 .collect()
1635 }
1636
1637 #[test]
1640 fn settings_file_seeds_overlay_and_restores_once() {
1641 let tmp = TempSettings::new("restore");
1642 std::fs::write(&tmp.0, serde_json::to_string(&sample_settings()).unwrap()).unwrap();
1643 let (mut app, mut rx) = test_app(DevtoolsConfig {
1644 settings_path: Some(tmp.0.clone()),
1645 ..default()
1646 });
1647
1648 assert!(
1649 !app.world()
1650 .resource::<DevtoolsState>()
1651 .show_selection_overlay,
1652 "the loaded overlay=false must seed the state at build"
1653 );
1654
1655 app.update();
1656 assert!(
1657 drain_restores(&mut rx).is_empty(),
1658 "no restore before the React app mounted"
1659 );
1660
1661 app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
1662 app.update();
1663 let restores = drain_restores(&mut rx);
1664 assert_eq!(restores.len(), 1, "exactly one restore after mount");
1665 assert_eq!(restores[0]["mode"], "float");
1666 assert_eq!(restores[0]["split"], 200.0);
1667 assert_eq!(restores[0]["overlay"], false);
1668
1669 app.update();
1670 assert!(drain_restores(&mut rx).is_empty(), "restore is one-shot");
1671 }
1672
1673 #[test]
1676 fn settings_message_writes_file_debounced() {
1677 let tmp = TempSettings::new("save");
1678 let (mut app, _rx) = test_app(DevtoolsConfig {
1679 settings_path: Some(tmp.0.clone()),
1680 ..default()
1681 });
1682 app.world_mut()
1683 .resource_mut::<DevtoolsPersistence>()
1684 .debounce = Duration::ZERO;
1685
1686 app.world_mut().trigger(sample_settings());
1687 app.update();
1688
1689 let written: DevtoolsSettings =
1690 serde_json::from_str(&std::fs::read_to_string(&tmp.0).unwrap()).unwrap();
1691 assert!(written == sample_settings(), "full round-trip");
1692
1693 let mtime = |p: &std::path::Path| std::fs::metadata(p).unwrap().modified().unwrap();
1695 let before = mtime(&tmp.0);
1696 app.world_mut().trigger(sample_settings());
1697 app.update();
1698 assert_eq!(mtime(&tmp.0), before, "identical settings skip the write");
1699 }
1700
1701 #[test]
1704 fn settings_flush_on_app_exit() {
1705 let tmp = TempSettings::new("flush");
1706 let (mut app, _rx) = test_app(DevtoolsConfig {
1707 settings_path: Some(tmp.0.clone()),
1708 ..default()
1709 });
1710 app.world_mut().trigger(sample_settings());
1712 app.update();
1713 assert!(!tmp.0.exists(), "still inside the debounce window");
1714
1715 app.world_mut().write_message(AppExit::Success);
1716 app.update();
1717 assert!(tmp.0.exists(), "AppExit must flush the pending settings");
1718 }
1719
1720 #[test]
1724 fn corrupt_or_disabled_settings_are_ignored() {
1725 let tmp = TempSettings::new("corrupt");
1726 std::fs::write(&tmp.0, "{ not json").unwrap();
1727 let (mut app, mut rx) = test_app(DevtoolsConfig {
1728 settings_path: Some(tmp.0.clone()),
1729 ..default()
1730 });
1731 assert!(
1732 app.world()
1733 .resource::<DevtoolsState>()
1734 .show_selection_overlay
1735 );
1736 app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
1737 app.update();
1738 let restores = drain_restores(&mut rx);
1739 assert_eq!(restores.len(), 1, "corrupt file → one restore, defaults");
1740 assert_eq!(restores[0]["mode"], "right");
1741 assert_eq!(restores[0]["open"], false);
1742 assert!(!app.world().resource::<DevtoolsState>().open);
1743 drop(app);
1746
1747 let (mut app, _rx) = test_app(DevtoolsConfig {
1748 settings_path: None,
1749 ..default()
1750 });
1751 app.world_mut()
1752 .resource_mut::<DevtoolsPersistence>()
1753 .debounce = Duration::ZERO;
1754 app.world_mut().trigger(sample_settings());
1755 app.update(); }
1757
1758 #[test]
1762 fn restore_defaults_sent_without_settings_file() {
1763 let (mut app, mut rx) = test_app(DevtoolsConfig {
1764 settings_path: None,
1765 ..default()
1766 });
1767 app.update();
1768 assert!(drain_restores(&mut rx).is_empty(), "not before mount");
1769
1770 app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
1771 app.update();
1772 let restores = drain_restores(&mut rx);
1773 assert_eq!(restores.len(), 1, "defaults restore exactly once");
1774 assert_eq!(restores[0]["open"], false);
1775 assert_eq!(restores[0]["mode"], "right");
1776
1777 app.update();
1778 assert!(drain_restores(&mut rx).is_empty(), "one-shot");
1779 }
1780
1781 #[test]
1785 fn legacy_pixel_settings_file_migrates() {
1786 let tmp = TempSettings::new("legacy");
1787 let legacy = serde_json::json!({
1788 "mode": "float",
1789 "width": 420.0,
1790 "float_x": 10.0,
1791 "float_y": 20.0,
1792 "float_w": 500.0,
1793 "float_h": 600.0,
1794 "reserve": true,
1795 "overlay": false,
1796 "split": 200.0,
1797 });
1798 std::fs::write(&tmp.0, legacy.to_string()).unwrap();
1799 let (mut app, mut rx) = test_app(DevtoolsConfig {
1800 settings_path: Some(tmp.0.clone()),
1801 ..default()
1802 });
1803 app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
1804 app.update();
1805 let restores = drain_restores(&mut rx);
1806 assert_eq!(restores.len(), 1, "a legacy file must still restore");
1807 assert_eq!(restores[0]["mode"], "float");
1808 assert_eq!(restores[0]["overlay"], false);
1809 assert_eq!(restores[0]["split"], 200.0);
1810 assert_eq!(restores[0]["open"], false, "no persisted open → closed");
1811 let frac = restores[0]["width_frac"].as_f64().expect("a number");
1812 assert!(
1813 (frac - f64::from(DevtoolsSettings::default().width_frac)).abs() < 1e-6,
1814 "stale pixel width is ignored; the fraction takes its default"
1815 );
1816 }
1817
1818 #[test]
1822 fn window_size_sent_on_open_and_resize() {
1823 use bevy::window::WindowResolution;
1824
1825 let (mut app, mut rx) = test_app(DevtoolsConfig {
1826 settings_path: None,
1827 ..default()
1828 });
1829 let window = app
1830 .world_mut()
1831 .spawn(Window {
1832 resolution: WindowResolution::new(800, 600),
1833 ..Default::default()
1834 })
1835 .id();
1836 let sizes = |rx: &mut UnboundedReceiver<Outbound>| {
1837 drain_events(rx)
1838 .into_iter()
1839 .filter(|(name, _)| name == "devtools.window")
1840 .map(|(_, v)| (v["width"].as_f64().unwrap(), v["height"].as_f64().unwrap()))
1841 .collect::<Vec<_>>()
1842 };
1843
1844 app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
1846 app.update();
1847 let restore_frame = sizes(&mut rx);
1848 assert_eq!(
1849 restore_frame,
1850 vec![(800.0, 600.0)],
1851 "the restore one-shot sends the size once, ahead of the blob"
1852 );
1853
1854 app.world_mut().resource_mut::<DevtoolsState>().open = true;
1856 app.update();
1857 assert_eq!(sizes(&mut rx), vec![(800.0, 600.0)], "sent on open");
1858 app.update();
1859 assert!(sizes(&mut rx).is_empty(), "idle frames are silent");
1860
1861 app.world_mut()
1863 .entity_mut(window)
1864 .get_mut::<Window>()
1865 .unwrap()
1866 .resolution = WindowResolution::new(1024, 768);
1867 app.update();
1868 assert_eq!(sizes(&mut rx), vec![(1024.0, 768.0)], "sent on resize");
1869
1870 app.world_mut().resource_mut::<DevtoolsState>().open = false;
1872 app.update();
1873 app.world_mut()
1874 .entity_mut(window)
1875 .get_mut::<Window>()
1876 .unwrap()
1877 .resolution = WindowResolution::new(640, 480);
1878 app.update();
1879 assert!(sizes(&mut rx).is_empty(), "closed: no size traffic");
1880 app.world_mut().resource_mut::<DevtoolsState>().open = true;
1881 app.update();
1882 assert_eq!(
1883 sizes(&mut rx),
1884 vec![(640.0, 480.0)],
1885 "reopen must catch up on a resize that happened while closed"
1886 );
1887 }
1888
1889 #[test]
1890 fn highlight_rect_converts_physical_center_to_logical_top_left() {
1891 let (pos, size) = highlight_rect(Vec2::new(200.0, 100.0), Vec2::new(300.0, 150.0), 0.5);
1893 assert_eq!(pos, Vec2::new(100.0, 50.0));
1894 assert_eq!(size, Vec2::new(100.0, 50.0));
1895 }
1896
1897 #[test]
1898 fn split_legs_computes_command_and_layout() {
1899 let t0 = std::time::Instant::now();
1900 let t1 = t0 + Duration::from_millis(5);
1901 let t2 = t1 + Duration::from_millis(7);
1902 assert_eq!(
1903 split_legs(t0, t1, t2),
1904 (Duration::from_millis(5), Duration::from_millis(7))
1905 );
1906 assert_eq!(split_legs(t1, t0, t2), (Duration::ZERO, t2 - t0));
1908 }
1909}