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  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:  --------- Co-authored-by: Olle Lukowski <lukowskiolle@gmail.com> Co-authored-by: Gilles Henaux <ghx_github_priv@fastmail.com>
This commit is contained in:
parent
8c34cbbb27
commit
5e2ecf4178
11
Cargo.toml
11
Cargo.toml
@ -3344,6 +3344,17 @@ description = "Illustrates creating and updating text"
|
|||||||
category = "UI (User Interface)"
|
category = "UI (User Interface)"
|
||||||
wasm = true
|
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]]
|
[[example]]
|
||||||
name = "text_debug"
|
name = "text_debug"
|
||||||
path = "examples/ui/text_debug.rs"
|
path = "examples/ui/text_debug.rs"
|
||||||
|
|||||||
@ -110,6 +110,7 @@ impl Plugin for TextPlugin {
|
|||||||
.register_type::<TextFont>()
|
.register_type::<TextFont>()
|
||||||
.register_type::<LineHeight>()
|
.register_type::<LineHeight>()
|
||||||
.register_type::<TextColor>()
|
.register_type::<TextColor>()
|
||||||
|
.register_type::<TextBackgroundColor>()
|
||||||
.register_type::<TextSpan>()
|
.register_type::<TextSpan>()
|
||||||
.register_type::<TextBounds>()
|
.register_type::<TextBounds>()
|
||||||
.register_type::<TextLayout>()
|
.register_type::<TextLayout>()
|
||||||
|
|||||||
@ -9,7 +9,7 @@ use bevy_ecs::{
|
|||||||
};
|
};
|
||||||
use bevy_image::prelude::*;
|
use bevy_image::prelude::*;
|
||||||
use bevy_log::{once, warn};
|
use bevy_log::{once, warn};
|
||||||
use bevy_math::{UVec2, Vec2};
|
use bevy_math::{Rect, UVec2, Vec2};
|
||||||
use bevy_platform::collections::HashMap;
|
use bevy_platform::collections::HashMap;
|
||||||
use bevy_reflect::{std_traits::ReflectDefault, Reflect};
|
use bevy_reflect::{std_traits::ReflectDefault, Reflect};
|
||||||
|
|
||||||
@ -234,6 +234,7 @@ impl TextPipeline {
|
|||||||
swash_cache: &mut SwashCache,
|
swash_cache: &mut SwashCache,
|
||||||
) -> Result<(), TextError> {
|
) -> Result<(), TextError> {
|
||||||
layout_info.glyphs.clear();
|
layout_info.glyphs.clear();
|
||||||
|
layout_info.section_rects.clear();
|
||||||
layout_info.size = Default::default();
|
layout_info.size = Default::default();
|
||||||
|
|
||||||
// Clear this here at the focal point of text rendering to ensure the field's lifecycle has strong boundaries.
|
// 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 box_size = buffer_dimensions(buffer);
|
||||||
|
|
||||||
let result = buffer.layout_runs().try_for_each(|run| {
|
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
|
let result = run
|
||||||
.glyphs
|
.glyphs
|
||||||
.iter()
|
.iter()
|
||||||
.map(move |layout_glyph| (layout_glyph, run.line_y, run.line_i))
|
.map(move |layout_glyph| (layout_glyph, run.line_y, run.line_i))
|
||||||
.try_for_each(|(layout_glyph, line_y, 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 mut temp_glyph;
|
||||||
let span_index = layout_glyph.metadata;
|
let span_index = layout_glyph.metadata;
|
||||||
let font_id = glyph_info[span_index].0;
|
let font_id = glyph_info[span_index].0;
|
||||||
@ -339,6 +367,12 @@ impl TextPipeline {
|
|||||||
layout_info.glyphs.push(pos_glyph);
|
layout_info.glyphs.push(pos_glyph);
|
||||||
Ok(())
|
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
|
result
|
||||||
});
|
});
|
||||||
@ -418,6 +452,9 @@ impl TextPipeline {
|
|||||||
pub struct TextLayoutInfo {
|
pub struct TextLayoutInfo {
|
||||||
/// Scaled and positioned glyphs in screenspace
|
/// Scaled and positioned glyphs in screenspace
|
||||||
pub glyphs: Vec<PositionedGlyph>,
|
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
|
/// The glyphs resulting size
|
||||||
pub size: Vec2,
|
pub size: Vec2,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -407,6 +407,30 @@ impl TextColor {
|
|||||||
pub const WHITE: Self = TextColor(Color::WHITE);
|
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.
|
/// 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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Reflect, Serialize, Deserialize)]
|
||||||
#[reflect(Serialize, Deserialize, Clone, PartialEq, Hash, Default)]
|
#[reflect(Serialize, Deserialize, Clone, PartialEq, Hash, Default)]
|
||||||
|
|||||||
@ -64,6 +64,7 @@ pub mod prelude {
|
|||||||
},
|
},
|
||||||
// `bevy_sprite` re-exports for texture slicing
|
// `bevy_sprite` re-exports for texture slicing
|
||||||
bevy_sprite::{BorderRect, SliceScaleMode, SpriteImageMode, TextureSlicer},
|
bevy_sprite::{BorderRect, SliceScaleMode, SpriteImageMode, TextureSlicer},
|
||||||
|
bevy_text::TextBackgroundColor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -51,7 +51,9 @@ pub use debug_overlay::UiDebugOptions;
|
|||||||
|
|
||||||
use crate::{Display, Node};
|
use crate::{Display, Node};
|
||||||
use bevy_platform::collections::{HashMap, HashSet};
|
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 bevy_transform::components::GlobalTransform;
|
||||||
use box_shadow::BoxShadowPlugin;
|
use box_shadow::BoxShadowPlugin;
|
||||||
use bytemuck::{Pod, Zeroable};
|
use bytemuck::{Pod, Zeroable};
|
||||||
@ -105,6 +107,7 @@ pub enum RenderUiSystem {
|
|||||||
ExtractImages,
|
ExtractImages,
|
||||||
ExtractTextureSlice,
|
ExtractTextureSlice,
|
||||||
ExtractBorders,
|
ExtractBorders,
|
||||||
|
ExtractTextBackgrounds,
|
||||||
ExtractTextShadows,
|
ExtractTextShadows,
|
||||||
ExtractText,
|
ExtractText,
|
||||||
ExtractDebug,
|
ExtractDebug,
|
||||||
@ -135,6 +138,7 @@ pub fn build_ui_render(app: &mut App) {
|
|||||||
RenderUiSystem::ExtractImages,
|
RenderUiSystem::ExtractImages,
|
||||||
RenderUiSystem::ExtractTextureSlice,
|
RenderUiSystem::ExtractTextureSlice,
|
||||||
RenderUiSystem::ExtractBorders,
|
RenderUiSystem::ExtractBorders,
|
||||||
|
RenderUiSystem::ExtractTextBackgrounds,
|
||||||
RenderUiSystem::ExtractTextShadows,
|
RenderUiSystem::ExtractTextShadows,
|
||||||
RenderUiSystem::ExtractText,
|
RenderUiSystem::ExtractText,
|
||||||
RenderUiSystem::ExtractDebug,
|
RenderUiSystem::ExtractDebug,
|
||||||
@ -148,6 +152,7 @@ pub fn build_ui_render(app: &mut App) {
|
|||||||
extract_uinode_background_colors.in_set(RenderUiSystem::ExtractBackgrounds),
|
extract_uinode_background_colors.in_set(RenderUiSystem::ExtractBackgrounds),
|
||||||
extract_uinode_images.in_set(RenderUiSystem::ExtractImages),
|
extract_uinode_images.in_set(RenderUiSystem::ExtractImages),
|
||||||
extract_uinode_borders.in_set(RenderUiSystem::ExtractBorders),
|
extract_uinode_borders.in_set(RenderUiSystem::ExtractBorders),
|
||||||
|
extract_text_background_colors.in_set(RenderUiSystem::ExtractTextBackgrounds),
|
||||||
extract_text_shadows.in_set(RenderUiSystem::ExtractTextShadows),
|
extract_text_shadows.in_set(RenderUiSystem::ExtractTextShadows),
|
||||||
extract_text_sections.in_set(RenderUiSystem::ExtractText),
|
extract_text_sections.in_set(RenderUiSystem::ExtractText),
|
||||||
#[cfg(feature = "bevy_ui_debug")]
|
#[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)]
|
#[repr(C)]
|
||||||
#[derive(Copy, Clone, Pod, Zeroable)]
|
#[derive(Copy, Clone, Pod, Zeroable)]
|
||||||
struct UiVertex {
|
struct UiVertex {
|
||||||
|
|||||||
@ -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.
|
[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
|
[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](../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 Debug](../examples/ui/text_debug.rs) | An example for debugging text layout
|
||||||
[Text Wrap Debug](../examples/ui/text_wrap_debug.rs) | Demonstrates text wrapping
|
[Text Wrap Debug](../examples/ui/text_wrap_debug.rs) | Demonstrates text wrapping
|
||||||
[Transparency UI](../examples/ui/transparency_ui.rs) | Demonstrates transparency for UI
|
[Transparency UI](../examples/ui/transparency_ui.rs) | Demonstrates transparency for UI
|
||||||
|
|||||||
77
examples/ui/text_background_colors.rs
Normal file
77
examples/ui/text_background_colors.rs
Normal 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()];
|
||||||
|
}
|
||||||
|
}
|
||||||
7
release-content/release-notes/text-background-colors.md
Normal file
7
release-content/release-notes/text-background-colors.md
Normal 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.
|
||||||
Loading…
Reference in New Issue
Block a user