Text background colors (#18892)

# Objective

Add background colors for text.

Fixes #18889

## Solution

New component `TextBackgroundColor`, add it to any UI `Text` or
`TextSpan` entity to add a background color to its text.
New field on `TextLayoutInfo` `section_rects` holds the list of bounding
rects for each text section.

The bounding rects are generated in `TextPipeline::queue_text` during
text layout, `extract_text_background_colors` extracts the colored
background rects for rendering.

Didn't include `Text2d` support because of z-order issues.

The section rects can also be used to implement interactions targeting
individual text sections.

## Testing
Includes a basic example that can be used for testing:
```
cargo run --example text_background_colors
```
---

## Showcase


![tbcm](https://github.com/user-attachments/assets/e584e197-1a8c-4248-82ab-2461d904a85b)

Using a proportional font with kerning the results aren't so tidy (since
the bounds of adjacent glyphs can overlap) but it still works fine:


![tbc](https://github.com/user-attachments/assets/788bb052-4216-4019-a594-7c1b41164dd5)

---------

Co-authored-by: Olle Lukowski <lukowskiolle@gmail.com>
Co-authored-by: Gilles Henaux <ghx_github_priv@fastmail.com>
This commit is contained in:
ickshonpe 2025-05-04 09:18:46 +01:00 committed by GitHub
parent 8c34cbbb27
commit 5e2ecf4178
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 230 additions and 2 deletions

View File

@ -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"

View File

@ -110,6 +110,7 @@ impl Plugin for TextPlugin {
.register_type::<TextFont>()
.register_type::<LineHeight>()
.register_type::<TextColor>()
.register_type::<TextBackgroundColor>()
.register_type::<TextSpan>()
.register_type::<TextBounds>()
.register_type::<TextLayout>()

View File

@ -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<usize> = 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<PositionedGlyph>,
/// 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,
}

View File

@ -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<T: Into<Color>> From<T> 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)]

View File

@ -64,6 +64,7 @@ pub mod prelude {
},
// `bevy_sprite` re-exports for texture slicing
bevy_sprite::{BorderRect, SliceScaleMode, SpriteImageMode, TextureSlicer},
bevy_text::TextBackgroundColor,
};
}

View File

@ -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<ExtractedUiNodes>,
uinode_query: Extract<
Query<(
Entity,
&ComputedNode,
&GlobalTransform,
&InheritedVisibility,
Option<&CalculatedClip>,
&ComputedNodeTarget,
&TextLayoutInfo,
)>,
>,
text_background_colors_query: Extract<Query<&TextBackgroundColor>>,
camera_map: Extract<UiCameraMap>,
) {
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 {

View File

@ -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

View File

@ -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<Time>,
children_query: Query<&Children, With<Text>>,
mut text_background_colors_query: Query<&mut TextBackgroundColor>,
) {
let n = time.elapsed_secs() as usize;
let children = children_query.single().unwrap();
for (i, child) in children.iter().enumerate() {
text_background_colors_query.get_mut(child).unwrap().0 = PALETTE[(i + n) % PALETTE.len()];
}
}

View File

@ -0,0 +1,7 @@
---
title: Text Background Colors
authors: ["@Ickshonpe"]
pull_requests: [18892]
---
UI Text now supports background colors. Insert the `TextBackgroundColor` component on a UI `Text` or `TextSpan` entity to set a background color for its text section.