1#![cfg_attr(not(test), forbid(unsafe_code))]
3#![cfg_attr(test, deny(unsafe_code))]
4
5pub mod animation;
24pub mod capability_override;
25pub mod cursor;
26pub mod cx;
27pub mod event;
28pub mod event_coalescer;
29pub mod geometry;
30pub mod gesture;
31pub mod glyph_policy;
32pub mod hover_stabilizer;
33pub mod inline_mode;
34pub mod input_parser;
35pub mod key_sequence;
36pub mod keybinding;
37pub mod logging;
38pub mod mux_passthrough;
39pub mod read_optimized;
40pub mod s3_fifo;
41pub mod semantic_event;
42pub mod terminal_capabilities;
43#[cfg(all(not(target_arch = "wasm32"), feature = "crossterm"))]
44pub mod terminal_session;
45
46#[cfg(feature = "caps-probe")]
47pub mod caps_probe;
48
49#[cfg(feature = "tracing")]
51pub use logging::{
52 debug, debug_span, error, error_span, info, info_span, trace, trace_span, warn, warn_span,
53};
54
55pub mod text_width {
56 use std::sync::OnceLock;
63
64 use unicode_display_width::width as unicode_display_width;
65 use unicode_segmentation::UnicodeSegmentation;
66 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
67
68 #[inline]
69 fn env_flag(value: &str) -> bool {
70 matches!(
71 value.trim().to_ascii_lowercase().as_str(),
72 "1" | "true" | "yes" | "on"
73 )
74 }
75
76 #[inline]
77 fn is_cjk_locale(locale: &str) -> bool {
78 let lower = locale.trim().to_ascii_lowercase();
79 lower.starts_with("ja") || lower.starts_with("zh") || lower.starts_with("ko")
80 }
81
82 #[inline]
83 fn cjk_width_from_env_impl<F>(get_env: F) -> bool
84 where
85 F: Fn(&str) -> Option<String>,
86 {
87 if let Some(value) = get_env("FTUI_GLYPH_DOUBLE_WIDTH") {
88 return env_flag(&value);
89 }
90 if let Some(value) = get_env("FTUI_TEXT_CJK_WIDTH").or_else(|| get_env("FTUI_CJK_WIDTH")) {
91 return env_flag(&value);
92 }
93 if let Some(locale) = get_env("LC_CTYPE").or_else(|| get_env("LANG")) {
94 return is_cjk_locale(&locale);
95 }
96 false
97 }
98
99 #[inline]
100 fn use_cjk_width() -> bool {
101 static CJK_WIDTH: OnceLock<bool> = OnceLock::new();
102 *CJK_WIDTH.get_or_init(|| cjk_width_from_env_impl(|key| std::env::var(key).ok()))
103 }
104
105 #[inline]
112 fn trust_vs16_width() -> bool {
113 static TRUST: OnceLock<bool> = OnceLock::new();
114 *TRUST.get_or_init(|| {
115 std::env::var("FTUI_EMOJI_VS16_WIDTH")
116 .map(|v| v.eq_ignore_ascii_case("unicode") || v == "2")
117 .unwrap_or(false)
118 })
119 }
120
121 #[inline]
123 pub fn vs16_trust_from_env<F>(get_env: F) -> bool
124 where
125 F: Fn(&str) -> Option<String>,
126 {
127 get_env("FTUI_EMOJI_VS16_WIDTH")
128 .map(|v| v.eq_ignore_ascii_case("unicode") || v == "2")
129 .unwrap_or(false)
130 }
131
132 #[inline]
134 pub fn vs16_width_trusted() -> bool {
135 trust_vs16_width()
136 }
137
138 #[inline]
141 fn strip_vs16(grapheme: &str) -> Option<String> {
142 if grapheme.contains('\u{FE0F}') {
143 Some(grapheme.chars().filter(|&c| c != '\u{FE0F}').collect())
144 } else {
145 None
146 }
147 }
148
149 #[inline]
151 pub fn cjk_width_from_env<F>(get_env: F) -> bool
152 where
153 F: Fn(&str) -> Option<String>,
154 {
155 cjk_width_from_env_impl(get_env)
156 }
157
158 #[inline]
160 pub fn cjk_width_enabled() -> bool {
161 use_cjk_width()
162 }
163
164 #[inline]
165 fn ascii_display_width(text: &str) -> usize {
166 let mut width = 0;
167 for b in text.bytes() {
168 match b {
169 b'\t' | b'\n' | b'\r' => width += 1,
170 0x20..=0x7E => width += 1,
171 _ => {}
172 }
173 }
174 width
175 }
176
177 #[inline]
179 #[must_use]
180 pub fn ascii_width(text: &str) -> Option<usize> {
181 if text.bytes().all(|b| (0x20..=0x7E).contains(&b)) {
182 Some(text.len())
183 } else {
184 None
185 }
186 }
187
188 #[inline]
189 fn is_zero_width_codepoint(c: char) -> bool {
190 let u = c as u32;
191 matches!(u, 0x0000..=0x001F | 0x007F..=0x009F)
192 || matches!(u, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF)
193 || matches!(u, 0xFE20..=0xFE2F)
194 || matches!(u, 0xFE00..=0xFE0F | 0xE0100..=0xE01EF)
195 || matches!(
196 u,
197 0x00AD
198 | 0x034F
199 | 0x180E
200 | 0x200B
201 | 0x200C
202 | 0x200D
203 | 0x200E
204 | 0x200F
205 | 0x2060
206 | 0xFEFF
207 )
208 || matches!(u, 0x202A..=0x202E | 0x2066..=0x2069 | 0x206A..=0x206F)
209 }
210
211 #[inline]
213 #[must_use]
214 pub fn grapheme_width(grapheme: &str) -> usize {
215 if grapheme.is_ascii() {
216 return ascii_display_width(grapheme);
217 }
218 if grapheme.chars().all(is_zero_width_codepoint) {
219 return 0;
220 }
221 if use_cjk_width() {
222 return grapheme.width_cjk();
223 }
224 if !trust_vs16_width()
228 && let Some(stripped) = strip_vs16(grapheme)
229 {
230 if stripped.is_empty() {
231 return 0;
232 }
233 return unicode_display_width(&stripped) as usize;
234 }
235 unicode_display_width(grapheme) as usize
236 }
237
238 #[inline]
240 #[must_use]
241 pub fn char_width(ch: char) -> usize {
242 if ch.is_ascii() {
243 return match ch {
244 '\t' | '\n' | '\r' => 1,
245 ' '..='~' => 1,
246 _ => 0,
247 };
248 }
249 if is_zero_width_codepoint(ch) {
250 return 0;
251 }
252 if use_cjk_width() {
253 ch.width_cjk().unwrap_or(0)
254 } else {
255 ch.width().unwrap_or(0)
256 }
257 }
258
259 #[inline]
261 #[must_use]
262 pub fn display_width(text: &str) -> usize {
263 if let Some(width) = ascii_width(text) {
264 return width;
265 }
266 if text.is_ascii() {
267 return ascii_display_width(text);
268 }
269 let cjk_width = use_cjk_width();
270 if !text.chars().any(is_zero_width_codepoint) {
271 if cjk_width {
272 return text.width_cjk();
273 }
274 return unicode_display_width(text) as usize;
275 }
276 text.graphemes(true).map(grapheme_width).sum()
277 }
278
279 #[cfg(test)]
280 mod tests {
281 use super::*;
282
283 #[test]
286 fn cjk_width_env_explicit_true() {
287 let get = |key: &str| match key {
288 "FTUI_GLYPH_DOUBLE_WIDTH" => Some("1".into()),
289 _ => None,
290 };
291 assert!(cjk_width_from_env(get));
292 }
293
294 #[test]
295 fn cjk_width_env_explicit_false() {
296 let get = |key: &str| match key {
297 "FTUI_GLYPH_DOUBLE_WIDTH" => Some("0".into()),
298 _ => None,
299 };
300 assert!(!cjk_width_from_env(get));
301 }
302
303 #[test]
304 fn cjk_width_env_text_cjk_key() {
305 let get = |key: &str| match key {
306 "FTUI_TEXT_CJK_WIDTH" => Some("true".into()),
307 _ => None,
308 };
309 assert!(cjk_width_from_env(get));
310 }
311
312 #[test]
313 fn cjk_width_env_fallback_key() {
314 let get = |key: &str| match key {
315 "FTUI_CJK_WIDTH" => Some("yes".into()),
316 _ => None,
317 };
318 assert!(cjk_width_from_env(get));
319 }
320
321 #[test]
322 fn cjk_width_env_japanese_locale() {
323 let get = |key: &str| match key {
324 "LC_CTYPE" => Some("ja_JP.UTF-8".into()),
325 _ => None,
326 };
327 assert!(cjk_width_from_env(get));
328 }
329
330 #[test]
331 fn cjk_width_env_chinese_locale() {
332 let get = |key: &str| match key {
333 "LANG" => Some("zh_CN.UTF-8".into()),
334 _ => None,
335 };
336 assert!(cjk_width_from_env(get));
337 }
338
339 #[test]
340 fn cjk_width_env_korean_locale() {
341 let get = |key: &str| match key {
342 "LC_CTYPE" => Some("ko_KR.UTF-8".into()),
343 _ => None,
344 };
345 assert!(cjk_width_from_env(get));
346 }
347
348 #[test]
349 fn cjk_width_env_english_locale_returns_false() {
350 let get = |key: &str| match key {
351 "LANG" => Some("en_US.UTF-8".into()),
352 _ => None,
353 };
354 assert!(!cjk_width_from_env(get));
355 }
356
357 #[test]
358 fn cjk_width_env_no_vars_returns_false() {
359 let get = |_: &str| -> Option<String> { None };
360 assert!(!cjk_width_from_env(get));
361 }
362
363 #[test]
364 fn cjk_width_env_glyph_overrides_locale() {
365 let get = |key: &str| match key {
367 "FTUI_GLYPH_DOUBLE_WIDTH" => Some("0".into()),
368 "LANG" => Some("ja_JP.UTF-8".into()),
369 _ => None,
370 };
371 assert!(!cjk_width_from_env(get));
372 }
373
374 #[test]
375 fn cjk_width_env_on_is_true() {
376 let get = |key: &str| match key {
377 "FTUI_GLYPH_DOUBLE_WIDTH" => Some("on".into()),
378 _ => None,
379 };
380 assert!(cjk_width_from_env(get));
381 }
382
383 #[test]
384 fn cjk_width_env_case_insensitive() {
385 let get = |key: &str| match key {
386 "FTUI_CJK_WIDTH" => Some("TRUE".into()),
387 _ => None,
388 };
389 assert!(cjk_width_from_env(get));
390 }
391
392 #[test]
395 fn vs16_trust_unicode_string() {
396 let get = |key: &str| match key {
397 "FTUI_EMOJI_VS16_WIDTH" => Some("unicode".into()),
398 _ => None,
399 };
400 assert!(vs16_trust_from_env(get));
401 }
402
403 #[test]
404 fn vs16_trust_value_2() {
405 let get = |key: &str| match key {
406 "FTUI_EMOJI_VS16_WIDTH" => Some("2".into()),
407 _ => None,
408 };
409 assert!(vs16_trust_from_env(get));
410 }
411
412 #[test]
413 fn vs16_trust_not_set() {
414 let get = |_: &str| -> Option<String> { None };
415 assert!(!vs16_trust_from_env(get));
416 }
417
418 #[test]
419 fn vs16_trust_other_value() {
420 let get = |key: &str| match key {
421 "FTUI_EMOJI_VS16_WIDTH" => Some("1".into()),
422 _ => None,
423 };
424 assert!(!vs16_trust_from_env(get));
425 }
426
427 #[test]
428 fn vs16_trust_case_insensitive() {
429 let get = |key: &str| match key {
430 "FTUI_EMOJI_VS16_WIDTH" => Some("UNICODE".into()),
431 _ => None,
432 };
433 assert!(vs16_trust_from_env(get));
434 }
435
436 #[test]
439 fn ascii_width_pure_ascii() {
440 assert_eq!(ascii_width("hello"), Some(5));
441 }
442
443 #[test]
444 fn ascii_width_empty() {
445 assert_eq!(ascii_width(""), Some(0));
446 }
447
448 #[test]
449 fn ascii_width_with_space() {
450 assert_eq!(ascii_width("hello world"), Some(11));
451 }
452
453 #[test]
454 fn ascii_width_non_ascii_returns_none() {
455 assert_eq!(ascii_width("héllo"), None);
456 }
457
458 #[test]
459 fn ascii_width_with_tab_returns_none() {
460 assert_eq!(ascii_width("hello\tworld"), None);
462 }
463
464 #[test]
465 fn ascii_width_with_newline_returns_none() {
466 assert_eq!(ascii_width("hello\n"), None);
467 }
468
469 #[test]
470 fn ascii_width_control_char_returns_none() {
471 assert_eq!(ascii_width("\x01"), None);
472 }
473
474 #[test]
477 fn char_width_ascii_letter() {
478 assert_eq!(char_width('A'), 1);
479 }
480
481 #[test]
482 fn char_width_space() {
483 assert_eq!(char_width(' '), 1);
484 }
485
486 #[test]
487 fn char_width_tab() {
488 assert_eq!(char_width('\t'), 1);
489 }
490
491 #[test]
492 fn char_width_newline() {
493 assert_eq!(char_width('\n'), 1);
494 }
495
496 #[test]
497 fn char_width_nul() {
498 assert_eq!(char_width('\0'), 0);
500 }
501
502 #[test]
503 fn char_width_bell() {
504 assert_eq!(char_width('\x07'), 0);
506 }
507
508 #[test]
509 fn char_width_combining_accent() {
510 assert_eq!(char_width('\u{0301}'), 0);
512 }
513
514 #[test]
515 fn char_width_zwj() {
516 assert_eq!(char_width('\u{200D}'), 0);
518 }
519
520 #[test]
521 fn char_width_zwnbsp() {
522 assert_eq!(char_width('\u{FEFF}'), 0);
524 }
525
526 #[test]
527 fn char_width_soft_hyphen() {
528 assert_eq!(char_width('\u{00AD}'), 0);
530 }
531
532 #[test]
533 fn char_width_wide_east_asian() {
534 assert_eq!(char_width('⚡'), 2);
536 }
537
538 #[test]
539 fn char_width_cjk_ideograph() {
540 assert_eq!(char_width('中'), 2);
542 }
543
544 #[test]
545 fn char_width_variation_selector() {
546 assert_eq!(char_width('\u{FE0F}'), 0);
548 }
549
550 #[test]
553 fn display_width_ascii() {
554 assert_eq!(display_width("hello"), 5);
555 }
556
557 #[test]
558 fn display_width_empty() {
559 assert_eq!(display_width(""), 0);
560 }
561
562 #[test]
563 fn display_width_cjk_chars() {
564 assert_eq!(display_width("中文"), 4);
566 }
567
568 #[test]
569 fn display_width_mixed_ascii_cjk() {
570 assert_eq!(display_width("a中b"), 4);
572 }
573
574 #[test]
575 fn display_width_combining_chars() {
576 assert_eq!(display_width("e\u{0301}"), 1);
578 }
579
580 #[test]
581 fn display_width_ascii_with_control_codes() {
582 assert_eq!(display_width("a\tb"), 3);
585 }
586
587 #[test]
590 fn grapheme_width_ascii_char() {
591 assert_eq!(grapheme_width("A"), 1);
592 }
593
594 #[test]
595 fn grapheme_width_cjk_ideograph() {
596 assert_eq!(grapheme_width("中"), 2);
597 }
598
599 #[test]
600 fn grapheme_width_combining_sequence() {
601 assert_eq!(grapheme_width("e\u{0301}"), 1);
603 }
604
605 #[test]
606 fn grapheme_width_zwj_cluster() {
607 assert_eq!(grapheme_width("\u{200D}"), 0);
609 }
610 }
611}