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    #[default]
9    Clip,
10    Ellipsis,
11    Visible,
12    StartEllipsis,
13    MiddleEllipsis,
14    ScaleDown {
15        min_font_size_sp: f32,
16    },
17}
18
19impl TextOverflow {
20    pub fn normalized(self) -> Self {
21        match self {
22            Self::ScaleDown { min_font_size_sp } => Self::ScaleDown {
23                min_font_size_sp: normalize_scale_down_min_font_size_sp(min_font_size_sp),
24            },
25            other => other,
26        }
27    }
28
29    pub fn scale_down_min_font_size_sp(self) -> Option<f32> {
30        match self.normalized() {
31            Self::ScaleDown { min_font_size_sp } => Some(min_font_size_sp),
32            _ => None,
33        }
34    }
35}
36
37impl PartialEq for TextOverflow {
38    fn eq(&self, other: &Self) -> bool {
39        match ((*self).normalized(), (*other).normalized()) {
40            (Self::Clip, Self::Clip)
41            | (Self::Ellipsis, Self::Ellipsis)
42            | (Self::Visible, Self::Visible)
43            | (Self::StartEllipsis, Self::StartEllipsis)
44            | (Self::MiddleEllipsis, Self::MiddleEllipsis) => true,
45            (
46                Self::ScaleDown {
47                    min_font_size_sp: left,
48                },
49                Self::ScaleDown {
50                    min_font_size_sp: right,
51                },
52            ) => left.to_bits() == right.to_bits(),
53            _ => false,
54        }
55    }
56}
57
58impl Eq for TextOverflow {}
59
60impl Hash for TextOverflow {
61    fn hash<H: Hasher>(&self, state: &mut H) {
62        match (*self).normalized() {
63            Self::Clip => 0u8.hash(state),
64            Self::Ellipsis => 1u8.hash(state),
65            Self::Visible => 2u8.hash(state),
66            Self::StartEllipsis => 3u8.hash(state),
67            Self::MiddleEllipsis => 4u8.hash(state),
68            Self::ScaleDown { min_font_size_sp } => {
69                5u8.hash(state);
70                min_font_size_sp.to_bits().hash(state);
71            }
72        }
73    }
74}
75
76/// Text layout behavior options matching Compose `BasicText` controls.
77#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
78pub struct TextLayoutOptions {
79    pub overflow: TextOverflow,
80    pub soft_wrap: bool,
81    pub max_lines: usize,
82    pub min_lines: usize,
83}
84
85impl Default for TextLayoutOptions {
86    fn default() -> Self {
87        Self {
88            overflow: TextOverflow::Clip,
89            soft_wrap: true,
90            max_lines: usize::MAX,
91            min_lines: 1,
92        }
93    }
94}
95
96impl TextLayoutOptions {
97    pub fn normalized(self) -> Self {
98        let min_lines = self.min_lines.max(1);
99        let max_lines = self.max_lines.max(min_lines);
100        Self {
101            overflow: self.overflow.normalized(),
102            soft_wrap: self.soft_wrap,
103            max_lines,
104            min_lines,
105        }
106    }
107}
108
109fn normalize_scale_down_min_font_size_sp(value: f32) -> f32 {
110    if value.is_finite() && value >= MIN_SCALE_DOWN_FONT_SIZE_SP {
111        value
112    } else {
113        MIN_SCALE_DOWN_FONT_SIZE_SP
114    }
115}
116
117/// High-level text widget options for constrained UI text.
118///
119/// `None` for `max_lines` means no explicit line limit.
120#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
121pub struct TextOptions {
122    pub overflow: TextOverflow,
123    pub soft_wrap: bool,
124    pub max_lines: Option<usize>,
125    pub min_lines: usize,
126}
127
128impl Default for TextOptions {
129    fn default() -> Self {
130        Self {
131            overflow: TextOverflow::Clip,
132            soft_wrap: true,
133            max_lines: None,
134            min_lines: 1,
135        }
136    }
137}
138
139impl From<TextOptions> for TextLayoutOptions {
140    fn from(options: TextOptions) -> Self {
141        Self {
142            overflow: options.overflow,
143            soft_wrap: options.soft_wrap,
144            max_lines: options.max_lines.unwrap_or(usize::MAX),
145            min_lines: options.min_lines,
146        }
147        .normalized()
148    }
149}
150
151impl From<TextLayoutOptions> for TextOptions {
152    fn from(options: TextLayoutOptions) -> Self {
153        let options = options.normalized();
154        Self {
155            overflow: options.overflow,
156            soft_wrap: options.soft_wrap,
157            max_lines: (options.max_lines != usize::MAX).then_some(options.max_lines),
158            min_lines: options.min_lines,
159        }
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn normalized_enforces_minimum_one_line() {
169        let options = TextLayoutOptions {
170            min_lines: 0,
171            max_lines: 0,
172            ..Default::default()
173        }
174        .normalized();
175
176        assert_eq!(options.min_lines, 1);
177        assert_eq!(options.max_lines, 1);
178    }
179
180    #[test]
181    fn normalized_ensures_max_not_smaller_than_min() {
182        let options = TextLayoutOptions {
183            min_lines: 3,
184            max_lines: 1,
185            ..Default::default()
186        }
187        .normalized();
188
189        assert_eq!(options.min_lines, 3);
190        assert_eq!(options.max_lines, 3);
191    }
192
193    #[test]
194    fn normalized_sanitizes_scale_down_min_font_size() {
195        let options = TextLayoutOptions {
196            overflow: TextOverflow::ScaleDown {
197                min_font_size_sp: f32::NAN,
198            },
199            ..Default::default()
200        }
201        .normalized();
202
203        assert_eq!(
204            options.overflow,
205            TextOverflow::ScaleDown {
206                min_font_size_sp: 1.0
207            }
208        );
209    }
210
211    #[test]
212    fn text_options_default_maps_to_unlimited_layout() {
213        let layout = TextLayoutOptions::from(TextOptions::default());
214
215        assert_eq!(layout.overflow, TextOverflow::Clip);
216        assert!(layout.soft_wrap);
217        assert_eq!(layout.max_lines, usize::MAX);
218        assert_eq!(layout.min_lines, 1);
219    }
220
221    #[test]
222    fn text_options_maps_optional_max_lines_to_layout_limit() {
223        let layout = TextLayoutOptions::from(TextOptions {
224            overflow: TextOverflow::Ellipsis,
225            soft_wrap: false,
226            max_lines: Some(1),
227            min_lines: 1,
228        });
229
230        assert_eq!(layout.overflow, TextOverflow::Ellipsis);
231        assert!(!layout.soft_wrap);
232        assert_eq!(layout.max_lines, 1);
233        assert_eq!(layout.min_lines, 1);
234    }
235
236    #[test]
237    fn text_options_preserve_scale_down_overflow() {
238        let layout = TextLayoutOptions::from(TextOptions {
239            overflow: TextOverflow::ScaleDown {
240                min_font_size_sp: 9.0,
241            },
242            soft_wrap: false,
243            max_lines: Some(1),
244            min_lines: 1,
245        });
246
247        assert_eq!(
248            layout.overflow,
249            TextOverflow::ScaleDown {
250                min_font_size_sp: 9.0
251            }
252        );
253        assert!(!layout.soft_wrap);
254        assert_eq!(layout.max_lines, 1);
255    }
256}