Skip to content

Commit 5e2ecf4

Browse files
ickshonpeOlle-LukowskiHenauxg
authored
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 <[email protected]> Co-authored-by: Gilles Henaux <[email protected]>
1 parent 8c34cbb commit 5e2ecf4

File tree

9 files changed

+230
-2
lines changed

9 files changed

+230
-2
lines changed

Cargo.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3344,6 +3344,17 @@ description = "Illustrates creating and updating text"
33443344
category = "UI (User Interface)"
33453345
wasm = true
33463346

3347+
[[example]]
3348+
name = "text_background_colors"
3349+
path = "examples/ui/text_background_colors.rs"
3350+
doc-scrape-examples = true
3351+
3352+
[package.metadata.example.text_background_colors]
3353+
name = "Text Background Colors"
3354+
description = "Demonstrates text background colors"
3355+
category = "UI (User Interface)"
3356+
wasm = true
3357+
33473358
[[example]]
33483359
name = "text_debug"
33493360
path = "examples/ui/text_debug.rs"

crates/bevy_text/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ impl Plugin for TextPlugin {
110110
.register_type::<TextFont>()
111111
.register_type::<LineHeight>()
112112
.register_type::<TextColor>()
113+
.register_type::<TextBackgroundColor>()
113114
.register_type::<TextSpan>()
114115
.register_type::<TextBounds>()
115116
.register_type::<TextLayout>()

crates/bevy_text/src/pipeline.rs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use bevy_ecs::{
99
};
1010
use bevy_image::prelude::*;
1111
use bevy_log::{once, warn};
12-
use bevy_math::{UVec2, Vec2};
12+
use bevy_math::{Rect, UVec2, Vec2};
1313
use bevy_platform::collections::HashMap;
1414
use bevy_reflect::{std_traits::ReflectDefault, Reflect};
1515

@@ -234,6 +234,7 @@ impl TextPipeline {
234234
swash_cache: &mut SwashCache,
235235
) -> Result<(), TextError> {
236236
layout_info.glyphs.clear();
237+
layout_info.section_rects.clear();
237238
layout_info.size = Default::default();
238239

239240
// 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 {
265266
let box_size = buffer_dimensions(buffer);
266267

267268
let result = buffer.layout_runs().try_for_each(|run| {
269+
let mut current_section: Option<usize> = None;
270+
let mut start = 0.;
271+
let mut end = 0.;
268272
let result = run
269273
.glyphs
270274
.iter()
271275
.map(move |layout_glyph| (layout_glyph, run.line_y, run.line_i))
272276
.try_for_each(|(layout_glyph, line_y, line_i)| {
277+
match current_section {
278+
Some(section) => {
279+
if section != layout_glyph.metadata {
280+
layout_info.section_rects.push((
281+
computed.entities[section].entity,
282+
Rect::new(
283+
start,
284+
run.line_top,
285+
end,
286+
run.line_top + run.line_height,
287+
),
288+
));
289+
start = end.max(layout_glyph.x);
290+
current_section = Some(layout_glyph.metadata);
291+
}
292+
end = layout_glyph.x + layout_glyph.w;
293+
}
294+
None => {
295+
current_section = Some(layout_glyph.metadata);
296+
start = layout_glyph.x;
297+
end = start + layout_glyph.w;
298+
}
299+
}
300+
273301
let mut temp_glyph;
274302
let span_index = layout_glyph.metadata;
275303
let font_id = glyph_info[span_index].0;
@@ -339,6 +367,12 @@ impl TextPipeline {
339367
layout_info.glyphs.push(pos_glyph);
340368
Ok(())
341369
});
370+
if let Some(section) = current_section {
371+
layout_info.section_rects.push((
372+
computed.entities[section].entity,
373+
Rect::new(start, run.line_top, end, run.line_top + run.line_height),
374+
));
375+
}
342376

343377
result
344378
});
@@ -418,6 +452,9 @@ impl TextPipeline {
418452
pub struct TextLayoutInfo {
419453
/// Scaled and positioned glyphs in screenspace
420454
pub glyphs: Vec<PositionedGlyph>,
455+
/// Rects bounding the text block's text sections.
456+
/// A text section spanning more than one line will have multiple bounding rects.
457+
pub section_rects: Vec<(Entity, Rect)>,
421458
/// The glyphs resulting size
422459
pub size: Vec2,
423460
}

crates/bevy_text/src/text.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,30 @@ impl TextColor {
407407
pub const WHITE: Self = TextColor(Color::WHITE);
408408
}
409409

410+
/// The background color of the text for this section.
411+
#[derive(Component, Copy, Clone, Debug, Deref, DerefMut, Reflect, PartialEq)]
412+
#[reflect(Component, Default, Debug, PartialEq, Clone)]
413+
pub struct TextBackgroundColor(pub Color);
414+
415+
impl Default for TextBackgroundColor {
416+
fn default() -> Self {
417+
Self(Color::BLACK)
418+
}
419+
}
420+
421+
impl<T: Into<Color>> From<T> for TextBackgroundColor {
422+
fn from(color: T) -> Self {
423+
Self(color.into())
424+
}
425+
}
426+
427+
impl TextBackgroundColor {
428+
/// Black background
429+
pub const BLACK: Self = TextBackgroundColor(Color::BLACK);
430+
/// White background
431+
pub const WHITE: Self = TextBackgroundColor(Color::WHITE);
432+
}
433+
410434
/// Determines how lines will be broken when preventing text from running out of bounds.
411435
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Reflect, Serialize, Deserialize)]
412436
#[reflect(Serialize, Deserialize, Clone, PartialEq, Hash, Default)]

crates/bevy_ui/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ pub mod prelude {
6464
},
6565
// `bevy_sprite` re-exports for texture slicing
6666
bevy_sprite::{BorderRect, SliceScaleMode, SpriteImageMode, TextureSlicer},
67+
bevy_text::TextBackgroundColor,
6768
};
6869
}
6970

crates/bevy_ui/src/render/mod.rs

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ pub use debug_overlay::UiDebugOptions;
5151

5252
use crate::{Display, Node};
5353
use bevy_platform::collections::{HashMap, HashSet};
54-
use bevy_text::{ComputedTextBlock, PositionedGlyph, TextColor, TextLayoutInfo};
54+
use bevy_text::{
55+
ComputedTextBlock, PositionedGlyph, TextBackgroundColor, TextColor, TextLayoutInfo,
56+
};
5557
use bevy_transform::components::GlobalTransform;
5658
use box_shadow::BoxShadowPlugin;
5759
use bytemuck::{Pod, Zeroable};
@@ -105,6 +107,7 @@ pub enum RenderUiSystem {
105107
ExtractImages,
106108
ExtractTextureSlice,
107109
ExtractBorders,
110+
ExtractTextBackgrounds,
108111
ExtractTextShadows,
109112
ExtractText,
110113
ExtractDebug,
@@ -135,6 +138,7 @@ pub fn build_ui_render(app: &mut App) {
135138
RenderUiSystem::ExtractImages,
136139
RenderUiSystem::ExtractTextureSlice,
137140
RenderUiSystem::ExtractBorders,
141+
RenderUiSystem::ExtractTextBackgrounds,
138142
RenderUiSystem::ExtractTextShadows,
139143
RenderUiSystem::ExtractText,
140144
RenderUiSystem::ExtractDebug,
@@ -148,6 +152,7 @@ pub fn build_ui_render(app: &mut App) {
148152
extract_uinode_background_colors.in_set(RenderUiSystem::ExtractBackgrounds),
149153
extract_uinode_images.in_set(RenderUiSystem::ExtractImages),
150154
extract_uinode_borders.in_set(RenderUiSystem::ExtractBorders),
155+
extract_text_background_colors.in_set(RenderUiSystem::ExtractTextBackgrounds),
151156
extract_text_shadows.in_set(RenderUiSystem::ExtractTextShadows),
152157
extract_text_sections.in_set(RenderUiSystem::ExtractText),
153158
#[cfg(feature = "bevy_ui_debug")]
@@ -879,6 +884,70 @@ pub fn extract_text_shadows(
879884
}
880885
}
881886

887+
pub fn extract_text_background_colors(
888+
mut commands: Commands,
889+
mut extracted_uinodes: ResMut<ExtractedUiNodes>,
890+
uinode_query: Extract<
891+
Query<(
892+
Entity,
893+
&ComputedNode,
894+
&GlobalTransform,
895+
&InheritedVisibility,
896+
Option<&CalculatedClip>,
897+
&ComputedNodeTarget,
898+
&TextLayoutInfo,
899+
)>,
900+
>,
901+
text_background_colors_query: Extract<Query<&TextBackgroundColor>>,
902+
camera_map: Extract<UiCameraMap>,
903+
) {
904+
let mut camera_mapper = camera_map.get_mapper();
905+
for (entity, uinode, global_transform, inherited_visibility, clip, camera, text_layout_info) in
906+
&uinode_query
907+
{
908+
// Skip if not visible or if size is set to zero (e.g. when a parent is set to `Display::None`)
909+
if !inherited_visibility.get() || uinode.is_empty() {
910+
continue;
911+
}
912+
913+
let Some(extracted_camera_entity) = camera_mapper.map(camera) else {
914+
continue;
915+
};
916+
917+
let transform = global_transform.affine()
918+
* bevy_math::Affine3A::from_translation(-0.5 * uinode.size().extend(0.));
919+
920+
for &(section_entity, rect) in text_layout_info.section_rects.iter() {
921+
let Ok(text_background_color) = text_background_colors_query.get(section_entity) else {
922+
continue;
923+
};
924+
925+
extracted_uinodes.uinodes.push(ExtractedUiNode {
926+
render_entity: commands.spawn(TemporaryRenderEntity).id(),
927+
stack_index: uinode.stack_index,
928+
color: text_background_color.0.to_linear(),
929+
rect: Rect {
930+
min: Vec2::ZERO,
931+
max: rect.size(),
932+
},
933+
clip: clip.map(|clip| clip.clip),
934+
image: AssetId::default(),
935+
extracted_camera_entity,
936+
item: ExtractedUiItem::Node {
937+
atlas_scaling: None,
938+
transform: transform * Mat4::from_translation(rect.center().extend(0.)),
939+
flip_x: false,
940+
flip_y: false,
941+
border: uinode.border(),
942+
border_radius: uinode.border_radius(),
943+
node_type: NodeType::Rect,
944+
},
945+
main_entity: entity.into(),
946+
});
947+
}
948+
}
949+
}
950+
882951
#[repr(C)]
883952
#[derive(Copy, Clone, Pod, Zeroable)]
884953
struct UiVertex {

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,7 @@ Example | Description
555555
[Size Constraints](../examples/ui/size_constraints.rs) | Demonstrates how the to use the size constraints to control the size of a UI node.
556556
[Tab Navigation](../examples/ui/tab_navigation.rs) | Demonstration of Tab Navigation between UI elements
557557
[Text](../examples/ui/text.rs) | Illustrates creating and updating text
558+
[Text Background Colors](../examples/ui/text_background_colors.rs) | Demonstrates text background colors
558559
[Text Debug](../examples/ui/text_debug.rs) | An example for debugging text layout
559560
[Text Wrap Debug](../examples/ui/text_wrap_debug.rs) | Demonstrates text wrapping
560561
[Transparency UI](../examples/ui/transparency_ui.rs) | Demonstrates transparency for UI

examples/ui/text_background_colors.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
//! This example demonstrates UI text with a background color
2+
3+
use bevy::{
4+
color::palettes::css::{BLUE, GREEN, PURPLE, RED, YELLOW},
5+
prelude::*,
6+
};
7+
8+
fn main() {
9+
App::new()
10+
.add_plugins(DefaultPlugins)
11+
.add_systems(Startup, setup)
12+
.add_systems(Update, cycle_text_background_colors)
13+
.run();
14+
}
15+
16+
const PALETTE: [Color; 5] = [
17+
Color::Srgba(RED),
18+
Color::Srgba(GREEN),
19+
Color::Srgba(BLUE),
20+
Color::Srgba(YELLOW),
21+
Color::Srgba(PURPLE),
22+
];
23+
24+
fn setup(mut commands: Commands) {
25+
// UI camera
26+
commands.spawn(Camera2d);
27+
28+
let message_text = [
29+
"T", "e", "x", "t\n", "B", "a", "c", "k", "g", "r", "o", "u", "n", "d\n", "C", "o", "l",
30+
"o", "r", "s", "!",
31+
];
32+
33+
commands
34+
.spawn(Node {
35+
width: Val::Percent(100.),
36+
height: Val::Percent(100.),
37+
justify_content: JustifyContent::Center,
38+
align_items: AlignItems::Center,
39+
..Default::default()
40+
})
41+
.with_children(|commands| {
42+
commands
43+
.spawn((
44+
Text::default(),
45+
TextLayout {
46+
justify: JustifyText::Center,
47+
..Default::default()
48+
},
49+
))
50+
.with_children(|commands| {
51+
for (i, section_str) in message_text.iter().enumerate() {
52+
commands.spawn((
53+
TextSpan::new(*section_str),
54+
TextColor::BLACK,
55+
TextFont {
56+
font_size: 100.,
57+
..default()
58+
},
59+
TextBackgroundColor(PALETTE[i % PALETTE.len()]),
60+
));
61+
}
62+
});
63+
});
64+
}
65+
66+
fn cycle_text_background_colors(
67+
time: Res<Time>,
68+
children_query: Query<&Children, With<Text>>,
69+
mut text_background_colors_query: Query<&mut TextBackgroundColor>,
70+
) {
71+
let n = time.elapsed_secs() as usize;
72+
let children = children_query.single().unwrap();
73+
74+
for (i, child) in children.iter().enumerate() {
75+
text_background_colors_query.get_mut(child).unwrap().0 = PALETTE[(i + n) % PALETTE.len()];
76+
}
77+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
title: Text Background Colors
3+
authors: ["@Ickshonpe"]
4+
pull_requests: [18892]
5+
---
6+
7+
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.

0 commit comments

Comments
 (0)