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}
157
158/// Leader key (space by default, like modern Neovim setups).
159pub const LEADER_KEY: char = ' ';
160
161/// Operator waiting for a motion (e.g., `d` waits for `w` → `dw`).
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum Operator {
164 Delete,
165 Yank,
166 Change,
167 Indent,
168 Dedent,
169 Uppercase,
170 Lowercase,
171 ToggleCase,
172}
173
174/// The range affected by a motion, used by operators.
175#[derive(Debug, Clone)]
176pub struct MotionRange {
177 pub start_row: usize,
178 pub start_col: usize,
179 pub end_row: usize,
180 pub end_col: usize,
181 pub linewise: bool,
182}
183
184/// Snapshot of editor state for undo/redo.
185#[derive(Debug, Clone)]
186pub struct Snapshot {
187 pub lines: Vec<String>,
188 pub cursor_row: usize,
189 pub cursor_col: usize,
190}
191
192/// Register content (for yank/paste).
193#[derive(Debug, Clone, Default)]
194pub struct Register {
195 pub content: String,
196 pub linewise: bool,
197}
198
199/// State for incremental search (`/` and `?`).
200#[derive(Debug, Clone)]
201pub struct SearchState {
202 pub pattern: String,
203 pub forward: bool,
204 pub active: bool,
205 pub input_buffer: String,
206}
207
208impl Default for SearchState {
209 fn default() -> Self {
210 Self {
211 pattern: String::new(),
212 forward: true,
213 active: false,
214 input_buffer: String::new(),
215 }
216 }
217}
218
219/// Recorded keystrokes for dot-repeat (`.`).
220#[derive(Debug, Clone)]
221pub struct EditRecord {
222 pub keys: Vec<KeyEvent>,
223}
224
225/// Temporary highlight for yanked text (like Neovim's `vim.highlight.on_yank()`).
226#[derive(Debug, Clone)]
227pub struct YankHighlight {
228 pub start_row: usize,
229 pub start_col: usize,
230 pub end_row: usize,
231 pub end_col: usize,
232 pub linewise: bool,
233 pub created_at: std::time::Instant,
234}
235
236impl YankHighlight {
237 /// Duration the highlight stays visible.
238 const DURATION_MS: u128 = 150;
239
240 pub fn is_expired(&self) -> bool {
241 self.created_at.elapsed().as_millis() > Self::DURATION_MS
242 }
243}
244
245/// A gutter sign for a specific line, used for diff indicators.
246///
247/// Consumers populate [`VimEditor::gutter_signs`] with these values.
248/// When empty, the gutter renders exactly as before (zero overhead).
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub enum GutterSign {
251 /// Line was added (new) — green `│` and green line number.
252 Added,
253 /// Line was modified — yellow `│` and yellow line number.
254 Modified,
255 /// Lines were deleted above this position — red `▲`.
256 DeletedAbove,
257 /// Lines were deleted below this position — red `▼`.
258 DeletedBelow,
259}
260
261/// Configuration for gutter diff signs.
262///
263/// Set [`VimEditor::gutter`] to `Some(GutterConfig { .. })` to enable
264/// diff indicators in the gutter. When `None` (the default), rendering
265/// is unchanged.
266#[derive(Debug, Clone)]
267pub struct GutterConfig {
268 /// Signs per line index.
269 pub signs: HashMap<usize, GutterSign>,
270 /// Color for "added" signs and line numbers (default: `Green`).
271 pub sign_added: Color,
272 /// Color for "modified" signs and line numbers (default: `Yellow`).
273 pub sign_modified: Color,
274 /// Color for "deleted" signs (default: `Red`).
275 pub sign_deleted: Color,
276}
277
278impl Default for GutterConfig {
279 fn default() -> Self {
280 Self {
281 signs: HashMap::new(),
282 sign_added: Color::Green,
283 sign_modified: Color::Yellow,
284 sign_deleted: Color::Red,
285 }
286 }
287}
288
289/// Direction for `f`/`F`/`t`/`T` character find motions.
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291pub enum FindDirection {
292 Forward,
293 Backward,
294}
295
296/// Theme colors used by the built-in [`render`] module.
297///
298/// Each application maps its own theme struct to `VimTheme` before calling
299/// [`render::render()`].
300#[derive(Debug, Clone)]
301pub struct VimTheme {
302 pub border_focused: Color,
303 pub border_unfocused: Color,
304 pub border_insert: Color,
305 pub editor_bg: Color,
306 pub line_nr: Color,
307 pub line_nr_active: Color,
308 pub visual_bg: Color,
309 pub visual_fg: Color,
310 pub dim: Color,
311 pub accent: Color,
312 /// Background for search matches (all occurrences).
313 pub search_match_bg: Color,
314 /// Background for the current search match (where the cursor is).
315 pub search_current_bg: Color,
316 /// Foreground for search match text.
317 pub search_match_fg: Color,
318 /// Background for yank highlight flash.
319 pub yank_highlight_bg: Color,
320 /// Background for live substitution replacement preview.
321 pub substitute_preview_bg: Color,
322}
323
324/// Trait for language-specific syntax highlighting.
325///
326/// Implement this for your language (SQL, JSON, YAML, etc.) and pass it to the
327/// [`render`] module. See [`PlainHighlighter`] for a no-op reference implementation.
328pub trait SyntaxHighlighter {
329 /// Highlight a full line and append styled [`Span`]s.
330 fn highlight_line<'a>(&self, line: &'a str, spans: &mut Vec<Span<'a>>);
331
332 /// Highlight a segment of a line (used when part of the line has visual selection).
333 /// Defaults to [`highlight_line`](SyntaxHighlighter::highlight_line).
334 fn highlight_segment<'a>(&self, text: &'a str, spans: &mut Vec<Span<'a>>) {
335 self.highlight_line(text, spans);
336 }
337}
338
339/// No-op highlighter — renders text without any syntax coloring.
340pub struct PlainHighlighter;
341
342impl SyntaxHighlighter for PlainHighlighter {
343 fn highlight_line<'a>(&self, line: &'a str, spans: &mut Vec<Span<'a>>) {
344 if !line.is_empty() {
345 spans.push(Span::raw(line));
346 }
347 }
348}
349
350/// Number of lines kept visible above/below the cursor when scrolling.
351pub const SCROLLOFF: usize = 3;