Text Editor Widget

April 11, 2026 · View on GitHub

Overview

The EditorWidget provides the main text editing interface for Ferrite. It wraps egui's TextEdit::multiline widget with additional functionality for cursor position tracking, scroll persistence, and integration with the application state.

Key Files

  • src/editor/mod.rs - Module declaration and exports
  • src/editor/widget.rs - EditorWidget implementation with builder pattern and line numbers
  • src/editor/line_numbers.rs - Line counting utilities
  • src/editor/stats.rs - Text statistics (word/char/line counting)

Implementation Details

EditorWidget Structure

The widget uses a builder pattern for flexible configuration:

EditorWidget::new(tab)
    .font_size(14.0)
    .word_wrap(true)
    .show_line_numbers(true)
    .theme_colors(theme_colors)
    .id(egui::Id::new("main_editor"))
    .show(ui);

Core Features

FeatureImplementation
Text editingegui TextEdit::multiline handles input natively
Cursor movementArrow keys, Home/End, Page Up/Down via egui
Text selectionMouse drag and Shift+Arrow supported natively
ClipboardCtrl+C/X/V handled by egui
ScrollingWrapped in ScrollArea with offset persistence
Line numbersOptional gutter with sync scrolling (see Line Numbers)

Cursor Position Tracking

The widget converts egui's character-based cursor index to (line, column) coordinates:

fn char_index_to_line_col(text: &str, char_index: usize) -> (usize, usize) {
    let mut line = 0;
    let mut col = 0;
    
    for (i, ch) in text.chars().enumerate() {
        if i >= char_index { break; }
        if ch == '\n' {
            line += 1;
            col = 0;
        } else {
            col += 1;
        }
    }
    (line, col)
}

Integration with Tab State

The editor integrates directly with the Tab struct:

  • Content: Mutates tab.content directly via TextEdit
  • Cursor: Updates tab.cursor_position as (line, col)
  • Scroll: Persists tab.scroll_offset from ScrollArea

EditorOutput

The show() method returns useful information:

pub struct EditorOutput {
    pub response: Response,      // egui interaction response
    pub changed: bool,           // Whether content was modified
    pub cursor_position: (usize, usize), // Current (line, col)
}

Scroll Persistence & egui Widget IDs

egui persists ScrollArea state (scroll offset) per widget Id. To prevent scroll positions leaking across tab switches, every central-panel editor/preview widget ID must be scoped with tab.id:

// ✅ DO: Scope widget ID with tab.id — each tab gets independent scroll state
let editor_widget_id = egui::Id::new("main_editor_raw").with(tab.id);
EditorWidget::new(tab)
    .id(editor_widget_id)
    .show(ui);

// ✅ DO: MarkdownEditor can inline it (only borrows tab.content, not tab)
MarkdownEditor::new(&mut tab.content)
    .id(egui::Id::new("main_editor_rendered").with(tab.id))
    .show(ui);

// ❌ DON'T: Fixed ID leaks scroll offset between tabs
EditorWidget::new(tab)
    .id(egui::Id::new("main_editor_raw"))
    .show(ui);

Note: For EditorWidget, capture the ID before the builder chain since EditorWidget::new(tab) takes a mutable borrow of the entire Tab, making tab.id inaccessible in the chain.

The four scoped IDs in src/app/central_panel.rs:

  • "main_editor_raw" — Raw mode, single pane
  • "main_editor_rendered" — Rendered mode, single pane
  • "split_editor_raw" — Split view, left (raw) pane
  • "split_preview_rendered" — Split view, right (preview) pane

FerriteEditor storage (raw editor) is separately keyed by tab_id in a HashMap inside FerriteEditorStorage (see src/editor/widget.rs), so its virtual scrolling is already per-tab independent of the widget ID.

Dependencies Used

  • egui - TextEdit::multiline, ScrollArea, FontId
  • eframe - Re-exports egui types

Usage

In app.rs, the editor is rendered in the central panel:

let font_size = self.state.settings.font_size;
let word_wrap = self.state.settings.word_wrap;

if let Some(tab) = self.state.active_tab_mut() {
    let editor_output = EditorWidget::new(tab)
        .font_size(font_size)
        .word_wrap(word_wrap)
        .id(egui::Id::new("main_editor"))
        .show(ui);
}

Tests

Run editor-specific tests:

cargo test editor::widget::tests

Test coverage includes:

  • test_char_index_to_line_col_* - Cursor position conversion
  • test_line_col_to_char_index_* - Reverse conversion
  • test_roundtrip_conversion - Bidirectional consistency