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 geometry;
30pub mod gesture;
31pub mod glyph_policy;
32pub mod hover_stabilizer;
33pub mod inline_mode;
34pub mod input_parser;
35pub mod key_sequence;
36pub mod keybinding;
37pub mod logging;
38pub mod mux_passthrough;
39pub mod read_optimized;
40pub mod s3_fifo;
41pub mod semantic_event;
42pub mod terminal_capabilities;
43#[cfg(all(not(target_arch = "wasm32"), feature = "crossterm"))]
44pub mod terminal_session;
45
46#[cfg(feature = "caps-probe")]
47pub mod caps_probe;
48
49// Re-export tracing macros at crate root for ergonomic use.
50#[cfg(feature = "tracing")]
51pub use logging::{
52    debug, debug_span, error, error_span, info, info_span, trace, trace_span, warn, warn_span,
53};
54
55pub mod text_width {
56    //! Shared display width helpers for layout and rendering.
57    //!
58    //! This module centralizes glyph width calculation so layout (ftui-text)
59    //! and rendering (ftui-render) stay in lockstep. It intentionally avoids
60    //! ad-hoc emoji heuristics and relies on Unicode data tables.
61
62    use std::sync::OnceLock;
63
64    use unicode_display_width::width as unicode_display_width;
65    use unicode_segmentation::UnicodeSegmentation;
66    use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
67
68    #[inline]
69    fn env_flag(value: &str) -> bool {
70        matches!(
71            value.trim().to_ascii_lowercase().as_str(),
72            "1" | "true" | "yes" | "on"
73        )
74    }
75
76    #[inline]
77    fn is_cjk_locale(locale: &str) -> bool {
78        let lower = locale.trim().to_ascii_lowercase();
79        lower.starts_with("ja") || lower.starts_with("zh") || lower.starts_with("ko")
80    }
81
82    #[inline]
83    fn cjk_width_from_env_impl<F>(get_env: F) -> bool
84    where
85        F: Fn(&str) -> Option<String>,
86    {
87        if let Some(value) = get_env("FTUI_GLYPH_DOUBLE_WIDTH") {
88            return env_flag(&value);
89        }
90        if let Some(value) = get_env("FTUI_TEXT_CJK_WIDTH").or_else(|| get_env("FTUI_CJK_WIDTH")) {
91            return env_flag(&value);
92        }
93        if let Some(locale) = get_env("LC_CTYPE").or_else(|| get_env("LANG")) {
94            return is_cjk_locale(&locale);
95        }
96        false
97    }
98
99    #[inline]
100    fn use_cjk_width() -> bool {
101        static CJK_WIDTH: OnceLock<bool> = OnceLock::new();
102        *CJK_WIDTH.get_or_init(|| cjk_width_from_env_impl(|key| std::env::var(key).ok()))
103    }
104
105    /// Whether the terminal is trusted to render text-default emoji + VS16 at
106    /// width 2 (matching the Unicode spec).  Most terminals do NOT — they
107    /// render these at width 1 — so the default is `false`.
108    ///
109    /// Set `FTUI_EMOJI_VS16_WIDTH=unicode` (or `=2`) to opt in for terminals
110    /// that handle this correctly (WezTerm, Kitty, Ghostty).
111    #[inline]
112    fn trust_vs16_width() -> bool {
113        static TRUST: OnceLock<bool> = OnceLock::new();
114        *TRUST.get_or_init(|| {
115            std::env::var("FTUI_EMOJI_VS16_WIDTH")
116                .map(|v| v.eq_ignore_ascii_case("unicode") || v == "2")
117                .unwrap_or(false)
118        })
119    }
120
121    /// Compute VS16 trust policy using a custom environment lookup (testable).
122    #[inline]
123    pub fn vs16_trust_from_env<F>(get_env: F) -> bool
124    where
125        F: Fn(&str) -> Option<String>,
126    {
127        get_env("FTUI_EMOJI_VS16_WIDTH")
128            .map(|v| v.eq_ignore_ascii_case("unicode") || v == "2")
129            .unwrap_or(false)
130    }
131
132    /// Cached VS16 width trust policy (fast path).
133    #[inline]
134    pub fn vs16_width_trusted() -> bool {
135        trust_vs16_width()
136    }
137
138    /// Strip U+FE0F (VS16) from a grapheme cluster.  Returns `None` if the
139    /// grapheme does not contain VS16 (no allocation needed).
140    #[inline]
141    fn strip_vs16(grapheme: &str) -> Option<String> {
142        if grapheme.contains('\u{FE0F}') {
143            Some(grapheme.chars().filter(|&c| c != '\u{FE0F}').collect())
144        } else {
145            None
146        }
147    }
148
149    /// Compute CJK width policy using a custom environment lookup.
150    #[inline]
151    pub fn cjk_width_from_env<F>(get_env: F) -> bool
152    where
153        F: Fn(&str) -> Option<String>,
154    {
155        cjk_width_from_env_impl(get_env)
156    }
157
158    /// Cached CJK width policy (fast path).
159    #[inline]
160    pub fn cjk_width_enabled() -> bool {
161        use_cjk_width()
162    }
163
164    #[inline]
165    fn ascii_display_width(text: &str) -> usize {
166        let mut width = 0;
167        for b in text.bytes() {
168            match b {
169                b'\t' | b'\n' | b'\r' => width += 1,
170                0x20..=0x7E => width += 1,
171                _ => {}
172            }
173        }
174        width
175    }
176
177    /// Fast-path width for pure printable ASCII.
178    #[inline]
179    #[must_use]
180    pub fn ascii_width(text: &str) -> Option<usize> {
181        if text.bytes().all(|b| (0x20..=0x7E).contains(&b)) {
182            Some(text.len())
183        } else {
184            None
185        }
186    }
187
188    #[inline]
189    fn is_zero_width_codepoint(c: char) -> bool {
190        let u = c as u32;
191        matches!(u, 0x0000..=0x001F | 0x007F..=0x009F)
192            || matches!(u, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF)
193            || matches!(u, 0xFE20..=0xFE2F)
194            || matches!(u, 0xFE00..=0xFE0F | 0xE0100..=0xE01EF)
195            || matches!(
196                u,
197                0x00AD
198                    | 0x034F
199                    | 0x180E
200                    | 0x200B
201                    | 0x200C
202                    | 0x200D
203                    | 0x200E
204                    | 0x200F
205                    | 0x2060
206                    | 0xFEFF
207            )
208            || matches!(u, 0x202A..=0x202E | 0x2066..=0x2069 | 0x206A..=0x206F)
209    }
210
211    /// Width of a single grapheme cluster.
212    #[inline]
213    #[must_use]
214    pub fn grapheme_width(grapheme: &str) -> usize {
215        if grapheme.is_ascii() {
216            return ascii_display_width(grapheme);
217        }
218        if grapheme.chars().all(is_zero_width_codepoint) {
219            return 0;
220        }
221        if use_cjk_width() {
222            return grapheme.width_cjk();
223        }
224        // Terminal-realistic VS16 handling: most terminals render text-default
225        // emoji (Emoji_Presentation=No) at 1 cell even with VS16 appended.
226        // Strip VS16 so unicode_display_width returns the text-presentation width.
227        if !trust_vs16_width()
228            && let Some(stripped) = strip_vs16(grapheme)
229        {
230            if stripped.is_empty() {
231                return 0;
232            }
233            return unicode_display_width(&stripped) as usize;
234        }
235        unicode_display_width(grapheme) as usize
236    }
237
238    /// Width of a single Unicode scalar.
239    #[inline]
240    #[must_use]
241    pub fn char_width(ch: char) -> usize {
242        if ch.is_ascii() {
243            return match ch {
244                '\t' | '\n' | '\r' => 1,
245                ' '..='~' => 1,
246                _ => 0,
247            };
248        }
249        if is_zero_width_codepoint(ch) {
250            return 0;
251        }
252        if use_cjk_width() {
253            ch.width_cjk().unwrap_or(0)
254        } else {
255            ch.width().unwrap_or(0)
256        }
257    }
258
259    /// Width of a string in terminal cells.
260    #[inline]
261    #[must_use]
262    pub fn display_width(text: &str) -> usize {
263        if let Some(width) = ascii_width(text) {
264            return width;
265        }
266        if text.is_ascii() {
267            return ascii_display_width(text);
268        }
269        let cjk_width = use_cjk_width();
270        if !text.chars().any(is_zero_width_codepoint) {
271            if cjk_width {
272                return text.width_cjk();
273            }
274            return unicode_display_width(text) as usize;
275        }
276        text.graphemes(true).map(grapheme_width).sum()
277    }
278
279    #[cfg(test)]
280    mod tests {
281        use super::*;
282
283        // ── env helpers (testable without OnceLock) ─────────────────
284
285        #[test]
286        fn cjk_width_env_explicit_true() {
287            let get = |key: &str| match key {
288                "FTUI_GLYPH_DOUBLE_WIDTH" => Some("1".into()),
289                _ => None,
290            };
291            assert!(cjk_width_from_env(get));
292        }
293
294        #[test]
295        fn cjk_width_env_explicit_false() {
296            let get = |key: &str| match key {
297                "FTUI_GLYPH_DOUBLE_WIDTH" => Some("0".into()),
298                _ => None,
299            };
300            assert!(!cjk_width_from_env(get));
301        }
302
303        #[test]
304        fn cjk_width_env_text_cjk_key() {
305            let get = |key: &str| match key {
306                "FTUI_TEXT_CJK_WIDTH" => Some("true".into()),
307                _ => None,
308            };
309            assert!(cjk_width_from_env(get));
310        }
311
312        #[test]
313        fn cjk_width_env_fallback_key() {
314            let get = |key: &str| match key {
315                "FTUI_CJK_WIDTH" => Some("yes".into()),
316                _ => None,
317            };
318            assert!(cjk_width_from_env(get));
319        }
320
321        #[test]
322        fn cjk_width_env_japanese_locale() {
323            let get = |key: &str| match key {
324                "LC_CTYPE" => Some("ja_JP.UTF-8".into()),
325                _ => None,
326            };
327            assert!(cjk_width_from_env(get));
328        }
329
330        #[test]
331        fn cjk_width_env_chinese_locale() {
332            let get = |key: &str| match key {
333                "LANG" => Some("zh_CN.UTF-8".into()),
334                _ => None,
335            };
336            assert!(cjk_width_from_env(get));
337        }
338
339        #[test]
340        fn cjk_width_env_korean_locale() {
341            let get = |key: &str| match key {
342                "LC_CTYPE" => Some("ko_KR.UTF-8".into()),
343                _ => None,
344            };
345            assert!(cjk_width_from_env(get));
346        }
347
348        #[test]
349        fn cjk_width_env_english_locale_returns_false() {
350            let get = |key: &str| match key {
351                "LANG" => Some("en_US.UTF-8".into()),
352                _ => None,
353            };
354            assert!(!cjk_width_from_env(get));
355        }
356
357        #[test]
358        fn cjk_width_env_no_vars_returns_false() {
359            let get = |_: &str| -> Option<String> { None };
360            assert!(!cjk_width_from_env(get));
361        }
362
363        #[test]
364        fn cjk_width_env_glyph_overrides_locale() {
365            // FTUI_GLYPH_DOUBLE_WIDTH=0 should override a CJK locale
366            let get = |key: &str| match key {
367                "FTUI_GLYPH_DOUBLE_WIDTH" => Some("0".into()),
368                "LANG" => Some("ja_JP.UTF-8".into()),
369                _ => None,
370            };
371            assert!(!cjk_width_from_env(get));
372        }
373
374        #[test]
375        fn cjk_width_env_on_is_true() {
376            let get = |key: &str| match key {
377                "FTUI_GLYPH_DOUBLE_WIDTH" => Some("on".into()),
378                _ => None,
379            };
380            assert!(cjk_width_from_env(get));
381        }
382
383        #[test]
384        fn cjk_width_env_case_insensitive() {
385            let get = |key: &str| match key {
386                "FTUI_CJK_WIDTH" => Some("TRUE".into()),
387                _ => None,
388            };
389            assert!(cjk_width_from_env(get));
390        }
391
392        // ── VS16 trust from env ─────────────────────────────────────
393
394        #[test]
395        fn vs16_trust_unicode_string() {
396            let get = |key: &str| match key {
397                "FTUI_EMOJI_VS16_WIDTH" => Some("unicode".into()),
398                _ => None,
399            };
400            assert!(vs16_trust_from_env(get));
401        }
402
403        #[test]
404        fn vs16_trust_value_2() {
405            let get = |key: &str| match key {
406                "FTUI_EMOJI_VS16_WIDTH" => Some("2".into()),
407                _ => None,
408            };
409            assert!(vs16_trust_from_env(get));
410        }
411
412        #[test]
413        fn vs16_trust_not_set() {
414            let get = |_: &str| -> Option<String> { None };
415            assert!(!vs16_trust_from_env(get));
416        }
417
418        #[test]
419        fn vs16_trust_other_value() {
420            let get = |key: &str| match key {
421                "FTUI_EMOJI_VS16_WIDTH" => Some("1".into()),
422                _ => None,
423            };
424            assert!(!vs16_trust_from_env(get));
425        }
426
427        #[test]
428        fn vs16_trust_case_insensitive() {
429            let get = |key: &str| match key {
430                "FTUI_EMOJI_VS16_WIDTH" => Some("UNICODE".into()),
431                _ => None,
432            };
433            assert!(vs16_trust_from_env(get));
434        }
435
436        // ── ascii_width fast path ───────────────────────────────────
437
438        #[test]
439        fn ascii_width_pure_ascii() {
440            assert_eq!(ascii_width("hello"), Some(5));
441        }
442
443        #[test]
444        fn ascii_width_empty() {
445            assert_eq!(ascii_width(""), Some(0));
446        }
447
448        #[test]
449        fn ascii_width_with_space() {
450            assert_eq!(ascii_width("hello world"), Some(11));
451        }
452
453        #[test]
454        fn ascii_width_non_ascii_returns_none() {
455            assert_eq!(ascii_width("héllo"), None);
456        }
457
458        #[test]
459        fn ascii_width_with_tab_returns_none() {
460            // Tab (0x09) is outside 0x20..=0x7E
461            assert_eq!(ascii_width("hello\tworld"), None);
462        }
463
464        #[test]
465        fn ascii_width_with_newline_returns_none() {
466            assert_eq!(ascii_width("hello\n"), None);
467        }
468
469        #[test]
470        fn ascii_width_control_char_returns_none() {
471            assert_eq!(ascii_width("\x01"), None);
472        }
473
474        // ── char_width ──────────────────────────────────────────────
475
476        #[test]
477        fn char_width_ascii_letter() {
478            assert_eq!(char_width('A'), 1);
479        }
480
481        #[test]
482        fn char_width_space() {
483            assert_eq!(char_width(' '), 1);
484        }
485
486        #[test]
487        fn char_width_tab() {
488            assert_eq!(char_width('\t'), 1);
489        }
490
491        #[test]
492        fn char_width_newline() {
493            assert_eq!(char_width('\n'), 1);
494        }
495
496        #[test]
497        fn char_width_nul() {
498            // NUL (0x00) is an ASCII control char, zero width
499            assert_eq!(char_width('\0'), 0);
500        }
501
502        #[test]
503        fn char_width_bell() {
504            // BEL (0x07) is an ASCII control char, zero width
505            assert_eq!(char_width('\x07'), 0);
506        }
507
508        #[test]
509        fn char_width_combining_accent() {
510            // U+0301 COMBINING ACUTE ACCENT is zero-width
511            assert_eq!(char_width('\u{0301}'), 0);
512        }
513
514        #[test]
515        fn char_width_zwj() {
516            // U+200D ZERO WIDTH JOINER
517            assert_eq!(char_width('\u{200D}'), 0);
518        }
519
520        #[test]
521        fn char_width_zwnbsp() {
522            // U+FEFF ZERO WIDTH NO-BREAK SPACE
523            assert_eq!(char_width('\u{FEFF}'), 0);
524        }
525
526        #[test]
527        fn char_width_soft_hyphen() {
528            // U+00AD SOFT HYPHEN
529            assert_eq!(char_width('\u{00AD}'), 0);
530        }
531
532        #[test]
533        fn char_width_wide_east_asian() {
534            // '⚡' (U+26A1) has east_asian_width=W, always width 2
535            assert_eq!(char_width('⚡'), 2);
536        }
537
538        #[test]
539        fn char_width_cjk_ideograph() {
540            // CJK ideographs are always width 2
541            assert_eq!(char_width('中'), 2);
542        }
543
544        #[test]
545        fn char_width_variation_selector() {
546            // U+FE0F VARIATION SELECTOR-16 is zero-width
547            assert_eq!(char_width('\u{FE0F}'), 0);
548        }
549
550        // ── display_width ───────────────────────────────────────────
551
552        #[test]
553        fn display_width_ascii() {
554            assert_eq!(display_width("hello"), 5);
555        }
556
557        #[test]
558        fn display_width_empty() {
559            assert_eq!(display_width(""), 0);
560        }
561
562        #[test]
563        fn display_width_cjk_chars() {
564            // Each CJK character is width 2
565            assert_eq!(display_width("中文"), 4);
566        }
567
568        #[test]
569        fn display_width_mixed_ascii_cjk() {
570            // 'a' = 1, '中' = 2, 'b' = 1
571            assert_eq!(display_width("a中b"), 4);
572        }
573
574        #[test]
575        fn display_width_combining_chars() {
576            // 'e' + combining acute = 1 grapheme, width 1
577            assert_eq!(display_width("e\u{0301}"), 1);
578        }
579
580        #[test]
581        fn display_width_ascii_with_control_codes() {
582            // Non-printable ASCII control chars in non-pure-ASCII path
583            // Tab/newline/CR get width 1 via ascii_display_width
584            assert_eq!(display_width("a\tb"), 3);
585        }
586
587        // ── grapheme_width ──────────────────────────────────────────
588
589        #[test]
590        fn grapheme_width_ascii_char() {
591            assert_eq!(grapheme_width("A"), 1);
592        }
593
594        #[test]
595        fn grapheme_width_cjk_ideograph() {
596            assert_eq!(grapheme_width("中"), 2);
597        }
598
599        #[test]
600        fn grapheme_width_combining_sequence() {
601            // 'e' + combining accent is one grapheme, width 1
602            assert_eq!(grapheme_width("e\u{0301}"), 1);
603        }
604
605        #[test]
606        fn grapheme_width_zwj_cluster() {
607            // ZWJ alone is zero-width
608            assert_eq!(grapheme_width("\u{200D}"), 0);
609        }
610    }
611}