use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use crate::wrap::{ParagraphObjective, display_width, wrap_optimal};
#[derive(Debug, Clone)]
struct BreakSolution {
lines: Vec<String>,
total_cost: u64,
#[allow(dead_code)]
line_badness: Vec<u64>,
width: usize,
text_hash: u64,
}
impl BreakSolution {
fn is_valid(&self, text_hash: u64, width: usize) -> bool {
self.text_hash == text_hash && self.width == width
}
}
fn hash_paragraph(text: &str) -> u64 {
let mut hasher = DefaultHasher::new();
text.hash(&mut hasher);
hasher.finish()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EditEvent {
pub offset: usize,
pub deleted: usize,
pub inserted: usize,
}
#[derive(Debug, Clone)]
pub struct ReflowResult {
pub lines: Vec<String>,
pub recomputed: Vec<usize>,
pub total_cost: u64,
pub paragraph_count: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BreakerSnapshot {
pub width: usize,
pub cached_paragraphs: usize,
pub dirty_paragraphs: usize,
pub generation: u64,
pub total_reflows: u64,
pub cache_hits: u64,
pub cache_misses: u64,
}
#[derive(Debug, Clone)]
pub struct IncrementalBreaker {
width: usize,
objective: ParagraphObjective,
solutions: Vec<Option<BreakSolution>>,
generation: u64,
total_reflows: u64,
cache_hits: u64,
cache_misses: u64,
}
impl IncrementalBreaker {
#[must_use]
pub fn new(width: usize, objective: ParagraphObjective) -> Self {
Self {
width,
objective,
solutions: Vec::new(),
generation: 0,
total_reflows: 0,
cache_hits: 0,
cache_misses: 0,
}
}
#[must_use]
pub fn terminal(width: usize) -> Self {
Self::new(width, ParagraphObjective::terminal())
}
#[must_use]
pub fn width(&self) -> usize {
self.width
}
#[must_use]
pub fn generation(&self) -> u64 {
self.generation
}
#[must_use]
pub fn objective(&self) -> &ParagraphObjective {
&self.objective
}
pub fn set_width(&mut self, width: usize) {
if self.width != width {
self.width = width;
self.generation += 1;
}
}
pub fn set_objective(&mut self, objective: ParagraphObjective) {
self.objective = objective;
self.generation += 1;
self.solutions.clear();
}
pub fn invalidate_all(&mut self) {
self.generation += 1;
self.solutions.clear();
}
pub fn invalidate_paragraph(&mut self, paragraph_idx: usize) {
if paragraph_idx < self.solutions.len() {
self.solutions[paragraph_idx] = None;
self.generation += 1;
}
}
pub fn notify_edit(&mut self, old_text: &str, event: &EditEvent) {
let paragraphs: Vec<&str> = old_text.split('\n').collect();
let mut byte_offset = 0;
for (idx, para) in paragraphs.iter().enumerate() {
let para_end = byte_offset + para.len();
let edit_end = event.offset + event.deleted;
if event.offset <= para_end && edit_end >= byte_offset {
self.invalidate_paragraph(idx);
}
byte_offset = para_end + 1; }
if event.deleted != event.inserted {
let mut byte_offset = 0;
for (idx, para) in paragraphs.iter().enumerate() {
let para_end = byte_offset + para.len();
if para_end >= event.offset {
self.invalidate_paragraph(idx);
}
byte_offset = para_end + 1;
}
}
}
pub fn reflow(&mut self, text: &str) -> ReflowResult {
self.total_reflows += 1;
let paragraphs: Vec<&str> = text.split('\n').collect();
let para_count = paragraphs.len();
self.solutions.resize_with(para_count, || None);
self.solutions.truncate(para_count);
let mut all_lines = Vec::new();
let mut recomputed = Vec::new();
let mut total_cost = 0u64;
for (idx, paragraph) in paragraphs.iter().enumerate() {
let text_hash = hash_paragraph(paragraph);
let cached_valid = self.solutions[idx]
.as_ref()
.is_some_and(|sol| sol.is_valid(text_hash, self.width));
if cached_valid {
let sol = self.solutions[idx].as_ref().unwrap();
all_lines.extend(sol.lines.iter().cloned());
total_cost = total_cost.saturating_add(sol.total_cost);
self.cache_hits += 1;
} else {
let sol = self.break_paragraph(paragraph, text_hash);
all_lines.extend(sol.lines.iter().cloned());
total_cost = total_cost.saturating_add(sol.total_cost);
self.solutions[idx] = Some(sol);
recomputed.push(idx);
self.cache_misses += 1;
}
}
ReflowResult {
lines: all_lines,
recomputed,
total_cost,
paragraph_count: para_count,
}
}
pub fn reflow_full(&mut self, text: &str) -> ReflowResult {
self.invalidate_all();
self.reflow(text)
}
fn break_paragraph(&self, paragraph: &str, text_hash: u64) -> BreakSolution {
if paragraph.is_empty() {
return BreakSolution {
lines: vec![String::new()],
total_cost: 0,
line_badness: vec![0],
width: self.width,
text_hash,
};
}
let result = wrap_optimal(paragraph, self.width);
let mut adjusted_cost = result.total_cost;
if result.lines.len() > 1 {
if let Some(last) = result.lines.last() {
let last_chars = display_width(last);
adjusted_cost =
adjusted_cost.saturating_add(self.objective.widow_demerits(last_chars));
}
if let Some(first) = result.lines.first() {
let first_chars = display_width(first);
adjusted_cost =
adjusted_cost.saturating_add(self.objective.orphan_demerits(first_chars));
}
}
BreakSolution {
lines: result.lines,
total_cost: adjusted_cost,
line_badness: result.line_badness,
width: self.width,
text_hash,
}
}
#[must_use]
pub fn snapshot(&self) -> BreakerSnapshot {
let cached = self.solutions.iter().filter(|s| s.is_some()).count();
let dirty = self.solutions.iter().filter(|s| s.is_none()).count();
BreakerSnapshot {
width: self.width,
cached_paragraphs: cached,
dirty_paragraphs: dirty,
generation: self.generation,
total_reflows: self.total_reflows,
cache_hits: self.cache_hits,
cache_misses: self.cache_misses,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn breaker(width: usize) -> IncrementalBreaker {
IncrementalBreaker::terminal(width)
}
#[test]
fn reflow_empty_text() {
let mut b = breaker(80);
let r = b.reflow("");
assert_eq!(r.lines, vec![""]);
assert_eq!(r.paragraph_count, 1);
}
#[test]
fn reflow_single_word() {
let mut b = breaker(80);
let r = b.reflow("hello");
assert_eq!(r.lines, vec!["hello"]);
assert_eq!(r.total_cost, 0); }
#[test]
fn reflow_fits_in_width() {
let mut b = breaker(80);
let r = b.reflow("The quick brown fox jumps over the lazy dog.");
assert_eq!(r.lines.len(), 1);
assert_eq!(r.recomputed, vec![0]);
}
#[test]
fn reflow_wraps_long_text() {
let mut b = breaker(20);
let text = "The quick brown fox jumps over the lazy dog.";
let r = b.reflow(text);
assert!(r.lines.len() > 1);
for line in &r.lines[..r.lines.len() - 1] {
assert!(
display_width(line) <= 20,
"line too wide: {:?} (width {})",
line,
display_width(line)
);
}
}
#[test]
fn reflow_preserves_paragraphs() {
let mut b = breaker(80);
let r = b.reflow("First paragraph.\nSecond paragraph.\nThird.");
assert_eq!(r.paragraph_count, 3);
assert_eq!(r.lines.len(), 3);
}
#[test]
fn second_reflow_uses_cache() {
let mut b = breaker(80);
let text = "Hello world.";
b.reflow(text);
let r2 = b.reflow(text);
assert!(r2.recomputed.is_empty(), "second reflow should be cached");
}
#[test]
fn cache_hit_increments_counter() {
let mut b = breaker(80);
let text = "Hello.";
b.reflow(text);
b.reflow(text);
let snap = b.snapshot();
assert_eq!(snap.cache_hits, 1);
assert_eq!(snap.cache_misses, 1);
}
#[test]
fn width_change_invalidates_cache() {
let mut b = breaker(80);
let text = "Hello world.";
b.reflow(text);
b.set_width(40);
let r = b.reflow(text);
assert_eq!(r.recomputed, vec![0]);
}
#[test]
fn text_change_invalidates_paragraph() {
let mut b = breaker(80);
b.reflow("Hello.\nWorld.");
let r2 = b.reflow("Hello.\nChanged.");
assert_eq!(r2.recomputed, vec![1]);
}
#[test]
fn invalidate_all_forces_recomputation() {
let mut b = breaker(80);
b.reflow("Hello.\nWorld.");
b.invalidate_all();
let r = b.reflow("Hello.\nWorld.");
assert_eq!(r.recomputed, vec![0, 1]);
}
#[test]
fn invalidate_paragraph_selective() {
let mut b = breaker(80);
b.reflow("A.\nB.\nC.");
b.invalidate_paragraph(1);
let r = b.reflow("A.\nB.\nC.");
assert_eq!(r.recomputed, vec![1]);
}
#[test]
fn invalidate_out_of_bounds_is_noop() {
let mut b = breaker(80);
b.reflow("Hello.");
b.invalidate_paragraph(999); }
#[test]
fn notify_edit_invalidates_affected_paragraph() {
let mut b = breaker(80);
let text = "First.\nSecond.\nThird.";
b.reflow(text);
b.notify_edit(
text,
&EditEvent {
offset: 7,
deleted: 7,
inserted: 8,
},
);
let r = b.reflow("First.\nChanged!.\nThird.");
assert!(r.recomputed.contains(&1));
}
#[test]
fn notify_edit_at_start() {
let mut b = breaker(80);
let text = "Hello.\nWorld.";
b.reflow(text);
b.notify_edit(
text,
&EditEvent {
offset: 0,
deleted: 5,
inserted: 3,
},
);
let r = b.reflow("Hi.\nWorld.");
assert!(r.recomputed.contains(&0));
}
#[test]
fn set_width_same_is_noop() {
let mut b = breaker(80);
b.reflow("Test.");
let prev_gen = b.generation();
b.set_width(80);
assert_eq!(b.generation(), prev_gen);
}
#[test]
fn set_width_different_bumps_generation() {
let mut b = breaker(80);
let prev_gen = b.generation();
b.set_width(40);
assert!(b.generation() > prev_gen);
}
#[test]
fn set_objective_clears_cache() {
let mut b = breaker(80);
b.reflow("Hello world.");
b.set_objective(ParagraphObjective::typographic());
let snap = b.snapshot();
assert_eq!(snap.cached_paragraphs, 0);
}
#[test]
fn reflow_full_recomputes_everything() {
let mut b = breaker(80);
b.reflow("A.\nB.");
let r = b.reflow_full("A.\nB.");
assert_eq!(r.recomputed, vec![0, 1]);
}
#[test]
fn snapshot_initial_state() {
let b = breaker(80);
let snap = b.snapshot();
assert_eq!(snap.width, 80);
assert_eq!(snap.cached_paragraphs, 0);
assert_eq!(snap.dirty_paragraphs, 0);
assert_eq!(snap.generation, 0);
assert_eq!(snap.total_reflows, 0);
}
#[test]
fn snapshot_after_reflow() {
let mut b = breaker(80);
b.reflow("A.\nB.\nC.");
let snap = b.snapshot();
assert_eq!(snap.cached_paragraphs, 3);
assert_eq!(snap.total_reflows, 1);
assert_eq!(snap.cache_misses, 3);
}
#[test]
fn reflow_deterministic() {
let mut b1 = breaker(30);
let mut b2 = breaker(30);
let text = "The quick brown fox jumps over the lazy dog near the river bank.";
let r1 = b1.reflow(text);
let r2 = b2.reflow(text);
assert_eq!(r1.lines, r2.lines);
assert_eq!(r1.total_cost, r2.total_cost);
}
#[test]
fn reflow_idempotent() {
let mut b = breaker(30);
let text = "The quick brown fox jumps over the lazy dog.";
let r1 = b.reflow(text);
let r2 = b.reflow_full(text);
assert_eq!(r1.lines, r2.lines);
assert_eq!(r1.total_cost, r2.total_cost);
}
#[test]
fn reflow_zero_width() {
let mut b = breaker(0);
let r = b.reflow("Hello");
assert!(!r.lines.is_empty());
}
#[test]
fn reflow_very_narrow() {
let mut b = breaker(1);
let r = b.reflow("abc");
assert!(!r.lines.is_empty());
}
#[test]
fn reflow_only_newlines() {
let mut b = breaker(80);
let r = b.reflow("\n\n\n");
assert_eq!(r.paragraph_count, 4); }
#[test]
fn reflow_paragraph_count_changes() {
let mut b = breaker(80);
b.reflow("A.\nB.\nC.");
let r = b.reflow("A.\nB.");
assert_eq!(r.paragraph_count, 2);
}
#[test]
fn reflow_paragraph_count_grows() {
let mut b = breaker(80);
b.reflow("A.\nB.");
let r = b.reflow("A.\nB.\nC.\nD.");
assert_eq!(r.paragraph_count, 4);
assert!(r.recomputed.contains(&2));
assert!(r.recomputed.contains(&3));
}
#[test]
fn multiple_edits_accumulate() {
let mut b = breaker(80);
b.reflow("One.\nTwo.\nThree.");
b.invalidate_paragraph(0);
b.invalidate_paragraph(2);
let r = b.reflow("One.\nTwo.\nThree.");
assert_eq!(r.recomputed, vec![0, 2]);
}
#[test]
fn long_paragraph_performance() {
let mut b = breaker(60);
let text = "word ".repeat(500);
let r = b.reflow(&text);
assert!(r.lines.len() > 1);
let r2 = b.reflow(&text);
assert!(r2.recomputed.is_empty());
}
#[test]
fn terminal_constructor() {
let b = IncrementalBreaker::terminal(120);
assert_eq!(b.width(), 120);
assert_eq!(b.objective().line_penalty, 20); }
#[test]
fn new_with_default_objective() {
let b = IncrementalBreaker::new(80, ParagraphObjective::default());
assert_eq!(b.objective().line_penalty, 10); }
}