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