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)"
|
||||
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"
|
||||
|
@ -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>()
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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)]
|
||||
|
@ -64,6 +64,7 @@ pub mod prelude {
|
||||
},
|
||||
// `bevy_sprite` re-exports for texture slicing
|
||||
bevy_sprite::{BorderRect, SliceScaleMode, SpriteImageMode, TextureSlicer},
|
||||
bevy_text::TextBackgroundColor,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
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