1#![forbid(unsafe_code)]
2
3pub mod alloc_budget;
23pub mod ansi;
24pub mod budget;
25pub mod buffer;
26pub mod cell;
27pub mod counting_writer;
28pub mod diff;
29pub mod diff_strategy;
30pub mod drawing;
31pub mod frame;
32pub mod grapheme_pool;
33pub mod headless;
34pub mod link_registry;
35pub mod presenter;
36pub mod sanitize;
37pub mod spatial_hit_index;
38pub mod terminal_model;
39
40mod text_width {
41 use std::sync::OnceLock;
42
43 use unicode_display_width::width as unicode_display_width;
44 use unicode_segmentation::UnicodeSegmentation;
45 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
46
47 #[inline]
48 fn env_flag(value: &str) -> bool {
49 matches!(
50 value.trim().to_ascii_lowercase().as_str(),
51 "1" | "true" | "yes" | "on"
52 )
53 }
54
55 #[inline]
56 fn is_cjk_locale(locale: &str) -> bool {
57 let lower = locale.trim().to_ascii_lowercase();
58 lower.starts_with("ja") || lower.starts_with("zh") || lower.starts_with("ko")
59 }
60
61 #[inline]
62 fn use_cjk_width() -> bool {
63 static CJK_WIDTH: OnceLock<bool> = OnceLock::new();
64 *CJK_WIDTH.get_or_init(|| {
65 if let Ok(value) =
66 std::env::var("FTUI_TEXT_CJK_WIDTH").or_else(|_| std::env::var("FTUI_CJK_WIDTH"))
67 {
68 return env_flag(&value);
69 }
70 if let Ok(locale) = std::env::var("LC_CTYPE").or_else(|_| std::env::var("LANG")) {
71 return is_cjk_locale(&locale);
72 }
73 false
74 })
75 }
76
77 #[inline]
78 fn ascii_display_width(text: &str) -> usize {
79 let mut width = 0;
80 for b in text.bytes() {
81 match b {
82 b'\t' | b'\n' | b'\r' => width += 1,
83 0x20..=0x7E => width += 1,
84 _ => {}
85 }
86 }
87 width
88 }
89
90 #[inline]
91 fn ascii_width(text: &str) -> Option<usize> {
92 if text.bytes().all(|b| (0x20..=0x7E).contains(&b)) {
93 Some(text.len())
94 } else {
95 None
96 }
97 }
98
99 #[inline]
100 fn is_zero_width_codepoint(c: char) -> bool {
101 let u = c as u32;
102 matches!(u, 0x0000..=0x001F | 0x007F..=0x009F)
103 || matches!(u, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF)
104 || matches!(u, 0xFE20..=0xFE2F)
105 || matches!(u, 0xFE00..=0xFE0F | 0xE0100..=0xE01EF)
106 || matches!(
107 u,
108 0x00AD
109 | 0x034F
110 | 0x180E
111 | 0x200B
112 | 0x200C
113 | 0x200D
114 | 0x200E
115 | 0x200F
116 | 0x2060
117 | 0xFEFF
118 )
119 || matches!(u, 0x202A..=0x202E | 0x2066..=0x2069 | 0x206A..=0x206F)
120 }
121
122 #[inline]
123 pub(crate) fn grapheme_width(grapheme: &str) -> usize {
124 if grapheme.is_ascii() {
125 return ascii_display_width(grapheme);
126 }
127 if grapheme.chars().all(is_zero_width_codepoint) {
128 return 0;
129 }
130 if use_cjk_width() {
131 return grapheme.width_cjk();
132 }
133 unicode_display_width(grapheme) as usize
134 }
135
136 #[inline]
137 pub(crate) fn char_width(ch: char) -> usize {
138 if ch.is_ascii() {
139 return match ch {
140 '\t' | '\n' | '\r' => 1,
141 ' '..='~' => 1,
142 _ => 0,
143 };
144 }
145 if is_zero_width_codepoint(ch) {
146 return 0;
147 }
148 if use_cjk_width() {
149 ch.width_cjk().unwrap_or(0)
150 } else {
151 ch.width().unwrap_or(0)
152 }
153 }
154
155 #[inline]
156 pub(crate) fn display_width(text: &str) -> usize {
157 if let Some(width) = ascii_width(text) {
158 return width;
159 }
160 if text.is_ascii() {
161 return ascii_display_width(text);
162 }
163 let cjk_width = use_cjk_width();
164 if !text.chars().any(is_zero_width_codepoint) {
165 if cjk_width {
166 return text.width_cjk();
167 }
168 return unicode_display_width(text) as usize;
169 }
170 text.graphemes(true).map(grapheme_width).sum()
171 }
172}
173
174pub(crate) use text_width::{char_width, display_width, grapheme_width};
175
176#[cfg(test)]
177mod tests {
178 use super::{display_width, grapheme_width};
179
180 #[test]
181 fn display_width_matches_expected_samples() {
182 let samples = [
184 ("hello", 5usize),
185 ("π", 2usize),
186 ("π©βπ»", 2usize),
187 ("πΊπΈ", 2usize),
188 ("β€οΈ", 2usize),
189 ("β¨οΈ", 2usize),
190 ("β οΈ", 2usize),
191 ("β", 2usize),
192 ("AπB", 4usize),
193 ("ok β
", 5usize),
194 ];
195 for (sample, expected) in samples {
196 assert_eq!(
197 display_width(sample),
198 expected,
199 "display width mismatch for {sample:?}"
200 );
201 }
202 }
203
204 #[test]
205 fn grapheme_width_matches_expected_samples() {
206 let samples = [
207 ("a", 1usize),
208 ("π", 2usize),
209 ("π©βπ»", 2usize),
210 ("πΊπΈ", 2usize),
211 ("ππ½", 2usize),
212 ("β€οΈ", 2usize),
213 ("β¨οΈ", 2usize),
214 ("β οΈ", 2usize),
215 ("β", 2usize),
216 ];
217 for (grapheme, expected) in samples {
218 assert_eq!(
219 grapheme_width(grapheme),
220 expected,
221 "grapheme width mismatch for {grapheme:?}"
222 );
223 }
224 }
225}