diff --git a/crates/bevy_ui/src/picking_backend.rs b/crates/bevy_ui/src/picking_backend.rs index d3ef8abd80d0f..46d29e7f1a4b2 100644 --- a/crates/bevy_ui/src/picking_backend.rs +++ b/crates/bevy_ui/src/picking_backend.rs @@ -30,6 +30,7 @@ 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::*; @@ -92,6 +93,7 @@ pub struct NodeQuery { pickable: Option<&'static Pickable>, inherited_visibility: Option<&'static InheritedVisibility>, target_camera: &'static ComputedUiTargetCamera, + text_node: Option<(&'static TextLayoutInfo, &'static ComputedTextBlock)>, } /// Computes the UI node entities under each pointer. @@ -108,6 +110,7 @@ pub fn ui_picking( mut output: MessageWriter, clipping_query: Query<(&ComputedNode, &UiGlobalTransform, &Node)>, child_of_query: Query<&ChildOf, Without>, + pickable_query: Query<&Pickable>, ) { // Map from each camera to its active pointers and their positions in viewport space let mut pointer_pos_by_camera = HashMap::>::default(); @@ -143,7 +146,7 @@ pub fn ui_picking( } // The list of node entities hovered for each (camera, pointer) combo - let mut hit_nodes = HashMap::<(Entity, PointerId), Vec<(Entity, Vec2)>>::default(); + let mut hit_nodes = HashMap::<(Entity, PointerId), Vec<(Entity, Entity, Vec2)>>::default(); // prepare an iterator that contains all the nodes that have the cursor in their rect, // from the top node to the bottom one. this will also reset the interaction to `None` @@ -175,7 +178,8 @@ pub fn ui_picking( continue; }; - if settings.require_markers && node.pickable.is_none() { + // Nodes with Display::None have a (0., 0.) logical rect and can be ignored + if node.node.size() == Vec2::ZERO { continue; } @@ -188,8 +192,8 @@ pub fn ui_picking( continue; } - // Nodes with Display::None have a (0., 0.) logical rect and can be ignored - if node.node.size() == Vec2::ZERO { + // If this is a text node, need to do this check per section. + if node.text_node.is_none() && settings.require_markers && node.pickable.is_none() { continue; } @@ -197,7 +201,29 @@ pub fn ui_picking( // (±0., 0.) is the center with the corners at points (±0.5, ±0.5). // Coordinates are relative to the entire node, not just the visible region. for (pointer_id, cursor_position) in pointers_on_this_cam.iter() { - if node.node.contains_point(*node.transform, *cursor_position) + 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, + ) { + 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.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, @@ -210,6 +236,7 @@ pub fn ui_picking( .or_default() .push(( node_entity, + camera_entity, node.transform.inverse().transform_point2(*cursor_position) / node.node.size(), )); @@ -224,19 +251,13 @@ pub fn ui_picking( let mut picks = Vec::new(); let mut depth = 0.0; - for (hovered_node, position) in hovered { - let node = node_query.get(*hovered_node).unwrap(); - - let Some(camera_entity) = node.target_camera.get() else { - continue; - }; - + for (hovered_node, camera_entity, position) in hovered { picks.push(( - node.entity, - HitData::new(camera_entity, depth, Some(position.extend(0.0)), None), + *hovered_node, + HitData::new(*camera_entity, depth, Some(position.extend(0.0)), None), )); - if let Some(pickable) = node.pickable { + if let Ok(pickable) = pickable_query.get(*hovered_node) { // If an entity has a `Pickable` component, we will use that as the source of truth. if pickable.should_block_lower { break; @@ -258,3 +279,22 @@ pub fn ui_picking( 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 { + 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 +} diff --git a/examples/ui/text_background_colors.rs b/examples/ui/text_background_colors.rs index 8f32c8d29e85b..d81e18565c881 100644 --- a/examples/ui/text_background_colors.rs +++ b/examples/ui/text_background_colors.rs @@ -49,15 +49,30 @@ fn setup(mut commands: Commands) { )) .with_children(|commands| { for (i, section_str) in message_text.iter().enumerate() { - commands.spawn(( - TextSpan::new(*section_str), - TextColor::BLACK, - TextFont { - font_size: 100., - ..default() - }, - TextBackgroundColor(PALETTE[i % PALETTE.len()]), - )); + commands + .spawn(( + TextSpan::new(*section_str), + TextColor::BLACK, + TextFont { + font_size: 100., + ..default() + }, + TextBackgroundColor(PALETTE[i % PALETTE.len()]), + )) + .observe( + |event: On>, mut query: Query<&mut TextColor>| { + if let Ok(mut text_color) = query.get_mut(event.entity) { + text_color.0 = Color::WHITE; + } + }, + ) + .observe( + |event: On>, mut query: Query<&mut TextColor>| { + if let Ok(mut text_color) = query.get_mut(event.entity) { + text_color.0 = Color::BLACK; + } + }, + ); } }); }); diff --git a/release-content/migration-guides/the_non-text_areas_of_text_nodes_are_no_longer_pickable.md b/release-content/migration-guides/the_non-text_areas_of_text_nodes_are_no_longer_pickable.md new file mode 100644 index 0000000000000..360d6ee483bc2 --- /dev/null +++ b/release-content/migration-guides/the_non-text_areas_of_text_nodes_are_no_longer_pickable.md @@ -0,0 +1,7 @@ +--- +title: "The non-text areas of UI `Text` nodes are no longer pickable" +pull_requests: [22047] +--- + +Only the sections of `Text` node's containing text are pickable now, the non-text areas of the node do not register pointer hits. +To replicate Bevy 0.17's picking behavior, use an intermediate parent node to intercept the pointer hits. diff --git a/release-content/release-notes/per_text_section_picking.md b/release-content/release-notes/per_text_section_picking.md new file mode 100644 index 0000000000000..7ab2e7ac5df2d --- /dev/null +++ b/release-content/release-notes/per_text_section_picking.md @@ -0,0 +1,7 @@ +--- +title: "UI per text section picking" +authors: ["@ickshonpe"] +pull_requests: [22047] +--- + +Individual text sections belonging to UI text nodes are now pickable and can be given observers.