Skip to main content

ftui_core/
lib.rs

1// Forbid unsafe in production; deny (with targeted allows) in tests for env var helpers.
2#![cfg_attr(not(test), forbid(unsafe_code))]
3#![cfg_attr(test, deny(unsafe_code))]
4
5//! Core: terminal lifecycle, capability detection, events, and input parsing.
6//!
7//! # Role in FrankenTUI
8//! `ftui-core` is the input layer. It owns terminal session setup/teardown,
9//! capability probing, and normalized event types that the runtime consumes.
10//!
11//! # Primary responsibilities
12//! - **TerminalSession**: RAII lifecycle for raw mode, alt-screen, and cleanup.
13//! - **Event**: canonical input events (keys, mouse, paste, resize, focus).
14//! - **Capability detection**: terminal features and overrides.
15//! - **Input parsing**: robust decoding of terminal input streams.
16//!
17//! # How it fits in the system
18//! The runtime (`ftui-runtime`) consumes `ftui-core::Event` values and drives
19//! application models. The render kernel (`ftui-render`) is independent of
20//! input, so `ftui-core` is the clean bridge between terminal I/O and the
21//! deterministic render pipeline.
22
23pub 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/// Feature-off mirror of [`terminal_session`] for builds without the
60/// crossterm backend. Nothing else owns the terminal writer in that
61/// configuration, so the one-writer output lock degrades to a no-op guard;
62/// downstream crates (e.g. franken_node's operator surface) can keep calling
63/// [`terminal_output_lock`](terminal_session::terminal_output_lock)
64/// unconditionally.
65#[cfg(not(all(not(target_arch = "wasm32"), feature = "crossterm")))]
66pub mod terminal_session {
67    /// Guard returned by the no-op [`terminal_output_lock`] stub.
68    #[derive(Debug, Default, Clone, Copy)]
69    pub struct TerminalOutputGuard;
70
71    /// Serialize terminal writes. Without crossterm there is no raw-mode
72    /// writer to contend with, so this is a no-op.
73    #[inline]
74    #[must_use]
75    pub fn terminal_output_lock() -> TerminalOutputGuard {
76        TerminalOutputGuard
77    }
78}
79
80pub mod shutdown_signal {
81    //! Process-wide graceful-termination signal state shared by runtime and backends.
82    //!
83    //! Signal handlers record the first pending termination signal here. The
84    //! runtime polls it, performs graceful teardown, then clears it to
85    //! acknowledge completion back to the signal thread.
86
87    use std::sync::{
88        Mutex, OnceLock,
89        atomic::{AtomicI32, Ordering},
90    };
91
92    static PENDING_TERMINATION_SIGNAL: AtomicI32 = AtomicI32::new(0);
93
94    /// Record that a termination signal was intercepted and graceful shutdown is required.
95    ///
96    /// The first pending signal wins until the runtime explicitly clears it
97    /// after finishing teardown.
98    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    /// Inspect the currently pending termination signal, if any.
108    #[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    /// Clear any pending graceful-termination request.
117    pub fn clear_pending_termination_signal() {
118        PENDING_TERMINATION_SIGNAL.store(0, Ordering::SeqCst);
119    }
120
121    /// Serialize tests that touch the process-global termination signal slot.
122    ///
123    /// This helper is intentionally exported so downstream workspace crates can
124    /// wrap signal-sensitive tests with the same lock. Without cross-crate
125    /// serialization, parallel test execution can clear the pending signal out
126    /// from under a runtime test and leave it blocked in the event loop.
127    #[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// Re-export tracing macros at crate root for ergonomic use.
146#[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    //! Shared display width helpers for layout and rendering.
153    //!
154    //! This module centralizes glyph width calculation so layout (ftui-text)
155    //! and rendering (ftui-render) stay in lockstep. It intentionally avoids
156    //! ad-hoc emoji heuristics and relies on Unicode data tables.
157    //!
158    //! ## Emoji Width Handling
159    //!
160    //! Most terminals render **text-default** emoji (those with
161    //! `Emoji_Presentation=No`, like U+2764 RED HEART) at **width 1**, even
162    //! when a Variation Selector 16 (U+FE0F) is appended. The Unicode spec
163    //! says VS16 requests emoji presentation (width 2), but terminal reality
164    //! disagrees.
165    //!
166    //! **Default behavior** (`FTUI_EMOJI_VS16_WIDTH` unset):
167    //! - `strip_vs16` removes U+FE0F before width calculation.
168    //! - Text-default emoji render at width 1 (matching most terminals).
169    //! - Emoji with `Emoji_Presentation=Yes` (e.g. U+1F600) are unaffected
170    //!   — they are always width 2.
171    //!
172    //! **Opt-in** for terminals that correctly render VS16 at width 2
173    //! (WezTerm, Kitty, Ghostty):
174    //! ```text
175    //! FTUI_EMOJI_VS16_WIDTH=unicode   # or =2
176    //! ```
177    //!
178    //! The policy is read once at startup via [`OnceLock`]. Changing the env
179    //! var mid-process has no effect. See [`vs16_width_trusted`] and
180    //! [`vs16_trust_from_env`] for the API surface.
181
182    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    /// Whether the terminal is trusted to render text-default emoji + VS16 at
226    /// width 2 (matching the Unicode spec).  Most terminals do NOT — they
227    /// render these at width 1 — so the default is `false`.
228    ///
229    /// Set `FTUI_EMOJI_VS16_WIDTH=unicode` (or `=2`) to opt in for terminals
230    /// that handle this correctly (WezTerm, Kitty, Ghostty).
231    #[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    /// Compute VS16 trust policy using a custom environment lookup (testable).
242    #[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    /// Cached VS16 width trust policy (fast path).
253    #[inline]
254    pub fn vs16_width_trusted() -> bool {
255        trust_vs16_width()
256    }
257
258    /// Strip U+FE0F (VS16) from a grapheme cluster.  Returns `None` if the
259    /// grapheme does not contain VS16 (no allocation needed).
260    #[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    /// Compute CJK width policy using a custom environment lookup.
270    #[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    /// Cached CJK width policy (fast path).
279    #[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    /// Fast-path width for pure printable ASCII.
298    #[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    /// Capacity of the per-thread grapheme width cache (entries).
332    ///
333    /// 4096 distinct non-ASCII graphemes covers the working set of a busy
334    /// CJK/emoji screen many times over; S3-FIFO keeps one-off scans (a log
335    /// stream of unique emoji) from evicting the hot set.
336    const WIDTH_CACHE_CAPACITY: usize = 4096;
337
338    /// Bound retained key bytes even for arbitrarily long combining clusters.
339    /// Larger graphemes remain valid input and use the uncached width tables.
340    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            // A matching hash is only a hit after exact byte verification.
385            // Collisions replace that hash's entry and count as width misses.
386            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    /// Whether the grapheme width cache is enabled (`FTUI_WIDTH_CACHE=0`,
399    /// `false`, `off`, or `no` disables it; anything else keeps it on).
400    #[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        /// Per-thread S3-FIFO cache from grapheme hash to display width.
417        ///
418        /// Non-ASCII width lookups (`unicode_display_width`, VS16 stripping,
419        /// zero-width scans) are the expensive part of measuring text; every
420        /// wrap, table column, and diff of a CJK or emoji screen repeats them
421        /// for the same handful of clusters. A hash selects the candidate;
422        /// exact cluster bytes decide whether its width can be reused.
423        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    /// Snapshot of the calling thread's grapheme width cache statistics,
438    /// or `None` when the cache is disabled.
439    ///
440    /// Hits require exact grapheme equality; hash collisions count as misses.
441    /// ASCII and clusters exceeding the retained-key byte limit bypass the
442    /// cache and do not contribute to either counter.
443    #[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    /// Drop every cached width on the calling thread (tests and benchmarks).
452    pub fn clear_width_cache() {
453        WIDTH_CACHE.with(|cache| cache.borrow_mut().clear());
454    }
455
456    /// Width of a single grapheme cluster.
457    ///
458    /// ASCII is answered inline; every other cluster goes through the
459    /// per-thread width cache (see [`width_cache_stats`]) in front of the
460    /// Unicode width tables.
461    #[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    /// Width of a non-ASCII grapheme cluster, computed from the Unicode
480    /// tables every time (the cached path in [`grapheme_width`] wraps this).
481    #[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        // Terminal-realistic VS16 handling: most terminals render text-default
494        // emoji (Emoji_Presentation=No) at 1 cell even with VS16 appended.
495        // Strip VS16 so unicode_display_width returns the text-presentation width.
496        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    /// Width of a single Unicode scalar.
508    #[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    /// Width of a string in terminal cells.
529    #[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        // ── grapheme width cache ────────────────────────────────────
553
554        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            // Route distinct real clusters through the same production lookup
578            // with a deliberately colliding hash, independent of hash quality.
579            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                    // Six hashes pressure a four-entry cache; each hash also
602                    // has multiple byte-distinct graphemes competing for it.
603                    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        /// The cache must be invisible: cached answers equal the uncached
678        /// computation for every cluster, and repeated lookups are hits.
679        #[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        /// ASCII never touches the cache: its width is answered inline.
703        #[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        /// `display_width` over mixed text agrees with a from-scratch sum of
716        /// uncached grapheme widths, so the cache cannot change measurements.
717        #[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        // ── env helpers (testable without OnceLock) ─────────────────
733
734        #[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            // FTUI_GLYPH_DOUBLE_WIDTH=0 should override a CJK locale
815            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        // ── VS16 trust from env ─────────────────────────────────────
842
843        #[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        // ── ascii_width fast path ───────────────────────────────────
886
887        #[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            // Tab (0x09) is outside 0x20..=0x7E
910            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        // ── char_width ──────────────────────────────────────────────
924
925        #[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            // NUL (0x00) is an ASCII control char, zero width
948            assert_eq!(char_width('\0'), 0);
949        }
950
951        #[test]
952        fn char_width_bell() {
953            // BEL (0x07) is an ASCII control char, zero width
954            assert_eq!(char_width('\x07'), 0);
955        }
956
957        #[test]
958        fn char_width_combining_accent() {
959            // U+0301 COMBINING ACUTE ACCENT is zero-width
960            assert_eq!(char_width('\u{0301}'), 0);
961        }
962
963        #[test]
964        fn char_width_zwj() {
965            // U+200D ZERO WIDTH JOINER
966            assert_eq!(char_width('\u{200D}'), 0);
967        }
968
969        #[test]
970        fn char_width_zwnbsp() {
971            // U+FEFF ZERO WIDTH NO-BREAK SPACE
972            assert_eq!(char_width('\u{FEFF}'), 0);
973        }
974
975        #[test]
976        fn char_width_soft_hyphen() {
977            // U+00AD SOFT HYPHEN
978            assert_eq!(char_width('\u{00AD}'), 0);
979        }
980
981        #[test]
982        fn char_width_wide_east_asian() {
983            // '⚡' (U+26A1) has east_asian_width=W, always width 2
984            assert_eq!(char_width('⚡'), 2);
985        }
986
987        #[test]
988        fn char_width_cjk_ideograph() {
989            // CJK ideographs are always width 2
990            assert_eq!(char_width('中'), 2);
991        }
992
993        #[test]
994        fn char_width_variation_selector() {
995            // U+FE0F VARIATION SELECTOR-16 is zero-width
996            assert_eq!(char_width('\u{FE0F}'), 0);
997        }
998
999        // ── display_width ───────────────────────────────────────────
1000
1001        #[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            // Each CJK character is width 2
1014            assert_eq!(display_width("中文"), 4);
1015        }
1016
1017        #[test]
1018        fn display_width_mixed_ascii_cjk() {
1019            // 'a' = 1, '中' = 2, 'b' = 1
1020            assert_eq!(display_width("a中b"), 4);
1021        }
1022
1023        #[test]
1024        fn display_width_combining_chars() {
1025            // 'e' + combining acute = 1 grapheme, width 1
1026            assert_eq!(display_width("e\u{0301}"), 1);
1027        }
1028
1029        #[test]
1030        fn display_width_ascii_with_control_codes() {
1031            // Non-printable ASCII control chars in non-pure-ASCII path
1032            // Tab/newline/CR get width 1 via ascii_display_width
1033            assert_eq!(display_width("a\tb"), 3);
1034        }
1035
1036        // ── grapheme_width ──────────────────────────────────────────
1037
1038        #[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            // 'e' + combining accent is one grapheme, width 1
1051            assert_eq!(grapheme_width("e\u{0301}"), 1);
1052        }
1053
1054        #[test]
1055        fn grapheme_width_zwj_cluster() {
1056            // ZWJ alone is zero-width
1057            assert_eq!(grapheme_width("\u{200D}"), 0);
1058        }
1059    }
1060}