cranpose_ui/text/paragraph.rs
1use icu_properties::{CodePointMapData, props::BidiClass};
2
3use crate::text::unit::TextUnit;
4
5/// Horizontal alignment of the lines of a paragraph within its width.
6/// Compose's `TextAlign`.
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
8pub enum TextAlign {
9 /// No alignment is set. The value comes from an enclosing style and acts like `Start`
10 /// when no style sets it.
11 #[default]
12 Unspecified,
13 /// Aligns lines to the left edge in both text directions.
14 Left,
15 /// Aligns lines to the right edge in both text directions.
16 Right,
17 /// Centers each line within the paragraph width.
18 Center,
19 /// Justified alignment. Lines are not stretched yet; they align like `Start`.
20 Justify,
21 /// Aligns lines to the start edge: left for left-to-right text, right otherwise.
22 Start,
23 /// Aligns lines to the end edge: right for left-to-right text, left otherwise.
24 End,
25}
26
27/// Base direction of a paragraph. Compose's `TextDirection`.
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
29pub enum TextDirection {
30 /// No direction is set. The value comes from an enclosing style and acts like `Content`
31 /// when no style sets it.
32 #[default]
33 Unspecified,
34 /// Left-to-right.
35 Ltr,
36 /// Right-to-left.
37 Rtl,
38 /// Taken from the first strong directional character of the text, left-to-right if the
39 /// text has none.
40 Content,
41 /// Like `Content`, with left-to-right as the fallback.
42 ContentOrLtr,
43 /// Like `Content`, with right-to-left as the fallback.
44 ContentOrRtl,
45}
46
47/// How a paragraph is broken into lines. Compose's `LineBreak`.
48#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
49pub enum LineBreak {
50 /// No strategy is set. The value comes from an enclosing style and acts like `Simple`
51 /// when no style sets it.
52 #[default]
53 Unspecified,
54 /// Fills each line with as many words as fit. Fast, and the wrap changes little while
55 /// the user edits text.
56 Simple,
57 /// Spreads the words over the lines to even out their lengths, leaving the last line
58 /// free to stay short. Costs more than `Simple`.
59 Paragraph,
60 /// Spreads the words over all lines, the last one included, for short text such as
61 /// titles. Costs more than `Simple`.
62 Heading,
63}
64
65/// Whether words may be split with a hyphen at the end of a line. Compose's `Hyphens`.
66#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
67pub enum Hyphens {
68 /// No setting. The value comes from an enclosing style and acts like `None` when no
69 /// style sets it.
70 #[default]
71 Unspecified,
72 /// Words are never split with a hyphen.
73 None,
74 /// Words are split at hyphenation points of the text's locale when it makes a line fit.
75 Auto,
76}
77
78/// Indentation of the lines of a paragraph. Compose's `TextIndent`. Not yet applied by
79/// text layout.
80#[derive(Clone, Copy, Debug, PartialEq)]
81pub struct TextIndent {
82 /// Indent of the first line, as a `TextUnit`.
83 pub first_line: TextUnit,
84 /// Indent of every line after the first, as a `TextUnit`.
85 pub rest_line: TextUnit,
86}
87
88impl Default for TextIndent {
89 fn default() -> Self {
90 Self {
91 first_line: TextUnit::Unspecified,
92 rest_line: TextUnit::Unspecified,
93 }
94 }
95}
96
97/// A text direction with the content-based choices resolved, as used for layout.
98#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
99pub enum ResolvedTextDirection {
100 /// Left-to-right.
101 #[default]
102 Ltr,
103 /// Right-to-left.
104 Rtl,
105}
106
107impl TextDirection {
108 /// Resolves content-based directions from the first strong Unicode bidi
109 /// character outside directional isolates. Text without a strong character
110 /// uses the variant's fallback, which is LTR except for `ContentOrRtl`.
111 pub fn resolve(self, text: &str) -> ResolvedTextDirection {
112 match self {
113 TextDirection::Ltr => ResolvedTextDirection::Ltr,
114 TextDirection::Rtl => ResolvedTextDirection::Rtl,
115 TextDirection::Content | TextDirection::ContentOrLtr => {
116 resolve_content_direction(text, ResolvedTextDirection::Ltr)
117 }
118 TextDirection::ContentOrRtl => {
119 resolve_content_direction(text, ResolvedTextDirection::Rtl)
120 }
121 TextDirection::Unspecified => {
122 resolve_content_direction(text, ResolvedTextDirection::Ltr)
123 }
124 }
125 }
126}
127
128impl LineBreak {
129 /// Returns true unless the value is `Unspecified`.
130 pub fn is_specified(self) -> bool {
131 !matches!(self, Self::Unspecified)
132 }
133
134 /// Returns `self` when it is specified, otherwise the value returned by `fallback`.
135 pub fn take_or_else(self, fallback: impl FnOnce() -> LineBreak) -> LineBreak {
136 if self.is_specified() {
137 self
138 } else {
139 fallback()
140 }
141 }
142}
143
144impl Hyphens {
145 /// Returns true unless the value is `Unspecified`.
146 pub fn is_specified(self) -> bool {
147 !matches!(self, Self::Unspecified)
148 }
149
150 /// Returns `self` when it is specified, otherwise the value returned by `fallback`.
151 pub fn take_or_else(self, fallback: impl FnOnce() -> Hyphens) -> Hyphens {
152 if self.is_specified() {
153 self
154 } else {
155 fallback()
156 }
157 }
158}
159
160/// Resolves a paragraph's direction, using content with an LTR fallback when
161/// no direction is specified.
162pub fn resolve_text_direction(
163 text: &str,
164 text_direction: Option<TextDirection>,
165) -> ResolvedTextDirection {
166 text_direction.unwrap_or_default().resolve(text)
167}
168
169fn resolve_content_direction(text: &str, fallback: ResolvedTextDirection) -> ResolvedTextDirection {
170 for (index, byte) in text.bytes().enumerate() {
171 if byte.is_ascii_alphabetic() {
172 return ResolvedTextDirection::Ltr;
173 }
174 if !byte.is_ascii() {
175 return resolve_unicode_content_direction(&text[index..], fallback);
176 }
177 }
178 fallback
179}
180
181fn resolve_unicode_content_direction(
182 text: &str,
183 fallback: ResolvedTextDirection,
184) -> ResolvedTextDirection {
185 let classes = CodePointMapData::<BidiClass>::new();
186 let mut isolate_depth = 0usize;
187 for ch in text.chars() {
188 match ch {
189 '\u{2066}'..='\u{2068}' => isolate_depth += 1,
190 '\u{2069}' => isolate_depth = isolate_depth.saturating_sub(1),
191 _ if isolate_depth == 0 => match classes.get(ch) {
192 BidiClass::LeftToRight => return ResolvedTextDirection::Ltr,
193 BidiClass::RightToLeft | BidiClass::ArabicLetter => {
194 return ResolvedTextDirection::Rtl;
195 }
196 _ => {}
197 },
198 _ => {}
199 }
200 }
201 fallback
202}
203
204#[cfg(test)]
205#[path = "tests/paragraph_tests.rs"]
206mod tests;