diff --git a/Cargo.toml b/Cargo.toml index 8bb16b741db86..f764793161a9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3344,6 +3344,17 @@ description = "Illustrates creating and updating text" category = "UI (User Interface)" wasm = true +[[example]] +name = "text_background_colors" +path = "examples/ui/text_background_colors.rs" +doc-scrape-examples = true + +[package.metadata.example.text_background_colors] +name = "Text Background Colors" +description = "Demonstrates text background colors" +category = "UI (User Interface)" +wasm = true + [[example]] name = "text_debug" path = "examples/ui/text_debug.rs" diff --git a/crates/bevy_text/src/lib.rs b/crates/bevy_text/src/lib.rs index 670f793c31c15..18d7ad01a7366 100644 --- a/crates/bevy_text/src/lib.rs +++ b/crates/bevy_text/src/lib.rs @@ -110,6 +110,7 @@ impl Plugin for TextPlugin { .register_type::() .register_type::() .register_type::() + .register_type::() .register_type::() .register_type::() .register_type::() diff --git a/crates/bevy_text/src/pipeline.rs b/crates/bevy_text/src/pipeline.rs index f1bdeded917bb..ebaa10b12b433 100644 --- a/crates/bevy_text/src/pipeline.rs +++ b/crates/bevy_text/src/pipeline.rs @@ -9,7 +9,7 @@ use bevy_ecs::{ }; use bevy_image::prelude::*; use bevy_log::{once, warn}; -use bevy_math::{UVec2, Vec2}; +use bevy_math::{Rect, UVec2, Vec2}; use bevy_platform::collections::HashMap; use bevy_reflect::{std_traits::ReflectDefault, Reflect}; @@ -234,6 +234,7 @@ impl TextPipeline { swash_cache: &mut SwashCache, ) -> Result<(), TextError> { layout_info.glyphs.clear(); + layout_info.section_rects.clear(); layout_info.size = Default::default(); // Clear this here at the focal point of text rendering to ensure the field's lifecycle has strong boundaries. @@ -265,11 +266,38 @@ impl TextPipeline { let box_size = buffer_dimensions(buffer); let result = buffer.layout_runs().try_for_each(|run| { + let mut current_section: Option = None; + let mut start = 0.; + let mut end = 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)| { + match current_section { + Some(section) => { + if section != layout_glyph.metadata { + layout_info.section_rects.push(( + computed.entities[section].entity, + Rect::new( + start, + run.line_top, + end, + run.line_top + run.line_height, + ), + )); + start = end.max(layout_glyph.x); + current_section = Some(layout_glyph.metadata); + } + end = layout_glyph.x + layout_glyph.w; + } + None => { + current_section = Some(layout_glyph.metadata); + start = layout_glyph.x; + end = start + layout_glyph.w; + } + } + let mut temp_glyph; let span_index = layout_glyph.metadata; let font_id = glyph_info[span_index].0; @@ -339,6 +367,12 @@ impl TextPipeline { layout_info.glyphs.push(pos_glyph); Ok(()) }); + if let Some(section) = current_section { + layout_info.section_rects.push(( + computed.entities[section].entity, + Rect::new(start, run.line_top, end, run.line_top + run.line_height), + )); + } result }); @@ -418,6 +452,9 @@ impl TextPipeline { pub struct TextLayoutInfo { /// Scaled and positioned glyphs in screenspace pub glyphs: Vec, + /// Rects bounding the text block's text sections. + /// A text section spanning more than one line will have multiple bounding rects. + pub section_rects: Vec<(Entity, Rect)>, /// The glyphs resulting size pub size: Vec2, } diff --git a/crates/bevy_text/src/text.rs b/crates/bevy_text/src/text.rs index faa5d93dc9cee..e9e78e3ed21b1 100644 --- a/crates/bevy_text/src/text.rs +++ b/crates/bevy_text/src/text.rs @@ -407,6 +407,30 @@ impl TextColor { pub const WHITE: Self = TextColor(Color::WHITE); } +/// The background color of the text for this section. +#[derive(Component, Copy, Clone, Debug, Deref, DerefMut, Reflect, PartialEq)] +#[reflect(Component, Default, Debug, PartialEq, Clone)] +pub struct TextBackgroundColor(pub Color); + +impl Default for TextBackgroundColor { + fn default() -> Self { + Self(Color::BLACK) + } +} + +impl> From for TextBackgroundColor { + fn from(color: T) -> Self { + Self(color.into()) + } +} + +impl TextBackgroundColor { + /// Black background + pub const BLACK: Self = TextBackgroundColor(Color::BLACK); + /// White background + pub const WHITE: Self = TextBackgroundColor(Color::WHITE); +} + /// Determines how lines will be broken when preventing text from running out of bounds. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Reflect, Serialize, Deserialize)] #[reflect(Serialize, Deserialize, Clone, PartialEq, Hash, Default)] diff --git a/crates/bevy_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index 56462b6952f23..4db3073a90a18 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -64,6 +64,7 @@ pub mod prelude { }, // `bevy_sprite` re-exports for texture slicing bevy_sprite::{BorderRect, SliceScaleMode, SpriteImageMode, TextureSlicer}, + bevy_text::TextBackgroundColor, }; } diff --git a/crates/bevy_ui/src/render/mod.rs b/crates/bevy_ui/src/render/mod.rs index 8cb61cde217e8..97ba9cd7ee4e4 100644 --- a/crates/bevy_ui/src/render/mod.rs +++ b/crates/bevy_ui/src/render/mod.rs @@ -51,7 +51,9 @@ pub use debug_overlay::UiDebugOptions; use crate::{Display, Node}; use bevy_platform::collections::{HashMap, HashSet}; -use bevy_text::{ComputedTextBlock, PositionedGlyph, TextColor, TextLayoutInfo}; +use bevy_text::{ + ComputedTextBlock, PositionedGlyph, TextBackgroundColor, TextColor, TextLayoutInfo, +}; use bevy_transform::components::GlobalTransform; use box_shadow::BoxShadowPlugin; use bytemuck::{Pod, Zeroable}; @@ -105,6 +107,7 @@ pub enum RenderUiSystem { ExtractImages, ExtractTextureSlice, ExtractBorders, + ExtractTextBackgrounds, ExtractTextShadows, ExtractText, ExtractDebug, @@ -135,6 +138,7 @@ pub fn build_ui_render(app: &mut App) { RenderUiSystem::ExtractImages, RenderUiSystem::ExtractTextureSlice, RenderUiSystem::ExtractBorders, + RenderUiSystem::ExtractTextBackgrounds, RenderUiSystem::ExtractTextShadows, RenderUiSystem::ExtractText, RenderUiSystem::ExtractDebug, @@ -148,6 +152,7 @@ pub fn build_ui_render(app: &mut App) { extract_uinode_background_colors.in_set(RenderUiSystem::ExtractBackgrounds), extract_uinode_images.in_set(RenderUiSystem::ExtractImages), extract_uinode_borders.in_set(RenderUiSystem::ExtractBorders), + extract_text_background_colors.in_set(RenderUiSystem::ExtractTextBackgrounds), extract_text_shadows.in_set(RenderUiSystem::ExtractTextShadows), extract_text_sections.in_set(RenderUiSystem::ExtractText), #[cfg(feature = "bevy_ui_debug")] @@ -879,6 +884,70 @@ pub fn extract_text_shadows( } } +pub fn extract_text_background_colors( + mut commands: Commands, + mut extracted_uinodes: ResMut, + uinode_query: Extract< + Query<( + Entity, + &ComputedNode, + &GlobalTransform, + &InheritedVisibility, + Option<&CalculatedClip>, + &ComputedNodeTarget, + &TextLayoutInfo, + )>, + >, + text_background_colors_query: Extract>, + camera_map: Extract, +) { + let mut camera_mapper = camera_map.get_mapper(); + for (entity, uinode, global_transform, inherited_visibility, clip, camera, text_layout_info) in + &uinode_query + { + // Skip if not visible or if size is set to zero (e.g. when a parent is set to `Display::None`) + if !inherited_visibility.get() || uinode.is_empty() { + continue; + } + + let Some(extracted_camera_entity) = camera_mapper.map(camera) else { + continue; + }; + + let transform = global_transform.affine() + * bevy_math::Affine3A::from_translation(-0.5 * uinode.size().extend(0.)); + + for &(section_entity, rect) in text_layout_info.section_rects.iter() { + let Ok(text_background_color) = text_background_colors_query.get(section_entity) else { + continue; + }; + + extracted_uinodes.uinodes.push(ExtractedUiNode { + render_entity: commands.spawn(TemporaryRenderEntity).id(), + stack_index: uinode.stack_index, + color: text_background_color.0.to_linear(), + rect: Rect { + min: Vec2::ZERO, + max: rect.size(), + }, + clip: clip.map(|clip| clip.clip), + image: AssetId::default(), + extracted_camera_entity, + item: ExtractedUiItem::Node { + atlas_scaling: None, + transform: transform * Mat4::from_translation(rect.center().extend(0.)), + flip_x: false, + flip_y: false, + border: uinode.border(), + border_radius: uinode.border_radius(), + node_type: NodeType::Rect, + }, + main_entity: entity.into(), + }); + } + } +} + #[repr(C)] #[derive(Copy, Clone, Pod, Zeroable)] struct UiVertex { diff --git a/examples/README.md b/examples/README.md index d0e33d957f2d4..aa91006e72091 100644 --- a/examples/README.md +++ b/examples/README.md @@ -555,6 +555,7 @@ Example | Description [Size Constraints](../examples/ui/size_constraints.rs) | Demonstrates how the to use the size constraints to control the size of a UI node. [Tab Navigation](../examples/ui/tab_navigation.rs) | Demonstration of Tab Navigation between UI elements [Text](../examples/ui/text.rs) | Illustrates creating and updating text +[Text Background Colors](../examples/ui/text_background_colors.rs) | Demonstrates text background colors [Text Debug](../examples/ui/text_debug.rs) | An example for debugging text layout [Text Wrap Debug](../examples/ui/text_wrap_debug.rs) | Demonstrates text wrapping [Transparency UI](../examples/ui/transparency_ui.rs) | Demonstrates transparency for UI diff --git a/examples/ui/text_background_colors.rs b/examples/ui/text_background_colors.rs new file mode 100644 index 0000000000000..caf0b60e85097 --- /dev/null +++ b/examples/ui/text_background_colors.rs @@ -0,0 +1,77 @@ +//! This example demonstrates UI text with a background color + +use bevy::{ + color::palettes::css::{BLUE, GREEN, PURPLE, RED, YELLOW}, + prelude::*, +}; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, setup) + .add_systems(Update, cycle_text_background_colors) + .run(); +} + +const PALETTE: [Color; 5] = [ + Color::Srgba(RED), + Color::Srgba(GREEN), + Color::Srgba(BLUE), + Color::Srgba(YELLOW), + Color::Srgba(PURPLE), +]; + +fn setup(mut commands: Commands) { + // UI camera + commands.spawn(Camera2d); + + let message_text = [ + "T", "e", "x", "t\n", "B", "a", "c", "k", "g", "r", "o", "u", "n", "d\n", "C", "o", "l", + "o", "r", "s", "!", + ]; + + commands + .spawn(Node { + width: Val::Percent(100.), + height: Val::Percent(100.), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + ..Default::default() + }) + .with_children(|commands| { + commands + .spawn(( + Text::default(), + TextLayout { + justify: JustifyText::Center, + ..Default::default() + }, + )) + .with_children(|commands| { + for (i, section_str) in message_text.iter().enumerate() { + commands.spawn(( + TextSpan::new(*section_str), + TextColor::BLACK, + TextFont { + font_size: 100., + ..default() + }, + TextBackgroundColor(PALETTE[i % PALETTE.len()]), + )); + } + }); + }); +} + +fn cycle_text_background_colors( + time: Res