Skip to content

Commit 2d4e140

Browse files
authored
UI per text section picking (#22047)
# Objective Allow picking text per section. Fixes #17477 ## Solution In `ui_picking` whenever a node with text components is below a pointer, don't add the `Text` node's entity to the `hit_nodes` list. Instead, iterate the list of text section bounding rects in its `TextLayoutInfo` component to find which text section the pointer is over, and add that section's entity id to the `hit_nodes` list. * Add optional text components to `NodeQuery`. * New helper function, `pick_ui_text`. Finds if a text section is hovered and returns its id. * Add the entities of text sections to `hit_nodes` when hovered. * Add the target camera entity to `hit_nodes`, instead of querying for it a second time. * When a picked node is a text node and `require_markers` is enabled, check if the hit section entity has a `Pickable` component, instead of the text node. # This design might need to be changed later to allow for text selection and copying. But there doesn't seem any need to overcomplicate things for now by considering it. ## Testing I added observers to the `TextBackgroundColors` example's text entities. The observers turn a text section's text color to white when the pointer hovers them. ``` cargo run --example text_background_colors ```
1 parent c7b752a commit 2d4e140

File tree

4 files changed

+93
-24
lines changed

4 files changed

+93
-24
lines changed

crates/bevy_ui/src/picking_backend.rs

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ use bevy_ecs::{prelude::*, query::QueryData};
3030
use bevy_math::Vec2;
3131
use bevy_platform::collections::HashMap;
3232
use bevy_reflect::{std_traits::ReflectDefault, Reflect};
33+
use bevy_text::{ComputedTextBlock, TextLayoutInfo};
3334
use bevy_window::PrimaryWindow;
3435

3536
use bevy_picking::backend::prelude::*;
@@ -92,6 +93,7 @@ pub struct NodeQuery {
9293
pickable: Option<&'static Pickable>,
9394
inherited_visibility: Option<&'static InheritedVisibility>,
9495
target_camera: &'static ComputedUiTargetCamera,
96+
text_node: Option<(&'static TextLayoutInfo, &'static ComputedTextBlock)>,
9597
}
9698

9799
/// Computes the UI node entities under each pointer.
@@ -108,6 +110,7 @@ pub fn ui_picking(
108110
mut output: MessageWriter<PointerHits>,
109111
clipping_query: Query<(&ComputedNode, &UiGlobalTransform, &Node)>,
110112
child_of_query: Query<&ChildOf, Without<OverrideClip>>,
113+
pickable_query: Query<&Pickable>,
111114
) {
112115
// Map from each camera to its active pointers and their positions in viewport space
113116
let mut pointer_pos_by_camera = HashMap::<Entity, HashMap<PointerId, Vec2>>::default();
@@ -143,7 +146,7 @@ pub fn ui_picking(
143146
}
144147

145148
// The list of node entities hovered for each (camera, pointer) combo
146-
let mut hit_nodes = HashMap::<(Entity, PointerId), Vec<(Entity, Vec2)>>::default();
149+
let mut hit_nodes = HashMap::<(Entity, PointerId), Vec<(Entity, Entity, Vec2)>>::default();
147150

148151
// prepare an iterator that contains all the nodes that have the cursor in their rect,
149152
// from the top node to the bottom one. this will also reset the interaction to `None`
@@ -175,7 +178,8 @@ pub fn ui_picking(
175178
continue;
176179
};
177180

178-
if settings.require_markers && node.pickable.is_none() {
181+
// Nodes with Display::None have a (0., 0.) logical rect and can be ignored
182+
if node.node.size() == Vec2::ZERO {
179183
continue;
180184
}
181185

@@ -188,16 +192,38 @@ pub fn ui_picking(
188192
continue;
189193
}
190194

191-
// Nodes with Display::None have a (0., 0.) logical rect and can be ignored
192-
if node.node.size() == Vec2::ZERO {
195+
// If this is a text node, need to do this check per section.
196+
if node.text_node.is_none() && settings.require_markers && node.pickable.is_none() {
193197
continue;
194198
}
195199

196200
// Find the normalized cursor position relative to the node.
197201
// (±0., 0.) is the center with the corners at points (±0.5, ±0.5).
198202
// Coordinates are relative to the entire node, not just the visible region.
199203
for (pointer_id, cursor_position) in pointers_on_this_cam.iter() {
200-
if node.node.contains_point(*node.transform, *cursor_position)
204+
if let Some((text_layout_info, text_block)) = node.text_node {
205+
if let Some(text_entity) = pick_ui_text_section(
206+
node.node,
207+
node.transform,
208+
*cursor_position,
209+
text_layout_info,
210+
text_block,
211+
) {
212+
if settings.require_markers && !pickable_query.contains(text_entity) {
213+
continue;
214+
}
215+
216+
hit_nodes
217+
.entry((camera_entity, *pointer_id))
218+
.or_default()
219+
.push((
220+
text_entity,
221+
camera_entity,
222+
node.transform.inverse().transform_point2(*cursor_position)
223+
/ node.node.size(),
224+
));
225+
}
226+
} else if node.node.contains_point(*node.transform, *cursor_position)
201227
&& clip_check_recursive(
202228
*cursor_position,
203229
node_entity,
@@ -210,6 +236,7 @@ pub fn ui_picking(
210236
.or_default()
211237
.push((
212238
node_entity,
239+
camera_entity,
213240
node.transform.inverse().transform_point2(*cursor_position)
214241
/ node.node.size(),
215242
));
@@ -224,19 +251,13 @@ pub fn ui_picking(
224251
let mut picks = Vec::new();
225252
let mut depth = 0.0;
226253

227-
for (hovered_node, position) in hovered {
228-
let node = node_query.get(*hovered_node).unwrap();
229-
230-
let Some(camera_entity) = node.target_camera.get() else {
231-
continue;
232-
};
233-
254+
for (hovered_node, camera_entity, position) in hovered {
234255
picks.push((
235-
node.entity,
236-
HitData::new(camera_entity, depth, Some(position.extend(0.0)), None),
256+
*hovered_node,
257+
HitData::new(*camera_entity, depth, Some(position.extend(0.0)), None),
237258
));
238259

239-
if let Some(pickable) = node.pickable {
260+
if let Ok(pickable) = pickable_query.get(*hovered_node) {
240261
// If an entity has a `Pickable` component, we will use that as the source of truth.
241262
if pickable.should_block_lower {
242263
break;
@@ -258,3 +279,22 @@ pub fn ui_picking(
258279
output.write(PointerHits::new(*pointer, picks, order));
259280
}
260281
}
282+
283+
fn pick_ui_text_section(
284+
uinode: &ComputedNode,
285+
global_transform: &UiGlobalTransform,
286+
point: Vec2,
287+
text_layout_info: &TextLayoutInfo,
288+
text_block: &ComputedTextBlock,
289+
) -> Option<Entity> {
290+
let local_point = global_transform
291+
.try_inverse()
292+
.map(|transform| transform.transform_point2(point) + 0.5 * uinode.size())?;
293+
294+
for run in text_layout_info.run_geometry.iter() {
295+
if run.bounds.contains(local_point) {
296+
return text_block.entities().get(run.span_index).map(|e| e.entity);
297+
}
298+
}
299+
None
300+
}

examples/ui/text_background_colors.rs

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,30 @@ fn setup(mut commands: Commands) {
4949
))
5050
.with_children(|commands| {
5151
for (i, section_str) in message_text.iter().enumerate() {
52-
commands.spawn((
53-
TextSpan::new(*section_str),
54-
TextColor::BLACK,
55-
TextFont {
56-
font_size: 100.,
57-
..default()
58-
},
59-
TextBackgroundColor(PALETTE[i % PALETTE.len()]),
60-
));
52+
commands
53+
.spawn((
54+
TextSpan::new(*section_str),
55+
TextColor::BLACK,
56+
TextFont {
57+
font_size: 100.,
58+
..default()
59+
},
60+
TextBackgroundColor(PALETTE[i % PALETTE.len()]),
61+
))
62+
.observe(
63+
|event: On<Pointer<Over>>, mut query: Query<&mut TextColor>| {
64+
if let Ok(mut text_color) = query.get_mut(event.entity) {
65+
text_color.0 = Color::WHITE;
66+
}
67+
},
68+
)
69+
.observe(
70+
|event: On<Pointer<Out>>, mut query: Query<&mut TextColor>| {
71+
if let Ok(mut text_color) = query.get_mut(event.entity) {
72+
text_color.0 = Color::BLACK;
73+
}
74+
},
75+
);
6176
}
6277
});
6378
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
title: "The non-text areas of UI `Text` nodes are no longer pickable"
3+
pull_requests: [22047]
4+
---
5+
6+
Only the sections of `Text` node's containing text are pickable now, the non-text areas of the node do not register pointer hits.
7+
To replicate Bevy 0.17's picking behavior, use an intermediate parent node to intercept the pointer hits.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
title: "UI per text section picking"
3+
authors: ["@ickshonpe"]
4+
pull_requests: [22047]
5+
---
6+
7+
Individual text sections belonging to UI text nodes are now pickable and can be given observers.

0 commit comments

Comments
 (0)