use bevy::picking::hover::HoverMap;
use bevy::picking::pointer::PointerId;
use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform};
use crate::bridge::{JsBridge, RNode};
use crate::event::ReactEvents;
use crate::plugin::PointerCapture;
use crate::protocol::NodeId;
use crate::reconcile::climb;
use crate::{react_event, react_message};
use super::DevtoolsState;
#[react_event(name = "devtools.picked")]
struct DevtoolsPicked {
id: NodeId,
}
#[react_message(name = "devtools.pick")]
pub(super) struct DevtoolsPickMessage {
on: bool,
}
#[react_message(name = "devtools.select")]
pub(super) struct DevtoolsSelectMessage {
id: Option<NodeId>,
}
#[react_message(name = "devtools.highlight")]
pub(super) struct DevtoolsHighlightMessage {
id: Option<NodeId>,
}
pub(super) fn on_pick_message(msg: On<DevtoolsPickMessage>, mut state: ResMut<DevtoolsState>) {
state.pick = msg.event().on;
if !state.pick {
state.pick_hover = None;
}
}
pub(super) fn on_select_message(msg: On<DevtoolsSelectMessage>, mut state: ResMut<DevtoolsState>) {
state.selected = msg.event().id;
}
pub(super) fn on_highlight_message(
msg: On<DevtoolsHighlightMessage>,
mut state: ResMut<DevtoolsState>,
) {
state.tree_hover = msg.event().id;
}
#[allow(clippy::too_many_arguments)]
pub(super) fn drive_pick_mode(
mut state: ResMut<DevtoolsState>,
hover_map: Option<Res<HoverMap>>,
mouse: Res<ButtonInput<MouseButton>>,
capture: Option<ResMut<PointerCapture>>,
bridge: Option<Res<JsBridge>>,
rnodes: Query<&RNode>,
child_of: Query<&ChildOf>,
events: ReactEvents,
) {
if !(state.open && state.pick) {
return;
}
if let Some(mut capture) = capture {
capture.over_ui = true;
capture.wheel_captured = true;
}
let panel_entity = state
.panel_root
.and_then(|id| bridge.as_ref().and_then(|b| b.nodes.get(&id).copied()));
let hovered = hover_map
.as_deref()
.and_then(|hover_map| hover_map.get(&PointerId::Mouse))
.and_then(|hits| {
let (&top, _) = hits
.iter()
.filter(|&(&entity, _)| climb(entity, &child_of, |e| rnodes.contains(e)).is_some())
.min_by(|a, b| a.1.depth.total_cmp(&b.1.depth))?;
if let Some(panel) = panel_entity
&& climb(top, &child_of, |e| e == panel).is_some()
{
return None;
}
let owner = climb(top, &child_of, |e| rnodes.contains(e))?;
rnodes.get(owner).ok().map(|r| r.0)
});
state.pick_hover = hovered;
if mouse.just_pressed(MouseButton::Left)
&& let Some(id) = hovered
{
state.pick = false;
state.pick_hover = None;
state.selected = Some(id);
events.send(&DevtoolsPicked { id });
}
}
#[derive(Component)]
pub(super) struct DevtoolsHighlightOverlay;
pub(super) fn spawn_highlight_overlay(mut commands: Commands) {
commands.spawn((
DevtoolsHighlightOverlay,
Node {
position_type: PositionType::Absolute,
display: Display::None,
..default()
},
BackgroundColor(Color::srgba(0.54, 0.71, 0.97, 0.30)),
Outline {
width: Val::Px(1.0),
color: Color::srgba(0.54, 0.71, 0.97, 0.9),
..default()
},
GlobalZIndex(i32::MAX - 1),
Pickable::IGNORE,
));
}
pub(super) fn position_highlight(
state: Res<DevtoolsState>,
bridge: Option<Res<JsBridge>>,
targets: Query<(&ComputedNode, &UiGlobalTransform)>,
mut overlay: Query<&mut Node, With<DevtoolsHighlightOverlay>>,
) {
let Ok(mut node) = overlay.single_mut() else {
return;
};
let Some(bridge) = bridge else {
return;
};
let target = state
.pick_hover
.or(state.tree_hover)
.or(state.selected.filter(|_| state.show_selection_overlay))
.filter(|_| state.open);
let rect = target
.and_then(|id| bridge.nodes.get(&id))
.and_then(|&e| targets.get(e).ok())
.map(|(computed, transform)| {
highlight_rect(
computed.size,
transform.translation,
computed.inverse_scale_factor,
)
});
match rect {
Some((pos, size)) => {
let (left, top) = (Val::Px(pos.x), Val::Px(pos.y));
let (width, height) = (Val::Px(size.x), Val::Px(size.y));
if node.display != Display::Flex
|| node.left != left
|| node.top != top
|| node.width != width
|| node.height != height
{
node.display = Display::Flex;
node.left = left;
node.top = top;
node.width = width;
node.height = height;
}
}
None => {
if node.display != Display::None {
node.display = Display::None;
}
}
}
}
fn highlight_rect(physical_size: Vec2, physical_center: Vec2, inverse_scale: f32) -> (Vec2, Vec2) {
let top_left = (physical_center - physical_size * 0.5) * inverse_scale;
(top_left, physical_size * inverse_scale)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bridge::{OutboundResource, RRoot};
use crate::devtools::DevtoolsConfig;
use crate::devtools::test_util::test_app;
use crate::protocol::outbound::Outbound;
use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel};
#[test]
fn pick_mode_claims_hover_and_wheel_channels() {
let (mut app, _rx) = test_app(DevtoolsConfig::default());
app.init_resource::<crate::PointerCapture>();
{
let mut state = app.world_mut().resource_mut::<DevtoolsState>();
state.open = true;
state.pick = true;
}
app.update();
let capture = app.world().resource::<crate::PointerCapture>();
assert!(capture.over_ui, "pick mode must claim the hover channel");
assert!(
capture.wheel_captured,
"pick mode must claim the wheel channel too"
);
}
fn pick_world(pressed: bool) -> (World, UnboundedReceiver<Outbound>) {
let mut world = World::new();
world.insert_resource(DevtoolsState {
open: true,
pick: true,
..Default::default()
});
let mut mouse = ButtonInput::<MouseButton>::default();
if pressed {
mouse.press(MouseButton::Left);
}
world.insert_resource(mouse);
let (tx, rx) = unbounded_channel::<Outbound>();
world.insert_resource(OutboundResource(tx));
(world, rx)
}
fn report_panel_root(world: &mut World, id: NodeId, panel_entity: Entity) {
let (out_tx, out_rx) = unbounded_channel::<Outbound>();
std::mem::forget(out_rx);
let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<crate::protocol::op::Op>>();
std::mem::forget(ops_tx);
let root = world.spawn_empty().id();
let mut bridge = JsBridge::new(ops_rx, out_tx, root);
bridge.nodes.insert(id, panel_entity);
world.insert_resource(bridge);
world.resource_mut::<DevtoolsState>().panel_root = Some(id);
}
fn set_mouse_hits(world: &mut World, hits: &[(Entity, f32)]) {
use bevy::ecs::entity::EntityHashMap;
use bevy::picking::backend::HitData;
let mut hovered = EntityHashMap::default();
for &(entity, depth) in hits {
hovered.insert(entity, HitData::new(Entity::PLACEHOLDER, depth, None, None));
}
let mut hover_map = HoverMap::default();
hover_map.insert(PointerId::Mouse, hovered);
world.insert_resource(hover_map);
}
#[test]
fn pick_ignores_mesh_hits_and_takes_frontmost_ui_node() {
use bevy::ecs::system::RunSystemOnce;
let (mut world, _rx) = pick_world(false);
let mesh = world.spawn_empty().id(); let node = world.spawn(RNode(7)).id();
let leaf = world.spawn(ChildOf(node)).id(); set_mouse_hits(&mut world, &[(mesh, 0.5), (leaf, 30.0)]);
world.run_system_once(drive_pick_mode).unwrap();
assert_eq!(
world.resource::<DevtoolsState>().pick_hover,
Some(7),
"the frontmost RNode-resolving hit must win; mesh hits are ignored"
);
}
#[test]
fn pick_rejects_panel_hits() {
use bevy::ecs::system::RunSystemOnce;
let (mut world, _rx) = pick_world(false);
let panel_root = world.spawn((RRoot, RNode(100))).id();
let panel_button = world.spawn((RNode(101), ChildOf(panel_root))).id();
let app_node = world.spawn(RNode(7)).id();
report_panel_root(&mut world, 100, panel_root);
set_mouse_hits(&mut world, &[(panel_button, 1.0), (app_node, 5.0)]);
world.run_system_once(drive_pick_mode).unwrap();
assert_eq!(
world.resource::<DevtoolsState>().pick_hover,
None,
"a panel hit in front must block picking (no pick-through)"
);
}
#[test]
fn pick_allows_nodes_under_app_roots() {
use bevy::ecs::system::RunSystemOnce;
let (mut world, _rx) = pick_world(false);
let panel_root = world.spawn((RRoot, RNode(100))).id();
let app_root = world.spawn((RRoot, RNode(50))).id();
let overlay_node = world.spawn((RNode(7), ChildOf(app_root))).id();
report_panel_root(&mut world, 100, panel_root);
set_mouse_hits(&mut world, &[(overlay_node, 5.0)]);
world.run_system_once(drive_pick_mode).unwrap();
assert_eq!(
world.resource::<DevtoolsState>().pick_hover,
Some(7),
"a node under an app <root> must be pickable"
);
}
#[test]
fn pick_prefers_frontmost_app_hit_over_panel_behind() {
use bevy::ecs::system::RunSystemOnce;
let (mut world, _rx) = pick_world(false);
let panel_root = world.spawn((RRoot, RNode(100))).id();
let panel_button = world.spawn((RNode(101), ChildOf(panel_root))).id();
let app_node = world.spawn(RNode(7)).id();
report_panel_root(&mut world, 100, panel_root);
set_mouse_hits(&mut world, &[(panel_button, 5.0), (app_node, 1.0)]);
world.run_system_once(drive_pick_mode).unwrap();
assert_eq!(
world.resource::<DevtoolsState>().pick_hover,
Some(7),
"an app node in front of the panel must win"
);
}
#[test]
fn pick_click_selects_and_notifies_js() {
use bevy::ecs::system::RunSystemOnce;
let (mut world, mut rx) = pick_world(true);
let app_node = world.spawn(RNode(7)).id();
set_mouse_hits(&mut world, &[(app_node, 5.0)]);
world.run_system_once(drive_pick_mode).unwrap();
let state = world.resource::<DevtoolsState>();
assert_eq!(state.selected, Some(7));
assert!(!state.pick, "a successful pick exits pick mode");
match rx.try_recv().expect("a devtools.picked event") {
Outbound::Event { name, value } => {
assert_eq!(name, "devtools.picked");
assert_eq!(value["id"], 7);
}
other => panic!("expected Outbound::Event, got {other:?}"),
}
}
#[test]
fn highlight_rect_converts_physical_center_to_logical_top_left() {
let (pos, size) = highlight_rect(Vec2::new(200.0, 100.0), Vec2::new(300.0, 150.0), 0.5);
assert_eq!(pos, Vec2::new(100.0, 50.0));
assert_eq!(size, Vec2::new(100.0, 50.0));
}
}