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