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 generic_diff;
30pub mod generic_repr;
31pub mod geometry;
32pub mod gesture;
33pub mod glyph_policy;
34pub mod hover_stabilizer;
35pub mod inline_mode;
36pub mod input_parser;
37pub mod key_sequence;
38pub mod keybinding;
39pub mod logging;
40pub mod mode_typestate;
41pub mod mux_passthrough;
42pub mod read_optimized;
43pub mod s3_fifo;
44pub mod semantic_event;
45pub mod terminal_capabilities;
46#[cfg(all(not(target_arch = "wasm32"), feature = "crossterm"))]
47pub mod terminal_session;
48#[cfg(all(not(target_arch = "wasm32"), feature = "crossterm"))]
49pub use terminal_session::with_panic_cleanup_suppressed;
50#[cfg(not(all(not(target_arch = "wasm32"), feature = "crossterm")))]
51#[inline]
52pub fn with_panic_cleanup_suppressed<F, R>(f: F) -> R
53where
54 F: FnOnce() -> R,
55{
56 f()
57}
58
59#[cfg(not(all(not(target_arch = "wasm32"), feature = "crossterm")))]
66pub mod terminal_session {
67 #[derive(Debug, Default, Clone, Copy)]
69 pub struct TerminalOutputGuard;
70
71 #[inline]
74 #[must_use]
75 pub fn terminal_output_lock() -> TerminalOutputGuard {
76 TerminalOutputGuard
77 }
78}
79
80pub mod shutdown_signal {
81 use std::sync::{
88 Mutex, OnceLock,
89 atomic::{AtomicI32, Ordering},
90 };
91
92 static PENDING_TERMINATION_SIGNAL: AtomicI32 = AtomicI32::new(0);
93
94 pub fn record_pending_termination_signal(signal: i32) {
99 let _ = PENDING_TERMINATION_SIGNAL.compare_exchange(
100 0,
101 signal,
102 Ordering::SeqCst,
103 Ordering::SeqCst,
104 );
105 }
106
107 #[must_use]
109 pub fn pending_termination_signal() -> Option<i32> {
110 match PENDING_TERMINATION_SIGNAL.load(Ordering::SeqCst) {
111 0 => None,
112 signal => Some(signal),
113 }
114 }
115
116 pub fn clear_pending_termination_signal() {
118 PENDING_TERMINATION_SIGNAL.store(0, Ordering::SeqCst);
119 }
120
121 #[doc(hidden)]
128 pub fn with_test_signal_serialization<R>(f: impl FnOnce() -> R) -> R {
129 static SIGNAL_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
130
131 let _guard = SIGNAL_TEST_LOCK
132 .get_or_init(|| Mutex::new(()))
133 .lock()
134 .expect("shutdown signal test lock poisoned");
135 clear_pending_termination_signal();
136 let result = f();
137 clear_pending_termination_signal();
138 result
139 }
140}
141
142#[cfg(feature = "caps-probe")]
143pub mod caps_probe;
144
145#[cfg(feature = "tracing")]
147pub use logging::{
148 debug, debug_span, error, error_span, info, info_span, trace, trace_span, warn, warn_span,
149};
150
151pub mod text_width {
152 use std::sync::OnceLock;
183
184 use unicode_display_width::width as unicode_display_width;
185 use unicode_segmentation::UnicodeSegmentation;
186 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
187
188 #[inline]
189 fn env_flag(value: &str) -> bool {
190 matches!(
191 value.trim().to_ascii_lowercase().as_str(),
192 "1" | "true" | "yes" | "on"
193 )
194 }
195
196 #[inline]
197 fn is_cjk_locale(locale: &str) -> bool {
198 let lower = locale.trim().to_ascii_lowercase();
199 lower.starts_with("ja") || lower.starts_with("zh") || lower.starts_with("ko")
200 }
201
202 #[inline]
203 fn cjk_width_from_env_impl<F>(get_env: F) -> bool
204 where
205 F: Fn(&str) -> Option<String>,
206 {
207 if let Some(value) = get_env("FTUI_GLYPH_DOUBLE_WIDTH") {
208 return env_flag(&value);
209 }
210 if let Some(value) = get_env("FTUI_TEXT_CJK_WIDTH").or_else(|| get_env("FTUI_CJK_WIDTH")) {
211 return env_flag(&value);
212 }
213 if let Some(locale) = get_env("LC_CTYPE").or_else(|| get_env("LANG")) {
214 return is_cjk_locale(&locale);
215 }
216 false
217 }
218
219 #[inline]
220 fn use_cjk_width() -> bool {
221 static CJK_WIDTH: OnceLock<bool> = OnceLock::new();
222 *CJK_WIDTH.get_or_init(|| cjk_width_from_env_impl(|key| std::env::var(key).ok()))
223 }
224
225 #[inline]
232 fn trust_vs16_width() -> bool {
233 static TRUST: OnceLock<bool> = OnceLock::new();
234 *TRUST.get_or_init(|| {
235 std::env::var("FTUI_EMOJI_VS16_WIDTH")
236 .map(|v| v.eq_ignore_ascii_case("unicode") || v == "2")
237 .unwrap_or(false)
238 })
239 }
240
241 #[inline]
243 pub fn vs16_trust_from_env<F>(get_env: F) -> bool
244 where
245 F: Fn(&str) -> Option<String>,
246 {
247 get_env("FTUI_EMOJI_VS16_WIDTH")
248 .map(|v| v.eq_ignore_ascii_case("unicode") || v == "2")
249 .unwrap_or(false)
250 }
251
252 #[inline]
254 pub fn vs16_width_trusted() -> bool {
255 trust_vs16_width()
256 }
257
258 #[inline]
261 fn strip_vs16(grapheme: &str) -> Option<String> {
262 if grapheme.contains('\u{FE0F}') {
263 Some(grapheme.chars().filter(|&c| c != '\u{FE0F}').collect())
264 } else {
265 None
266 }
267 }
268
269 #[inline]
271 pub fn cjk_width_from_env<F>(get_env: F) -> bool
272 where
273 F: Fn(&str) -> Option<String>,
274 {
275 cjk_width_from_env_impl(get_env)
276 }
277
278 #[inline]
280 pub fn cjk_width_enabled() -> bool {
281 use_cjk_width()
282 }
283
284 #[inline]
285 fn ascii_display_width(text: &str) -> usize {
286 let mut width = 0;
287 for b in text.bytes() {
288 match b {
289 b'\t' | b'\n' | b'\r' => width += 1,
290 0x20..=0x7E => width += 1,
291 _ => {}
292 }
293 }
294 width
295 }
296
297 #[inline]
299 #[must_use]
300 pub fn ascii_width(text: &str) -> Option<usize> {
301 if text.bytes().all(|b| (0x20..=0x7E).contains(&b)) {
302 Some(text.len())
303 } else {
304 None
305 }
306 }
307
308 #[inline]
309 fn is_zero_width_codepoint(c: char) -> bool {
310 let u = c as u32;
311 matches!(u, 0x0000..=0x001F | 0x007F..=0x009F)
312 || matches!(u, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF)
313 || matches!(u, 0xFE20..=0xFE2F)
314 || matches!(u, 0xFE00..=0xFE0F | 0xE0100..=0xE01EF)
315 || matches!(
316 u,
317 0x00AD
318 | 0x034F
319 | 0x180E
320 | 0x200B
321 | 0x200C
322 | 0x200D
323 | 0x200E
324 | 0x200F
325 | 0x2060
326 | 0xFEFF
327 )
328 || matches!(u, 0x202A..=0x202E | 0x2066..=0x2069 | 0x206A..=0x206F)
329 }
330
331 const WIDTH_CACHE_CAPACITY: usize = 4096;
337
338 const WIDTH_CACHE_MAX_GRAPHEME_BYTES: usize = 128;
341
342 struct CachedGraphemeWidth {
343 grapheme: Box<str>,
344 width: usize,
345 }
346
347 struct GraphemeWidthCache {
348 entries: crate::s3_fifo::S3Fifo<u64, CachedGraphemeWidth>,
349 hits: u64,
350 misses: u64,
351 }
352
353 impl GraphemeWidthCache {
354 fn new(capacity: usize) -> Self {
355 Self {
356 entries: crate::s3_fifo::S3Fifo::new(capacity),
357 hits: 0,
358 misses: 0,
359 }
360 }
361
362 fn width(&mut self, grapheme: &str, key: u64) -> usize {
363 if let Some(entry) = self.entries.get(&key)
364 && entry.grapheme.as_ref() == grapheme
365 {
366 self.hits += 1;
367 return entry.width;
368 }
369
370 self.misses += 1;
371 let width = grapheme_width_uncached(grapheme);
372 self.entries.insert(
373 key,
374 CachedGraphemeWidth {
375 grapheme: grapheme.into(),
376 width,
377 },
378 );
379 width
380 }
381
382 fn stats(&self) -> crate::s3_fifo::S3FifoStats {
383 let mut stats = self.entries.stats();
384 stats.hits = self.hits;
387 stats.misses = self.misses;
388 stats
389 }
390
391 fn clear(&mut self) {
392 self.entries.clear();
393 self.hits = 0;
394 self.misses = 0;
395 }
396 }
397
398 #[inline]
401 fn use_width_cache() -> bool {
402 static ENABLED: OnceLock<bool> = OnceLock::new();
403 *ENABLED.get_or_init(|| {
404 std::env::var("FTUI_WIDTH_CACHE")
405 .map(|value| {
406 !matches!(
407 value.trim().to_ascii_lowercase().as_str(),
408 "0" | "false" | "off" | "no"
409 )
410 })
411 .unwrap_or(true)
412 })
413 }
414
415 thread_local! {
416 static WIDTH_CACHE: std::cell::RefCell<GraphemeWidthCache> =
424 std::cell::RefCell::new(GraphemeWidthCache::new(WIDTH_CACHE_CAPACITY));
425 }
426
427 #[inline]
428 fn grapheme_cache_key(grapheme: &str) -> u64 {
429 use std::hash::{BuildHasher, Hasher};
430 let mut hasher =
431 ahash::RandomState::with_seeds(0x5749_4454, 0x485f_4341, 0x4348_455f, 0x4b45_5921)
432 .build_hasher();
433 hasher.write(grapheme.as_bytes());
434 hasher.finish()
435 }
436
437 #[must_use]
444 pub fn width_cache_stats() -> Option<crate::s3_fifo::S3FifoStats> {
445 if !use_width_cache() {
446 return None;
447 }
448 Some(WIDTH_CACHE.with(|cache| cache.borrow().stats()))
449 }
450
451 pub fn clear_width_cache() {
453 WIDTH_CACHE.with(|cache| cache.borrow_mut().clear());
454 }
455
456 #[inline]
462 #[must_use]
463 pub fn grapheme_width(grapheme: &str) -> usize {
464 if grapheme.is_ascii() {
465 return ascii_display_width(grapheme);
466 }
467 if !use_width_cache() || grapheme.len() > WIDTH_CACHE_MAX_GRAPHEME_BYTES {
468 return grapheme_width_uncached(grapheme);
469 }
470 let key = grapheme_cache_key(grapheme);
471 cached_grapheme_width(grapheme, key)
472 }
473
474 #[inline]
475 fn cached_grapheme_width(grapheme: &str, key: u64) -> usize {
476 WIDTH_CACHE.with(|cache| cache.borrow_mut().width(grapheme, key))
477 }
478
479 #[inline]
482 #[must_use]
483 pub fn grapheme_width_uncached(grapheme: &str) -> usize {
484 if grapheme.is_ascii() {
485 return ascii_display_width(grapheme);
486 }
487 if grapheme.chars().all(is_zero_width_codepoint) {
488 return 0;
489 }
490 if use_cjk_width() {
491 return grapheme.width_cjk();
492 }
493 if !trust_vs16_width()
497 && let Some(stripped) = strip_vs16(grapheme)
498 {
499 if stripped.is_empty() {
500 return 0;
501 }
502 return unicode_display_width(&stripped) as usize;
503 }
504 unicode_display_width(grapheme) as usize
505 }
506
507 #[inline]
509 #[must_use]
510 pub fn char_width(ch: char) -> usize {
511 if ch.is_ascii() {
512 return match ch {
513 '\t' | '\n' | '\r' => 1,
514 ' '..='~' => 1,
515 _ => 0,
516 };
517 }
518 if is_zero_width_codepoint(ch) {
519 return 0;
520 }
521 if use_cjk_width() {
522 ch.width_cjk().unwrap_or(0)
523 } else {
524 ch.width().unwrap_or(0)
525 }
526 }
527
528 #[inline]
530 #[must_use]
531 pub fn display_width(text: &str) -> usize {
532 if let Some(width) = ascii_width(text) {
533 return width;
534 }
535 if text.is_ascii() {
536 return ascii_display_width(text);
537 }
538 let cjk_width = use_cjk_width();
539 if !text.chars().any(is_zero_width_codepoint) {
540 if cjk_width {
541 return text.width_cjk();
542 }
543 return unicode_display_width(text) as usize;
544 }
545 text.graphemes(true).map(grapheme_width).sum()
546 }
547
548 #[cfg(test)]
549 mod tests {
550 use super::*;
551
552 const CORPUS: &[&str] = &[
555 "é",
556 "日",
557 "本",
558 "語",
559 "한",
560 "😀",
561 "👨👩👧👦",
562 "🇯🇵",
563 "\u{1F3F4}\u{E0067}",
564 "a\u{0301}",
565 "\u{200B}",
566 "\u{FE0F}",
567 "☂\u{FE0F}",
568 "ア",
569 "Ω",
570 "→",
571 "…",
572 ];
573
574 #[test]
575 fn width_cache_rejects_hash_collisions() {
576 clear_width_cache();
577 for grapheme in ["\u{200B}", "日", "é", "👨👩👧👦", "\u{200B}"] {
580 for _ in 0..2 {
581 assert_eq!(
582 cached_grapheme_width(grapheme, 0),
583 grapheme_width_uncached(grapheme),
584 "collision changed the width of {grapheme:?}"
585 );
586 }
587 }
588 let stats = WIDTH_CACHE.with(|cache| cache.borrow().stats());
589 assert_eq!(stats.hits, 5, "only exact repeats are cache hits");
590 assert_eq!(stats.misses, 5, "collisions are cache misses");
591 assert_eq!(stats.small_size + stats.main_size, 1);
592 }
593
594 #[test]
595 fn width_cache_collision_and_eviction_order_preserves_corpus() {
596 let mut cache = GraphemeWidthCache::new(4);
597 for round in 0..32 {
598 for offset in 0..CORPUS.len() {
599 let index = (round + offset) % CORPUS.len();
600 let grapheme = CORPUS[index];
601 let key = (index % 6) as u64;
604 let expected = grapheme_width_uncached(grapheme);
605 assert_eq!(cache.width(grapheme, key), expected);
606 assert_eq!(cache.width(grapheme, key), expected);
607 let stats = cache.stats();
608 assert!(stats.small_size + stats.main_size <= 4);
609 assert!(stats.ghost_size <= 1);
610 }
611 }
612 }
613
614 #[test]
615 fn width_cache_bypasses_unbounded_combining_clusters() {
616 clear_width_cache();
617 let grapheme = format!("a{}", "\u{0301}".repeat(WIDTH_CACHE_MAX_GRAPHEME_BYTES));
618 assert_eq!(grapheme.graphemes(true).count(), 1);
619 assert!(grapheme.len() > WIDTH_CACHE_MAX_GRAPHEME_BYTES);
620 let before = WIDTH_CACHE.with(|cache| cache.borrow().stats());
621 for _ in 0..3 {
622 assert_eq!(
623 grapheme_width(&grapheme),
624 grapheme_width_uncached(&grapheme)
625 );
626 }
627 assert_eq!(WIDTH_CACHE.with(|cache| cache.borrow().stats()), before);
628 }
629
630 #[test]
631 fn width_cache_retained_key_boundary() {
632 clear_width_cache();
633 let grapheme = format!("é{}", "\u{0301}".repeat(63));
634 assert_eq!(grapheme.len(), WIDTH_CACHE_MAX_GRAPHEME_BYTES);
635 assert_eq!(grapheme.graphemes(true).count(), 1);
636 for _ in 0..2 {
637 assert_eq!(
638 grapheme_width(&grapheme),
639 grapheme_width_uncached(&grapheme)
640 );
641 }
642 if let Some(stats) = width_cache_stats() {
643 assert_eq!(stats.hits, 1);
644 assert_eq!(stats.misses, 1);
645 }
646 }
647
648 #[test]
649 fn width_cache_is_thread_local_and_clear_resets_identity() {
650 clear_width_cache();
651 assert_eq!(
652 cached_grapheme_width("日", 0),
653 grapheme_width_uncached("日")
654 );
655 let parent_stats = WIDTH_CACHE.with(|cache| cache.borrow().stats());
656 std::thread::spawn(|| {
657 let initial = WIDTH_CACHE.with(|cache| cache.borrow().stats());
658 assert_eq!(initial.hits + initial.misses, 0);
659 assert_eq!(cached_grapheme_width("\u{200B}", 0), 0);
660 clear_width_cache();
661 let cleared = WIDTH_CACHE.with(|cache| cache.borrow().stats());
662 assert_eq!(cleared.hits + cleared.misses, 0);
663 assert_eq!(cleared.small_size + cleared.main_size, 0);
664 })
665 .join()
666 .expect("thread-local cache checks");
667 assert_eq!(
668 WIDTH_CACHE.with(|cache| cache.borrow().stats()),
669 parent_stats
670 );
671 assert_eq!(
672 cached_grapheme_width("日", 0),
673 grapheme_width_uncached("日")
674 );
675 }
676
677 #[test]
680 fn width_cache_is_transparent_and_hits_on_repeat() {
681 clear_width_cache();
682 let before = width_cache_stats();
683 for grapheme in CORPUS {
684 assert_eq!(
685 grapheme_width(grapheme),
686 grapheme_width_uncached(grapheme),
687 "cached width differs for {grapheme:?}"
688 );
689 }
690 for grapheme in CORPUS {
691 assert_eq!(grapheme_width(grapheme), grapheme_width_uncached(grapheme));
692 }
693 if let (Some(before), Some(after)) = (before, width_cache_stats()) {
694 assert!(
695 after.hits >= before.hits + CORPUS.len() as u64,
696 "second pass must hit the cache: before={before:?} after={after:?}"
697 );
698 assert!(after.small_size + after.main_size >= 1);
699 }
700 }
701
702 #[test]
704 fn width_cache_skips_ascii() {
705 clear_width_cache();
706 let before = width_cache_stats();
707 for text in ["a", "hello", " ", "~", "\t"] {
708 let _ = grapheme_width(text);
709 }
710 let after = width_cache_stats();
711 assert_eq!(before.map(|s| s.hits), after.map(|s| s.hits));
712 assert_eq!(before.map(|s| s.misses), after.map(|s| s.misses));
713 }
714
715 #[test]
718 fn display_width_matches_uncached_sum_on_mixed_text() {
719 let samples = [
720 "hello 世界 👋🏽 done",
721 "table │ 日本語 │ ok",
722 "🇯🇵🇺🇸 flags and ☂\u{FE0F} rain",
723 "combining a\u{0301}e\u{0301} marks",
724 ];
725 for text in samples {
726 let expected: usize = text.graphemes(true).map(grapheme_width_uncached).sum();
727 assert_eq!(display_width(text), expected, "{text:?}");
728 assert_eq!(display_width(text), expected, "second pass {text:?}");
729 }
730 }
731
732 #[test]
735 fn cjk_width_env_explicit_true() {
736 let get = |key: &str| match key {
737 "FTUI_GLYPH_DOUBLE_WIDTH" => Some("1".into()),
738 _ => None,
739 };
740 assert!(cjk_width_from_env(get));
741 }
742
743 #[test]
744 fn cjk_width_env_explicit_false() {
745 let get = |key: &str| match key {
746 "FTUI_GLYPH_DOUBLE_WIDTH" => Some("0".into()),
747 _ => None,
748 };
749 assert!(!cjk_width_from_env(get));
750 }
751
752 #[test]
753 fn cjk_width_env_text_cjk_key() {
754 let get = |key: &str| match key {
755 "FTUI_TEXT_CJK_WIDTH" => Some("true".into()),
756 _ => None,
757 };
758 assert!(cjk_width_from_env(get));
759 }
760
761 #[test]
762 fn cjk_width_env_fallback_key() {
763 let get = |key: &str| match key {
764 "FTUI_CJK_WIDTH" => Some("yes".into()),
765 _ => None,
766 };
767 assert!(cjk_width_from_env(get));
768 }
769
770 #[test]
771 fn cjk_width_env_japanese_locale() {
772 let get = |key: &str| match key {
773 "LC_CTYPE" => Some("ja_JP.UTF-8".into()),
774 _ => None,
775 };
776 assert!(cjk_width_from_env(get));
777 }
778
779 #[test]
780 fn cjk_width_env_chinese_locale() {
781 let get = |key: &str| match key {
782 "LANG" => Some("zh_CN.UTF-8".into()),
783 _ => None,
784 };
785 assert!(cjk_width_from_env(get));
786 }
787
788 #[test]
789 fn cjk_width_env_korean_locale() {
790 let get = |key: &str| match key {
791 "LC_CTYPE" => Some("ko_KR.UTF-8".into()),
792 _ => None,
793 };
794 assert!(cjk_width_from_env(get));
795 }
796
797 #[test]
798 fn cjk_width_env_english_locale_returns_false() {
799 let get = |key: &str| match key {
800 "LANG" => Some("en_US.UTF-8".into()),
801 _ => None,
802 };
803 assert!(!cjk_width_from_env(get));
804 }
805
806 #[test]
807 fn cjk_width_env_no_vars_returns_false() {
808 let get = |_: &str| -> Option<String> { None };
809 assert!(!cjk_width_from_env(get));
810 }
811
812 #[test]
813 fn cjk_width_env_glyph_overrides_locale() {
814 let get = |key: &str| match key {
816 "FTUI_GLYPH_DOUBLE_WIDTH" => Some("0".into()),
817 "LANG" => Some("ja_JP.UTF-8".into()),
818 _ => None,
819 };
820 assert!(!cjk_width_from_env(get));
821 }
822
823 #[test]
824 fn cjk_width_env_on_is_true() {
825 let get = |key: &str| match key {
826 "FTUI_GLYPH_DOUBLE_WIDTH" => Some("on".into()),
827 _ => None,
828 };
829 assert!(cjk_width_from_env(get));
830 }
831
832 #[test]
833 fn cjk_width_env_case_insensitive() {
834 let get = |key: &str| match key {
835 "FTUI_CJK_WIDTH" => Some("TRUE".into()),
836 _ => None,
837 };
838 assert!(cjk_width_from_env(get));
839 }
840
841 #[test]
844 fn vs16_trust_unicode_string() {
845 let get = |key: &str| match key {
846 "FTUI_EMOJI_VS16_WIDTH" => Some("unicode".into()),
847 _ => None,
848 };
849 assert!(vs16_trust_from_env(get));
850 }
851
852 #[test]
853 fn vs16_trust_value_2() {
854 let get = |key: &str| match key {
855 "FTUI_EMOJI_VS16_WIDTH" => Some("2".into()),
856 _ => None,
857 };
858 assert!(vs16_trust_from_env(get));
859 }
860
861 #[test]
862 fn vs16_trust_not_set() {
863 let get = |_: &str| -> Option<String> { None };
864 assert!(!vs16_trust_from_env(get));
865 }
866
867 #[test]
868 fn vs16_trust_other_value() {
869 let get = |key: &str| match key {
870 "FTUI_EMOJI_VS16_WIDTH" => Some("1".into()),
871 _ => None,
872 };
873 assert!(!vs16_trust_from_env(get));
874 }
875
876 #[test]
877 fn vs16_trust_case_insensitive() {
878 let get = |key: &str| match key {
879 "FTUI_EMOJI_VS16_WIDTH" => Some("UNICODE".into()),
880 _ => None,
881 };
882 assert!(vs16_trust_from_env(get));
883 }
884
885 #[test]
888 fn ascii_width_pure_ascii() {
889 assert_eq!(ascii_width("hello"), Some(5));
890 }
891
892 #[test]
893 fn ascii_width_empty() {
894 assert_eq!(ascii_width(""), Some(0));
895 }
896
897 #[test]
898 fn ascii_width_with_space() {
899 assert_eq!(ascii_width("hello world"), Some(11));
900 }
901
902 #[test]
903 fn ascii_width_non_ascii_returns_none() {
904 assert_eq!(ascii_width("héllo"), None);
905 }
906
907 #[test]
908 fn ascii_width_with_tab_returns_none() {
909 assert_eq!(ascii_width("hello\tworld"), None);
911 }
912
913 #[test]
914 fn ascii_width_with_newline_returns_none() {
915 assert_eq!(ascii_width("hello\n"), None);
916 }
917
918 #[test]
919 fn ascii_width_control_char_returns_none() {
920 assert_eq!(ascii_width("\x01"), None);
921 }
922
923 #[test]
926 fn char_width_ascii_letter() {
927 assert_eq!(char_width('A'), 1);
928 }
929
930 #[test]
931 fn char_width_space() {
932 assert_eq!(char_width(' '), 1);
933 }
934
935 #[test]
936 fn char_width_tab() {
937 assert_eq!(char_width('\t'), 1);
938 }
939
940 #[test]
941 fn char_width_newline() {
942 assert_eq!(char_width('\n'), 1);
943 }
944
945 #[test]
946 fn char_width_nul() {
947 assert_eq!(char_width('\0'), 0);
949 }
950
951 #[test]
952 fn char_width_bell() {
953 assert_eq!(char_width('\x07'), 0);
955 }
956
957 #[test]
958 fn char_width_combining_accent() {
959 assert_eq!(char_width('\u{0301}'), 0);
961 }
962
963 #[test]
964 fn char_width_zwj() {
965 assert_eq!(char_width('\u{200D}'), 0);
967 }
968
969 #[test]
970 fn char_width_zwnbsp() {
971 assert_eq!(char_width('\u{FEFF}'), 0);
973 }
974
975 #[test]
976 fn char_width_soft_hyphen() {
977 assert_eq!(char_width('\u{00AD}'), 0);
979 }
980
981 #[test]
982 fn char_width_wide_east_asian() {
983 assert_eq!(char_width('⚡'), 2);
985 }
986
987 #[test]
988 fn char_width_cjk_ideograph() {
989 assert_eq!(char_width('中'), 2);
991 }
992
993 #[test]
994 fn char_width_variation_selector() {
995 assert_eq!(char_width('\u{FE0F}'), 0);
997 }
998
999 #[test]
1002 fn display_width_ascii() {
1003 assert_eq!(display_width("hello"), 5);
1004 }
1005
1006 #[test]
1007 fn display_width_empty() {
1008 assert_eq!(display_width(""), 0);
1009 }
1010
1011 #[test]
1012 fn display_width_cjk_chars() {
1013 assert_eq!(display_width("中文"), 4);
1015 }
1016
1017 #[test]
1018 fn display_width_mixed_ascii_cjk() {
1019 assert_eq!(display_width("a中b"), 4);
1021 }
1022
1023 #[test]
1024 fn display_width_combining_chars() {
1025 assert_eq!(display_width("e\u{0301}"), 1);
1027 }
1028
1029 #[test]
1030 fn display_width_ascii_with_control_codes() {
1031 assert_eq!(display_width("a\tb"), 3);
1034 }
1035
1036 #[test]
1039 fn grapheme_width_ascii_char() {
1040 assert_eq!(grapheme_width("A"), 1);
1041 }
1042
1043 #[test]
1044 fn grapheme_width_cjk_ideograph() {
1045 assert_eq!(grapheme_width("中"), 2);
1046 }
1047
1048 #[test]
1049 fn grapheme_width_combining_sequence() {
1050 assert_eq!(grapheme_width("e\u{0301}"), 1);
1052 }
1053
1054 #[test]
1055 fn grapheme_width_zwj_cluster() {
1056 assert_eq!(grapheme_width("\u{200D}"), 0);
1058 }
1059 }
1060}