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 event;
27pub mod event_coalescer;
28pub mod geometry;
29pub mod gesture;
30pub mod glyph_policy;
31pub mod hover_stabilizer;
32pub mod inline_mode;
33pub mod input_parser;
34pub mod key_sequence;
35pub mod keybinding;
36pub mod logging;
37pub mod mux_passthrough;
38pub mod semantic_event;
39pub mod terminal_capabilities;
40pub mod terminal_session;
41
42#[cfg(feature = "caps-probe")]
43pub mod caps_probe;
44
45// Re-export tracing macros at crate root for ergonomic use.
46#[cfg(feature = "tracing")]
47pub use logging::{
48    debug, debug_span, error, error_span, info, info_span, trace, trace_span, warn, warn_span,
49};
50
51pub mod text_width {
52    //! Shared display width helpers for layout and rendering.
53    //!
54    //! This module centralizes glyph width calculation so layout (ftui-text)
55    //! and rendering (ftui-render) stay in lockstep. It intentionally avoids
56    //! ad-hoc emoji heuristics and relies on Unicode data tables.
57
58    use std::sync::OnceLock;
59
60    use unicode_display_width::width as unicode_display_width;
61    use unicode_segmentation::UnicodeSegmentation;
62    use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
63
64    #[inline]
65    fn env_flag(value: &str) -> bool {
66        matches!(
67            value.trim().to_ascii_lowercase().as_str(),
68            "1" | "true" | "yes" | "on"
69        )
70    }
71
72    #[inline]
73    fn is_cjk_locale(locale: &str) -> bool {
74        let lower = locale.trim().to_ascii_lowercase();
75        lower.starts_with("ja") || lower.starts_with("zh") || lower.starts_with("ko")
76    }
77
78    #[inline]
79    fn cjk_width_from_env_impl<F>(get_env: F) -> bool
80    where
81        F: Fn(&str) -> Option<String>,
82    {
83        if let Some(value) = get_env("FTUI_TEXT_CJK_WIDTH").or_else(|| get_env("FTUI_CJK_WIDTH")) {
84            return env_flag(&value);
85        }
86        if let Some(locale) = get_env("LC_CTYPE").or_else(|| get_env("LANG")) {
87            return is_cjk_locale(&locale);
88        }
89        false
90    }
91
92    #[inline]
93    fn use_cjk_width() -> bool {
94        static CJK_WIDTH: OnceLock<bool> = OnceLock::new();
95        *CJK_WIDTH.get_or_init(|| cjk_width_from_env_impl(|key| std::env::var(key).ok()))
96    }
97
98    /// Compute CJK width policy using a custom environment lookup.
99    #[inline]
100    pub fn cjk_width_from_env<F>(get_env: F) -> bool
101    where
102        F: Fn(&str) -> Option<String>,
103    {
104        cjk_width_from_env_impl(get_env)
105    }
106
107    /// Cached CJK width policy (fast path).
108    #[inline]
109    pub fn cjk_width_enabled() -> bool {
110        use_cjk_width()
111    }
112
113    #[inline]
114    fn ascii_display_width(text: &str) -> usize {
115        let mut width = 0;
116        for b in text.bytes() {
117            match b {
118                b'\t' | b'\n' | b'\r' => width += 1,
119                0x20..=0x7E => width += 1,
120                _ => {}
121            }
122        }
123        width
124    }
125
126    /// Fast-path width for pure printable ASCII.
127    #[inline]
128    #[must_use]
129    pub fn ascii_width(text: &str) -> Option<usize> {
130        if text.bytes().all(|b| (0x20..=0x7E).contains(&b)) {
131            Some(text.len())
132        } else {
133            None
134        }
135    }
136
137    #[inline]
138    fn is_zero_width_codepoint(c: char) -> bool {
139        let u = c as u32;
140        matches!(u, 0x0000..=0x001F | 0x007F..=0x009F)
141            || matches!(u, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF)
142            || matches!(u, 0xFE20..=0xFE2F)
143            || matches!(u, 0xFE00..=0xFE0F | 0xE0100..=0xE01EF)
144            || matches!(
145                u,
146                0x00AD
147                    | 0x034F
148                    | 0x180E
149                    | 0x200B
150                    | 0x200C
151                    | 0x200D
152                    | 0x200E
153                    | 0x200F
154                    | 0x2060
155                    | 0xFEFF
156            )
157            || matches!(u, 0x202A..=0x202E | 0x2066..=0x2069 | 0x206A..=0x206F)
158    }
159
160    /// Width of a single grapheme cluster.
161    #[inline]
162    #[must_use]
163    pub fn grapheme_width(grapheme: &str) -> usize {
164        if grapheme.is_ascii() {
165            return ascii_display_width(grapheme);
166        }
167        if grapheme.chars().all(is_zero_width_codepoint) {
168            return 0;
169        }
170        if use_cjk_width() {
171            return grapheme.width_cjk();
172        }
173        unicode_display_width(grapheme) as usize
174    }
175
176    /// Width of a single Unicode scalar.
177    #[inline]
178    #[must_use]
179    pub fn char_width(ch: char) -> usize {
180        if ch.is_ascii() {
181            return match ch {
182                '\t' | '\n' | '\r' => 1,
183                ' '..='~' => 1,
184                _ => 0,
185            };
186        }
187        if is_zero_width_codepoint(ch) {
188            return 0;
189        }
190        if use_cjk_width() {
191            ch.width_cjk().unwrap_or(0)
192        } else {
193            ch.width().unwrap_or(0)
194        }
195    }
196
197    /// Width of a string in terminal cells.
198    #[inline]
199    #[must_use]
200    pub fn display_width(text: &str) -> usize {
201        if let Some(width) = ascii_width(text) {
202            return width;
203        }
204        if text.is_ascii() {
205            return ascii_display_width(text);
206        }
207        let cjk_width = use_cjk_width();
208        if !text.chars().any(is_zero_width_codepoint) {
209            if cjk_width {
210                return text.width_cjk();
211            }
212            return unicode_display_width(text) as usize;
213        }
214        text.graphemes(true).map(grapheme_width).sum()
215    }
216}