Skip to content

Commit d5a16b7

Browse files
authored
Text underline (#21559)
# Objective Text Underline ## Solution New `Underline` marker component, add to text entities to draw an underline. This PR is based on #21555, that should probably be reviewed and merged first.
1 parent 563051d commit d5a16b7

File tree

9 files changed

+232
-90
lines changed

9 files changed

+232
-90
lines changed

crates/bevy_sprite_render/src/text2d/mod.rs

Lines changed: 103 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use bevy_camera::visibility::ViewVisibility;
66
use bevy_color::LinearRgba;
77
use bevy_ecs::{
88
entity::Entity,
9-
query::With,
9+
query::Has,
1010
system::{Commands, Query, Res, ResMut},
1111
};
1212
use bevy_image::prelude::*;
@@ -16,7 +16,7 @@ use bevy_render::Extract;
1616
use bevy_sprite::{Anchor, Text2dShadow};
1717
use bevy_text::{
1818
ComputedTextBlock, PositionedGlyph, Strikethrough, TextBackgroundColor, TextBounds, TextColor,
19-
TextLayoutInfo,
19+
TextLayoutInfo, Underline,
2020
};
2121
use bevy_transform::prelude::GlobalTransform;
2222

@@ -41,7 +41,7 @@ pub fn extract_text2d_sprite(
4141
>,
4242
text_colors: Extract<Query<&TextColor>>,
4343
text_background_colors_query: Extract<Query<&TextBackgroundColor>>,
44-
strikethrough_query: Extract<Query<&TextColor, With<Strikethrough>>>,
44+
decoration_query: Extract<Query<(&TextColor, Has<Strikethrough>, Has<Underline>)>>,
4545
) {
4646
let mut start = extracted_slices.slices.len();
4747
let mut end = start + 1;
@@ -71,7 +71,7 @@ pub fn extract_text2d_sprite(
7171

7272
let top_left = (Anchor::TOP_LEFT.0 - anchor.as_vec()) * size;
7373

74-
for &(section_index, rect, _, _) in text_layout_info.section_geometry.iter() {
74+
for &(section_index, rect, _, _, _) in text_layout_info.section_geometry.iter() {
7575
let section_entity = computed_block.entities()[section_index].entity;
7676
let Ok(text_background_color) = text_background_colors_query.get(section_entity) else {
7777
continue;
@@ -149,32 +149,59 @@ pub fn extract_text2d_sprite(
149149
end += 1;
150150
}
151151

152-
for &(section_index, rect, strikethrough_y, stroke) in
152+
for &(section_index, rect, strikethrough_y, stroke, underline_y) in
153153
text_layout_info.section_geometry.iter()
154154
{
155155
let section_entity = computed_block.entities()[section_index].entity;
156-
let Ok(_) = strikethrough_query.get(section_entity) else {
156+
let Ok((_, has_strikethrough, has_underline)) =
157+
decoration_query.get(section_entity)
158+
else {
157159
continue;
158160
};
159-
let render_entity = commands.spawn(TemporaryRenderEntity).id();
160-
let offset = Vec2::new(rect.center().x, -strikethrough_y - 0.5 * stroke);
161-
let transform =
162-
shadow_transform * GlobalTransform::from_translation(offset.extend(0.));
163-
extracted_sprites.sprites.push(ExtractedSprite {
164-
main_entity,
165-
render_entity,
166-
transform,
167-
color,
168-
image_handle_id: AssetId::default(),
169-
flip_x: false,
170-
flip_y: false,
171-
kind: ExtractedSpriteKind::Single {
172-
anchor: Vec2::ZERO,
173-
rect: None,
174-
scaling_mode: None,
175-
custom_size: Some(Vec2::new(rect.size().x, stroke)),
176-
},
177-
});
161+
162+
if has_strikethrough {
163+
let render_entity = commands.spawn(TemporaryRenderEntity).id();
164+
let offset = Vec2::new(rect.center().x, -strikethrough_y - 0.5 * stroke);
165+
let transform =
166+
shadow_transform * GlobalTransform::from_translation(offset.extend(0.));
167+
extracted_sprites.sprites.push(ExtractedSprite {
168+
main_entity,
169+
render_entity,
170+
transform,
171+
color,
172+
image_handle_id: AssetId::default(),
173+
flip_x: false,
174+
flip_y: false,
175+
kind: ExtractedSpriteKind::Single {
176+
anchor: Vec2::ZERO,
177+
rect: None,
178+
scaling_mode: None,
179+
custom_size: Some(Vec2::new(rect.size().x, stroke)),
180+
},
181+
});
182+
}
183+
184+
if has_underline {
185+
let render_entity = commands.spawn(TemporaryRenderEntity).id();
186+
let offset = Vec2::new(rect.center().x, -underline_y - 0.5 * stroke);
187+
let transform =
188+
shadow_transform * GlobalTransform::from_translation(offset.extend(0.));
189+
extracted_sprites.sprites.push(ExtractedSprite {
190+
main_entity,
191+
render_entity,
192+
transform,
193+
color,
194+
image_handle_id: AssetId::default(),
195+
flip_x: false,
196+
flip_y: false,
197+
kind: ExtractedSpriteKind::Single {
198+
anchor: Vec2::ZERO,
199+
rect: None,
200+
scaling_mode: None,
201+
custom_size: Some(Vec2::new(rect.size().x, stroke)),
202+
},
203+
});
204+
}
178205
}
179206
}
180207

@@ -239,34 +266,62 @@ pub fn extract_text2d_sprite(
239266
end += 1;
240267
}
241268

242-
for &(section_index, rect, strikethrough_y, stroke) in
269+
for &(section_index, rect, strikethrough_y, stroke, underline_y) in
243270
text_layout_info.section_geometry.iter()
244271
{
245272
let section_entity = computed_block.entities()[section_index].entity;
246-
let Ok(text_color) = strikethrough_query.get(section_entity) else {
273+
let Ok((text_color, has_strike_through, has_underline)) =
274+
decoration_query.get(section_entity)
275+
else {
247276
continue;
248277
};
249-
let render_entity = commands.spawn(TemporaryRenderEntity).id();
250-
let offset = Vec2::new(rect.center().x, -strikethrough_y - 0.5 * stroke);
251-
let transform = *global_transform
252-
* GlobalTransform::from_translation(top_left.extend(0.))
253-
* scaling
254-
* GlobalTransform::from_translation(offset.extend(0.));
255-
extracted_sprites.sprites.push(ExtractedSprite {
256-
main_entity,
257-
render_entity,
258-
transform,
259-
color: text_color.0.into(),
260-
image_handle_id: AssetId::default(),
261-
flip_x: false,
262-
flip_y: false,
263-
kind: ExtractedSpriteKind::Single {
264-
anchor: Vec2::ZERO,
265-
rect: None,
266-
scaling_mode: None,
267-
custom_size: Some(Vec2::new(rect.size().x, stroke)),
268-
},
269-
});
278+
if has_strike_through {
279+
let render_entity = commands.spawn(TemporaryRenderEntity).id();
280+
let offset = Vec2::new(rect.center().x, -strikethrough_y - 0.5 * stroke);
281+
let transform = *global_transform
282+
* GlobalTransform::from_translation(top_left.extend(0.))
283+
* scaling
284+
* GlobalTransform::from_translation(offset.extend(0.));
285+
extracted_sprites.sprites.push(ExtractedSprite {
286+
main_entity,
287+
render_entity,
288+
transform,
289+
color: text_color.0.into(),
290+
image_handle_id: AssetId::default(),
291+
flip_x: false,
292+
flip_y: false,
293+
kind: ExtractedSpriteKind::Single {
294+
anchor: Vec2::ZERO,
295+
rect: None,
296+
scaling_mode: None,
297+
custom_size: Some(Vec2::new(rect.size().x, stroke)),
298+
},
299+
});
300+
}
301+
302+
if has_underline {
303+
let render_entity = commands.spawn(TemporaryRenderEntity).id();
304+
let offset = Vec2::new(rect.center().x, -underline_y - 0.5 * stroke);
305+
let transform = *global_transform
306+
* GlobalTransform::from_translation(top_left.extend(0.))
307+
* scaling
308+
* GlobalTransform::from_translation(offset.extend(0.));
309+
extracted_sprites.sprites.push(ExtractedSprite {
310+
main_entity,
311+
render_entity,
312+
transform,
313+
color: text_color.0.into(),
314+
image_handle_id: AssetId::default(),
315+
flip_x: false,
316+
flip_y: false,
317+
kind: ExtractedSpriteKind::Single {
318+
anchor: Vec2::ZERO,
319+
rect: None,
320+
scaling_mode: None,
321+
custom_size: Some(Vec2::new(rect.size().x, stroke)),
322+
},
323+
});
324+
}
270325
}
271326
}
272327
}

crates/bevy_text/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ pub mod prelude {
6060
#[doc(hidden)]
6161
pub use crate::{
6262
Font, Justify, LineBreak, Strikethrough, TextColor, TextError, TextFont, TextLayout,
63-
TextSpan,
63+
TextSpan, Underline,
6464
};
6565
}
6666

crates/bevy_text/src/pipeline.rs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ pub struct TextPipeline {
7777
/// See [this dark magic](https://users.rust-lang.org/t/how-to-cache-a-vectors-capacity/94478/10).
7878
spans_buffer: Vec<(usize, &'static str, &'static TextFont, FontFaceInfo)>,
7979
/// Buffered vec for collecting info for glyph assembly.
80-
glyph_info: Vec<(AssetId<Font>, FontSmoothing, f32, f32, f32)>,
80+
glyph_info: Vec<(AssetId<Font>, FontSmoothing, f32, f32, f32, f32)>,
8181
}
8282

8383
impl TextPipeline {
@@ -254,6 +254,7 @@ impl TextPipeline {
254254
text_font.font_size,
255255
0.,
256256
0.,
257+
0.,
257258
));
258259
});
259260

@@ -272,7 +273,9 @@ impl TextPipeline {
272273

273274
update_result?;
274275

275-
for (font, _, size, strike_offset, stroke) in self.glyph_info.iter_mut() {
276+
for (font, _, size, strikethrough_offset, stroke, underline_offset) in
277+
self.glyph_info.iter_mut()
278+
{
276279
let Some((id, _)) = self.map_handle_to_font_id.get(font) else {
277280
continue;
278281
};
@@ -281,8 +284,9 @@ impl TextPipeline {
281284
let metrics = swash.metrics(&[]);
282285
let upem = metrics.units_per_em as f32;
283286
let scalar = *size * scale_factor as f32 / upem;
284-
*strike_offset = (metrics.strikeout_offset * scalar).round();
287+
*strikethrough_offset = (metrics.strikeout_offset * scalar).round();
285288
*stroke = (metrics.stroke_size * scalar).round().max(1.);
289+
*underline_offset = (metrics.underline_offset * scalar).round();
286290
}
287291
}
288292

@@ -311,6 +315,7 @@ impl TextPipeline {
311315
),
312316
(run.line_y - self.glyph_info[section].3).round(),
313317
self.glyph_info[section].4,
318+
(run.line_y - self.glyph_info[section].5).round(),
314319
));
315320
start = end.max(layout_glyph.x);
316321
current_section = Some(layout_glyph.metadata);
@@ -401,6 +406,7 @@ impl TextPipeline {
401406
Rect::new(start, run.line_top, end, run.line_top + run.line_height),
402407
(run.line_y - self.glyph_info[section].3).round(),
403408
self.glyph_info[section].4,
409+
(run.line_y - self.glyph_info[section].5).round(),
404410
));
405411
}
406412

@@ -481,9 +487,9 @@ pub struct TextLayoutInfo {
481487
pub scale_factor: f32,
482488
/// Scaled and positioned glyphs in screenspace
483489
pub glyphs: Vec<PositionedGlyph>,
484-
/// Geometry of each text segment: (section index, bounding rect, strikeout offset, strikeout stroke thickness)
490+
/// Geometry of each text segment: (section index, bounding rect, strikethrough offset, stroke thickness, underline offset)
485491
/// A text section spanning more than one line will have multiple segments.
486-
pub section_geometry: Vec<(usize, Rect, f32, f32)>,
492+
pub section_geometry: Vec<(usize, Rect, f32, f32, f32)>,
487493
/// The glyphs resulting size
488494
pub size: Vec2,
489495
}

crates/bevy_text/src/text.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,11 @@ pub enum LineBreak {
430430
#[reflect(Serialize, Deserialize, Clone, Default)]
431431
pub struct Strikethrough;
432432

433+
/// Add to a text entity to draw its text with underline.
434+
#[derive(Component, Copy, Clone, Debug, Reflect, Default, Serialize, Deserialize)]
435+
#[reflect(Serialize, Deserialize, Clone, Default)]
436+
pub struct Underline;
437+
433438
/// Determines which antialiasing method to use when rendering text. By default, text is
434439
/// rendered with grayscale antialiasing, but this can be changed to achieve a pixelated look.
435440
///

0 commit comments

Comments
 (0)