diff --git a/Cargo.toml b/Cargo.toml index f5efb88019769..8212a6f963fd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4001,6 +4001,19 @@ description = "Demonstrates picking sprites and sprite atlases" category = "Picking" wasm = true +[[example]] +name = "text_picking" +path = "examples/picking/text_picking.rs" +doc-scrape-examples = true +required-features = ["bevy_ui_picking_backend"] + +[package.metadata.example.text_picking] +name = "Text Picking" +description = "Demonstrates picking text" +category = "Picking" +wasm = true +required-features = ["bevy_ui_picking_backend"] + [[example]] name = "debug_picking" path = "examples/picking/debug_picking.rs" diff --git a/crates/bevy_text/Cargo.toml b/crates/bevy_text/Cargo.toml index a5e8dea07db51..fb546ecb8ff51 100644 --- a/crates/bevy_text/Cargo.toml +++ b/crates/bevy_text/Cargo.toml @@ -10,6 +10,7 @@ keywords = ["bevy"] [features] default_font = [] +picking = ["bevy_picking"] [dependencies] # bevy @@ -21,6 +22,7 @@ bevy_ecs = { path = "../bevy_ecs", version = "0.16.0-dev" } bevy_image = { path = "../bevy_image", version = "0.16.0-dev" } bevy_log = { path = "../bevy_log", version = "0.16.0-dev" } bevy_math = { path = "../bevy_math", version = "0.16.0-dev" } +bevy_picking = { path = "../bevy_picking", version = "0.16.0-dev", optional = true } bevy_reflect = { path = "../bevy_reflect", version = "0.16.0-dev", features = [ "bevy", ] } @@ -42,6 +44,7 @@ smallvec = "1.13" unicode-bidi = "0.3.13" sys-locale = "0.3.0" tracing = { version = "0.1", default-features = false, features = ["std"] } +radsort = "0.1" [dev-dependencies] approx = "0.5.1" diff --git a/crates/bevy_text/src/glyph.rs b/crates/bevy_text/src/glyph.rs index 6de501266c23d..752b7e6e0c616 100644 --- a/crates/bevy_text/src/glyph.rs +++ b/crates/bevy_text/src/glyph.rs @@ -20,24 +20,12 @@ pub struct PositionedGlyph { pub atlas_info: GlyphAtlasInfo, /// The index of the glyph in the [`ComputedTextBlock`](crate::ComputedTextBlock)'s tracked spans. pub span_index: usize, - /// TODO: In order to do text editing, we need access to the size of glyphs and their index in the associated String. - /// For example, to figure out where to place the cursor in an input box from the mouse's position. - /// Without this, it's only possible in texts where each glyph is one byte. Cosmic text has methods for this - /// cosmic-texts [hit detection](https://pop-os.github.io/cosmic-text/cosmic_text/struct.Buffer.html#method.hit) - byte_index: usize, -} - -impl PositionedGlyph { - /// Creates a new [`PositionedGlyph`] - pub fn new(position: Vec2, size: Vec2, atlas_info: GlyphAtlasInfo, span_index: usize) -> Self { - Self { - position, - size, - atlas_info, - span_index, - byte_index: 0, - } - } + /// The index of the glyph's line. + pub line_index: usize, + /// The byte index of the glyph in it's line. + pub byte_index: usize, + /// The byte length of the glyph. + pub byte_length: usize, } /// Information about a glyph in an atlas. diff --git a/crates/bevy_text/src/lib.rs b/crates/bevy_text/src/lib.rs index 670f793c31c15..7c8d0a216afe2 100644 --- a/crates/bevy_text/src/lib.rs +++ b/crates/bevy_text/src/lib.rs @@ -38,10 +38,16 @@ mod font_atlas; mod font_atlas_set; mod font_loader; mod glyph; +#[cfg(feature = "picking")] +mod picking_backend; mod pipeline; mod text; mod text2d; mod text_access; +#[cfg(feature = "picking")] +pub mod text_picking_backend; +#[cfg(feature = "picking")] +pub mod text_pointer; pub use bounds::*; pub use error::*; @@ -154,5 +160,14 @@ impl Plugin for TextPlugin { "FiraMono-subset.ttf", |bytes: &[u8], _path: String| { Font::try_from_bytes(bytes.to_vec()).unwrap() } ); + + #[cfg(feature = "picking")] + { + app.add_plugins(( + text_pointer::plugin, + text_picking_backend::plugin, + picking_backend::Text2dPickingPlugin, + )); + } } } diff --git a/crates/bevy_text/src/picking_backend.rs b/crates/bevy_text/src/picking_backend.rs new file mode 100644 index 0000000000000..8d4e57244a56a --- /dev/null +++ b/crates/bevy_text/src/picking_backend.rs @@ -0,0 +1,220 @@ +use bevy_app::{App, Plugin, PreUpdate}; +use bevy_ecs::component::Component; +use bevy_ecs::prelude::ReflectResource; +use bevy_ecs::query::Has; +use bevy_ecs::system::Res; +use bevy_ecs::{ + entity::Entity, event::EventWriter, query::With, resource::Resource, + schedule::IntoSystemConfigs, system::Query, +}; +use bevy_math::{FloatExt, Ray3d, Rect, Vec2, Vec3, Vec3Swizzles}; +use bevy_picking::Pickable; +use bevy_picking::{ + backend::{HitData, PointerHits}, + pointer::{PointerId, PointerLocation}, + PickSet, +}; +use bevy_reflect::prelude::ReflectDefault; +use bevy_reflect::Reflect; +use bevy_render::camera::{Camera, OrthographicProjection, Projection}; +use bevy_render::view::ViewVisibility; +use bevy_transform::components::GlobalTransform; +use bevy_window::PrimaryWindow; + +use crate::{Text2d, TextBounds, TextLayoutInfo}; + +/// Runtime settings for the [`Text2dPickingPlugin`]. +#[derive(Default, Resource, Reflect)] +#[reflect(Resource, Default)] +pub struct Text2dPickingSettings { + /// When set to `true` picking will only consider cameras marked with + /// [`Text2dPickingCamera`] and entities marked with [`Pickable`]. `false` by default. + /// + /// This setting is provided to give you fine-grained control over which cameras and entities + /// should be used by the picking backend at runtime. + pub require_markers: bool, +} + +pub struct Text2dPickingPlugin; + +impl Plugin for Text2dPickingPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .register_type::() + .add_systems(PreUpdate, text2d_picking.in_set(PickSet::Backend)); + } +} + +#[derive(Component)] +pub struct Text2dPickingCamera; + +fn text2d_picking( + pointers: Query<(&PointerId, &PointerLocation)>, + primary_window: Query>, + cameras: Query<( + Entity, + &Camera, + &GlobalTransform, + &Projection, + Has, + )>, + text_query: Query< + ( + Entity, + &TextLayoutInfo, + &TextBounds, + &GlobalTransform, + Option<&Pickable>, + &ViewVisibility, + ), + With, + >, + settings: Res, + mut output: EventWriter, +) { + let mut sorted_texts: Vec<_> = text_query + .iter() + .filter_map(|(entity, layout, bounds, transform, pickable, vis)| { + let marker_requirement = !settings.require_markers || pickable.is_some(); + if !transform.affine().is_nan() && vis.get() && marker_requirement { + Some((entity, layout, bounds, transform, pickable)) + } else { + None + } + }) + .collect(); + + radsort::sort_by_key(&mut sorted_texts, |(_, _, _, t, _)| -t.translation().z); + + let primary_window = primary_window.get_single().ok(); + + for (pointer, location) in pointers.iter().filter_map(|(pointer, pointer_location)| { + pointer_location.location().map(|loc| (pointer, loc)) + }) { + let mut blocked = false; + let Some((cam_entity, camera, cam_transform, Projection::Orthographic(cam_ortho), _)) = + cameras + .iter() + .filter(|(_, camera, _, _, cam_can_pick)| { + let marker_requirement = !settings.require_markers || *cam_can_pick; + camera.is_active && marker_requirement + }) + .find(|(_, camera, _, _, _)| { + camera + .target + .normalize(primary_window) + .is_some_and(|x| x == location.target) + }) + else { + continue; + }; + + let Some((cursor_ray_world, cursor_ray_end)) = + rays_from_cursor_camera(location.position, camera, cam_transform, cam_ortho) + else { + continue; + }; + + let picks: Vec<(Entity, HitData)> = sorted_texts + .iter() + .filter_map( + |(entity, text_layout, text_bounds, text_transform, pickable)| { + if blocked { + return None; + } + + // Transform cursor line segment to local coordinate system + let relative_cursor_pos = + get_relative_cursor_pos(text_transform, cursor_ray_world, cursor_ray_end)?; + + // Find target rect, check cursor is contained inside + let size = Vec2::new( + text_bounds.width.unwrap_or(text_layout.size.x), + text_bounds.height.unwrap_or(text_layout.size.y), + ); + + let text_rect = Rect::from_corners(-size / 2.0, size / 2.0); + if !text_rect.contains(relative_cursor_pos) { + return None; + } + + blocked = pickable.is_none_or(|p| p.should_block_lower); + + let hit_pos_world = + text_transform.transform_point(relative_cursor_pos.extend(0.0)); + // Transform point from world to camera space to get the Z distance + let hit_pos_cam = cam_transform + .affine() + .inverse() + .transform_point3(hit_pos_world); + // HitData requires a depth as calculated from the camera's near clipping plane + let depth = -cam_ortho.near - hit_pos_cam.z; + + Some(( + *entity, + HitData::new( + cam_entity, + depth, + Some(hit_pos_world), + Some(*text_transform.back()), + ), + )) + }, + ) + .collect(); + + let order = camera.order as f32; + output.send(PointerHits::new(*pointer, picks, order)); + } +} + +// TODO: find a better home for this helper +pub fn get_relative_cursor_pos( + transform: &GlobalTransform, + world_ray: Ray3d, + end_ray: Vec3, +) -> Option { + // Transform cursor line segment to target's local coordinate system + let world_to_target = transform.affine().inverse(); + let cursor_start = world_to_target.transform_point3(world_ray.origin); + let cursor_end = world_to_target.transform_point3(end_ray); + + // Find where the cursor segment intersects the plane Z=0 (which is the target's + // plane in local space). It may not intersect if, for example, we're + // viewing the target side-on + if cursor_start.z == cursor_end.z { + // Cursor ray is parallel to the text and misses it + return None; + } + let lerp_factor = f32::inverse_lerp(cursor_start.z, cursor_end.z, 0.0); + if !(0.0..=1.0).contains(&lerp_factor) { + // Lerp factor is out of range, meaning that while an infinite line cast by + // the cursor would intersect the target, the target is not between the + // camera's near and far planes + return None; + } + + // Otherwise we can interpolate the xy of the start and end positions by the + // lerp factor to get the cursor position in local space + Some(cursor_start.lerp(cursor_end, lerp_factor).xy()) +} + +pub fn rays_from_cursor_camera( + cursor: Vec2, + camera: &Camera, + cam_transform: &GlobalTransform, + projection: &OrthographicProjection, +) -> Option<(Ray3d, Vec3)> { + let viewport_pos = camera + .logical_viewport_rect() + .map(|v| v.min) + .unwrap_or_default(); + let pos_in_viewport = cursor - viewport_pos; + + let Ok(cursor_ray_world) = camera.viewport_to_world(cam_transform, pos_in_viewport) else { + return None; + }; + let cursor_ray_len = projection.far - projection.near; + let cursor_ray_end = cursor_ray_world.origin + cursor_ray_world.direction * cursor_ray_len; + Some((cursor_ray_world, cursor_ray_end)) +} diff --git a/crates/bevy_text/src/pipeline.rs b/crates/bevy_text/src/pipeline.rs index e7774569cfb25..5568c65845225 100644 --- a/crates/bevy_text/src/pipeline.rs +++ b/crates/bevy_text/src/pipeline.rs @@ -263,77 +263,88 @@ impl TextPipeline { let buffer = &mut computed.buffer; let box_size = buffer_dimensions(buffer); - let result = buffer - .layout_runs() - .flat_map(|run| { - run.glyphs - .iter() - .map(move |layout_glyph| (layout_glyph, run.line_y)) - }) - .try_for_each(|(layout_glyph, line_y)| { - let mut temp_glyph; - let span_index = layout_glyph.metadata; - let font_id = glyph_info[span_index].0; - let font_smoothing = glyph_info[span_index].1; - - let layout_glyph = if font_smoothing == FontSmoothing::None { - // If font smoothing is disabled, round the glyph positions and sizes, - // effectively discarding all subpixel layout. - temp_glyph = layout_glyph.clone(); - temp_glyph.x = temp_glyph.x.round(); - temp_glyph.y = temp_glyph.y.round(); - temp_glyph.w = temp_glyph.w.round(); - temp_glyph.x_offset = temp_glyph.x_offset.round(); - temp_glyph.y_offset = temp_glyph.y_offset.round(); - temp_glyph.line_height_opt = temp_glyph.line_height_opt.map(f32::round); - - &temp_glyph - } else { - layout_glyph - }; - - let font_atlas_set = font_atlas_sets.sets.entry(font_id).or_default(); - - let physical_glyph = layout_glyph.physical((0., 0.), 1.); - - let atlas_info = font_atlas_set - .get_glyph_atlas_info(physical_glyph.cache_key, font_smoothing) - .map(Ok) - .unwrap_or_else(|| { - font_atlas_set.add_glyph_to_atlas( - texture_atlases, - textures, - &mut font_system.0, - &mut swash_cache.0, - layout_glyph, - font_smoothing, - ) - })?; - - let texture_atlas = texture_atlases.get(&atlas_info.texture_atlas).unwrap(); - let location = atlas_info.location; - let glyph_rect = texture_atlas.textures[location.glyph_index]; - let left = location.offset.x as f32; - let top = location.offset.y as f32; - let glyph_size = UVec2::new(glyph_rect.width(), glyph_rect.height()); - - // offset by half the size because the origin is center - let x = glyph_size.x as f32 / 2.0 + left + physical_glyph.x as f32; - let y = line_y.round() + physical_glyph.y as f32 - top + glyph_size.y as f32 / 2.0; - let y = match y_axis_orientation { - YAxisOrientation::TopToBottom => y, - YAxisOrientation::BottomToTop => box_size.y - y, - }; - - let position = Vec2::new(x, y); - - // TODO: recreate the byte index, that keeps track of where a cursor is, - // when glyphs are not limited to single byte representation, relevant for #1319 - let pos_glyph = - PositionedGlyph::new(position, glyph_size.as_vec2(), atlas_info, span_index); - layout_info.glyphs.push(pos_glyph); - Ok(()) - }); + let mut byte_index = 0; + + let result = buffer.layout_runs().try_for_each(|run| { + byte_index = 0; + + let result = run + .glyphs + .iter() + .map(move |layout_glyph| (layout_glyph, run.line_y, run.line_i)) + .try_for_each(|(layout_glyph, line_y, line_i)| { + let mut temp_glyph; + let span_index = layout_glyph.metadata; + let font_id = glyph_info[span_index].0; + let font_smoothing = glyph_info[span_index].1; + + let layout_glyph = if font_smoothing == FontSmoothing::None { + // If font smoothing is disabled, round the glyph positions and sizes, + // effectively discarding all subpixel layout. + temp_glyph = layout_glyph.clone(); + temp_glyph.x = temp_glyph.x.round(); + temp_glyph.y = temp_glyph.y.round(); + temp_glyph.w = temp_glyph.w.round(); + temp_glyph.x_offset = temp_glyph.x_offset.round(); + temp_glyph.y_offset = temp_glyph.y_offset.round(); + temp_glyph.line_height_opt = temp_glyph.line_height_opt.map(f32::round); + + &temp_glyph + } else { + layout_glyph + }; + + let font_atlas_set = font_atlas_sets.sets.entry(font_id).or_default(); + + let physical_glyph = layout_glyph.physical((0., 0.), 1.); + + let atlas_info = font_atlas_set + .get_glyph_atlas_info(physical_glyph.cache_key, font_smoothing) + .map(Ok) + .unwrap_or_else(|| { + font_atlas_set.add_glyph_to_atlas( + texture_atlases, + textures, + &mut font_system.0, + &mut swash_cache.0, + layout_glyph, + font_smoothing, + ) + })?; + + let texture_atlas = texture_atlases.get(&atlas_info.texture_atlas).unwrap(); + let location = atlas_info.location; + let glyph_rect = texture_atlas.textures[location.glyph_index]; + let left = location.offset.x as f32; + let top = location.offset.y as f32; + let glyph_size = UVec2::new(glyph_rect.width(), glyph_rect.height()); + + // offset by half the size because the origin is center + let x = glyph_size.x as f32 / 2.0 + left + physical_glyph.x as f32; + let y = + line_y.round() + physical_glyph.y as f32 - top + glyph_size.y as f32 / 2.0; + let y = match y_axis_orientation { + YAxisOrientation::TopToBottom => y, + YAxisOrientation::BottomToTop => box_size.y - y, + }; + + let position = Vec2::new(x, y); + + let pos_glyph = PositionedGlyph { + position, + size: glyph_size.as_vec2(), + atlas_info, + span_index, + byte_index: layout_glyph.start, + byte_length: layout_glyph.end - layout_glyph.start, + line_index: line_i, + }; + layout_info.glyphs.push(pos_glyph); + Ok(()) + }); + + result + }); // Return the scratch vec. self.glyph_info = glyph_info; diff --git a/crates/bevy_text/src/text_picking_backend.rs b/crates/bevy_text/src/text_picking_backend.rs new file mode 100644 index 0000000000000..0563b84f2e670 --- /dev/null +++ b/crates/bevy_text/src/text_picking_backend.rs @@ -0,0 +1,115 @@ +//! Text picking backend for `Text2d`. + +use bevy_app::App; +use bevy_ecs::{ + observer::Trigger, + query::With, + system::{Commands, Query}, +}; +use bevy_math::Vec2; +use bevy_picking::events::{ + Cancel, Click, DragDrop, DragEnter, DragLeave, DragOver, DragStart, Move, Out, Over, Pointer, + Pressed, Released, +}; +use bevy_render::camera::{Camera, Projection}; +use bevy_transform::components::GlobalTransform; + +use crate::{ + picking_backend::{get_relative_cursor_pos, rays_from_cursor_camera}, + text_pointer::{HasHit, TextPointer}, + ComputedTextBlock, Text2d, TextBounds, TextLayoutInfo, +}; + +pub(crate) fn plugin(app: &mut App) { + app.add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::); +} + +// BUG: this double triggers the text events for some reason? +pub(crate) fn get_and_emit_text_hits( + trigger: Trigger>, + q: Query< + ( + &ComputedTextBlock, + &TextLayoutInfo, + &TextBounds, + &GlobalTransform, + ), + With, + >, + cam_q: Query<(&Camera, &GlobalTransform, &Projection)>, + mut commands: Commands, +) { + let Ok((c_text, text_layout, bounds, transform)) = q.get(trigger.target) else { + return; + }; + + let Ok((camera, cam_transform, Projection::Orthographic(cam_proj))) = + cam_q.get(trigger.event.hit().camera) + else { + return; + }; + + // TODO: this is duplicate work, pointer already did it + // Can we just hit_pos-transform here? + let Some((world_ray, end_ray)) = rays_from_cursor_camera( + trigger.pointer_location.position, + camera, + cam_transform, + cam_proj, + ) else { + return; + }; + + let Some(mut local_pos) = get_relative_cursor_pos(transform, world_ray, end_ray) else { + return; + }; + + // BUG: incorrect hits reported from this math, scaling issue? + let size = Vec2::new( + bounds.width.unwrap_or(text_layout.size.x), + bounds.height.unwrap_or(text_layout.size.y), + ); + + local_pos.y *= -1.; + local_pos += size / 2.; + + // TODO: DRY: this is repeated in UI text picking + + let Some(cursor) = c_text.buffer().hit(local_pos.x, local_pos.y) else { + return; + }; + + // PERF: doing this as well as using cosmic's `hit` is the worst of both worlds. This approach + // allows for span-specific events, whereas cosmic's hit detection is faster by discarding + // per-line, and also gives cursor affinity (direction on glyph) + let Some(positioned_glyph) = text_layout + .glyphs + .iter() + .find(|g| g.byte_index == cursor.index && g.line_index == cursor.line) + else { + return; + }; + // Get span entity + let target_span = c_text.entities()[positioned_glyph.span_index]; + + let text_pointer = TextPointer:: { + cursor, + // TODO: can this be a borrow? + // TODO: getting positioned_glyph can (+ should) be a helper fn (on TextPointer?) + glyph: positioned_glyph.clone(), + event: trigger.event().clone(), + }; + + commands.trigger_targets(text_pointer.clone(), target_span.entity); +} diff --git a/crates/bevy_text/src/text_pointer.rs b/crates/bevy_text/src/text_pointer.rs new file mode 100644 index 0000000000000..35068cfb4a0df --- /dev/null +++ b/crates/bevy_text/src/text_pointer.rs @@ -0,0 +1,161 @@ +//! Common types used for text picking. + +use bevy_app::App; +use bevy_ecs::{ + entity::{Entity, EntityBorrow}, + event::Event, + hierarchy::ChildOf, + query::QueryData, + traversal::Traversal, +}; +use bevy_picking::{ + backend::HitData, + events::{ + Cancel, Click, Drag, DragDrop, DragEnd, DragEnter, DragLeave, DragOver, DragStart, Move, + Out, Over, Pointer, Pressed, Released, + }, +}; +use bevy_reflect::Reflect; +use bevy_render::camera::NormalizedRenderTarget; +use bevy_window::Window; +use cosmic_text::Cursor; + +use crate::PositionedGlyph; + +pub(crate) fn plugin(app: &mut App) { + app.add_event::>() + .add_event::>() + .add_event::>() + .add_event::>() + .add_event::>() + .add_event::>() + .add_event::>() + .add_event::>() + .add_event::>() + .add_event::>() + .add_event::>() + .add_event::>() + .add_event::>() + .add_event::>(); +} + +/// Text-specific pointer event. +#[derive(Debug, Clone)] +pub struct TextPointer { + /// The picked location in text. + pub cursor: Cursor, + /// The `PositionedGlyph` the the picked location in text. + pub glyph: PositionedGlyph, + /// The original `Pointer` event that triggered the `TextPointer` event. + pub event: Pointer, +} + +impl Event for TextPointer +where + E: Clone + Reflect + core::fmt::Debug, +{ + const AUTO_PROPAGATE: bool = true; + type Traversal = TextPointerTraversal; +} + +/// A traversal query (eg it implements [`Traversal`]) intended for use with [`TextPointer`] events. +/// +/// This will always traverse to the parent, if the entity being visited has one. Otherwise, it +/// propagates to the pointer's window and stops there. +#[derive(QueryData)] +pub struct TextPointerTraversal { + parent: Option<&'static ChildOf>, + window: Option<&'static Window>, +} + +impl Traversal> for TextPointerTraversal +where + E: core::fmt::Debug + Clone + Reflect, +{ + fn traverse(item: Self::Item<'_>, pointer: &TextPointer) -> Option { + let TextPointerTraversalItem { parent, window } = item; + + // Send event to parent, if it has one. + if let Some(parent) = parent { + return Some(parent.get()); + }; + + // Otherwise, send it to the window entity (unless this is a window entity). + if window.is_none() { + if let NormalizedRenderTarget::Window(window_ref) = + pointer.event.pointer_location.target + { + return Some(window_ref.entity()); + } + } + + None + } +} + +/// Pointer event shared trait where `HitData` exists. +pub trait HasHit: Clone + Reflect + core::fmt::Debug { + /// Provides access to the event's `HitData`. + fn hit(&self) -> &HitData; +} + +impl HasHit for Cancel { + fn hit(&self) -> &HitData { + &self.hit + } +} +impl HasHit for Click { + fn hit(&self) -> &HitData { + &self.hit + } +} +impl HasHit for Pressed { + fn hit(&self) -> &HitData { + &self.hit + } +} +impl HasHit for DragDrop { + fn hit(&self) -> &HitData { + &self.hit + } +} +impl HasHit for DragEnter { + fn hit(&self) -> &HitData { + &self.hit + } +} +impl HasHit for DragLeave { + fn hit(&self) -> &HitData { + &self.hit + } +} +impl HasHit for DragOver { + fn hit(&self) -> &HitData { + &self.hit + } +} +impl HasHit for DragStart { + fn hit(&self) -> &HitData { + &self.hit + } +} +impl HasHit for Move { + fn hit(&self) -> &HitData { + &self.hit + } +} +impl HasHit for Out { + fn hit(&self) -> &HitData { + &self.hit + } +} +impl HasHit for Over { + fn hit(&self) -> &HitData { + &self.hit + } +} +impl HasHit for Released { + fn hit(&self) -> &HitData { + &self.hit + } +} diff --git a/crates/bevy_ui/Cargo.toml b/crates/bevy_ui/Cargo.toml index c287ede55b5a3..672386d947b32 100644 --- a/crates/bevy_ui/Cargo.toml +++ b/crates/bevy_ui/Cargo.toml @@ -53,7 +53,7 @@ serialize = [ "bevy_math/serialize", "bevy_platform_support/serialize", ] -bevy_ui_picking_backend = ["bevy_picking"] +bevy_ui_picking_backend = ["bevy_picking", "bevy_text/picking"] bevy_ui_debug = [] # Experimental features diff --git a/crates/bevy_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index fd573f1c0c54a..a7111ab8c67d9 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -17,6 +17,8 @@ pub mod widget; #[cfg(feature = "bevy_ui_picking_backend")] pub mod picking_backend; +#[cfg(feature = "bevy_ui_picking_backend")] +pub mod text_picking_backend; use bevy_derive::{Deref, DerefMut}; use bevy_reflect::{std_traits::ReflectDefault, Reflect}; @@ -222,6 +224,7 @@ impl Plugin for UiPlugin { #[cfg(feature = "bevy_ui_picking_backend")] if self.add_picking { app.add_plugins(picking_backend::UiPickingPlugin); + app.add_plugins(text_picking_backend::plugin); } if !self.enable_rendering { diff --git a/crates/bevy_ui/src/text_picking_backend.rs b/crates/bevy_ui/src/text_picking_backend.rs new file mode 100644 index 0000000000000..b6a9dacab333c --- /dev/null +++ b/crates/bevy_ui/src/text_picking_backend.rs @@ -0,0 +1,80 @@ +use bevy_app::App; +use bevy_ecs::{ + observer::Trigger, + system::{Commands, Query}, +}; +use bevy_picking::events::{ + Cancel, Click, DragDrop, DragEnter, DragLeave, DragOver, DragStart, Move, Out, Over, Pointer, + Pressed, Released, +}; +use bevy_text::{ + text_pointer::{HasHit, TextPointer}, + ComputedTextBlock, TextLayoutInfo, +}; + +use crate::ComputedNode; + +pub(crate) fn plugin(app: &mut App) { + app.add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::); + + // TODO: investigate whether hit data can be added here + // + investigate if drag events are ever useful? + // .add_observer(get_and_emit_text_hits::) + // .add_observer(get_and_emit_text_hits::) +} + +/// Takes UI pointer hits and re-emits them as `TextPointer` triggers. +pub(crate) fn get_and_emit_text_hits( + trigger: Trigger>, + q: Query<(&ComputedNode, &ComputedTextBlock, &TextLayoutInfo)>, + mut commands: Commands, +) { + // Get click position relative to node + let Ok((c_node, c_text, text_layout)) = q.get(trigger.target()) else { + return; + }; + + let Some(hit_pos) = trigger.event.hit().position else { + return; + }; + + let physical_pos = hit_pos.truncate() * c_node.size; + + let Some(cursor) = c_text.buffer().hit(physical_pos.x, physical_pos.y) else { + return; + }; + + // PERF: doing this as well as using cosmic's `hit` is the worst of both worlds. This approach + // allows for span-specific events, whereas cosmic's hit detection is faster by discarding + // per-line, and also gives cursor affinity (direction on glyph) + let Some(positioned_glyph) = text_layout + .glyphs + .iter() + .find(|g| g.byte_index == cursor.index && g.line_index == cursor.line) + else { + return; + }; + + // Get span entity + let target_span = c_text.entities()[positioned_glyph.span_index]; + + let text_pointer = TextPointer:: { + cursor, + // TODO: can this be a borrow? + glyph: positioned_glyph.clone(), + event: trigger.event().clone(), + }; + + commands.trigger_targets(text_pointer.clone(), target_span.entity); +} diff --git a/examples/README.md b/examples/README.md index 4569d0ceacefc..ae4633621b2f0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -395,6 +395,7 @@ Example | Description [Picking Debug Tools](../examples/picking/debug_picking.rs) | Demonstrates picking debug overlay [Showcases simple picking events and usage](../examples/picking/simple_picking.rs) | Demonstrates how to use picking events to spawn simple objects [Sprite Picking](../examples/picking/sprite_picking.rs) | Demonstrates picking sprites and sprite atlases +[Text Picking](../examples/picking/text_picking.rs) | Demonstrates picking text ## Reflection diff --git a/examples/picking/text_picking.rs b/examples/picking/text_picking.rs new file mode 100644 index 0000000000000..0a0c7d5efebdc --- /dev/null +++ b/examples/picking/text_picking.rs @@ -0,0 +1,141 @@ +//! Demo picking text. + +use bevy::{ + color::palettes::css::GREEN, + prelude::*, + text::{cosmic_text::Affinity, text_pointer::TextPointer, TextLayoutInfo}, + ui::RelativeCursorPosition, + window::WindowResolution, +}; + +fn main() { + App::new() + .add_plugins(DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + resolution: WindowResolution::default().with_scale_factor_override(1.0), + ..default() + }), + ..default() + })) + .init_resource::() + .add_systems(Startup, (setup,)) + .add_systems(Update, cursor_to_target) + .run(); +} + +fn set_cursor_target_ui( + t: Trigger>, + q: Query<(&GlobalTransform, &TextLayoutInfo)>, + mut target: ResMut, + cam: Single<(&Camera, &GlobalTransform)>, +) { + let Ok((gt, tli)) = q.get(t.target()) else { + return; + }; + + let (cam, cam_transform) = cam.into_inner(); + + const LINEHEIGHT: f32 = 12.0; + + let xoff = match t.cursor.affinity { + Affinity::Before => -t.glyph.size.x / 2.0, + Affinity::After => t.glyph.size.x / 2.0, + }; + let xpos = t.glyph.position.x + xoff - tli.size.x / 2.; + let ypos = (t.cursor.line + 1) as f32 * LINEHEIGHT - tli.size.y / 2.; + + let pos = gt.translation().truncate() + Vec2::new(xpos, ypos); + + target.0 = cam + .viewport_to_world_2d(cam_transform, pos) + .unwrap() + .extend(1.0); +} + +fn setup(mut commands: Commands) { + commands.spawn(Camera2d); + commands.spawn(( + Sprite { + custom_size: Some(Vec2::new(4.0, 16.0)), + color: GREEN.into(), + ..default() + }, + MyCursor, + )); + commands + .spawn(( + Text::new("hello text picking"), + Node { + position_type: PositionType::Absolute, + top: Val::Percent(25.0), + left: Val::Percent(25.0), + ..default() + }, + RelativeCursorPosition::default(), + )) + .with_children(|cb| { + // TODO: find a better text string that shows multibyte adherence within bevy's font + // subset. + cb.spawn(TextSpan::new( + "i'm a new span\n●●●●i'm the same span...\n····", + )) + .observe(|mut t: Trigger>| { + info!("Span specific observer clicked! {:?}", t); + // Prevent parent observer triggering when span clicked. + t.propagate(false); + }) + .observe(set_cursor_target_ui); + }) + .observe(set_cursor_target_ui); + + commands + .spawn(( + Text2d::new("I'm a Text2d"), + Transform::from_xyz(10., 10., 0.), + )) + .with_children(|cb| { + cb.spawn(TextSpan("And I'm a text span!".into())).observe( + |t: Trigger>| { + info!("Textmode clicked text2d span {:?}", t); + // t.propagate(false); + }, + ); + }) + .observe( + |t: Trigger>, + mut target: ResMut, + q: Query<(&Transform, &TextLayoutInfo)>| { + info!("Textmode clicked text2d {:?} ", t); + + let Ok((transform, tli)) = q.get(t.target()) else { + return; + }; + + const LINEHEIGHT: f32 = 12.0; + + let xoff = match t.cursor.affinity { + Affinity::Before => -t.glyph.size.x / 2.0, + Affinity::After => t.glyph.size.x / 2.0, + }; + + let xpos = transform.translation.x + t.glyph.position.x + xoff - tli.size.x / 2.; + let ypos = transform.translation.y + (t.cursor.line + 1) as f32 * LINEHEIGHT + - tli.size.y / 2.; + + target.0 = Vec3::new(xpos, ypos, 1.0); + }, + ) + .observe(|_t: Trigger>| info!("clicked text2d")); +} + +#[derive(Component)] +struct MyCursor; + +#[derive(Resource, Default)] +struct CursorTarget(pub Vec3); + +fn cursor_to_target(target: Res, mut q: Query<&mut Transform, With>) { + for mut t in &mut q { + t.translation = target.0; + } +} diff --git a/rustc-ice-2025-02-04T08_57_53-3790547.txt b/rustc-ice-2025-02-04T08_57_53-3790547.txt new file mode 100644 index 0000000000000..8fce2f0d6a372 --- /dev/null +++ b/rustc-ice-2025-02-04T08_57_53-3790547.txt @@ -0,0 +1,69 @@ +thread 'rustc' panicked at /rustc/bd53aa3bf7a24a70d763182303bd75e5fc51a9af/compiler/rustc_type_ir/src/search_graph/global_cache.rs:62:13: +assertion failed: prev.is_none() +stack backtrace: + 0: 0x78fcfbec01e5 - std::backtrace::Backtrace::create::h9beea7623d909a8c + 1: 0x78fcfa5c5595 - std::backtrace::Backtrace::force_capture::hd72ff652e10a8b6e + 2: 0x78fcf9741d17 - std[3955799ea844ceb3]::panicking::update_hook::>::{closure#0} + 3: 0x78fcfa5dc9b8 - std::panicking::rust_panic_with_hook::ha2e90e1b96f5d037 + 4: 0x78fcfa5dc753 - std::panicking::begin_panic_handler::{{closure}}::h6c4d132b2a37bbbf + 5: 0x78fcfa5da3c9 - std::sys::backtrace::__rust_end_short_backtrace::hc93ea4abeb2120e1 + 6: 0x78fcfa5dc454 - rust_begin_unwind + 7: 0x78fcf7487aa3 - core::panicking::panic_fmt::h88ea488a0d94e9ea + 8: 0x78fcf765fe2c - core::panicking::panic::hc0afb850dfc4f031 + 9: 0x78fcfbcc685a - , rustc_middle[84a3b9fea629b5f1]::ty::context::TyCtxt>>::insert_global_cache::{closure#0} + 10: 0x78fcfbcc582b - , rustc_middle[84a3b9fea629b5f1]::ty::context::TyCtxt>>::with_new_goal::<>::evaluate_canonical_goal::{closure#0}::{closure#0}::{closure#0}> + 11: 0x78fcfbcbf445 - >::evaluate_goal_raw + 12: 0x78fcfbcc2305 - >::try_evaluate_added_goals + 13: 0x78fcfbcc157d - >::evaluate_added_goals_and_make_canonical_response::{closure#0} + 14: 0x78fcfa3344b0 - >::probe_and_evaluate_goal_for_constituent_tys::> + 15: 0x78fcf8bd146f - >::compute_trait_goal + 16: 0x78fcfbccbbb3 - , rustc_middle[84a3b9fea629b5f1]::ty::context::TyCtxt>>::evaluate_goal_in_task::<&mut >::evaluate_canonical_goal::{closure#0}::{closure#0}::{closure#0}> + 17: 0x78fcfbcc4b70 - , rustc_middle[84a3b9fea629b5f1]::ty::context::TyCtxt>>::with_new_goal::<>::evaluate_canonical_goal::{closure#0}::{closure#0}::{closure#0}> + 18: 0x78fcfbcbf445 - >::evaluate_goal_raw + 19: 0x78fcfbcbe59a - ::evaluate_root_goal + 20: 0x78fcfbcbe170 - as rustc_infer[a1c80c431651667b]::traits::engine::TraitEngine>::select_where_possible + 21: 0x78fcfb6b2fa5 - rustc_trait_selection[6307f589a1799ac4]::error_reporting::traits::ambiguity::compute_applicable_impls_for_diagnostics::{closure#2} + 22: 0x78fcfb6b1f9b - rustc_trait_selection[6307f589a1799ac4]::error_reporting::traits::ambiguity::compute_applicable_impls_for_diagnostics + 23: 0x78fcfb16bbf2 - ::check_item + 24: 0x78fcfb16ecaa - ::check_item + 25: 0x78fcfb174597 - as rustc_hir[c840dc94259f5ffd]::intravisit::Visitor>::visit_nested_item + 26: 0x78fcfb133a75 - rustc_hir[c840dc94259f5ffd]::intravisit::walk_block::> + 27: 0x78fcfb17fe41 - as rustc_hir[c840dc94259f5ffd]::intravisit::Visitor>::visit_nested_body + 28: 0x78fcfb174b56 - as rustc_hir[c840dc94259f5ffd]::intravisit::Visitor>::visit_nested_item + 29: 0x78fcfb133a75 - rustc_hir[c840dc94259f5ffd]::intravisit::walk_block::> + 30: 0x78fcfb17fe41 - as rustc_hir[c840dc94259f5ffd]::intravisit::Visitor>::visit_nested_body + 31: 0x78fcfb174b56 - as rustc_hir[c840dc94259f5ffd]::intravisit::Visitor>::visit_nested_item + 32: 0x78fcfb173ee4 - rustc_lint[42a983de081c1aa7]::lint_mod + 33: 0x78fcfb173cdf - rustc_query_impl[762dd1939c7ece16]::plumbing::__rust_begin_short_backtrace::> + 34: 0x78fcfbbcdf8a - rustc_query_system[1704d0a4bf762c9]::query::plumbing::try_execute_query::>, false, false, false>, rustc_query_impl[762dd1939c7ece16]::plumbing::QueryCtxt, true> + 35: 0x78fcfbbcdad9 - rustc_query_impl[762dd1939c7ece16]::query_impl::lint_mod::get_query_incr::__rust_end_short_backtrace + 36: 0x78fcf9bf31c7 - rayon[3b7d8baf0c01111c]::iter::plumbing::bridge_producer_consumer::helper::, rayon[3b7d8baf0c01111c]::iter::for_each::ForEachConsumer::par_for_each_module::{closure#0}>::{closure#0}::{closure#0}>> + 37: 0x78fcf9bfc621 - rayon_core[d93ea486124cbf12]::join::join_context::, rayon[3b7d8baf0c01111c]::iter::for_each::ForEachConsumer::par_for_each_module::{closure#0}>::{closure#0}::{closure#0}>>::{closure#0}, rayon[3b7d8baf0c01111c]::iter::plumbing::bridge_producer_consumer::helper, rayon[3b7d8baf0c01111c]::iter::for_each::ForEachConsumer::par_for_each_module::{closure#0}>::{closure#0}::{closure#0}>>::{closure#1}, (), ()>::{closure#0} + 38: 0x78fcf9bf3379 - rayon[3b7d8baf0c01111c]::iter::plumbing::bridge_producer_consumer::helper::, rayon[3b7d8baf0c01111c]::iter::for_each::ForEachConsumer::par_for_each_module::{closure#0}>::{closure#0}::{closure#0}>> + 39: 0x78fcf9bfc621 - rayon_core[d93ea486124cbf12]::join::join_context::, rayon[3b7d8baf0c01111c]::iter::for_each::ForEachConsumer::par_for_each_module::{closure#0}>::{closure#0}::{closure#0}>>::{closure#0}, rayon[3b7d8baf0c01111c]::iter::plumbing::bridge_producer_consumer::helper, rayon[3b7d8baf0c01111c]::iter::for_each::ForEachConsumer::par_for_each_module::{closure#0}>::{closure#0}::{closure#0}>>::{closure#1}, (), ()>::{closure#0} + 40: 0x78fcf9bf3379 - rayon[3b7d8baf0c01111c]::iter::plumbing::bridge_producer_consumer::helper::, rayon[3b7d8baf0c01111c]::iter::for_each::ForEachConsumer::par_for_each_module::{closure#0}>::{closure#0}::{closure#0}>> + 41: 0x78fcf9bfc621 - rayon_core[d93ea486124cbf12]::join::join_context::, rayon[3b7d8baf0c01111c]::iter::for_each::ForEachConsumer::par_for_each_module::{closure#0}>::{closure#0}::{closure#0}>>::{closure#0}, rayon[3b7d8baf0c01111c]::iter::plumbing::bridge_producer_consumer::helper, rayon[3b7d8baf0c01111c]::iter::for_each::ForEachConsumer::par_for_each_module::{closure#0}>::{closure#0}::{closure#0}>>::{closure#1}, (), ()>::{closure#0} + 42: 0x78fcf9bf3379 - rayon[3b7d8baf0c01111c]::iter::plumbing::bridge_producer_consumer::helper::, rayon[3b7d8baf0c01111c]::iter::for_each::ForEachConsumer::par_for_each_module::{closure#0}>::{closure#0}::{closure#0}>> + 43: 0x78fcf9c04e48 - , rayon[3b7d8baf0c01111c]::iter::for_each::ForEachConsumer::par_for_each_module::{closure#0}>::{closure#0}::{closure#0}>>::{closure#1}>::{closure#0}, ()> as rayon_core[d93ea486124cbf12]::job::Job>::execute + 44: 0x78fcf92625be - ::wait_until_cold + 45: 0x78fcf9b81522 - rayon_core[d93ea486124cbf12]::join::join_context::, rayon[3b7d8baf0c01111c]::iter::for_each::ForEachConsumer::par_for_each_module::{closure#0}>::{closure#0}::{closure#0}>>::{closure#0}, rayon[3b7d8baf0c01111c]::iter::plumbing::bridge_producer_consumer::helper, rayon[3b7d8baf0c01111c]::iter::for_each::ForEachConsumer::par_for_each_module::{closure#0}>::{closure#0}::{closure#0}>>::{closure#1}, (), ()>::{closure#0} + 46: 0x78fcf9b61a09 - rayon[3b7d8baf0c01111c]::iter::plumbing::bridge_producer_consumer::helper::, rayon[3b7d8baf0c01111c]::iter::for_each::ForEachConsumer::par_for_each_module::{closure#0}>::{closure#0}::{closure#0}>> + 47: 0x78fcf9bac978 - , rayon[3b7d8baf0c01111c]::iter::for_each::ForEachConsumer::par_for_each_module::{closure#0}>::{closure#0}::{closure#0}>>::{closure#1}>::{closure#0}, ()> as rayon_core[d93ea486124cbf12]::job::Job>::execute + 48: 0x78fcf92625be - ::wait_until_cold + 49: 0x78fcf9260149 - ::run + 50: 0x78fcf97440e7 - <::spawn<::build_scoped, rustc_driver_impl[d99d56083ab92750]::run_compiler::{closure#0}>::{closure#1}, core[420b71e5fcfa1e50]::result::Result<(), rustc_span[28cede8597ee1bb4]::ErrorGuaranteed>>::{closure#3}::{closure#0}::{closure#0}, rustc_interface[518cb27c8fb52ec1]::util::run_in_thread_pool_with_globals, rustc_driver_impl[d99d56083ab92750]::run_compiler::{closure#0}>::{closure#1}, core[420b71e5fcfa1e50]::result::Result<(), rustc_span[28cede8597ee1bb4]::ErrorGuaranteed>>::{closure#3}::{closure#0}::{closure#1}, core[420b71e5fcfa1e50]::result::Result<(), rustc_span[28cede8597ee1bb4]::ErrorGuaranteed>>::{closure#0}::{closure#0}::{closure#0}, ()>::{closure#0} as core[420b71e5fcfa1e50]::ops::function::FnOnce<()>>::call_once::{shim:vtable#0} + 51: 0x78fcf97380fe - std[3955799ea844ceb3]::sys::backtrace::__rust_begin_short_backtrace:: + core[420b71e5fcfa1e50]::marker::Send>, ()> + 52: 0x78fcf9743d8a - <::spawn_unchecked_ + core[420b71e5fcfa1e50]::marker::Send>, ()>::{closure#1} as core[420b71e5fcfa1e50]::ops::function::FnOnce<()>>::call_once::{shim:vtable#0} + 53: 0x78fcfbc8fbab - std::sys::pal::unix::thread::Thread::new::thread_start::h4351cd95dc3be91c + 54: 0x78fcf60a339d - + 55: 0x78fcf612849c - + 56: 0x0 - + + +rustc version: 1.83.0-nightly (bd53aa3bf 2024-09-02) +platform: x86_64-unknown-linux-gnu + +query stack during panic: +#0 [lint_mod] linting module `lcha` +#1 [analysis] running analysis passes on this crate +end of query stack