Skip to main content

ftui_text/
incremental_break.rs

1//! Incremental Knuth-Plass line-break optimizer.
2//!
3//! Wraps the existing [`wrap_optimal`] DP algorithm with paragraph-level
4//! caching and dirty-region tracking, so only edited/resized paragraphs are
5//! recomputed during reflow.
6//!
7//! # Incrementality model
8//!
9//! A document is a sequence of paragraphs separated by hard line breaks (`\n`).
10//! The Knuth-Plass DP runs independently per paragraph, so:
11//!
12//! - A text edit affects at most one paragraph (unless it inserts/removes `\n`).
13//! - A width change invalidates all paragraphs.
14//! - Cached solutions are keyed by `(text_hash, width)` for staleness detection.
15//!
16//! For very long documents, only dirty paragraphs are re-broken, providing
17//! bounded latency proportional to the edited paragraph's length rather than
18//! the total document length.
19//!
20//! # Usage
21//!
22//! ```
23//! use ftui_text::incremental_break::IncrementalBreaker;
24//! use ftui_text::wrap::ParagraphObjective;
25//!
26//! let mut breaker = IncrementalBreaker::new(80, ParagraphObjective::terminal());
27//! let result = breaker.reflow("Hello world. This is a test paragraph.");
28//! assert!(!result.lines.is_empty());
29//! ```
30
31use std::collections::hash_map::DefaultHasher;
32use std::hash::{Hash, Hasher};
33
34use crate::wrap::{ParagraphObjective, display_width, wrap_optimal};
35
36// =========================================================================
37// BreakSolution
38// =========================================================================
39
40/// Cached break solution for a single paragraph.
41#[derive(Debug, Clone)]
42struct BreakSolution {
43    /// The wrapped lines for this paragraph.
44    lines: Vec<String>,
45    /// Total cost of the solution.
46    total_cost: u64,
47    /// Per-line badness values (retained for diagnostics).
48    #[allow(dead_code)]
49    line_badness: Vec<u64>,
50    /// Width used for this solution (for staleness detection).
51    width: usize,
52    /// Hash of the paragraph text (for staleness detection).
53    text_hash: u64,
54}
55
56impl BreakSolution {
57    /// Check if this cached solution is still valid for the given text and width.
58    fn is_valid(&self, text_hash: u64, width: usize) -> bool {
59        self.text_hash == text_hash && self.width == width
60    }
61}
62
63/// Hash a paragraph text deterministically.
64fn hash_paragraph(text: &str) -> u64 {
65    let mut hasher = DefaultHasher::new();
66    text.hash(&mut hasher);
67    hasher.finish()
68}
69
70// =========================================================================
71// EditEvent
72// =========================================================================
73
74/// Describes a text edit for incremental reflow.
75///
76/// The breaker uses this to determine which paragraphs need recomputation.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct EditEvent {
79    /// Byte offset where the edit starts.
80    pub offset: usize,
81    /// Number of bytes deleted from the old text.
82    pub deleted: usize,
83    /// Number of bytes inserted in the new text.
84    pub inserted: usize,
85}
86
87// =========================================================================
88// ReflowResult
89// =========================================================================
90
91/// Result of an incremental reflow operation.
92#[derive(Debug, Clone)]
93pub struct ReflowResult {
94    /// All lines of the document after reflow.
95    pub lines: Vec<String>,
96    /// Indices of paragraphs that were recomputed (not cache hits).
97    pub recomputed: Vec<usize>,
98    /// Total cost across all paragraphs (sum of per-paragraph costs).
99    pub total_cost: u64,
100    /// Number of paragraphs in the document.
101    pub paragraph_count: usize,
102}
103
104// =========================================================================
105// BreakerSnapshot
106// =========================================================================
107
108/// Diagnostic snapshot of the incremental breaker state.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct BreakerSnapshot {
111    /// Target line width.
112    pub width: usize,
113    /// Number of cached paragraph solutions.
114    pub cached_paragraphs: usize,
115    /// Number of dirty paragraphs pending reflow.
116    pub dirty_paragraphs: usize,
117    /// Generation counter.
118    pub generation: u64,
119    /// Total reflow operations performed.
120    pub total_reflows: u64,
121    /// Total cache hits across all reflows.
122    pub cache_hits: u64,
123    /// Total cache misses (recomputations) across all reflows.
124    pub cache_misses: u64,
125}
126
127// =========================================================================
128// IncrementalBreaker
129// =========================================================================
130
131/// Incremental Knuth-Plass line-break optimizer with paragraph-level caching.
132///
133/// Maintains cached break solutions per paragraph and only recomputes
134/// paragraphs that have been modified or whose line width has changed.
135#[derive(Debug, Clone)]
136pub struct IncrementalBreaker {
137    /// Target line width in cells.
138    width: usize,
139    /// Paragraph objective configuration.
140    objective: ParagraphObjective,
141    /// Cached solutions per paragraph index.
142    solutions: Vec<Option<BreakSolution>>,
143    /// Generation counter (incremented on any change).
144    generation: u64,
145    /// Total reflow operations.
146    total_reflows: u64,
147    /// Total cache hits.
148    cache_hits: u64,
149    /// Total cache misses.
150    cache_misses: u64,
151}
152
153impl IncrementalBreaker {
154    /// Create a new incremental breaker with the given line width and objective.
155    #[must_use]
156    pub fn new(width: usize, objective: ParagraphObjective) -> Self {
157        Self {
158            width,
159            objective,
160            solutions: Vec::new(),
161            generation: 0,
162            total_reflows: 0,
163            cache_hits: 0,
164            cache_misses: 0,
165        }
166    }
167
168    /// Create a breaker with terminal-optimized defaults.
169    #[must_use]
170    pub fn terminal(width: usize) -> Self {
171        Self::new(width, ParagraphObjective::terminal())
172    }
173
174    /// Current line width.
175    #[must_use]
176    pub fn width(&self) -> usize {
177        self.width
178    }
179
180    /// Current generation.
181    #[must_use]
182    pub fn generation(&self) -> u64 {
183        self.generation
184    }
185
186    /// Current paragraph objective.
187    #[must_use]
188    pub fn objective(&self) -> &ParagraphObjective {
189        &self.objective
190    }
191
192    /// Update the target line width.
193    ///
194    /// All cached solutions are invalidated (width change affects every paragraph).
195    pub fn set_width(&mut self, width: usize) {
196        if self.width != width {
197            self.width = width;
198            self.generation += 1;
199            // Don't clear solutions — they'll be detected as stale via width mismatch.
200        }
201    }
202
203    /// Update the paragraph objective.
204    ///
205    /// All cached solutions are invalidated.
206    pub fn set_objective(&mut self, objective: ParagraphObjective) {
207        self.objective = objective;
208        self.generation += 1;
209        self.solutions.clear();
210    }
211
212    /// Invalidate all cached solutions, forcing full recomputation on next reflow.
213    pub fn invalidate_all(&mut self) {
214        self.generation += 1;
215        self.solutions.clear();
216    }
217
218    /// Invalidate a specific paragraph by index.
219    ///
220    /// Safe to call with out-of-bounds index (no-op).
221    pub fn invalidate_paragraph(&mut self, paragraph_idx: usize) {
222        if paragraph_idx < self.solutions.len() {
223            self.solutions[paragraph_idx] = None;
224            self.generation += 1;
225        }
226    }
227
228    /// Notify the breaker of a text edit.
229    ///
230    /// This determines which paragraph(s) are affected by the edit and
231    /// invalidates their cached solutions. The caller must provide the
232    /// *old* text so the breaker can locate paragraph boundaries.
233    pub fn notify_edit(&mut self, old_text: &str, event: &EditEvent) {
234        let paragraphs: Vec<&str> = old_text.split('\n').collect();
235
236        // Find which paragraph(s) the edit range overlaps.
237        let mut byte_offset = 0;
238        for (idx, para) in paragraphs.iter().enumerate() {
239            let para_end = byte_offset + para.len();
240            // Check if the edit overlaps this paragraph
241            let edit_end = event.offset + event.deleted;
242            if event.offset <= para_end && edit_end >= byte_offset {
243                self.invalidate_paragraph(idx);
244            }
245            byte_offset = para_end + 1; // +1 for the '\n'
246        }
247
248        // If the edit introduces or removes newlines, invalidate everything
249        // after the edit point (paragraph structure changed).
250        if event.deleted != event.inserted {
251            let mut byte_offset = 0;
252            for (idx, para) in paragraphs.iter().enumerate() {
253                let para_end = byte_offset + para.len();
254                if para_end >= event.offset {
255                    self.invalidate_paragraph(idx);
256                }
257                byte_offset = para_end + 1;
258            }
259        }
260    }
261
262    /// Reflow the document text, reusing cached solutions where possible.
263    ///
264    /// This is the primary entry point. It splits the text into paragraphs,
265    /// checks each against cached solutions, and only recomputes dirty ones.
266    pub fn reflow(&mut self, text: &str) -> ReflowResult {
267        self.total_reflows += 1;
268
269        let paragraphs: Vec<&str> = text.split('\n').collect();
270        let para_count = paragraphs.len();
271
272        // Resize solution cache to match paragraph count.
273        self.solutions.resize_with(para_count, || None);
274        // Truncate excess if text has fewer paragraphs now.
275        self.solutions.truncate(para_count);
276
277        let mut all_lines = Vec::new();
278        let mut recomputed = Vec::new();
279        let mut total_cost = 0u64;
280
281        for (idx, paragraph) in paragraphs.iter().enumerate() {
282            let text_hash = hash_paragraph(paragraph);
283
284            // Check cache validity.
285            let cached_valid = self.solutions[idx]
286                .as_ref()
287                .is_some_and(|sol| sol.is_valid(text_hash, self.width));
288
289            if cached_valid {
290                let sol = self.solutions[idx].as_ref().unwrap();
291                all_lines.extend(sol.lines.iter().cloned());
292                total_cost = total_cost.saturating_add(sol.total_cost);
293                self.cache_hits += 1;
294            } else {
295                // Recompute this paragraph.
296                let sol = self.break_paragraph(paragraph, text_hash);
297                all_lines.extend(sol.lines.iter().cloned());
298                total_cost = total_cost.saturating_add(sol.total_cost);
299                self.solutions[idx] = Some(sol);
300                recomputed.push(idx);
301                self.cache_misses += 1;
302            }
303        }
304
305        ReflowResult {
306            lines: all_lines,
307            recomputed,
308            total_cost,
309            paragraph_count: para_count,
310        }
311    }
312
313    /// Reflow with forced full recomputation (no caching).
314    pub fn reflow_full(&mut self, text: &str) -> ReflowResult {
315        self.invalidate_all();
316        self.reflow(text)
317    }
318
319    /// Break a single paragraph using the Knuth-Plass algorithm.
320    fn break_paragraph(&self, paragraph: &str, text_hash: u64) -> BreakSolution {
321        if paragraph.is_empty() {
322            return BreakSolution {
323                lines: vec![String::new()],
324                total_cost: 0,
325                line_badness: vec![0],
326                width: self.width,
327                text_hash,
328            };
329        }
330
331        let result = wrap_optimal(paragraph, self.width);
332
333        // Apply widow/orphan demerits from the objective.
334        let mut adjusted_cost = result.total_cost;
335        if result.lines.len() > 1 {
336            if let Some(last) = result.lines.last() {
337                let last_chars = display_width(last);
338                adjusted_cost =
339                    adjusted_cost.saturating_add(self.objective.widow_demerits(last_chars));
340            }
341            if let Some(first) = result.lines.first() {
342                let first_chars = display_width(first);
343                adjusted_cost =
344                    adjusted_cost.saturating_add(self.objective.orphan_demerits(first_chars));
345            }
346        }
347
348        BreakSolution {
349            lines: result.lines,
350            total_cost: adjusted_cost,
351            line_badness: result.line_badness,
352            width: self.width,
353            text_hash,
354        }
355    }
356
357    /// Diagnostic snapshot of the current state.
358    #[must_use]
359    pub fn snapshot(&self) -> BreakerSnapshot {
360        let cached = self.solutions.iter().filter(|s| s.is_some()).count();
361        let dirty = self.solutions.iter().filter(|s| s.is_none()).count();
362        BreakerSnapshot {
363            width: self.width,
364            cached_paragraphs: cached,
365            dirty_paragraphs: dirty,
366            generation: self.generation,
367            total_reflows: self.total_reflows,
368            cache_hits: self.cache_hits,
369            cache_misses: self.cache_misses,
370        }
371    }
372}
373
374// =========================================================================
375// Tests
376// =========================================================================
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    fn breaker(width: usize) -> IncrementalBreaker {
383        IncrementalBreaker::terminal(width)
384    }
385
386    // ── Basic reflow ──────────────────────────────────────────────────
387
388    #[test]
389    fn reflow_empty_text() {
390        let mut b = breaker(80);
391        let r = b.reflow("");
392        assert_eq!(r.lines, vec![""]);
393        assert_eq!(r.paragraph_count, 1);
394    }
395
396    #[test]
397    fn reflow_single_word() {
398        let mut b = breaker(80);
399        let r = b.reflow("hello");
400        assert_eq!(r.lines, vec!["hello"]);
401        assert_eq!(r.total_cost, 0); // last line has zero badness
402    }
403
404    #[test]
405    fn reflow_fits_in_width() {
406        let mut b = breaker(80);
407        let r = b.reflow("The quick brown fox jumps over the lazy dog.");
408        assert_eq!(r.lines.len(), 1);
409        assert_eq!(r.recomputed, vec![0]);
410    }
411
412    #[test]
413    fn reflow_wraps_long_text() {
414        let mut b = breaker(20);
415        let text = "The quick brown fox jumps over the lazy dog.";
416        let r = b.reflow(text);
417        assert!(r.lines.len() > 1);
418        // All lines should be within width (except possibly last)
419        for line in &r.lines[..r.lines.len() - 1] {
420            assert!(
421                display_width(line) <= 20,
422                "line too wide: {:?} (width {})",
423                line,
424                display_width(line)
425            );
426        }
427    }
428
429    #[test]
430    fn reflow_preserves_paragraphs() {
431        let mut b = breaker(80);
432        let r = b.reflow("First paragraph.\nSecond paragraph.\nThird.");
433        assert_eq!(r.paragraph_count, 3);
434        assert_eq!(r.lines.len(), 3);
435    }
436
437    // ── Caching ───────────────────────────────────────────────────────
438
439    #[test]
440    fn second_reflow_uses_cache() {
441        let mut b = breaker(80);
442        let text = "Hello world.";
443        b.reflow(text);
444        let r2 = b.reflow(text);
445        assert!(r2.recomputed.is_empty(), "second reflow should be cached");
446    }
447
448    #[test]
449    fn cache_hit_increments_counter() {
450        let mut b = breaker(80);
451        let text = "Hello.";
452        b.reflow(text);
453        b.reflow(text);
454        let snap = b.snapshot();
455        assert_eq!(snap.cache_hits, 1);
456        assert_eq!(snap.cache_misses, 1);
457    }
458
459    #[test]
460    fn width_change_invalidates_cache() {
461        let mut b = breaker(80);
462        let text = "Hello world.";
463        b.reflow(text);
464        b.set_width(40);
465        let r = b.reflow(text);
466        assert_eq!(r.recomputed, vec![0]);
467    }
468
469    #[test]
470    fn text_change_invalidates_paragraph() {
471        let mut b = breaker(80);
472        b.reflow("Hello.\nWorld.");
473        let r2 = b.reflow("Hello.\nChanged.");
474        // Paragraph 0 should be cached, paragraph 1 recomputed
475        assert_eq!(r2.recomputed, vec![1]);
476    }
477
478    #[test]
479    fn invalidate_all_forces_recomputation() {
480        let mut b = breaker(80);
481        b.reflow("Hello.\nWorld.");
482        b.invalidate_all();
483        let r = b.reflow("Hello.\nWorld.");
484        assert_eq!(r.recomputed, vec![0, 1]);
485    }
486
487    #[test]
488    fn invalidate_paragraph_selective() {
489        let mut b = breaker(80);
490        b.reflow("A.\nB.\nC.");
491        b.invalidate_paragraph(1);
492        let r = b.reflow("A.\nB.\nC.");
493        // Only paragraph 1 should be recomputed
494        assert_eq!(r.recomputed, vec![1]);
495    }
496
497    #[test]
498    fn invalidate_out_of_bounds_is_noop() {
499        let mut b = breaker(80);
500        b.reflow("Hello.");
501        b.invalidate_paragraph(999); // should not panic
502    }
503
504    // ── Edit notification ──────────────────────────────────────────────
505
506    #[test]
507    fn notify_edit_invalidates_affected_paragraph() {
508        let mut b = breaker(80);
509        let text = "First.\nSecond.\nThird.";
510        b.reflow(text);
511        // Edit in "Second." (byte offset 7..14)
512        b.notify_edit(
513            text,
514            &EditEvent {
515                offset: 7,
516                deleted: 7,
517                inserted: 8,
518            },
519        );
520        let r = b.reflow("First.\nChanged!.\nThird.");
521        // Paragraphs 1 and 2 should be recomputed (edit + structure change)
522        assert!(r.recomputed.contains(&1));
523    }
524
525    #[test]
526    fn notify_edit_at_start() {
527        let mut b = breaker(80);
528        let text = "Hello.\nWorld.";
529        b.reflow(text);
530        b.notify_edit(
531            text,
532            &EditEvent {
533                offset: 0,
534                deleted: 5,
535                inserted: 3,
536            },
537        );
538        let r = b.reflow("Hi.\nWorld.");
539        assert!(r.recomputed.contains(&0));
540    }
541
542    // ── Width changes ──────────────────────────────────────────────────
543
544    #[test]
545    fn set_width_same_is_noop() {
546        let mut b = breaker(80);
547        b.reflow("Test.");
548        let prev_gen = b.generation();
549        b.set_width(80);
550        assert_eq!(b.generation(), prev_gen);
551    }
552
553    #[test]
554    fn set_width_different_bumps_generation() {
555        let mut b = breaker(80);
556        let prev_gen = b.generation();
557        b.set_width(40);
558        assert!(b.generation() > prev_gen);
559    }
560
561    // ── Objective changes ──────────────────────────────────────────────
562
563    #[test]
564    fn set_objective_clears_cache() {
565        let mut b = breaker(80);
566        b.reflow("Hello world.");
567        b.set_objective(ParagraphObjective::typographic());
568        let snap = b.snapshot();
569        assert_eq!(snap.cached_paragraphs, 0);
570    }
571
572    // ── Reflow full ────────────────────────────────────────────────────
573
574    #[test]
575    fn reflow_full_recomputes_everything() {
576        let mut b = breaker(80);
577        b.reflow("A.\nB.");
578        let r = b.reflow_full("A.\nB.");
579        assert_eq!(r.recomputed, vec![0, 1]);
580    }
581
582    // ── Snapshot diagnostics ───────────────────────────────────────────
583
584    #[test]
585    fn snapshot_initial_state() {
586        let b = breaker(80);
587        let snap = b.snapshot();
588        assert_eq!(snap.width, 80);
589        assert_eq!(snap.cached_paragraphs, 0);
590        assert_eq!(snap.dirty_paragraphs, 0);
591        assert_eq!(snap.generation, 0);
592        assert_eq!(snap.total_reflows, 0);
593    }
594
595    #[test]
596    fn snapshot_after_reflow() {
597        let mut b = breaker(80);
598        b.reflow("A.\nB.\nC.");
599        let snap = b.snapshot();
600        assert_eq!(snap.cached_paragraphs, 3);
601        assert_eq!(snap.total_reflows, 1);
602        assert_eq!(snap.cache_misses, 3);
603    }
604
605    // ── Determinism ────────────────────────────────────────────────────
606
607    #[test]
608    fn reflow_deterministic() {
609        let mut b1 = breaker(30);
610        let mut b2 = breaker(30);
611        let text = "The quick brown fox jumps over the lazy dog near the river bank.";
612        let r1 = b1.reflow(text);
613        let r2 = b2.reflow(text);
614        assert_eq!(r1.lines, r2.lines);
615        assert_eq!(r1.total_cost, r2.total_cost);
616    }
617
618    #[test]
619    fn reflow_idempotent() {
620        let mut b = breaker(30);
621        let text = "The quick brown fox jumps over the lazy dog.";
622        let r1 = b.reflow(text);
623        let r2 = b.reflow_full(text);
624        assert_eq!(r1.lines, r2.lines);
625        assert_eq!(r1.total_cost, r2.total_cost);
626    }
627
628    // ── Edge cases ─────────────────────────────────────────────────────
629
630    #[test]
631    fn reflow_zero_width() {
632        let mut b = breaker(0);
633        let r = b.reflow("Hello");
634        assert!(!r.lines.is_empty());
635    }
636
637    #[test]
638    fn reflow_very_narrow() {
639        let mut b = breaker(1);
640        let r = b.reflow("abc");
641        // Should not panic; may have forced breaks
642        assert!(!r.lines.is_empty());
643    }
644
645    #[test]
646    fn reflow_only_newlines() {
647        let mut b = breaker(80);
648        let r = b.reflow("\n\n\n");
649        assert_eq!(r.paragraph_count, 4); // 3 newlines = 4 paragraphs
650    }
651
652    #[test]
653    fn reflow_paragraph_count_changes() {
654        let mut b = breaker(80);
655        b.reflow("A.\nB.\nC.");
656        // Now text has fewer paragraphs
657        let r = b.reflow("A.\nB.");
658        assert_eq!(r.paragraph_count, 2);
659    }
660
661    #[test]
662    fn reflow_paragraph_count_grows() {
663        let mut b = breaker(80);
664        b.reflow("A.\nB.");
665        let r = b.reflow("A.\nB.\nC.\nD.");
666        assert_eq!(r.paragraph_count, 4);
667        // New paragraphs 2,3 should be recomputed
668        assert!(r.recomputed.contains(&2));
669        assert!(r.recomputed.contains(&3));
670    }
671
672    #[test]
673    fn multiple_edits_accumulate() {
674        let mut b = breaker(80);
675        b.reflow("One.\nTwo.\nThree.");
676        b.invalidate_paragraph(0);
677        b.invalidate_paragraph(2);
678        let r = b.reflow("One.\nTwo.\nThree.");
679        assert_eq!(r.recomputed, vec![0, 2]);
680    }
681
682    #[test]
683    fn long_paragraph_performance() {
684        let mut b = breaker(60);
685        let text = "word ".repeat(500);
686        let r = b.reflow(&text);
687        assert!(r.lines.len() > 1);
688        // Second reflow should be instant (cached)
689        let r2 = b.reflow(&text);
690        assert!(r2.recomputed.is_empty());
691    }
692
693    // ── Constructor variants ───────────────────────────────────────────
694
695    #[test]
696    fn terminal_constructor() {
697        let b = IncrementalBreaker::terminal(120);
698        assert_eq!(b.width(), 120);
699        assert_eq!(b.objective().line_penalty, 20); // terminal preset
700    }
701
702    #[test]
703    fn new_with_default_objective() {
704        let b = IncrementalBreaker::new(80, ParagraphObjective::default());
705        assert_eq!(b.objective().line_penalty, 10); // TeX default
706    }
707}