Skip to main content

ferroui_controls/
text_layout_rounding_placement.rs

1//! Where a text block puts the height that layout rounding adds to it, the type of
2//! `TextBlock.LayoutRoundingPlacement` (a FerroUI addition, docs/porting/DEVIATIONS.md, Text
3//! block; docs/porting/text-rendering.md, section 6).
4//!
5//! With layout rounding the desired height of a text block is the height of its text and its
6//! vertical padding rounded up to whole device pixels. The reference draws the text from the top
7//! of the block, so the added height ends up below the text. WinUI's TextBlock puts it above the
8//! text: `CTextBlock::MeasureOverride` keeps `m_layoutRoundingHeightAdjustment` (the rounded
9//! height minus the height) and `GetContentRenderingOffset` adds it to the top of the text, which
10//! lowers the baseline by up to one device pixel before the glyph run is snapped to the pixel
11//! grid.
12
13use ferroui_base::ferro_markup_enum;
14use ferroui_base::layout::LayoutHelper;
15
16/// Where a text block puts the height layout rounding adds to it: below its text (the
17/// reference's placement, the default) or above it (WinUI's).
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
19#[repr(i32)]
20pub enum TextLayoutRoundingPlacement {
21    /// The text starts at the top of the block (after its padding); the added height is below
22    /// it. The placement of the reference, and of the Fluent and Simple themes.
23    #[default]
24    Below,
25    /// The added height is above the text, as WinUI's TextBlock puts it: the text ends at the
26    /// bottom of the rounded box. The placement of the WinUI theme.
27    Above,
28}
29
30ferro_markup_enum!(TextLayoutRoundingPlacement { Below, Above }, { namespace: "FerroUI.Controls" });
31
32impl TextLayoutRoundingPlacement {
33    /// How far down a text block with layout rounding moves its text, in device-independent
34    /// pixels: `height` is the height of its content (the text and the vertical padding),
35    /// `scale` the layout scale. Zero for [`TextLayoutRoundingPlacement::Below`], and for a
36    /// height that is a whole number of device pixels already.
37    pub fn offset(self, height: f64, scale: f64) -> f64 {
38        match self {
39            Self::Below => 0.0,
40            Self::Above => {
41                if !height.is_finite() || height <= 0.0 || !scale.is_finite() || scale <= 0.0 {
42                    return 0.0;
43                }
44                (LayoutHelper::round_layout_value_up(height, scale) - height).max(0.0)
45            }
46        }
47    }
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    /// Segoe UI Variable: 2048 units per em, ascent 2210, descent 514, no line gap.
55    fn segoe(size: f64) -> (f64, f64) {
56        (size * 2210.0 / 2048.0, size * 2724.0 / 2048.0)
57    }
58
59    /// The baseline in device pixels from the top of a block on a whole pixel, as Skia and
60    /// Direct2D snap it (to the nearest pixel).
61    fn baseline(placement: TextLayoutRoundingPlacement, size: f64, scale: f64) -> f64 {
62        let (ascent, height) = segoe(size);
63        ((ascent + placement.offset(height, scale)) * scale + 0.5).floor()
64    }
65
66    #[test]
67    fn above_puts_the_baselines_where_winui_draws_them() {
68        // Measured on Windows (docs/porting/text-rendering.md, section 6): WinUI's 14 px text is
69        // where FerroUI's is at a scale of 1 and one pixel lower at 2; the 28 px title one pixel
70        // lower at both.
71        let below = TextLayoutRoundingPlacement::Below;
72        let above = TextLayoutRoundingPlacement::Above;
73        assert_eq!((15.0, 15.0), (baseline(below, 14.0, 1.0), baseline(above, 14.0, 1.0)));
74        assert_eq!((30.0, 31.0), (baseline(below, 14.0, 2.0), baseline(above, 14.0, 2.0)));
75        assert_eq!((30.0, 31.0), (baseline(below, 28.0, 1.0), baseline(above, 28.0, 1.0)));
76        assert_eq!((60.0, 61.0), (baseline(below, 28.0, 2.0), baseline(above, 28.0, 2.0)));
77    }
78
79    #[test]
80    fn the_offset_is_what_layout_rounding_adds() {
81        let above = TextLayoutRoundingPlacement::Above;
82        for scale in [1.0, 1.25, 1.5, 2.0] {
83            for height in [18.62, 15.96, 37.24, 42.56] {
84                let offset = above.offset(height, scale);
85                let rounded = ((height * scale).ceil()) / scale;
86                assert!((offset - (rounded - height)).abs() < 1e-9, "{height} at {scale}: {offset}");
87                assert!((0.0..1.0 / scale).contains(&offset), "{height} at {scale}: {offset}");
88                assert_eq!(0.0, TextLayoutRoundingPlacement::Below.offset(height, scale));
89            }
90        }
91        assert!((above.offset(18.62, 1.0) - 0.38).abs() < 1e-9);
92        assert!((above.offset(18.62, 2.0) - 0.38).abs() < 1e-9);
93        // 27.93 device pixels at 1.5 become 28.
94        assert!((above.offset(18.62, 1.5) - (28.0 / 1.5 - 18.62)).abs() < 1e-9);
95        // 23.275 device pixels at 1.25 become 24.
96        assert!((above.offset(18.62, 1.25) - (24.0 / 1.25 - 18.62)).abs() < 1e-9);
97        assert_eq!(0.0, above.offset(19.0, 1.0));
98        assert_eq!(0.0, above.offset(0.0, 2.0));
99        assert_eq!(0.0, above.offset(f64::INFINITY, 2.0));
100        assert_eq!(0.0, above.offset(18.62, 0.0));
101    }
102
103    #[test]
104    fn below_is_the_default() {
105        assert_eq!(TextLayoutRoundingPlacement::Below, TextLayoutRoundingPlacement::default());
106    }
107}