Skip to content

Latest commit

 

History

History
240 lines (187 loc) · 12.3 KB

README.md

File metadata and controls

240 lines (187 loc) · 12.3 KB

ratatui-diff

A navigable unified and split diff widget for Ratatui.

Display text comparisons or Git patches with line numbers, word highlights, and keyboard-driven navigation. Your application supplies terminal setup, key bindings, and input data. Rendering depends only on ratatui-core.

Render in a Ratatui application

Prepare a DiffDocument once and keep a DiffState between frames to retain the viewport and cached layout. Build a Diff in the draw closure to select presentation options:

use ratatui_diff::{Diff, DiffDocument, DiffState, ViewMode};

let document = DiffDocument::from_text("hello world\n", "hello Rust\n");
let mut state = DiffState::new();
ratatui::run(|terminal| {
    terminal.draw(|frame| {
        let diff = Diff::new(&document).mode(ViewMode::Split);
        frame.render_stateful_widget(&diff, frame.area(), &mut state);
    })?;
    // Call from your PageDown handler, then redraw.
    state.scroll_pages(1);
    Ok::<(), std::io::Error>(())
})?;

ratatui::run sets up a Crossterm terminal and restores it when the closure returns. The draw closure works with any Ratatui backend. The interactive viewer includes key bindings and an event loop.

Choose an input

use ratatui_diff::DiffDocument;

let patch = "--- a/greeting\n+++ b/greeting\n@@ -1 +1 @@\n-hello world\n+hello Rust\n";
let document = DiffDocument::parse(patch)?;
assert_eq!(document.files().len(), 1);

A patch displays only its supplied changes and context. Binary changes appear as summaries. Combined merge diffs are rejected. Source content retains CRLF and final-newline differences; control characters are displayed as visible text.

Configure appearance

The defaults are unified view, visible line numbers and word highlights, no wrapping or whitespace markers, four-cell tab stops, and the dark theme. Split replacements use bounded similarity anchors and pad unmatched lines; unanchored gaps and large runs pair in source order. Each DiffTheme style can be customized independently.

use ratatui_diff::{Diff, DiffDocument, DiffTheme, ViewMode};

let document = DiffDocument::from_text("old\n", "new\n");
let diff = Diff::new(&document)
    .mode(ViewMode::Split)
    .theme(DiffTheme::light())
    .wrap(true)
    .whitespace(true)
    .tab_width(4);

Wrapped split rows use the taller side's height. Without wrapping, horizontal scrolling is synchronized across panes. Graphemes are never split at viewport edges. Disable inline highlights with Diff::word_highlights when whole-line styling is sufficient.

Syntax colors (optional)

Enable the non-default syntax feature to use bundled Syntect grammars and two-face themes. Choose a language and theme; the adapter handles tokenization and source-byte mapping. Prepare outside drawing, then borrow the result. Complete source establishes lexical state through omitted lines; explicit SyntaxSource::Retained is best-effort when only patch text is available.

use ratatui_diff::{
    Diff, DiffDocument, DiffTheme, FileSyntax, SyntaxHighlighter, SyntaxSource,
};

let old = "fn retry() { let timeout = 500; }\n";
let new = "fn retry() { let timeout = 1500; }\n";
let document = DiffDocument::from_text(old, new);
let theme = DiffTheme::aardvark_ink();
let highlighter = SyntaxHighlighter::bundled("Coldark-Dark")?.contrast_with(theme)?;
let styles = highlighter.prepare(
    &document,
    &[FileSyntax {
        file: 0,
        language: "rs",
        source: SyntaxSource::Full { old, new },
    }],
)?;
let diff = Diff::new(&document).theme(theme).syntax_styles(&styles)?;

contrast_with explicitly adjusts syntax foregrounds for the selected RGB diff backgrounds; omit it to preserve bundled colors. Word/search/selection overlays remain later in composition. Syntax results own no source references. Replacing them preserves navigation and interaction state; a new document requires new preparation. Size limits bound accepted input and stored spans, not regex time. Hosts can schedule preparation on a worker. Distributing applications must include the bundled asset notices from SyntaxHighlighter::acknowledgements.

Navigate and retain the viewport

Render once before page navigation or source mapping so DiffState has the viewport dimensions and row positions. Scrolling counts displayed rows, including headers and wrapped continuations. Source positions identify a file, an old or new side, and a one-based line number.

use ratatui_core::buffer::Buffer;
use ratatui_core::layout::Rect;
use ratatui_core::widgets::StatefulWidget;
use ratatui_diff::{Diff, DiffDocument, DiffState, Side, SourcePosition};

let document = DiffDocument::from_text("before\n", "after\n");
let area = Rect::new(0, 0, 80, 3);
let mut buffer = Buffer::empty(area);
let mut state = DiffState::new();
let diff = Diff::new(&document);
diff.render(area, &mut buffer, &mut state);
assert!(state.scroll_to_source(SourcePosition {
    file: 0,
    side: Side::New,
    line: 1
}));

Resizing or switching modes retains the nearest available source anchor. Replacing the document resets navigation on the next render. Theme and word-highlight changes preserve layout. Give each independently navigated pane its own state, even when both borrow the same document.

Select source for copying

Keep anchor and focus in DiffState::set_selection, then extend focus with SelectionMotion from host key bindings. Pointer handlers can convert a hit SourceRange with start_boundary and end_boundary. Selection stays within one file and side, survives resizing and view changes, and clears on document replacement.

selected_text returns original source bytes for the host's clipboard integration. It preserves tabs, controls, CRLF, and final-newline state; it excludes gutters, wrapping, padding, and display notation. Invalid grapheme boundaries and missing patch context are rejected. Diff::selection_style controls the overlay applied after word highlights.

Search available source

Update a case-sensitive literal query in your event handler, then navigate cached occurrences. Both-side search counts shared context once. Restrict search with Some(Side::Old) or Some(Side::New); an empty query clears it. Matches follow document order and navigation wraps. Omitted patch context, file headers, and metadata are not searched.

use ratatui_diff::{DiffDocument, DiffState};

let document = DiffDocument::from_text("timeout: 500\n", "timeout: 1500\n");
let mut state = DiffState::new();
state.set_search(&document, "timeout", None);
assert_eq!(state.search_matches().len(), 2);
assert!(state.next_match());
assert_eq!(state.active_match(), Some(0));
// Render the widget to reveal the selected occurrence.
assert!(state.previous_match());
assert_eq!(state.active_match(), Some(1));

Matches retain source byte ranges through wrapping, resizing, and mode changes. Search styles compose over changed-word styles; the selected occurrence uses reverse video in every preset, including monochrome. Query updates scan available source synchronously; frame rendering reuses the results. Updating a query or side clears the active match, while identical updates preserve it. Rendering a replacement document clears search; the host can then reapply its query.

Large documents

Comparison is synchronous; prepare large documents outside the event loop. Rendering reuses the comparison results and draws only visible rows, but first layout and resize can process the whole document. Large replacements fall back to whole-line styling when they exceed the word-comparison size limits.

Compatibility

This pre-1.0 API may change between minor releases.

Views

Unified view keeps additions and deletions together, with changed words emphasized.

Unified diff with line numbers and word highlights

Split view places old source on the left and new source on the right.

Split diff with aligned old and new source

Whitespace markers make spaces visible. The animated demo holds each captioned view for five seconds.

Development

The user guide walks through inputs, appearance, and interaction with screenshots. See Contributing, architecture, dependency decisions, and deferred work. Run just example for the interactive viewer. See releasing for package and screenshot publication.

License

Licensed under either MIT or Apache-2.0, at your option.