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
59pub mod shutdown_signal {
60    //! Process-wide graceful-termination signal state shared by runtime and backends.
61    //!
62    //! Signal handlers record the first pending termination signal here. The
63    //! runtime polls it, performs graceful teardown, then clears it to
64    //! acknowledge completion back to the signal thread.
65
66    use std::sync::{
67        Mutex, OnceLock,
68        atomic::{AtomicI32, Ordering},
69    };
70
71    static PENDING_TERMINATION_SIGNAL: AtomicI32 = AtomicI32::new(0);
72
73    /// Record that a termination signal was intercepted and graceful shutdown is required.
74    ///
75    /// The first pending signal wins until the runtime explicitly clears it
76    /// after finishing teardown.
77    pub fn record_pending_termination_signal(signal: i32) {
78        let _ = PENDING_TERMINATION_SIGNAL.compare_exchange(
79            0,
80            signal,
81            Ordering::SeqCst,
82            Ordering::SeqCst,
83        );
84    }
85
86    /// Inspect the currently pending termination signal, if any.
87    #[must_use]
88    pub fn pending_termination_signal() -> Option<i32> {
89        match PENDING_TERMINATION_SIGNAL.load(Ordering::SeqCst) {
90            0 => None,
91            signal => Some(signal),
92        }
93    }
94
95    /// Clear any pending graceful-termination request.
96    pub fn clear_pending_termination_signal() {
97        PENDING_TERMINATION_SIGNAL.store(0, Ordering::SeqCst);
98    }
99
100    /// Serialize tests that touch the process-global termination signal slot.
101    ///
102    /// This helper is intentionally exported so downstream workspace crates can
103    /// wrap signal-sensitive tests with the same lock. Without cross-crate
104    /// serialization, parallel test execution can clear the pending signal out
105    /// from under a runtime test and leave it blocked in the event loop.
106    #[doc(hidden)]
107    pub fn with_test_signal_serialization<R>(f: impl FnOnce() -> R) -> R {
108        static SIGNAL_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
109
110        let _guard = SIGNAL_TEST_LOCK
111            .get_or_init(|| Mutex::new(()))
112            .lock()
113            .expect("shutdown signal test lock poisoned");
114        clear_pending_termination_signal();
115        let result = f();
116        clear_pending_termination_signal();
117        result
118    }
119}
120
121#[cfg(feature = "caps-probe")]
122pub mod caps_probe;
123
124// Re-export tracing macros at crate root for ergonomic use.
125#[cfg(feature = "tracing")]
126pub use logging::{
127    debug, debug_span, error, error_span, info, info_span, trace, trace_span, warn, warn_span,
128};
129
130pub mod text_width {
131    //! Shared display width helpers for layout and rendering.
132    //!
133    //! This module centralizes glyph width calculation so layout (ftui-text)
134    //! and rendering (ftui-render) stay in lockstep. It intentionally avoids
135    //! ad-hoc emoji heuristics and relies on Unicode data tables.
136    //!
137    //! ## Emoji Width Handling
138    //!
139    //! Most terminals render **text-default** emoji (those with
140    //! `Emoji_Presentation=No`, like U+2764 RED HEART) at **width 1**, even
141    //! when a Variation Selector 16 (U+FE0F) is appended. The Unicode spec
142    //! says VS16 requests emoji presentation (width 2), but terminal reality
143    //! disagrees.
144    //!
145    //! **Default behavior** (`FTUI_EMOJI_VS16_WIDTH` unset):
146    //! - [`strip_vs16`] removes U+FE0F before width calculation.
147    //! - Text-default emoji render at width 1 (matching most terminals).
148    //! - Emoji with `Emoji_Presentation=Yes` (e.g. U+1F600) are unaffected
149    //!   — they are always width 2.
150    //!
151    //! **Opt-in** for terminals that correctly render VS16 at width 2
152    //! (WezTerm, Kitty, Ghostty):
153    //! ```text
154    //! FTUI_EMOJI_VS16_WIDTH=unicode   # or =2
155    //! ```
156    //!
157    //! The policy is read once at startup via [`OnceLock`]. Changing the env
158    //! var mid-process has no effect. See [`vs16_width_trusted`] and
159    //! [`vs16_trust_from_env`] for the API surface.
160
161    use std::sync::OnceLock;
162
163    use unicode_display_width::width as unicode_display_width;
164    use unicode_segmentation::UnicodeSegmentation;
165    use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
166
167    #[inline]
168    fn env_flag(value: &str) -> bool {
169        matches!(
170            value.trim().to_ascii_lowercase().as_str(),
171            "1" | "true" | "yes" | "on"
172        )
173    }
174
175    #[inline]
176    fn is_cjk_locale(locale: &str) -> bool {
177        let lower = locale.trim().to_ascii_lowercase();
178        lower.starts_with("ja") || lower.starts_with("zh") || lower.starts_with("ko")
179    }
180
181    #[inline]
182    fn cjk_width_from_env_impl<F>(get_env: F) -> bool
183    where
184        F: Fn(&str) -> Option<String>,
185    {
186        if let Some(value) = get_env("FTUI_GLYPH_DOUBLE_WIDTH") {
187            return env_flag(&value);
188        }
189        if let Some(value) = get_env("FTUI_TEXT_CJK_WIDTH").or_else(|| get_env("FTUI_CJK_WIDTH")) {
190            return env_flag(&value);
191        }
192        if let Some(locale) = get_env("LC_CTYPE").or_else(|| get_env("LANG")) {
193            return is_cjk_locale(&locale);
194        }
195        false
196    }
197
198    #[inline]
199    fn use_cjk_width() -> bool {
200        static CJK_WIDTH: OnceLock<bool> = OnceLock::new();
201        *CJK_WIDTH.get_or_init(|| cjk_width_from_env_impl(|key| std::env::var(key).ok()))
202    }
203
204    /// Whether the terminal is trusted to render text-default emoji + VS16 at
205    /// width 2 (matching the Unicode spec).  Most terminals do NOT — they
206    /// render these at width 1 — so the default is `false`.
207    ///
208    /// Set `FTUI_EMOJI_VS16_WIDTH=unicode` (or `=2`) to opt in for terminals
209    /// that handle this correctly (WezTerm, Kitty, Ghostty).
210    #[inline]
211    fn trust_vs16_width() -> bool {
212        static TRUST: OnceLock<bool> = OnceLock::new();
213        *TRUST.get_or_init(|| {
214            std::env::var("FTUI_EMOJI_VS16_WIDTH")
215                .map(|v| v.eq_ignore_ascii_case("unicode") || v == "2")
216                .unwrap_or(false)
217        })
218    }
219
220    /// Compute VS16 trust policy using a custom environment lookup (testable).
221    #[inline]
222    pub fn vs16_trust_from_env<F>(get_env: F) -> bool
223    where
224        F: Fn(&str) -> Option<String>,
225    {
226        get_env("FTUI_EMOJI_VS16_WIDTH")
227            .map(|v| v.eq_ignore_ascii_case("unicode") || v == "2")
228            .unwrap_or(false)
229    }
230
231    /// Cached VS16 width trust policy (fast path).
232    #[inline]
233    pub fn vs16_width_trusted() -> bool {
234        trust_vs16_width()
235    }
236
237    /// Strip U+FE0F (VS16) from a grapheme cluster.  Returns `None` if the
238    /// grapheme does not contain VS16 (no allocation needed).
239    #[inline]
240    fn strip_vs16(grapheme: &str) -> Option<String> {
241        if grapheme.contains('\u{FE0F}') {
242            Some(grapheme.chars().filter(|&c| c != '\u{FE0F}').collect())
243        } else {
244            None
245        }
246    }
247
248    /// Compute CJK width policy using a custom environment lookup.
249    #[inline]
250    pub fn cjk_width_from_env<F>(get_env: F) -> bool
251    where
252        F: Fn(&str) -> Option<String>,
253    {
254        cjk_width_from_env_impl(get_env)
255    }
256
257    /// Cached CJK width policy (fast path).
258    #[inline]
259    pub fn cjk_width_enabled() -> bool {
260        use_cjk_width()
261    }
262
263    #[inline]
264    fn ascii_display_width(text: &str) -> usize {
265        let mut width = 0;
266        for b in text.bytes() {
267            match b {
268                b'\t' | b'\n' | b'\r' => width += 1,
269                0x20..=0x7E => width += 1,
270                _ => {}
271            }
272        }
273        width
274    }
275
276    /// Fast-path width for pure printable ASCII.
277    #[inline]
278    #[must_use]
279    pub fn ascii_width(text: &str) -> Option<usize> {
280        if text.bytes().all(|b| (0x20..=0x7E).contains(&b)) {
281            Some(text.len())
282        } else {
283            None
284        }
285    }
286
287    #[inline]
288    fn is_zero_width_codepoint(c: char) -> bool {
289        let u = c as u32;
290        matches!(u, 0x0000..=0x001F | 0x007F..=0x009F)
291            || matches!(u, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF)
292            || matches!(u, 0xFE20..=0xFE2F)
293            || matches!(u, 0xFE00..=0xFE0F | 0xE0100..=0xE01EF)
294            || matches!(
295                u,
296                0x00AD
297                    | 0x034F
298                    | 0x180E
299                    | 0x200B
300                    | 0x200C
301                    | 0x200D
302                    | 0x200E
303                    | 0x200F
304                    | 0x2060
305                    | 0xFEFF
306            )
307            || matches!(u, 0x202A..=0x202E | 0x2066..=0x2069 | 0x206A..=0x206F)
308    }
309
310    /// Width of a single grapheme cluster.
311    #[inline]
312    #[must_use]
313    pub fn grapheme_width(grapheme: &str) -> usize {
314        if grapheme.is_ascii() {
315            return ascii_display_width(grapheme);
316        }
317        if grapheme.chars().all(is_zero_width_codepoint) {
318            return 0;
319        }
320        if use_cjk_width() {
321            return grapheme.width_cjk();
322        }
323        // Terminal-realistic VS16 handling: most terminals render text-default
324        // emoji (Emoji_Presentation=No) at 1 cell even with VS16 appended.
325        // Strip VS16 so unicode_display_width returns the text-presentation width.
326        if !trust_vs16_width()
327            && let Some(stripped) = strip_vs16(grapheme)
328        {
329            if stripped.is_empty() {
330                return 0;
331            }
332            return unicode_display_width(&stripped) as usize;
333        }
334        unicode_display_width(grapheme) as usize
335    }
336
337    /// Width of a single Unicode scalar.
338    #[inline]
339    #[must_use]
340    pub fn char_width(ch: char) -> usize {
341        if ch.is_ascii() {
342            return match ch {
343                '\t' | '\n' | '\r' => 1,
344                ' '..='~' => 1,
345                _ => 0,
346            };
347        }
348        if is_zero_width_codepoint(ch) {
349            return 0;
350        }
351        if use_cjk_width() {
352            ch.width_cjk().unwrap_or(0)
353        } else {
354            ch.width().unwrap_or(0)
355        }
356    }
357
358    /// Width of a string in terminal cells.
359    #[inline]
360    #[must_use]
361    pub fn display_width(text: &str) -> usize {
362        if let Some(width) = ascii_width(text) {
363            return width;
364        }
365        if text.is_ascii() {
366            return ascii_display_width(text);
367        }
368        let cjk_width = use_cjk_width();
369        if !text.chars().any(is_zero_width_codepoint) {
370            if cjk_width {
371                return text.width_cjk();
372            }
373            return unicode_display_width(text) as usize;
374        }
375        text.graphemes(true).map(grapheme_width).sum()
376    }
377
378    #[cfg(test)]
379    mod tests {
380        use super::*;
381
382        // ── env helpers (testable without OnceLock) ─────────────────
383
384        #[test]
385        fn cjk_width_env_explicit_true() {
386            let get = |key: &str| match key {
387                "FTUI_GLYPH_DOUBLE_WIDTH" => Some("1".into()),
388                _ => None,
389            };
390            assert!(cjk_width_from_env(get));
391        }
392
393        #[test]
394        fn cjk_width_env_explicit_false() {
395            let get = |key: &str| match key {
396                "FTUI_GLYPH_DOUBLE_WIDTH" => Some("0".into()),
397                _ => None,
398            };
399            assert!(!cjk_width_from_env(get));
400        }
401
402        #[test]
403        fn cjk_width_env_text_cjk_key() {
404            let get = |key: &str| match key {
405                "FTUI_TEXT_CJK_WIDTH" => Some("true".into()),
406                _ => None,
407            };
408            assert!(cjk_width_from_env(get));
409        }
410
411        #[test]
412        fn cjk_width_env_fallback_key() {
413            let get = |key: &str| match key {
414                "FTUI_CJK_WIDTH" => Some("yes".into()),
415                _ => None,
416            };
417            assert!(cjk_width_from_env(get));
418        }
419
420        #[test]
421        fn cjk_width_env_japanese_locale() {
422            let get = |key: &str| match key {
423                "LC_CTYPE" => Some("ja_JP.UTF-8".into()),
424                _ => None,
425            };
426            assert!(cjk_width_from_env(get));
427        }
428
429        #[test]
430        fn cjk_width_env_chinese_locale() {
431            let get = |key: &str| match key {
432                "LANG" => Some("zh_CN.UTF-8".into()),
433                _ => None,
434            };
435            assert!(cjk_width_from_env(get));
436        }
437
438        #[test]
439        fn cjk_width_env_korean_locale() {
440            let get = |key: &str| match key {
441                "LC_CTYPE" => Some("ko_KR.UTF-8".into()),
442                _ => None,
443            };
444            assert!(cjk_width_from_env(get));
445        }
446
447        #[test]
448        fn cjk_width_env_english_locale_returns_false() {
449            let get = |key: &str| match key {
450                "LANG" => Some("en_US.UTF-8".into()),
451                _ => None,
452            };
453            assert!(!cjk_width_from_env(get));
454        }
455
456        #[test]
457        fn cjk_width_env_no_vars_returns_false() {
458            let get = |_: &str| -> Option<String> { None };
459            assert!(!cjk_width_from_env(get));
460        }
461
462        #[test]
463        fn cjk_width_env_glyph_overrides_locale() {
464            // FTUI_GLYPH_DOUBLE_WIDTH=0 should override a CJK locale
465            let get = |key: &str| match key {
466                "FTUI_GLYPH_DOUBLE_WIDTH" => Some("0".into()),
467                "LANG" => Some("ja_JP.UTF-8".into()),
468                _ => None,
469            };
470            assert!(!cjk_width_from_env(get));
471        }
472
473        #[test]
474        fn cjk_width_env_on_is_true() {
475            let get = |key: &str| match key {
476                "FTUI_GLYPH_DOUBLE_WIDTH" => Some("on".into()),
477                _ => None,
478            };
479            assert!(cjk_width_from_env(get));
480        }
481
482        #[test]
483        fn cjk_width_env_case_insensitive() {
484            let get = |key: &str| match key {
485                "FTUI_CJK_WIDTH" => Some("TRUE".into()),
486                _ => None,
487            };
488            assert!(cjk_width_from_env(get));
489        }
490
491        // ── VS16 trust from env ─────────────────────────────────────
492
493        #[test]
494        fn vs16_trust_unicode_string() {
495            let get = |key: &str| match key {
496                "FTUI_EMOJI_VS16_WIDTH" => Some("unicode".into()),
497                _ => None,
498            };
499            assert!(vs16_trust_from_env(get));
500        }
501
502        #[test]
503        fn vs16_trust_value_2() {
504            let get = |key: &str| match key {
505                "FTUI_EMOJI_VS16_WIDTH" => Some("2".into()),
506                _ => None,
507            };
508            assert!(vs16_trust_from_env(get));
509        }
510
511        #[test]
512        fn vs16_trust_not_set() {
513            let get = |_: &str| -> Option<String> { None };
514            assert!(!vs16_trust_from_env(get));
515        }
516
517        #[test]
518        fn vs16_trust_other_value() {
519            let get = |key: &str| match key {
520                "FTUI_EMOJI_VS16_WIDTH" => Some("1".into()),
521                _ => None,
522            };
523            assert!(!vs16_trust_from_env(get));
524        }
525
526        #[test]
527        fn vs16_trust_case_insensitive() {
528            let get = |key: &str| match key {
529                "FTUI_EMOJI_VS16_WIDTH" => Some("UNICODE".into()),
530                _ => None,
531            };
532            assert!(vs16_trust_from_env(get));
533        }
534
535        // ── ascii_width fast path ───────────────────────────────────
536
537        #[test]
538        fn ascii_width_pure_ascii() {
539            assert_eq!(ascii_width("hello"), Some(5));
540        }
541
542        #[test]
543        fn ascii_width_empty() {
544            assert_eq!(ascii_width(""), Some(0));
545        }
546
547        #[test]
548        fn ascii_width_with_space() {
549            assert_eq!(ascii_width("hello world"), Some(11));
550        }
551
552        #[test]
553        fn ascii_width_non_ascii_returns_none() {
554            assert_eq!(ascii_width("héllo"), None);
555        }
556
557        #[test]
558        fn ascii_width_with_tab_returns_none() {
559            // Tab (0x09) is outside 0x20..=0x7E
560            assert_eq!(ascii_width("hello\tworld"), None);
561        }
562
563        #[test]
564        fn ascii_width_with_newline_returns_none() {
565            assert_eq!(ascii_width("hello\n"), None);
566        }
567
568        #[test]
569        fn ascii_width_control_char_returns_none() {
570            assert_eq!(ascii_width("\x01"), None);
571        }
572
573        // ── char_width ──────────────────────────────────────────────
574
575        #[test]
576        fn char_width_ascii_letter() {
577            assert_eq!(char_width('A'), 1);
578        }
579
580        #[test]
581        fn char_width_space() {
582            assert_eq!(char_width(' '), 1);
583        }
584
585        #[test]
586        fn char_width_tab() {
587            assert_eq!(char_width('\t'), 1);
588        }
589
590        #[test]
591        fn char_width_newline() {
592            assert_eq!(char_width('\n'), 1);
593        }
594
595        #[test]
596        fn char_width_nul() {
597            // NUL (0x00) is an ASCII control char, zero width
598            assert_eq!(char_width('\0'), 0);
599        }
600
601        #[test]
602        fn char_width_bell() {
603            // BEL (0x07) is an ASCII control char, zero width
604            assert_eq!(char_width('\x07'), 0);
605        }
606
607        #[test]
608        fn char_width_combining_accent() {
609            // U+0301 COMBINING ACUTE ACCENT is zero-width
610            assert_eq!(char_width('\u{0301}'), 0);
611        }
612
613        #[test]
614        fn char_width_zwj() {
615            // U+200D ZERO WIDTH JOINER
616            assert_eq!(char_width('\u{200D}'), 0);
617        }
618
619        #[test]
620        fn char_width_zwnbsp() {
621            // U+FEFF ZERO WIDTH NO-BREAK SPACE
622            assert_eq!(char_width('\u{FEFF}'), 0);
623        }
624
625        #[test]
626        fn char_width_soft_hyphen() {
627            // U+00AD SOFT HYPHEN
628            assert_eq!(char_width('\u{00AD}'), 0);
629        }
630
631        #[test]
632        fn char_width_wide_east_asian() {
633            // '⚡' (U+26A1) has east_asian_width=W, always width 2
634            assert_eq!(char_width('⚡'), 2);
635        }
636
637        #[test]
638        fn char_width_cjk_ideograph() {
639            // CJK ideographs are always width 2
640            assert_eq!(char_width('中'), 2);
641        }
642
643        #[test]
644        fn char_width_variation_selector() {
645            // U+FE0F VARIATION SELECTOR-16 is zero-width
646            assert_eq!(char_width('\u{FE0F}'), 0);
647        }
648
649        // ── display_width ───────────────────────────────────────────
650
651        #[test]
652        fn display_width_ascii() {
653            assert_eq!(display_width("hello"), 5);
654        }
655
656        #[test]
657        fn display_width_empty() {
658            assert_eq!(display_width(""), 0);
659        }
660
661        #[test]
662        fn display_width_cjk_chars() {
663            // Each CJK character is width 2
664            assert_eq!(display_width("中文"), 4);
665        }
666
667        #[test]
668        fn display_width_mixed_ascii_cjk() {
669            // 'a' = 1, '中' = 2, 'b' = 1
670            assert_eq!(display_width("a中b"), 4);
671        }
672
673        #[test]
674        fn display_width_combining_chars() {
675            // 'e' + combining acute = 1 grapheme, width 1
676            assert_eq!(display_width("e\u{0301}"), 1);
677        }
678
679        #[test]
680        fn display_width_ascii_with_control_codes() {
681            // Non-printable ASCII control chars in non-pure-ASCII path
682            // Tab/newline/CR get width 1 via ascii_display_width
683            assert_eq!(display_width("a\tb"), 3);
684        }
685
686        // ── grapheme_width ──────────────────────────────────────────
687
688        #[test]
689        fn grapheme_width_ascii_char() {
690            assert_eq!(grapheme_width("A"), 1);
691        }
692
693        #[test]
694        fn grapheme_width_cjk_ideograph() {
695            assert_eq!(grapheme_width("中"), 2);
696        }
697
698        #[test]
699        fn grapheme_width_combining_sequence() {
700            // 'e' + combining accent is one grapheme, width 1
701            assert_eq!(grapheme_width("e\u{0301}"), 1);
702        }
703
704        #[test]
705        fn grapheme_width_zwj_cluster() {
706            // ZWJ alone is zero-width
707            assert_eq!(grapheme_width("\u{200D}"), 0);
708        }
709    }
710}