diff --git a/crates/bevy_dev_tools/src/fps_overlay.rs b/crates/bevy_dev_tools/src/fps_overlay.rs index 97d0c3989a..fc8b7c8516 100644 --- a/crates/bevy_dev_tools/src/fps_overlay.rs +++ b/crates/bevy_dev_tools/src/fps_overlay.rs @@ -7,15 +7,17 @@ use bevy_diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin}; use bevy_ecs::{ change_detection::DetectChangesMut, component::Component, + entity::Entity, query::With, schedule::{common_conditions::resource_changed, IntoSystemConfigs}, system::{Commands, Query, Res, Resource}, }; use bevy_hierarchy::{BuildChildren, ChildBuild}; use bevy_render::view::Visibility; -use bevy_text::{Font, Text, TextSection, TextStyle}; +use bevy_text::{Font, TextSpan, TextStyle}; use bevy_ui::{ - node_bundles::{NodeBundle, TextBundle}, + node_bundles::NodeBundle, + widget::{Text, UiTextWriter}, GlobalZIndex, PositionType, Style, }; use bevy_utils::default; @@ -72,6 +74,7 @@ impl Default for FpsOverlayConfig { font: Handle::::default(), font_size: 32.0, color: Color::WHITE, + ..default() }, enabled: true, } @@ -95,22 +98,25 @@ fn setup(mut commands: Commands, overlay_config: Res) { }, GlobalZIndex(FPS_OVERLAY_ZINDEX), )) - .with_children(|c| { - c.spawn(( - TextBundle::from_sections([ - TextSection::new("FPS: ", overlay_config.text_config.clone()), - TextSection::from_style(overlay_config.text_config.clone()), - ]), + .with_children(|p| { + p.spawn(( + Text::new("FPS: "), + overlay_config.text_config.clone(), FpsText, - )); + )) + .with_child((TextSpan::default(), overlay_config.text_config.clone())); }); } -fn update_text(diagnostic: Res, mut query: Query<&mut Text, With>) { - for mut text in &mut query { +fn update_text( + diagnostic: Res, + query: Query>, + mut writer: UiTextWriter, +) { + for entity in &query { if let Some(fps) = diagnostic.get(&FrameTimeDiagnosticsPlugin::FPS) { if let Some(value) = fps.smoothed() { - text.sections[1].value = format!("{value:.2}"); + *writer.text(entity, 1) = format!("{value:.2}"); } } } @@ -118,12 +124,13 @@ fn update_text(diagnostic: Res, mut query: Query<&mut Text, Wi fn customize_text( overlay_config: Res, - mut query: Query<&mut Text, With>, + query: Query>, + mut writer: UiTextWriter, ) { - for mut text in &mut query { - for section in text.sections.iter_mut() { - section.style = overlay_config.text_config.clone(); - } + for entity in &query { + writer.for_each_style(entity, |mut style| { + *style = overlay_config.text_config.clone(); + }); } } diff --git a/crates/bevy_ecs/src/system/commands/mod.rs b/crates/bevy_ecs/src/system/commands/mod.rs index b4c6d46ff6..4e10f88bb3 100644 --- a/crates/bevy_ecs/src/system/commands/mod.rs +++ b/crates/bevy_ecs/src/system/commands/mod.rs @@ -939,7 +939,7 @@ pub struct EntityCommands<'a> { pub(crate) commands: Commands<'a, 'a>, } -impl EntityCommands<'_> { +impl<'a> EntityCommands<'a> { /// Returns the [`Entity`] id of the entity. /// /// # Example @@ -1533,6 +1533,11 @@ impl EntityCommands<'_> { self.commands.reborrow() } + /// Returns a mutable reference to the underlying [`Commands`]. + pub fn commands_mut(&mut self) -> &mut Commands<'a, 'a> { + &mut self.commands + } + /// Sends a [`Trigger`] targeting this entity. This will run any [`Observer`] of the `event` that /// watches this entity. /// diff --git a/crates/bevy_text/Cargo.toml b/crates/bevy_text/Cargo.toml index 57cea37b52..ef3d235113 100644 --- a/crates/bevy_text/Cargo.toml +++ b/crates/bevy_text/Cargo.toml @@ -18,6 +18,7 @@ bevy_asset = { path = "../bevy_asset", version = "0.15.0-dev" } bevy_color = { path = "../bevy_color", version = "0.15.0-dev" } bevy_derive = { path = "../bevy_derive", version = "0.15.0-dev" } bevy_ecs = { path = "../bevy_ecs", version = "0.15.0-dev" } +bevy_hierarchy = { path = "../bevy_hierarchy", version = "0.15.0-dev" } bevy_math = { path = "../bevy_math", version = "0.15.0-dev" } bevy_reflect = { path = "../bevy_reflect", version = "0.15.0-dev", features = [ "bevy", @@ -36,6 +37,7 @@ derive_more = { version = "1", default-features = false, features = [ "display", ] } serde = { version = "1", features = ["derive"] } +smallvec = "1.13" unicode-bidi = "0.3.13" sys-locale = "0.3.0" diff --git a/crates/bevy_text/src/font_atlas_set.rs b/crates/bevy_text/src/font_atlas_set.rs index 9cd7b64379..3092c9c008 100644 --- a/crates/bevy_text/src/font_atlas_set.rs +++ b/crates/bevy_text/src/font_atlas_set.rs @@ -60,9 +60,9 @@ pub struct FontAtlasKey(pub u32, pub FontSmoothing); /// A `FontAtlasSet` is an [`Asset`]. /// /// There is one `FontAtlasSet` for each font: -/// - When a [`Font`] is loaded as an asset and then used in [`Text`](crate::Text), +/// - When a [`Font`] is loaded as an asset and then used in [`TextStyle`](crate::TextStyle), /// a `FontAtlasSet` asset is created from a weak handle to the `Font`. -/// - ~When a font is loaded as a system font, and then used in [`Text`](crate::Text), +/// - ~When a font is loaded as a system font, and then used in [`TextStyle`](crate::TextStyle), /// a `FontAtlasSet` asset is created and stored with a strong handle to the `FontAtlasSet`.~ /// (*Note that system fonts are not currently supported by the `TextPipeline`.*) /// diff --git a/crates/bevy_text/src/glyph.rs b/crates/bevy_text/src/glyph.rs index 3efd1ac8af..4b264a8c89 100644 --- a/crates/bevy_text/src/glyph.rs +++ b/crates/bevy_text/src/glyph.rs @@ -13,14 +13,14 @@ use bevy_sprite::TextureAtlasLayout; /// Used in [`TextPipeline::queue_text`](crate::TextPipeline::queue_text) and [`crate::TextLayoutInfo`] for rendering glyphs. #[derive(Debug, Clone, Reflect)] pub struct PositionedGlyph { - /// The position of the glyph in the [`Text`](crate::Text)'s bounding box. + /// The position of the glyph in the text block's bounding box. pub position: Vec2, /// The width and height of the glyph in logical pixels. pub size: Vec2, /// Information about the glyph's atlas. pub atlas_info: GlyphAtlasInfo, - /// The index of the glyph in the [`Text`](crate::Text)'s sections. - pub section_index: usize, + /// 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 @@ -30,17 +30,12 @@ pub struct PositionedGlyph { impl PositionedGlyph { /// Creates a new [`PositionedGlyph`] - pub fn new( - position: Vec2, - size: Vec2, - atlas_info: GlyphAtlasInfo, - section_index: usize, - ) -> Self { + pub fn new(position: Vec2, size: Vec2, atlas_info: GlyphAtlasInfo, span_index: usize) -> Self { Self { position, size, atlas_info, - section_index, + span_index, byte_index: 0, } } diff --git a/crates/bevy_text/src/lib.rs b/crates/bevy_text/src/lib.rs index ee2c28a8e1..25b29d26ad 100644 --- a/crates/bevy_text/src/lib.rs +++ b/crates/bevy_text/src/lib.rs @@ -14,7 +14,7 @@ //! //! The [`TextPipeline`] resource does all of the heavy lifting for rendering text. //! -//! [`Text`] is first measured by creating a [`TextMeasureInfo`] in [`TextPipeline::create_text_measure`], +//! UI `Text` is first measured by creating a [`TextMeasureInfo`] in [`TextPipeline::create_text_measure`], //! which is called by the `measure_text_system` system of `bevy_ui`. //! //! Note that text measurement is only relevant in a UI context. @@ -23,7 +23,7 @@ //! or [`text2d::update_text2d_layout`] system (in a 2d world space context) //! passes it into [`TextPipeline::queue_text`], which: //! -//! 1. creates a [`Buffer`](cosmic_text::Buffer) from the [`TextSection`]s, generating new [`FontAtlasSet`]s if necessary. +//! 1. updates a [`Buffer`](cosmic_text::Buffer) from the [`TextSpan`]s, generating new [`FontAtlasSet`]s if necessary. //! 2. iterates over each glyph in the [`Buffer`](cosmic_text::Buffer) to create a [`PositionedGlyph`], //! retrieving glyphs from the cache, or rasterizing to a [`FontAtlas`] if necessary. //! 3. [`PositionedGlyph`]s are stored in a [`TextLayoutInfo`], @@ -43,6 +43,7 @@ mod glyph; mod pipeline; mod text; mod text2d; +mod text_access; pub use cosmic_text; @@ -56,13 +57,17 @@ pub use glyph::*; pub use pipeline::*; pub use text::*; pub use text2d::*; +pub use text_access::*; /// The text prelude. /// /// This includes the most common types in this crate, re-exported for your convenience. pub mod prelude { #[doc(hidden)] - pub use crate::{Font, JustifyText, Text, Text2dBundle, TextError, TextSection, TextStyle}; + pub use crate::{ + Font, JustifyText, LineBreak, Text2d, TextBlock, TextError, TextReader2d, TextSpan, + TextStyle, TextWriter2d, + }; } use bevy_app::prelude::*; @@ -87,7 +92,7 @@ pub const DEFAULT_FONT_DATA: &[u8] = include_bytes!("FiraMono-subset.ttf"); pub struct TextPlugin; /// Text is rendered for two different view projections; -/// 2-dimensional text ([`Text2dBundle`]) is rendered in "world space" with a `BottomToTop` Y-axis, +/// 2-dimensional text ([`Text2d`]) is rendered in "world space" with a `BottomToTop` Y-axis, /// while UI is rendered with a `TopToBottom` Y-axis. /// This matters for text because the glyph positioning is different in either layout. /// For `TopToBottom`, 0 is the top of the text, while for `BottomToTop` 0 is the bottom. @@ -98,35 +103,37 @@ pub enum YAxisOrientation { BottomToTop, } -/// A convenient alias for `With`, for use with -/// [`bevy_render::view::VisibleEntities`]. -pub type WithText = With; +/// System set in [`PostUpdate`] where all 2d text update systems are executed. +#[derive(Debug, Hash, PartialEq, Eq, Clone, SystemSet)] +pub struct Update2dText; impl Plugin for TextPlugin { fn build(&self, app: &mut App) { app.init_asset::() - .register_type::() + .register_type::() + .register_type::() .register_type::() .init_asset_loader::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_systems( PostUpdate, ( - calculate_bounds_text2d - .in_set(VisibilitySystems::CalculateBounds) - .after(update_text2d_layout), + remove_dropped_font_atlas_sets, + detect_text_needs_rerender::, update_text2d_layout - .after(remove_dropped_font_atlas_sets) // Potential conflict: `Assets` // In practice, they run independently since `bevy_render::camera_update_system` // will only ever observe its own render target, and `update_text2d_layout` // will never modify a pre-existing `Image` asset. .ambiguous_with(CameraUpdateSystem), - remove_dropped_font_atlas_sets, - ), + calculate_bounds_text2d.in_set(VisibilitySystems::CalculateBounds), + ) + .chain() + .in_set(Update2dText), ) .add_systems(Last, trim_cosmic_cache); diff --git a/crates/bevy_text/src/pipeline.rs b/crates/bevy_text/src/pipeline.rs index e63cd2d76d..de96661b52 100644 --- a/crates/bevy_text/src/pipeline.rs +++ b/crates/bevy_text/src/pipeline.rs @@ -16,8 +16,8 @@ use bevy_utils::HashMap; use cosmic_text::{Attrs, Buffer, Family, Metrics, Shaping, Wrap}; use crate::{ - error::TextError, CosmicBuffer, Font, FontAtlasSets, FontSmoothing, JustifyText, LineBreak, - PositionedGlyph, TextBounds, TextSection, TextStyle, YAxisOrientation, + error::TextError, ComputedTextBlock, Font, FontAtlasSets, FontSmoothing, JustifyText, + LineBreak, PositionedGlyph, TextBlock, TextBounds, TextEntity, TextStyle, YAxisOrientation, }; /// A wrapper resource around a [`cosmic_text::FontSystem`] @@ -60,7 +60,7 @@ struct FontFaceInfo { family_name: Arc, } -/// The `TextPipeline` is used to layout and render [`Text`](crate::Text). +/// The `TextPipeline` is used to layout and render text blocks (see `Text`/[`Text2d`](crate::Text2d)). /// /// See the [crate-level documentation](crate) for more information. #[derive(Default, Resource)] @@ -71,6 +71,8 @@ pub struct TextPipeline { /// /// See [this dark magic](https://users.rust-lang.org/t/how-to-cache-a-vectors-capacity/94478/10). spans_buffer: Vec<(usize, &'static str, &'static TextStyle, FontFaceInfo)>, + /// Buffered vec for collecting info for glyph assembly. + glyph_info: Vec<(AssetId, FontSmoothing)>, } impl TextPipeline { @@ -81,12 +83,12 @@ impl TextPipeline { pub fn update_buffer<'a>( &mut self, fonts: &Assets, - text_spans: impl Iterator, + text_spans: impl Iterator, linebreak: LineBreak, + justify: JustifyText, bounds: TextBounds, scale_factor: f64, - buffer: &mut CosmicBuffer, - alignment: JustifyText, + computed: &mut ComputedTextBlock, font_system: &mut CosmicFontSystem, ) -> Result<(), TextError> { let font_system = &mut font_system.0; @@ -100,7 +102,9 @@ impl TextPipeline { .map(|_| -> (usize, &str, &TextStyle, FontFaceInfo) { unreachable!() }) .collect(); - for (span_index, (span, style)) in text_spans.enumerate() { + computed.entities.clear(); + + for (span_index, (entity, depth, span, style)) in text_spans.enumerate() { // Return early if a font is not loaded yet. if !fonts.contains(style.font.id()) { spans.clear(); @@ -116,6 +120,9 @@ impl TextPipeline { return Err(TextError::NoSuchFont); } + // Save this span entity in the computed text block. + computed.entities.push(TextEntity { entity, depth }); + // Get max font size for use in cosmic Metrics. font_size = font_size.max(style.font_size); @@ -152,6 +159,7 @@ impl TextPipeline { }); // Update the buffer. + let buffer = &mut computed.buffer; buffer.set_metrics(font_system, metrics); buffer.set_size(font_system, bounds.width, bounds.height); @@ -170,7 +178,7 @@ impl TextPipeline { // PERF: https://github.com/pop-os/cosmic-text/issues/166: // Setting alignment afterwards appears to invalidate some layouting performed by `set_text` which is presumably not free? for buffer_line in buffer.lines.iter_mut() { - buffer_line.set_align(Some(alignment.into())); + buffer_line.set_align(Some(justify.into())); } buffer.shape_until_scroll(font_system, false); @@ -189,47 +197,54 @@ impl TextPipeline { /// Produces a [`TextLayoutInfo`], containing [`PositionedGlyph`]s /// which contain information for rendering the text. #[allow(clippy::too_many_arguments)] - pub fn queue_text( + pub fn queue_text<'a>( &mut self, layout_info: &mut TextLayoutInfo, fonts: &Assets, - sections: &[TextSection], + text_spans: impl Iterator, scale_factor: f64, - text_alignment: JustifyText, - linebreak: LineBreak, - font_smoothing: FontSmoothing, + block: &TextBlock, bounds: TextBounds, font_atlas_sets: &mut FontAtlasSets, texture_atlases: &mut Assets, textures: &mut Assets, y_axis_orientation: YAxisOrientation, - buffer: &mut CosmicBuffer, + computed: &mut ComputedTextBlock, font_system: &mut CosmicFontSystem, swash_cache: &mut SwashCache, ) -> Result<(), TextError> { layout_info.glyphs.clear(); layout_info.size = Default::default(); - if sections.is_empty() { - return Ok(()); - } + // Clear this here at the focal point of text rendering to ensure the field's lifecycle has strong boundaries. + computed.needs_rerender = false; - self.update_buffer( + // Extract font ids from the iterator while traversing it. + let mut glyph_info = core::mem::take(&mut self.glyph_info); + glyph_info.clear(); + let text_spans = text_spans.inspect(|(_, _, _, style)| { + glyph_info.push((style.font.id(), style.font_smoothing)); + }); + + let update_result = self.update_buffer( fonts, - sections - .iter() - .map(|section| (section.value.as_str(), §ion.style)), - linebreak, + text_spans, + block.linebreak, + block.justify, bounds, scale_factor, - buffer, - text_alignment, + computed, font_system, - )?; + ); + if let Err(err) = update_result { + self.glyph_info = glyph_info; + return Err(err); + } + let buffer = &mut computed.buffer; let box_size = buffer_dimensions(buffer); - buffer + let result = buffer .layout_runs() .flat_map(|run| { run.glyphs @@ -238,6 +253,9 @@ impl TextPipeline { }) .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, @@ -255,10 +273,7 @@ impl TextPipeline { layout_glyph }; - let section_index = layout_glyph.metadata; - - let font_handle = sections[section_index].style.font.clone_weak(); - let font_atlas_set = font_atlas_sets.sets.entry(font_handle.id()).or_default(); + let font_atlas_set = font_atlas_sets.sets.entry(font_id).or_default(); let physical_glyph = layout_glyph.physical((0., 0.), 1.); @@ -296,10 +311,16 @@ impl TextPipeline { // 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, section_index); + PositionedGlyph::new(position, glyph_size.as_vec2(), atlas_info, span_index); layout_info.glyphs.push(pos_glyph); Ok(()) - })?; + }); + + // Return the scratch vec. + self.glyph_info = glyph_info; + + // Check result. + result?; layout_info.size = box_size; Ok(()) @@ -310,32 +331,34 @@ impl TextPipeline { /// Produces a [`TextMeasureInfo`] which can be used by a layout system /// to measure the text area on demand. #[allow(clippy::too_many_arguments)] - pub fn create_text_measure( + pub fn create_text_measure<'a>( &mut self, entity: Entity, fonts: &Assets, - sections: &[TextSection], + text_spans: impl Iterator, scale_factor: f64, - linebreak: LineBreak, - buffer: &mut CosmicBuffer, - text_alignment: JustifyText, + block: &TextBlock, + computed: &mut ComputedTextBlock, font_system: &mut CosmicFontSystem, ) -> Result { const MIN_WIDTH_CONTENT_BOUNDS: TextBounds = TextBounds::new_horizontal(0.0); + // Clear this here at the focal point of measured text rendering to ensure the field's lifecycle has + // strong boundaries. + computed.needs_rerender = false; + self.update_buffer( fonts, - sections - .iter() - .map(|section| (section.value.as_str(), §ion.style)), - linebreak, + text_spans, + block.linebreak, + block.justify, MIN_WIDTH_CONTENT_BOUNDS, scale_factor, - buffer, - text_alignment, + computed, font_system, )?; + let buffer = &mut computed.buffer; let min_width_content_size = buffer_dimensions(buffer); let max_width_content_size = { @@ -360,9 +383,10 @@ impl TextPipeline { } } -/// Render information for a corresponding [`Text`](crate::Text) component. +/// Render information for a corresponding text block. /// -/// Contains scaled glyphs and their size. Generated via [`TextPipeline::queue_text`]. +/// Contains scaled glyphs and their size. Generated via [`TextPipeline::queue_text`] when an entity has +/// [`TextBlock`] and [`ComputedTextBlock`] components. #[derive(Component, Clone, Default, Debug, Reflect)] #[reflect(Component, Default, Debug)] pub struct TextLayoutInfo { @@ -372,7 +396,7 @@ pub struct TextLayoutInfo { pub size: Vec2, } -/// Size information for a corresponding [`Text`](crate::Text) component. +/// Size information for a corresponding [`ComputedTextBlock`] component. /// /// Generated via [`TextPipeline::create_text_measure`]. #[derive(Debug)] @@ -390,13 +414,15 @@ impl TextMeasureInfo { pub fn compute_size( &mut self, bounds: TextBounds, - buffer: &mut Buffer, + computed: &mut ComputedTextBlock, font_system: &mut cosmic_text::FontSystem, ) -> Vec2 { // Note that this arbitrarily adjusts the buffer layout. We assume the buffer is always 'refreshed' // whenever a canonical state is required. - buffer.set_size(font_system, bounds.width, bounds.height); - buffer_dimensions(buffer) + computed + .buffer + .set_size(font_system, bounds.width, bounds.height); + buffer_dimensions(&computed.buffer) } } diff --git a/crates/bevy_text/src/text.rs b/crates/bevy_text/src/text.rs index 5f6215ea0a..96490ac28d 100644 --- a/crates/bevy_text/src/text.rs +++ b/crates/bevy_text/src/text.rs @@ -1,20 +1,22 @@ -use bevy_asset::Handle; -use bevy_color::Color; -use bevy_derive::{Deref, DerefMut}; -use bevy_ecs::{prelude::Component, reflect::ReflectComponent}; -use bevy_reflect::prelude::*; -use bevy_utils::default; -use cosmic_text::{Buffer, Metrics}; -use serde::{Deserialize, Serialize}; - -use crate::Font; pub use cosmic_text::{ self, FamilyOwned as FontFamily, Stretch as FontStretch, Style as FontStyle, Weight as FontWeight, }; +use crate::{Font, TextLayoutInfo, TextSpanAccess, TextSpanComponent}; +use bevy_asset::Handle; +use bevy_color::Color; +use bevy_derive::{Deref, DerefMut}; +use bevy_ecs::{prelude::*, reflect::ReflectComponent}; +use bevy_hierarchy::{Children, Parent}; +use bevy_reflect::prelude::*; +use bevy_utils::warn_once; +use cosmic_text::{Buffer, Metrics}; +use serde::{Deserialize, Serialize}; +use smallvec::SmallVec; + /// Wrapper for [`cosmic_text::Buffer`] -#[derive(Component, Deref, DerefMut, Debug, Clone)] +#[derive(Deref, DerefMut, Debug, Clone)] pub struct CosmicBuffer(pub Buffer); impl Default for CosmicBuffer { @@ -23,160 +25,199 @@ impl Default for CosmicBuffer { } } -/// A component that is the entry point for rendering text. +/// A sub-entity of a [`TextBlock`]. /// -/// It contains all of the text value and styling information. -#[derive(Component, Debug, Clone, Default, Reflect)] +/// Returned by [`ComputedTextBlock::entities`]. +#[derive(Debug, Copy, Clone)] +pub struct TextEntity { + /// The entity. + pub entity: Entity, + /// Records the hierarchy depth of the entity within a `TextBlock`. + pub depth: usize, +} + +/// Computed information for a [`TextBlock`]. +/// +/// Automatically updated by 2d and UI text systems. +#[derive(Component, Debug, Clone)] +pub struct ComputedTextBlock { + /// Buffer for managing text layout and creating [`TextLayoutInfo`]. + /// + /// This is private because buffer contents are always refreshed from ECS state when writing glyphs to + /// `TextLayoutInfo`. If you want to control the buffer contents manually or use the `cosmic-text` + /// editor, then you need to not use `TextBlock` and instead manually implement the conversion to + /// `TextLayoutInfo`. + pub(crate) buffer: CosmicBuffer, + /// Entities for all text spans in the block, including the root-level text. + /// + /// The [`TextEntity::depth`] field can be used to reconstruct the hierarchy. + pub(crate) entities: SmallVec<[TextEntity; 1]>, + /// Flag set when any change has been made to this block that should cause it to be rerendered. + /// + /// Includes: + /// - [`TextBlock`] changes. + /// - [`TextStyle`] or `Text2d`/`Text`/`TextSpan` changes anywhere in the block's entity hierarchy. + // TODO: This encompasses both structural changes like font size or justification and non-structural + // changes like text color and font smoothing. This field currently causes UI to 'remeasure' text, even if + // the actual changes are non-structural and can be handled by only rerendering and not remeasuring. A full + // solution would probably require splitting TextBlock and TextStyle into structural/non-structural + // components for more granular change detection. A cost/benefit analysis is needed. + pub(crate) needs_rerender: bool, +} + +impl ComputedTextBlock { + /// Accesses entities in this block. + /// + /// Can be used to look up [`TextStyle`] components for glyphs in [`TextLayoutInfo`] using the `span_index` + /// stored there. + pub fn entities(&self) -> &[TextEntity] { + &self.entities + } + + /// Indicates if the text needs to be refreshed in [`TextLayoutInfo`]. + /// + /// Updated automatically by [`detect_text_needs_rerender`] and cleared + /// by [`TextPipeline`](crate::TextPipeline) methods. + pub fn needs_rerender(&self) -> bool { + self.needs_rerender + } +} + +impl Default for ComputedTextBlock { + fn default() -> Self { + Self { + buffer: CosmicBuffer::default(), + entities: SmallVec::default(), + needs_rerender: true, + } + } +} + +/// Component with text format settings for a block of text. +/// +/// A block of text is composed of text spans, which each have a separate string value and [`TextStyle`]. Text +/// spans associated with a text block are collected into [`ComputedTextBlock`] for layout, and then inserted +/// to [`TextLayoutInfo`] for rendering. +/// +/// See [`Text2d`](crate::Text2d) for the core component of 2d text, and `Text` in `bevy_ui` for UI text. +#[derive(Component, Debug, Copy, Clone, Default, Reflect)] #[reflect(Component, Default, Debug)] -pub struct Text { - /// The text's sections - pub sections: Vec, +#[require(ComputedTextBlock, TextLayoutInfo)] +pub struct TextBlock { /// The text's internal alignment. /// Should not affect its position within a container. pub justify: JustifyText, - /// How the text should linebreak when running out of the bounds determined by `max_size` + /// How the text should linebreak when running out of the bounds determined by `max_size`. pub linebreak: LineBreak, - /// The antialiasing method to use when rendering text. - pub font_smoothing: FontSmoothing, } -impl Text { - /// Constructs a [`Text`] with a single section. - /// - /// ``` - /// # use bevy_asset::Handle; - /// # use bevy_color::Color; - /// # use bevy_text::{Font, Text, TextStyle, JustifyText}; - /// # - /// # let font_handle: Handle = Default::default(); - /// # - /// // Basic usage. - /// let hello_world = Text::from_section( - /// // Accepts a String or any type that converts into a String, such as &str. - /// "hello world!", - /// TextStyle { - /// font: font_handle.clone().into(), - /// font_size: 60.0, - /// color: Color::WHITE, - /// }, - /// ); - /// - /// let hello_bevy = Text::from_section( - /// "hello world\nand bevy!", - /// TextStyle { - /// font: font_handle.into(), - /// font_size: 60.0, - /// color: Color::WHITE, - /// }, - /// ) // You can still add text justifaction. - /// .with_justify(JustifyText::Center); - /// ``` - pub fn from_section(value: impl Into, style: TextStyle) -> Self { - Self { - sections: vec![TextSection::new(value, style)], - ..default() - } +impl TextBlock { + /// Makes a new [`TextBlock`]. + pub const fn new(justify: JustifyText, linebreak: LineBreak) -> Self { + Self { justify, linebreak } } - /// Constructs a [`Text`] from a list of sections. - /// - /// ``` - /// # use bevy_asset::Handle; - /// # use bevy_color::Color; - /// # use bevy_color::palettes::basic::{RED, BLUE}; - /// # use bevy_text::{Font, Text, TextStyle, TextSection}; - /// # - /// # let font_handle: Handle = Default::default(); - /// # - /// let hello_world = Text::from_sections([ - /// TextSection::new( - /// "Hello, ", - /// TextStyle { - /// font: font_handle.clone().into(), - /// font_size: 60.0, - /// color: BLUE.into(), - /// }, - /// ), - /// TextSection::new( - /// "World!", - /// TextStyle { - /// font: font_handle.into(), - /// font_size: 60.0, - /// color: RED.into(), - /// }, - /// ), - /// ]); - /// ``` - pub fn from_sections(sections: impl IntoIterator) -> Self { - Self { - sections: sections.into_iter().collect(), - ..default() - } + /// Makes a new [`TextBlock`] with the specified [`JustifyText`]. + pub fn new_with_justify(justify: JustifyText) -> Self { + Self::default().with_justify(justify) } - /// Returns this [`Text`] with a new [`JustifyText`]. + /// Makes a new [`TextBlock`] with the specified [`LineBreak`]. + pub fn new_with_linebreak(linebreak: LineBreak) -> Self { + Self::default().with_linebreak(linebreak) + } + + /// Makes a new [`TextBlock`] with soft wrapping disabled. + /// Hard wrapping, where text contains an explicit linebreak such as the escape sequence `\n`, will still occur. + pub fn new_with_no_wrap() -> Self { + Self::default().with_no_wrap() + } + + /// Returns this [`TextBlock`] with the specified [`JustifyText`]. pub const fn with_justify(mut self, justify: JustifyText) -> Self { self.justify = justify; self } - /// Returns this [`Text`] with soft wrapping disabled. + /// Returns this [`TextBlock`] with the specified [`LineBreak`]. + pub const fn with_linebreak(mut self, linebreak: LineBreak) -> Self { + self.linebreak = linebreak; + self + } + + /// Returns this [`TextBlock`] with soft wrapping disabled. /// Hard wrapping, where text contains an explicit linebreak such as the escape sequence `\n`, will still occur. pub const fn with_no_wrap(mut self) -> Self { self.linebreak = LineBreak::NoWrap; self } +} - /// Returns this [`Text`] with the specified [`FontSmoothing`]. - pub const fn with_font_smoothing(mut self, font_smoothing: FontSmoothing) -> Self { - self.font_smoothing = font_smoothing; - self +/// A span of UI text in a tree of spans under an entity with [`TextBlock`], such as `Text` or `Text2d`. +/// +/// Spans are collected in hierarchy traversal order into a [`ComputedTextBlock`] for layout. +/// +/* +``` +# use bevy_asset::Handle; +# use bevy_color::Color; +# use bevy_color::palettes::basic::{RED, BLUE}; +# use bevy_ecs::World; +# use bevy_text::{Font, TextBlock, TextStyle, TextSection}; + +# let font_handle: Handle = Default::default(); +# let mut world = World::default(); +# +world.spawn(( + TextBlock::default(), + TextStyle { + font: font_handle.clone().into(), + font_size: 60.0, + color: BLUE.into(), + } +)) +.with_child(( + TextSpan::new("Hello!"), + TextStyle { + font: font_handle.into(), + font_size: 60.0, + color: RED.into(), + } +)); +``` +*/ +#[derive(Component, Debug, Default, Clone, Deref, DerefMut, Reflect)] +#[reflect(Component, Default, Debug)] +#[require(TextStyle)] +pub struct TextSpan(pub String); + +impl TextSpan { + /// Makes a new text span component. + pub fn new(text: impl Into) -> Self { + Self(text.into()) } } -/// Contains the value of the text in a section and how it should be styled. -#[derive(Debug, Default, Clone, Reflect)] -#[reflect(Default)] -pub struct TextSection { - /// The content (in `String` form) of the text in the section. - pub value: String, - /// The style of the text in the section, including the font face, font size, and color. - pub style: TextStyle, -} +impl TextSpanComponent for TextSpan {} -impl TextSection { - /// Create a new [`TextSection`]. - pub fn new(value: impl Into, style: TextStyle) -> Self { - Self { - value: value.into(), - style, - } +impl TextSpanAccess for TextSpan { + fn read_span(&self) -> &str { + self.as_str() } - - /// Create an empty [`TextSection`] from a style. Useful when the value will be set dynamically. - pub const fn from_style(style: TextStyle) -> Self { - Self { - value: String::new(), - style, - } + fn write_span(&mut self) -> &mut String { + &mut *self } } -impl From<&str> for TextSection { +impl From<&str> for TextSpan { fn from(value: &str) -> Self { - Self { - value: value.into(), - ..default() - } + Self(String::from(value)) } } -impl From for TextSection { +impl From for TextSpan { fn from(value: String) -> Self { - Self { - value, - ..Default::default() - } + Self(value) } } @@ -216,9 +257,10 @@ impl From for cosmic_text::Align { } } -#[derive(Clone, Debug, Reflect)] -/// `TextStyle` determines the style of the text in a section, specifically +/// `TextStyle` determines the style of a text span within a [`TextBlock`], specifically /// the font face, the font size, and the color. +#[derive(Component, Clone, Debug, Reflect)] +#[reflect(Component, Default, Debug)] pub struct TextStyle { /// The specific font face to use, as a `Handle` to a [`Font`] asset. /// @@ -238,6 +280,16 @@ pub struct TextStyle { pub font_size: f32, /// The color of the text for this section. pub color: Color, + /// The antialiasing method to use when rendering text. + pub font_smoothing: FontSmoothing, +} + +impl TextStyle { + /// Returns this [`TextBlock`] with the specified [`FontSmoothing`]. + pub const fn with_font_smoothing(mut self, font_smoothing: FontSmoothing) -> Self { + self.font_smoothing = font_smoothing; + self + } } impl Default for TextStyle { @@ -246,6 +298,7 @@ impl Default for TextStyle { font: Default::default(), font_size: 20.0, color: Color::WHITE, + font_smoothing: Default::default(), } } } @@ -293,3 +346,112 @@ pub enum FontSmoothing { // TODO: Add subpixel antialias support // SubpixelAntiAliased, } + +/// System that detects changes to text blocks and sets `ComputedTextBlock::should_rerender`. +/// +/// Generic over the root text component and text span component. For example, [`Text2d`](crate::Text2d)/[`TextSpan`] for +/// 2d or `Text`/[`TextSpan`] for UI. +pub fn detect_text_needs_rerender( + changed_roots: Query< + Entity, + ( + Or<( + Changed, + Changed, + Changed, + Changed, + )>, + With, + With, + With, + ), + >, + changed_spans: Query< + (Entity, Option<&Parent>, Has), + ( + Or<( + Changed, + Changed, + Changed, + Changed, // Included to detect broken text block hierarchies. + Added, + )>, + With, + With, + ), + >, + mut computed: Query<( + Option<&Parent>, + Option<&mut ComputedTextBlock>, + Has, + )>, +) { + // Root entity: + // - Root component changed. + // - TextStyle on root changed. + // - TextBlock changed. + // - Root children changed (can include additions and removals). + for root in changed_roots.iter() { + let Ok((_, Some(mut computed), _)) = computed.get_mut(root) else { + warn_once!("found entity {:?} with a root text component ({}) but no ComputedTextBlock; this warning only \ + prints once", root, core::any::type_name::()); + continue; + }; + computed.needs_rerender = true; + } + + // Span entity: + // - Span component changed. + // - Span TextStyle changed. + // - Span children changed (can include additions and removals). + for (entity, maybe_span_parent, has_text_block) in changed_spans.iter() { + if has_text_block { + warn_once!("found entity {:?} with a TextSpan that has a TextBlock, which should only be on root \ + text entities (that have {}); this warning only prints once", + entity, core::any::type_name::()); + } + + let Some(span_parent) = maybe_span_parent else { + warn_once!( + "found entity {:?} with a TextSpan that has no parent; it should have an ancestor \ + with a root text component ({}); this warning only prints once", + entity, + core::any::type_name::() + ); + continue; + }; + let mut parent: Entity = **span_parent; + + // Search for the nearest ancestor with ComputedTextBlock. + // Note: We assume the perf cost from duplicate visits in the case that multiple spans in a block are visited + // is outweighed by the expense of tracking visited spans. + loop { + let Ok((maybe_parent, maybe_computed, has_span)) = computed.get_mut(parent) else { + warn_once!("found entity {:?} with a TextSpan that is part of a broken hierarchy with a Parent \ + component that points at non-existent entity {:?}; this warning only prints once", + entity, parent); + break; + }; + if let Some(mut computed) = maybe_computed { + computed.needs_rerender = true; + break; + } + if !has_span { + warn_once!("found entity {:?} with a TextSpan that has an ancestor ({}) that does not have a text \ + span component or a ComputedTextBlock component; this warning only prints once", + entity, parent); + break; + } + let Some(next_parent) = maybe_parent else { + warn_once!( + "found entity {:?} with a TextSpan that has no ancestor with the root text \ + component ({}); this warning only prints once", + entity, + core::any::type_name::() + ); + break; + }; + parent = **next_parent; + } + } +} diff --git a/crates/bevy_text/src/text2d.rs b/crates/bevy_text/src/text2d.rs index db175fd09d..7047c0aeff 100644 --- a/crates/bevy_text/src/text2d.rs +++ b/crates/bevy_text/src/text2d.rs @@ -1,69 +1,129 @@ use crate::pipeline::CosmicFontSystem; use crate::{ - CosmicBuffer, Font, FontAtlasSets, LineBreak, PositionedGlyph, SwashCache, Text, TextBounds, - TextError, TextLayoutInfo, TextPipeline, YAxisOrientation, + ComputedTextBlock, Font, FontAtlasSets, LineBreak, PositionedGlyph, SwashCache, TextBlock, + TextBounds, TextError, TextLayoutInfo, TextPipeline, TextReader, TextRoot, TextSpanAccess, + TextStyle, TextWriter, YAxisOrientation, }; use bevy_asset::Assets; use bevy_color::LinearRgba; +use bevy_derive::{Deref, DerefMut}; +use bevy_ecs::component::Component; use bevy_ecs::{ - bundle::Bundle, change_detection::{DetectChanges, Ref}, entity::Entity, event::EventReader, - prelude::With, + prelude::{ReflectComponent, With}, query::{Changed, Without}, system::{Commands, Local, Query, Res, ResMut}, }; use bevy_math::Vec2; +use bevy_reflect::{prelude::ReflectDefault, Reflect}; use bevy_render::sync_world::TemporaryRenderEntity; +use bevy_render::view::Visibility; use bevy_render::{ primitives::Aabb, texture::Image, - view::{InheritedVisibility, NoFrustumCulling, ViewVisibility, Visibility}, + view::{NoFrustumCulling, ViewVisibility}, Extract, }; use bevy_sprite::{Anchor, ExtractedSprite, ExtractedSprites, SpriteSource, TextureAtlasLayout}; -use bevy_transform::prelude::{GlobalTransform, Transform}; +use bevy_transform::components::Transform; +use bevy_transform::prelude::GlobalTransform; use bevy_utils::HashSet; use bevy_window::{PrimaryWindow, Window, WindowScaleFactorChanged}; -/// The bundle of components needed to draw text in a 2D scene via a `Camera2d`. +/// The top-level 2D text component. +/// +/// Adding `Text2d` to an entity will pull in required components for setting up 2d text. /// [Example usage.](https://github.com/bevyengine/bevy/blob/latest/examples/2d/text2d.rs) -#[derive(Bundle, Clone, Debug, Default)] -pub struct Text2dBundle { - /// Contains the text. - /// - /// With `Text2dBundle` the alignment field of `Text` only affects the internal alignment of a block of text and not its - /// relative position which is controlled by the `Anchor` component. - /// This means that for a block of text consisting of only one line that doesn't wrap, the `alignment` field will have no effect. - pub text: Text, - /// Cached buffer for layout with cosmic-text - pub buffer: CosmicBuffer, - /// How the text is positioned relative to its transform. - /// - /// `text_anchor` does not affect the internal alignment of the block of text, only - /// its position. - pub text_anchor: Anchor, - /// The maximum width and height of the text. - pub text_2d_bounds: TextBounds, - /// The transform of the text. - pub transform: Transform, - /// The global transform of the text. - pub global_transform: GlobalTransform, - /// The visibility properties of the text. - pub visibility: Visibility, - /// Inherited visibility of an entity. - pub inherited_visibility: InheritedVisibility, - /// Algorithmically-computed indication of whether an entity is visible and should be extracted for rendering - pub view_visibility: ViewVisibility, - /// Contains the size of the text and its glyph's position and scale data. Generated via [`TextPipeline::queue_text`] - pub text_layout_info: TextLayoutInfo, - /// Marks that this is a [`SpriteSource`]. - /// - /// This is needed for visibility computation to work properly. - pub sprite_source: SpriteSource, +/// +/// The string in this component is the first 'text span' in a hierarchy of text spans that are collected into +/// a [`TextBlock`]. See [`TextSpan`](crate::TextSpan) for the component used by children of entities with [`Text2d`]. +/// +/// With `Text2d` the `justify` field of [`TextBlock`] only affects the internal alignment of a block of text and not its +/// relative position, which is controlled by the [`Anchor`] component. +/// This means that for a block of text consisting of only one line that doesn't wrap, the `justify` field will have no effect. +/// +/* +``` +# use bevy_asset::Handle; +# use bevy_color::Color; +# use bevy_color::palettes::basic::BLUE; +# use bevy_ecs::World; +# use bevy_text::{Font, JustifyText, Text2d, TextBlock, TextStyle}; +# +# let font_handle: Handle = Default::default(); +# let mut world = World::default(); +# +// Basic usage. +world.spawn(Text2d::new("hello world!")); + +// With non-default style. +world.spawn(( + Text2d::new("hello world!"), + TextStyle { + font: font_handle.clone().into(), + font_size: 60.0, + color: BLUE.into(), + } +)); + +// With text justification. +world.spawn(( + Text2d::new("hello world\nand bevy!"), + TextBlock::new_with_justify(JustifyText::Center) +)); +``` +*/ +#[derive(Component, Clone, Debug, Default, Deref, DerefMut, Reflect)] +#[reflect(Component, Default, Debug)] +#[require( + TextBlock, + TextStyle, + TextBounds, + Anchor, + SpriteSource, + Visibility, + Transform +)] +pub struct Text2d(pub String); + +impl Text2d { + /// Makes a new 2d text component. + pub fn new(text: impl Into) -> Self { + Self(text.into()) + } } +impl TextRoot for Text2d {} + +impl TextSpanAccess for Text2d { + fn read_span(&self) -> &str { + self.as_str() + } + fn write_span(&mut self) -> &mut String { + &mut *self + } +} + +impl From<&str> for Text2d { + fn from(value: &str) -> Self { + Self(String::from(value)) + } +} + +impl From for Text2d { + fn from(value: String) -> Self { + Self(value) + } +} + +/// 2d alias for [`TextReader`]. +pub type TextReader2d<'w, 's> = TextReader<'w, 's, Text2d>; + +/// 2d alias for [`TextWriter`]. +pub type TextWriter2d<'w, 's> = TextWriter<'w, 's, Text2d>; + /// This system extracts the sprites from the 2D text components and adds them to the /// "render world". pub fn extract_text2d_sprite( @@ -75,12 +135,13 @@ pub fn extract_text2d_sprite( Query<( Entity, &ViewVisibility, - &Text, + &ComputedTextBlock, &TextLayoutInfo, &Anchor, &GlobalTransform, )>, >, + text_styles: Extract>, ) { // TODO: Support window-independent scaling: https://github.com/bevyengine/bevy/issues/5621 let scale_factor = windows @@ -89,8 +150,14 @@ pub fn extract_text2d_sprite( .unwrap_or(1.0); let scaling = GlobalTransform::from_scale(Vec2::splat(scale_factor.recip()).extend(1.)); - for (original_entity, view_visibility, text, text_layout_info, anchor, global_transform) in - text2d_query.iter() + for ( + original_entity, + view_visibility, + computed_block, + text_layout_info, + anchor, + global_transform, + ) in text2d_query.iter() { if !view_visibility.get() { continue; @@ -102,17 +169,26 @@ pub fn extract_text2d_sprite( * GlobalTransform::from_translation(alignment_translation.extend(0.)) * scaling; let mut color = LinearRgba::WHITE; - let mut current_section = usize::MAX; + let mut current_span = usize::MAX; for PositionedGlyph { position, atlas_info, - section_index, + span_index, .. } in &text_layout_info.glyphs { - if *section_index != current_section { - color = LinearRgba::from(text.sections[*section_index].style.color); - current_section = *section_index; + if *span_index != current_span { + color = text_styles + .get( + computed_block + .entities() + .get(*span_index) + .map(|t| t.entity) + .unwrap_or(Entity::PLACEHOLDER), + ) + .map(|style| LinearRgba::from(style.color)) + .unwrap_or_default(); + current_span = *span_index; } let atlas = texture_atlases.get(&atlas_info.texture_atlas).unwrap(); @@ -154,11 +230,12 @@ pub fn update_text2d_layout( mut text_pipeline: ResMut, mut text_query: Query<( Entity, - Ref, + Ref, Ref, &mut TextLayoutInfo, - &mut CosmicBuffer, + &mut ComputedTextBlock, )>, + mut text_reader: TextReader2d, mut font_system: ResMut, mut swash_cache: ResMut, ) { @@ -173,10 +250,14 @@ pub fn update_text2d_layout( let inverse_scale_factor = scale_factor.recip(); - for (entity, text, bounds, text_layout_info, mut buffer) in &mut text_query { - if factor_changed || text.is_changed() || bounds.is_changed() || queue.remove(&entity) { + for (entity, block, bounds, text_layout_info, mut computed) in &mut text_query { + if factor_changed + || computed.needs_rerender() + || bounds.is_changed() + || queue.remove(&entity) + { let text_bounds = TextBounds { - width: if text.linebreak == LineBreak::NoWrap { + width: if block.linebreak == LineBreak::NoWrap { None } else { bounds.width.map(|width| scale_value(width, scale_factor)) @@ -190,17 +271,15 @@ pub fn update_text2d_layout( match text_pipeline.queue_text( text_layout_info, &fonts, - &text.sections, + text_reader.iter(entity), scale_factor.into(), - text.justify, - text.linebreak, - text.font_smoothing, + &block, text_bounds, &mut font_atlas_sets, &mut texture_atlases, &mut textures, YAxisOrientation::BottomToTop, - buffer.as_mut(), + computed.as_mut(), &mut font_system, &mut swash_cache, ) { @@ -265,7 +344,8 @@ mod tests { use bevy_app::{App, Update}; use bevy_asset::{load_internal_binary_asset, Handle}; use bevy_ecs::{event::Events, schedule::IntoSystemConfigs}; - use bevy_utils::default; + + use crate::{detect_text_needs_rerender, TextIterScratch}; use super::*; @@ -282,12 +362,15 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_systems( Update, ( + detect_text_needs_rerender::, update_text2d_layout, - calculate_bounds_text2d.after(update_text2d_layout), - ), + calculate_bounds_text2d, + ) + .chain(), ); // A font is needed to ensure the text is laid out with an actual size. @@ -298,13 +381,7 @@ mod tests { |bytes: &[u8], _path: String| { Font::try_from_bytes(bytes.to_vec()).unwrap() } ); - let entity = app - .world_mut() - .spawn((Text2dBundle { - text: Text::from_section(FIRST_TEXT, default()), - ..default() - },)) - .id(); + let entity = app.world_mut().spawn(Text2d::new(FIRST_TEXT)).id(); (app, entity) } @@ -356,8 +433,8 @@ mod tests { .get_entity_mut(entity) .expect("Could not find entity"); *entity_ref - .get_mut::() - .expect("Missing Text on entity") = Text::from_section(SECOND_TEXT, default()); + .get_mut::() + .expect("Missing Text2d on entity") = Text2d::new(SECOND_TEXT); // Recomputes the AABB. app.update(); diff --git a/crates/bevy_text/src/text_access.rs b/crates/bevy_text/src/text_access.rs new file mode 100644 index 0000000000..e8a9a0a0c0 --- /dev/null +++ b/crates/bevy_text/src/text_access.rs @@ -0,0 +1,381 @@ +use bevy_ecs::{ + prelude::*, + system::{Query, SystemParam}, +}; +use bevy_hierarchy::Children; + +use crate::{TextSpan, TextStyle}; + +/// Helper trait for using the [`TextReader`] and [`TextWriter`] system params. +pub trait TextSpanAccess: Component { + /// Gets the text span's string. + fn read_span(&self) -> &str; + /// Gets mutable reference to the text span's string. + fn write_span(&mut self) -> &mut String; +} + +/// Helper trait for the root text component in a text block. +pub trait TextRoot: TextSpanAccess + From {} + +/// Helper trait for the text span components in a text block. +pub trait TextSpanComponent: TextSpanAccess + From {} + +#[derive(Resource, Default)] +pub(crate) struct TextIterScratch { + stack: Vec<(&'static Children, usize)>, +} + +impl TextIterScratch { + fn take<'a>(&mut self) -> Vec<(&'a Children, usize)> { + core::mem::take(&mut self.stack) + .into_iter() + .map(|_| -> (&Children, usize) { unreachable!() }) + .collect() + } + + fn recover(&mut self, mut stack: Vec<(&Children, usize)>) { + stack.clear(); + self.stack = stack + .into_iter() + .map(|_| -> (&'static Children, usize) { unreachable!() }) + .collect(); + } +} + +/// System parameter for reading text spans in a [`TextBlock`](crate::TextBlock). +/// +/// `R` is the root text component, and `S` is the text span component on children. +#[derive(SystemParam)] +pub struct TextReader<'w, 's, R: TextRoot> { + // This is a local to avoid system ambiguities when TextReaders run in parallel. + scratch: Local<'s, TextIterScratch>, + roots: Query<'w, 's, (&'static R, &'static TextStyle, Option<&'static Children>)>, + spans: Query< + 'w, + 's, + ( + &'static TextSpan, + &'static TextStyle, + Option<&'static Children>, + ), + >, +} + +impl<'w, 's, R: TextRoot> TextReader<'w, 's, R> { + /// Returns an iterator over text spans in a text block, starting with the root entity. + pub fn iter(&mut self, root_entity: Entity) -> TextSpanIter { + let stack = self.scratch.take(); + + TextSpanIter { + scratch: &mut self.scratch, + root_entity: Some(root_entity), + stack, + roots: &self.roots, + spans: &self.spans, + } + } + + /// Gets a text span within a text block at a specific index in the flattened span list. + pub fn get( + &mut self, + root_entity: Entity, + index: usize, + ) -> Option<(Entity, usize, &str, &TextStyle)> { + self.iter(root_entity).nth(index) + } + + /// Gets the text value of a text span within a text block at a specific index in the flattened span list. + pub fn get_text(&mut self, root_entity: Entity, index: usize) -> Option<&str> { + self.get(root_entity, index).map(|(_, _, text, _)| text) + } + + /// Gets the [`TextStyle`] of a text span within a text block at a specific index in the flattened span list. + pub fn get_style(&mut self, root_entity: Entity, index: usize) -> Option<&TextStyle> { + self.get(root_entity, index).map(|(_, _, _, style)| style) + } + + /// Gets the text value of a text span within a text block at a specific index in the flattened span list. + /// + /// Panics if there is no span at the requested index. + pub fn text(&mut self, root_entity: Entity, index: usize) -> &str { + self.get_text(root_entity, index).unwrap() + } + + /// Gets the [`TextStyle`] of a text span within a text block at a specific index in the flattened span list. + /// + /// Panics if there is no span at the requested index. + pub fn style(&mut self, root_entity: Entity, index: usize) -> &TextStyle { + self.get_style(root_entity, index).unwrap() + } +} + +/// Iterator returned by [`TextReader::iter`]. +/// +/// Iterates all spans in a text block according to hierarchy traversal order. +/// Does *not* flatten interspersed ghost nodes. Only contiguous spans are traversed. +// TODO: Use this iterator design in UiChildrenIter to reduce allocations. +pub struct TextSpanIter<'a, R: TextRoot> { + scratch: &'a mut TextIterScratch, + root_entity: Option, + /// Stack of (children, next index into children). + stack: Vec<(&'a Children, usize)>, + roots: &'a Query<'a, 'a, (&'static R, &'static TextStyle, Option<&'static Children>)>, + spans: &'a Query< + 'a, + 'a, + ( + &'static TextSpan, + &'static TextStyle, + Option<&'static Children>, + ), + >, +} + +impl<'a, R: TextRoot> Iterator for TextSpanIter<'a, R> { + /// Item = (entity in text block, hierarchy depth in the block, span text, span style). + type Item = (Entity, usize, &'a str, &'a TextStyle); + fn next(&mut self) -> Option { + // Root + if let Some(root_entity) = self.root_entity.take() { + if let Ok((text, style, maybe_children)) = self.roots.get(root_entity) { + if let Some(children) = maybe_children { + self.stack.push((children, 0)); + } + return Some((root_entity, 0, text.read_span(), style)); + } + return None; + } + + // Span + loop { + let (children, idx) = self.stack.last_mut()?; + + loop { + let Some(child) = children.get(*idx) else { + break; + }; + + // Increment to prep the next entity in this stack level. + *idx += 1; + + let entity = *child; + let Ok((span, style, maybe_children)) = self.spans.get(entity) else { + continue; + }; + + let depth = self.stack.len(); + if let Some(children) = maybe_children { + self.stack.push((children, 0)); + } + return Some((entity, depth, span.read_span(), style)); + } + + // All children at this stack entry have been iterated. + self.stack.pop(); + } + } +} + +impl<'a, R: TextRoot> Drop for TextSpanIter<'a, R> { + fn drop(&mut self) { + // Return the internal stack. + let stack = core::mem::take(&mut self.stack); + self.scratch.recover(stack); + } +} + +/// System parameter for reading and writing text spans in a [`TextBlock`](crate::TextBlock). +/// +/// `R` is the root text component, and `S` is the text span component on children. +#[derive(SystemParam)] +pub struct TextWriter<'w, 's, R: TextRoot> { + // This is a resource because two TextWriters can't run in parallel. + scratch: ResMut<'w, TextIterScratch>, + roots: Query<'w, 's, (&'static mut R, &'static mut TextStyle), Without>, + spans: Query<'w, 's, (&'static mut TextSpan, &'static mut TextStyle), Without>, + children: Query<'w, 's, &'static Children>, +} + +impl<'w, 's, R: TextRoot> TextWriter<'w, 's, R> { + /// Gets a mutable reference to a text span within a text block at a specific index in the flattened span list. + pub fn get( + &mut self, + root_entity: Entity, + index: usize, + ) -> Option<(Entity, usize, Mut, Mut)> { + // Root + if index == 0 { + let (text, style) = self.roots.get_mut(root_entity).ok()?; + return Some(( + root_entity, + 0, + text.map_unchanged(|t| t.write_span()), + style, + )); + } + + // Prep stack. + let mut stack: Vec<(&Children, usize)> = self.scratch.take(); + if let Ok(children) = self.children.get(root_entity) { + stack.push((children, 0)); + } + + // Span + let mut count = 1; + let (depth, entity) = 'l: loop { + let Some((children, idx)) = stack.last_mut() else { + self.scratch.recover(stack); + return None; + }; + + loop { + let Some(child) = children.get(*idx) else { + // All children at this stack entry have been iterated. + stack.pop(); + break; + }; + + // Increment to prep the next entity in this stack level. + *idx += 1; + + if !self.spans.contains(*child) { + continue; + }; + count += 1; + + if count - 1 == index { + let depth = stack.len(); + self.scratch.recover(stack); + break 'l (depth, *child); + } + + if let Ok(children) = self.children.get(*child) { + stack.push((children, 0)); + break; + } + } + }; + + // Note: We do this outside the loop due to borrow checker limitations. + let (text, style) = self.spans.get_mut(entity).unwrap(); + Some((entity, depth, text.map_unchanged(|t| t.write_span()), style)) + } + + /// Gets the text value of a text span within a text block at a specific index in the flattened span list. + pub fn get_text(&mut self, root_entity: Entity, index: usize) -> Option> { + self.get(root_entity, index).map(|(_, _, text, _)| text) + } + + /// Gets the [`TextStyle`] of a text span within a text block at a specific index in the flattened span list. + pub fn get_style(&mut self, root_entity: Entity, index: usize) -> Option> { + self.get(root_entity, index).map(|(_, _, _, style)| style) + } + + /// Gets the text value of a text span within a text block at a specific index in the flattened span list. + /// + /// Panics if there is no span at the requested index. + pub fn text(&mut self, root_entity: Entity, index: usize) -> Mut { + self.get_text(root_entity, index).unwrap() + } + + /// Gets the [`TextStyle`] of a text span within a text block at a specific index in the flattened span list. + /// + /// Panics if there is no span at the requested index. + pub fn style(&mut self, root_entity: Entity, index: usize) -> Mut { + self.get_style(root_entity, index).unwrap() + } + + /// Invokes a callback on each span in a text block, starting with the root entity. + pub fn for_each( + &mut self, + root_entity: Entity, + mut callback: impl FnMut(Entity, usize, Mut, Mut), + ) { + self.for_each_until(root_entity, |a, b, c, d| { + (callback)(a, b, c, d); + true + }); + } + + /// Invokes a callback on each span's string value in a text block, starting with the root entity. + pub fn for_each_text(&mut self, root_entity: Entity, mut callback: impl FnMut(Mut)) { + self.for_each(root_entity, |_, _, text, _| { + (callback)(text); + }); + } + + /// Invokes a callback on each span's [`TextStyle`] in a text block, starting with the root entity. + pub fn for_each_style( + &mut self, + root_entity: Entity, + mut callback: impl FnMut(Mut), + ) { + self.for_each(root_entity, |_, _, _, style| { + (callback)(style); + }); + } + + /// Invokes a callback on each span in a text block, starting with the root entity. + /// + /// Traversal will stop when the callback returns `false`. + // TODO: find a way to consolidate get and for_each_until, or provide a real iterator. Lifetime issues are challenging here. + pub fn for_each_until( + &mut self, + root_entity: Entity, + mut callback: impl FnMut(Entity, usize, Mut, Mut) -> bool, + ) { + // Root + let Ok((text, style)) = self.roots.get_mut(root_entity) else { + return; + }; + if !(callback)( + root_entity, + 0, + text.map_unchanged(|t| t.write_span()), + style, + ) { + return; + } + + // Prep stack. + let mut stack: Vec<(&Children, usize)> = self.scratch.take(); + if let Ok(children) = self.children.get(root_entity) { + stack.push((children, 0)); + } + + // Span + loop { + let depth = stack.len(); + let Some((children, idx)) = stack.last_mut() else { + self.scratch.recover(stack); + return; + }; + + loop { + let Some(child) = children.get(*idx) else { + // All children at this stack entry have been iterated. + stack.pop(); + break; + }; + + // Increment to prep the next entity in this stack level. + *idx += 1; + + let entity = *child; + let Ok((text, style)) = self.spans.get_mut(entity) else { + continue; + }; + + if !(callback)(entity, depth, text.map_unchanged(|t| t.write_span()), style) { + self.scratch.recover(stack); + return; + } + + if let Ok(children) = self.children.get(entity) { + stack.push((children, 0)); + break; + } + } + } + } +} diff --git a/crates/bevy_ui/src/accessibility.rs b/crates/bevy_ui/src/accessibility.rs index fb17415fe2..451cc2637c 100644 --- a/crates/bevy_ui/src/accessibility.rs +++ b/crates/bevy_ui/src/accessibility.rs @@ -1,5 +1,6 @@ use crate::{ prelude::{Button, Label}, + widget::UiTextReader, Node, UiChildren, UiImage, }; use bevy_a11y::{ @@ -15,18 +16,19 @@ use bevy_ecs::{ world::Ref, }; use bevy_render::{camera::CameraUpdateSystem, prelude::Camera}; -use bevy_text::Text; use bevy_transform::prelude::GlobalTransform; -fn calc_name(texts: &Query<&Text>, children: impl Iterator) -> Option> { +fn calc_name( + text_reader: &mut UiTextReader, + children: impl Iterator, +) -> Option> { let mut name = None; for child in children { - if let Ok(text) = texts.get(child) { - let values = text - .sections - .iter() - .map(|v| v.value.to_string()) - .collect::>(); + let values = text_reader + .iter(child) + .map(|(_, _, text, _)| text.into()) + .collect::>(); + if !values.is_empty() { name = Some(values.join(" ")); } } @@ -60,10 +62,10 @@ fn button_changed( mut commands: Commands, mut query: Query<(Entity, Option<&mut AccessibilityNode>), Changed