Skip to main content

vimltui/
lib.rs

1//! # vimltui
2//!
3//! A self-contained, embeddable Vim editor for [Ratatui](https://ratatui.rs) TUI applications.
4//!
5//! `vimltui` provides a fully functional Vim editing experience that you can drop into any
6//! Ratatui-based terminal application. Each [`VimEditor`] instance owns its own text buffer,
7//! cursor, mode, undo/redo history, search state, and registers — completely independent
8//! from your application state.
9//!
10//! ## Features
11//!
12//! - **Normal / Insert / Visual** modes (Char, Line, Block)
13//! - **Operator + Motion** composition (`dw`, `ci"`, `y$`, `>j`, `gUw`, ...)
14//! - **f / F / t / T** character find motions
15//! - **Search** with `/`, `?`, `n`, `N` and match highlighting
16//! - **Dot repeat** (`.`) for last edit
17//! - **Undo / Redo** with snapshot stack
18//! - **Command mode** (`:w`, `:q`, `:wq`, `:123`)
19//! - **Registers** and system clipboard integration
20//! - **Text objects** (`iw`, `i"`, `i(`)
21//! - **Relative line numbers** in the built-in renderer
22//! - **Pluggable syntax highlighting** via the [`SyntaxHighlighter`] trait
23//!
24//! ## Quick Start
25//!
26//! ```rust
27//! use vimltui::{VimEditor, VimModeConfig};
28//!
29//! // Create an editor with full editing capabilities
30//! let mut editor = VimEditor::new("Hello, Vim!", VimModeConfig::default());
31//!
32//! // Create a read-only viewer (visual selection only, no insert)
33//! let mut viewer = VimEditor::new("Read only content", VimModeConfig::read_only());
34//!
35//! // Get the current content back
36//! let text = editor.content();
37//! ```
38//!
39//! ## Handling Input
40//!
41//! ```rust,no_run
42//! use vimltui::{VimEditor, VimModeConfig, EditorAction};
43//! use crossterm::event::KeyEvent;
44//!
45//! let mut editor = VimEditor::new("", VimModeConfig::default());
46//!
47//! // In your event loop:
48//! // let action = editor.handle_key(key_event);
49//! // match action {
50//! //     EditorAction::Handled => { /* editor consumed the key */ }
51//! //     EditorAction::Unhandled(key) => { /* pass to your app's handler */ }
52//! //     EditorAction::Save => { /* user typed :w */ }
53//! //     EditorAction::Close => { /* user typed :q */ }
54//! //     EditorAction::ForceClose => { /* user typed :q! */ }
55//! //     EditorAction::SaveAndClose => { /* user typed :wq */ }
56//! // }
57//! ```
58//!
59//! ## Rendering
60//!
61//! Use the built-in renderer with your own [`SyntaxHighlighter`]:
62//!
63//! ```rust
64//! use vimltui::{VimTheme, PlainHighlighter, SyntaxHighlighter};
65//! use ratatui::style::Color;
66//! use ratatui::text::Span;
67//!
68//! // Built-in PlainHighlighter for no syntax coloring
69//! let highlighter = PlainHighlighter;
70//!
71//! // Or implement your own:
72//! struct SqlHighlighter;
73//! impl SyntaxHighlighter for SqlHighlighter {
74//!     fn highlight_line<'a>(&self, line: &'a str, spans: &mut Vec<Span<'a>>) {
75//!         spans.push(Span::raw(line));
76//!     }
77//! }
78//! ```
79
80pub mod editor;
81pub mod render;
82
83use std::collections::HashMap;
84use crossterm::event::KeyEvent;
85use ratatui::style::Color;
86use ratatui::text::Span;
87
88// Re-export the primary type for convenience
89pub use editor::VimEditor;
90
91/// Vim editing mode.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum VimMode {
94    Normal,
95    Insert,
96    Replace,
97    Visual(VisualKind),
98}
99
100/// Cursor shape hint for renderers.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum CursorShape {
103    Block,
104    Bar,
105    Underline,
106}
107
108/// Visual selection kind.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum VisualKind {
111    Char,
112    Line,
113    Block,
114}
115
116/// Configures which Vim modes are available in an editor instance.
117#[derive(Debug, Clone)]
118pub struct VimModeConfig {
119    pub insert_allowed: bool,
120    pub visual_allowed: bool,
121}
122
123impl Default for VimModeConfig {
124    fn default() -> Self {
125        Self {
126            insert_allowed: true,
127            visual_allowed: true,
128        }
129    }
130}
131
132impl VimModeConfig {
133    /// Read-only mode: visual selection is allowed but insert is disabled.
134    pub fn read_only() -> Self {
135        Self {
136            insert_allowed: false,
137            visual_allowed: true,
138        }
139    }
140}
141
142/// Actions returned from [`VimEditor::handle_key()`] to inform the parent application.
143pub enum EditorAction {
144    /// The editor consumed the key — no further action needed.
145    Handled,
146    /// The editor does not handle this key — bubble up to the parent.
147    Unhandled(KeyEvent),
148    /// Save buffer (`:w` or `Ctrl+S`).
149    Save,
150    /// Close buffer (`:q`).
151    Close,
152    /// Force close without saving (`:q!`).
153    ForceClose,
154    /// Save and close (`:wq`, `:x`).
155    SaveAndClose,
156    /// Toggle line comment on the current line (`gcc` in Normal mode).
157    ToggleComment,
158    /// Toggle block comment on the visual selection (`gc` in Visual mode).
159    /// Contains (start_row, end_row) of the selection.
160    ToggleBlockComment { start_row: usize, end_row: usize },
161    /// Go to definition at cursor position (`gd` in Normal mode).
162    /// The consumer implements the actual navigation.
163    GoToDefinition,
164    /// Show hover/documentation at cursor position (`K` in Normal mode).
165    /// The consumer implements the actual display.
166    Hover,
167}
168
169/// Leader key (space by default, like modern Neovim setups).
170pub const LEADER_KEY: char = ' ';
171
172/// Operator waiting for a motion (e.g., `d` waits for `w` → `dw`).
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub enum Operator {
175    Delete,
176    Yank,
177    Change,
178    Indent,
179    Dedent,
180    Uppercase,
181    Lowercase,
182    ToggleCase,
183}
184
185/// The range affected by a motion, used by operators.
186#[derive(Debug, Clone)]
187pub struct MotionRange {
188    pub start_row: usize,
189    pub start_col: usize,
190    pub end_row: usize,
191    pub end_col: usize,
192    pub linewise: bool,
193}
194
195/// Snapshot of editor state for undo/redo.
196#[derive(Debug, Clone)]
197pub struct Snapshot {
198    pub lines: Vec<String>,
199    pub cursor_row: usize,
200    pub cursor_col: usize,
201}
202
203/// Register content (for yank/paste).
204#[derive(Debug, Clone, Default)]
205pub struct Register {
206    pub content: String,
207    pub linewise: bool,
208}
209
210/// State for incremental search (`/` and `?`).
211#[derive(Debug, Clone)]
212pub struct SearchState {
213    pub pattern: String,
214    pub forward: bool,
215    pub active: bool,
216    pub input_buffer: String,
217}
218
219impl Default for SearchState {
220    fn default() -> Self {
221        Self {
222            pattern: String::new(),
223            forward: true,
224            active: false,
225            input_buffer: String::new(),
226        }
227    }
228}
229
230/// Recorded keystrokes for dot-repeat (`.`).
231#[derive(Debug, Clone)]
232pub struct EditRecord {
233    pub keys: Vec<KeyEvent>,
234}
235
236/// State saved when entering block insert/append/change mode.
237/// Used to replay the first-line edits on all other lines when Esc is pressed.
238#[derive(Debug, Clone)]
239pub struct BlockInsertState {
240    /// The rows affected by the block operation (inclusive).
241    pub start_row: usize,
242    pub end_row: usize,
243    /// The column at which text is inserted on each row.
244    pub col: usize,
245}
246
247/// Temporary highlight for yanked text (like Neovim's `vim.highlight.on_yank()`).
248#[derive(Debug, Clone)]
249pub struct YankHighlight {
250    pub start_row: usize,
251    pub start_col: usize,
252    pub end_row: usize,
253    pub end_col: usize,
254    pub linewise: bool,
255    pub created_at: std::time::Instant,
256}
257
258impl YankHighlight {
259    /// Duration the highlight stays visible.
260    const DURATION_MS: u128 = 150;
261
262    pub fn is_expired(&self) -> bool {
263        self.created_at.elapsed().as_millis() > Self::DURATION_MS
264    }
265}
266
267/// A diff sign for a specific line, rendered to the RIGHT of the line number.
268///
269/// Consumers populate [`GutterConfig::signs`] with these values.
270/// When empty, the gutter renders exactly as before (zero overhead).
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub enum GutterSign {
273    /// Line was added (new) — green `│` and green line number.
274    Added,
275    /// Line was modified — yellow `│` and yellow line number.
276    Modified,
277    /// Lines were deleted above this position — red `▲`.
278    DeletedAbove,
279    /// Lines were deleted below this position — red `▼`.
280    DeletedBelow,
281}
282
283/// Diagnostic severity level, determines the icon and color in the gutter.
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub enum DiagnosticSeverity {
286    /// Error — red `✘`.
287    Error,
288    /// Warning — yellow `⚠`.
289    Warning,
290}
291
292/// A diagnostic for a specific line, rendered to the LEFT of the line number.
293///
294/// Consumers populate [`GutterConfig::diagnostics`] with these values.
295/// When non-empty, the gutter reserves 2 extra characters for the diagnostic
296/// column (`[icon][space]`). When empty, no extra space is used.
297///
298/// When the cursor is on a line with a diagnostic that has a `message`,
299/// the message is shown in the command line.
300#[derive(Debug, Clone)]
301pub struct Diagnostic {
302    /// Severity level (determines icon and color).
303    pub severity: DiagnosticSeverity,
304    /// Optional message shown in the command line when the cursor is on this line.
305    pub message: Option<String>,
306}
307
308/// Configuration for gutter signs and diagnostics.
309///
310/// Set [`VimEditor::gutter`] to `Some(GutterConfig { .. })` to enable
311/// indicators in the gutter. When `None` (the default), rendering is unchanged.
312///
313/// The gutter layout with both features active:
314/// `[diagnostic][space][number][space][diff_sign]`
315///
316/// Each column is independently optional — diagnostics only add width when
317/// the `diagnostics` map is non-empty; diff signs only replace the trailing
318/// space when the `signs` map is non-empty.
319#[derive(Debug, Clone)]
320pub struct GutterConfig {
321    /// Diff signs per line index (right of number).
322    pub signs: HashMap<usize, GutterSign>,
323    /// Diagnostics per line index (left of number).
324    pub diagnostics: HashMap<usize, Diagnostic>,
325    /// Color for "added" signs and line numbers (default: `Green`).
326    pub sign_added: Color,
327    /// Color for "modified" signs and line numbers (default: `Yellow`).
328    pub sign_modified: Color,
329    /// Color for "deleted" signs (default: `Red`).
330    pub sign_deleted: Color,
331    /// Color for "error" diagnostic signs (default: `Red`).
332    pub sign_error: Color,
333    /// Color for "warning" diagnostic signs (default: `Yellow`).
334    pub sign_warning: Color,
335}
336
337impl Default for GutterConfig {
338    fn default() -> Self {
339        Self {
340            signs: HashMap::new(),
341            diagnostics: HashMap::new(),
342            sign_added: Color::Green,
343            sign_modified: Color::Yellow,
344            sign_deleted: Color::Red,
345            sign_error: Color::Red,
346            sign_warning: Color::Yellow,
347        }
348    }
349}
350
351/// Direction for `f`/`F`/`t`/`T` character find motions.
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353pub enum FindDirection {
354    Forward,
355    Backward,
356}
357
358/// Theme colors used by the built-in [`render`] module.
359///
360/// Each application maps its own theme struct to `VimTheme` before calling
361/// [`render::render()`].
362#[derive(Debug, Clone)]
363pub struct VimTheme {
364    pub border_focused: Color,
365    pub border_unfocused: Color,
366    pub border_insert: Color,
367    pub editor_bg: Color,
368    pub line_nr: Color,
369    pub line_nr_active: Color,
370    pub visual_bg: Color,
371    pub visual_fg: Color,
372    pub dim: Color,
373    pub accent: Color,
374    /// Background for search matches (all occurrences).
375    pub search_match_bg: Color,
376    /// Background for the current search match (where the cursor is).
377    pub search_current_bg: Color,
378    /// Foreground for search match text.
379    pub search_match_fg: Color,
380    /// Background for yank highlight flash.
381    pub yank_highlight_bg: Color,
382    /// Background for live substitution replacement preview.
383    pub substitute_preview_bg: Color,
384}
385
386/// Trait for language-specific syntax highlighting.
387///
388/// Implement this for your language (SQL, JSON, YAML, etc.) and pass it to the
389/// [`render`] module. See [`PlainHighlighter`] for a no-op reference implementation.
390pub trait SyntaxHighlighter {
391    /// Highlight a full line and append styled [`Span`]s.
392    fn highlight_line<'a>(&self, line: &'a str, spans: &mut Vec<Span<'a>>);
393
394    /// Highlight a segment of a line (used when part of the line has visual selection).
395    /// Defaults to [`highlight_line`](SyntaxHighlighter::highlight_line).
396    fn highlight_segment<'a>(&self, text: &'a str, spans: &mut Vec<Span<'a>>) {
397        self.highlight_line(text, spans);
398    }
399}
400
401/// No-op highlighter — renders text without any syntax coloring.
402pub struct PlainHighlighter;
403
404impl SyntaxHighlighter for PlainHighlighter {
405    fn highlight_line<'a>(&self, line: &'a str, spans: &mut Vec<Span<'a>>) {
406        if !line.is_empty() {
407            spans.push(Span::raw(line));
408        }
409    }
410}
411
412/// Number of lines kept visible above/below the cursor when scrolling.
413pub const SCROLLOFF: usize = 3;