fresh/view/line_wrap_cache.rs
1//! Line-wrap pipeline-output cache.
2//!
3//! A bounded per-buffer cache from `LineWrapKey` to `Arc<Vec<ViewLine>>` —
4//! the final output of the render pipeline for a single logical line.
5//!
6//! See `docs/internal/line-wrap-cache-plan.md` for the full design. In
7//! brief:
8//!
9//! * **Single source of truth.** The value stored is what the renderer
10//! actually produces. Every consumer that needs to know "how many
11//! visual rows?", "where does byte X land visually?", "what byte is at
12//! visual column N?" reads the same `ViewLine` structures via the
13//! methods `ViewLine` already exposes (`source_byte_at_char`,
14//! `char_at_visual_col`, `source_byte_at_visual_col`, `visual_col_at_char`,
15//! `visual_width`). No second implementation to drift from.
16//!
17//! * **Two writers, one pipeline.** The renderer populates cache entries
18//! as a side effect of its normal per-frame work; the miss handler in
19//! this module runs the same four-step pipeline scoped to a single
20//! logical line. Same inputs → same output.
21//!
22//! * **Invalidation by key.** The key includes `pipeline_inputs_version`
23//! (a packed u64 derived from `buffer.version()`, `SoftBreakManager::
24//! version()`, and `ConcealManager::version()`) plus every geometry /
25//! view dimension the pipeline reads. Mutating any of those produces a
26//! different key; old entries become unreachable and age out via FIFO
27//! eviction. There is no active invalidate step.
28//!
29//! * **Byte-budget eviction.** Because `Vec<ViewLine>` sizes vary from
30//! a few hundred bytes for a short line to megabytes for a long line
31//! wrapping into thousands of rows, count-based eviction is the wrong
32//! metric. The cache tracks approximate total memory and evicts
33//! oldest-first when a new insert would exceed the byte budget.
34//!
35//! Structural invariants maintained at all times:
36//!
37//! self.map.len() == self.order.len()
38//! self.current_bytes <= self.byte_budget (after any insert)
39
40use crate::state::EditorState;
41use crate::view::ui::split_rendering::base_tokens::build_base_tokens;
42use crate::view::ui::split_rendering::transforms::{
43 apply_conceal_ranges, apply_soft_breaks, apply_wrapping_transform,
44};
45use crate::view::ui::view_pipeline::{ViewLine, ViewLineIterator};
46use fresh_core::api::ViewTokenWireKind;
47use std::collections::{HashMap, VecDeque};
48use std::sync::Arc;
49
50/// Default byte budget: 8 MiB. Comfortably holds the full layout for a
51/// small-to-medium buffer, a handful of huge lines, or any interactive
52/// scroll span. A single 200 KB line wrapping to ~2000 rows takes
53/// roughly 2 MB in its `Vec<ViewLine>` form, so the budget can absorb
54/// several such lines before churning.
55pub const DEFAULT_BYTE_BUDGET: usize = 8 * 1024 * 1024;
56
57/// View mode the pipeline is running in. Conceals and some plugin-
58/// rendered content only apply in Compose. Kept as a small plain enum
59/// so the key stays cheap to hash.
60#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
61pub enum CacheViewMode {
62 Source,
63 Compose,
64}
65
66/// Full set of inputs that determine a single logical line's wrapped
67/// layout. Every mutable input must be represented here — if the
68/// caller forgets one, stale entries can be returned.
69///
70/// The `pipeline_inputs_version` folds in the buffer version plus the
71/// soft-break and conceal managers' versions (see
72/// [`pipeline_inputs_version`]). The remaining fields are geometry /
73/// viewport config.
74#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
75pub struct LineWrapKey {
76 pub pipeline_inputs_version: u64,
77 pub view_mode: CacheViewMode,
78 pub line_start: usize,
79 pub effective_width: u32,
80 pub gutter_width: u16,
81 pub wrap_column: Option<u32>,
82 pub hanging_indent: bool,
83 pub line_wrap_enabled: bool,
84}
85
86/// Derive the combined pipeline-inputs version from the three source
87/// versions. Any change to any of them flips the combined value. This
88/// is not a hash — it's a packed integer with enough bit-budget to make
89/// accidental collisions astronomically unlikely in a single session.
90///
91/// * `buffer_version` gets the low 32 bits (wrapped to u32). Buffer
92/// edits are the most frequent source of change.
93/// * `soft_breaks_version` is shifted up 32 bits.
94/// * `conceal_version` is shifted up 48 bits.
95/// * `virtual_text_version` is shifted up 16 bits. Folded so that
96/// adding / removing plugin virtual lines (e.g.
97/// markdown_compose's table borders, git blame headers)
98/// invalidates the same caches the other three sources do —
99/// `VisualRowIndex` adds virtual line counts to its prefix sums and
100/// would otherwise serve a stale total when the plugin re-tiles a
101/// table.
102#[inline]
103pub fn pipeline_inputs_version(
104 buffer_version: u64,
105 soft_breaks_version: u32,
106 conceal_version: u32,
107 virtual_text_version: u32,
108) -> u64 {
109 (buffer_version & 0xFFFF_FFFF)
110 ^ ((soft_breaks_version as u64) << 32)
111 ^ ((conceal_version as u64) << 48)
112 ^ ((virtual_text_version as u64) << 16)
113}
114
115/// Estimate the in-memory size of a `Vec<ViewLine>` for byte-budget
116/// accounting. Rough but stable — we'd rather over- than under-estimate
117/// so the budget stays honest.
118///
119/// Per `ViewLine`:
120/// - `text` (String): bytes in the rendered text
121/// - `char_source_bytes` (Vec<Option<usize>>): 16 bytes × chars
122/// - `char_styles` (Vec<Option<ViewTokenStyle>>): ~32 bytes × chars
123/// - `char_visual_cols` (Vec<usize>): 8 bytes × chars
124/// - `visual_to_char` (Vec<usize>): 8 bytes × visual cols
125/// - overhead (HashSet, enum, bool, alignment padding): ~64 bytes
126///
127/// Round up to `visual_width * 64 + text.len() + 96` for simplicity.
128fn estimate_view_lines_bytes(lines: &[ViewLine]) -> usize {
129 let mut total = 48; // Arc + Vec overhead
130 for line in lines {
131 let chars = line.char_source_bytes.len();
132 let visual = line.visual_to_char.len();
133 total += line.text.len() + chars * 56 + visual * 8 + 96;
134 }
135 total
136}
137
138/// Bounded FIFO cache from `LineWrapKey` to `Arc<Vec<ViewLine>>`.
139///
140/// FIFO (not LRU) because the dominant access pattern is sequential
141/// scrolling: each line is queried a few times in close succession, then
142/// rarely again. FIFO is simpler to reason about and matches this
143/// pattern well enough. If future profiling shows churn we can swap the
144/// eviction policy — the external API doesn't change.
145#[derive(Debug, Clone)]
146pub struct LineWrapCache {
147 map: HashMap<LineWrapKey, Arc<Vec<ViewLine>>>,
148 order: VecDeque<LineWrapKey>,
149 byte_budget: usize,
150 current_bytes: usize,
151}
152
153impl Default for LineWrapCache {
154 fn default() -> Self {
155 Self::with_byte_budget(DEFAULT_BYTE_BUDGET)
156 }
157}
158
159impl LineWrapCache {
160 pub fn with_byte_budget(byte_budget: usize) -> Self {
161 assert!(byte_budget > 0, "LineWrapCache byte_budget must be > 0");
162 Self {
163 map: HashMap::new(),
164 order: VecDeque::new(),
165 byte_budget,
166 current_bytes: 0,
167 }
168 }
169
170 pub fn len(&self) -> usize {
171 debug_assert_eq!(
172 self.map.len(),
173 self.order.len(),
174 "LineWrapCache invariant: map.len() == order.len()"
175 );
176 self.map.len()
177 }
178
179 pub fn is_empty(&self) -> bool {
180 self.len() == 0
181 }
182
183 pub fn byte_budget(&self) -> usize {
184 self.byte_budget
185 }
186
187 pub fn current_bytes(&self) -> usize {
188 self.current_bytes
189 }
190
191 /// Look up a cached value. Returns `None` on miss. The returned
192 /// `Arc` is a cheap clone; callers can hold it without copying the
193 /// underlying `Vec<ViewLine>`.
194 pub fn get(&self, key: &LineWrapKey) -> Option<Arc<Vec<ViewLine>>> {
195 self.map.get(key).cloned()
196 }
197
198 /// Query by key; on miss, run `compute` and store its result. The
199 /// primary entry point for both the renderer's write path and the
200 /// scroll-math miss handler.
201 ///
202 /// Returns the (possibly just-computed) value as an `Arc`. The
203 /// `compute` closure is called at most once per cache miss; hits do
204 /// not invoke it.
205 pub fn get_or_insert_with<F>(&mut self, key: LineWrapKey, compute: F) -> Arc<Vec<ViewLine>>
206 where
207 F: FnOnce() -> Vec<ViewLine>,
208 {
209 if let Some(v) = self.map.get(&key) {
210 return v.clone();
211 }
212 let value = Arc::new(compute());
213 self.insert_fresh(key, value.clone());
214 value
215 }
216
217 /// Unconditionally store a value for `key`. If `key` is already
218 /// present, its value is replaced in place and its FIFO position is
219 /// **not** changed (this keeps the queue simple — re-inserts don't
220 /// refresh age). Byte-budget accounting is updated.
221 pub fn put(&mut self, key: LineWrapKey, value: Arc<Vec<ViewLine>>) {
222 if let Some(existing) = self.map.get_mut(&key) {
223 let old_bytes = estimate_view_lines_bytes(existing);
224 let new_bytes = estimate_view_lines_bytes(&value);
225 *existing = value;
226 self.current_bytes = self.current_bytes + new_bytes - old_bytes.min(self.current_bytes);
227 return;
228 }
229 self.insert_fresh(key, value);
230 }
231
232 /// Remove all entries. Used by tests and by future
233 /// plugin-lifecycle events.
234 pub fn clear(&mut self) {
235 self.map.clear();
236 self.order.clear();
237 self.current_bytes = 0;
238 }
239
240 /// Insert a never-before-seen key, evicting oldest-first until the
241 /// new entry fits inside `byte_budget`.
242 fn insert_fresh(&mut self, key: LineWrapKey, value: Arc<Vec<ViewLine>>) {
243 debug_assert!(!self.map.contains_key(&key));
244 let new_bytes = estimate_view_lines_bytes(&value);
245
246 // Evict until (current_bytes + new_bytes) fits. Always keep at
247 // least one slot — if the single new entry alone exceeds the
248 // budget, we still accept it (the cache was asked to hold it;
249 // the alternative is silently dropping data the caller just
250 // paid to compute).
251 while self.current_bytes + new_bytes > self.byte_budget && !self.order.is_empty() {
252 if let Some(oldest_key) = self.order.pop_front() {
253 if let Some(oldest_val) = self.map.remove(&oldest_key) {
254 let shed = estimate_view_lines_bytes(&oldest_val);
255 self.current_bytes = self.current_bytes.saturating_sub(shed);
256 }
257 }
258 }
259
260 self.map.insert(key, value);
261 self.order.push_back(key);
262 self.current_bytes += new_bytes;
263 debug_assert_eq!(self.map.len(), self.order.len());
264 }
265}
266
267/// Materialise a line's layout as `Vec<ViewLine>` from plain text
268/// alone — no buffer iteration, no soft breaks, no conceals.
269///
270/// Useful at sites that have `line_text: &str` in hand and can't
271/// easily reach `EditorState` (or are inside a `line_iterator` borrow).
272/// The produced `ViewLine`s match the renderer's word-boundary wrap
273/// on the same text at the same geometry, so row counts and cursor
274/// mappings agree with `layout_for_line` in the absence of soft
275/// breaks / conceals. When soft breaks or conceals ARE active for
276/// the line, callers should prefer `layout_for_line` to get accurate
277/// layout.
278pub fn layout_for_plain_text(
279 line_text: &str,
280 effective_width: usize,
281 gutter_width: usize,
282 hanging_indent: bool,
283 tab_size: usize,
284) -> Vec<ViewLine> {
285 use crate::view::ui::view_pipeline::LineStart;
286 use fresh_core::api::ViewTokenWire;
287 let tokens = vec![ViewTokenWire {
288 source_offset: Some(0),
289 kind: ViewTokenWireKind::Text(line_text.to_string()),
290 style: None,
291 }];
292 let wrapped = apply_wrapping_transform(tokens, effective_width, gutter_width, hanging_indent);
293 let mut lines: Vec<ViewLine> =
294 ViewLineIterator::new(&wrapped, false, true, tab_size, false).collect();
295 // Invariant: every logical line is at least one visual row. An
296 // empty input produces zero ViewLines through the iterator; emit
297 // one placeholder so callers (scrollbar row counts, scroll math)
298 // see consistent ≥1 results matching `compute_line_layout`.
299 if lines.is_empty() {
300 lines.push(ViewLine {
301 text: String::new(),
302 source_start_byte: Some(0),
303 char_source_bytes: Vec::new(),
304 char_styles: Vec::new(),
305 char_visual_cols: Vec::new(),
306 visual_to_char: Vec::new(),
307 tab_starts: std::collections::HashSet::new(),
308 line_start: LineStart::Beginning,
309 ends_with_newline: false,
310 virtual_gutter_glyph: None,
311 virtual_line_style: None,
312 });
313 }
314 lines
315}
316
317/// Look up a line's layout in the cache, running the mini-pipeline to
318/// fill on miss. The primary read-path entry point for consumers that
319/// need full `ViewLine` layout (not just row count).
320///
321/// Guarantees that the value returned matches what the renderer would
322/// produce for the same line under the same pipeline inputs: same
323/// function chain is called either way, so cache hit and miss are
324/// indistinguishable to the caller.
325pub fn layout_for_line(
326 state: &mut EditorState,
327 line_start: usize,
328 line_end: usize,
329 geom: &WrapGeometry,
330) -> Arc<Vec<ViewLine>> {
331 let version = pipeline_inputs_version(
332 state.buffer.version(),
333 state.soft_breaks.version(),
334 state.conceals.version(),
335 state.virtual_texts.version(),
336 );
337 let key = geom.key(line_start, version);
338 if let Some(cached) = state.line_wrap_cache.get(&key) {
339 return cached;
340 }
341 let layout = compute_line_layout(state, line_start, line_end, geom);
342 let arc = Arc::new(layout);
343 state.line_wrap_cache.put(key, arc.clone());
344 arc
345}
346
347/// Given a logical line's layout and a character position within the
348/// LOGICAL line (not the ViewLine), return `(segment_idx,
349/// col_in_segment)` — the index of the `ViewLine` the character falls
350/// into, and the visual column within that `ViewLine`.
351///
352/// Replaces `primitives::line_wrapping::char_position_to_segment` for
353/// callers that have a cached `Vec<ViewLine>`.
354///
355/// The trick: continuation `ViewLine`s can carry hanging-indent
356/// characters at their start whose `source_offset` is `None` (they
357/// don't correspond to any source byte). Those chars must NOT count
358/// toward the source-character position we're walking past. So we
359/// sum *source* characters per row (char_source_bytes entries that
360/// are `Some(_)`) to find the row containing `char_pos_in_line`, and
361/// within that row we locate the specific char whose source_offset
362/// matches.
363///
364/// If `layout` is empty, returns `(0, 0)`. If the position is past
365/// the end of the last row, returns the last row with the last
366/// visual column of that row.
367pub fn char_position_in_layout(layout: &[ViewLine], char_pos_in_line: usize) -> (usize, usize) {
368 if layout.is_empty() {
369 return (0, 0);
370 }
371 let mut source_chars_consumed = 0usize;
372 for (i, line) in layout.iter().enumerate() {
373 let source_chars_in_row = line
374 .char_source_bytes
375 .iter()
376 .filter(|b| b.is_some())
377 .count();
378 if char_pos_in_line < source_chars_consumed + source_chars_in_row {
379 // The target source-char is in this row. Find the
380 // `char_idx` whose position-among-source-chars equals
381 // the within-row offset, then convert to visual column.
382 let within_row = char_pos_in_line - source_chars_consumed;
383 let mut source_count = 0usize;
384 for (char_idx, byte) in line.char_source_bytes.iter().enumerate() {
385 if byte.is_some() {
386 if source_count == within_row {
387 return (i, line.visual_col_at_char(char_idx));
388 }
389 source_count += 1;
390 }
391 }
392 // Fallback: shouldn't happen given the length check above,
393 // but don't return garbage if it does.
394 return (i, line.visual_width().saturating_sub(1));
395 }
396 source_chars_consumed += source_chars_in_row;
397 }
398 // Past the end: return the last row's last visual column. (A
399 // cursor one past the last source char on the last row lands
400 // here.)
401 let last_idx = layout.len() - 1;
402 let last = &layout[last_idx];
403 let last_col = last.visual_width().saturating_sub(1);
404 (last_idx, last_col)
405}
406
407/// Geometry + view config inputs to the wrap pipeline that aren't carried
408/// by `EditorState`. Bundled so the plumbing through call sites doesn't
409/// grow a laundry list of parameters.
410#[derive(Debug, Clone, Copy)]
411pub struct WrapGeometry {
412 pub effective_width: usize,
413 pub gutter_width: usize,
414 pub hanging_indent: bool,
415 pub wrap_column: Option<u32>,
416 pub line_wrap_enabled: bool,
417 pub view_mode: CacheViewMode,
418}
419
420impl WrapGeometry {
421 /// Build a cache key for a logical line at `line_start` under these
422 /// geometry and pipeline-input versions.
423 pub fn key(&self, line_start: usize, pipeline_inputs_version: u64) -> LineWrapKey {
424 LineWrapKey {
425 pipeline_inputs_version,
426 view_mode: self.view_mode,
427 line_start,
428 effective_width: self.effective_width as u32,
429 gutter_width: self.gutter_width as u16,
430 wrap_column: self.wrap_column,
431 hanging_indent: self.hanging_indent,
432 line_wrap_enabled: self.line_wrap_enabled,
433 }
434 }
435}
436
437/// Run the same pipeline the renderer runs, scoped to exactly one
438/// logical line starting at `line_start`, and return the rendered
439/// [`ViewLine`]s for that line. Used by the cache miss handler.
440///
441/// When `geom.line_wrap_enabled` is false, returns a single
442/// placeholder `ViewLine` — an unwrapped line always occupies exactly
443/// one visual row. (Callers that only need a count can read
444/// `.len()`; callers that need coordinate mappings would not query
445/// this path with wrapping off.)
446///
447/// The four pipeline steps mirror `view_data::build_view_data`:
448/// 1. `build_base_tokens(top_byte=line_start, count=1)`
449/// 2. `apply_soft_breaks` (Compose mode, when any soft breaks overlap)
450/// 3. `apply_conceal_ranges` (Compose mode, when any conceals overlap)
451/// 4. `apply_wrapping_transform`
452/// followed by `ViewLineIterator::collect()` to materialise the
453/// `Vec<ViewLine>`.
454///
455/// The result is what the renderer would produce for this single
456/// logical line — the single source of truth the cache exists to
457/// share.
458pub fn compute_line_layout(
459 state: &mut EditorState,
460 line_start: usize,
461 line_end: usize,
462 geom: &WrapGeometry,
463) -> Vec<ViewLine> {
464 let is_binary = state.buffer.is_binary();
465 let line_ending = state.buffer.line_ending();
466 let estimated_line_length = state.buffer.estimated_line_length();
467 let tab_size = state.buffer_settings.tab_size;
468
469 // Step 1: build tokens for just this one logical line.
470 let mut tokens = build_base_tokens(
471 &mut state.buffer,
472 line_start,
473 estimated_line_length,
474 1, // just this one logical line
475 is_binary,
476 line_ending,
477 &[], // no fold skip ranges — folds affect what's rendered, not per-line wrap count
478 );
479
480 let is_compose = matches!(geom.view_mode, CacheViewMode::Compose);
481
482 // Step 2: soft breaks (Compose mode only; same gating as the renderer).
483 if is_compose && !state.soft_breaks.is_empty() {
484 let sb = state
485 .soft_breaks
486 .query_viewport(line_start, line_end, &state.marker_list);
487 if !sb.is_empty() {
488 tokens = apply_soft_breaks(tokens, &sb);
489 }
490 }
491
492 // Step 3: conceal ranges (Compose mode only).
493 if is_compose && !state.conceals.is_empty() {
494 let cr = state
495 .conceals
496 .query_viewport(line_start, line_end, &state.marker_list);
497 if !cr.is_empty() {
498 tokens = apply_conceal_ranges(tokens, &cr);
499 }
500 }
501
502 // Step 4: wrap (only when line-wrap is actually enabled). When
503 // disabled, pass tokens through unchanged; ViewLineIterator will
504 // still yield one ViewLine per Newline boundary.
505 if geom.line_wrap_enabled {
506 tokens = apply_wrapping_transform(
507 tokens,
508 geom.effective_width,
509 geom.gutter_width,
510 geom.hanging_indent,
511 );
512 }
513
514 // Materialise the ViewLines. `build_base_tokens` may emit tokens
515 // for more than one logical line; collect only the first logical
516 // line's ViewLines (those up to and including the first Newline).
517 let all_lines: Vec<ViewLine> =
518 ViewLineIterator::new(&tokens, is_binary, !is_binary, tab_size, false).collect();
519
520 // The `ViewLineIterator` produces one `ViewLine` per visual row.
521 // The Newline tokens inside split the stream at logical-line
522 // boundaries: every `ViewLine` after the first whose `line_start`
523 // is `AfterSourceNewline` begins a NEW logical line, which we
524 // don't want. Keep only rows up to (but not including) the first
525 // such transition.
526 let mut result = Vec::with_capacity(all_lines.len().min(8));
527 for (i, line) in all_lines.into_iter().enumerate() {
528 use crate::view::ui::view_pipeline::LineStart;
529 if i > 0 && matches!(line.line_start, LineStart::AfterSourceNewline) {
530 break;
531 }
532 result.push(line);
533 }
534 if result.is_empty() {
535 // Defensive: even a completely empty logical line corresponds
536 // to exactly one visual row. The iterator should always
537 // produce at least one, but be safe.
538 result.push(ViewLine {
539 text: String::new(),
540 source_start_byte: Some(line_start),
541 char_source_bytes: Vec::new(),
542 char_styles: Vec::new(),
543 char_visual_cols: Vec::new(),
544 visual_to_char: Vec::new(),
545 tab_starts: std::collections::HashSet::new(),
546 line_start: crate::view::ui::view_pipeline::LineStart::Beginning,
547 ends_with_newline: false,
548 virtual_gutter_glyph: None,
549 virtual_line_style: None,
550 });
551 }
552 result
553}
554
555/// Row count only. Thin wrapper over [`compute_line_layout`] for
556/// callers that need just the visual-row count — scroll math,
557/// thumb-size math. Prefer calling through the cache
558/// (`get_or_insert_with(key, || compute_line_layout(...)).len()`).
559pub fn count_visual_rows_via_pipeline(
560 state: &mut EditorState,
561 line_start: usize,
562 line_end: usize,
563 geom: &WrapGeometry,
564) -> u32 {
565 compute_line_layout(state, line_start, line_end, geom).len() as u32
566}
567
568/// Combined version of all pipeline inputs on the given state. Fold into
569/// a `LineWrapKey` to make stale entries unreachable on any mutation.
570#[inline]
571pub fn state_pipeline_inputs_version(state: &EditorState) -> u64 {
572 pipeline_inputs_version(
573 state.buffer.version(),
574 state.soft_breaks.version(),
575 state.conceals.version(),
576 state.virtual_texts.version(),
577 )
578}
579
580/// Build a placeholder `Vec<ViewLine>` of a given row count for cache
581/// consumers that only need `.len()` (e.g. scroll math's count-only
582/// queries, or the per-viewport row-count memoization). The returned
583/// `ViewLine`s have empty char/visual mappings — they carry no real
584/// layout information.
585///
586/// This exists because the cache is typed on `Vec<ViewLine>` so the
587/// cross-consumer path can share real layout, but some call sites
588/// don't yet have access to `EditorState` (needed by
589/// [`compute_line_layout`]). When those sites are migrated to take
590/// `&mut EditorState`, this helper can go away.
591pub fn placeholder_layout_for_row_count(n: u32) -> Vec<ViewLine> {
592 use crate::view::ui::view_pipeline::LineStart;
593 (0..n)
594 .map(|_| ViewLine {
595 text: String::new(),
596 source_start_byte: None,
597 char_source_bytes: Vec::new(),
598 char_styles: Vec::new(),
599 char_visual_cols: Vec::new(),
600 visual_to_char: Vec::new(),
601 tab_starts: std::collections::HashSet::new(),
602 line_start: LineStart::Beginning,
603 ends_with_newline: false,
604 virtual_gutter_glyph: None,
605 virtual_line_style: None,
606 })
607 .collect()
608}
609
610/// Count visual rows for a single line's text after applying the
611/// plugin's soft breaks AND the renderer's word-wrap. Mirrors the
612/// renderer's full pipeline (`apply_soft_breaks` → `apply_wrapping_transform`)
613/// so the scroll math agrees row-for-row with the rendered output even
614/// when the plugin has injected breaks at narrower-than-viewport
615/// widths (e.g. markdown_compose's per-paragraph wrap).
616///
617/// `soft_breaks_in_line` is the slice of `(byte_position, indent)` pairs
618/// for breaks falling **inside** `[line_start, line_start + line_text.len())`.
619/// Callers should pre-filter from the buffer-wide list.
620///
621/// When `soft_breaks_in_line` is empty this is a thin wrapper over
622/// [`count_visual_rows_for_text`].
623pub fn count_visual_rows_for_text_with_soft_breaks(
624 line_text: &str,
625 line_start: usize,
626 soft_breaks_in_line: &[(usize, u16)],
627 effective_width: usize,
628 gutter_width: usize,
629 hanging_indent: bool,
630) -> u32 {
631 if soft_breaks_in_line.is_empty() {
632 return count_visual_rows_for_text(
633 line_text,
634 effective_width,
635 gutter_width,
636 hanging_indent,
637 );
638 }
639
640 let mut total: u32 = 0;
641 let mut prev_end: usize = 0; // byte offset within `line_text`
642 let mut prev_indent: u16 = 0;
643
644 for &(pos, indent) in soft_breaks_in_line {
645 // Defensive: callers pre-filter, but ignore anything out of
646 // range so a stale break list can't OOB-slice the line.
647 if pos < line_start {
648 continue;
649 }
650 let rel = pos - line_start;
651 if rel >= line_text.len() {
652 continue;
653 }
654 if rel < prev_end {
655 // Break list is sorted; this would only fire on a
656 // duplicate or a not-byte-aligned offset. Skip rather
657 // than panic.
658 continue;
659 }
660 let segment = &line_text[prev_end..rel];
661 total = total.saturating_add(count_segment_rows_with_indent(
662 segment,
663 prev_indent,
664 effective_width,
665 gutter_width,
666 hanging_indent,
667 ));
668 // The renderer's `apply_soft_breaks` consumes the Space token
669 // *at* the break position when one is present (see
670 // transforms.rs::apply_soft_breaks). Skip exactly one
671 // character at `rel` to mirror that — UTF-8 safe.
672 let consumed = line_text[rel..]
673 .chars()
674 .next()
675 .map(|c| c.len_utf8())
676 .unwrap_or(0);
677 prev_end = (rel + consumed).min(line_text.len());
678 prev_indent = indent;
679 }
680 let segment = &line_text[prev_end..];
681 total = total.saturating_add(count_segment_rows_with_indent(
682 segment,
683 prev_indent,
684 effective_width,
685 gutter_width,
686 hanging_indent,
687 ));
688 total.max(1)
689}
690
691/// Helper for [`count_visual_rows_for_text_with_soft_breaks`]:
692/// row count for one inter-break segment with `leading_indent`
693/// columns reserved at the front. An empty segment still occupies
694/// one visual row (matches the renderer, which emits a trailing
695/// `Break` for the broken position).
696fn count_segment_rows_with_indent(
697 segment: &str,
698 leading_indent: u16,
699 effective_width: usize,
700 gutter_width: usize,
701 hanging_indent: bool,
702) -> u32 {
703 if segment.is_empty() && leading_indent == 0 {
704 return 1;
705 }
706 if leading_indent == 0 {
707 return count_visual_rows_for_text(segment, effective_width, gutter_width, hanging_indent);
708 }
709 // Prepend the indent columns; this lets the renderer's word-wrap
710 // see the same `current_line_width` it would after
711 // `apply_soft_breaks` injected indent Spaces.
712 let mut prefixed = String::with_capacity(leading_indent as usize + segment.len());
713 for _ in 0..leading_indent {
714 prefixed.push(' ');
715 }
716 prefixed.push_str(segment);
717 count_visual_rows_for_text(&prefixed, effective_width, gutter_width, hanging_indent)
718}
719
720/// Count visual rows for a single line's text under the renderer's
721/// wrap algorithm. Pure function of (text, geometry).
722///
723/// Behaves exactly like the renderer's per-logical-line wrap count:
724/// runs `apply_wrapping_transform` on a single-`Text`-token input and
725/// tallies non-empty rows. A trailing `Break` emitted when the last
726/// chunk exactly fills the effective width is followed by nothing
727/// meaningful and does not count as a row.
728pub fn count_visual_rows_for_text(
729 line_text: &str,
730 effective_width: usize,
731 gutter_width: usize,
732 hanging_indent: bool,
733) -> u32 {
734 use crate::view::ui::split_rendering::transforms::apply_wrapping_transform;
735 use fresh_core::api::ViewTokenWire;
736
737 let tokens = vec![ViewTokenWire {
738 source_offset: Some(0),
739 kind: ViewTokenWireKind::Text(line_text.to_string()),
740 style: None,
741 }];
742 let wrapped = apply_wrapping_transform(tokens, effective_width, gutter_width, hanging_indent);
743 let mut rows: u32 = 0;
744 let mut row_has_content = false;
745 for t in &wrapped {
746 match &t.kind {
747 ViewTokenWireKind::Newline => break,
748 ViewTokenWireKind::Break => {
749 if row_has_content {
750 rows += 1;
751 }
752 row_has_content = false;
753 }
754 ViewTokenWireKind::Text(s) => {
755 if !s.is_empty() {
756 row_has_content = true;
757 }
758 }
759 ViewTokenWireKind::Space | ViewTokenWireKind::BinaryByte(_) => {
760 row_has_content = true;
761 }
762 }
763 }
764 if row_has_content {
765 rows += 1;
766 }
767 rows.max(1)
768}
769
770#[cfg(test)]
771mod tests {
772 use super::*;
773 use crate::view::ui::view_pipeline::LineStart;
774
775 fn key(line_start: usize, version: u64) -> LineWrapKey {
776 LineWrapKey {
777 pipeline_inputs_version: version,
778 view_mode: CacheViewMode::Source,
779 line_start,
780 effective_width: 80,
781 gutter_width: 6,
782 wrap_column: None,
783 hanging_indent: false,
784 line_wrap_enabled: true,
785 }
786 }
787
788 /// Build a dummy `Vec<ViewLine>` of length `n` for primitive tests
789 /// that only care about how the cache stores / evicts values, not
790 /// about the actual pipeline output. Each `ViewLine` is empty
791 /// apart from its row identity.
792 fn dummy_lines(n: u32) -> Vec<ViewLine> {
793 (0..n)
794 .map(|_| ViewLine {
795 text: String::new(),
796 source_start_byte: Some(0),
797 char_source_bytes: Vec::new(),
798 char_styles: Vec::new(),
799 char_visual_cols: Vec::new(),
800 visual_to_char: Vec::new(),
801 tab_starts: std::collections::HashSet::new(),
802 line_start: LineStart::Beginning,
803 ends_with_newline: false,
804 virtual_gutter_glyph: None,
805 virtual_line_style: None,
806 })
807 .collect()
808 }
809
810 /// Roomy byte budget for tests that shouldn't evict.
811 const ROOMY: usize = 1024 * 1024;
812 /// Tight byte budget that evicts after a handful of empty lines.
813 /// Each empty `ViewLine` is ~96 bytes plus 48 Vec/Arc overhead, so
814 /// this budget holds roughly 3 entries.
815 const TIGHT: usize = 500;
816
817 #[test]
818 fn empty_cache_is_empty() {
819 let cache = LineWrapCache::default();
820 assert!(cache.is_empty());
821 assert_eq!(cache.len(), 0);
822 assert_eq!(cache.current_bytes(), 0);
823 }
824
825 #[test]
826 fn get_or_insert_caches_on_miss() {
827 let mut cache = LineWrapCache::with_byte_budget(ROOMY);
828 let mut compute_calls = 0;
829 let v = cache.get_or_insert_with(key(100, 1), || {
830 compute_calls += 1;
831 dummy_lines(7)
832 });
833 assert_eq!(v.len(), 7);
834 assert_eq!(compute_calls, 1);
835 assert_eq!(cache.len(), 1);
836 }
837
838 #[test]
839 fn repeat_lookup_is_a_hit() {
840 let mut cache = LineWrapCache::with_byte_budget(ROOMY);
841 let mut compute_calls = 0;
842 cache.get_or_insert_with(key(100, 1), || {
843 compute_calls += 1;
844 dummy_lines(7)
845 });
846 let v = cache.get_or_insert_with(key(100, 1), || {
847 compute_calls += 1;
848 dummy_lines(99) // wrong value, should not be invoked
849 });
850 assert_eq!(v.len(), 7);
851 assert_eq!(compute_calls, 1, "second lookup should be a hit");
852 }
853
854 #[test]
855 fn different_versions_are_separate_entries() {
856 let mut cache = LineWrapCache::with_byte_budget(ROOMY);
857 cache.get_or_insert_with(key(100, 1), || dummy_lines(3));
858 cache.get_or_insert_with(key(100, 2), || dummy_lines(5));
859 assert_eq!(cache.get(&key(100, 1)).map(|v| v.len()), Some(3));
860 assert_eq!(cache.get(&key(100, 2)).map(|v| v.len()), Some(5));
861 assert_eq!(cache.len(), 2);
862 }
863
864 #[test]
865 fn evicts_oldest_when_byte_budget_reached() {
866 let mut cache = LineWrapCache::with_byte_budget(TIGHT);
867 cache.get_or_insert_with(key(100, 1), || dummy_lines(1));
868 cache.get_or_insert_with(key(200, 1), || dummy_lines(1));
869 cache.get_or_insert_with(key(300, 1), || dummy_lines(1));
870 // Adding a fourth tiny entry should evict at least the oldest
871 // (line_start=100) to stay within the budget.
872 cache.get_or_insert_with(key(400, 1), || dummy_lines(1));
873 assert!(cache.current_bytes() <= TIGHT);
874 assert_eq!(cache.get(&key(100, 1)).is_none(), true, "oldest evicted");
875 // Later entries still reachable.
876 assert!(cache.get(&key(400, 1)).is_some());
877 }
878
879 #[test]
880 fn structural_invariant_holds_under_many_inserts() {
881 let mut cache = LineWrapCache::with_byte_budget(TIGHT);
882 for i in 0..200u64 {
883 cache.get_or_insert_with(key(i as usize, i), || dummy_lines(1));
884 assert_eq!(cache.len(), cache.map.len());
885 assert_eq!(cache.len(), cache.order.len());
886 assert_eq!(cache.current_bytes <= cache.byte_budget, true);
887 }
888 }
889
890 #[test]
891 fn put_overwrites_existing_value_without_reordering() {
892 let mut cache = LineWrapCache::with_byte_budget(ROOMY);
893 cache.get_or_insert_with(key(100, 1), || dummy_lines(1));
894 cache.get_or_insert_with(key(200, 1), || dummy_lines(1));
895 cache.get_or_insert_with(key(300, 1), || dummy_lines(1));
896 // Overwrite middle with a different-sized value.
897 cache.put(key(200, 1), Arc::new(dummy_lines(42)));
898 assert_eq!(cache.get(&key(200, 1)).map(|v| v.len()), Some(42));
899 // key=100 is still the oldest in the FIFO.
900 cache.get_or_insert_with(key(400, 1), || dummy_lines(1));
901 // With ROOMY budget nothing's evicted yet; all present.
902 for k in [100usize, 200, 300, 400] {
903 assert!(cache.get(&key(k, 1)).is_some(), "k={k} should be present");
904 }
905 }
906
907 #[test]
908 fn clear_empties_cache() {
909 let mut cache = LineWrapCache::with_byte_budget(ROOMY);
910 cache.get_or_insert_with(key(100, 1), || dummy_lines(1));
911 cache.get_or_insert_with(key(200, 1), || dummy_lines(1));
912 cache.clear();
913 assert!(cache.is_empty());
914 assert_eq!(cache.current_bytes(), 0);
915 assert!(cache.get(&key(100, 1)).is_none());
916 }
917
918 #[test]
919 fn pipeline_inputs_version_changes_when_any_source_changes() {
920 let a = pipeline_inputs_version(100, 5, 3, 7);
921 assert_ne!(
922 a,
923 pipeline_inputs_version(101, 5, 3, 7),
924 "buffer bump changes version"
925 );
926 assert_ne!(
927 a,
928 pipeline_inputs_version(100, 6, 3, 7),
929 "soft-break bump changes version"
930 );
931 assert_ne!(
932 a,
933 pipeline_inputs_version(100, 5, 4, 7),
934 "conceal bump changes version"
935 );
936 assert_ne!(
937 a,
938 pipeline_inputs_version(100, 5, 3, 8),
939 "virtual-text bump changes version"
940 );
941 }
942
943 #[test]
944 #[should_panic]
945 fn zero_byte_budget_rejected() {
946 LineWrapCache::with_byte_budget(0);
947 }
948
949 /// Even if a single new entry's estimated size exceeds the budget,
950 /// the cache accepts it rather than silently dropping data the
951 /// caller just paid to compute. Later inserts will still evict it
952 /// like any other FIFO entry.
953 #[test]
954 fn oversize_entry_is_accepted_then_agable() {
955 let mut cache = LineWrapCache::with_byte_budget(TIGHT);
956 // dummy_lines(50) is ~7 KB per line × 50 = ~350 KB... no, empty
957 // ViewLines are ~96 bytes each, so 50 × 96 ≈ 5 KB. That
958 // exceeds TIGHT (500 bytes).
959 cache.get_or_insert_with(key(1, 1), || dummy_lines(50));
960 assert!(cache.get(&key(1, 1)).is_some());
961 // Inserting a second entry evicts the oversize one.
962 cache.get_or_insert_with(key(2, 1), || dummy_lines(1));
963 assert!(cache.get(&key(1, 1)).is_none());
964 assert!(cache.get(&key(2, 1)).is_some());
965 }
966
967 // -------------------------------------------------------------------
968 // Layer 4: wrap-function invariants.
969 //
970 // These hold for any correct wrap regardless of cache state. A cache
971 // bug that corrupts a stored value would eventually violate one of
972 // them via the cache-backed path (e.g. width-monotonicity).
973 // -------------------------------------------------------------------
974
975 /// An empty line wraps to exactly one visual row.
976 #[test]
977 fn empty_line_is_one_row() {
978 for width in [5usize, 10, 42, 80, 120] {
979 assert_eq!(count_visual_rows_for_text("", width, 0, false), 1);
980 assert_eq!(count_visual_rows_for_text("", width, 6, false), 1);
981 }
982 }
983
984 /// A line whose visual width fits inside the available width wraps to
985 /// exactly one row. Tests a few short ASCII strings at a few widths.
986 #[test]
987 fn line_that_fits_is_one_row() {
988 // "hello world" = 11 chars; at effective_width=80, gutter=6 →
989 // available width = 74 > 11, must be 1 row.
990 for text in ["hello", "hello world", "a b c d"] {
991 assert_eq!(count_visual_rows_for_text(text, 80, 6, false), 1);
992 }
993 }
994
995 /// Width monotonicity: widening `effective_width` never *increases*
996 /// the row count.
997 ///
998 /// For a fixed text, any correct wrap satisfies
999 /// w1 <= w2 → rows(w1) >= rows(w2).
1000 #[test]
1001 fn width_monotonicity() {
1002 let texts = [
1003 "",
1004 "short",
1005 "a b c d e f g h i j k l m n o",
1006 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
1007 "word00 word01 word02 word03 word04 word05 word06 word07",
1008 ];
1009 let gutter = 2usize;
1010 for text in &texts {
1011 let mut prev_rows: Option<u32> = None;
1012 // effective_width must be > gutter to leave any available
1013 // width; start well above.
1014 for w in [10usize, 15, 20, 30, 50, 80, 120, 200] {
1015 let rows = count_visual_rows_for_text(text, w, gutter, false);
1016 if let Some(prev) = prev_rows {
1017 assert!(
1018 rows <= prev,
1019 "width monotonicity violated: rows({} chars, w={}) = {} > rows at prev w = {}. \
1020 text={:?}",
1021 text.len(),
1022 w,
1023 rows,
1024 prev,
1025 text,
1026 );
1027 }
1028 prev_rows = Some(rows);
1029 }
1030 }
1031 }
1032
1033 /// No row count is ever zero — even pathologically narrow widths or
1034 /// unusual inputs return at least 1.
1035 #[test]
1036 fn row_count_is_always_at_least_one() {
1037 let cases = [
1038 ("", 80usize),
1039 ("x", 80),
1040 ("", 2), // near-minimum width
1041 ("abc", 3),
1042 (
1043 "a very long line with lots of words that will definitely wrap",
1044 20,
1045 ),
1046 ];
1047 for (text, w) in cases {
1048 assert!(
1049 count_visual_rows_for_text(text, w, 0, false) >= 1,
1050 "row count < 1 for text={:?}, width={}",
1051 text,
1052 w,
1053 );
1054 }
1055 }
1056
1057 /// Adding characters never *decreases* the row count at a fixed width.
1058 ///
1059 /// Subset-superset property: if `a` is a prefix of `b`, `rows(a) <=
1060 /// rows(b)`. A cache that returned a stale value for a shortened
1061 /// line would fail this.
1062 #[test]
1063 fn prefix_never_has_more_rows() {
1064 let base = "aaaaaaaaaa bbbbbbbbbb cccccccccc dddddddddd eeeeeeeeee";
1065 let width = 20usize;
1066 let gutter = 2usize;
1067 let mut prev_rows: u32 = 0;
1068 for len in (0..=base.len()).step_by(5) {
1069 let prefix = &base[..len];
1070 let rows = count_visual_rows_for_text(prefix, width, gutter, false);
1071 assert!(
1072 rows >= prev_rows,
1073 "prefix property violated: len={}, rows={}, prev_rows={}",
1074 len,
1075 rows,
1076 prev_rows,
1077 );
1078 prev_rows = rows;
1079 }
1080 }
1081
1082 /// Deterministic: same input → same output, always.
1083 #[test]
1084 fn count_is_deterministic() {
1085 let text = "word00 word01 word02 word03 word04 word05 word06 word07 word08 word09";
1086 let w = 30usize;
1087 let g = 4usize;
1088 let r1 = count_visual_rows_for_text(text, w, g, false);
1089 for _ in 0..16 {
1090 let r = count_visual_rows_for_text(text, w, g, false);
1091 assert_eq!(r, r1, "non-deterministic row count");
1092 }
1093 }
1094
1095 // -------------------------------------------------------------------
1096 // Layer 3 (partial): shadow-model property test.
1097 //
1098 // A "shadow" cache always recomputes from the pure `count_visual_rows
1099 // _for_text` function; the "real" cache uses `LineWrapCache`. A
1100 // mutation-free op stream with random (text, width) probes must
1101 // always agree between real and shadow — otherwise the cache is
1102 // returning a value inconsistent with fresh computation. Covers the
1103 // insert / hit / evict surfaces on the cache primitive without
1104 // running the full editor pipeline.
1105 //
1106 // Full plugin-state shadow (buffer edits, soft-break injection,
1107 // conceals, view-mode toggles) lives in an e2e-level test — this
1108 // layer is the pure-primitive check.
1109 // -------------------------------------------------------------------
1110
1111 #[test]
1112 fn shadow_agreement_pure_primitive() {
1113 // Deterministic "random" inputs from simple counters, so this is
1114 // reproducible without a proptest dep.
1115 let texts: Vec<String> = (0..30)
1116 .map(|i| {
1117 let n = (i * 7 + 3) % 120 + 5;
1118 let seed = [b'a', b'b', b'c', b' ', b'd', b'e', b'f', b' ', b'1', b'2'];
1119 (0..n).map(|k| seed[k % seed.len()] as char).collect()
1120 })
1121 .collect();
1122 let widths: [usize; 5] = [12, 20, 42, 80, 120];
1123
1124 // Cache stores Vec<ViewLine>, so the shadow compares the LENGTH
1125 // (row count) the cache would expose with a fresh recompute.
1126 // The full-pipeline shadow (ViewLine coordinates agreeing with
1127 // the renderer) lives in e2e tests; this primitive-level shadow
1128 // checks that the FIFO / byte-budget machinery doesn't corrupt
1129 // stored values across inserts and evictions.
1130 //
1131 // Real cache values are built from `dummy_lines(shadow_count)`
1132 // so the cache value's length equals the shadow row count.
1133 let mut real = LineWrapCache::with_byte_budget(TIGHT);
1134 for step in 0..400usize {
1135 let t_idx = (step * 37 + 11) % texts.len();
1136 let w_idx = (step * 5 + 3) % widths.len();
1137 let text = &texts[t_idx];
1138 let width = widths[w_idx];
1139
1140 let shadow_rows = count_visual_rows_for_text(text, width, 2, false);
1141
1142 let key = LineWrapKey {
1143 pipeline_inputs_version: 0,
1144 view_mode: CacheViewMode::Source,
1145 line_start: t_idx, // stand-in for byte; distinct per text
1146 effective_width: width as u32,
1147 gutter_width: 2,
1148 wrap_column: None,
1149 hanging_indent: false,
1150 line_wrap_enabled: true,
1151 };
1152 let real_val = real.get_or_insert_with(key, || dummy_lines(shadow_rows));
1153 assert_eq!(
1154 real_val.len() as u32,
1155 shadow_rows,
1156 "shadow disagreement at step {step}: text_idx={t_idx}, width={width}, \
1157 real={}, shadow={shadow_rows}",
1158 real_val.len(),
1159 );
1160 assert!(
1161 real.current_bytes() <= real.byte_budget(),
1162 "cache exceeded byte budget"
1163 );
1164 }
1165 }
1166
1167 /// Version-bump invalidation: entries stored under version V are
1168 /// NEVER returned when a lookup is built at version V+1. The
1169 /// old entry sits in memory until FIFO evicts it, but no caller
1170 /// should ever get the stale value.
1171 #[test]
1172 fn version_bump_makes_old_entry_unreachable() {
1173 let mut cache = LineWrapCache::with_byte_budget(ROOMY);
1174 let key_v0 = LineWrapKey {
1175 pipeline_inputs_version: 100,
1176 view_mode: CacheViewMode::Source,
1177 line_start: 42,
1178 effective_width: 80,
1179 gutter_width: 6,
1180 wrap_column: None,
1181 hanging_indent: false,
1182 line_wrap_enabled: true,
1183 };
1184 cache.get_or_insert_with(key_v0, || dummy_lines(5));
1185 assert_eq!(cache.get(&key_v0).map(|v| v.len()), Some(5));
1186
1187 let key_v1 = LineWrapKey {
1188 pipeline_inputs_version: 101,
1189 ..key_v0
1190 };
1191 assert!(
1192 cache.get(&key_v1).is_none(),
1193 "v1 lookup must miss even though v0 entry is still present"
1194 );
1195
1196 // Miss path stores under v1; v0 remains in the map, untouched.
1197 let mut miss_called = 0;
1198 let v = cache.get_or_insert_with(key_v1, || {
1199 miss_called += 1;
1200 dummy_lines(7)
1201 });
1202 assert_eq!(v.len(), 7);
1203 assert_eq!(miss_called, 1);
1204 assert_eq!(cache.get(&key_v1).map(|v| v.len()), Some(7));
1205 assert_eq!(
1206 cache.get(&key_v0).map(|v| v.len()),
1207 Some(5),
1208 "v0 entry preserved until evicted"
1209 );
1210 }
1211
1212 /// All geometry dimensions in the key are distinct — changing any one
1213 /// produces a miss.
1214 #[test]
1215 fn every_key_dimension_separates_entries() {
1216 let base = LineWrapKey {
1217 pipeline_inputs_version: 1,
1218 view_mode: CacheViewMode::Source,
1219 line_start: 10,
1220 effective_width: 80,
1221 gutter_width: 6,
1222 wrap_column: None,
1223 hanging_indent: false,
1224 line_wrap_enabled: true,
1225 };
1226
1227 // Vary each field in turn; each variation must be a distinct key.
1228 let variations: [LineWrapKey; 8] = [
1229 LineWrapKey {
1230 pipeline_inputs_version: 2,
1231 ..base
1232 },
1233 LineWrapKey {
1234 view_mode: CacheViewMode::Compose,
1235 ..base
1236 },
1237 LineWrapKey {
1238 line_start: 11,
1239 ..base
1240 },
1241 LineWrapKey {
1242 effective_width: 81,
1243 ..base
1244 },
1245 LineWrapKey {
1246 gutter_width: 7,
1247 ..base
1248 },
1249 LineWrapKey {
1250 wrap_column: Some(70),
1251 ..base
1252 },
1253 LineWrapKey {
1254 hanging_indent: true,
1255 ..base
1256 },
1257 LineWrapKey {
1258 line_wrap_enabled: false,
1259 ..base
1260 },
1261 ];
1262
1263 let mut cache = LineWrapCache::with_byte_budget(ROOMY);
1264 cache.get_or_insert_with(base, || dummy_lines(1));
1265 for (i, v) in variations.iter().enumerate() {
1266 assert_ne!(*v, base, "variation {i} shouldn't equal base");
1267 assert!(
1268 cache.get(v).is_none(),
1269 "variation {i} unexpectedly hit base entry"
1270 );
1271 cache.get_or_insert_with(*v, || dummy_lines(2 + i as u32));
1272 }
1273 // Base entry is still reachable.
1274 assert_eq!(cache.get(&base).map(|v| v.len()), Some(1));
1275 // Each variation stored its own value (distinguished by length).
1276 for (i, v) in variations.iter().enumerate() {
1277 assert_eq!(cache.get(v).map(|v| v.len()), Some(2 + i));
1278 }
1279 }
1280}