1use crate::geometry::{TextLineDecorationGeometry, caret_x_from_stops};
2use crate::spans::ResolvedSpan;
3use fret_core::{Color, Point, Rect, Size, geometry::Px};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum TextDecorationKind {
7 Underline,
8 Strikethrough,
9}
10
11#[derive(Debug, Clone)]
12pub struct TextDecoration {
13 kind: TextDecorationKind,
14 rect: Rect,
16 paint_span: Option<u16>,
18 color: Option<Color>,
20}
21
22impl TextDecoration {
23 pub fn new(
24 kind: TextDecorationKind,
25 rect: Rect,
26 paint_span: Option<u16>,
27 color: Option<Color>,
28 ) -> Self {
29 Self {
30 kind,
31 rect,
32 paint_span,
33 color,
34 }
35 }
36
37 pub fn kind(&self) -> TextDecorationKind {
38 self.kind
39 }
40
41 pub fn rect(&self) -> Rect {
42 self.rect
43 }
44
45 pub fn paint_span(&self) -> Option<u16> {
46 self.paint_span
47 }
48
49 pub fn color(&self) -> Option<Color> {
50 self.color
51 }
52}
53
54#[derive(Debug, Clone, Copy)]
55pub struct TextDecorationMetricsPx {
56 underline_offset_px: f32,
57 strikeout_offset_px: f32,
58 stroke_size_px: f32,
59}
60
61impl TextDecorationMetricsPx {
62 pub fn new(underline_offset_px: f32, strikeout_offset_px: f32, stroke_size_px: f32) -> Self {
63 Self {
64 underline_offset_px,
65 strikeout_offset_px,
66 stroke_size_px,
67 }
68 }
69
70 pub fn underline_offset_px(&self) -> f32 {
71 self.underline_offset_px
72 }
73
74 pub fn strikeout_offset_px(&self) -> f32 {
75 self.strikeout_offset_px
76 }
77
78 pub fn stroke_size_px(&self) -> f32 {
79 self.stroke_size_px
80 }
81}
82
83pub fn decoration_metrics_px_for_font_bytes(
84 font_bytes: &[u8],
85 face_index: u32,
86 coords: &[i16],
87 ppem: f32,
88) -> Option<TextDecorationMetricsPx> {
89 if !ppem.is_finite() || ppem <= 0.0 {
90 return None;
91 }
92
93 let font_ref = parley::swash::FontRef::from_index(font_bytes, face_index as usize)?;
94 let m = font_ref.metrics(coords).scale(ppem);
95 if !m.underline_offset.is_finite()
96 || !m.strikeout_offset.is_finite()
97 || !m.stroke_size.is_finite()
98 {
99 return None;
100 }
101
102 Some(TextDecorationMetricsPx::new(
103 m.underline_offset,
104 m.strikeout_offset,
105 m.stroke_size,
106 ))
107}
108
109pub fn decorations_for_lines<L: TextLineDecorationGeometry>(
110 lines: &[L],
111 spans: &[ResolvedSpan],
112 metrics_px: Option<TextDecorationMetricsPx>,
113 scale: f32,
114 snap_vertical: bool,
115) -> Vec<TextDecoration> {
116 let mut out: Vec<TextDecoration> = Vec::new();
117 if lines.is_empty() || spans.is_empty() {
118 return out;
119 }
120 if !scale.is_finite() || scale <= 0.0 {
121 return out;
122 }
123
124 for line in lines {
125 let y_top = line.y_top().0;
126 let height = line.height().0.max(0.0);
127 let baseline = line.y_baseline().0;
128
129 let line_top_px = y_top * scale;
130 let line_bottom_px = (y_top + height).max(y_top) * scale;
131 let baseline_px = baseline * scale;
132
133 let line_height_px = (height * scale).max(0.0);
134 let max_thickness_px = line_height_px.max(1.0);
135
136 let (thickness_px, underline_y, strike_y) = if let Some(m) = metrics_px {
137 let raw = m.stroke_size_px().abs().max(1.0).min(max_thickness_px);
138 let thickness_px = if snap_vertical {
139 raw.round().max(1.0)
140 } else {
141 raw
142 };
143
144 let underline_top_px_raw = baseline_px - m.underline_offset_px();
148 let underline_bottom_px_raw = underline_top_px_raw + thickness_px;
149 let underline_bottom_px = if snap_vertical {
150 underline_bottom_px_raw.round()
151 } else {
152 underline_bottom_px_raw
153 }
154 .clamp(line_top_px, line_bottom_px);
155 let max_top_px = (line_bottom_px - thickness_px).max(line_top_px);
156 let underline_top_px =
157 (underline_bottom_px - thickness_px).clamp(line_top_px, max_top_px);
158
159 let strike_top_px_raw = baseline_px - m.strikeout_offset_px();
160 let strike_bottom_px_raw = strike_top_px_raw + thickness_px;
161 let strike_bottom_px = if snap_vertical {
162 strike_bottom_px_raw.round()
163 } else {
164 strike_bottom_px_raw
165 }
166 .clamp(line_top_px, line_bottom_px);
167 let strike_top_px = (strike_bottom_px - thickness_px).clamp(line_top_px, max_top_px);
168
169 (
170 thickness_px,
171 Px((underline_top_px / scale).max(0.0)),
172 Px((strike_top_px / scale).max(0.0)),
173 )
174 } else {
175 let thickness_px = 1.0_f32;
176
177 let underline_bottom_px_raw = baseline_px + 1.0;
179 let underline_bottom_px = if snap_vertical {
180 underline_bottom_px_raw.round()
181 } else {
182 underline_bottom_px_raw
183 }
184 .clamp(line_top_px, line_bottom_px);
185 let max_top_px = (line_bottom_px - thickness_px).max(line_top_px);
186 let underline_top_px =
187 (underline_bottom_px - thickness_px).clamp(line_top_px, max_top_px);
188 let underline_y = Px((underline_top_px / scale).max(0.0));
189
190 let strike_offset_px_raw = (line_height_px * 0.30).clamp(1.0, line_height_px);
192 let strike_bottom_px_raw = baseline_px - strike_offset_px_raw;
193 let strike_bottom_px = if snap_vertical {
194 strike_bottom_px_raw.round()
195 } else {
196 strike_bottom_px_raw
197 }
198 .clamp(line_top_px, line_bottom_px);
199 let strike_top_px = (strike_bottom_px - thickness_px).clamp(line_top_px, max_top_px);
200 let strike_y = Px((strike_top_px / scale).max(0.0));
201
202 (thickness_px, underline_y, strike_y)
203 };
204
205 let thickness = Px((thickness_px / scale).max(0.0));
206
207 for span in spans {
208 if span.underline().is_none() && span.strikethrough().is_none() {
209 continue;
210 }
211
212 let start = span.start().max(line.start());
213 let end = span.end().min(line.end());
214 if start >= end {
215 continue;
216 }
217
218 let x0 = caret_x_from_stops(line.caret_stops(), start);
219 let x1 = caret_x_from_stops(line.caret_stops(), end);
220 let left = Px(x0.0.min(x1.0));
221 let right = Px(x0.0.max(x1.0));
222 let width = Px((right.0 - left.0).max(thickness.0));
223
224 if let Some(underline) = span.underline() {
225 out.push(TextDecoration::new(
226 TextDecorationKind::Underline,
227 Rect::new(Point::new(left, underline_y), Size::new(width, thickness)),
228 Some(span.slot()),
229 underline.color(),
230 ));
231 }
232
233 if let Some(strikethrough) = span.strikethrough() {
234 out.push(TextDecoration::new(
235 TextDecorationKind::Strikethrough,
236 Rect::new(Point::new(left, strike_y), Size::new(width, thickness)),
237 Some(span.slot()),
238 strikethrough.color(),
239 ));
240 }
241 }
242 }
243
244 out
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use crate::{parley_shaper::ParleyShaper, prepare_layout, spans, wrapper};
251 use fret_core::{
252 DecorationLineStyle, FontId, Px, StrikethroughStyle, TextConstraints, TextInputRef,
253 TextOverflow, TextPaintStyle, TextShapingStyle, TextSpan, TextStyle, TextWrap,
254 UnderlineStyle,
255 };
256
257 fn shaper_with_bundled_fonts() -> ParleyShaper {
258 let mut shaper = ParleyShaper::new_without_system_fonts();
259 let added = shaper.add_fonts(fret_fonts::test_support::face_blobs(
260 fret_fonts::bootstrap_profile()
261 .faces
262 .iter()
263 .chain(fret_fonts_emoji::default_profile().faces.iter())
264 .chain(fret_fonts_cjk::default_profile().faces.iter()),
265 ));
266 assert!(added > 0, "expected bundled fonts to load");
267 shaper
268 }
269
270 #[test]
271 fn decorations_are_pixel_snapped_under_non_integer_scale_factor() {
272 let mut shaper = shaper_with_bundled_fonts();
273
274 let content = {
275 let mut out = String::new();
276 for _ in 0..60 {
277 out.push_str("The quick brown fox jumps over the lazy dog. ");
278 }
279 out
280 };
281
282 let scale_factor = 1.25_f32;
283 let constraints = TextConstraints {
284 max_width: Some(Px(180.0)),
285 wrap: TextWrap::Word,
286 overflow: TextOverflow::Clip,
287 align: fret_core::TextAlign::Start,
288 scale_factor,
289 };
290 let style = TextStyle {
291 font: FontId::family("Inter"),
292 size: Px(13.0),
293 ..Default::default()
294 };
295
296 let mut span = TextSpan {
297 len: content.len(),
298 shaping: TextShapingStyle::default(),
299 paint: TextPaintStyle::default(),
300 };
301 span.paint.underline = Some(UnderlineStyle {
302 color: None,
303 style: DecorationLineStyle::Solid,
304 });
305 span.paint.strikethrough = Some(StrikethroughStyle {
306 color: None,
307 style: DecorationLineStyle::Solid,
308 });
309
310 let spans = [span];
311 let resolved = spans::resolve_spans_for_text(content.as_str(), spans.as_slice())
312 .expect("resolve spans");
313 assert_eq!(resolved.len(), 1);
314
315 let scale = crate::effective_text_scale_factor(scale_factor);
316 let snap_vertical = scale.fract().abs() > 1e-4;
317 assert!(
318 snap_vertical,
319 "expected fractional scale to enable snapping"
320 );
321
322 let wrapped = wrapper::wrap_with_constraints(
323 &mut shaper,
324 TextInputRef::attributed(content.as_str(), &style, spans.as_slice()),
325 constraints,
326 );
327 let prepared = prepare_layout::prepare_layout_from_wrapped(
328 content.as_str(),
329 wrapped,
330 constraints,
331 scale,
332 snap_vertical,
333 );
334 let lines: Vec<_> = prepared
335 .lines()
336 .iter()
337 .map(|line| line.layout().clone())
338 .collect();
339
340 let ppem = style.size.0 * scale;
341 let metrics_px = decoration_metrics_px_for_font_bytes(
342 fret_fonts::bootstrap_profile()
343 .faces
344 .first()
345 .map(|face| face.bytes)
346 .expect("bootstrap font bytes"),
347 0,
348 &[],
349 ppem,
350 )
351 .expect("decoration metrics");
352
353 let decorations = decorations_for_lines(
354 lines.as_slice(),
355 resolved.as_slice(),
356 Some(metrics_px),
357 scale,
358 snap_vertical,
359 );
360
361 let underlines: Vec<_> = decorations
362 .iter()
363 .filter(|d| d.kind() == TextDecorationKind::Underline)
364 .collect();
365 let strikes: Vec<_> = decorations
366 .iter()
367 .filter(|d| d.kind() == TextDecorationKind::Strikethrough)
368 .collect();
369 assert!(!underlines.is_empty(), "expected underline decorations");
370 assert!(!strikes.is_empty(), "expected strikethrough decorations");
371
372 let is_pixel_aligned = |logical: Px| {
373 let px = logical.0 * scale_factor;
374 (px - px.round()).abs() < 1e-3
375 };
376
377 for d in underlines.iter().chain(strikes.iter()) {
378 let rect = d.rect();
379 assert!(
380 is_pixel_aligned(rect.origin.y),
381 "expected decoration y to be pixel-aligned"
382 );
383 assert!(
384 is_pixel_aligned(rect.size.height),
385 "expected decoration height to be pixel-aligned"
386 );
387
388 let h_px = rect.size.height.0 * scale_factor;
389 assert!(
390 h_px >= 1.0 - 1e-3,
391 "expected a visible decoration thickness (>= 1px), got {h_px}"
392 );
393 assert!(
394 h_px <= 4.0 + 1e-3,
395 "expected decoration thickness to remain bounded, got {h_px}"
396 );
397
398 assert!(
399 rect.origin.y.0 >= -1e-3,
400 "expected decoration to stay within the text box (top)"
401 );
402 assert!(
403 rect.origin.y.0 + rect.size.height.0 <= prepared.metrics().size.height.0 + 1e-3,
404 "expected decoration to stay within the text box (bottom)"
405 );
406 }
407 }
408}