FerriteEditor Widget

May 18, 2026 · View on GitHub

Overview

FerriteEditor is a custom text editor widget that integrates Phase 1 modules for high-performance text editing with virtual scrolling and efficient rendering.

Key Files

The Ferrite editor is organized in a modular subfolder structure at src/editor/ferrite/:

FileDescription
src/editor/ferrite/mod.rsModule exports and re-exports
src/editor/ferrite/editor.rsMain FerriteEditor widget
src/editor/ferrite/buffer.rsTextBuffer - rope-based text storage
src/editor/ferrite/history.rsEditHistory types + diff (Tab owns the stack)
src/editor/ferrite/view.rsViewState - viewport tracking
src/editor/ferrite/line_cache.rsLineCache - galley caching
src/editor/ferrite/cursor.rsCursor - position tracking
src/editor/ferrite/input/mod.rsInputHandler - event dispatch
src/editor/ferrite/input/keyboard.rsKeyboard input handling
src/editor/ferrite/input/mouse.rsMouse input handling
src/editor/ferrite/rendering/mod.rsRendering coordinator
src/editor/ferrite/rendering/gutter.rsLine number gutter rendering
src/editor/ferrite/rendering/text.rsText galley rendering
src/editor/ferrite/rendering/cursor.rsCursor/caret rendering

Architecture

src/editor/ferrite/
├── mod.rs                    # Re-exports: FerriteEditor, TextBuffer, etc.
├── editor.rs                 # FerriteEditor struct + ui() coordinator
├── buffer.rs                 # TextBuffer - rope-based content (ropey)
├── history.rs                # EditHistory module (owned by Tab, not FerriteEditor)
├── view.rs                   # ViewState - viewport tracking
├── line_cache.rs             # LineCache - LRU galley cache (200 entries)
├── cursor.rs                 # Cursor - line/column position
├── input/
│   ├── mod.rs                # InputHandler dispatch + InputResult
│   ├── keyboard.rs           # Key events (arrows, backspace, etc.)
│   └── mouse.rs              # Mouse wheel scrolling
└── rendering/
    ├── mod.rs                # Rendering coordinator
    ├── gutter.rs             # Line numbers (right-aligned)
    ├── text.rs               # Text galley rendering
    └── cursor.rs             # Cursor/caret drawing

Component Relationships:

FerriteEditor
├── TextBuffer      - Rope-based content storage (ropey)
├── (undo via Tab)  - Rope edits synced to tab.content → tab.edit_history
├── ViewState       - Viewport tracking, visible line range
├── LineCache       - LRU galley cache (200 entries)
├── Cursor          - Simple line/column position
├── input/          - Input event processing (keyboard + mouse)
├── rendering/      - Visual rendering (gutter, text, cursor)
└── ui()            - egui widget method (coordinator)

Struct Definition

pub struct FerriteEditor {
    buffer: TextBuffer,           // Text content (undo recorded on Tab after sync)
    view: ViewState,              // Viewport state
    line_cache: LineCache,        // Galley cache
    selection: Selection,         // Current selection (anchor + head)
    font_size: f32,               // Rendering font size
    content_dirty: bool,          // Cache invalidation flag
    wrap_enabled: bool,           // Word wrap toggle
    max_wrap_width: Option<f32>,  // Maximum wrap width
    // Multi-click and drag tracking
    last_click_time: Option<Instant>,
    click_count: u32,
    last_click_pos: Option<Cursor>,
    drag_start_cursor: Option<Cursor>,
    // Syntax highlighting (Phase 2)
    syntax_enabled: bool,         // Whether syntax highlighting is on
    syntax_language: Option<String>, // Language identifier (e.g., "rust")
    syntax_dark_mode: bool,       // Dark/light theme selection
    syntax_theme_hash: u64,       // Cache invalidation on theme change
}

pub struct Cursor {
    pub line: usize,          // 0-indexed line
    pub column: usize,        // 0-indexed column (char-based)
}

pub struct Selection {
    pub anchor: Cursor,       // Fixed point of selection
    pub head: Cursor,         // Moving point (follows cursor)
}

Selection vs Cursor:

  • Cursor is a single position (line, column)
  • Selection has two cursors: anchor (where selection started) and head (current cursor)
  • When anchor == head, selection is "collapsed" (just a cursor, no range)
  • selection.head is the logical cursor position

Key Methods

MethodDescription
new()Create empty editor
from_string(content)Create with initial content
ui(&mut self, ctx, ui) -> ResponseMain egui widget method
cursor()Get current cursor position (selection.head)
set_cursor(cursor)Set cursor (clamped to valid range)
selection()Get current selection
set_selection(selection)Set selection
has_selection()Check if there's a range selection
selected_text()Get text within selection range
delete_selection()Delete selected text, collapse to anchor
select_all()Select entire document
set_font_size(size)Set font size (8.0-72.0)
enable_wrap() / disable_wrap()Toggle word wrap
mark_dirty()Invalidate cache for next render

Rendering Pipeline (ui())

  1. Cache Check: Invalidate LineCache if content_dirty
  2. Layout: Calculate gutter width, text area
  3. Visible Range: Get lines from ViewState::get_visible_line_range()
  4. Gutter Rendering: Draw line numbers (right-aligned)
  5. Text Rendering: Draw visible lines using cached galleys
  6. Cursor Rendering: Draw cursor at current position

Usage

use ferrite::editor::{FerriteEditor, Cursor};

// Create editor
let mut editor = FerriteEditor::from_string("Hello\nWorld");

// Set cursor
editor.set_cursor(Cursor::new(1, 3)); // Line 1, column 3

// In egui update loop
egui::CentralPanel::default().show(ctx, |ui| {
    let response = editor.ui(ctx, ui);
    // Handle response (clicks, etc.)
});

EditorWidget Integration

FerriteEditor is the default editor in EditorWidget.

Build:

cargo build

Integration details (src/editor/widget.rs):

  • EditorWidget::show() renders the FerriteEditor widget
  • Each tab gets its own FerriteEditor instance stored in egui's memory

Content synchronization:

  • Tab.content → FerriteEditor buffer (automatic on content change)
  • FerriteEditor buffer → Tab.content (Phase 2, after keyboard input)
  • Cursor position is preserved across sync operations

FerriteEditor storage:

  • Editors are stored in egui's memory keyed by tab ID
  • Content hashes track external changes for re-sync
  • Editors persist across frames (not recreated each render)

Large file handling:

  • Files > 5MB trigger "large file mode"
  • Some features may be disabled for performance

Implementation Status

✅ Phase 1 Complete - Core Editor:

  • Virtual scrolling (only visible lines rendered)
  • Line number gutter with right-aligned numbers
  • Galley caching (LRU, 200 entries)
  • Cursor display (vertical line with blinking)
  • Horizontal scroll support for long lines
  • Feature flag integration with EditorWidget
  • Tab/AppState content synchronization

✅ Phase 2 Complete - Full Feature Parity:

  • Word wrap with dynamic line heights and visual row navigation
  • Full keyboard input handling (arrows, Home/End, Page Up/Down, Ctrl+arrows)
  • Text selection (click-drag, shift+arrow, double/triple-click)
  • Clipboard support (Ctrl+A/C/X/V)
  • Syntax highlighting (per-line caching, theme-aware, viewport-optimized)
  • Search highlights (matches highlighted, current match distinct, capped at 1000)
  • Bracket matching (windowed search ±100 lines, theme-aware colors)
  • Find & Replace integration (via EditorWidget configuration)

✅ Phase 3 Complete - Advanced Features (v0.2.6):

  • Undo/redo - Ctrl+Z/Y with operation-based history and grouping
  • Multi-cursor editing - Ctrl+Click to add cursors, simultaneous typing/deletion
  • Code folding - Gutter indicators, click-to-toggle, content hiding
  • IME support - CJK input composition with proper cursor positioning
  • Selection rendering - Semi-transparent (~40% alpha) with readable text
  • Cursor improvements - Blinking (500ms), theme-aware color, auto-focus

Rendering Constants:

const DEFAULT_FONT_SIZE: f32 = 14.0;  // Font size in points
const FIXED_LINE_HEIGHT: f32 = 20.0;  // Line spacing (non-wrapped mode)
const GUTTER_CHARS: usize = 5;        // Line number width (99999)
const GUTTER_PADDING: f32 = 8.0;      // Space between gutter and text
const LARGE_FILE_THRESHOLD: usize = 5 * 1024 * 1024;  // 5MB
const MAX_DISPLAYED_MATCHES: usize = 1000;  // Search match display cap

Performance Characteristics:

  • Viewport rendering: Only ~20-30 lines rendered (visible + 5 overscan)
  • Large file support: 100k+ lines with O(log n) access
  • Cache efficiency: 200 galleys cached, LRU eviction
  • Memory: ~80MB for 80MB file (was 460MB+ with egui TextEdit)
  • Bracket matching: O(window) complexity, cursor ±100 lines max
  • Search highlights: Pre-computed line numbers, capped display

⏳ Future Enhancements:

  • Column selection mode (Alt+Shift+drag)
  • Ctrl+D for "select next occurrence"
  • Improved undo/redo that tracks multi-cursor state

Input Handling

The input handling is split into modular submodules under src/editor/ferrite/input/:

  • mod.rs - InputHandler dispatch and InputResult enum
  • keyboard.rs - Keyboard event processing (arrows, backspace, etc.)
  • mouse.rs - Mouse wheel scrolling

Keyboard Operations:

OperationKeysDescription
Character insertionAny printableInsert at cursor (deletes selection first)
NewlineEnterInsert newline (deletes selection first)
BackspaceBackspaceDelete selection, or char before cursor
DeleteDeleteDelete selection, or char after cursor
Move leftMove cursor left (collapses selection)
Move rightMove cursor right (collapses selection)
Move upMove to previous line (collapses selection)
Move downMove to next line (collapses selection)
Extend leftShift+←Extend selection left
Extend rightShift+→Extend selection right
Extend upShift+↑Extend selection up
Extend downShift+↓Extend selection down
Word leftCtrl+←Move to start of previous word
Word rightCtrl+→Move to start of next word
Line startHomeMove to start of line
Line endEndMove to end of line
Doc startCtrl+HomeMove to start of document
Doc endCtrl+EndMove to end of document
Page upPageUpMove cursor up by viewport height
Page downPageDownMove cursor down by viewport height
Select allCtrl+ASelect entire document
CopyCtrl+CCopy selection to clipboard
CutCtrl+XCut selection to clipboard
PasteCtrl+VPaste from clipboard (replaces selection)

Mouse Operations:

OperationActionDescription
ClickSingle clickPosition cursor at click location
Shift+ClickShift + clickExtend selection to click location
Double-clickDouble clickSelect word at cursor
Triple-clickTriple clickSelect entire line
Click-dragClick and dragSelect text range

Click Position Calculation:

  • Uses galley.cursor_from_pos() for wrapped text (accurate to character)
  • Uses binary search with midpoint rounding for non-wrapped text
  • Focus is requested on click for keyboard input

InputResult Enum:

pub enum InputResult {
    NoChange,      // No modification
    CursorMoved,   // Cursor moved, text unchanged
    TextChanged,   // Text was modified
    ViewScrolled,  // Mouse wheel scrolled view
}

Selection & Clipboard Implementation

Selection Architecture

Selection uses two cursors: anchor (start) and head (end/current):

pub struct Selection {
    pub anchor: Cursor,  // Fixed point where selection started
    pub head: Cursor,    // Moving point (follows user actions)
}

impl Selection {
    pub fn collapsed(cursor: Cursor) -> Self;  // No selection, just cursor
    pub fn is_range(&self) -> bool;            // anchor != head
    pub fn ordered(&self) -> (Cursor, Cursor); // (start, end) in document order
    pub fn with_head(self, new_head: Cursor) -> Self;  // Keep anchor, change head
}

Clipboard Operations

Clipboard uses egui's event and output systems:

// Handle Copy/Cut events (preferred - generated by OS/egui)
match event {
    egui::Event::Copy => {
        if self.selection.is_range() {
            let text = self.selected_text();
            ui.output_mut(|o| o.copied_text = text);
        }
    }
    egui::Event::Cut => {
        if self.selection.is_range() {
            let text = self.selected_text();
            ui.output_mut(|o| o.copied_text = text);
            self.delete_selection();
        }
    }
    egui::Event::Paste(text) => {
        self.delete_selection();
        insert_text(&mut self.buffer, &mut cursor, text);
    }
    _ => {}
}

Important: egui generates Event::Copy and Event::Cut events for clipboard operations, NOT Key::C/Key::X events. Handle both for cross-platform compatibility.

Important Implementation Details

1. Drag Anchor Capture (Critical Fix)

egui's drag_started() fires after the mouse has moved (to distinguish from clicks). This means interact_pointer_pos() at that moment is NOT the original click position.

Solution: Capture position on initial press using is_pointer_button_down_on():

// Capture position when button first goes down (before egui decides if it's a drag)
if response.is_pointer_button_down_on() && self.drag_start_cursor.is_none() {
    if let Some(pos) = response.interact_pointer_pos() {
        self.drag_start_cursor = Some(self.pos_to_cursor(pos, ...));
    }
}

// When drag starts, use the stored position as anchor
if response.drag_started() {
    if let Some(anchor) = self.drag_start_cursor {
        self.selection = Selection::collapsed(anchor);
    }
}

// Clear when button released
if !response.is_pointer_button_down_on() {
    self.drag_start_cursor = None;
}

2. Content Dirty vs Wrap Info (Flickering Fix)

When content_dirty is set, do NOT clear wrap_info. Clearing it causes y-position calculations to alternate between methods on consecutive frames, creating flickering.

// CORRECT: Only invalidate line cache
if self.content_dirty {
    self.line_cache.invalidate();
    // DON'T clear wrap_info here - causes flickering!
    self.content_dirty = false;
}

3. Selection Rendering Order

Selection background must be rendered BEFORE text galleys:

// In ui() method:
self.render_selection(painter, ...);  // Background first
// ... then render text galleys
render_cursor(painter, ...);          // Cursor last

Usage in FerriteEditor:

// In ui() method, when focused:
for event in &events {
    let result = InputHandler::handle_event(
        event,
        &mut self.buffer,
        &mut self.cursor,
        &mut self.view,
    );
    
    match result {
        InputResult::TextChanged => {
            self.content_dirty = true;
            self.view.ensure_line_visible(self.cursor.line, total_lines);
        }
        InputResult::CursorMoved => {
            self.view.ensure_line_visible(self.cursor.line, total_lines);
        }
        InputResult::NoChange => {}
    }
}

Syntax Highlighting

FerriteEditor supports per-line syntax highlighting with efficient caching.

Configuration

// Configure syntax highlighting
editor.configure_syntax(
    enabled: true,
    language: Some("rust".to_string()),
    dark_mode: true,
);

// Or individual setters
editor.set_syntax_enabled(true);
editor.set_syntax_language(Some("rust".to_string()));
editor.set_syntax_dark_mode(true);

Implementation

  1. Per-line highlighting: Only visible lines are syntax-highlighted, integrating with virtual scrolling
  2. Cache key includes theme: CacheKey::new_highlighted() hashes content + font + color + syntax_theme_hash
  3. Uses existing syntax module: crate::markdown::syntax::highlight_code() provides the highlighting
  4. HighlightedSegment: Simplified struct for cache (text, color)

Integration with EditorWidget

EditorWidget automatically configures syntax highlighting based on:

  • syntax_highlighting setting (enabled/disabled)
  • file_path (language detection via language_from_path())
  • is_dark_mode (theme selection)
// In widget.rs show_with_ferrite_editor():
let syntax_language = if self.syntax_highlighting {
    self.file_path.as_ref().and_then(|p| language_from_path(p))
} else {
    None
};
editor.configure_syntax(
    self.syntax_highlighting && syntax_language.is_some(),
    syntax_language,
    self.is_dark_mode,
);

Search Highlights

FerriteEditor supports search match highlighting with a distinct current match indicator.

Configuration

// Set search matches from find panel
editor.set_search_matches(
    matches: Vec<(usize, usize)>,  // (start_byte, end_byte) pairs
    current_match: usize,          // Index of current match
    scroll_to_match: bool,         // Whether to scroll to current match
);

// Clear all search matches
editor.clear_search_matches();

Implementation Details

  1. Pre-computed line numbers: Matches store pre-computed line numbers (SearchMatch.line) for O(1) scroll-to-match
  2. Display cap: Only first 1000 matches are rendered (matches VS Code behavior)
  3. Distinct current match: Current match has brighter highlight color
  4. Viewport-aware: Only visible matches are rendered
  5. Theme-aware: Colors adapt to dark/light mode

Integration with EditorWidget

// In widget.rs show_with_ferrite_editor():
if let Some(ref highlights) = self.search_highlights {
    editor.set_search_matches(
        highlights.matches.clone(),
        highlights.current_match,
        highlights.scroll_to_match,
    );
} else {
    editor.clear_search_matches();
}

Bracket Matching

FerriteEditor highlights matching brackets when the cursor is adjacent to one.

Configuration

// Enable/disable bracket matching
editor.set_bracket_matching_enabled(true);

// Set custom colors (optional - uses theme defaults if None)
editor.set_bracket_colors(Some((
    bg_color: Color32,     // Background fill
    border_color: Color32, // Border stroke
)));

Implementation Details

  1. Windowed search: Only searches cursor ±100 lines (O(window), not O(N))
  2. Uses existing matcher: Leverages crate::editor::matching::DelimiterMatcher
  3. Supported delimiters: (), [], {}, <>, markdown emphasis **, __
  4. Theme-aware: Colors from theme_colors.ui.matching_bracket_bg/border

Rendering

Both the cursor-adjacent bracket and its matching pair are highlighted:

  • Background fill for visibility
  • Border stroke for clarity

Dependencies

  • egui - GUI framework
  • ropey - Rope data structure (via TextBuffer)
  • syntect / two-face - Syntax highlighting (via crate::markdown::syntax)