Skip to main content

cranpose_ui/text/
paragraph.rs

1use icu_properties::{CodePointMapData, props::BidiClass};
2
3use crate::text::unit::TextUnit;
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
6pub enum TextAlign {
7    #[default]
8    Unspecified,
9    Left,
10    Right,
11    Center,
12    Justify,
13    Start,
14    End,
15}
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
18pub enum TextDirection {
19    #[default]
20    Unspecified,
21    Ltr,
22    Rtl,
23    Content,
24    ContentOrLtr,
25    ContentOrRtl,
26}
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
29pub enum LineBreak {
30    #[default]
31    Unspecified,
32    Simple,
33    Paragraph,
34    Heading,
35}
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
38pub enum Hyphens {
39    #[default]
40    Unspecified,
41    None,
42    Auto,
43}
44
45#[derive(Clone, Copy, Debug, PartialEq)]
46pub struct TextIndent {
47    pub first_line: TextUnit,
48    pub rest_line: TextUnit,
49}
50
51impl Default for TextIndent {
52    fn default() -> Self {
53        Self {
54            first_line: TextUnit::Unspecified,
55            rest_line: TextUnit::Unspecified,
56        }
57    }
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
61pub enum ResolvedTextDirection {
62    #[default]
63    Ltr,
64    Rtl,
65}
66
67impl TextDirection {
68    /// Resolves content-based directions from the first strong Unicode bidi
69    /// character outside directional isolates. Text without a strong character
70    /// uses the variant's fallback, which is LTR except for `ContentOrRtl`.
71    pub fn resolve(self, text: &str) -> ResolvedTextDirection {
72        match self {
73            TextDirection::Ltr => ResolvedTextDirection::Ltr,
74            TextDirection::Rtl => ResolvedTextDirection::Rtl,
75            TextDirection::Content | TextDirection::ContentOrLtr => {
76                resolve_content_direction(text, ResolvedTextDirection::Ltr)
77            }
78            TextDirection::ContentOrRtl => {
79                resolve_content_direction(text, ResolvedTextDirection::Rtl)
80            }
81            TextDirection::Unspecified => {
82                resolve_content_direction(text, ResolvedTextDirection::Ltr)
83            }
84        }
85    }
86}
87
88impl LineBreak {
89    pub fn is_specified(self) -> bool {
90        !matches!(self, Self::Unspecified)
91    }
92
93    pub fn take_or_else(self, fallback: impl FnOnce() -> LineBreak) -> LineBreak {
94        if self.is_specified() {
95            self
96        } else {
97            fallback()
98        }
99    }
100}
101
102impl Hyphens {
103    pub fn is_specified(self) -> bool {
104        !matches!(self, Self::Unspecified)
105    }
106
107    pub fn take_or_else(self, fallback: impl FnOnce() -> Hyphens) -> Hyphens {
108        if self.is_specified() {
109            self
110        } else {
111            fallback()
112        }
113    }
114}
115
116/// Resolves a paragraph's direction, using content with an LTR fallback when
117/// no direction is specified.
118pub fn resolve_text_direction(
119    text: &str,
120    text_direction: Option<TextDirection>,
121) -> ResolvedTextDirection {
122    text_direction.unwrap_or_default().resolve(text)
123}
124
125fn resolve_content_direction(text: &str, fallback: ResolvedTextDirection) -> ResolvedTextDirection {
126    for (index, byte) in text.bytes().enumerate() {
127        if byte.is_ascii_alphabetic() {
128            return ResolvedTextDirection::Ltr;
129        }
130        if !byte.is_ascii() {
131            return resolve_unicode_content_direction(&text[index..], fallback);
132        }
133    }
134    fallback
135}
136
137fn resolve_unicode_content_direction(
138    text: &str,
139    fallback: ResolvedTextDirection,
140) -> ResolvedTextDirection {
141    let classes = CodePointMapData::<BidiClass>::new();
142    let mut isolate_depth = 0usize;
143    for ch in text.chars() {
144        match ch {
145            '\u{2066}'..='\u{2068}' => isolate_depth += 1,
146            '\u{2069}' => isolate_depth = isolate_depth.saturating_sub(1),
147            _ if isolate_depth == 0 => match classes.get(ch) {
148                BidiClass::LeftToRight => return ResolvedTextDirection::Ltr,
149                BidiClass::RightToLeft | BidiClass::ArabicLetter => {
150                    return ResolvedTextDirection::Rtl;
151                }
152                _ => {}
153            },
154            _ => {}
155        }
156    }
157    fallback
158}
159
160#[cfg(test)]
161#[path = "tests/paragraph_tests.rs"]
162mod tests;