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    fn is_ellipsis(self) -> bool {
37        matches!(
38            self,
39            Self::Ellipsis | Self::StartEllipsis | Self::MiddleEllipsis
40        )
41    }
42}
43
44impl PartialEq for TextOverflow {
45    fn eq(&self, other: &Self) -> bool {
46        match ((*self).normalized(), (*other).normalized()) {
47            (Self::Clip, Self::Clip)
48            | (Self::Ellipsis, Self::Ellipsis)
49            | (Self::Visible, Self::Visible)
50            | (Self::StartEllipsis, Self::StartEllipsis)
51            | (Self::MiddleEllipsis, Self::MiddleEllipsis) => true,
52            (
53                Self::ScaleDown {
54                    min_font_size_sp: left,
55                },
56                Self::ScaleDown {
57                    min_font_size_sp: right,
58                },
59            ) => left.to_bits() == right.to_bits(),
60            _ => false,
61        }
62    }
63}
64
65impl Eq for TextOverflow {}
66
67impl Hash for TextOverflow {
68    fn hash<H: Hasher>(&self, state: &mut H) {
69        match (*self).normalized() {
70            Self::Clip => 0u8.hash(state),
71            Self::Ellipsis => 1u8.hash(state),
72            Self::Visible => 2u8.hash(state),
73            Self::StartEllipsis => 3u8.hash(state),
74            Self::MiddleEllipsis => 4u8.hash(state),
75            Self::ScaleDown { min_font_size_sp } => {
76                5u8.hash(state);
77                min_font_size_sp.to_bits().hash(state);
78            }
79        }
80    }
81}
82
83/// Text layout behavior options matching Compose `BasicText` controls.
84#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
85pub struct TextLayoutOptions {
86    pub overflow: TextOverflow,
87    pub soft_wrap: bool,
88    pub max_lines: usize,
89    pub min_lines: usize,
90}
91
92impl Default for TextLayoutOptions {
93    fn default() -> Self {
94        Self {
95            overflow: TextOverflow::Clip,
96            soft_wrap: true,
97            max_lines: usize::MAX,
98            min_lines: 1,
99        }
100    }
101}
102
103impl TextLayoutOptions {
104    /// Returns these options with `min_lines` at least 1 and `max_lines` at
105    /// least `min_lines`.
106    ///
107    /// Ellipsized text that does not soft wrap lays out one line, as Compose's
108    /// `finalMaxLines` does, since each unwrapped line cannot end in its own
109    /// ellipsis. `min_lines` still sets its height.
110    pub fn normalized(self) -> Self {
111        let min_lines = self.min_lines.max(1);
112        let max_lines = if !self.soft_wrap && self.overflow.is_ellipsis() {
113            1
114        } else {
115            self.max_lines.max(min_lines)
116        };
117        Self {
118            overflow: self.overflow.normalized(),
119            soft_wrap: self.soft_wrap,
120            max_lines,
121            min_lines,
122        }
123    }
124}
125
126fn normalize_scale_down_min_font_size_sp(value: f32) -> f32 {
127    if value.is_finite() && value >= MIN_SCALE_DOWN_FONT_SIZE_SP {
128        value
129    } else {
130        MIN_SCALE_DOWN_FONT_SIZE_SP
131    }
132}
133
134/// High-level text widget options for constrained UI text.
135///
136/// `None` for `max_lines` means no explicit line limit.
137#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
138pub struct TextOptions {
139    pub overflow: TextOverflow,
140    pub soft_wrap: bool,
141    pub max_lines: Option<usize>,
142    pub min_lines: usize,
143}
144
145impl Default for TextOptions {
146    fn default() -> Self {
147        Self {
148            overflow: TextOverflow::Clip,
149            soft_wrap: true,
150            max_lines: None,
151            min_lines: 1,
152        }
153    }
154}
155
156impl From<TextOptions> for TextLayoutOptions {
157    fn from(options: TextOptions) -> Self {
158        Self {
159            overflow: options.overflow,
160            soft_wrap: options.soft_wrap,
161            max_lines: options.max_lines.unwrap_or(usize::MAX),
162            min_lines: options.min_lines,
163        }
164        .normalized()
165    }
166}
167
168impl From<TextLayoutOptions> for TextOptions {
169    fn from(options: TextLayoutOptions) -> Self {
170        let options = options.normalized();
171        Self {
172            overflow: options.overflow,
173            soft_wrap: options.soft_wrap,
174            max_lines: (options.max_lines != usize::MAX).then_some(options.max_lines),
175            min_lines: options.min_lines,
176        }
177    }
178}
179
180#[cfg(test)]
181#[path = "tests/layout_options_tests.rs"]
182mod tests;