Skip to main content

fret_render_text/
decorations.rs

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 in the same coordinate space as selection rects (y=0 at the top of the text box).
15    rect: Rect,
16    /// When present, uses `paint_palette[paint_span]` as the base color if no explicit override exists.
17    paint_span: Option<u16>,
18    /// Optional explicit decoration color override.
19    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            // Swash metrics are expressed in the typical typographic coordinate system where
145            // positive Y points upward. Convert to our Y-down coordinate space by subtracting
146            // from the baseline.
147            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            // Underline: anchor to the baseline and snap in device px under fractional scaling.
178            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            // Strikethrough: approximate as a fraction of the line height above the baseline.
191            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}