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