Skip to main content

ftui_render/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Render kernel: cells, buffers, diffs, and ANSI presentation.
4//!
5//! # Role in FrankenTUI
6//! `ftui-render` is the deterministic rendering engine. It turns a logical
7//! `Frame` into a `Buffer`, computes diffs, and emits minimal ANSI output via
8//! the `Presenter`.
9//!
10//! # Primary responsibilities
11//! - **Cell/Buffer**: 2D grid with fixed-size cells and scissor/opacity stacks.
12//! - **BufferDiff**: efficient change detection between frames.
13//! - **Presenter**: stateful ANSI emitter with cursor/mode tracking.
14//! - **Frame**: rendering surface used by widgets and application views.
15//!
16//! # How it fits in the system
17//! `ftui-runtime` calls your model's `view()` to render into a `Frame`. That
18//! frame becomes a `Buffer`, which is diffed and presented to the terminal via
19//! `TerminalWriter`. This crate is the kernel of FrankenTUI's flicker-free,
20//! deterministic output guarantees.
21
22pub mod alloc_budget;
23pub mod ansi;
24pub mod budget;
25pub mod buffer;
26pub mod cell;
27pub mod counting_writer;
28pub mod diff;
29pub mod diff_strategy;
30pub mod drawing;
31pub mod frame;
32pub mod grapheme_pool;
33pub mod headless;
34pub mod link_registry;
35pub mod presenter;
36pub mod sanitize;
37pub mod spatial_hit_index;
38pub mod terminal_model;
39
40mod text_width {
41    use std::sync::OnceLock;
42
43    use unicode_display_width::width as unicode_display_width;
44    use unicode_segmentation::UnicodeSegmentation;
45    use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
46
47    #[inline]
48    fn env_flag(value: &str) -> bool {
49        matches!(
50            value.trim().to_ascii_lowercase().as_str(),
51            "1" | "true" | "yes" | "on"
52        )
53    }
54
55    #[inline]
56    fn is_cjk_locale(locale: &str) -> bool {
57        let lower = locale.trim().to_ascii_lowercase();
58        lower.starts_with("ja") || lower.starts_with("zh") || lower.starts_with("ko")
59    }
60
61    #[inline]
62    fn use_cjk_width() -> bool {
63        static CJK_WIDTH: OnceLock<bool> = OnceLock::new();
64        *CJK_WIDTH.get_or_init(|| {
65            if let Ok(value) =
66                std::env::var("FTUI_TEXT_CJK_WIDTH").or_else(|_| std::env::var("FTUI_CJK_WIDTH"))
67            {
68                return env_flag(&value);
69            }
70            if let Ok(locale) = std::env::var("LC_CTYPE").or_else(|_| std::env::var("LANG")) {
71                return is_cjk_locale(&locale);
72            }
73            false
74        })
75    }
76
77    #[inline]
78    fn ascii_display_width(text: &str) -> usize {
79        let mut width = 0;
80        for b in text.bytes() {
81            match b {
82                b'\t' | b'\n' | b'\r' => width += 1,
83                0x20..=0x7E => width += 1,
84                _ => {}
85            }
86        }
87        width
88    }
89
90    #[inline]
91    fn ascii_width(text: &str) -> Option<usize> {
92        if text.bytes().all(|b| (0x20..=0x7E).contains(&b)) {
93            Some(text.len())
94        } else {
95            None
96        }
97    }
98
99    #[inline]
100    fn is_zero_width_codepoint(c: char) -> bool {
101        let u = c as u32;
102        matches!(u, 0x0000..=0x001F | 0x007F..=0x009F)
103            || matches!(u, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF)
104            || matches!(u, 0xFE20..=0xFE2F)
105            || matches!(u, 0xFE00..=0xFE0F | 0xE0100..=0xE01EF)
106            || matches!(
107                u,
108                0x00AD
109                    | 0x034F
110                    | 0x180E
111                    | 0x200B
112                    | 0x200C
113                    | 0x200D
114                    | 0x200E
115                    | 0x200F
116                    | 0x2060
117                    | 0xFEFF
118            )
119            || matches!(u, 0x202A..=0x202E | 0x2066..=0x2069 | 0x206A..=0x206F)
120    }
121
122    #[inline]
123    pub(crate) fn grapheme_width(grapheme: &str) -> usize {
124        if grapheme.is_ascii() {
125            return ascii_display_width(grapheme);
126        }
127        if grapheme.chars().all(is_zero_width_codepoint) {
128            return 0;
129        }
130        if use_cjk_width() {
131            return grapheme.width_cjk();
132        }
133        unicode_display_width(grapheme) as usize
134    }
135
136    #[inline]
137    pub(crate) fn char_width(ch: char) -> usize {
138        if ch.is_ascii() {
139            return match ch {
140                '\t' | '\n' | '\r' => 1,
141                ' '..='~' => 1,
142                _ => 0,
143            };
144        }
145        if is_zero_width_codepoint(ch) {
146            return 0;
147        }
148        if use_cjk_width() {
149            ch.width_cjk().unwrap_or(0)
150        } else {
151            ch.width().unwrap_or(0)
152        }
153    }
154
155    #[inline]
156    pub(crate) fn display_width(text: &str) -> usize {
157        if let Some(width) = ascii_width(text) {
158            return width;
159        }
160        if text.is_ascii() {
161            return ascii_display_width(text);
162        }
163        let cjk_width = use_cjk_width();
164        if !text.chars().any(is_zero_width_codepoint) {
165            if cjk_width {
166                return text.width_cjk();
167            }
168            return unicode_display_width(text) as usize;
169        }
170        text.graphemes(true).map(grapheme_width).sum()
171    }
172}
173
174pub(crate) use text_width::{char_width, display_width, grapheme_width};
175
176#[cfg(test)]
177mod tests {
178    use super::{display_width, grapheme_width};
179
180    #[test]
181    fn display_width_matches_expected_samples() {
182        // Avoid CJK samples to keep results independent of locale/CJK width flags.
183        let samples = [
184            ("hello", 5usize),
185            ("πŸ˜€", 2usize),
186            ("πŸ‘©β€πŸ’»", 2usize),
187            ("πŸ‡ΊπŸ‡Έ", 2usize),
188            ("❀️", 2usize),
189            ("⌨️", 2usize),
190            ("⚠️", 2usize),
191            ("⭐", 2usize),
192            ("AπŸ˜€B", 4usize),
193            ("ok βœ…", 5usize),
194        ];
195        for (sample, expected) in samples {
196            assert_eq!(
197                display_width(sample),
198                expected,
199                "display width mismatch for {sample:?}"
200            );
201        }
202    }
203
204    #[test]
205    fn grapheme_width_matches_expected_samples() {
206        let samples = [
207            ("a", 1usize),
208            ("πŸ˜€", 2usize),
209            ("πŸ‘©β€πŸ’»", 2usize),
210            ("πŸ‡ΊπŸ‡Έ", 2usize),
211            ("πŸ‘πŸ½", 2usize),
212            ("❀️", 2usize),
213            ("⌨️", 2usize),
214            ("⚠️", 2usize),
215            ("⭐", 2usize),
216        ];
217        for (grapheme, expected) in samples {
218            assert_eq!(
219                grapheme_width(grapheme),
220                expected,
221                "grapheme width mismatch for {grapheme:?}"
222            );
223        }
224    }
225}