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;