use crate::{clip_check_recursive, prelude::*, ui_transform::UiGlobalTransform, UiStack};
use bevy_app::prelude::*;
use bevy_camera::{visibility::InheritedVisibility, Camera, RenderTarget};
use bevy_ecs::{prelude::*, query::QueryData};
use bevy_math::Vec2;
use bevy_platform::collections::HashMap;
use bevy_reflect::{std_traits::ReflectDefault, Reflect};
use bevy_text::{ComputedTextBlock, TextLayoutInfo};
use bevy_window::PrimaryWindow;
use bevy_picking::backend::prelude::*;
#[derive(Debug, Clone, Default, Component, Reflect)]
#[reflect(Debug, Default, Component)]
pub struct UiPickingCamera;
#[derive(Resource, Reflect)]
#[reflect(Resource, Default)]
pub struct UiPickingSettings {
pub require_markers: bool,
}
#[expect(
clippy::allow_attributes,
reason = "clippy::derivable_impls is not always linted"
)]
#[allow(
clippy::derivable_impls,
reason = "Known false positive with clippy: <https://github.com/rust-lang/rust-clippy/issues/13160>"
)]
impl Default for UiPickingSettings {
fn default() -> Self {
Self {
require_markers: false,
}
}
}
#[derive(Clone)]
pub struct UiPickingPlugin;
impl Plugin for UiPickingPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<UiPickingSettings>()
.add_systems(PreUpdate, ui_picking.in_set(PickingSystems::Backend));
}
}
#[derive(QueryData)]
#[query_data(mutable)]
pub struct NodeQuery {
entity: Entity,
node: &'static ComputedNode,
transform: &'static UiGlobalTransform,
pickable: Option<&'static Pickable>,
inherited_visibility: Option<&'static InheritedVisibility>,
target_camera: &'static ComputedUiTargetCamera,
text_node: Option<(&'static TextLayoutInfo, &'static ComputedTextBlock)>,
}
pub fn ui_picking(
pointers: Query<(&PointerId, &PointerLocation)>,
camera_query: Query<(Entity, &Camera, &RenderTarget, Has<UiPickingCamera>)>,
primary_window: Query<Entity, With<PrimaryWindow>>,
settings: Res<UiPickingSettings>,
ui_stack: Res<UiStack>,
node_query: Query<NodeQuery>,
mut output: MessageWriter<PointerHits>,
clipping_query: Query<(&ComputedNode, &UiGlobalTransform, &Node)>,
child_of_query: Query<&ChildOf, Without<OverrideClip>>,
pickable_query: Query<&Pickable>,
) {
let mut pointer_pos_by_camera = HashMap::<Entity, HashMap<PointerId, Vec2>>::default();
for (pointer_id, pointer_location) in
pointers.iter().filter_map(|(pointer, pointer_location)| {
Some(*pointer).zip(pointer_location.location().cloned())
})
{
for (entity, camera, _, _) in
camera_query
.iter()
.filter(|(_, _, render_target, cam_can_pick)| {
(!settings.require_markers || *cam_can_pick)
&& render_target
.normalize(primary_window.single().ok())
.is_some_and(|target| target == pointer_location.target)
})
{
let mut pointer_pos =
pointer_location.position * camera.target_scaling_factor().unwrap_or(1.);
if let Some(viewport) = camera.physical_viewport_rect() {
if !viewport.as_rect().contains(pointer_pos) {
continue;
}
pointer_pos -= viewport.min.as_vec2();
}
pointer_pos_by_camera
.entry(entity)
.or_default()
.insert(pointer_id, pointer_pos);
}
}
let mut hit_nodes =
HashMap::<(Entity, PointerId), Vec<(Entity, Entity, Option<Pickable>, Vec2)>>::default();
for uinodes in ui_stack
.partition
.iter()
.rev()
.map(|range| &ui_stack.uinodes[range.clone()])
{
let Ok(uinode) = node_query.get(uinodes[0]) else {
continue;
};
let Some(camera_entity) = uinode.target_camera.get() else {
continue;
};
let Some(pointers_on_this_cam) = pointer_pos_by_camera.get(&camera_entity) else {
continue;
};
for node_entity in uinodes.iter().rev().cloned() {
let Ok(node) = node_query.get(node_entity) else {
continue;
};
if node.node.size() == Vec2::ZERO {
continue;
}
if node
.inherited_visibility
.map(|inherited_visibility| inherited_visibility.get())
!= Some(true)
{
continue;
}
if node.text_node.is_none() && settings.require_markers && node.pickable.is_none() {
continue;
}
for (pointer_id, cursor_position) in pointers_on_this_cam.iter() {
if let Some((text_layout_info, text_block)) = node.text_node {
if let Some(text_entity) = pick_ui_text_section(
node.node,
node.transform,
*cursor_position,
text_layout_info,
text_block,
) && clip_check_recursive(
*cursor_position,
node_entity,
&clipping_query,
&child_of_query,
) {
if settings.require_markers && !pickable_query.contains(text_entity) {
continue;
}
hit_nodes
.entry((camera_entity, *pointer_id))
.or_default()
.push((
text_entity,
camera_entity,
node.pickable.cloned(),
node.transform.inverse().transform_point2(*cursor_position)
/ node.node.size(),
));
}
} else if node.node.contains_point(*node.transform, *cursor_position)
&& clip_check_recursive(
*cursor_position,
node_entity,
&clipping_query,
&child_of_query,
)
{
hit_nodes
.entry((camera_entity, *pointer_id))
.or_default()
.push((
node_entity,
camera_entity,
node.pickable.cloned(),
node.transform.inverse().transform_point2(*cursor_position)
/ node.node.size(),
));
}
}
}
}
for ((camera, pointer), hovered) in hit_nodes.iter() {
let mut picks = Vec::new();
let mut depth = 0.0;
for (hovered_node, camera_entity, pickable, position) in hovered {
picks.push((
*hovered_node,
HitData::new(*camera_entity, depth, Some(position.extend(0.0)), None),
));
if let Some(pickable) = pickable {
if pickable.should_block_lower {
break;
}
} else {
break;
}
depth += 0.00001;
}
let order = camera_query
.get(*camera)
.map(|(_, cam, _, _)| cam.order)
.unwrap_or_default() as f32
+ 0.5;
output.write(PointerHits::new(*pointer, picks, order));
}
}
fn pick_ui_text_section(
uinode: &ComputedNode,
global_transform: &UiGlobalTransform,
point: Vec2,
text_layout_info: &TextLayoutInfo,
text_block: &ComputedTextBlock,
) -> Option<Entity> {
let local_point = global_transform
.try_inverse()
.map(|transform| transform.transform_point2(point) + 0.5 * uinode.size())?;
for run in text_layout_info.run_geometry.iter() {
if run.bounds.contains(local_point) {
return text_block.entities().get(run.span_index).map(|e| e.entity);
}
}
None
}