Fresh is a high-performance, terminal-based text editor built in Rust. It's designed to be fast, responsive, and extensible, with a modern architecture that draws inspiration from the best ideas in the world of text editors.
- Performance First: Every architectural decision is made with performance in mind. This includes the choice of data structures, the design of the rendering pipeline, and the implementation of core features.
- Event-Driven: All state changes are represented as events, which are processed by a central event loop. This makes the editor's state predictable and enables features like unlimited undo/redo.
- Asynchronous I/O: All file and process I/O is handled asynchronously on a separate thread pool. This ensures that the editor's UI is never blocked by slow I/O operations.
- Extensible: A powerful TypeScript-based plugin system allows for deep customization and extension of the editor's functionality.
┌─────────────────────────────────────────────────────────┐
│ MAIN THREAD (Sync) │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ Event Loop │→ │ Editor │→ │ Renderer │ │
│ │ (crossterm) │ │ (state) │ │ (ratatui) │ │
│ └──────────────┘ └──────────────┘ └─────────────┘ │
│ ↓ ↑ │
│ Input Queue EventQueue (mpsc) │
└─────────────────────────────────────────────────────────┘
↑ ↑
│ send events │ send messages
│ │
┌────┴──────────┐ ┌────────┴──────────┐
│ LSP Tasks │ │ File I/O Tasks │
│ (Tokio) │ │ (Tokio) │
└───────────────┘ └───────────────────┘
To provide a clean separation between the editor's UI and the underlying text buffer, Fresh uses a DocumentModel trait. This abstraction layer is responsible for all interactions with the text buffer and provides a consistent API for both small and large files.
To support multi-gigabyte files where line indexing may be unavailable, the DocumentModel uses a dual position system:
DocumentPosition::LineColumn: For small files, this provides precise line and column-based positioning.DocumentPosition::ByteOffset: For large files, this provides byte-offset-based positioning, which is always available and precise.
The DocumentModel trait defines a set of methods for interacting with the document, including:
get_viewport_content: The core rendering primitive, which returns the content for the visible portion of the screen.position_to_offsetandoffset_to_position: For converting between the two position types.insert,delete, andreplace: For modifying the document's content.
This abstraction allows the rest of the editor to be blissfully unaware of the details of the underlying text buffer, such as whether it's a small file with a full line index or a large file with lazy loading.
The core of the editor is the text buffer, which is implemented as a PieceTree. A PieceTree is a balanced binary tree that represents the text as a sequence of "pieces," which are references to either the original, immutable file buffer or an in-memory buffer of user additions.
This data structure provides several key advantages:
- O(log n) Edits: Inserts and deletes are O(log n), where n is the number of pieces. This makes text editing extremely fast, even in large files.
- Efficient Memory Usage: The
PieceTreeonly stores the changes to the file, not the entire file content. This makes it very memory-efficient, especially for large files. - Lazy Loading: For multi-gigabyte files, Fresh uses a lazy loading strategy. The file is not loaded into memory all at once. Instead, chunks of the file are loaded on demand as the user scrolls through the file.
The rendering pipeline transforms source bytes into styled terminal output through a series of well-defined stages. Each stage preserves source-byte mappings for cursor positioning, and plugins can intercept the pipeline at multiple points.
flowchart TD
subgraph Storage["1. Storage Layer"]
FILE[Source File bytes]
FILE --> TB[TextBuffer::load_from_file]
TB --> PT[PieceTree + Vec‹StringBuffer›]
end
subgraph Viewport["2. Viewport Calculation"]
PT --> VP[Viewport.top_byte]
VP --> VR[Visible byte range]
end
subgraph Tokenization["3. Tokenization"]
VR --> BT[build_base_tokens]
BT --> VTW[Vec‹ViewTokenWire›]
end
subgraph PluginTransform["4. Plugin Transform"]
VTW --> HOOK{view_transform_request hook}
HOOK -->|Plugin responds| SVT[SubmitViewTransform]
SVT --> TVT[Transformed tokens]
HOOK -->|No plugin| TVT
end
subgraph ViewLines["5. ViewLine Generation"]
TVT --> WRAP{Line wrapping?}
WRAP -->|Yes| WRP[apply_wrapping_transform]
WRAP -->|No| VLI
WRP --> VLI[ViewLineIterator]
VLI --> VL[Vec‹ViewLine›]
end
subgraph Styling["6. Styling Layers"]
VL --> TS[Token styles]
TS --> ANSI[ANSI escape parsing]
ANSI --> SYN[Syntax highlighting]
SYN --> SEM[Semantic highlighting]
SEM --> SEL[Selection ranges]
SEL --> OVL[Overlays]
OVL --> STYLED[Styled characters]
end
subgraph Output["7. Ratatui Output"]
STYLED --> SPANS[Vec‹Line‹Span››]
SPANS --> PARA[Paragraph widget]
PARA --> FRAME[frame.render_widget]
FRAME --> TERM[Terminal]
end
Source bytes are stored in a PieceTree (src/piece_tree.rs), a balanced tree of pieces referencing either the original file or an append-only buffer of edits.
| File Size | Storage Strategy | Line Indexing |
|---|---|---|
| < 100MB | BufferData::Loaded { data, line_starts } |
Full index available |
| ≥ 100MB | BufferData::Unloaded { file_path, file_offset, bytes } |
Lazy chunk loading |
Key files: src/text_buffer.rs:141-149, src/piece_tree.rs:12-36
The Viewport (src/viewport.rs) determines which bytes are visible:
Viewport {
top_byte: usize, // Authoritative scroll position
left_column: usize, // Horizontal scroll
width: u16,
height: u16,
}top_byte is the anchor—line boundaries are discovered by scanning backward. This works even for files without line indexing.
Key file: src/viewport.rs:94-104
build_base_tokens() converts visible bytes into a stream of ViewTokenWire:
pub struct ViewTokenWire {
pub source_offset: Option<usize>, // Maps back to source byte
pub kind: ViewTokenWireKind, // Text | Newline | Space | Break
pub style: Option<ViewTokenStyle>, // For injected content
}The source_offset field is critical—it enables cursor positioning and determines whether syntax highlighting applies.
Key file: src/ui/split_rendering.rs:581-654
Plugins can intercept and transform the token stream before it becomes display lines.
The view transform system uses a synchronous, single-threaded frame pipeline to guarantee per-frame coherence:
sequenceDiagram
participant R as Renderer
participant H as Hook System
participant P as Plugin
participant Q as Command Queue
participant S as SplitViewState
Note over R: Frame N begins
R->>R: build_base_tokens()
R->>H: Fire view_transform_request (BLOCKING)
H->>P: Send buffer_id, viewport, base tokens
P->>P: Transform tokens
P->>Q: submitViewTransform() enqueues command
H-->>R: Hook returns (unblocks)
R->>Q: Drain command queue (non-blocking)
Q-->>R: SubmitViewTransform command
R->>S: view_transform = Some(payload)
R->>R: Render using SplitViewState.view_transform
Note over R: Frame N complete (coherent)
| Phase | Operation | Sync/Async | Blocks Frame? |
|---|---|---|---|
| Hook fire | run_hook("view_transform_request") |
Sync | Yes |
| Plugin execution | Plugin transforms tokens | Sync | Yes (in-thread) |
| Command enqueue | submitViewTransform() |
Sync | No (mpsc send) |
| Queue drain | process_commands() |
Sync | No (try_recv) |
| State update | view_state.view_transform = payload |
Sync | No |
| Render | Read SplitViewState.view_transform |
Sync | No |
Key files:
- Hook firing:
src/editor/render.rs:135 - Command queue:
src/ts_runtime.rs:2996-3002 - Queue drain:
src/editor/render.rs:194-200 - State update:
src/editor/mod.rs:4209-4226
The design ensures frame coherence—each frame renders with a consistent view of all state:
- Hooks block the frame -
run_hook()does not return until all plugin callbacks complete - Commands drain immediately - After hooks return, pending commands are processed before rendering
- Single source of truth -
SplitViewState.view_transformis updated in-place, then read during render - No async gaps - No
awaitpoints between state mutation and rendering
flowchart LR
subgraph Frame["Single Frame (No Async Boundaries)"]
direction LR
A[Build tokens] --> B[Fire hook<br/>BLOCKS]
B --> C[Drain queue]
C --> D[Apply transforms]
D --> E[Render]
end
If a plugin does not respond (doesn't call submitViewTransform), the renderer uses whatever transform is already in SplitViewState:
| Scenario | Behavior |
|---|---|
| Plugin responds this frame | New transform used immediately |
| Plugin doesn't respond | Previous frame's transform reused |
Plugin calls ClearViewTransform |
Falls back to base tokens |
| No plugin registered | Base tokens used (no transform) |
Because hooks are blocking, slow plugins stall the entire frame:
Frame timeline (60 FPS target = 16.6ms budget):
Fast plugin: [tokens 1ms][hook 2ms][drain <1ms][render 5ms] = 8ms ✓
Slow plugin: [tokens 1ms][hook 50ms][drain <1ms][render 5ms] = 56ms ✗ (dropped frames)
Best practices for plugins:
- Pre-compute annotation positions during async operations (file load, git commands)
- Store computed data in plugin state
- In
view_transform_request, only do O(viewport) token manipulation - Never perform I/O or spawn processes inside the hook
Hook: view_transform_request (src/editor/render.rs:113-135)
Plugin command: PluginCommand::SubmitViewTransform (src/plugin_api.rs:259-264)
Token semantics:
source_offset: Some(n)→ Source content, syntax highlighting appliedsource_offset: None→ Injected by plugin, usesstylefield instead
Use cases: Git blame annotations, markdown soft breaks, interleaved views, code coverage headers.
ViewLineIterator (src/ui/view_pipeline.rs) converts tokens into display lines:
pub struct ViewLine {
pub text: String, // Tabs expanded to spaces
pub char_mappings: Vec<Option<usize>>, // Display char → source byte
pub char_styles: Vec<Option<ViewTokenStyle>>,
pub tab_starts: HashSet<usize>,
pub line_start: LineStart, // Source | Injected | Wrapped
}Tab expansion: '\t' → (8 - (col % 8)) spaces, each mapped to the original tab byte.
Line classification: LineStart::AfterInjectedNewline distinguishes plugin-injected lines (no line number shown) from source lines.
Key file: src/ui/view_pipeline.rs:72-74
Multiple styling systems are applied per character, in order:
flowchart LR
subgraph Layers["Styling Priority (low → high)"]
direction LR
A[Token style] --> B[ANSI escapes]
B --> C[Syntax highlighting]
C --> D[Semantic highlighting]
D --> E[Selection]
E --> F[Overlays]
end
| Layer | Source | Applies To |
|---|---|---|
| Token style | ViewTokenWire.style |
Injected content (source_offset: None) |
| ANSI escapes | AnsiParser::parse_char() |
Content with escape sequences |
| Syntax | Tree-sitter (src/highlighter.rs) |
Source content only |
| Semantic | Word occurrence matching | Matching identifiers |
| Selection | Cursor ranges | Selected text |
| Overlays | OverlayManager |
Diagnostics, search hits |
Key file: src/ui/split_rendering.rs:1005-1720
Styled characters are accumulated into ratatui Spans, grouped into Lines:
// Final structure
Vec<Line> {
Line {
spans: Vec<Span> {
Span { content: String, style: Style }
}
}
}
// Handoff to ratatui
frame.render_widget(Clear, render_area);
frame.render_widget(Paragraph::new(lines), render_area);
frame.set_cursor_position((cursor_x, cursor_y));Key file: src/ui/split_rendering.rs:1926-1930
Fresh provides four independent mechanisms for plugins to affect rendering:
flowchart TB
subgraph Mechanisms["Plugin Rendering Mechanisms"]
VT[View Transform<br/>Token stream modification]
VL[Virtual Lines<br/>Header/footer lines]
VTX[Virtual Text<br/>Inline insertions]
OV[Overlays<br/>Range decorations]
end
subgraph UseCases["Use Cases"]
VT --> UC1[Interleaved views]
VT --> UC2[Semantic reordering]
VL --> UC3[Git blame headers]
VL --> UC4[Code coverage]
VL --> UC5[Section separators]
VTX --> UC6[Type hints]
VTX --> UC7[Parameter hints]
VTX --> UC8[Color swatches]
OV --> UC9[Diagnostics]
OV --> UC10[Search highlights]
OV --> UC11[Lint underlines]
end
Fresh follows an Emacs-inspired architecture for plugin rendering where:
- Plugins update state in response to events (buffer changes, file open) - async, fire-and-forget
- State is stored persistently with marker-based position tracking
- Render loop reads state synchronously from memory - no async waiting
This ensures frame coherence: each frame renders with a consistent snapshot of all plugin state.
sequenceDiagram
participant B as Buffer
participant P as Plugin
participant S as VirtualTextManager
participant R as Renderer
Note over B,R: Plugin updates state (async)
B->>P: buffer_changed event
P->>P: Compute data (can take time)
P->>S: AddVirtualLine commands
S->>S: Store with markers
Note over B,R: Render reads state (sync)
R->>S: query_lines_in_range()
S-->>R: Virtual lines (from memory)
R->>R: Inject into ViewLines
R->>R: Render frame
Modifies the entire token stream. For advanced use cases requiring complete restructuring.
PluginCommand::SubmitViewTransform {
buffer_id,
split_id,
payload: ViewTransformPayload {
range: Range<usize>,
tokens: Vec<ViewTokenWire>,
layout_hints: Option<LayoutHints>,
},
}Note: View transforms use a per-frame hook which has sync/async timing considerations. For simpler annotation use cases, prefer Virtual Lines.
Key file: src/plugin_api.rs:122-130
Injects full display lines above or below source lines. Recommended for git blame, code coverage, section headers.
PluginCommand::AddVirtualLine {
buffer_id,
position: usize, // Anchor byte position
text: String, // Full line content
color: (u8, u8, u8), // RGB color
above: bool, // true = above, false = below
namespace: String, // For bulk removal
priority: i32, // Ordering at same position
}
// Clear all virtual lines in a namespace (before updating)
PluginCommand::ClearVirtualTextNamespace {
buffer_id,
namespace: String,
}Characteristics:
- Lines do NOT show line numbers in the gutter
- Positions auto-adjust on buffer edits (marker-based)
- Namespaced for efficient bulk removal
- Read synchronously during render (no frame lag)
Example: Git blame plugin
fresh.hooks.on('buffer_changed', async (bufferId) => {
const blame = await computeGitBlame(bufferId);
// Clear old headers
fresh.clearVirtualTextNamespace(bufferId, 'git-blame');
// Add new headers (persistent until next change)
for (const chunk of blame.chunks) {
fresh.addVirtualLine({
bufferId,
position: chunk.startByte,
text: `── ${chunk.commit} (${chunk.author}) ──`,
color: [128, 128, 128],
above: true,
namespace: 'git-blame',
priority: 0,
});
}
});Key file: src/virtual_text.rs
Inserts styled text inline (before/after a character). For type hints, parameter hints.
PluginCommand::AddVirtualText {
buffer_id,
virtual_text_id: String,
position: usize,
text: String,
color: (u8, u8, u8),
before: bool, // true = before char, false = after
}Key file: src/virtual_text.rs
Applies decorations to byte ranges. Stored in BTreeMap<usize, Vec<Overlay>> for O(log n) lookup.
PluginCommand::AddOverlay {
buffer_id,
namespace: Option<OverlayNamespace>,
range: Range<usize>,
color: (u8, u8, u8),
underline: bool,
bold: bool,
italic: bool,
} -> OverlayHandlePerformance optimizations:
- Line-indexed storage: Overlays keyed by starting line for fast viewport filtering
- Render-time cache: Visible overlays cached per frame
- Diagnostic hash check: LSP diagnostics skip update if unchanged
Key file: src/overlay.rs
Each split maintains independent view state, enabling different presentations of the same buffer:
pub struct SplitViewState {
pub view_transform: Option<ViewTransformPayload>,
pub compose_width: Option<u16>,
pub compose_column_guides: Option<Vec<u16>>,
pub layout: Option<Layout>,
pub layout_dirty: bool,
}Example: Split A shows normal view, Split B shows git blame—same buffer, different transforms.
Key file: src/split.rs:60-105
sequenceDiagram
participant E as Editor
participant B as Buffer
participant H as Hooks
participant P as Plugins
participant R as SplitRenderer
E->>B: prepare_for_render() (lazy load chunks)
loop For each visible buffer
E->>H: Fire render_start
E->>B: build_base_tokens()
E->>H: Fire view_transform_request
H->>P: Plugin transforms tokens
P->>E: SubmitViewTransform
E->>H: Fire lines_changed (new visible lines)
H->>P: Plugin adds overlays
end
E->>E: Process queued PluginCommands
E->>R: render_buffer_in_split()
R->>R: Apply view_transform if present
R->>R: Build ViewLines, apply styling
R->>R: Render to ratatui
Key file: src/editor/render.rs:97-201
| Operation | Complexity | Notes |
|---|---|---|
| File load (small) | O(n) | Full line indexing |
| File load (large) | O(1) | Lazy, viewport-only |
| Viewport scroll | O(log n) | Binary search for line boundary |
| Syntax highlighting | O(viewport) | Tree-sitter parses visible lines only |
| Overlay lookup | O(log n + k) | n = total overlays, k = visible |
| Render frame | O(height) | Independent of file size |
Fresh has a deep and robust integration with the Language Server Protocol (LSP), providing features like code completion, diagnostics, and go-to-definition.
The LSP integration is built on a multi-threaded architecture that ensures the editor's UI is never blocked by the language server.
LspManager: A central coordinator that manages multiple language servers (one for each language).LspHandle: A handle to a specific language server, providing a non-blocking API for sending commands and notifications.AsyncBridge: Anmpscchannel that bridges the asynchronous world of the LSP tasks with the synchronous world of the editor's main event loop.
This architecture allows Fresh to communicate with language servers in a highly efficient and non-blocking way, providing a smooth and responsive user experience.
Fresh supports Annotated Views, a powerful pattern for displaying file content with injected annotations (such as headers, metadata lines, or visual separators) while preserving core editor features like line numbers and syntax highlighting. This architecture enables features like git blame, code coverage overlays, and inline documentation.
Consider displaying git blame information: you want to show the file content with header lines above each block indicating the commit, author, and date. A naive approach creates several problems:
-
Line Number Mismatch: If headers are part of the buffer content, line numbers include them, making it impossible to show "line 42" next to the actual line 42 of the source file.
-
Syntax Highlighting Loss: If the buffer contains mixed content (headers + code), tree-sitter cannot parse it correctly, and syntax highlighting breaks.
-
Historical Content: For features like "blame at parent commit," you need to display historical file versions that differ from the current file.
Fresh solves this with a hybrid architecture combining two mechanisms:
┌─────────────────────────────────────────────────────────────────┐
│ ANNOTATED VIEW ARCHITECTURE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Virtual Buffer (Content Layer) │ │
│ │ • Contains actual file content (current or historical) │ │
│ │ • Language detected from buffer name extension │ │
│ │ • Syntax highlighting via tree-sitter │ │
│ │ • Text properties store annotation metadata │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ View Transform (Presentation Layer) │ │
│ │ • Injects annotation headers between content blocks │ │
│ │ • Headers: source_offset = None (no line number) │ │
│ │ • Content: source_offset = Some(byte) (has line num) │ │
│ │ • Styling via ViewTokenWire.style field │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Rendered Output │ │
│ │ ── abc123 (Alice, 2 days ago) "Fix bug" ── │ │
│ │ 42 │ fn main() { │ │
│ │ 43 │ println!("Hello"); │ │
│ │ ── def456 (Bob, 1 week ago) "Add feature" ── │ │
│ │ 44 │ do_something(); │ │
│ │ 45 │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
The first layer is a virtual buffer containing the actual file content. Key properties:
- Buffer name includes file extension (e.g.,
*blame:main.rs*) for automatic language detection - Content is pure source code, enabling correct tree-sitter parsing
- Text properties can store metadata without affecting content
The ViewTokenWire structure (in src/plugin_api.rs) enables precise control over line numbering via its source_offset field:
Some(byte_position): Token maps to source content. The renderer includes it in line number calculation, applies syntax highlighting, and enables cursor positioning.None: Token is injected annotation. The renderer skips line number increment (shows blank in gutter), applies thestylefield if present, and does not participate in source-based features.
Plugins register for the view_transform_request hook, called each render frame. The plugin receives the current viewport's tokens and can inject headers with source_offset: None, passing through content tokens unchanged to preserve line numbers and highlighting.
The renderer in split_rendering.rs checks if the previous character had no source mapping. Lines starting after a source_offset: None newline show blank in the line number gutter. Lines starting after a source_offset: Some(_) newline increment and display the line number.
Syntax highlighting is applied based on source_offset. Content tokens with Some(byte) are looked up in highlight spans and colored. Annotation tokens with None skip highlight lookup and use the style field instead.
Git Blame: Virtual buffer contains historical file content from git show commit:path. Annotations are commit headers above each blame block. Line numbers match historical file lines.
Code Coverage: Buffer contains current file content. Annotations are coverage percentage headers above functions. Line numbers match current file.
Inline Documentation: Buffer contains source code. Annotations are doc comments rendered as styled blocks. Line numbers only appear for code lines.
-
Viewport-Only Processing: View transforms only process the visible viewport, not the entire file. For a 100K line file, only ~50 lines are transformed per frame.
-
Efficient Block Lookup: Annotation metadata should be stored in a sorted structure enabling O(log n) lookup of blocks overlapping the viewport.
-
Frame-Rate Transform: The
view_transform_requesthook is called every frame. Plugins must respond quickly. Pre-compute annotation positions; don't run git commands during the hook. -
Caching: View transforms are cached per-split and reused until explicitly cleared or the viewport changes significantly.
To implement an annotated view feature:
-
Define annotation structure: What metadata accompanies each block? (commit info, coverage data, etc.)
-
Create content buffer: Use
createVirtualBufferwith appropriate name for language detection -
Store block positions: Track byte ranges for each annotation block in the content
-
Implement view transform hook: Inject headers with
source_offset: Noneandstyle, pass through content tokens unchanged -
Handle navigation: Map cursor positions in the view back to logical positions in your annotation model
-
Support refresh: When annotations change (e.g., blame at different commit), update buffer content and block positions; view transform auto-updates on next render
Fresh cleanly separates Buffer state (the document) from View state (how it's displayed). This enables multiple independent views of the same buffer, each with its own scroll position, cursor, and view transform.
Consider two splits showing the same file:
- Split A: Normal view, scrolled to line 100, cursor at line 105
- Split B: Git blame view, scrolled to line 1, cursor at line 10
Both views share the same underlying text, but have completely independent:
- Scroll positions (viewport)
- Cursor positions
- View transforms (blame vs normal)
- Computed layouts
┌─────────────────────────────────────────────────────────────┐
│ BUFFER STATE (shared) │
│ EditorState per buffer_id │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ • buffer: PieceTree (actual text content) │ │
│ │ • undo/redo history │ │
│ │ • syntax highlighter (tokens derived from content) │ │
│ │ • overlays (content-anchored decorations) │ │
│ │ • markers (bookmarks, breakpoints) │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────┼─────────────────┐
▼ ▼
┌───────────────────────────┐ ┌───────────────────────────┐
│ VIEW STATE (Split A) │ │ VIEW STATE (Split B) │
│ SplitViewState │ │ SplitViewState │
│ ┌───────────────────────┐ │ │ ┌───────────────────────┐ │
│ │ • viewport (scroll) │ │ │ │ • viewport (scroll) │ │
│ │ • cursors │ │ │ │ • cursors │ │
│ │ • view_transform │ │ │ │ • view_transform │ │
│ │ • view_mode │ │ │ │ • view_mode │ │
│ │ • layout (computed) │ │ │ │ • layout (computed) │ │
│ └───────────────────────┘ │ │ └───────────────────────┘ │
└───────────────────────────┘ └───────────────────────────┘
Events are routed based on whether they affect the buffer or the view:
Buffer Events → Applied to shared EditorState:
Insert,Delete(text modifications)AddOverlay,RemoveOverlay(content decorations)- Undo/Redo
View Events → Applied to SplitViewState for active split:
Scroll(viewport movement)MoveCursor(cursor navigation)SetViewMode(normal/blame/etc)
When a buffer event modifies content, ALL views of that buffer must:
- Adjust their cursor positions (if affected by the edit)
- Mark their layout as dirty (needs rebuild)
Base syntax highlighting is buffer state because:
- Tokens are derived from content (same content = same tokens)
- Expensive to compute, wasteful to duplicate per-view
- Most editors share highlighting across views
However, style application can be view-specific via view transforms. A git blame view transform can restyle tokens (dim old code, highlight recent changes) without affecting other views.
The Layout is View state, not Buffer state. Each view (split) has its own Layout computed from its view_transform. This enables different views of the same buffer to have different layouts (e.g., blame view vs normal view).
┌─────────────────────────────────────────────────────────────┐
│ BUFFER LAYER (EditorState - shared) │
│ • Buffer: source bytes (PieceTree) │
│ • Syntax tokens (shared across views) │
│ • Overlays, markers (content-anchored) │
└─────────────────────────────────────────────────────────────┘
│
┌───────────────────┼───────────────────┐
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ VIEW LAYER (Split A) │ │ VIEW LAYER (Split B) │
│ SplitViewState │ │ SplitViewState │
│ ┌─────────────────────────┐ │ │ ┌─────────────────────────┐ │
│ │ view_transform (plugin) │ │ │ │ view_transform (plugin) │ │
│ │ │ │ │ │ │ │ │ │
│ │ ▼ │ │ │ │ ▼ │ │
│ │ layout (ViewLines) │ │ │ │ layout (ViewLines) │ │
│ │ │ │ │ │ │ │ │ │
│ │ ▼ │ │ │ │ ▼ │ │
│ │ viewport (view line idx)│ │ │ │ viewport (view line idx)│ │
│ │ cursors (source bytes) │ │ │ │ cursors (source bytes) │ │
│ └─────────────────────────┘ │ │ └─────────────────────────┘ │
└─────────────────────────────┘ └─────────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ RENDERING │ │ RENDERING │
│ ViewLines[top..top+height] │ │ ViewLines[top..top+height] │
└─────────────────────────────┘ └─────────────────────────────┘
When view transforms inject content (like git blame headers), the display has more lines than the source buffer:
Source Buffer (5 lines): Display Layout (7 lines):
───────────────────── ─────────────────────────
Line 1 ── Header (injected) ──
Line 2 Line 1
Line 3 Line 2
Line 4 ── Header (injected) ──
Line 5 Line 3
Line 4
Line 5
If the viewport operates on source lines, scroll limits are wrong (can't scroll to show all 7 display lines). If cursor movement uses source lines, pressing ↓ skips over injected headers.
The solution: viewport and visual navigation operate on the Layout Layer.
See source files for current implementations:
-
ViewLine (
src/ui/view_pipeline.rs): A single display line withchar_mappingsmapping each character to source byte (None = injected), plus styles and tab expansion info. -
Layout (
src/ui/view_pipeline.rs): Collection of ViewLines for the viewport region, withbyte_to_lineindex for fast source byte → view line lookup. -
SplitViewState (
src/split.rs): Per-split state including cursors, viewport, view_transform, layout, and layout_dirty flag. -
Viewport (
src/viewport.rs): Scroll position (currentlytop_bytein source bytes), dimensions, and scroll offset settings. -
Cursor (
src/cursor.rs): Position in source bytes with optional selection anchor.
Following VSCode's ViewModel pattern, the Layout is built lazily but is never optional. Every operation that needs Layout calls ensure_layout() first (see src/split.rs). There is no "fallback to buffer-based scrolling" - Layout always exists when needed.
Layout becomes dirty when:
- Buffer content changes (Insert/Delete events)
- View transform changes (plugin sends new tokens)
- Scroll would move past current layout's source_range
When the layout changes (edit, view transform update), view line indices shift. To maintain stable scroll position:
- Store anchor_byte: The source byte at the top of the viewport
- On layout rebuild: Find anchor_byte in new layout → new top_view_line
- Clamp if needed: If anchor_byte no longer exists, clamp to valid range
The Layout is built from viewport-scoped tokens (for performance). When scrolling would move past the current layout:
- Request new tokens from the view transform for the target range
- Rebuild layout to cover the new viewport
- Complete the scroll using the new layout
The estimate for the target byte doesn't need to be perfect - the view transform will give correct tokens for whatever range is requested.
To know scroll limits without building the entire layout, we track total_view_lines and total_injected_lines in the Layout struct. The plugin knows how many headers it injects and can report this in the view transform response via total_injected_lines in layout hints.
This section documents the authoritative architecture for viewport state and scrolling. The key principle: SplitViewState.viewport is the single source of truth for scroll position.
Early implementations had viewport state in two places: EditorState (per buffer) and SplitViewState (per split). This caused several bugs:
- Sync loops: Changes to one viewport would sync to the other, then sync back, causing flickering
- Stale state: After cursor movement, scroll position would reset because sync copied old values
- Wrong dimensions: Editor.render() used EditorState.viewport dimensions before split_rendering resized it, causing incorrect scroll calculations
SplitViewState.viewport is authoritative. EditorState should NOT have a viewport (or at most, temporary dimensions for PageDown/PageUp before layout is available).
EditorState (per buffer):
└── buffer: PieceTree
└── cursors: Cursors (deprecated - use SplitViewState.cursors)
└── (NO viewport)
SplitViewState (per split):
└── viewport: Viewport // THE source of truth
└── cursors: Cursors // per-view cursors
└── view_transform: Option<...>
└── layout: Option<Layout>
The correct flow for cursor-triggered scrolling:
1. Input Event (↓ key)
│
▼
2. Cursor moves in SplitViewState.cursors
│
▼
3. Render phase begins
│
├─► Build view_lines from tokens (with view_transform if present)
│
├─► Call ensure_cursor_visible(view_lines, cursor, viewport)
│ • Find cursor's position in view_lines (accounts for virtual lines)
│ • If cursor outside [top, top+height], adjust top_view_line
│ • This is Layout-aware scrolling
│
├─► If scrolled, rebuild view_lines for new viewport position
│
└─► Render view_lines[top..top+height]
Key insight: Scrolling must happen DURING render, when view_lines (with virtual lines) are available. Scrolling BEFORE render doesn't know about injected virtual lines.
Consider git blame with 120 virtual header lines injected at the top:
Source buffer: Display (with view transform):
Line 1 ── Header (virtual) ──
Line 2 ── Header (virtual) ──
Line 3 ... (120 headers) ...
Line 1 ← cursor here
Line 2
Line 3
Source-based scrolling thinks "cursor is at byte 0, which is source line 1, keep top at line 1". But the display has 120 virtual lines before that! The cursor appears off-screen.
Layout-aware scrolling looks at view_lines: "cursor maps to view_line 121, viewport shows lines 0-30, need to scroll to line 121".
To reach the target architecture:
- Move cursors to SplitViewState: Each view has independent cursor positions
- Remove EditorState.viewport: Or keep only for dimension hints
- Remove sync functions: No more bidirectional state syncing
- Single scrolling function: Only Layout-aware
ensure_cursor_visible - Scroll during render: After building view_lines, before rendering
Until full migration, these workarounds maintain correctness:
sync_viewport_from_split_view_stateonly syncs DIMENSIONS, not scroll positionensure_visible_in_layoutcalled in render phase with actual view_lines- Editor.render() does NOT call sync_with_cursor (let split_rendering handle it)
Fresh automatically saves and restores editor state per working directory. When you close the editor and reopen in the same directory, your workspace is restored.
- Split layout: Window split configuration (horizontal/vertical splits, ratios)
- Open files: All open files in each split, with active tab
- Cursor positions: Per-file cursor position and selection state
- Scroll positions: Per-file scroll position (as byte offset)
- File explorer state: Visibility, width, expanded directories
- Search/replace history: Per-project search terms
- Search options: Case sensitivity, regex, whole word settings
- Bookmarks: Global bookmarks with file paths and positions
- Config overrides: Per-project toggle overrides (line wrap, line numbers, etc.)
Sessions are stored in ~/.local/share/fresh/sessions/ with filenames derived from the working directory path:
~/.local/share/fresh/sessions/
├── home_user_my%20project.json # Encoded path as filename
└── ...
Path encoding: / → _, spaces → %20 (URL encoding for special chars).
Key files:
src/session.rs- Session types and serializationsrc/app/session.rs- Save/restore integration
Session lifecycle:
- Load on startup:
SessionManager::load()checks for existing session - Apply to editor:
Session::apply_to_editor()opens files, restores splits, cursors - Save on exit:
SessionManager::save()captures current state - Incremental saves: Dirty flag + debounce saves periodically (crash resistance)
--no-session: Don't restore previous session--no-save-session: Don't save session on exit
| Scenario | Behavior |
|---|---|
| File deleted | Skipped, warning logged |
| File moved | Skipped (path-based lookup) |
| Corrupt session | Logged, start fresh |
| Future version | Error with message |
| Virtual buffers | Not persisted (file-backed only) |
Fresh includes a built-in file browser for the Open File command (Ctrl+O). This works without plugins and provides a rich file browsing experience.
- Column sorting: Click headers to sort by name, size, modified date, or type
- Quick navigation: Shortcuts for parent (
..), root (/), and home (~) - Fuzzy filtering: Type to filter; non-matching entries shown grayed at bottom
- Metadata display: File size (human-readable) and modification date
- Mouse support: Click to select, double-click to open, drag scrollbar
Key files:
src/app/file_open.rs-FileOpenState, sorting, filtering logicsrc/view/ui/file_browser.rs-FileBrowserRenderer
State management:
pub struct FileOpenState {
pub current_dir: PathBuf,
pub entries: Vec<FileOpenEntry>,
pub sort_mode: SortMode, // Name, Size, Modified, Type
pub sort_ascending: bool,
pub selected_index: usize,
pub filter: String, // From prompt input
}Rendering: The file browser popup renders above the prompt when PromptType::OpenFile is active. Directories load asynchronously via FsManager.
Fresh supports two syntax highlighting backends with automatic fallback:
- Tree-sitter (primary): Fast, accurate, incremental parsing. Used for built-in languages (Rust, JavaScript, TypeScript, Python, etc.)
- TextMate grammars (fallback): Uses
syntectcrate for broad language coverage. Supports VSCode-compatible grammars.
1. Tree-sitter (if built-in support exists)
2. User TextMate grammar (from ~/.config/fresh/grammars/)
3. Built-in syntect grammars (100+ languages)
4. No highlighting
Add VSCode-compatible grammars to ~/.config/fresh/grammars/:
~/.config/fresh/grammars/
my-language/
package.json # VSCode extension manifest
syntaxes/
language.tmLanguage.json
Key files:
src/primitives/highlighter.rs- Tree-sitter highlightersrc/primitives/textmate_highlighter.rs- TextMate grammar highlightersrc/primitives/highlight_engine.rs- UnifiedHighlightEngineabstractionsrc/primitives/grammar_registry.rs- Grammar discovery and loading
Configuration (in config.json):
{
"languages": {
"haskell": {
"extensions": ["hs"],
"highlighter": "textmate"
}
}
}Options: auto (default), tree-sitter, textmate
Both backends use viewport-only parsing:
- Parse only visible lines + 1KB context
- Cache parsed results (category-based for theme independence)
- Invalidate cache on edits