editor-core 0.3.0

A headless editor engine focused on state management, Unicode-aware text measurement, and coordinate conversion.
Documentation

editor-core

editor-core is a headless editor engine focused on state management, Unicode-aware text measurement, and coordinate conversion. It is intentionally UI-agnostic: consumers render from snapshots and drive edits through the command/state APIs.

Features

  • Efficient text storage via a Piece Table (PieceTable) for inserts/deletes.
  • Fast line indexing via a rope-backed LineIndex for line access and conversions.
  • Soft wrapping layout (LayoutEngine) with Unicode-aware cell widths.
  • Style + folding metadata via interval trees (IntervalTree) and fold regions (FoldingManager) (derived folds + stable user folds).
  • Symbols/outline model (DocumentOutline, DocumentSymbol, WorkspaceSymbol) for building outline trees and symbol search UIs (typically populated from LSP).
  • Headless snapshots (SnapshotGeneratorHeadlessGrid) for building “text grid” UIs.
  • Lightweight minimap snapshots (MinimapGrid) for overview rendering without per-cell payload.
  • Decoration-aware composed snapshots (ComposedGrid) that inject virtual text (inlay hints, code lens) so hosts can render from snapshot data without re-implementing layout rules.
  • Command interface (CommandExecutor) and state/query layer (EditorStateManager).
  • Workspace model (Workspace) for multi-buffer + multi-view (split panes):
    • open buffers: Workspace::open_bufferOpenBufferResult { buffer_id, view_id }
    • create additional views: Workspace::create_view
    • execute commands against a view: Workspace::execute
    • render from a view: Workspace::get_viewport_content_styled
    • query visual-row mappings from a view: Workspace::{total_visual_lines_for_view, visual_to_logical_for_view, logical_to_visual_for_view, visual_position_to_logical_for_view}
    • query viewport + smooth-scroll metadata: Workspace::viewport_state_for_view
    • render minimap summaries from a view: Workspace::get_minimap_content
    • search across open buffers: Workspace::search_all_open_buffers
    • apply workspace edits (per-buffer undo grouping): Workspace::apply_text_edits
  • Kernel-level editing commands for common editor UX:
    • line ops: DuplicateLines, DeleteLines, MoveLinesUp/Down, JoinLines, SplitLine
    • comment toggling: ToggleComment (language-config driven)
    • selection/multi-cursor ops: SelectLine, SelectWord, ExpandSelection, AddCursorAbove/Below, AddNextOccurrence, AddAllOccurrences
  • Search utilities (find_next, find_prev, find_all) operating on character offsets.

Choosing an API surface (single view vs workspace)

editor-core is intentionally UI-agnostic, but there are a few different “levels” of API you can build on:

  • CommandExecutor: the lowest-level command runner for one buffer, including undo/redo.
    • own it directly if you want maximum control and you already have your own state manager
    • used internally by both EditorStateManager and Workspace
  • EditorStateManager: ergonomic single-buffer / single-view wrapper.
    • adds version, is_modified, subscriptions, and structured query helpers
    • great starting point for simple apps and tests
  • Workspace: multi-buffer + multi-view model (tabs + split panes).
    • buffers are identified by BufferId and own text + undo + derived metadata
    • views are identified by ViewId and own cursor/selection + viewport config + scroll

Relation to the older “single view” interface

If you previously integrated editor-core by treating EditorStateManager as “the editor”, that model still works and is still supported. Conceptually, it corresponds to “a Workspace with one buffer and one view”. Moving to Workspace makes those identities explicit so that multiple views can share the same buffer state.

Migration cheat sheet (EditorStateManagerWorkspace)

  • Run a command: state.execute(cmd)ws.execute(view_id, cmd)
  • Render a viewport: state.get_viewport_content_styled(start, count)ws.get_viewport_content_styled(view_id, start, count)
  • Render lightweight minimap summaries: state.get_minimap_content(start, count)ws.get_minimap_content(view_id, start, count)
  • Visual row mapping queries: state.visual_to_logical_line(...)ws.visual_to_logical_for_view(view_id, ...)
  • Subscribe to changes: state.subscribe(cb)ws.subscribe_view(view_id, cb)
  • Apply derived state: state.apply_processing_edits(edits)ws.apply_processing_edits(buffer_id, edits)
  • Observe text deltas: state.take_last_text_delta()ws.take_last_text_delta_for_buffer(buffer_id) (once per buffer edit) or ws.take_last_text_delta_for_view(view_id) (per view)

Design overview

editor-core is organized as a set of small layers:

  • Storage: Piece Table holds the document text.
  • Indexing: LineIndex provides line access + offset/position conversions.
  • Layout: LayoutEngine computes wrap points and logical↔visual mappings.
  • Intervals: styles/folding are represented as ranges and queried efficiently.
  • Snapshots: a UI-facing “text grid” snapshot (HeadlessGrid) can be rendered by any frontend.
  • State/commands: public APIs for edits, queries, versioning, and change notifications.

Offsets and coordinates

  • Many public APIs use character offsets (not byte offsets) for robustness with Unicode.
  • Rendering uses cell widths (Cell.width is typically 1 or 2) to support CJK and emoji.
  • There is a distinction between logical lines (document lines) and visual lines (after soft wrapping and/or folding).

Derived state pipeline

Higher-level integrations (like LSP semantic tokens or syntax highlighting) can compute derived editor metadata and apply it through:

  • DocumentProcessor (produce edits)
  • ProcessingEdit (apply edits)
  • EditorStateManager::apply_processing_edits (update state consistently)

Quick start

Command-driven editing

use editor_core::{Command, CommandExecutor, CursorCommand, EditCommand, Position};

let mut executor = CommandExecutor::empty(80);

executor.execute(Command::Edit(EditCommand::Insert {
    offset: 0,
    text: "Hello\nWorld".to_string(),
})).unwrap();

executor.execute(Command::Cursor(CursorCommand::MoveTo {
    line: 1,
    column: 2,
})).unwrap();

assert_eq!(executor.editor().cursor_position(), Position::new(1, 2));

State queries + change notifications

use editor_core::{Command, EditCommand, EditorStateManager, StateChangeType};

let mut manager = EditorStateManager::new("Initial text", 80);
manager.subscribe(|change| {
    println!("change={:?} version {}->{}", change.change_type, change.old_version, change.new_version);
});

manager.execute(Command::Edit(EditCommand::Insert {
    offset: 0,
    text: "New: ".to_string(),
})).unwrap();
assert!(manager.get_document_state().is_modified);

// Manual edits are possible, but callers must preserve invariants and call `mark_modified`.
manager.editor_mut().piece_table.insert(0, "X");
manager.mark_modified(StateChangeType::DocumentModified);

Multi-view workspace (split panes)

use editor_core::{Command, CursorCommand, EditCommand, Workspace};

let mut ws = Workspace::new();
let opened = ws.open_buffer(Some("file:///demo.txt".to_string()), "0123456789\n", 10).unwrap();

let left = opened.view_id;
let right = ws.create_view(opened.buffer_id, 5).unwrap();

ws.execute(left, Command::Cursor(CursorCommand::MoveTo { line: 0, column: 1 })).unwrap();
ws.execute(right, Command::Cursor(CursorCommand::MoveTo { line: 0, column: 5 })).unwrap();

ws.execute(left, Command::Edit(EditCommand::InsertText { text: "X".to_string() })).unwrap();
assert_eq!(ws.buffer_text(opened.buffer_id).unwrap(), "0X123456789\n");

Decoration-aware composed snapshots (virtual text)

If you apply decorations that include Decoration.text (e.g. inlay hints or code lens), you can render them via ComposedGrid:

use editor_core::{
    Decoration, DecorationKind, DecorationLayerId, DecorationPlacement, DecorationRange,
    EditorStateManager, ProcessingEdit,
};

let mut manager = EditorStateManager::new("a = 1\n", 80);
let anchor = manager.editor().line_index.position_to_char_offset(0, 1);

manager.apply_processing_edits(vec![ProcessingEdit::ReplaceDecorations {
    layer: DecorationLayerId::INLAY_HINTS,
    decorations: vec![Decoration {
        range: DecorationRange::new(anchor, anchor),
        placement: DecorationPlacement::After,
        kind: DecorationKind::InlayHint,
        text: Some(": i32".to_string()),
        styles: vec![],
        tooltip: None,
        data_json: None,
    }],
}]);

let composed = manager.get_viewport_content_composed(0, 10);
assert!(composed.actual_line_count() > 0);

Performance & benches

editor-core aims to keep the common editor hot paths incremental:

  • Text edits update LineIndex and LayoutEngine incrementally (instead of rebuilding from a full get_text() copy on every keystroke).
  • Viewport rendering streams visible lines from LineIndex + LayoutEngine (no full-document intermediate strings in the viewport path).

Run the P1.5 benchmark suite:

cargo bench -p editor-core --bench performance

For a quick local sanity run (smaller sample sizes):

cargo bench -p editor-core --bench performance -- --sample-size 10 --warm-up-time 0.1 --measurement-time 0.1

There is also a small runtime example (prints timings for observation):

cargo run -p editor-core --example performance_milestones

Related crates

  • editor-core-lsp: LSP integration (UTF-16 conversions, semantic tokens helpers, stdio JSON-RPC).
  • editor-core-sublime: .sublime-syntax highlighting + folding engine.
  • editor-core-treesitter: Tree-sitter integration (incremental parsing → highlighting + folding).