Skip to main content

cranpose_ui/text/
layout_options.rs

1use std::hash::{Hash, Hasher};
2
3const MIN_SCALE_DOWN_FONT_SIZE_SP: f32 = 1.0;
4
5/// How overflowing text should be handled.
6#[derive(Clone, Copy, Debug, Default)]
7pub enum TextOverflow {
8    /// Text that does not fit is cut off at the bounds of the text.
9    #[default]
10    Clip,
11    /// The end of the last visible line is replaced with an ellipsis.
12    Ellipsis,
13    /// Text is laid out in full and drawn beyond its bounds.
14    Visible,
15    /// The start of the line is replaced with an ellipsis. Takes effect only when `max_lines`
16    /// is 1; otherwise no ellipsis is added.
17    StartEllipsis,
18    /// The middle of the line is replaced with an ellipsis. Takes effect only when `max_lines`
19    /// is 1; otherwise no ellipsis is added.
20    MiddleEllipsis,
21    /// The font size shrinks until the text fits, down to `min_font_size_sp`. Text that
22    /// still overflows at that size is clipped.
23    ScaleDown {
24        /// Smallest font size to shrink to, in sp. Values below 1.0 and non-finite values count
25        /// as 1.0.
26        min_font_size_sp: f32,
27    },
28}
29
30impl TextOverflow {
31    /// Returns the overflow with the `ScaleDown` minimum size raised to at least 1.0 sp.
32    pub fn normalized(self) -> Self {
33        match self {
34            Self::ScaleDown { min_font_size_sp } => Self::ScaleDown {
35                min_font_size_sp: normalize_scale_down_min_font_size_sp(min_font_size_sp),
36            },
37            other => other,
38        }
39    }
40
41    /// Returns the minimum font size of `ScaleDown` in sp after normalizing, and `None` for
42    /// the other kinds.
43    pub fn scale_down_min_font_size_sp(self) -> Option<f32> {
44        match self.normalized() {
45            Self::ScaleDown { min_font_size_sp } => Some(min_font_size_sp),
46            _ => None,
47        }
48    }
49
50    fn is_ellipsis(self) -> bool {
51        matches!(
52            self,
53            Self::Ellipsis | Self::StartEllipsis | Self::MiddleEllipsis
54        )
55    }
56}
57
58impl PartialEq for TextOverflow {
59    fn eq(&self, other: &Self) -> bool {
60        match ((*self).normalized(), (*other).normalized()) {
61            (Self::Clip, Self::Clip)
62            | (Self::Ellipsis, Self::Ellipsis)
63            | (Self::Visible, Self::Visible)
64            | (Self::StartEllipsis, Self::StartEllipsis)
65            | (Self::MiddleEllipsis, Self::MiddleEllipsis) => true,
66            (
67                Self::ScaleDown {
68                    min_font_size_sp: left,
69                },
70                Self::ScaleDown {
71                    min_font_size_sp: right,
72                },
73            ) => left.to_bits() == right.to_bits(),
74            _ => false,
75        }
76    }
77}
78
79impl Eq for TextOverflow {}
80
81impl Hash for TextOverflow {
82    fn hash<H: Hasher>(&self, state: &mut H) {
83        match (*self).normalized() {
84            Self::Clip => 0u8.hash(state),
85            Self::Ellipsis => 1u8.hash(state),
86            Self::Visible => 2u8.hash(state),
87            Self::StartEllipsis => 3u8.hash(state),
88            Self::MiddleEllipsis => 4u8.hash(state),
89            Self::ScaleDown { min_font_size_sp } => {
90                5u8.hash(state);
91                min_font_size_sp.to_bits().hash(state);
92            }
93        }
94    }
95}
96
97/// Text layout behavior options matching Compose `BasicText` controls.
98#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
99pub struct TextLayoutOptions {
100    /// How text that does not fit is handled.
101    pub overflow: TextOverflow,
102    /// Whether lines wrap at the available width. When false, only explicit newlines break
103    /// lines.
104    pub soft_wrap: bool,
105    /// Largest number of lines to lay out. `usize::MAX` means no limit.
106    pub max_lines: usize,
107    /// Number of lines the text's height covers at least, even when it has fewer lines.
108    pub min_lines: usize,
109}
110
111impl Default for TextLayoutOptions {
112    fn default() -> Self {
113        Self {
114            overflow: TextOverflow::Clip,
115            soft_wrap: true,
116            max_lines: usize::MAX,
117            min_lines: 1,
118        }
119    }
120}
121
122impl TextLayoutOptions {
123    /// Returns these options with `min_lines` at least 1 and `max_lines` at
124    /// least `min_lines`.
125    ///
126    /// Ellipsized text that does not soft wrap lays out one line, as Compose's
127    /// `finalMaxLines` does, since each unwrapped line cannot end in its own
128    /// ellipsis. `min_lines` still sets its height.
129    pub fn normalized(self) -> Self {
130        let min_lines = self.min_lines.max(1);
131        let max_lines = if !self.soft_wrap && self.overflow.is_ellipsis() {
132            1
133        } else {
134            self.max_lines.max(min_lines)
135        };
136        Self {
137            overflow: self.overflow.normalized(),
138            soft_wrap: self.soft_wrap,
139            max_lines,
140            min_lines,
141        }
142    }
143}
144
145fn normalize_scale_down_min_font_size_sp(value: f32) -> f32 {
146    if value.is_finite() && value >= MIN_SCALE_DOWN_FONT_SIZE_SP {
147        value
148    } else {
149        MIN_SCALE_DOWN_FONT_SIZE_SP
150    }
151}
152
153/// High-level text widget options for constrained UI text.
154///
155/// `None` for `max_lines` means no explicit line limit.
156#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
157pub struct TextOptions {
158    /// How text that does not fit is handled.
159    pub overflow: TextOverflow,
160    /// Whether lines wrap at the available width. When false, only explicit newlines break
161    /// lines.
162    pub soft_wrap: bool,
163    /// Largest number of lines to lay out. `None` means no limit.
164    pub max_lines: Option<usize>,
165    /// Number of lines the text's height covers at least, even when it has fewer lines.
166    pub min_lines: usize,
167}
168
169impl Default for TextOptions {
170    fn default() -> Self {
171        Self {
172            overflow: TextOverflow::Clip,
173            soft_wrap: true,
174            max_lines: None,
175            min_lines: 1,
176        }
177    }
178}
179
180impl From<TextOptions> for TextLayoutOptions {
181    fn from(options: TextOptions) -> Self {
182        Self {
183            overflow: options.overflow,
184            soft_wrap: options.soft_wrap,
185            max_lines: options.max_lines.unwrap_or(usize::MAX),
186            min_lines: options.min_lines,
187        }
188        .normalized()
189    }
190}
191
192impl From<TextLayoutOptions> for TextOptions {
193    fn from(options: TextLayoutOptions) -> Self {
194        let options = options.normalized();
195        Self {
196            overflow: options.overflow,
197            soft_wrap: options.soft_wrap,
198            max_lines: (options.max_lines != usize::MAX).then_some(options.max_lines),
199            min_lines: options.min_lines,
200        }
201    }
202}
203
204#[cfg(test)]
205#[path = "tests/layout_options_tests.rs"]
206mod tests;