From 083020f64f2442c942c96dfa2bc49b988dc00eb8 Mon Sep 17 00:00:00 2001 From: sam edelsten Date: Wed, 5 Feb 2025 19:31:25 +0000 Subject: [PATCH 01/14] UI text picking, piggybacks UI picking --- Cargo.toml | 13 ++ crates/bevy_text/src/glyph.rs | 24 +--- crates/bevy_text/src/pipeline.rs | 153 +++++++++++---------- crates/bevy_ui/src/lib.rs | 8 +- crates/bevy_ui/src/text_picking_backend.rs | 59 ++++++++ examples/picking/text_picking.rs | 34 +++++ 6 files changed, 201 insertions(+), 90 deletions(-) create mode 100644 crates/bevy_ui/src/text_picking_backend.rs create mode 100644 examples/picking/text_picking.rs diff --git a/Cargo.toml b/Cargo.toml index 8c2543542b6ae..73984421dc628 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3954,6 +3954,19 @@ category = "Picking" wasm = true required-features = ["bevy_sprite_picking_backend"] +[[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/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/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_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index a5c0313e3453b..75f910b016788 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -17,8 +17,11 @@ 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_picking::events::Click; use bevy_reflect::{std_traits::ReflectDefault, Reflect}; mod accessibility; // This module is not re-exported, but is instead made public. @@ -36,6 +39,7 @@ pub use geometry::*; pub use layout::*; pub use measurement::*; pub use render::*; +use text_picking_backend::{get_and_emit_text_hits, TextPointer}; pub use ui_material::*; pub use ui_node::*; @@ -180,7 +184,9 @@ impl Plugin for UiPlugin { .add_systems( PreUpdate, ui_focus_system.in_set(UiSystem::Focus).after(InputSystem), - ); + ) + .add_event::>() + .add_observer(get_and_emit_text_hits::); let ui_layout_system_config = ui_layout_system .in_set(UiSystem::Layout) 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..72f82c294d08c --- /dev/null +++ b/crates/bevy_ui/src/text_picking_backend.rs @@ -0,0 +1,59 @@ +use bevy_app::App; +use bevy_ecs::{ + event::Event, + observer::Trigger, + system::{Commands, Query}, +}; +use bevy_math::Rect; +use bevy_picking::events::{Click, Pointer}; +use bevy_reflect::Reflect; +use bevy_text::{PositionedGlyph, TextLayoutInfo}; + +use crate::{ComputedNode, RelativeCursorPosition}; + +#[derive(Event, Debug, Clone, Reflect)] +pub struct TextPointer { + positioned_glyph: PositionedGlyph, + event: Pointer, +} + +/// Takes UI pointer hits and re-emits them as `TextPointer` triggers. +pub(crate) fn get_and_emit_text_hits( + trigger: Trigger>, + q: Query<(&ComputedNode, &TextLayoutInfo, &RelativeCursorPosition)>, + mut commands: Commands, +) { + if q.get(trigger.target()).is_err() { + return; + } + // Get click position relative to node + let (c_node, text, pos) = q + .get(trigger.target()) + .expect("missing required component(s)"); + + let Some(hit_pos) = pos.normalized else { + return; + }; + + let Some(positioned_glyph) = text.glyphs.iter().find_map(|g| { + // TODO: fullheight rects, use g.position and c_node tings + // TODO: spaces, fill from previous rect to next rect... somehow + let rect = Rect::from_corners(g.position - g.size / 2.0, g.position + g.size / 2.0); + + if rect.contains(hit_pos * c_node.size()) { + Some(g) + } else { + None + } + }) else { + return; + }; + + commands.trigger_targets( + TextPointer:: { + positioned_glyph: positioned_glyph.clone(), + event: trigger.event().clone(), + }, + trigger.target(), + ); +} diff --git a/examples/picking/text_picking.rs b/examples/picking/text_picking.rs new file mode 100644 index 0000000000000..7f084da1d4ec6 --- /dev/null +++ b/examples/picking/text_picking.rs @@ -0,0 +1,34 @@ +//! Demo picking text. + +use bevy::{ + prelude::*, + ui::{text_picking_backend::TextPointer, RelativeCursorPosition}, +}; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, (setup,)) + .run(); +} + +fn setup(mut commands: Commands) { + commands.spawn(Camera2d); + 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| { + cb.spawn(TextSpan::new( + "i'm a new span\n●●●●i'm the same span...\n····", + )); + }) + .observe(|t: Trigger>| info!("{:?}", t)); +} From 86afc5eaa693536c306a9aa1c2807efb013cd463 Mon Sep 17 00:00:00 2001 From: sam edelsten Date: Thu, 6 Feb 2025 11:36:27 +0000 Subject: [PATCH 02/14] use cosmic `Cursor` for text picks --- crates/bevy_text/src/text.rs | 7 +++++ crates/bevy_ui/src/text_picking_backend.rs | 31 ++++++++-------------- examples/picking/text_picking.rs | 24 ++++++++++++++++- 3 files changed, 41 insertions(+), 21 deletions(-) diff --git a/crates/bevy_text/src/text.rs b/crates/bevy_text/src/text.rs index f564a3078a7bb..54a23a77379c4 100644 --- a/crates/bevy_text/src/text.rs +++ b/crates/bevy_text/src/text.rs @@ -79,6 +79,13 @@ impl ComputedTextBlock { &self.entities } + /// Read only access to the internal buffer. + /// + /// Used for text picking. + pub fn buffer(&self) -> &CosmicBuffer { + &self.buffer + } + /// Indicates if the text needs to be refreshed in [`TextLayoutInfo`]. /// /// Updated automatically by [`detect_text_needs_rerender`] and cleared diff --git a/crates/bevy_ui/src/text_picking_backend.rs b/crates/bevy_ui/src/text_picking_backend.rs index 72f82c294d08c..dc434a00ed1d4 100644 --- a/crates/bevy_ui/src/text_picking_backend.rs +++ b/crates/bevy_ui/src/text_picking_backend.rs @@ -1,33 +1,31 @@ -use bevy_app::App; use bevy_ecs::{ event::Event, observer::Trigger, system::{Commands, Query}, }; -use bevy_math::Rect; -use bevy_picking::events::{Click, Pointer}; +use bevy_picking::events::Pointer; use bevy_reflect::Reflect; -use bevy_text::{PositionedGlyph, TextLayoutInfo}; +use bevy_text::{cosmic_text::Cursor, ComputedTextBlock}; use crate::{ComputedNode, RelativeCursorPosition}; -#[derive(Event, Debug, Clone, Reflect)] +#[derive(Event, Debug, Clone)] pub struct TextPointer { - positioned_glyph: PositionedGlyph, - event: Pointer, + pub cursor: Cursor, + pub event: Pointer, } /// Takes UI pointer hits and re-emits them as `TextPointer` triggers. pub(crate) fn get_and_emit_text_hits( trigger: Trigger>, - q: Query<(&ComputedNode, &TextLayoutInfo, &RelativeCursorPosition)>, + q: Query<(&ComputedNode, &RelativeCursorPosition, &ComputedTextBlock)>, mut commands: Commands, ) { if q.get(trigger.target()).is_err() { return; } // Get click position relative to node - let (c_node, text, pos) = q + let (c_node, pos, c_text) = q .get(trigger.target()) .expect("missing required component(s)"); @@ -35,23 +33,16 @@ pub(crate) fn get_and_emit_text_hits( return; }; - let Some(positioned_glyph) = text.glyphs.iter().find_map(|g| { - // TODO: fullheight rects, use g.position and c_node tings - // TODO: spaces, fill from previous rect to next rect... somehow - let rect = Rect::from_corners(g.position - g.size / 2.0, g.position + g.size / 2.0); + let physical_pos = hit_pos * c_node.size; - if rect.contains(hit_pos * c_node.size()) { - Some(g) - } else { - None - } - }) else { + let Some(cursor) = c_text.buffer().hit(physical_pos.x, physical_pos.y) else { return; }; + // TODO: trigger targeted span entities, might need to have PositionedGlyph at this point? commands.trigger_targets( TextPointer:: { - positioned_glyph: positioned_glyph.clone(), + cursor, event: trigger.event().clone(), }, trigger.target(), diff --git a/examples/picking/text_picking.rs b/examples/picking/text_picking.rs index 7f084da1d4ec6..9b645ee405085 100644 --- a/examples/picking/text_picking.rs +++ b/examples/picking/text_picking.rs @@ -2,6 +2,7 @@ use bevy::{ prelude::*, + text::TextLayoutInfo, ui::{text_picking_backend::TextPointer, RelativeCursorPosition}, }; @@ -26,9 +27,30 @@ fn setup(mut commands: Commands) { 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(|t: Trigger>| info!("{:?}", t)); + .observe( + |t: Trigger>, texts: Query<&TextLayoutInfo>| { + // Observer to get the `PositionedGlyph` at the `Cursor` position. + let text = texts + .get(t.target()) + .expect("no TLI? This should be unreachable."); + + let Some(positioned_glyph) = text + .glyphs + .iter() + .find(|g| g.byte_index == t.cursor.index && g.line_index == t.cursor.line) + else { + return; + }; + + info!("found positioned glyph from cursor {:?}", positioned_glyph); + + // TODO: Visualize a cursor on click. + }, + ); } From 4e798ac7038c1d26e171d24bd9cd3c8841260d75 Mon Sep 17 00:00:00 2001 From: sam edelsten Date: Mon, 10 Feb 2025 13:10:23 +0000 Subject: [PATCH 03/14] span-specific observer triggering --- crates/bevy_ui/src/lib.rs | 7 +-- crates/bevy_ui/src/text_picking_backend.rs | 56 +++++++++++++++++----- examples/picking/text_picking.rs | 30 ++++-------- 3 files changed, 54 insertions(+), 39 deletions(-) diff --git a/crates/bevy_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index 75f910b016788..ae6f21249f3a9 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -21,7 +21,6 @@ pub mod picking_backend; pub mod text_picking_backend; use bevy_derive::{Deref, DerefMut}; -use bevy_picking::events::Click; use bevy_reflect::{std_traits::ReflectDefault, Reflect}; mod accessibility; // This module is not re-exported, but is instead made public. @@ -39,7 +38,6 @@ pub use geometry::*; pub use layout::*; pub use measurement::*; pub use render::*; -use text_picking_backend::{get_and_emit_text_hits, TextPointer}; pub use ui_material::*; pub use ui_node::*; @@ -184,9 +182,7 @@ impl Plugin for UiPlugin { .add_systems( PreUpdate, ui_focus_system.in_set(UiSystem::Focus).after(InputSystem), - ) - .add_event::>() - .add_observer(get_and_emit_text_hits::); + ); let ui_layout_system_config = ui_layout_system .in_set(UiSystem::Layout) @@ -224,6 +220,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 index dc434a00ed1d4..e564a3401f85a 100644 --- a/crates/bevy_ui/src/text_picking_backend.rs +++ b/crates/bevy_ui/src/text_picking_backend.rs @@ -1,14 +1,20 @@ +use bevy_app::App; use bevy_ecs::{ event::Event, observer::Trigger, system::{Commands, Query}, }; -use bevy_picking::events::Pointer; +use bevy_picking::events::{Click, Pointer}; use bevy_reflect::Reflect; -use bevy_text::{cosmic_text::Cursor, ComputedTextBlock}; +use bevy_text::{cosmic_text::Cursor, ComputedTextBlock, TextLayoutInfo}; use crate::{ComputedNode, RelativeCursorPosition}; +pub(crate) fn plugin(app: &mut App) { + app.add_event::>(); + app.add_observer(get_and_emit_text_hits::); +} + #[derive(Event, Debug, Clone)] pub struct TextPointer { pub cursor: Cursor, @@ -18,14 +24,19 @@ pub struct TextPointer { /// Takes UI pointer hits and re-emits them as `TextPointer` triggers. pub(crate) fn get_and_emit_text_hits( trigger: Trigger>, - q: Query<(&ComputedNode, &RelativeCursorPosition, &ComputedTextBlock)>, + q: Query<( + &ComputedNode, + &RelativeCursorPosition, + &ComputedTextBlock, + &TextLayoutInfo, + )>, mut commands: Commands, ) { if q.get(trigger.target()).is_err() { return; } // Get click position relative to node - let (c_node, pos, c_text) = q + let (c_node, pos, c_text, text_layout) = q .get(trigger.target()) .expect("missing required component(s)"); @@ -39,12 +50,33 @@ pub(crate) fn get_and_emit_text_hits( return; }; - // TODO: trigger targeted span entities, might need to have PositionedGlyph at this point? - commands.trigger_targets( - TextPointer:: { - cursor, - event: trigger.event().clone(), - }, - trigger.target(), - ); + // 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]; + + // TODO: consider sending the `PositionedGlyph` along with the event + let text_pointer = TextPointer:: { + cursor, + event: trigger.event().clone(), + }; + + commands.trigger_targets(text_pointer.clone(), target_span.entity); + + // If span == 0, this event was sent already, so skip. This second dispatch means that an + // observer only added to the root text entity still triggers when child spans are interacted + // with. + // TODO: i think event propagation could be useful here? + if positioned_glyph.span_index != 0 { + commands.trigger_targets(text_pointer.clone(), trigger.target()); + } } diff --git a/examples/picking/text_picking.rs b/examples/picking/text_picking.rs index 9b645ee405085..2560234bc8a2d 100644 --- a/examples/picking/text_picking.rs +++ b/examples/picking/text_picking.rs @@ -2,7 +2,6 @@ use bevy::{ prelude::*, - text::TextLayoutInfo, ui::{text_picking_backend::TextPointer, RelativeCursorPosition}, }; @@ -31,26 +30,13 @@ fn setup(mut commands: Commands) { // subset. cb.spawn(TextSpan::new( "i'm a new span\n●●●●i'm the same span...\n····", - )); + )) + .observe(|t: Trigger>| { + info!("Span specific observer clicked! {:?}", t); + }); }) - .observe( - |t: Trigger>, texts: Query<&TextLayoutInfo>| { - // Observer to get the `PositionedGlyph` at the `Cursor` position. - let text = texts - .get(t.target()) - .expect("no TLI? This should be unreachable."); - - let Some(positioned_glyph) = text - .glyphs - .iter() - .find(|g| g.byte_index == t.cursor.index && g.line_index == t.cursor.line) - else { - return; - }; - - info!("found positioned glyph from cursor {:?}", positioned_glyph); - - // TODO: Visualize a cursor on click. - }, - ); + .observe(|t: Trigger>| { + info!("Root observer clicked! {:?}", t); + // TODO: Visualize a cursor on click. + }); } From 137a4ca195e945d13a4d6875045313a1b107847b Mon Sep 17 00:00:00 2001 From: sam edelsten Date: Mon, 10 Feb 2025 13:22:55 +0000 Subject: [PATCH 04/14] add other pointer events --- crates/bevy_ui/src/text_picking_backend.rs | 36 ++++++++++++++++++++-- examples/picking/text_picking.rs | 3 ++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/bevy_ui/src/text_picking_backend.rs b/crates/bevy_ui/src/text_picking_backend.rs index e564a3401f85a..40d802df03bf4 100644 --- a/crates/bevy_ui/src/text_picking_backend.rs +++ b/crates/bevy_ui/src/text_picking_backend.rs @@ -4,15 +4,45 @@ use bevy_ecs::{ observer::Trigger, system::{Commands, Query}, }; -use bevy_picking::events::{Click, Pointer}; +use bevy_picking::events::{ + Cancel, Click, Drag, DragDrop, DragEnd, DragEnter, DragLeave, DragOver, DragStart, Move, Out, + Over, Pointer, Pressed, Released, +}; use bevy_reflect::Reflect; use bevy_text::{cosmic_text::Cursor, ComputedTextBlock, TextLayoutInfo}; use crate::{ComputedNode, RelativeCursorPosition}; pub(crate) fn plugin(app: &mut App) { - app.add_event::>(); - app.add_observer(get_and_emit_text_hits::); + 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::>(); + + 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::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::); } #[derive(Event, Debug, Clone)] diff --git a/examples/picking/text_picking.rs b/examples/picking/text_picking.rs index 2560234bc8a2d..edc1c37c2ef8b 100644 --- a/examples/picking/text_picking.rs +++ b/examples/picking/text_picking.rs @@ -33,6 +33,9 @@ fn setup(mut commands: Commands) { )) .observe(|t: Trigger>| { info!("Span specific observer clicked! {:?}", t); + }) + .observe(|t: Trigger>| { + info!("Span specific observer released! {:?}", t); }); }) .observe(|t: Trigger>| { From 24e0da4314b5196b5579911a2322bd37754b250a Mon Sep 17 00:00:00 2001 From: sam edelsten Date: Mon, 10 Feb 2025 17:21:49 +0000 Subject: [PATCH 05/14] add `Traversal` to `TextPointer` --- crates/bevy_ui/src/text_picking_backend.rs | 74 +++++++++++++++++----- examples/picking/text_picking.rs | 15 +++-- 2 files changed, 68 insertions(+), 21 deletions(-) diff --git a/crates/bevy_ui/src/text_picking_backend.rs b/crates/bevy_ui/src/text_picking_backend.rs index 40d802df03bf4..643adbc1f9573 100644 --- a/crates/bevy_ui/src/text_picking_backend.rs +++ b/crates/bevy_ui/src/text_picking_backend.rs @@ -1,15 +1,21 @@ use bevy_app::App; use bevy_ecs::{ + entity::{Entity, EntityBorrow}, event::Event, + hierarchy::ChildOf, observer::Trigger, + query::QueryData, system::{Commands, Query}, + traversal::Traversal, }; use bevy_picking::events::{ Cancel, Click, Drag, DragDrop, DragEnd, DragEnter, DragLeave, DragOver, DragStart, Move, Out, Over, Pointer, Pressed, Released, }; use bevy_reflect::Reflect; -use bevy_text::{cosmic_text::Cursor, ComputedTextBlock, TextLayoutInfo}; +use bevy_render::camera::NormalizedRenderTarget; +use bevy_text::{cosmic_text::Cursor, ComputedTextBlock, PositionedGlyph, TextLayoutInfo}; +use bevy_window::Window; use crate::{ComputedNode, RelativeCursorPosition}; @@ -45,12 +51,56 @@ pub(crate) fn plugin(app: &mut App) { .add_observer(get_and_emit_text_hits::); } -#[derive(Event, Debug, Clone)] +#[derive(Debug, Clone)] pub struct TextPointer { pub cursor: Cursor, + pub glyph: PositionedGlyph, pub event: Pointer, } +impl Event for TextPointer +where + E: Clone + Reflect + std::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: std::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 + } +} + /// Takes UI pointer hits and re-emits them as `TextPointer` triggers. pub(crate) fn get_and_emit_text_hits( trigger: Trigger>, @@ -62,13 +112,10 @@ pub(crate) fn get_and_emit_text_hits( )>, mut commands: Commands, ) { - if q.get(trigger.target()).is_err() { - return; - } // Get click position relative to node - let (c_node, pos, c_text, text_layout) = q - .get(trigger.target()) - .expect("missing required component(s)"); + let Ok((c_node, pos, c_text, text_layout)) = q.get(trigger.target()) else { + return; + }; let Some(hit_pos) = pos.normalized else { return; @@ -94,19 +141,12 @@ pub(crate) fn get_and_emit_text_hits( // Get span entity let target_span = c_text.entities()[positioned_glyph.span_index]; - // TODO: consider sending the `PositionedGlyph` along with the event 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); - - // If span == 0, this event was sent already, so skip. This second dispatch means that an - // observer only added to the root text entity still triggers when child spans are interacted - // with. - // TODO: i think event propagation could be useful here? - if positioned_glyph.span_index != 0 { - commands.trigger_targets(text_pointer.clone(), trigger.target()); - } } diff --git a/examples/picking/text_picking.rs b/examples/picking/text_picking.rs index edc1c37c2ef8b..f2aaa6ca90d62 100644 --- a/examples/picking/text_picking.rs +++ b/examples/picking/text_picking.rs @@ -2,7 +2,10 @@ use bevy::{ prelude::*, - ui::{text_picking_backend::TextPointer, RelativeCursorPosition}, + ui::{ + text_picking_backend::{GlyphPointer, TextPointer}, + RelativeCursorPosition, + }, }; fn main() { @@ -31,11 +34,15 @@ fn setup(mut commands: Commands) { cb.spawn(TextSpan::new( "i'm a new span\n●●●●i'm the same span...\n····", )) - .observe(|t: Trigger>| { + .observe(|mut t: Trigger>| { info!("Span specific observer clicked! {:?}", t); + t.propagate(false); }) - .observe(|t: Trigger>| { - info!("Span specific observer released! {:?}", t); + .observe(|t: Trigger>| { + info!("Span specific glyph observer released! {:?}", t); + }) + .observe(|t: Trigger>| { + info!("\nGot the thing {:?}\n", t); }); }) .observe(|t: Trigger>| { From 6e5b269a5eff3a4b5da712d4f90fe0f57ada68fe Mon Sep 17 00:00:00 2001 From: sam edelsten Date: Mon, 10 Feb 2025 17:25:23 +0000 Subject: [PATCH 06/14] run template generator --- examples/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/README.md b/examples/README.md index 9ff424a1d21ea..e41c055be3d9d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -393,6 +393,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 From 222fa70285b890a9212fd385fbbd555bc0ad997d Mon Sep 17 00:00:00 2001 From: sam edelsten Date: Tue, 11 Feb 2025 10:30:53 +0000 Subject: [PATCH 07/14] start backend work for text2d --- crates/bevy_text/Cargo.toml | 2 + crates/bevy_text/src/lib.rs | 9 ++ crates/bevy_text/src/picking_backend.rs | 163 +++++++++++++++++++++ crates/bevy_text/src/text_pointer.rs | 85 +++++++++++ crates/bevy_ui/Cargo.toml | 2 +- crates/bevy_ui/src/text_picking_backend.rs | 75 +--------- examples/picking/text_picking.rs | 28 ++-- rustc-ice-2025-02-04T08_57_53-3790547.txt | 69 +++++++++ 8 files changed, 345 insertions(+), 88 deletions(-) create mode 100644 crates/bevy_text/src/picking_backend.rs create mode 100644 crates/bevy_text/src/text_pointer.rs create mode 100644 rustc-ice-2025-02-04T08_57_53-3790547.txt diff --git a/crates/bevy_text/Cargo.toml b/crates/bevy_text/Cargo.toml index a5e8dea07db51..a86b5e3646a42 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", ] } diff --git a/crates/bevy_text/src/lib.rs b/crates/bevy_text/src/lib.rs index 7f71f486e4eb6..9eec84c961533 100644 --- a/crates/bevy_text/src/lib.rs +++ b/crates/bevy_text/src/lib.rs @@ -38,10 +38,14 @@ 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_pointer; pub use bounds::*; pub use error::*; @@ -154,5 +158,10 @@ 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, picking_backend::plugin)); + } } } diff --git a/crates/bevy_text/src/picking_backend.rs b/crates/bevy_text/src/picking_backend.rs new file mode 100644 index 0000000000000..cc09c5787f1f7 --- /dev/null +++ b/crates/bevy_text/src/picking_backend.rs @@ -0,0 +1,163 @@ +use bevy_app::{App, PreUpdate}; +use bevy_ecs::{ + entity::Entity, + event::EventWriter, + observer::Trigger, + query::With, + schedule::IntoSystemConfigs, + system::{Commands, Query}, +}; +use bevy_math::{FloatExt, Vec3Swizzles}; +use bevy_picking::{ + backend::{HitData, PointerHits}, + events::{ + Cancel, Click, Drag, DragDrop, DragEnd, DragEnter, DragLeave, DragOver, DragStart, Move, + Out, Over, Pointer, Pressed, Released, + }, + pointer::{PointerId, PointerLocation}, + PickSet, +}; +use bevy_reflect::Reflect; +use bevy_render::camera::{Camera, Projection}; +use bevy_sprite::Anchor; +use bevy_transform::components::{GlobalTransform, Transform}; +use bevy_window::PrimaryWindow; +use tracing::info; + +use crate::{ComputedTextBlock, 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::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::); + + app.add_systems(PreUpdate, text2d_picking.in_set(PickSet::Backend)); +} + +pub(crate) fn get_and_emit_text_hits( + trigger: Trigger>, + q: Query<(&ComputedTextBlock, &TextLayoutInfo, &Anchor, &TextBounds)>, + mut commands: Commands, +) { + let Ok((c_text, text_layout, anchor, bounds)) = q.get(trigger.target) else { + return; + }; +} + +fn text2d_picking( + pointers: Query<(&PointerId, &PointerLocation)>, + primary_window: Query>, + cameras: Query<(Entity, &Camera, &GlobalTransform, &Projection)>, + text_query: Query<( + Entity, + &TextLayoutInfo, + &ComputedTextBlock, + &Anchor, + &TextBounds, + &GlobalTransform, + )>, + mut output: EventWriter, +) { + 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, _, _)| { + // TODO: marker reqs + // let marker_requirement = !settings.require_markers || *cam_can_pick; + // camera.is_active && marker_requirement + true + }) + .find(|(_, camera, _, _)| { + camera + .target + .normalize(primary_window) + .is_some_and(|x| x == location.target) + }) + else { + continue; + }; + + let viewport_pos = camera + .logical_viewport_rect() + .map(|v| v.min) + .unwrap_or_default(); + let pos_in_viewport = location.position - viewport_pos; + + let Ok(cursor_ray_world) = camera.viewport_to_world(cam_transform, pos_in_viewport) else { + continue; + }; + let cursor_ray_len = cam_ortho.far - cam_ortho.near; + let cursor_ray_end = cursor_ray_world.origin + cursor_ray_world.direction * cursor_ray_len; + + let picks: Vec<(Entity, HitData)> = text_query + .iter() + .filter_map(|(entity, a, b, c, d, text_transform)| { + // + // Transform cursor line segment to text coordinate system + let world_to_text = text_transform.affine().inverse(); + let cursor_start_text = world_to_text.transform_point3(cursor_ray_world.origin); + let cursor_end_text = world_to_text.transform_point3(cursor_ray_end); + + // Find where the cursor segment intersects the plane Z=0 (which is the sprite's + // plane in sprite-local space). It may not intersect if, for example, we're + // viewing the sprite side-on + if cursor_start_text.z == cursor_end_text.z { + // Cursor ray is parallel to the sprite and misses it + return None; + } + let lerp_factor = f32::inverse_lerp(cursor_start_text.z, cursor_end_text.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 sprite, the sprite 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 sprite space! + let relative_cursor_pos = cursor_start_text.lerp(cursor_end_text, lerp_factor).xy(); + + // TODO: Find target rect, check cursor is contained inside + + 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)); + } +} diff --git a/crates/bevy_text/src/text_pointer.rs b/crates/bevy_text/src/text_pointer.rs new file mode 100644 index 0000000000000..b267b6189aa83 --- /dev/null +++ b/crates/bevy_text/src/text_pointer.rs @@ -0,0 +1,85 @@ +use bevy_app::App; +use bevy_ecs::{ + entity::{Entity, EntityBorrow}, + event::Event, + hierarchy::ChildOf, + query::QueryData, + traversal::Traversal, +}; +use bevy_picking::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::>(); +} + +#[derive(Debug, Clone)] +pub struct TextPointer { + pub cursor: Cursor, + pub glyph: PositionedGlyph, + pub event: Pointer, +} + +impl Event for TextPointer +where + E: Clone + Reflect + std::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: std::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 + } +} 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/text_picking_backend.rs b/crates/bevy_ui/src/text_picking_backend.rs index 643adbc1f9573..976e7c02d74c4 100644 --- a/crates/bevy_ui/src/text_picking_backend.rs +++ b/crates/bevy_ui/src/text_picking_backend.rs @@ -1,40 +1,18 @@ use bevy_app::App; use bevy_ecs::{ - entity::{Entity, EntityBorrow}, - event::Event, - hierarchy::ChildOf, observer::Trigger, - query::QueryData, system::{Commands, Query}, - traversal::Traversal, }; use bevy_picking::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_text::{cosmic_text::Cursor, ComputedTextBlock, PositionedGlyph, TextLayoutInfo}; -use bevy_window::Window; +use bevy_text::{text_pointer::TextPointer, ComputedTextBlock, TextLayoutInfo}; use crate::{ComputedNode, RelativeCursorPosition}; 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::>(); - app.add_observer(get_and_emit_text_hits::) .add_observer(get_and_emit_text_hits::) .add_observer(get_and_emit_text_hits::) @@ -50,57 +28,6 @@ pub(crate) fn plugin(app: &mut App) { .add_observer(get_and_emit_text_hits::) .add_observer(get_and_emit_text_hits::); } - -#[derive(Debug, Clone)] -pub struct TextPointer { - pub cursor: Cursor, - pub glyph: PositionedGlyph, - pub event: Pointer, -} - -impl Event for TextPointer -where - E: Clone + Reflect + std::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: std::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 - } -} - /// Takes UI pointer hits and re-emits them as `TextPointer` triggers. pub(crate) fn get_and_emit_text_hits( trigger: Trigger>, diff --git a/examples/picking/text_picking.rs b/examples/picking/text_picking.rs index f2aaa6ca90d62..8d9c6285a4d82 100644 --- a/examples/picking/text_picking.rs +++ b/examples/picking/text_picking.rs @@ -1,12 +1,6 @@ //! Demo picking text. -use bevy::{ - prelude::*, - ui::{ - text_picking_backend::{GlyphPointer, TextPointer}, - RelativeCursorPosition, - }, -}; +use bevy::{prelude::*, text::text_pointer::TextPointer, ui::RelativeCursorPosition}; fn main() { App::new() @@ -37,16 +31,24 @@ fn setup(mut commands: Commands) { .observe(|mut t: Trigger>| { info!("Span specific observer clicked! {:?}", t); t.propagate(false); - }) - .observe(|t: Trigger>| { - info!("Span specific glyph observer released! {:?}", t); - }) - .observe(|t: Trigger>| { - info!("\nGot the thing {:?}\n", t); }); }) .observe(|t: Trigger>| { info!("Root observer clicked! {:?}", t); // TODO: Visualize a cursor on click. }); + + commands + .spawn((Text2d::new("I'm a Text2d"),)) + .with_children(|cb| { + cb.spawn(TextSpan("And I'm a text span!".into())).observe( + |_: Trigger>| { + info!("text2d span click"); + }, + ); + }) + .observe(|_t: Trigger>| { + info!("Textmode clicked text2d"); + }) + .observe(|_t: Trigger>| info!("clicked text2d")); } 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 From 8bc3f21f30766977688bc1a0dd4a7a47d43fa778 Mon Sep 17 00:00:00 2001 From: sam edelsten Date: Tue, 11 Feb 2025 18:55:28 +0000 Subject: [PATCH 08/14] restrict picking to text2d bounds --- crates/bevy_text/src/picking_backend.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/bevy_text/src/picking_backend.rs b/crates/bevy_text/src/picking_backend.rs index cc09c5787f1f7..da2edee7339e9 100644 --- a/crates/bevy_text/src/picking_backend.rs +++ b/crates/bevy_text/src/picking_backend.rs @@ -7,7 +7,7 @@ use bevy_ecs::{ schedule::IntoSystemConfigs, system::{Commands, Query}, }; -use bevy_math::{FloatExt, Vec3Swizzles}; +use bevy_math::{FloatExt, Rect, Vec2, Vec3Swizzles}; use bevy_picking::{ backend::{HitData, PointerHits}, events::{ @@ -74,6 +74,7 @@ fn text2d_picking( for (pointer, location) in pointers.iter().filter_map(|(pointer, pointer_location)| { pointer_location.location().map(|loc| (pointer, loc)) }) { + // TODO: blocking let mut blocked = false; let Some((cam_entity, camera, cam_transform, Projection::Orthographic(cam_ortho))) = cameras @@ -106,9 +107,10 @@ fn text2d_picking( let cursor_ray_len = cam_ortho.far - cam_ortho.near; let cursor_ray_end = cursor_ray_world.origin + cursor_ray_world.direction * cursor_ray_len; + // TODO: sort by Z let picks: Vec<(Entity, HitData)> = text_query .iter() - .filter_map(|(entity, a, b, c, d, text_transform)| { + .filter_map(|(entity, text_layout, b, c, text_bounds, text_transform)| { // // Transform cursor line segment to text coordinate system let world_to_text = text_transform.affine().inverse(); @@ -134,7 +136,16 @@ fn text2d_picking( // lerp factor to get the cursor position in sprite space! let relative_cursor_pos = cursor_start_text.lerp(cursor_end_text, lerp_factor).xy(); - // TODO: Find target rect, check cursor is contained inside + // 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; + } 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 From 99e3790e5a8459107fc44d54c193a7c7fa40f85f Mon Sep 17 00:00:00 2001 From: sam edelsten Date: Tue, 11 Feb 2025 21:04:59 +0000 Subject: [PATCH 09/14] finish stealing sprite picking for text2d --- crates/bevy_text/Cargo.toml | 1 + crates/bevy_text/src/lib.rs | 8 +- crates/bevy_text/src/picking_backend.rs | 252 ++++++++++--------- crates/bevy_text/src/text_picking_backend.rs | 40 +++ 4 files changed, 186 insertions(+), 115 deletions(-) create mode 100644 crates/bevy_text/src/text_picking_backend.rs diff --git a/crates/bevy_text/Cargo.toml b/crates/bevy_text/Cargo.toml index a86b5e3646a42..fb546ecb8ff51 100644 --- a/crates/bevy_text/Cargo.toml +++ b/crates/bevy_text/Cargo.toml @@ -44,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/lib.rs b/crates/bevy_text/src/lib.rs index 9eec84c961533..650ba4d7857c8 100644 --- a/crates/bevy_text/src/lib.rs +++ b/crates/bevy_text/src/lib.rs @@ -45,6 +45,8 @@ 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::*; @@ -161,7 +163,11 @@ impl Plugin for TextPlugin { #[cfg(feature = "picking")] { - app.add_plugins((text_pointer::plugin, picking_backend::plugin)); + 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 index da2edee7339e9..f0c9ff8e4bf25 100644 --- a/crates/bevy_text/src/picking_backend.rs +++ b/crates/bevy_text/src/picking_backend.rs @@ -1,91 +1,105 @@ -use bevy_app::{App, PreUpdate}; +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, - observer::Trigger, - query::With, - schedule::IntoSystemConfigs, - system::{Commands, Query}, + entity::Entity, event::EventWriter, query::With, resource::Resource, + schedule::IntoSystemConfigs, system::Query, }; use bevy_math::{FloatExt, Rect, Vec2, Vec3Swizzles}; +use bevy_picking::Pickable; use bevy_picking::{ backend::{HitData, PointerHits}, - events::{ - Cancel, Click, Drag, DragDrop, DragEnd, DragEnter, DragLeave, DragOver, DragStart, Move, - Out, Over, Pointer, Pressed, Released, - }, pointer::{PointerId, PointerLocation}, PickSet, }; +use bevy_reflect::prelude::ReflectDefault; use bevy_reflect::Reflect; use bevy_render::camera::{Camera, Projection}; -use bevy_sprite::Anchor; -use bevy_transform::components::{GlobalTransform, Transform}; +use bevy_render::view::ViewVisibility; +use bevy_transform::components::GlobalTransform; use bevy_window::PrimaryWindow; -use tracing::info; - -use crate::{ComputedTextBlock, 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::) - .add_observer(get_and_emit_text_hits::) - .add_observer(get_and_emit_text_hits::); - - app.add_systems(PreUpdate, text2d_picking.in_set(PickSet::Backend)); + +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(crate) fn get_and_emit_text_hits( - trigger: Trigger>, - q: Query<(&ComputedTextBlock, &TextLayoutInfo, &Anchor, &TextBounds)>, - mut commands: Commands, -) { - let Ok((c_text, text_layout, anchor, bounds)) = q.get(trigger.target) else { - return; - }; +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)>, - text_query: Query<( + cameras: Query<( Entity, - &TextLayoutInfo, - &ComputedTextBlock, - &Anchor, - &TextBounds, + &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)) }) { - // TODO: blocking let mut blocked = false; - let Some((cam_entity, camera, cam_transform, Projection::Orthographic(cam_ortho))) = + let Some((cam_entity, camera, cam_transform, Projection::Orthographic(cam_ortho), _)) = cameras .iter() - .filter(|(_, camera, _, _)| { - // TODO: marker reqs - // let marker_requirement = !settings.require_markers || *cam_can_pick; - // camera.is_active && marker_requirement - true + .filter(|(_, camera, _, _, cam_can_pick)| { + let marker_requirement = !settings.require_markers || *cam_can_pick; + camera.is_active && marker_requirement }) - .find(|(_, camera, _, _)| { + .find(|(_, camera, _, _, _)| { camera .target .normalize(primary_window) @@ -107,65 +121,75 @@ fn text2d_picking( let cursor_ray_len = cam_ortho.far - cam_ortho.near; let cursor_ray_end = cursor_ray_world.origin + cursor_ray_world.direction * cursor_ray_len; - // TODO: sort by Z - let picks: Vec<(Entity, HitData)> = text_query + let picks: Vec<(Entity, HitData)> = sorted_texts .iter() - .filter_map(|(entity, text_layout, b, c, text_bounds, text_transform)| { - // - // Transform cursor line segment to text coordinate system - let world_to_text = text_transform.affine().inverse(); - let cursor_start_text = world_to_text.transform_point3(cursor_ray_world.origin); - let cursor_end_text = world_to_text.transform_point3(cursor_ray_end); - - // Find where the cursor segment intersects the plane Z=0 (which is the sprite's - // plane in sprite-local space). It may not intersect if, for example, we're - // viewing the sprite side-on - if cursor_start_text.z == cursor_end_text.z { - // Cursor ray is parallel to the sprite and misses it - return None; - } - let lerp_factor = f32::inverse_lerp(cursor_start_text.z, cursor_end_text.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 sprite, the sprite 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 sprite space! - let relative_cursor_pos = cursor_start_text.lerp(cursor_end_text, lerp_factor).xy(); - - // 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; - } - - 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()), - ), - )) - }) + .filter_map( + |(entity, text_layout, text_bounds, text_transform, pickable)| { + if blocked { + return None; + } + // + // Transform cursor line segment to text coordinate system + let world_to_text = text_transform.affine().inverse(); + let cursor_start_text = world_to_text.transform_point3(cursor_ray_world.origin); + let cursor_end_text = world_to_text.transform_point3(cursor_ray_end); + + // Find where the cursor segment intersects the plane Z=0 (which is the text's + // plane in local space). It may not intersect if, for example, we're + // viewing the text side-on + if cursor_start_text.z == cursor_end_text.z { + // Cursor ray is parallel to the text and misses it + return None; + } + let lerp_factor = + f32::inverse_lerp(cursor_start_text.z, cursor_end_text.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 text, the text 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 + let relative_cursor_pos = + cursor_start_text.lerp(cursor_end_text, lerp_factor).xy(); + + // 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; 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..e298d11f2d06b --- /dev/null +++ b/crates/bevy_text/src/text_picking_backend.rs @@ -0,0 +1,40 @@ +use bevy_app::App; +use bevy_ecs::{ + observer::Trigger, + system::{Commands, Query}, +}; +use bevy_picking::events::{ + Cancel, Click, Drag, DragDrop, DragEnd, DragEnter, DragLeave, DragOver, DragStart, Move, Out, + Over, Pointer, Pressed, Released, +}; +use bevy_reflect::Reflect; +use bevy_sprite::Anchor; + +use crate::{ComputedTextBlock, 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::) + .add_observer(get_and_emit_text_hits::) + .add_observer(get_and_emit_text_hits::); +} + +pub(crate) fn get_and_emit_text_hits( + trigger: Trigger>, + q: Query<(&ComputedTextBlock, &TextLayoutInfo, &Anchor, &TextBounds)>, + mut commands: Commands, +) { + let Ok((c_text, text_layout, anchor, bounds)) = q.get(trigger.target) else { + return; + }; +} From f265517d006c251d3886cd63fc2a9ed2e0218256 Mon Sep 17 00:00:00 2001 From: sam edelsten Date: Wed, 12 Feb 2025 12:06:31 +0000 Subject: [PATCH 10/14] resolve merge conflict --- crates/bevy_text/src/text.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/crates/bevy_text/src/text.rs b/crates/bevy_text/src/text.rs index 8b2a3e0c62ee2..7c88010de9118 100644 --- a/crates/bevy_text/src/text.rs +++ b/crates/bevy_text/src/text.rs @@ -79,13 +79,6 @@ impl ComputedTextBlock { &self.entities } - /// Read only access to the internal buffer. - /// - /// Used for text picking. - pub fn buffer(&self) -> &CosmicBuffer { - &self.buffer - } - /// Indicates if the text needs to be refreshed in [`TextLayoutInfo`]. /// /// Updated automatically by [`detect_text_needs_rerender`] and cleared From 6dfd5b4da56eefab56ce417189ecfdfd4f2f9811 Mon Sep 17 00:00:00 2001 From: sam edelsten Date: Wed, 12 Feb 2025 12:07:08 +0000 Subject: [PATCH 11/14] use `HitData` for cursor position in UI rather than `RelativeCursorPosition` --- crates/bevy_ui/src/text_picking_backend.rs | 100 +++++++++++++++++---- 1 file changed, 84 insertions(+), 16 deletions(-) diff --git a/crates/bevy_ui/src/text_picking_backend.rs b/crates/bevy_ui/src/text_picking_backend.rs index 976e7c02d74c4..7eadb0d15a76d 100644 --- a/crates/bevy_ui/src/text_picking_backend.rs +++ b/crates/bevy_ui/src/text_picking_backend.rs @@ -3,23 +3,26 @@ use bevy_ecs::{ observer::Trigger, system::{Commands, Query}, }; -use bevy_picking::events::{ - Cancel, Click, Drag, DragDrop, DragEnd, DragEnter, DragLeave, DragOver, DragStart, Move, Out, - Over, Pointer, Pressed, Released, +use bevy_picking::{ + backend::HitData, + events::{ + Cancel, Click, DragDrop, DragEnter, DragLeave, DragOver, DragStart, Move, Out, Over, + Pointer, Pressed, Released, + }, }; use bevy_reflect::Reflect; use bevy_text::{text_pointer::TextPointer, ComputedTextBlock, TextLayoutInfo}; -use crate::{ComputedNode, RelativeCursorPosition}; +use crate::ComputedNode; + +// TODO: differentiate drag events, just reemit as a text event :) 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::) @@ -27,28 +30,93 @@ pub(crate) fn plugin(app: &mut App) { .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 + // .add_observer(get_and_emit_text_hits::) + // .add_observer(get_and_emit_text_hits::) +} + +pub trait HasHit: Clone + Reflect + std::fmt::Debug { + 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 + } +} + /// Takes UI pointer hits and re-emits them as `TextPointer` triggers. -pub(crate) fn get_and_emit_text_hits( +pub(crate) fn get_and_emit_text_hits( trigger: Trigger>, - q: Query<( - &ComputedNode, - &RelativeCursorPosition, - &ComputedTextBlock, - &TextLayoutInfo, - )>, + q: Query<(&ComputedNode, &ComputedTextBlock, &TextLayoutInfo)>, mut commands: Commands, ) { // Get click position relative to node - let Ok((c_node, pos, c_text, text_layout)) = q.get(trigger.target()) else { + let Ok((c_node, c_text, text_layout)) = q.get(trigger.target()) else { return; }; - let Some(hit_pos) = pos.normalized else { + let Some(hit_pos) = trigger.event.hit().position else { return; }; - let physical_pos = hit_pos * c_node.size; + let physical_pos = hit_pos.truncate() * c_node.size; let Some(cursor) = c_text.buffer().hit(physical_pos.x, physical_pos.y) else { return; From 2176ca3b2c809005a6493bf5ec87bcd071e7133b Mon Sep 17 00:00:00 2001 From: sam edelsten Date: Wed, 12 Feb 2025 16:56:29 +0000 Subject: [PATCH 12/14] buggy text2d implementation --- crates/bevy_text/src/picking_backend.rs | 99 ++++++++++++-------- crates/bevy_text/src/text_picking_backend.rs | 98 +++++++++++++++++-- crates/bevy_text/src/text_pointer.rs | 82 +++++++++++++++- crates/bevy_ui/src/text_picking_backend.rs | 83 ++-------------- examples/picking/text_picking.rs | 76 +++++++++++++-- 5 files changed, 305 insertions(+), 133 deletions(-) diff --git a/crates/bevy_text/src/picking_backend.rs b/crates/bevy_text/src/picking_backend.rs index f0c9ff8e4bf25..26eebfcd2efbb 100644 --- a/crates/bevy_text/src/picking_backend.rs +++ b/crates/bevy_text/src/picking_backend.rs @@ -7,7 +7,7 @@ use bevy_ecs::{ entity::Entity, event::EventWriter, query::With, resource::Resource, schedule::IntoSystemConfigs, system::Query, }; -use bevy_math::{FloatExt, Rect, Vec2, Vec3Swizzles}; +use bevy_math::{FloatExt, Ray3d, Rect, Vec2, Vec3, Vec3Swizzles}; use bevy_picking::Pickable; use bevy_picking::{ backend::{HitData, PointerHits}, @@ -16,7 +16,7 @@ use bevy_picking::{ }; use bevy_reflect::prelude::ReflectDefault; use bevy_reflect::Reflect; -use bevy_render::camera::{Camera, Projection}; +use bevy_render::camera::{Camera, OrthographicProjection, Projection}; use bevy_render::view::ViewVisibility; use bevy_transform::components::GlobalTransform; use bevy_window::PrimaryWindow; @@ -109,17 +109,11 @@ fn text2d_picking( continue; }; - let viewport_pos = camera - .logical_viewport_rect() - .map(|v| v.min) - .unwrap_or_default(); - let pos_in_viewport = location.position - viewport_pos; - - let Ok(cursor_ray_world) = camera.viewport_to_world(cam_transform, pos_in_viewport) else { + let Some((cursor_ray_world, cursor_ray_end)) = + rays_from_cursor_camera(location.position, camera, cam_transform, cam_ortho) + else { continue; }; - let cursor_ray_len = cam_ortho.far - cam_ortho.near; - let cursor_ray_end = cursor_ray_world.origin + cursor_ray_world.direction * cursor_ray_len; let picks: Vec<(Entity, HitData)> = sorted_texts .iter() @@ -128,32 +122,13 @@ fn text2d_picking( if blocked { return None; } - // - // Transform cursor line segment to text coordinate system - let world_to_text = text_transform.affine().inverse(); - let cursor_start_text = world_to_text.transform_point3(cursor_ray_world.origin); - let cursor_end_text = world_to_text.transform_point3(cursor_ray_end); - - // Find where the cursor segment intersects the plane Z=0 (which is the text's - // plane in local space). It may not intersect if, for example, we're - // viewing the text side-on - if cursor_start_text.z == cursor_end_text.z { - // Cursor ray is parallel to the text and misses it - return None; - } - let lerp_factor = - f32::inverse_lerp(cursor_start_text.z, cursor_end_text.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 text, the text 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 - let relative_cursor_pos = - cursor_start_text.lerp(cursor_end_text, lerp_factor).xy(); + // Transform cursor line segment to local coordinate system + let Some(relative_cursor_pos) = + get_relative_cursor_pos(&text_transform, cursor_ray_world, cursor_ray_end) + else { + return None; + }; // Find target rect, check cursor is contained inside let size = Vec2::new( @@ -162,7 +137,6 @@ fn text2d_picking( ); let text_rect = Rect::from_corners(-size / 2.0, size / 2.0); - if !text_rect.contains(relative_cursor_pos) { return None; } @@ -196,3 +170,54 @@ fn text2d_picking( 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/text_picking_backend.rs b/crates/bevy_text/src/text_picking_backend.rs index e298d11f2d06b..e30f0d423e166 100644 --- a/crates/bevy_text/src/text_picking_backend.rs +++ b/crates/bevy_text/src/text_picking_backend.rs @@ -1,25 +1,32 @@ +//! 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, Drag, DragDrop, DragEnd, DragEnter, DragLeave, DragOver, DragStart, Move, Out, - Over, Pointer, Pressed, Released, + Cancel, Click, DragDrop, DragEnter, DragLeave, DragOver, DragStart, Move, Out, Over, Pointer, + Pressed, Released, }; -use bevy_reflect::Reflect; -use bevy_sprite::Anchor; +use bevy_render::camera::{Camera, Projection}; +use bevy_transform::components::GlobalTransform; +use tracing::info; -use crate::{ComputedTextBlock, TextBounds, TextLayoutInfo}; +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::) @@ -29,12 +36,83 @@ pub(crate) fn plugin(app: &mut App) { .add_observer(get_and_emit_text_hits::); } -pub(crate) fn 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, &Anchor, &TextBounds)>, + q: Query< + ( + &ComputedTextBlock, + &TextLayoutInfo, + &TextBounds, + &GlobalTransform, + ), + With, + >, + cam_q: Query<(&Camera, &GlobalTransform, &Projection)>, mut commands: Commands, ) { - let Ok((c_text, text_layout, anchor, bounds)) = q.get(trigger.target) else { + 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.; + + info!("{:?}", local_pos); + + // 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 index b267b6189aa83..3cb40ec6b2c97 100644 --- a/crates/bevy_text/src/text_pointer.rs +++ b/crates/bevy_text/src/text_pointer.rs @@ -1,3 +1,5 @@ +//! Common types used for text picking. + use bevy_app::App; use bevy_ecs::{ entity::{Entity, EntityBorrow}, @@ -6,9 +8,12 @@ use bevy_ecs::{ query::QueryData, traversal::Traversal, }; -use bevy_picking::events::{ - Cancel, Click, Drag, DragDrop, DragEnd, DragEnter, DragLeave, DragOver, DragStart, Move, Out, - Over, Pointer, Pressed, Released, +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; @@ -34,10 +39,14 @@ pub(crate) fn plugin(app: &mut App) { .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, } @@ -83,3 +92,70 @@ where None } } + +/// Pointer event shared trait where `HitData` exists. +pub trait HasHit: Clone + Reflect + std::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/src/text_picking_backend.rs b/crates/bevy_ui/src/text_picking_backend.rs index 7eadb0d15a76d..b6a9dacab333c 100644 --- a/crates/bevy_ui/src/text_picking_backend.rs +++ b/crates/bevy_ui/src/text_picking_backend.rs @@ -3,20 +3,17 @@ use bevy_ecs::{ observer::Trigger, system::{Commands, Query}, }; -use bevy_picking::{ - backend::HitData, - events::{ - Cancel, Click, DragDrop, DragEnter, DragLeave, DragOver, DragStart, Move, Out, Over, - Pointer, Pressed, Released, - }, +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 bevy_reflect::Reflect; -use bevy_text::{text_pointer::TextPointer, ComputedTextBlock, TextLayoutInfo}; use crate::ComputedNode; -// TODO: differentiate drag events, just reemit as a text event :) - pub(crate) fn plugin(app: &mut App) { app.add_observer(get_and_emit_text_hits::) .add_observer(get_and_emit_text_hits::) @@ -32,75 +29,11 @@ pub(crate) fn plugin(app: &mut App) { .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::) } -pub trait HasHit: Clone + Reflect + std::fmt::Debug { - 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 - } -} - /// Takes UI pointer hits and re-emits them as `TextPointer` triggers. pub(crate) fn get_and_emit_text_hits( trigger: Trigger>, diff --git a/examples/picking/text_picking.rs b/examples/picking/text_picking.rs index 8d9c6285a4d82..cd186ba606f21 100644 --- a/examples/picking/text_picking.rs +++ b/examples/picking/text_picking.rs @@ -1,16 +1,38 @@ //! Demo picking text. -use bevy::{prelude::*, text::text_pointer::TextPointer, ui::RelativeCursorPosition}; +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) + .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 setup(mut commands: Commands) { commands.spawn(Camera2d); + commands.spawn(( + Sprite { + custom_size: Some(Vec2::new(2.0, 12.0)), + color: GREEN.into(), + ..default() + }, + MyCursor, + )); commands .spawn(( Text::new("hello text picking"), @@ -30,6 +52,7 @@ fn setup(mut commands: Commands) { )) .observe(|mut t: Trigger>| { info!("Span specific observer clicked! {:?}", t); + // Prevent parent observer triggering when span clicked. t.propagate(false); }); }) @@ -39,16 +62,53 @@ fn setup(mut commands: Commands) { }); commands - .spawn((Text2d::new("I'm a Text2d"),)) + .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( - |_: Trigger>| { - info!("text2d span click"); + |mut t: Trigger>| { + info!("Textmode clicked text2d span {:?}", t); + // t.propagate(false); }, ); }) - .observe(|_t: Trigger>| { - info!("Textmode clicked text2d"); - }) + .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, transform.translation.z); + }, + ) .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 + } +} From 3f42552ff2c04417e4443fc5e1b594234733f64d Mon Sep 17 00:00:00 2001 From: sam edelsten Date: Wed, 12 Feb 2025 17:19:22 +0000 Subject: [PATCH 13/14] add debug cursor to example --- crates/bevy_text/src/text_picking_backend.rs | 2 - examples/picking/text_picking.rs | 41 ++++++++++++++++---- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/crates/bevy_text/src/text_picking_backend.rs b/crates/bevy_text/src/text_picking_backend.rs index e30f0d423e166..d7364e432058a 100644 --- a/crates/bevy_text/src/text_picking_backend.rs +++ b/crates/bevy_text/src/text_picking_backend.rs @@ -85,8 +85,6 @@ pub(crate) fn get_and_emit_text_hits( local_pos.y *= -1.; local_pos += size / 2.; - info!("{:?}", local_pos); - // TODO: DRY: this is repeated in UI text picking let Some(cursor) = c_text.buffer().hit(local_pos.x, local_pos.y) else { diff --git a/examples/picking/text_picking.rs b/examples/picking/text_picking.rs index cd186ba606f21..b156800362735 100644 --- a/examples/picking/text_picking.rs +++ b/examples/picking/text_picking.rs @@ -23,11 +23,40 @@ fn main() { .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(2.0, 12.0)), + custom_size: Some(Vec2::new(4.0, 16.0)), color: GREEN.into(), ..default() }, @@ -54,12 +83,10 @@ fn setup(mut commands: Commands) { info!("Span specific observer clicked! {:?}", t); // Prevent parent observer triggering when span clicked. t.propagate(false); - }); + }) + .observe(set_cursor_target_ui); }) - .observe(|t: Trigger>| { - info!("Root observer clicked! {:?}", t); - // TODO: Visualize a cursor on click. - }); + .observe(set_cursor_target_ui); commands .spawn(( @@ -95,7 +122,7 @@ fn setup(mut commands: Commands) { let ypos = transform.translation.y + (t.cursor.line + 1) as f32 * LINEHEIGHT - tli.size.y / 2.; - target.0 = Vec3::new(xpos, ypos, transform.translation.z); + target.0 = Vec3::new(xpos, ypos, 1.0); }, ) .observe(|_t: Trigger>| info!("clicked text2d")); From 526e18a414ac910e43a26b390ebeff66c1c5dace Mon Sep 17 00:00:00 2001 From: sam edelsten Date: Wed, 12 Feb 2025 19:33:26 +0000 Subject: [PATCH 14/14] ci fixes --- crates/bevy_text/src/picking_backend.rs | 7 ++----- crates/bevy_text/src/text_picking_backend.rs | 1 - crates/bevy_text/src/text_pointer.rs | 8 ++++---- examples/picking/text_picking.rs | 4 ++-- 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/crates/bevy_text/src/picking_backend.rs b/crates/bevy_text/src/picking_backend.rs index 26eebfcd2efbb..8d4e57244a56a 100644 --- a/crates/bevy_text/src/picking_backend.rs +++ b/crates/bevy_text/src/picking_backend.rs @@ -124,11 +124,8 @@ fn text2d_picking( } // Transform cursor line segment to local coordinate system - let Some(relative_cursor_pos) = - get_relative_cursor_pos(&text_transform, cursor_ray_world, cursor_ray_end) - else { - return None; - }; + 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( diff --git a/crates/bevy_text/src/text_picking_backend.rs b/crates/bevy_text/src/text_picking_backend.rs index d7364e432058a..0563b84f2e670 100644 --- a/crates/bevy_text/src/text_picking_backend.rs +++ b/crates/bevy_text/src/text_picking_backend.rs @@ -13,7 +13,6 @@ use bevy_picking::events::{ }; use bevy_render::camera::{Camera, Projection}; use bevy_transform::components::GlobalTransform; -use tracing::info; use crate::{ picking_backend::{get_relative_cursor_pos, rays_from_cursor_camera}, diff --git a/crates/bevy_text/src/text_pointer.rs b/crates/bevy_text/src/text_pointer.rs index 3cb40ec6b2c97..35068cfb4a0df 100644 --- a/crates/bevy_text/src/text_pointer.rs +++ b/crates/bevy_text/src/text_pointer.rs @@ -41,7 +41,7 @@ pub(crate) fn plugin(app: &mut App) { /// Text-specific pointer event. #[derive(Debug, Clone)] -pub struct TextPointer { +pub struct TextPointer { /// The picked location in text. pub cursor: Cursor, /// The `PositionedGlyph` the the picked location in text. @@ -52,7 +52,7 @@ pub struct TextPointer { impl Event for TextPointer where - E: Clone + Reflect + std::fmt::Debug, + E: Clone + Reflect + core::fmt::Debug, { const AUTO_PROPAGATE: bool = true; type Traversal = TextPointerTraversal; @@ -70,7 +70,7 @@ pub struct TextPointerTraversal { impl Traversal> for TextPointerTraversal where - E: std::fmt::Debug + Clone + Reflect, + E: core::fmt::Debug + Clone + Reflect, { fn traverse(item: Self::Item<'_>, pointer: &TextPointer) -> Option { let TextPointerTraversalItem { parent, window } = item; @@ -94,7 +94,7 @@ where } /// Pointer event shared trait where `HitData` exists. -pub trait HasHit: Clone + Reflect + std::fmt::Debug { +pub trait HasHit: Clone + Reflect + core::fmt::Debug { /// Provides access to the event's `HitData`. fn hit(&self) -> &HitData; } diff --git a/examples/picking/text_picking.rs b/examples/picking/text_picking.rs index b156800362735..0a0c7d5efebdc 100644 --- a/examples/picking/text_picking.rs +++ b/examples/picking/text_picking.rs @@ -95,7 +95,7 @@ fn setup(mut commands: Commands) { )) .with_children(|cb| { cb.spawn(TextSpan("And I'm a text span!".into())).observe( - |mut t: Trigger>| { + |t: Trigger>| { info!("Textmode clicked text2d span {:?}", t); // t.propagate(false); }, @@ -136,6 +136,6 @@ 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 + t.translation = target.0; } }