Skip to content

Commit 160661b

Browse files
authored
Text strikethrough (#21555)
# Objective Add minimal strikethrough support for text. ## Solution * Insert the new `Strikethrough` component on any `Text`, `Text2d`, or `TextSpan` entity and its text will be drawn with strikethrough. * The strikethrough geometry is stored in `TextLayoutInfo` in the vec with the section bounding rects. * Rendering is trivial, identical to drawing text background colours except it's a narrower rect drawn in front instead of behind. * Text shadows also have strikethrough if the text does. # This implementation can easily be expanded to support underline, I've already made a follow up PR that does this here: #21559. ## Testing ``` cargo run --example strikethrough ``` ## Showcase <img width="1422" height="924" alt="strikeout" src="https://github.com/user-attachments/assets/c8ea2578-e40c-4c46-ae0d-df9e3f261f3a" />
1 parent d431f7e commit 160661b

File tree

9 files changed

+378
-45
lines changed

9 files changed

+378
-45
lines changed

Cargo.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3549,6 +3549,17 @@ description = "Demonstrates how the to use the size constraints to control the s
35493549
category = "UI (User Interface)"
35503550
wasm = true
35513551

3552+
[[example]]
3553+
name = "strikethrough"
3554+
path = "examples/ui/strikethrough.rs"
3555+
doc-scrape-examples = true
3556+
3557+
[package.metadata.example.strikethrough]
3558+
name = "Strikethrough"
3559+
description = "Demonstrates how to display text with strikethrough."
3560+
category = "UI (User Interface)"
3561+
wasm = true
3562+
35523563
[[example]]
35533564
name = "text"
35543565
path = "examples/ui/text.rs"

crates/bevy_sprite_render/src/text2d/mod.rs

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use bevy_camera::visibility::ViewVisibility;
66
use bevy_color::LinearRgba;
77
use bevy_ecs::{
88
entity::Entity,
9+
query::With,
910
system::{Commands, Query, Res, ResMut},
1011
};
1112
use bevy_image::prelude::*;
@@ -14,7 +15,8 @@ use bevy_render::sync_world::TemporaryRenderEntity;
1415
use bevy_render::Extract;
1516
use bevy_sprite::{Anchor, Text2dShadow};
1617
use bevy_text::{
17-
ComputedTextBlock, PositionedGlyph, TextBackgroundColor, TextBounds, TextColor, TextLayoutInfo,
18+
ComputedTextBlock, PositionedGlyph, Strikethrough, TextBackgroundColor, TextBounds, TextColor,
19+
TextLayoutInfo,
1820
};
1921
use bevy_transform::prelude::GlobalTransform;
2022

@@ -39,6 +41,7 @@ pub fn extract_text2d_sprite(
3941
>,
4042
text_colors: Extract<Query<&TextColor>>,
4143
text_background_colors_query: Extract<Query<&TextBackgroundColor>>,
44+
strikethrough_query: Extract<Query<&TextColor, With<Strikethrough>>>,
4245
) {
4346
let mut start = extracted_slices.slices.len();
4447
let mut end = start + 1;
@@ -68,7 +71,8 @@ pub fn extract_text2d_sprite(
6871

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

71-
for &(section_entity, rect) in text_layout_info.section_rects.iter() {
74+
for &(section_index, rect, _, _) in text_layout_info.section_geometry.iter() {
75+
let section_entity = computed_block.entities()[section_index].entity;
7276
let Ok(text_background_color) = text_background_colors_query.get(section_entity) else {
7377
continue;
7478
};
@@ -144,6 +148,34 @@ pub fn extract_text2d_sprite(
144148

145149
end += 1;
146150
}
151+
152+
for &(section_index, rect, strikethrough_y, stroke) in
153+
text_layout_info.section_geometry.iter()
154+
{
155+
let section_entity = computed_block.entities()[section_index].entity;
156+
let Ok(_) = strikethrough_query.get(section_entity) else {
157+
continue;
158+
};
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+
});
178+
}
147179
}
148180

149181
let transform =
@@ -206,5 +238,35 @@ pub fn extract_text2d_sprite(
206238

207239
end += 1;
208240
}
241+
242+
for &(section_index, rect, strikethrough_y, stroke) in
243+
text_layout_info.section_geometry.iter()
244+
{
245+
let section_entity = computed_block.entities()[section_index].entity;
246+
let Ok(text_color) = strikethrough_query.get(section_entity) else {
247+
continue;
248+
};
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+
});
270+
}
209271
}
210272
}

crates/bevy_text/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ pub use text_access::*;
5959
pub mod prelude {
6060
#[doc(hidden)]
6161
pub use crate::{
62-
Font, Justify, LineBreak, TextColor, TextError, TextFont, TextLayout, TextSpan,
62+
Font, Justify, LineBreak, Strikethrough, TextColor, TextError, TextFont, TextLayout,
63+
TextSpan,
6364
};
6465
}
6566

crates/bevy_text/src/pipeline.rs

Lines changed: 36 additions & 11 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)>,
80+
glyph_info: Vec<(AssetId<Font>, FontSmoothing, f32, f32, f32)>,
8181
}
8282

8383
impl TextPipeline {
@@ -238,7 +238,7 @@ impl TextPipeline {
238238
swash_cache: &mut SwashCache,
239239
) -> Result<(), TextError> {
240240
layout_info.glyphs.clear();
241-
layout_info.section_rects.clear();
241+
layout_info.section_geometry.clear();
242242
layout_info.size = Default::default();
243243

244244
// Clear this here at the focal point of text rendering to ensure the field's lifecycle has strong boundaries.
@@ -248,7 +248,13 @@ impl TextPipeline {
248248
let mut glyph_info = core::mem::take(&mut self.glyph_info);
249249
glyph_info.clear();
250250
let text_spans = text_spans.inspect(|(_, _, _, text_font, _)| {
251-
glyph_info.push((text_font.font.id(), text_font.font_smoothing));
251+
glyph_info.push((
252+
text_font.font.id(),
253+
text_font.font_smoothing,
254+
text_font.font_size,
255+
0.,
256+
0.,
257+
));
252258
});
253259

254260
let update_result = self.update_buffer(
@@ -266,6 +272,20 @@ impl TextPipeline {
266272

267273
update_result?;
268274

275+
for (font, _, size, strike_offset, stroke) in self.glyph_info.iter_mut() {
276+
let Some((id, _)) = self.map_handle_to_font_id.get(font) else {
277+
continue;
278+
};
279+
if let Some(font) = font_system.get_font(*id) {
280+
let swash = font.as_swash();
281+
let metrics = swash.metrics(&[]);
282+
let upem = metrics.units_per_em as f32;
283+
let scalar = *size * scale_factor as f32 / upem;
284+
*strike_offset = (metrics.strikeout_offset * scalar).round();
285+
*stroke = (metrics.stroke_size * scalar).round().max(1.);
286+
}
287+
}
288+
269289
let buffer = &mut computed.buffer;
270290
let box_size = buffer_dimensions(buffer);
271291

@@ -281,14 +301,16 @@ impl TextPipeline {
281301
match current_section {
282302
Some(section) => {
283303
if section != layout_glyph.metadata {
284-
layout_info.section_rects.push((
285-
computed.entities[section].entity,
304+
layout_info.section_geometry.push((
305+
section,
286306
Rect::new(
287307
start,
288308
run.line_top,
289309
end,
290310
run.line_top + run.line_height,
291311
),
312+
(run.line_y - self.glyph_info[section].3).round(),
313+
self.glyph_info[section].4,
292314
));
293315
start = end.max(layout_glyph.x);
294316
current_section = Some(layout_glyph.metadata);
@@ -374,9 +396,11 @@ impl TextPipeline {
374396
Ok(())
375397
});
376398
if let Some(section) = current_section {
377-
layout_info.section_rects.push((
378-
computed.entities[section].entity,
399+
layout_info.section_geometry.push((
400+
section,
379401
Rect::new(start, run.line_top, end, run.line_top + run.line_height),
402+
(run.line_y - self.glyph_info[section].3).round(),
403+
self.glyph_info[section].4,
380404
));
381405
}
382406

@@ -457,9 +481,9 @@ pub struct TextLayoutInfo {
457481
pub scale_factor: f32,
458482
/// Scaled and positioned glyphs in screenspace
459483
pub glyphs: Vec<PositionedGlyph>,
460-
/// Rects bounding the text block's text sections.
461-
/// A text section spanning more than one line will have multiple bounding rects.
462-
pub section_rects: Vec<(Entity, Rect)>,
484+
/// Geometry of each text segment: (section index, bounding rect, strikeout offset, strikeout stroke thickness)
485+
/// A text section spanning more than one line will have multiple segments.
486+
pub section_geometry: Vec<(usize, Rect, f32, f32)>,
463487
/// The glyphs resulting size
464488
pub size: Vec2,
465489
}
@@ -516,10 +540,11 @@ pub fn load_font_to_fontdb(
516540
// TODO: it is assumed this is the right font face
517541
let face_id = *ids.last().unwrap();
518542
let face = font_system.db().face(face_id).unwrap();
519-
let family_name = Arc::from(face.families[0].0.as_str());
520543

544+
let family_name = Arc::from(face.families[0].0.as_str());
521545
(face_id, family_name)
522546
});
547+
523548
let face = font_system.db().face(*face_id).unwrap();
524549

525550
FontFaceInfo {

crates/bevy_text/src/text.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,11 @@ pub enum LineBreak {
425425
NoWrap,
426426
}
427427

428+
/// A text entity with this component is drawn with strikethrough.
429+
#[derive(Component, Copy, Clone, Debug, Reflect, Default, Serialize, Deserialize)]
430+
#[reflect(Serialize, Deserialize, Clone, Default)]
431+
pub struct Strikethrough;
432+
428433
/// Determines which antialiasing method to use when rendering text. By default, text is
429434
/// rendered with grayscale antialiasing, but this can be changed to achieve a pixelated look.
430435
///

0 commit comments

Comments
 (0)