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
66pub mod hyphenation;
67pub mod incremental_break;
68
69#[cfg(feature = "markup")]
70pub mod markup;
71
72#[cfg(feature = "bidi")]
73pub mod bidi;
74
75#[cfg(feature = "normalization")]
76pub mod normalization;
77
78pub mod justification;
79pub mod layout_policy;
80pub mod search;
81pub mod vertical_metrics;
82
83/// Bounds-based text measurement for layout negotiation.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
85pub struct TextMeasurement {
86    /// Minimum possible width.
87    pub minimum: usize,
88    /// Maximum possible width.
89    pub maximum: usize,
90}
91
92impl TextMeasurement {
93    /// Zero-width measurement.
94    pub const ZERO: Self = Self {
95        minimum: 0,
96        maximum: 0,
97    };
98
99    /// Union: take max of both bounds (side-by-side layout).
100    pub fn union(self, other: Self) -> Self {
101        Self {
102            minimum: self.minimum.max(other.minimum),
103            maximum: self.maximum.max(other.maximum),
104        }
105    }
106
107    /// Stack: add both bounds (vertical stacking).
108    pub fn stack(self, other: Self) -> Self {
109        Self {
110            minimum: self.minimum.saturating_add(other.minimum),
111            maximum: self.maximum.saturating_add(other.maximum),
112        }
113    }
114
115    /// Clamp bounds to optional min/max constraints.
116    pub fn clamp(self, min_width: Option<usize>, max_width: Option<usize>) -> Self {
117        let mut result = self;
118        if let Some(min_w) = min_width {
119            result.minimum = result.minimum.max(min_w);
120            result.maximum = result.maximum.max(min_w);
121        }
122        if let Some(max_w) = max_width {
123            result.minimum = result.minimum.min(max_w);
124            result.maximum = result.maximum.min(max_w);
125        }
126        result
127    }
128}
129
130pub use cursor::{CursorNavigator, CursorPosition};
131pub use editor::{Editor, Selection};
132pub use hyphenation::{
133    HyphenBreakPoint, HyphenationDict, HyphenationPattern, PatternTrie, break_penalties,
134    compile_pattern, english_dict_mini,
135};
136pub use incremental_break::{BreakerSnapshot, EditEvent, IncrementalBreaker, ReflowResult};
137pub use justification::{
138    GlueSpec, JustificationControl, JustifyMode, SUBCELL_SCALE, SpaceCategory, SpacePenalty,
139};
140pub use layout_policy::{LayoutPolicy, LayoutTier, PolicyError, ResolvedPolicy, RuntimeCapability};
141pub use rope::Rope;
142pub use segment::{ControlCode, Segment, SegmentLine, SegmentLines, join_lines, split_into_lines};
143pub use text::{Line, Span, Text};
144pub use vertical_metrics::{
145    BaselineGrid, LeadingSpec, ParagraphSpacing, VerticalMetrics, VerticalPolicy,
146};
147pub use view::{TextView, ViewLine, Viewport};
148pub use width_cache::{
149    CacheStats, CountMinSketch, DEFAULT_CACHE_CAPACITY, Doorkeeper, S3FifoWidthCache,
150    TinyLfuWidthCache, WidthCache,
151};
152pub use wrap::{
153    KpBreakResult, WrapMode, WrapOptions, ascii_width, display_width, grapheme_count,
154    grapheme_width, graphemes, has_wide_chars, is_ascii_only, truncate_to_width,
155    truncate_to_width_with_info, truncate_with_ellipsis, word_boundaries, word_segments,
156    wrap_optimal, wrap_text, wrap_text_optimal, wrap_with_options,
157};
158
159#[cfg(feature = "markup")]
160pub use markup::{MarkupError, MarkupParser, parse_markup};
161
162#[cfg(feature = "normalization")]
163pub use normalization::{NormForm, eq_normalized, is_normalized, normalize, normalize_for_search};
164
165pub use search::{
166    SearchResult, search_ascii_case_insensitive, search_exact, search_exact_overlapping,
167};
168#[cfg(feature = "normalization")]
169pub use search::{search_case_insensitive, search_normalized};
170
171#[cfg(test)]
172mod measurement_tests {
173    use super::TextMeasurement;
174
175    #[test]
176    fn union_uses_max_bounds() {
177        let a = TextMeasurement {
178            minimum: 2,
179            maximum: 8,
180        };
181        let b = TextMeasurement {
182            minimum: 4,
183            maximum: 6,
184        };
185        let merged = a.union(b);
186        assert_eq!(
187            merged,
188            TextMeasurement {
189                minimum: 4,
190                maximum: 8
191            }
192        );
193    }
194
195    #[test]
196    fn stack_adds_bounds() {
197        let a = TextMeasurement {
198            minimum: 1,
199            maximum: 5,
200        };
201        let b = TextMeasurement {
202            minimum: 2,
203            maximum: 7,
204        };
205        let stacked = a.stack(b);
206        assert_eq!(
207            stacked,
208            TextMeasurement {
209                minimum: 3,
210                maximum: 12
211            }
212        );
213    }
214
215    #[test]
216    fn clamp_enforces_min() {
217        let measurement = TextMeasurement {
218            minimum: 2,
219            maximum: 6,
220        };
221        let clamped = measurement.clamp(Some(5), None);
222        assert_eq!(
223            clamped,
224            TextMeasurement {
225                minimum: 5,
226                maximum: 6
227            }
228        );
229    }
230
231    #[test]
232    fn clamp_enforces_max() {
233        let measurement = TextMeasurement {
234            minimum: 4,
235            maximum: 10,
236        };
237        let clamped = measurement.clamp(None, Some(6));
238        assert_eq!(
239            clamped,
240            TextMeasurement {
241                minimum: 4,
242                maximum: 6
243            }
244        );
245    }
246
247    #[test]
248    fn clamp_preserves_ordering() {
249        let measurement = TextMeasurement {
250            minimum: 3,
251            maximum: 5,
252        };
253        let clamped = measurement.clamp(Some(7), Some(4));
254        assert!(clamped.minimum <= clamped.maximum);
255        assert_eq!(clamped.minimum, 4);
256        assert_eq!(clamped.maximum, 4);
257    }
258
259    #[test]
260    fn zero_constant() {
261        assert_eq!(TextMeasurement::ZERO.minimum, 0);
262        assert_eq!(TextMeasurement::ZERO.maximum, 0);
263    }
264
265    #[test]
266    fn default_is_zero() {
267        let m = TextMeasurement::default();
268        assert_eq!(m, TextMeasurement::ZERO);
269    }
270
271    #[test]
272    fn union_with_zero_is_identity() {
273        let m = TextMeasurement {
274            minimum: 5,
275            maximum: 10,
276        };
277        assert_eq!(m.union(TextMeasurement::ZERO), m);
278        assert_eq!(TextMeasurement::ZERO.union(m), m);
279    }
280
281    #[test]
282    fn stack_with_zero_is_identity() {
283        let m = TextMeasurement {
284            minimum: 5,
285            maximum: 10,
286        };
287        assert_eq!(m.stack(TextMeasurement::ZERO), m);
288        assert_eq!(TextMeasurement::ZERO.stack(m), m);
289    }
290
291    #[test]
292    fn stack_saturates_on_overflow() {
293        let big = TextMeasurement {
294            minimum: usize::MAX - 1,
295            maximum: usize::MAX,
296        };
297        let one = TextMeasurement {
298            minimum: 5,
299            maximum: 5,
300        };
301        let stacked = big.stack(one);
302        // saturating_add should prevent overflow
303        assert_eq!(stacked.maximum, usize::MAX);
304    }
305
306    #[test]
307    fn clamp_no_constraints() {
308        let m = TextMeasurement {
309            minimum: 3,
310            maximum: 7,
311        };
312        let clamped = m.clamp(None, None);
313        assert_eq!(clamped, m);
314    }
315
316    #[test]
317    fn clamp_min_raises_both_bounds() {
318        let m = TextMeasurement {
319            minimum: 1,
320            maximum: 2,
321        };
322        // min_width = 5 should raise both bounds
323        let clamped = m.clamp(Some(5), None);
324        assert_eq!(clamped.minimum, 5);
325        assert_eq!(clamped.maximum, 5);
326    }
327
328    #[test]
329    fn union_is_commutative() {
330        let a = TextMeasurement {
331            minimum: 2,
332            maximum: 8,
333        };
334        let b = TextMeasurement {
335            minimum: 4,
336            maximum: 6,
337        };
338        assert_eq!(a.union(b), b.union(a));
339    }
340
341    #[test]
342    fn stack_is_commutative() {
343        let a = TextMeasurement {
344            minimum: 2,
345            maximum: 8,
346        };
347        let b = TextMeasurement {
348            minimum: 4,
349            maximum: 6,
350        };
351        assert_eq!(a.stack(b), b.stack(a));
352    }
353}