Skip to main content

ftui_text/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Text handling for FrankenTUI.
4//!
5//! This crate provides text primitives for styled text rendering:
6//! - [`Segment`] - atomic unit of styled text with cell-aware splitting
7//! - [`SegmentLine`] - a line of segments
8//! - [`SegmentLines`] - multi-line text
9//! - [`Span`] - styled text span for ergonomic construction
10//! - [`Line`] - a line of styled spans
11//! - [`Text`] - multi-line styled text
12//! - [`Rope`] - rope-backed text storage
13//! - [`CursorPosition`] / [`CursorNavigator`] - text cursor utilities
14//! - [`Editor`] - core text editing operations (insert, delete, cursor movement)
15//! - [`WidthCache`] - LRU cache for text width measurements
16//!
17//! # Role in FrankenTUI
18//! `ftui-text` owns the text model used by widgets and renderers: spans, lines,
19//! wrapping, width calculations, and editing utilities. It is deliberately
20//! independent of rendering and terminal I/O so it can be reused across
21//! widgets, the demo showcase, and any consumer crate.
22//!
23//! # How it fits in the system
24//! Widgets build `Text` and `Span` structures, layout depends on width
25//! measurement, and the render kernel consumes text as styled cells. This
26//! crate is the glue between high-level content and low-level cell output.
27//!
28//! # Example
29//! ```
30//! use ftui_text::{Segment, Text, Span, Line, WidthCache};
31//! use ftui_style::Style;
32//!
33//! // Create styled segments (low-level)
34//! let seg = Segment::styled("Error:", Style::new().bold());
35//!
36//! // Create styled text (high-level)
37//! let text = Text::from_spans([
38//!     Span::raw("Status: "),
39//!     Span::styled("OK", Style::new().bold()),
40//! ]);
41//!
42//! // Multi-line text
43//! let text = Text::raw("line 1\nline 2\nline 3");
44//! assert_eq!(text.height(), 3);
45//!
46//! // Truncate with ellipsis
47//! let mut text = Text::raw("hello world");
48//! text.truncate(8, Some("..."));
49//! assert_eq!(text.to_plain_text(), "hello...");
50//!
51//! // Cache text widths for performance
52//! let mut cache = WidthCache::new(1000);
53//! let width = cache.get_or_compute("Hello, world!");
54//! assert_eq!(width, 13);
55//! ```
56
57pub mod cursor;
58pub mod editor;
59pub mod rope;
60pub mod segment;
61pub mod text;
62pub mod view;
63pub mod width_cache;
64pub mod wrap;
65
66#[cfg(feature = "markup")]
67pub mod markup;
68
69#[cfg(feature = "bidi")]
70pub mod bidi;
71
72#[cfg(feature = "normalization")]
73pub mod normalization;
74
75pub mod search;
76
77/// Bounds-based text measurement for layout negotiation.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
79pub struct TextMeasurement {
80    /// Minimum possible width.
81    pub minimum: usize,
82    /// Maximum possible width.
83    pub maximum: usize,
84}
85
86impl TextMeasurement {
87    /// Zero-width measurement.
88    pub const ZERO: Self = Self {
89        minimum: 0,
90        maximum: 0,
91    };
92
93    /// Union: take max of both bounds (side-by-side layout).
94    pub fn union(self, other: Self) -> Self {
95        Self {
96            minimum: self.minimum.max(other.minimum),
97            maximum: self.maximum.max(other.maximum),
98        }
99    }
100
101    /// Stack: add both bounds (vertical stacking).
102    pub fn stack(self, other: Self) -> Self {
103        Self {
104            minimum: self.minimum.saturating_add(other.minimum),
105            maximum: self.maximum.saturating_add(other.maximum),
106        }
107    }
108
109    /// Clamp bounds to optional min/max constraints.
110    pub fn clamp(self, min_width: Option<usize>, max_width: Option<usize>) -> Self {
111        let mut result = self;
112        if let Some(min_w) = min_width {
113            result.minimum = result.minimum.max(min_w);
114            result.maximum = result.maximum.max(min_w);
115        }
116        if let Some(max_w) = max_width {
117            result.minimum = result.minimum.min(max_w);
118            result.maximum = result.maximum.min(max_w);
119        }
120        result
121    }
122}
123
124pub use cursor::{CursorNavigator, CursorPosition};
125pub use editor::{Editor, Selection};
126pub use rope::Rope;
127pub use segment::{ControlCode, Segment, SegmentLine, SegmentLines, join_lines, split_into_lines};
128pub use text::{Line, Span, Text};
129pub use view::{TextView, ViewLine, Viewport};
130pub use width_cache::{
131    CacheStats, CountMinSketch, DEFAULT_CACHE_CAPACITY, Doorkeeper, TinyLfuWidthCache, WidthCache,
132};
133pub use wrap::{
134    KpBreakResult, WrapMode, WrapOptions, ascii_width, display_width, grapheme_count,
135    grapheme_width, graphemes, has_wide_chars, is_ascii_only, truncate_to_width,
136    truncate_to_width_with_info, truncate_with_ellipsis, word_boundaries, word_segments,
137    wrap_optimal, wrap_text, wrap_text_optimal, wrap_with_options,
138};
139
140#[cfg(feature = "markup")]
141pub use markup::{MarkupError, MarkupParser, parse_markup};
142
143#[cfg(feature = "normalization")]
144pub use normalization::{NormForm, eq_normalized, is_normalized, normalize, normalize_for_search};
145
146pub use search::{
147    SearchResult, search_ascii_case_insensitive, search_exact, search_exact_overlapping,
148};
149#[cfg(feature = "normalization")]
150pub use search::{search_case_insensitive, search_normalized};
151
152#[cfg(test)]
153mod measurement_tests {
154    use super::TextMeasurement;
155
156    #[test]
157    fn union_uses_max_bounds() {
158        let a = TextMeasurement {
159            minimum: 2,
160            maximum: 8,
161        };
162        let b = TextMeasurement {
163            minimum: 4,
164            maximum: 6,
165        };
166        let merged = a.union(b);
167        assert_eq!(
168            merged,
169            TextMeasurement {
170                minimum: 4,
171                maximum: 8
172            }
173        );
174    }
175
176    #[test]
177    fn stack_adds_bounds() {
178        let a = TextMeasurement {
179            minimum: 1,
180            maximum: 5,
181        };
182        let b = TextMeasurement {
183            minimum: 2,
184            maximum: 7,
185        };
186        let stacked = a.stack(b);
187        assert_eq!(
188            stacked,
189            TextMeasurement {
190                minimum: 3,
191                maximum: 12
192            }
193        );
194    }
195
196    #[test]
197    fn clamp_enforces_min() {
198        let measurement = TextMeasurement {
199            minimum: 2,
200            maximum: 6,
201        };
202        let clamped = measurement.clamp(Some(5), None);
203        assert_eq!(
204            clamped,
205            TextMeasurement {
206                minimum: 5,
207                maximum: 6
208            }
209        );
210    }
211
212    #[test]
213    fn clamp_enforces_max() {
214        let measurement = TextMeasurement {
215            minimum: 4,
216            maximum: 10,
217        };
218        let clamped = measurement.clamp(None, Some(6));
219        assert_eq!(
220            clamped,
221            TextMeasurement {
222                minimum: 4,
223                maximum: 6
224            }
225        );
226    }
227
228    #[test]
229    fn clamp_preserves_ordering() {
230        let measurement = TextMeasurement {
231            minimum: 3,
232            maximum: 5,
233        };
234        let clamped = measurement.clamp(Some(7), Some(4));
235        assert!(clamped.minimum <= clamped.maximum);
236        assert_eq!(clamped.minimum, 4);
237        assert_eq!(clamped.maximum, 4);
238    }
239
240    #[test]
241    fn zero_constant() {
242        assert_eq!(TextMeasurement::ZERO.minimum, 0);
243        assert_eq!(TextMeasurement::ZERO.maximum, 0);
244    }
245
246    #[test]
247    fn default_is_zero() {
248        let m = TextMeasurement::default();
249        assert_eq!(m, TextMeasurement::ZERO);
250    }
251
252    #[test]
253    fn union_with_zero_is_identity() {
254        let m = TextMeasurement {
255            minimum: 5,
256            maximum: 10,
257        };
258        assert_eq!(m.union(TextMeasurement::ZERO), m);
259        assert_eq!(TextMeasurement::ZERO.union(m), m);
260    }
261
262    #[test]
263    fn stack_with_zero_is_identity() {
264        let m = TextMeasurement {
265            minimum: 5,
266            maximum: 10,
267        };
268        assert_eq!(m.stack(TextMeasurement::ZERO), m);
269        assert_eq!(TextMeasurement::ZERO.stack(m), m);
270    }
271
272    #[test]
273    fn stack_saturates_on_overflow() {
274        let big = TextMeasurement {
275            minimum: usize::MAX - 1,
276            maximum: usize::MAX,
277        };
278        let one = TextMeasurement {
279            minimum: 5,
280            maximum: 5,
281        };
282        let stacked = big.stack(one);
283        // saturating_add should prevent overflow
284        assert_eq!(stacked.maximum, usize::MAX);
285    }
286
287    #[test]
288    fn clamp_no_constraints() {
289        let m = TextMeasurement {
290            minimum: 3,
291            maximum: 7,
292        };
293        let clamped = m.clamp(None, None);
294        assert_eq!(clamped, m);
295    }
296
297    #[test]
298    fn clamp_min_raises_both_bounds() {
299        let m = TextMeasurement {
300            minimum: 1,
301            maximum: 2,
302        };
303        // min_width = 5 should raise both bounds
304        let clamped = m.clamp(Some(5), None);
305        assert_eq!(clamped.minimum, 5);
306        assert_eq!(clamped.maximum, 5);
307    }
308
309    #[test]
310    fn union_is_commutative() {
311        let a = TextMeasurement {
312            minimum: 2,
313            maximum: 8,
314        };
315        let b = TextMeasurement {
316            minimum: 4,
317            maximum: 6,
318        };
319        assert_eq!(a.union(b), b.union(a));
320    }
321
322    #[test]
323    fn stack_is_commutative() {
324        let a = TextMeasurement {
325            minimum: 2,
326            maximum: 8,
327        };
328        let b = TextMeasurement {
329            minimum: 4,
330            maximum: 6,
331        };
332        assert_eq!(a.stack(b), b.stack(a));
333    }
334}