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 use event_coalescer::EventCoalescer;
30pub mod generic_diff;
31pub mod generic_repr;
32pub mod geometry;
33pub mod gesture;
34pub mod glyph_policy;
35pub mod hover_stabilizer;
36pub mod inline_mode;
37pub mod input_parser;
38pub mod key_sequence;
39pub mod keybinding;
40pub mod logging;
41pub mod mode_typestate;
42pub mod mux_passthrough;
43pub mod osc52;
44pub mod read_optimized;
45pub mod s3_fifo;
46pub mod semantic_event;
47pub mod session_teardown;
48pub mod terminal_capabilities;
49#[cfg(all(not(target_arch = "wasm32"), feature = "crossterm"))]
50pub mod terminal_session;
51pub use session_teardown::with_panic_cleanup_suppressed;
52
53#[cfg(not(all(not(target_arch = "wasm32"), feature = "crossterm")))]
60pub mod terminal_session {
61 #[derive(Debug, Default, Clone, Copy)]
63 pub struct TerminalOutputGuard;
64
65 #[inline]
68 #[must_use]
69 pub fn terminal_output_lock() -> TerminalOutputGuard {
70 TerminalOutputGuard
71 }
72}
73
74pub mod shutdown_signal {
75 use std::sync::{
82 Mutex, OnceLock,
83 atomic::{AtomicI32, Ordering},
84 };
85
86 static PENDING_TERMINATION_SIGNAL: AtomicI32 = AtomicI32::new(0);
87
88 pub fn record_pending_termination_signal(signal: i32) {
93 let _ = PENDING_TERMINATION_SIGNAL.compare_exchange(
94 0,
95 signal,
96 Ordering::SeqCst,
97 Ordering::SeqCst,
98 );
99 }
100
101 #[must_use]
103 pub fn pending_termination_signal() -> Option<i32> {
104 match PENDING_TERMINATION_SIGNAL.load(Ordering::SeqCst) {
105 0 => None,
106 signal => Some(signal),
107 }
108 }
109
110 pub fn clear_pending_termination_signal() {
112 PENDING_TERMINATION_SIGNAL.store(0, Ordering::SeqCst);
113 }
114
115 #[doc(hidden)]
122 pub fn with_test_signal_serialization<R>(f: impl FnOnce() -> R) -> R {
123 static SIGNAL_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
124
125 let _guard = SIGNAL_TEST_LOCK
131 .get_or_init(|| Mutex::new(()))
132 .lock()
133 .unwrap_or_else(|poison| poison.into_inner());
134
135 struct ClearOnDrop;
140 impl Drop for ClearOnDrop {
141 fn drop(&mut self) {
142 clear_pending_termination_signal();
143 }
144 }
145
146 clear_pending_termination_signal();
147 let _clear_on_exit = ClearOnDrop;
148 f()
149 }
150
151 #[cfg(test)]
152 mod tests {
153 use super::*;
154
155 #[test]
156 fn serialization_clears_and_stays_usable_when_the_body_panics() {
157 let caught = std::panic::catch_unwind(|| {
158 with_test_signal_serialization(|| {
159 record_pending_termination_signal(15);
162 panic!("a signal test failing inside the critical section");
163 })
164 });
165 assert!(caught.is_err(), "the panic should propagate to the caller");
166 assert!(
167 pending_termination_signal().is_none(),
168 "a panicking body must not leave a pending signal behind"
169 );
170
171 with_test_signal_serialization(|| {
173 assert!(pending_termination_signal().is_none());
174 });
175 }
176 }
177}
178
179pub mod job_control {
180 #[cfg(unix)]
194 use std::sync::{
195 Arc, OnceLock,
196 atomic::{AtomicBool, AtomicUsize, Ordering},
197 };
198
199 #[cfg(unix)]
202 struct Handlers {
203 pending: Arc<AtomicUsize>,
204 unclaimed: Arc<AtomicBool>,
205 claims: AtomicUsize,
206 }
207
208 #[cfg(unix)]
209 fn handlers() -> Option<&'static Handlers> {
210 use signal_hook::consts::signal::{SIGTSTP, SIGTTIN, SIGTTOU};
211 static HANDLERS: OnceLock<Option<Handlers>> = OnceLock::new();
212 HANDLERS
213 .get_or_init(|| {
214 let pending = Arc::new(AtomicUsize::new(0));
215 let unclaimed = Arc::new(AtomicBool::new(true));
216 for signal in [SIGTSTP, SIGTTIN, SIGTTOU] {
217 signal_hook::flag::register_conditional_default(signal, Arc::clone(&unclaimed))
220 .ok()?;
221 let value = usize::try_from(signal).ok()?;
222 signal_hook::flag::register_usize(signal, Arc::clone(&pending), value).ok()?;
223 }
224 Some(Handlers {
225 pending,
226 unclaimed,
227 claims: AtomicUsize::new(0),
228 })
229 })
230 .as_ref()
231 }
232
233 #[cfg(unix)]
237 pub const KEYBOARD_STOP_SIGNAL: i32 = signal_hook::consts::signal::SIGTSTP;
238 #[cfg(not(unix))]
240 pub const KEYBOARD_STOP_SIGNAL: i32 = 20;
241
242 #[derive(Debug)]
244 pub struct JobControlClaim {
245 _private: (),
246 }
247
248 impl JobControlClaim {
249 #[must_use]
254 pub fn acquire() -> Option<Self> {
255 #[cfg(unix)]
256 {
257 let handlers = handlers()?;
258 if handlers.claims.fetch_add(1, Ordering::SeqCst) == 0 {
259 handlers.pending.store(0, Ordering::SeqCst);
263 handlers.unclaimed.store(false, Ordering::SeqCst);
264 }
265 Some(Self { _private: () })
266 }
267 #[cfg(not(unix))]
268 {
269 None
270 }
271 }
272
273 #[must_use]
275 pub fn take_pending(&self) -> Option<i32> {
276 #[cfg(unix)]
277 {
278 let signal = handlers()?.pending.swap(0, Ordering::SeqCst);
279 i32::try_from(signal).ok().filter(|&signal| signal != 0)
280 }
281 #[cfg(not(unix))]
282 {
283 None
284 }
285 }
286 }
287
288 impl Drop for JobControlClaim {
289 fn drop(&mut self) {
290 #[cfg(unix)]
291 if let Some(handlers) = handlers()
292 && handlers.claims.fetch_sub(1, Ordering::SeqCst) == 1
293 {
294 handlers.unclaimed.store(true, Ordering::SeqCst);
295 }
296 }
297 }
298
299 pub fn stop_process(signal: i32) -> std::io::Result<()> {
311 #[cfg(unix)]
312 {
313 signal_hook::low_level::emulate_default_handler(signal)
314 }
315 #[cfg(not(unix))]
316 {
317 let _ = signal;
318 Err(std::io::Error::new(
319 std::io::ErrorKind::Unsupported,
320 "job control is Unix-only",
321 ))
322 }
323 }
324
325 #[cfg(all(test, unix))]
326 mod tests {
327 use super::*;
328 use std::sync::{Mutex, MutexGuard};
329
330 fn serial() -> MutexGuard<'static, ()> {
332 static LOCK: Mutex<()> = Mutex::new(());
333 LOCK.lock().unwrap_or_else(|poison| poison.into_inner())
334 }
335
336 #[test]
337 fn claims_are_counted_and_the_default_returns_with_the_last() {
338 let _serial = serial();
339 let handlers = handlers().expect("handlers install on unix");
342 let before = handlers.claims.load(Ordering::SeqCst);
343 let first = JobControlClaim::acquire().expect("claim");
344 let second = JobControlClaim::acquire().expect("claim");
345 assert!(!handlers.unclaimed.load(Ordering::SeqCst));
346 drop(first);
347 assert!(
348 !handlers.unclaimed.load(Ordering::SeqCst),
349 "one claim still held"
350 );
351 drop(second);
352 if before == 0 {
353 assert!(handlers.unclaimed.load(Ordering::SeqCst));
354 }
355 }
356
357 #[test]
358 fn pending_signal_is_taken_once() {
359 let _serial = serial();
360 let claim = JobControlClaim::acquire().expect("claim");
361 let handlers = handlers().expect("handlers");
362 handlers.pending.store(20, Ordering::SeqCst);
363 assert_eq!(claim.take_pending(), Some(20));
364 assert_eq!(claim.take_pending(), None);
365 }
366 }
367}
368
369#[cfg(feature = "caps-probe")]
370pub mod caps_probe;
371
372#[cfg(feature = "tracing")]
374pub use logging::{
375 debug, debug_span, error, error_span, info, info_span, trace, trace_span, warn, warn_span,
376};
377
378pub mod text_width {
379 use std::sync::OnceLock;
410
411 use unicode_display_width::width as unicode_display_width;
412 use unicode_segmentation::UnicodeSegmentation;
413 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
414
415 #[inline]
416 fn env_flag(value: &str) -> bool {
417 matches!(
418 value.trim().to_ascii_lowercase().as_str(),
419 "1" | "true" | "yes" | "on"
420 )
421 }
422
423 #[inline]
424 fn is_cjk_locale(locale: &str) -> bool {
425 let lower = locale.trim().to_ascii_lowercase();
426 lower.starts_with("ja") || lower.starts_with("zh") || lower.starts_with("ko")
427 }
428
429 #[inline]
430 fn cjk_width_from_env_impl<F>(get_env: F) -> bool
431 where
432 F: Fn(&str) -> Option<String>,
433 {
434 if let Some(value) = get_env("FTUI_GLYPH_DOUBLE_WIDTH") {
435 return env_flag(&value);
436 }
437 if let Some(value) = get_env("FTUI_TEXT_CJK_WIDTH").or_else(|| get_env("FTUI_CJK_WIDTH")) {
438 return env_flag(&value);
439 }
440 if let Some(locale) = get_env("LC_CTYPE").or_else(|| get_env("LANG")) {
441 return is_cjk_locale(&locale);
442 }
443 false
444 }
445
446 #[inline]
447 fn use_cjk_width() -> bool {
448 static CJK_WIDTH: OnceLock<bool> = OnceLock::new();
449 *CJK_WIDTH.get_or_init(|| cjk_width_from_env_impl(|key| std::env::var(key).ok()))
450 }
451
452 #[inline]
459 fn trust_vs16_width() -> bool {
460 static TRUST: OnceLock<bool> = OnceLock::new();
461 *TRUST.get_or_init(|| {
462 std::env::var("FTUI_EMOJI_VS16_WIDTH")
463 .map(|v| v.eq_ignore_ascii_case("unicode") || v == "2")
464 .unwrap_or(false)
465 })
466 }
467
468 #[inline]
470 pub fn vs16_trust_from_env<F>(get_env: F) -> bool
471 where
472 F: Fn(&str) -> Option<String>,
473 {
474 get_env("FTUI_EMOJI_VS16_WIDTH")
475 .map(|v| v.eq_ignore_ascii_case("unicode") || v == "2")
476 .unwrap_or(false)
477 }
478
479 #[inline]
481 pub fn vs16_width_trusted() -> bool {
482 trust_vs16_width()
483 }
484
485 #[inline]
488 fn strip_vs16(grapheme: &str) -> Option<String> {
489 if grapheme.contains('\u{FE0F}') {
490 Some(grapheme.chars().filter(|&c| c != '\u{FE0F}').collect())
491 } else {
492 None
493 }
494 }
495
496 #[inline]
498 pub fn cjk_width_from_env<F>(get_env: F) -> bool
499 where
500 F: Fn(&str) -> Option<String>,
501 {
502 cjk_width_from_env_impl(get_env)
503 }
504
505 #[inline]
507 pub fn cjk_width_enabled() -> bool {
508 use_cjk_width()
509 }
510
511 #[inline]
512 fn ascii_display_width(text: &str) -> usize {
513 let mut width = 0;
514 for b in text.bytes() {
515 match b {
516 b'\t' | b'\n' | b'\r' => width += 1,
517 0x20..=0x7E => width += 1,
518 _ => {}
519 }
520 }
521 width
522 }
523
524 #[inline]
535 #[must_use]
536 pub fn ascii_width(text: &str) -> Option<usize> {
537 #[cfg(feature = "simd")]
538 {
539 ftui_simd::ascii_width(text.as_bytes())
540 }
541 #[cfg(not(feature = "simd"))]
542 {
543 ascii_width_scalar(text)
544 }
545 }
546
547 #[inline]
550 #[must_use]
551 pub fn ascii_width_scalar(text: &str) -> Option<usize> {
552 if text.bytes().all(|b| (0x20..=0x7E).contains(&b)) {
553 Some(text.len())
554 } else {
555 None
556 }
557 }
558
559 #[inline]
560 fn is_zero_width_codepoint(c: char) -> bool {
561 let u = c as u32;
562 matches!(u, 0x0000..=0x001F | 0x007F..=0x009F)
563 || matches!(u, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF)
564 || matches!(u, 0xFE20..=0xFE2F)
565 || matches!(u, 0xFE00..=0xFE0F | 0xE0100..=0xE01EF)
566 || matches!(
567 u,
568 0x00AD
569 | 0x034F
570 | 0x180E
571 | 0x200B
572 | 0x200C
573 | 0x200D
574 | 0x200E
575 | 0x200F
576 | 0x2060
577 | 0xFEFF
578 )
579 || matches!(u, 0x202A..=0x202E | 0x2066..=0x2069 | 0x206A..=0x206F)
580 }
581
582 const WIDTH_CACHE_CAPACITY: usize = 4096;
588
589 const WIDTH_CACHE_MAX_GRAPHEME_BYTES: usize = 128;
592
593 struct CachedGraphemeWidth {
594 grapheme: Box<str>,
595 width: usize,
596 }
597
598 pub const TARGET_WIDTH_CACHE: &str = "ftui.text.width_cache";
600
601 struct GraphemeWidthCache {
602 entries: crate::s3_fifo::S3Fifo<u64, CachedGraphemeWidth>,
603 hits: u64,
604 misses: u64,
605 }
606
607 impl GraphemeWidthCache {
608 fn new(capacity: usize) -> Self {
609 Self {
610 entries: crate::s3_fifo::S3Fifo::new(capacity),
611 hits: 0,
612 misses: 0,
613 }
614 }
615
616 fn width(&mut self, grapheme: &str, key: u64) -> usize {
617 if let Some(entry) = self.entries.get(&key)
618 && entry.grapheme.as_ref() == grapheme
619 {
620 self.hits += 1;
621 return entry.width;
622 }
623
624 self.misses += 1;
625 #[cfg(feature = "tracing")]
626 tracing::trace!(target: TARGET_WIDTH_CACHE, grapheme, key, "width cache miss");
627 let width = grapheme_width_uncached(grapheme);
628 self.entries.insert(
629 key,
630 CachedGraphemeWidth {
631 grapheme: grapheme.into(),
632 width,
633 },
634 );
635 width
636 }
637
638 fn stats(&self) -> crate::s3_fifo::S3FifoStats {
639 let mut stats = self.entries.stats();
640 stats.hits = self.hits;
643 stats.misses = self.misses;
644 stats
645 }
646
647 fn clear(&mut self) {
648 self.entries.clear();
649 self.hits = 0;
650 self.misses = 0;
651 }
652 }
653
654 #[inline]
657 fn use_width_cache() -> bool {
658 static ENABLED: OnceLock<bool> = OnceLock::new();
659 *ENABLED.get_or_init(|| {
660 std::env::var("FTUI_WIDTH_CACHE")
661 .map(|value| {
662 !matches!(
663 value.trim().to_ascii_lowercase().as_str(),
664 "0" | "false" | "off" | "no"
665 )
666 })
667 .unwrap_or(true)
668 })
669 }
670
671 thread_local! {
672 static WIDTH_CACHE: std::cell::RefCell<GraphemeWidthCache> =
680 std::cell::RefCell::new(GraphemeWidthCache::new(WIDTH_CACHE_CAPACITY));
681 }
682
683 #[inline]
684 fn grapheme_cache_key(grapheme: &str) -> u64 {
685 use std::hash::{BuildHasher, Hasher};
686 let mut hasher =
687 ahash::RandomState::with_seeds(0x5749_4454, 0x485f_4341, 0x4348_455f, 0x4b45_5921)
688 .build_hasher();
689 hasher.write(grapheme.as_bytes());
690 hasher.finish()
691 }
692
693 #[must_use]
700 pub fn width_cache_stats() -> Option<crate::s3_fifo::S3FifoStats> {
701 if !use_width_cache() {
702 return None;
703 }
704 Some(WIDTH_CACHE.with(|cache| cache.borrow().stats()))
705 }
706
707 pub fn clear_width_cache() {
709 WIDTH_CACHE.with(|cache| cache.borrow_mut().clear());
710 }
711
712 #[inline]
718 #[must_use]
719 pub fn grapheme_width(grapheme: &str) -> usize {
720 if grapheme.is_ascii() {
721 return ascii_display_width(grapheme);
722 }
723 if !use_width_cache() || grapheme.len() > WIDTH_CACHE_MAX_GRAPHEME_BYTES {
724 return grapheme_width_uncached(grapheme);
725 }
726 let key = grapheme_cache_key(grapheme);
727 cached_grapheme_width(grapheme, key)
728 }
729
730 #[inline]
731 fn cached_grapheme_width(grapheme: &str, key: u64) -> usize {
732 WIDTH_CACHE.with(|cache| cache.borrow_mut().width(grapheme, key))
733 }
734
735 #[inline]
738 #[must_use]
739 pub fn grapheme_width_uncached(grapheme: &str) -> usize {
740 grapheme_width_with_cjk(grapheme, use_cjk_width())
741 }
742
743 #[must_use]
750 pub fn grapheme_width_with_cjk(grapheme: &str, cjk: bool) -> usize {
751 if grapheme.is_ascii() {
752 return ascii_display_width(grapheme);
753 }
754 if grapheme.chars().all(is_zero_width_codepoint) {
755 return 0;
756 }
757 if cjk {
758 return grapheme.width_cjk();
759 }
760 if !trust_vs16_width()
764 && let Some(stripped) = strip_vs16(grapheme)
765 {
766 if stripped.is_empty() {
767 return 0;
768 }
769 return unicode_display_width(&stripped) as usize;
770 }
771 unicode_display_width(grapheme) as usize
772 }
773
774 #[inline]
776 #[must_use]
777 pub fn char_width(ch: char) -> usize {
778 if ch.is_ascii() {
779 return match ch {
780 '\t' | '\n' | '\r' => 1,
781 ' '..='~' => 1,
782 _ => 0,
783 };
784 }
785 if is_zero_width_codepoint(ch) {
786 return 0;
787 }
788 if use_cjk_width() {
789 ch.width_cjk().unwrap_or(0)
790 } else {
791 ch.width().unwrap_or(0)
792 }
793 }
794
795 #[inline]
797 #[must_use]
798 pub fn display_width(text: &str) -> usize {
799 if let Some(width) = ascii_width(text) {
800 return width;
801 }
802 if text.is_ascii() {
803 return ascii_display_width(text);
804 }
805 let cjk_width = use_cjk_width();
806 if !text.chars().any(is_zero_width_codepoint) {
807 if cjk_width {
808 return text.width_cjk();
809 }
810 return unicode_display_width(text) as usize;
811 }
812 text.graphemes(true).map(grapheme_width).sum()
813 }
814
815 #[cfg(test)]
816 mod tests {
817 use super::*;
818
819 const CORPUS: &[&str] = &[
822 "é",
823 "日",
824 "本",
825 "語",
826 "한",
827 "😀",
828 "👨👩👧👦",
829 "🇯🇵",
830 "\u{1F3F4}\u{E0067}",
831 "a\u{0301}",
832 "\u{200B}",
833 "\u{FE0F}",
834 "☂\u{FE0F}",
835 "ア",
836 "Ω",
837 "→",
838 "…",
839 ];
840
841 #[test]
842 fn width_cache_rejects_hash_collisions() {
843 clear_width_cache();
844 for grapheme in ["\u{200B}", "日", "é", "👨👩👧👦", "\u{200B}"] {
847 for _ in 0..2 {
848 assert_eq!(
849 cached_grapheme_width(grapheme, 0),
850 grapheme_width_uncached(grapheme),
851 "collision changed the width of {grapheme:?}"
852 );
853 }
854 }
855 let stats = WIDTH_CACHE.with(|cache| cache.borrow().stats());
856 assert_eq!(stats.hits, 5, "only exact repeats are cache hits");
857 assert_eq!(stats.misses, 5, "collisions are cache misses");
858 assert_eq!(stats.small_size + stats.main_size, 1);
859 }
860
861 #[test]
862 fn width_cache_collision_and_eviction_order_preserves_corpus() {
863 let mut cache = GraphemeWidthCache::new(4);
864 for round in 0..32 {
865 for offset in 0..CORPUS.len() {
866 let index = (round + offset) % CORPUS.len();
867 let grapheme = CORPUS[index];
868 let key = (index % 6) as u64;
871 let expected = grapheme_width_uncached(grapheme);
872 assert_eq!(cache.width(grapheme, key), expected);
873 assert_eq!(cache.width(grapheme, key), expected);
874 let stats = cache.stats();
875 assert!(stats.small_size + stats.main_size <= 4);
876 assert!(stats.ghost_size <= 1);
877 }
878 }
879 }
880
881 #[test]
882 fn width_cache_bypasses_unbounded_combining_clusters() {
883 clear_width_cache();
884 let grapheme = format!("a{}", "\u{0301}".repeat(WIDTH_CACHE_MAX_GRAPHEME_BYTES));
885 assert_eq!(grapheme.graphemes(true).count(), 1);
886 assert!(grapheme.len() > WIDTH_CACHE_MAX_GRAPHEME_BYTES);
887 let before = WIDTH_CACHE.with(|cache| cache.borrow().stats());
888 for _ in 0..3 {
889 assert_eq!(
890 grapheme_width(&grapheme),
891 grapheme_width_uncached(&grapheme)
892 );
893 }
894 assert_eq!(WIDTH_CACHE.with(|cache| cache.borrow().stats()), before);
895 }
896
897 #[test]
898 fn width_cache_retained_key_boundary() {
899 clear_width_cache();
900 let grapheme = format!("é{}", "\u{0301}".repeat(63));
901 assert_eq!(grapheme.len(), WIDTH_CACHE_MAX_GRAPHEME_BYTES);
902 assert_eq!(grapheme.graphemes(true).count(), 1);
903 for _ in 0..2 {
904 assert_eq!(
905 grapheme_width(&grapheme),
906 grapheme_width_uncached(&grapheme)
907 );
908 }
909 if let Some(stats) = width_cache_stats() {
910 assert_eq!(stats.hits, 1);
911 assert_eq!(stats.misses, 1);
912 }
913 }
914
915 #[test]
916 fn width_cache_is_thread_local_and_clear_resets_identity() {
917 clear_width_cache();
918 assert_eq!(
919 cached_grapheme_width("日", 0),
920 grapheme_width_uncached("日")
921 );
922 let parent_stats = WIDTH_CACHE.with(|cache| cache.borrow().stats());
923 std::thread::spawn(|| {
924 let initial = WIDTH_CACHE.with(|cache| cache.borrow().stats());
925 assert_eq!(initial.hits + initial.misses, 0);
926 assert_eq!(cached_grapheme_width("\u{200B}", 0), 0);
927 clear_width_cache();
928 let cleared = WIDTH_CACHE.with(|cache| cache.borrow().stats());
929 assert_eq!(cleared.hits + cleared.misses, 0);
930 assert_eq!(cleared.small_size + cleared.main_size, 0);
931 })
932 .join()
933 .expect("thread-local cache checks");
934 assert_eq!(
935 WIDTH_CACHE.with(|cache| cache.borrow().stats()),
936 parent_stats
937 );
938 assert_eq!(
939 cached_grapheme_width("日", 0),
940 grapheme_width_uncached("日")
941 );
942 }
943
944 #[test]
947 fn width_cache_is_transparent_and_hits_on_repeat() {
948 clear_width_cache();
949 let before = width_cache_stats();
950 for grapheme in CORPUS {
951 assert_eq!(
952 grapheme_width(grapheme),
953 grapheme_width_uncached(grapheme),
954 "cached width differs for {grapheme:?}"
955 );
956 }
957 for grapheme in CORPUS {
958 assert_eq!(grapheme_width(grapheme), grapheme_width_uncached(grapheme));
959 }
960 if let (Some(before), Some(after)) = (before, width_cache_stats()) {
961 assert!(
962 after.hits >= before.hits + CORPUS.len() as u64,
963 "second pass must hit the cache: before={before:?} after={after:?}"
964 );
965 assert!(after.small_size + after.main_size >= 1);
966 }
967 }
968
969 #[test]
971 fn width_cache_skips_ascii() {
972 clear_width_cache();
973 let before = width_cache_stats();
974 for text in ["a", "hello", " ", "~", "\t"] {
975 let _ = grapheme_width(text);
976 }
977 let after = width_cache_stats();
978 assert_eq!(before.map(|s| s.hits), after.map(|s| s.hits));
979 assert_eq!(before.map(|s| s.misses), after.map(|s| s.misses));
980 }
981
982 #[test]
985 fn display_width_matches_uncached_sum_on_mixed_text() {
986 let samples = [
987 "hello 世界 👋🏽 done",
988 "table │ 日本語 │ ok",
989 "🇯🇵🇺🇸 flags and ☂\u{FE0F} rain",
990 "combining a\u{0301}e\u{0301} marks",
991 ];
992 for text in samples {
993 let expected: usize = text.graphemes(true).map(grapheme_width_uncached).sum();
994 assert_eq!(display_width(text), expected, "{text:?}");
995 assert_eq!(display_width(text), expected, "second pass {text:?}");
996 }
997 }
998
999 #[test]
1002 fn cjk_width_env_explicit_true() {
1003 let get = |key: &str| match key {
1004 "FTUI_GLYPH_DOUBLE_WIDTH" => Some("1".into()),
1005 _ => None,
1006 };
1007 assert!(cjk_width_from_env(get));
1008 }
1009
1010 #[test]
1011 fn cjk_width_env_explicit_false() {
1012 let get = |key: &str| match key {
1013 "FTUI_GLYPH_DOUBLE_WIDTH" => Some("0".into()),
1014 _ => None,
1015 };
1016 assert!(!cjk_width_from_env(get));
1017 }
1018
1019 #[test]
1020 fn cjk_width_env_text_cjk_key() {
1021 let get = |key: &str| match key {
1022 "FTUI_TEXT_CJK_WIDTH" => Some("true".into()),
1023 _ => None,
1024 };
1025 assert!(cjk_width_from_env(get));
1026 }
1027
1028 #[test]
1029 fn cjk_width_env_fallback_key() {
1030 let get = |key: &str| match key {
1031 "FTUI_CJK_WIDTH" => Some("yes".into()),
1032 _ => None,
1033 };
1034 assert!(cjk_width_from_env(get));
1035 }
1036
1037 #[test]
1038 fn cjk_width_env_japanese_locale() {
1039 let get = |key: &str| match key {
1040 "LC_CTYPE" => Some("ja_JP.UTF-8".into()),
1041 _ => None,
1042 };
1043 assert!(cjk_width_from_env(get));
1044 }
1045
1046 #[test]
1047 fn cjk_width_env_chinese_locale() {
1048 let get = |key: &str| match key {
1049 "LANG" => Some("zh_CN.UTF-8".into()),
1050 _ => None,
1051 };
1052 assert!(cjk_width_from_env(get));
1053 }
1054
1055 #[test]
1056 fn cjk_width_env_korean_locale() {
1057 let get = |key: &str| match key {
1058 "LC_CTYPE" => Some("ko_KR.UTF-8".into()),
1059 _ => None,
1060 };
1061 assert!(cjk_width_from_env(get));
1062 }
1063
1064 #[test]
1065 fn cjk_width_env_english_locale_returns_false() {
1066 let get = |key: &str| match key {
1067 "LANG" => Some("en_US.UTF-8".into()),
1068 _ => None,
1069 };
1070 assert!(!cjk_width_from_env(get));
1071 }
1072
1073 #[test]
1074 fn cjk_width_env_no_vars_returns_false() {
1075 let get = |_: &str| -> Option<String> { None };
1076 assert!(!cjk_width_from_env(get));
1077 }
1078
1079 #[test]
1080 fn cjk_width_env_glyph_overrides_locale() {
1081 let get = |key: &str| match key {
1083 "FTUI_GLYPH_DOUBLE_WIDTH" => Some("0".into()),
1084 "LANG" => Some("ja_JP.UTF-8".into()),
1085 _ => None,
1086 };
1087 assert!(!cjk_width_from_env(get));
1088 }
1089
1090 #[test]
1091 fn cjk_width_env_on_is_true() {
1092 let get = |key: &str| match key {
1093 "FTUI_GLYPH_DOUBLE_WIDTH" => Some("on".into()),
1094 _ => None,
1095 };
1096 assert!(cjk_width_from_env(get));
1097 }
1098
1099 #[test]
1100 fn cjk_width_env_case_insensitive() {
1101 let get = |key: &str| match key {
1102 "FTUI_CJK_WIDTH" => Some("TRUE".into()),
1103 _ => None,
1104 };
1105 assert!(cjk_width_from_env(get));
1106 }
1107
1108 #[test]
1111 fn vs16_trust_unicode_string() {
1112 let get = |key: &str| match key {
1113 "FTUI_EMOJI_VS16_WIDTH" => Some("unicode".into()),
1114 _ => None,
1115 };
1116 assert!(vs16_trust_from_env(get));
1117 }
1118
1119 #[test]
1120 fn vs16_trust_value_2() {
1121 let get = |key: &str| match key {
1122 "FTUI_EMOJI_VS16_WIDTH" => Some("2".into()),
1123 _ => None,
1124 };
1125 assert!(vs16_trust_from_env(get));
1126 }
1127
1128 #[test]
1129 fn vs16_trust_not_set() {
1130 let get = |_: &str| -> Option<String> { None };
1131 assert!(!vs16_trust_from_env(get));
1132 }
1133
1134 #[test]
1135 fn vs16_trust_other_value() {
1136 let get = |key: &str| match key {
1137 "FTUI_EMOJI_VS16_WIDTH" => Some("1".into()),
1138 _ => None,
1139 };
1140 assert!(!vs16_trust_from_env(get));
1141 }
1142
1143 #[test]
1144 fn vs16_trust_case_insensitive() {
1145 let get = |key: &str| match key {
1146 "FTUI_EMOJI_VS16_WIDTH" => Some("UNICODE".into()),
1147 _ => None,
1148 };
1149 assert!(vs16_trust_from_env(get));
1150 }
1151
1152 #[test]
1155 fn ascii_width_pure_ascii() {
1156 assert_eq!(ascii_width("hello"), Some(5));
1157 }
1158
1159 #[test]
1160 fn ascii_width_empty() {
1161 assert_eq!(ascii_width(""), Some(0));
1162 }
1163
1164 #[test]
1165 fn ascii_width_with_space() {
1166 assert_eq!(ascii_width("hello world"), Some(11));
1167 }
1168
1169 #[test]
1170 fn ascii_width_non_ascii_returns_none() {
1171 assert_eq!(ascii_width("héllo"), None);
1172 }
1173
1174 #[test]
1175 fn ascii_width_with_tab_returns_none() {
1176 assert_eq!(ascii_width("hello\tworld"), None);
1178 }
1179
1180 #[test]
1181 fn ascii_width_with_newline_returns_none() {
1182 assert_eq!(ascii_width("hello\n"), None);
1183 }
1184
1185 #[test]
1186 fn ascii_width_control_char_returns_none() {
1187 assert_eq!(ascii_width("\x01"), None);
1188 }
1189
1190 #[test]
1193 fn char_width_ascii_letter() {
1194 assert_eq!(char_width('A'), 1);
1195 }
1196
1197 #[test]
1198 fn char_width_space() {
1199 assert_eq!(char_width(' '), 1);
1200 }
1201
1202 #[test]
1203 fn char_width_tab() {
1204 assert_eq!(char_width('\t'), 1);
1205 }
1206
1207 #[test]
1208 fn char_width_newline() {
1209 assert_eq!(char_width('\n'), 1);
1210 }
1211
1212 #[test]
1213 fn char_width_nul() {
1214 assert_eq!(char_width('\0'), 0);
1216 }
1217
1218 #[test]
1219 fn char_width_bell() {
1220 assert_eq!(char_width('\x07'), 0);
1222 }
1223
1224 #[test]
1225 fn char_width_combining_accent() {
1226 assert_eq!(char_width('\u{0301}'), 0);
1228 }
1229
1230 #[test]
1231 fn char_width_zwj() {
1232 assert_eq!(char_width('\u{200D}'), 0);
1234 }
1235
1236 #[test]
1237 fn char_width_zwnbsp() {
1238 assert_eq!(char_width('\u{FEFF}'), 0);
1240 }
1241
1242 #[test]
1243 fn char_width_soft_hyphen() {
1244 assert_eq!(char_width('\u{00AD}'), 0);
1246 }
1247
1248 #[test]
1249 fn char_width_wide_east_asian() {
1250 assert_eq!(char_width('⚡'), 2);
1252 }
1253
1254 #[test]
1255 fn char_width_cjk_ideograph() {
1256 assert_eq!(char_width('中'), 2);
1258 }
1259
1260 #[test]
1261 fn char_width_variation_selector() {
1262 assert_eq!(char_width('\u{FE0F}'), 0);
1264 }
1265
1266 #[test]
1269 fn display_width_ascii() {
1270 assert_eq!(display_width("hello"), 5);
1271 }
1272
1273 #[test]
1274 fn display_width_empty() {
1275 assert_eq!(display_width(""), 0);
1276 }
1277
1278 #[test]
1279 fn display_width_cjk_chars() {
1280 assert_eq!(display_width("中文"), 4);
1282 }
1283
1284 #[test]
1285 fn display_width_mixed_ascii_cjk() {
1286 assert_eq!(display_width("a中b"), 4);
1288 }
1289
1290 #[test]
1291 fn display_width_combining_chars() {
1292 assert_eq!(display_width("e\u{0301}"), 1);
1294 }
1295
1296 #[test]
1297 fn display_width_ascii_with_control_codes() {
1298 assert_eq!(display_width("a\tb"), 3);
1301 }
1302
1303 #[test]
1309 fn ascii_width_matches_scalar() {
1310 let cases: &[&str] = &[
1311 "",
1312 " ",
1313 "~",
1314 "hello world",
1315 "\u{1f}",
1316 "\u{7f}",
1317 "\t",
1318 "caf\u{e9}",
1319 "\u{65e5}\u{672c}",
1320 ];
1321 for case in cases {
1322 assert_eq!(ascii_width(case), ascii_width_scalar(case), "{case:?}");
1323 }
1324
1325 for len in [63_usize, 64, 65, 129, 300] {
1329 let clean = "a".repeat(len);
1330 assert_eq!(ascii_width(&clean), Some(len), "clean len {len}");
1331 assert_eq!(ascii_width(&clean), ascii_width_scalar(&clean));
1332
1333 for idx in [0, len / 2, len - 1] {
1334 for bad in ['\u{0}', '\u{1f}', '\u{7f}', '\u{e9}'] {
1335 let mut probe: Vec<char> = clean.chars().collect();
1336 probe[idx] = bad;
1337 let probe: String = probe.into_iter().collect();
1338 assert_eq!(
1339 ascii_width(&probe),
1340 ascii_width_scalar(&probe),
1341 "len {len}, idx {idx}, bad {bad:?}"
1342 );
1343 assert_eq!(ascii_width(&probe), None);
1344 }
1345 }
1346 }
1347 }
1348
1349 #[test]
1352 fn grapheme_width_ascii_char() {
1353 assert_eq!(grapheme_width("A"), 1);
1354 }
1355
1356 #[test]
1357 fn grapheme_width_cjk_ideograph() {
1358 assert_eq!(grapheme_width("中"), 2);
1359 }
1360
1361 #[test]
1362 fn grapheme_width_combining_sequence() {
1363 assert_eq!(grapheme_width("e\u{0301}"), 1);
1365 }
1366
1367 #[test]
1368 fn grapheme_width_zwj_cluster() {
1369 assert_eq!(grapheme_width("\u{200D}"), 0);
1371 }
1372 }
1373}