Ferrite Editor Architecture

January 26, 2026 · View on GitHub

Overview

This document defines the architectural principles and constraints for the Ferrite editor component. All editor features must follow these guidelines to ensure consistent performance across file sizes from 1KB to 100MB+.

Core Principles

1. Single Source of Truth

The Rope (TextBuffer) is the only authoritative source of content.

┌─────────────────────────────────────────────────────────────┐
│                     Current (WRONG)                         │
├─────────────────────────────────────────────────────────────┤
│  Tab.content: String (80MB)                                 │
│       ↓ sync                                                │
│  FerriteEditor.buffer: Rope (80MB)                          │
│                                                             │
│  Total: 160MB for an 80MB file (2x memory)                  │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                     Target (CORRECT)                        │
├─────────────────────────────────────────────────────────────┤
│  FerriteEditor.buffer: Rope (80-100MB)  ← Single source     │
│       ↓ on-demand (save only)                               │
│  String conversion                                          │
│                                                             │
│  Total: ~100MB for an 80MB file (rope overhead only)        │
└─────────────────────────────────────────────────────────────┘

Rules:

  • Tab.content: String should be removed or made optional (only for non-Ferrite mode)
  • All content reads go through TextBuffer methods
  • String conversion (to_string()) only happens on:
    • File save
    • Export operations
    • Clipboard copy of entire document
  • NEVER call buffer.to_string() in per-frame code

2. Complexity Tiers

Every operation must fall into one of these tiers:

TierComplexityWhen AllowedExamples
O(1)ConstantAlways, any contextline_count(), is_dirty(), cursor position
O(log N)LogarithmicAlways, any contextRope index lookups, get_line(idx)
O(visible)Proportional to viewportPer-frame renderingSyntax highlighting, line rendering
O(window)Small window around cursorPer-frame, cursor-dependentBracket matching (±100 lines)
O(N)Linear in file sizeUser-initiated onlyFind all, save, export

Per-frame operations MUST be O(1), O(log N), O(visible), or O(window).

O(N) operations are ONLY allowed when:

  • User explicitly triggers them (Find All, Save, Export)
  • They run in a background thread with progress indication
  • They can be cancelled

3. Viewport-Aware Processing

All rendering features must only process visible content plus a small buffer.

// WRONG: Process entire file
for line_idx in 0..total_lines {
    render_line(line_idx);
}

// CORRECT: Process only visible lines
let (start, end) = view.get_visible_line_range(total_lines);
for line_idx in start..end {
    render_line(line_idx);
}

Viewport buffer sizes:

  • Rendering: visible lines only
  • Syntax highlighting: visible lines + 10 lines buffer (for scroll smoothness)
  • Bracket matching: cursor line ± 100 lines (configurable)
  • Auto-complete: cursor line ± 50 lines for context

4. No Per-Frame Allocations

Per-frame code (60fps) must not allocate memory proportional to file size.

// WRONG: 80MB allocation every frame
let content = self.buffer.to_string();
let matcher = DelimiterMatcher::new(&content);

// CORRECT: Work with rope slices or bounded windows
let cursor_line = self.cursor.line;
let search_start = cursor_line.saturating_sub(100);
let search_end = (cursor_line + 100).min(total_lines);
// Only extract the window we need

Allowed per-frame allocations:

  • Fixed-size buffers (e.g., 64KB scratch buffer)
  • Visible line content (typically < 10KB)
  • Small Vec for visible matches (capped at 1000 items)

Module Architecture

Modular impl Pattern

FerriteEditor uses Rust's ability to split impl blocks across files. The struct is defined in editor.rs, but methods are distributed across logical modules:

src/editor/ferrite/
├── editor.rs       # struct FerriteEditor { ... } + ui() + core methods
├── selection.rs    # impl FerriteEditor { render_selection(), select_all(), ... }
├── highlights.rs   # impl FerriteEditor { render_search_highlights(), ... }
├── find_replace.rs # impl FerriteEditor { replace_current_match(), ... }
├── mouse.rs        # impl FerriteEditor { pos_to_cursor(), ... }
└── search.rs       # impl FerriteEditor { search_matches(), set_search_matches(), ... }

Benefits:

  • Each file stays focused and <500 lines
  • Related functionality grouped together
  • Tests live with their implementations
  • Clear extension points for new features

Pattern:

// In selection.rs
use super::editor::FerriteEditor;

impl FerriteEditor {
    pub fn select_all(&mut self) { ... }
    pub(crate) fn render_selection(&self, ...) { ... }
}

Fields must be pub(crate) for sibling modules to access them.

TextBuffer (Rope Wrapper)

The TextBuffer struct wraps ropey::Rope and provides efficient access methods.

impl TextBuffer {
    // O(1) operations
    pub fn line_count(&self) -> usize;
    pub fn len_chars(&self) -> usize;
    pub fn len_bytes(&self) -> usize;
    pub fn is_empty(&self) -> bool;
    
    // O(log N) operations - safe for per-frame use
    pub fn get_line(&self, idx: usize) -> Option<Cow<str>>;
    pub fn char_to_line(&self, char_idx: usize) -> usize;
    pub fn line_to_char(&self, line_idx: usize) -> usize;
    pub fn byte_to_line(&self, byte_idx: usize) -> usize;
    pub fn byte_to_char(&self, byte_idx: usize) -> usize;
    
    // O(window) operations - use with bounded range
    pub fn slice(&self, start_char: usize, end_char: usize) -> RopeSlice;
    pub fn lines_in_range(&self, start_line: usize, end_line: usize) -> impl Iterator;
    
    // O(N) operations - user-initiated only, NEVER per-frame
    pub fn to_string(&self) -> String;  // Mark with #[doc(hidden)] or rename
}

ViewState (Viewport Management)

Manages what's visible and provides viewport bounds to other systems.

impl ViewState {
    // Returns (start_line, end_line) for the current viewport
    pub fn get_visible_line_range(&self, total_lines: usize) -> (usize, usize);
    
    // Returns extended range for features that need lookahead
    pub fn get_extended_range(&self, total_lines: usize, buffer: usize) -> (usize, usize);
    
    // Scroll management
    pub fn scroll_to_line(&mut self, line: usize);
    pub fn ensure_line_visible(&mut self, line: usize, total_lines: usize);
}

Feature Modules

Each feature module must document its complexity and follow these patterns:

Syntax Highlighting

// Per-frame: O(visible)
pub fn highlight_visible_lines(
    buffer: &TextBuffer,
    visible_range: (usize, usize),
    language: &str,
) -> Vec<HighlightedLine>;

Bracket Matching

// Per-frame: O(window) where window = cursor ± MAX_SEARCH_DISTANCE
const MAX_SEARCH_DISTANCE: usize = 100; // lines

pub fn find_matching_bracket(
    buffer: &TextBuffer,
    cursor_line: usize,
    cursor_col: usize,
    total_lines: usize,
) -> Option<BracketPair> {
    let search_start = cursor_line.saturating_sub(MAX_SEARCH_DISTANCE);
    let search_end = (cursor_line + MAX_SEARCH_DISTANCE).min(total_lines);
    // Only search within this window
}

Search/Find

// User-initiated: O(N) but runs async with progress
pub async fn find_all(
    buffer: &TextBuffer,
    pattern: &str,
    progress: impl Fn(f32),
) -> Vec<SearchMatch>;

// Per-frame rendering: O(visible) - only render visible matches
pub fn render_visible_matches(
    matches: &[SearchMatch],
    visible_range: (usize, usize),
) -> Vec<HighlightRect>;

Data Flow

Content Lifecycle

File Open:
  disk → bytes → encoding detection → String → Rope

                                   (discard String after Rope creation)

Editing:
  keypress → Rope.insert/delete → mark dirty → UI updates from Rope

File Save:
  Rope.to_string() → encoding → bytes → disk
  (only allocation point for full content)

Tab ↔ Editor Relationship

Current (problematic):

Tab {
    content: String,        // 80MB - redundant
    cursor_position: (usize, usize),
    ...
}

FerriteEditor {
    buffer: TextBuffer,     // 80MB - authoritative
    cursor: Cursor,
    ...
}

// Sync happens every frame - expensive

Target:

Tab {
    editor_id: usize,       // Reference to editor in storage
    // No content field!
    file_path: Option<PathBuf>,
    is_modified: bool,
    ...
}

FerriteEditorStorage {
    editors: HashMap<usize, FerriteEditor>,
}

FerriteEditor {
    buffer: TextBuffer,     // Single source of truth
    cursor: Cursor,
    view: ViewState,
    ...
}

// Tab only stores metadata, editor owns content

Memory Budget

Per-File Memory

ComponentSizeNotes
Rope content1.0-1.2x file sizeRope has ~10-20% overhead
Rope metadata~50 bytes per lineTree nodes
Line cacheFixed 1000 entriesLRU cache for galleys
Search matchesCapped at 1000~24 bytes each
Undo historyConfigurableReduced for large files

Target: For an 80MB file, total RAM should be ~100-120MB, not 460MB.

Per-Frame Budget

OperationMax AllocationNotes
Line rendering100KBVisible lines only
Syntax segments50KBVisible lines only
Search highlights24KB1000 matches × 24 bytes
Bracket matching1KBSingle pair
Total per frame<500KB

Migration Plan

Phase 1: Audit Current Code ✅

  • List all places that call buffer.to_string() or tab.content
  • Classify each as O(1), O(visible), O(N)
  • Identify per-frame O(N) violations

Phase 2: Fix Per-Frame Violations ✅

  • Bracket matching: implement windowed search (cursor ±100 lines)
  • Content sync uses is_content_dirty() flag, not string comparison
  • Syntax highlighting: viewport-aware, per-line caching
  • Search highlights: capped at 1000 matches

Phase 3: Remove Content Duplication ✅ (v0.2.6)

  • Tab.content still exists but is synced lazily (not per-frame)
  • Save/export get content from Tab.content (synced from editor buffer)
  • FerriteEditor.buffer is the authoritative source during editing
  • Future: Make Tab.content optional for full memory optimization

Phase 4: Validate ✅ (v0.2.6)

  • Test with 1MB, 10MB, 50MB, 80MB files
  • RAM usage: ~80MB for 80MB file (1x file size)
  • Frame time: Smooth 60fps scrolling
  • See docs/v0.2.6-manual-test-suite.md for test coverage

Feature Implementation Checklist

When implementing any new editor feature, verify:

  • Complexity documented: What tier is this operation?
  • Viewport-aware: Does it only process visible content?
  • No per-frame O(N): Does the render path avoid full-file operations?
  • Bounded allocations: Are allocations capped or fixed-size?
  • Rope-native: Does it use rope methods, not to_string()?
  • Tested at scale: Verified with 50MB+ file?

Anti-Patterns to Avoid

1. Full Content Conversion

// NEVER in per-frame code
let text = self.buffer.to_string();

2. Unbounded Iteration

// WRONG
for line in 0..self.buffer.line_count() {
    process(line);
}

// CORRECT
let (start, end) = self.view.get_visible_line_range(total);
for line in start..end {
    process(line);
}

3. Per-Frame Cloning

// WRONG
let content_copy = tab.content.clone(); // 80MB clone at 60fps = 4.8GB/s

// CORRECT
let is_modified = editor.is_dirty(); // O(1) flag check

4. Disabling Features Instead of Fixing

// WRONG approach (what we've been doing)
if is_large_file {
    return; // Just disable the feature
}

// CORRECT approach
let window = get_bounded_window(cursor, MAX_DISTANCE);
process_within_window(window); // Works for any file size