LineCache: Smart Invalidation & Dynamic Sizing

March 29, 2026 · View on GitHub

Task 30 — Improve LineCache to support targeted invalidation for changed line ranges and dynamic sizing based on visible lines.

Problem

Before this change, every content edit (typing a character, pasting text, cutting) called line_cache.invalidate() which cleared all cached galleys. For a 10 000-line file with 200 cached galleys, a single keystroke destroyed the entire cache, forcing every visible line to be re-laid-out on the next frame.

Solution

1. Content-hash keys already handle correctness

CacheKey is computed from (content_hash, font, color) — not from line indices. When a line's content changes, the old hash produces a miss and a new entry is created. Lines whose content is unchanged always hit, regardless of whether their line index shifted (e.g. after an insertion above them).

This means clearing the entire cache on content edits was never necessary for correctness — only for reclaiming space occupied by stale entries.

2. Targeted invalidation (invalidate_range)

A reverse index line_keys: HashMap<usize, CacheKey> maps each rendered line to the CacheKey used for it. During the rendering loop, register_line(line_idx, content, font_id, color) populates this index for every visible line.

When content changes, invalidate_range(start_line, end_line) looks up the affected lines in line_keys, evicts their entries from both the standard and shaped caches, and removes the stale index entries. Lines outside the range are untouched.

Edit line 5000 → invalidate_range(5000, 5000)

line_keys[5000] → CacheKey(0xABCD)

cache.remove(0xABCD)  +  shaped_cache.remove(0xABCD)

Lines 0–4999, 5001+ retain their cached galleys

3. Dirty range tracking in the editor

FerriteEditor accumulates a dirty_range: Option<(usize, usize)> across edits within a frame:

Edit pathTracking
Normal typing, IME, Vim inputmark_lines_dirty(cursor_line, cursor_line)
Cut (selection)mark_lines_dirty(sel_start_line, sel_end_line)
Undo / Redomark_dirty() (full invalidation — arbitrary range)
Font / theme / zoom / wrap changesinvalidate() (full — all galleys are stale)

On the next ui() frame:

if self.content_dirty {
    if let Some((start, end)) = self.dirty_range.take() {
        self.line_cache.invalidate_range(start, end);
    } else {
        self.line_cache.invalidate(); // full fallback
    }
    self.content_dirty = false;
}

4. Dynamic cache capacity

The fixed const MAX_CACHE_ENTRIES: usize = 200 is replaced by a per-instance limit recomputed every frame:

``rustmax_entries=max(200,visible_lines×3)\text{rust} \text{max\_entries} = \text{max}(200, \text{visible\_lines} \times 3) `$

\text{Viewport}\text{Visible} \text{lines}\text{Cache} \text{capacity}
\text{Small} (400 \text{px})~20200 (\text{floor})
\text{Normal} (800 \text{px})~40200 (\text{floor})
\text{Large} (1600 \text{px})~80240
4\text{K} \text{ultrawide}~200600

\text{The} 3 \times \text{multiplier} \text{provides} \text{headroom} \text{for}:

  • \text{Overscan} \text{lines} \text{rendered} \text{above}/\text{below} \text{the} \text{viewport}
  • \text{Temporarily} \text{stale} \text{entries} \text{awaiting} \text{LRU} \text{eviction}
  • \text{Lines} \text{just} \text{scrolled} \text{out} \text{of} \text{view} (\text{likely} \text{to} \text{scroll} \text{back})

\text{The} \text{shaped}-\text{line} \text{cache} \text{scales} \text{similarly} \text{at} $max(100, visible_lines)`.

Files Changed

FileChanges
src/editor/ferrite/line_cache.rsDynamic max_cache_entries / max_shaped_entries fields; update_capacity(), register_line(), register_line_highlighted(), invalidate_range(), clear_line_keys(), tracked_lines(); 11 new tests
src/editor/ferrite/editor.rsdirty_range field; mark_lines_dirty() helper; targeted invalidation in ui(); update_capacity() call after visible range computed; register_line() call during rendering; removed redundant double-invalidation from undo/redo

Performance Impact

ScenarioBeforeAfter
Type character in 10K-line file~200 cache entries destroyed, all visible lines re-laid-out1 entry evicted, ~39 lines hit cache
Scroll after editingAll galleys miss (cache was cleared)Unchanged lines hit immediately
Resize windowCache stays at 200Cache grows to match viewport
Font/theme changeFull clearFull clear (unchanged — correct behavior)

Considerations

  • Multi-line edits (paste spanning lines): mark_lines_dirty accumulates the full range via min/max merging across the frame.
  • Undo/redo: Falls back to full invalidation since the affected range is not easily known.
  • Uniform-height mode (100K+ line files, Task 29): Dynamic sizing still applies; the cache can be smaller since there is no wrap info to track.
  • Thread safety: LineCache remains single-threaded (UI thread only).
  • Memory bound: With dynamic sizing, a 1M-line file viewed at typical zoom would have a cache capacity of ~600 entries — well under the 50 MB budget.