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 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/// Feature-off mirror of [`terminal_session`] for builds without the
54/// crossterm backend. Nothing else owns the terminal writer in that
55/// configuration, so the one-writer output lock degrades to a no-op guard;
56/// downstream crates (e.g. franken_node's operator surface) can keep calling
57/// [`terminal_output_lock`](terminal_session::terminal_output_lock)
58/// unconditionally.
59#[cfg(not(all(not(target_arch = "wasm32"), feature = "crossterm")))]
60pub mod terminal_session {
61    /// Guard returned by the no-op [`terminal_output_lock`] stub.
62    #[derive(Debug, Default, Clone, Copy)]
63    pub struct TerminalOutputGuard;
64
65    /// Serialize terminal writes. Without crossterm there is no raw-mode
66    /// writer to contend with, so this is a no-op.
67    #[inline]
68    #[must_use]
69    pub fn terminal_output_lock() -> TerminalOutputGuard {
70        TerminalOutputGuard
71    }
72}
73
74pub mod shutdown_signal {
75    //! Process-wide graceful-termination signal state shared by runtime and backends.
76    //!
77    //! Signal handlers record the first pending termination signal here. The
78    //! runtime polls it, performs graceful teardown, then clears it to
79    //! acknowledge completion back to the signal thread.
80
81    use std::sync::{
82        Mutex, OnceLock,
83        atomic::{AtomicI32, Ordering},
84    };
85
86    static PENDING_TERMINATION_SIGNAL: AtomicI32 = AtomicI32::new(0);
87
88    /// Record that a termination signal was intercepted and graceful shutdown is required.
89    ///
90    /// The first pending signal wins until the runtime explicitly clears it
91    /// after finishing teardown.
92    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    /// Inspect the currently pending termination signal, if any.
102    #[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    /// Clear any pending graceful-termination request.
111    pub fn clear_pending_termination_signal() {
112        PENDING_TERMINATION_SIGNAL.store(0, Ordering::SeqCst);
113    }
114
115    /// Serialize tests that touch the process-global termination signal slot.
116    ///
117    /// This helper is intentionally exported so downstream workspace crates can
118    /// wrap signal-sensitive tests with the same lock. Without cross-crate
119    /// serialization, parallel test execution can clear the pending signal out
120    /// from under a runtime test and leave it blocked in the event loop.
121    #[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        // Recover from poison rather than propagating it. The guarded value is
126        // `()`, so there is no invariant a panic could have broken - and a
127        // test that fails inside `f` would otherwise turn every later signal
128        // test in the process into "shutdown signal test lock poisoned",
129        // burying the one real failure under a cascade of fake ones.
130        let _guard = SIGNAL_TEST_LOCK
131            .get_or_init(|| Mutex::new(()))
132            .lock()
133            .unwrap_or_else(|poison| poison.into_inner());
134
135        // Clear on the way out even if `f` unwinds. Declared after `_guard`,
136        // so it runs first and the slot is clean before the lock is released;
137        // a plain statement after `f()` was skipped on panic and left a
138        // pending signal for whoever ran next.
139        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                    // Any non-zero signal number; `signal_hook`'s constants are
160                    // Unix-gated and this behaviour is not platform-specific.
161                    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            // And the next test is not punished for the previous one's failure.
172            with_test_signal_serialization(|| {
173                assert!(pending_termination_signal().is_none());
174            });
175        }
176    }
177}
178
179pub mod job_control {
180    //! Process-wide job-control state: SIGTSTP, SIGTTIN and SIGTTOU.
181    //!
182    //! By default those signals stop the process on the spot, which leaves a
183    //! raw-mode terminal behind for the shell. A live session holds a
184    //! [`JobControlClaim`]; while any claim is held the signals are only
185    //! recorded, the runtime hands the terminal back, and then calls
186    //! [`stop_process`] itself. Design: `docs/spec/suspend-resume.md`.
187    //!
188    //! With no claim held the signals keep their default action. That fallback
189    //! is load-bearing: signal-hook never removes a handler once installed, so
190    //! without it a program that had exited would leave its process impossible
191    //! to suspend for the rest of its life.
192
193    #[cfg(unix)]
194    use std::sync::{
195        Arc, OnceLock,
196        atomic::{AtomicBool, AtomicUsize, Ordering},
197    };
198
199    /// Handlers installed once per process: the stop signal last recorded
200    /// while a claim was held, and whether no claim is held.
201    #[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                    // Default action first, so a partial installation still
218                    // stops the process rather than swallowing the signal.
219                    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    /// SIGTSTP, the signal a terminal's Ctrl-Z would send: 18 on macOS and
234    /// the BSDs, 20 on Linux. A non-zero placeholder where there is no job
235    /// control, so a keyboard stop request still reads as a request.
236    #[cfg(unix)]
237    pub const KEYBOARD_STOP_SIGNAL: i32 = signal_hook::consts::signal::SIGTSTP;
238    /// SIGTSTP, the signal a terminal's Ctrl-Z would send.
239    #[cfg(not(unix))]
240    pub const KEYBOARD_STOP_SIGNAL: i32 = 20;
241
242    /// A live session's hold on the stop signals; released on drop.
243    #[derive(Debug)]
244    pub struct JobControlClaim {
245        _private: (),
246    }
247
248    impl JobControlClaim {
249        /// Claim the stop signals for a live session.
250        ///
251        /// Returns `None` where they cannot be intercepted: non-Unix targets,
252        /// or when the handlers failed to install.
253        #[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                    // A signal recorded while unclaimed already stopped the
260                    // process through the default action; it is not a request
261                    // for this session.
262                    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        /// Take the stop signal that arrived since the last call, if any.
274        #[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    /// Stop the process the way `signal`'s default action would, returning
300    /// once it has been continued (SIGCONT).
301    ///
302    /// For the stop signals this raises SIGSTOP, which cannot be caught, so a
303    /// supervisor reading `WSTOPSIG` sees SIGSTOP rather than `signal`. Call it
304    /// only after the terminal has been handed back.
305    ///
306    /// # Errors
307    ///
308    /// Fails if `signal` is unknown or cannot be raised, and on non-Unix
309    /// targets, which have no job control.
310    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        /// The claim count is process-global; `cargo test` runs these in threads.
331        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            // Never raise a stop signal here: a stopped test process hangs the
340            // suite. This checks the bookkeeping that decides whether one would.
341            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// Re-export tracing macros at crate root for ergonomic use.
373#[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    //! Shared display width helpers for layout and rendering.
380    //!
381    //! This module centralizes glyph width calculation so layout (ftui-text)
382    //! and rendering (ftui-render) stay in lockstep. It intentionally avoids
383    //! ad-hoc emoji heuristics and relies on Unicode data tables.
384    //!
385    //! ## Emoji Width Handling
386    //!
387    //! Most terminals render **text-default** emoji (those with
388    //! `Emoji_Presentation=No`, like U+2764 RED HEART) at **width 1**, even
389    //! when a Variation Selector 16 (U+FE0F) is appended. The Unicode spec
390    //! says VS16 requests emoji presentation (width 2), but terminal reality
391    //! disagrees.
392    //!
393    //! **Default behavior** (`FTUI_EMOJI_VS16_WIDTH` unset):
394    //! - `strip_vs16` removes U+FE0F before width calculation.
395    //! - Text-default emoji render at width 1 (matching most terminals).
396    //! - Emoji with `Emoji_Presentation=Yes` (e.g. U+1F600) are unaffected
397    //!   — they are always width 2.
398    //!
399    //! **Opt-in** for terminals that correctly render VS16 at width 2
400    //! (WezTerm, Kitty, Ghostty):
401    //! ```text
402    //! FTUI_EMOJI_VS16_WIDTH=unicode   # or =2
403    //! ```
404    //!
405    //! The policy is read once at startup via [`OnceLock`]. Changing the env
406    //! var mid-process has no effect. See [`vs16_width_trusted`] and
407    //! [`vs16_trust_from_env`] for the API surface.
408
409    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    /// Whether the terminal is trusted to render text-default emoji + VS16 at
453    /// width 2 (matching the Unicode spec).  Most terminals do NOT — they
454    /// render these at width 1 — so the default is `false`.
455    ///
456    /// Set `FTUI_EMOJI_VS16_WIDTH=unicode` (or `=2`) to opt in for terminals
457    /// that handle this correctly (WezTerm, Kitty, Ghostty).
458    #[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    /// Compute VS16 trust policy using a custom environment lookup (testable).
469    #[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    /// Cached VS16 width trust policy (fast path).
480    #[inline]
481    pub fn vs16_width_trusted() -> bool {
482        trust_vs16_width()
483    }
484
485    /// Strip U+FE0F (VS16) from a grapheme cluster.  Returns `None` if the
486    /// grapheme does not contain VS16 (no allocation needed).
487    #[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    /// Compute CJK width policy using a custom environment lookup.
497    #[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    /// Cached CJK width policy (fast path).
506    #[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    /// Fast-path width for pure printable ASCII.
525    ///
526    /// `None` means the run needs the real width tables, which covers both
527    /// non-ASCII bytes and ASCII control characters.
528    ///
529    /// With the `simd` feature this delegates to `ftui_simd::ascii_width`,
530    /// which is the same predicate over 64-byte vector chunks: measured at
531    /// 21x the scalar loop over a kilobyte and 5x over 64 bytes
532    /// (`docs/perf/simd_kernels_2026-09-18.md`). Both paths return identical
533    /// answers for identical input, which `ascii_width_matches_scalar` pins.
534    #[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    /// Scalar printable-ASCII width, and the reference the `simd` path is
548    /// tested against.
549    #[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    /// Capacity of the per-thread grapheme width cache (entries).
583    ///
584    /// 4096 distinct non-ASCII graphemes covers the working set of a busy
585    /// CJK/emoji screen many times over; S3-FIFO keeps one-off scans (a log
586    /// stream of unique emoji) from evicting the hot set.
587    const WIDTH_CACHE_CAPACITY: usize = 4096;
588
589    /// Bound retained key bytes even for arbitrarily long combining clusters.
590    /// Larger graphemes remain valid input and use the uncached width tables.
591    const WIDTH_CACHE_MAX_GRAPHEME_BYTES: usize = 128;
592
593    struct CachedGraphemeWidth {
594        grapheme: Box<str>,
595        width: usize,
596    }
597
598    /// Tracing target for width cache operations and misses.
599    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            // A matching hash is only a hit after exact byte verification.
641            // Collisions replace that hash's entry and count as width misses.
642            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    /// Whether the grapheme width cache is enabled (`FTUI_WIDTH_CACHE=0`,
655    /// `false`, `off`, or `no` disables it; anything else keeps it on).
656    #[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        /// Per-thread S3-FIFO cache from grapheme hash to display width.
673        ///
674        /// Non-ASCII width lookups (`unicode_display_width`, VS16 stripping,
675        /// zero-width scans) are the expensive part of measuring text; every
676        /// wrap, table column, and diff of a CJK or emoji screen repeats them
677        /// for the same handful of clusters. A hash selects the candidate;
678        /// exact cluster bytes decide whether its width can be reused.
679        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    /// Snapshot of the calling thread's grapheme width cache statistics,
694    /// or `None` when the cache is disabled.
695    ///
696    /// Hits require exact grapheme equality; hash collisions count as misses.
697    /// ASCII and clusters exceeding the retained-key byte limit bypass the
698    /// cache and do not contribute to either counter.
699    #[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    /// Drop every cached width on the calling thread (tests and benchmarks).
708    pub fn clear_width_cache() {
709        WIDTH_CACHE.with(|cache| cache.borrow_mut().clear());
710    }
711
712    /// Width of a single grapheme cluster.
713    ///
714    /// ASCII is answered inline; every other cluster goes through the
715    /// per-thread width cache (see [`width_cache_stats`]) in front of the
716    /// Unicode width tables.
717    #[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    /// Width of a non-ASCII grapheme cluster, computed from the Unicode
736    /// tables every time (the cached path in [`grapheme_width`] wraps this).
737    #[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    /// Width of a grapheme cluster with East Asian Ambiguous characters
744    /// double-width when `cjk` is set, instead of as the environment says.
745    ///
746    /// For callers with their own width policy, such as a search that reports
747    /// columns for a caller-chosen mode. Otherwise it measures exactly as
748    /// [`grapheme_width`] does.
749    #[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        // Terminal-realistic VS16 handling: most terminals render text-default
761        // emoji (Emoji_Presentation=No) at 1 cell even with VS16 appended.
762        // Strip VS16 so unicode_display_width returns the text-presentation width.
763        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    /// Width of a single Unicode scalar.
775    #[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    /// Width of a string in terminal cells.
796    #[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        // ── grapheme width cache ────────────────────────────────────
820
821        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            // Route distinct real clusters through the same production lookup
845            // with a deliberately colliding hash, independent of hash quality.
846            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                    // Six hashes pressure a four-entry cache; each hash also
869                    // has multiple byte-distinct graphemes competing for it.
870                    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        /// The cache must be invisible: cached answers equal the uncached
945        /// computation for every cluster, and repeated lookups are hits.
946        #[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        /// ASCII never touches the cache: its width is answered inline.
970        #[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        /// `display_width` over mixed text agrees with a from-scratch sum of
983        /// uncached grapheme widths, so the cache cannot change measurements.
984        #[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        // ── env helpers (testable without OnceLock) ─────────────────
1000
1001        #[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            // FTUI_GLYPH_DOUBLE_WIDTH=0 should override a CJK locale
1082            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        // ── VS16 trust from env ─────────────────────────────────────
1109
1110        #[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        // ── ascii_width fast path ───────────────────────────────────
1153
1154        #[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            // Tab (0x09) is outside 0x20..=0x7E
1177            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        // ── char_width ──────────────────────────────────────────────
1191
1192        #[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            // NUL (0x00) is an ASCII control char, zero width
1215            assert_eq!(char_width('\0'), 0);
1216        }
1217
1218        #[test]
1219        fn char_width_bell() {
1220            // BEL (0x07) is an ASCII control char, zero width
1221            assert_eq!(char_width('\x07'), 0);
1222        }
1223
1224        #[test]
1225        fn char_width_combining_accent() {
1226            // U+0301 COMBINING ACUTE ACCENT is zero-width
1227            assert_eq!(char_width('\u{0301}'), 0);
1228        }
1229
1230        #[test]
1231        fn char_width_zwj() {
1232            // U+200D ZERO WIDTH JOINER
1233            assert_eq!(char_width('\u{200D}'), 0);
1234        }
1235
1236        #[test]
1237        fn char_width_zwnbsp() {
1238            // U+FEFF ZERO WIDTH NO-BREAK SPACE
1239            assert_eq!(char_width('\u{FEFF}'), 0);
1240        }
1241
1242        #[test]
1243        fn char_width_soft_hyphen() {
1244            // U+00AD SOFT HYPHEN
1245            assert_eq!(char_width('\u{00AD}'), 0);
1246        }
1247
1248        #[test]
1249        fn char_width_wide_east_asian() {
1250            // '⚡' (U+26A1) has east_asian_width=W, always width 2
1251            assert_eq!(char_width('⚡'), 2);
1252        }
1253
1254        #[test]
1255        fn char_width_cjk_ideograph() {
1256            // CJK ideographs are always width 2
1257            assert_eq!(char_width('中'), 2);
1258        }
1259
1260        #[test]
1261        fn char_width_variation_selector() {
1262            // U+FE0F VARIATION SELECTOR-16 is zero-width
1263            assert_eq!(char_width('\u{FE0F}'), 0);
1264        }
1265
1266        // ── display_width ───────────────────────────────────────────
1267
1268        #[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            // Each CJK character is width 2
1281            assert_eq!(display_width("中文"), 4);
1282        }
1283
1284        #[test]
1285        fn display_width_mixed_ascii_cjk() {
1286            // 'a' = 1, '中' = 2, 'b' = 1
1287            assert_eq!(display_width("a中b"), 4);
1288        }
1289
1290        #[test]
1291        fn display_width_combining_chars() {
1292            // 'e' + combining acute = 1 grapheme, width 1
1293            assert_eq!(display_width("e\u{0301}"), 1);
1294        }
1295
1296        #[test]
1297        fn display_width_ascii_with_control_codes() {
1298            // Non-printable ASCII control chars in non-pure-ASCII path
1299            // Tab/newline/CR get width 1 via ascii_display_width
1300            assert_eq!(display_width("a\tb"), 3);
1301        }
1302
1303        // ── ascii_width ─────────────────────────────────────────────
1304
1305        /// Whichever path the `simd` feature selected must agree with the
1306        /// scalar reference, including on the boundaries either side of
1307        /// printable ASCII and past a full 64-byte vector chunk.
1308        #[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            // Lengths around one chunk, with a rejected byte at each end and
1326            // in the middle, so a chunked implementation cannot pass by
1327            // looking only at the head.
1328            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        // ── grapheme_width ──────────────────────────────────────────
1350
1351        #[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            // 'e' + combining accent is one grapheme, width 1
1364            assert_eq!(grapheme_width("e\u{0301}"), 1);
1365        }
1366
1367        #[test]
1368        fn grapheme_width_zwj_cluster() {
1369            // ZWJ alone is zero-width
1370            assert_eq!(grapheme_width("\u{200D}"), 0);
1371        }
1372    }
1373}