use std::ops::Range;
use crate::batch::{Batch, Kind};
use frus_core::{Color, Point, Primitive, Rect, Scene, TextDecoration};
use frus_core::DEFAULT_LINE_HEIGHT as LINE_HEIGHT_FACTOR;
pub(crate) struct DecorationQuad {
pub rect: Rect,
pub color: Color,
pub clip: Rect,
}
const UNDERLINE_OFFSET: f32 = 0.12;
const STRIKETHROUGH_OFFSET: f32 = 0.28;
const OVERLINE_OFFSET: f32 = 0.90;
#[allow(clippy::too_many_arguments)]
fn push_line_quads(
quads: &mut Vec<DecorationQuad>,
origin: Point,
x0: f32,
x1: f32,
baseline: f32,
size: f32,
decoration: TextDecoration,
color: Color,
clip: Rect,
) {
if x1 <= x0 {
return;
}
let thickness = (size / 14.0).max(1.0);
let mut line = |y: f32| {
quads.push(DecorationQuad {
rect: Rect::new(
origin.x + x0,
origin.y + baseline + y - thickness / 2.0,
x1 - x0,
thickness,
),
color,
clip,
});
};
if decoration.underline {
line(UNDERLINE_OFFSET * size);
}
if decoration.strikethrough {
line(-STRIKETHROUGH_OFFSET * size);
}
if decoration.overline {
line(-OVERLINE_OFFSET * size);
}
}
fn to_u8(channel: f32) -> u8 {
(channel.clamp(0.0, 1.0) * 255.0).round() as u8
}
pub(crate) struct TextPainter {
font_system: glyphon::FontSystem,
swash_cache: glyphon::SwashCache,
atlas: glyphon::TextAtlas,
viewport: glyphon::Viewport,
renderers: Vec<glyphon::TextRenderer>,
multisample: wgpu::MultisampleState,
}
impl TextPainter {
pub(crate) fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
sample_count: u32,
) -> Self {
let font_system = frus_text::new_font_system();
let swash_cache = glyphon::SwashCache::new();
let cache = glyphon::Cache::new(device);
let viewport = glyphon::Viewport::new(device, &cache);
let atlas = glyphon::TextAtlas::new(device, queue, &cache, format);
let multisample = wgpu::MultisampleState {
count: sample_count,
mask: !0,
alpha_to_coverage_enabled: false,
};
Self {
font_system,
swash_cache,
atlas,
viewport,
renderers: Vec::new(),
multisample,
}
}
fn ensure_renderers(&mut self, device: &wgpu::Device, count: usize) {
while self.renderers.len() < count {
self.renderers.push(glyphon::TextRenderer::new(
&mut self.atlas,
device,
self.multisample,
None,
));
}
}
pub(crate) fn prepare_frame(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
scene: &Scene,
width: u32,
height: u32,
batches: &[Batch],
) -> (Vec<DecorationQuad>, Vec<Range<u32>>) {
self.viewport
.update(queue, glyphon::Resolution { width, height });
let text_batches = batches.iter().filter(|b| b.kind == Kind::Text).count();
self.ensure_renderers(device, text_batches);
let mut decorations = Vec::new();
let mut decoration_ranges = Vec::with_capacity(batches.len());
let to_glyphon = |color: &frus_core::Color| {
glyphon::Color::rgba(
to_u8(color.r),
to_u8(color.g),
to_u8(color.b),
to_u8(color.a),
)
};
let to_bounds = |clip: &frus_core::Rect| glyphon::TextBounds {
left: clip.x.max(0.0) as i32,
top: clip.y.max(0.0) as i32,
right: (clip.x + clip.width).min(width as f32) as i32,
bottom: (clip.y + clip.height).min(height as f32) as i32,
};
let mut slot = 0;
for batch in batches {
let decoration_start = decorations.len() as u32;
if batch.kind != Kind::Text {
decoration_ranges.push(decoration_start..decoration_start);
continue;
}
let mut buffers = Vec::new();
for &member in &batch.members {
match &scene.primitives()[member] {
Primitive::Text {
position,
text,
size,
color,
weight,
italic,
max_width,
soft_wrap,
align,
decoration,
decoration_color,
height: line_ratio,
family,
letter_spacing,
clip,
..
} => {
let line = *size * line_ratio.unwrap_or(LINE_HEIGHT_FACTOR);
let metrics = glyphon::Metrics::new(*size, line);
let mut buffer = glyphon::Buffer::new(&mut self.font_system, metrics);
if !soft_wrap {
buffer.set_wrap(glyphon::Wrap::None);
}
buffer.set_size(*max_width, Some(height_below(height, position.y)));
let attrs = glyphon::Attrs::new()
.family(frus_text::family_for_style(text, *family))
.weight(glyphon::Weight(frus_text::available_weight(*weight)))
.style(frus_text::available_style(*italic));
let attrs = frus_text::spaced(attrs, *letter_spacing, *size);
buffer.set_text(text, &attrs, glyphon::Shaping::Advanced, None);
if let Some(align) = to_align(*align) {
for line in &mut buffer.lines {
line.set_align(Some(align));
}
}
buffer.shape_until_scroll(&mut self.font_system, false);
if !decoration.is_none() {
let deco_color = decoration_color.unwrap_or(*color);
for run in buffer.layout_runs() {
let (Some(first), Some(last)) =
(run.glyphs.first(), run.glyphs.last())
else {
continue;
};
push_line_quads(
&mut decorations,
*position,
first.x,
last.x + last.w,
run.line_y,
*size,
*decoration,
deco_color,
*clip,
);
}
}
buffers.push((
buffer,
position.x,
position.y,
to_glyphon(color),
to_bounds(clip),
));
}
Primitive::RichText {
position,
runs,
max_width,
soft_wrap,
align,
clip,
..
} => {
if runs.is_empty() {
continue;
}
let base = runs.iter().map(|r| r.size).fold(0.0_f32, f32::max);
let metrics = glyphon::Metrics::new(base, base * LINE_HEIGHT_FACTOR);
let mut buffer = glyphon::Buffer::new(&mut self.font_system, metrics);
if !soft_wrap {
buffer.set_wrap(glyphon::Wrap::None);
}
buffer.set_size(*max_width, Some(height_below(height, position.y)));
let spans = runs.iter().enumerate().map(|(index, run)| {
(
run.text.as_str(),
frus_text::spaced(
glyphon::Attrs::new()
.family(frus_text::family_for(&run.text))
.weight(glyphon::Weight(frus_text::available_weight(
run.weight,
)))
.style(frus_text::available_style(run.italic))
.metrics(glyphon::Metrics::new(
run.size,
run.size * LINE_HEIGHT_FACTOR,
))
.color(to_glyphon(&run.color))
.metadata(index),
run.letter_spacing,
run.size,
),
)
});
buffer.set_rich_text(
spans,
&glyphon::Attrs::new(),
glyphon::Shaping::Advanced,
None,
);
if let Some(align) = to_align(*align) {
for line in &mut buffer.lines {
line.set_align(Some(align));
}
}
buffer.shape_until_scroll(&mut self.font_system, false);
if runs.iter().any(|r| !r.decoration.is_none()) {
for lrun in buffer.layout_runs() {
let glyphs = lrun.glyphs;
let mut start = 0;
while start < glyphs.len() {
let meta = glyphs[start].metadata;
let mut end = start + 1;
while end < glyphs.len() && glyphs[end].metadata == meta {
end += 1;
}
if let Some(run) = runs.get(meta) {
if !run.decoration.is_none() {
let last = &glyphs[end - 1];
push_line_quads(
&mut decorations,
*position,
glyphs[start].x,
last.x + last.w,
lrun.line_y,
run.size,
run.decoration,
run.decoration_color.unwrap_or(run.color),
*clip,
);
}
}
start = end;
}
}
}
let default_color = to_glyphon(&runs[0].color);
buffers.push((
buffer,
position.x,
position.y,
default_color,
to_bounds(clip),
));
}
Primitive::Rect { .. }
| Primitive::Path { .. }
| Primitive::Image { .. }
| Primitive::Layer { .. } => {}
}
}
decoration_ranges.push(decoration_start..decorations.len() as u32);
let areas =
buffers
.iter()
.map(|(buffer, left, top, color, bounds)| glyphon::TextArea {
buffer,
left: *left,
top: *top,
scale: 1.0,
bounds: *bounds,
default_color: *color,
custom_glyphs: &[],
});
if let Err(err) = self.renderers[slot].prepare(
device,
queue,
&mut self.font_system,
&mut self.atlas,
&self.viewport,
areas,
&mut self.swash_cache,
) {
log::warn!("glyphon prepare failed: {err:?}");
}
slot += 1;
}
(decorations, decoration_ranges)
}
pub(crate) fn draw<'pass>(&'pass self, pass: &mut wgpu::RenderPass<'pass>, slot: usize) {
let Some(renderer) = self.renderers.get(slot) else {
return;
};
if let Err(err) = renderer.render(&self.atlas, &self.viewport, pass) {
log::warn!("glyphon render failed: {err:?}");
}
}
}
fn height_below(surface: u32, top: f32) -> f32 {
(surface as f32 - top).max(0.0)
}
fn to_align(align: frus_core::TextAlign) -> Option<glyphon::cosmic_text::Align> {
use frus_core::TextAlign;
use glyphon::cosmic_text::Align;
match align {
TextAlign::Start => None,
TextAlign::End => Some(Align::End),
TextAlign::Center => Some(Align::Center),
TextAlign::Justify => Some(Align::Justified),
TextAlign::Left => Some(Align::Left),
TextAlign::Right => Some(Align::Right),
}
}
#[cfg(test)]
mod tests {
use super::*;
use frus_core::{Color, Point, Scene};
fn lit_pixels_for(scene: &Scene) -> Option<usize> {
let frame = crate::offscreen::render_offscreen(scene, 128, 128, Color::BLACK)?;
Some(
frame
.rgba
.as_chunks::<4>()
.0
.iter()
.filter(|px| px[0] > 16)
.count(),
)
}
fn rightmost_lit(scene: &Scene) -> Option<usize> {
let frame = crate::offscreen::render_offscreen(scene, 256, 64, Color::BLACK)?;
let width = 256;
Some(
frame
.rgba
.as_chunks::<4>()
.0
.iter()
.enumerate()
.filter(|(_, px)| px[0] > 16)
.map(|(i, _)| i % width)
.max()
.unwrap_or(0),
)
}
#[test]
fn letter_spacing_reaches_the_pixels() {
let draw = |spacing: f32| {
let mut scene = Scene::new();
scene.text(
Point::new(4.0, 4.0),
"IIII",
&frus_core::ResolvedTextStyle {
letter_spacing: spacing,
..frus_core::ResolvedTextStyle::exact(32.0)
},
Color::WHITE,
);
rightmost_lit(&scene)
};
match (draw(0.0), draw(6.0)) {
(Some(plain), Some(spaced)) => {
let further = spaced as f32 - plain as f32;
assert!(
(further - 18.0).abs() <= 3.0,
"spaced ink ends {further} px further, three gaps of 6 expected"
);
}
_ => eprintln!("no GPU adapter available: test skipped"),
}
}
#[test]
fn text_scrolled_above_the_surface_draws_its_lower_lines() {
let mut scene = Scene::new();
scene.text(
Point::new(4.0, -300.0),
["IIII"; 40].join("\n"),
&frus_core::ResolvedTextStyle::exact(16.0),
Color::WHITE,
);
match lit_pixels_for(&scene) {
None => eprintln!("no GPU adapter available: test skipped"),
Some(lit) => assert!(lit > 100, "the lower lines should be drawn ({lit})"),
}
}
#[test]
fn height_below_runs_from_the_top_to_the_surface_bottom() {
assert_eq!(height_below(800, 0.0), 800.0);
assert_eq!(height_below(800, -1200.0), 2000.0);
assert_eq!(height_below(800, 300.0), 500.0);
assert_eq!(height_below(800, 900.0), 0.0);
}
#[test]
fn renders_text_to_non_background_pixels() {
let mut scene = Scene::new();
scene.text(
Point::new(4.0, 4.0),
"Hello",
&frus_core::ResolvedTextStyle::exact(48.0),
Color::WHITE,
);
match lit_pixels_for(&scene) {
None => eprintln!("no GPU adapter available: test skipped"),
Some(lit) => {
assert!(lit > 0, "text should produce non-black pixels ({lit})")
}
}
}
#[test]
fn renders_arabic_to_non_background_pixels() {
let mut scene = Scene::new();
scene.text(
Point::new(4.0, 40.0),
"مهامي",
&frus_core::ResolvedTextStyle::exact(40.0),
Color::WHITE,
);
match lit_pixels_for(&scene) {
None => eprintln!("no GPU adapter available: test skipped"),
Some(lit) => {
assert!(lit > 20, "Arabic should rasterise glyphs ({lit})")
}
}
}
#[test]
fn renders_rich_text_to_non_background_pixels() {
use frus_core::{FontWeight, TextRun};
let run = |text: &str, size: f32, weight: FontWeight| TextRun {
text: text.to_string(),
size,
weight,
italic: false,
color: Color::WHITE,
decoration: TextDecoration::NONE,
decoration_color: None,
letter_spacing: 0.0,
};
let mut scene = Scene::new();
scene.rich_text(
Point::new(4.0, 4.0),
vec![
run("Ri", 40.0, FontWeight::Regular),
run("ch", 24.0, FontWeight::Bold),
],
);
match lit_pixels_for(&scene) {
None => eprintln!("no GPU adapter available: test skipped"),
Some(lit) => {
assert!(lit > 0, "rich text should produce non-black pixels ({lit})")
}
}
}
#[test]
fn underline_lights_more_pixels_than_plain_text() {
use frus_core::TextStyle;
let plain = {
let mut scene = Scene::new();
scene.text(
Point::new(4.0, 4.0),
"Hello",
&TextStyle::new(40.0).resolved(),
Color::WHITE,
);
scene
};
let underlined = {
let mut scene = Scene::new();
scene.text(
Point::new(4.0, 4.0),
"Hello",
&TextStyle::new(40.0).underline().resolved(),
Color::WHITE,
);
scene
};
match (lit_pixels_for(&plain), lit_pixels_for(&underlined)) {
(Some(bare), Some(deco)) => {
assert!(
deco > bare + 50,
"the underline must add pixels (bare {bare}, decorated {deco})"
);
}
_ => eprintln!("no GPU adapter available: test skipped"),
}
}
#[test]
fn rich_text_strikethrough_is_per_run() {
use frus_core::{FontWeight, TextRun};
let run = |text: &str, decoration: TextDecoration| TextRun {
text: text.to_string(),
size: 40.0,
weight: FontWeight::Regular,
italic: false,
color: Color::WHITE,
decoration,
decoration_color: None,
letter_spacing: 0.0,
};
let plain = {
let mut scene = Scene::new();
scene.rich_text(
Point::new(4.0, 4.0),
vec![
run("ab", TextDecoration::NONE),
run("cd", TextDecoration::NONE),
],
);
scene
};
let struck = {
let mut scene = Scene::new();
scene.rich_text(
Point::new(4.0, 4.0),
vec![
run("ab", TextDecoration::NONE),
run("cd", TextDecoration::STRIKETHROUGH),
],
);
scene
};
match (lit_pixels_for(&plain), lit_pixels_for(&struck)) {
(Some(bare), Some(deco)) => {
assert!(
deco > bare,
"run 2's strikethrough must add pixels (bare {bare}, decorated {deco})"
);
}
_ => eprintln!("no GPU adapter available: test skipped"),
}
}
}