I've been using Vim for years, but I always treated it as a black box. Writing my own implementation forced me to understand the mechanics behind modal editing, efficient text manipulation, and raw terminal I/O. Here's what I learned.
Raw Terminal Mode
By default, terminals buffer input and process it line-by-line. To build an interactive editor, you need raw mode: every keypress is delivered immediately, without echo, and control sequences aren't processed by the terminal driver.
In Rust, I used the libc crate to call tcgetattr/tcsetattr directly:
use libc::{tcgetattr, tcsetattr, termios, ECHO, ICANON, TCSAFLUSH, STDIN_FILENO};
pub struct RawTerminal {
orig: termios,
}
impl RawTerminal {
pub fn enable() -> Result<Self> {
let mut raw: termios = unsafe { std::mem::zeroed() };
unsafe { tcgetattr(STDIN_FILENO, &mut raw) };
let orig = raw;
// Disable echo and canonical mode
raw.c_lflag &= !(ECHO | ICANON);
// Minimum bytes to read, timeout
raw.c_cc[libc::VMIN] = 1;
raw.c_cc[libc::VTIME] = 0;
unsafe { tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) };
Ok(RawTerminal { orig })
}
}
impl Drop for RawTerminal {
fn drop(&mut self) {
unsafe { tcsetattr(STDIN_FILENO, TCSAFLUSH, &self.orig) };
}
}Using Drop to restore the terminal on exit is crucial — otherwise users are left with a broken shell.
The Gap Buffer
A naïve approach would store text as a Vec<char> or String. But insertions in the middle of a large file would be O(n) — you'd have to shift everything after the cursor.
A gap buffer is a classic solution: store the text as two halves with a "gap" between them at the cursor position. Insertions at the cursor are O(1) — you just write into the gap. Movement requires shifting the gap, which is O(distance).
pub struct GapBuffer {
data: Vec<u8>,
gap_start: usize,
gap_end: usize,
}
impl GapBuffer {
pub fn insert(&mut self, byte: u8) {
if self.gap_start == self.gap_end {
self.grow();
}
self.data[self.gap_start] = byte;
self.gap_start += 1;
}
pub fn delete_before(&mut self) {
if self.gap_start > 0 {
self.gap_start -= 1;
}
}
pub fn move_cursor_right(&mut self) {
if self.gap_end < self.data.len() {
self.data[self.gap_start] = self.data[self.gap_end];
self.gap_start += 1;
self.gap_end += 1;
}
}
fn grow(&mut self) {
let new_gap_size = (self.data.len() + 1) * 2;
let mut new_data = vec![0u8; self.data.len() + new_gap_size];
new_data[..self.gap_start].copy_from_slice(&self.data[..self.gap_start]);
let tail_len = self.data.len() - self.gap_end;
let new_gap_end = new_data.len() - tail_len;
new_data[new_gap_end..].copy_from_slice(&self.data[self.gap_end..]);
self.gap_end = new_gap_end;
self.data = new_data;
}
}Modal Editing
Vim's key insight is that editing text is mostly navigation and transformation, not typing. The modal model gives different meanings to keys depending on the current mode.
I modeled this as a simple state machine:
#[derive(Debug, Clone, PartialEq)]
pub enum Mode {
Normal,
Insert,
Visual { anchor: Position },
Command,
}
pub struct Editor {
buffer: GapBuffer,
mode: Mode,
cursor: Position,
// ...
}
impl Editor {
pub fn handle_key(&mut self, key: Key) {
match &self.mode {
Mode::Normal => self.handle_normal(key),
Mode::Insert => self.handle_insert(key),
Mode::Visual { anchor } => self.handle_visual(key, *anchor),
Mode::Command => self.handle_command(key),
}
}
fn handle_normal(&mut self, key: Key) {
match key {
Key::Char('i') => self.mode = Mode::Insert,
Key::Char('a') => {
self.cursor.col += 1;
self.mode = Mode::Insert;
}
Key::Char('h') => self.move_left(),
Key::Char('l') => self.move_right(),
Key::Char('j') => self.move_down(),
Key::Char('k') => self.move_up(),
Key::Char('v') => {
self.mode = Mode::Visual { anchor: self.cursor };
}
Key::Char(':') => self.mode = Mode::Command,
Key::Char('d') => { /* next key determines motion */ }
_ => {}
}
}
}Rendering
Instead of clearing the entire screen on every frame, I maintain a diff of what changed and only emit ANSI escape codes for those lines. This eliminates the flicker you'd get with a full clear-and-redraw approach.
pub fn render_diff(&self, prev: &Screen, next: &Screen) -> Vec<u8> {
let mut output = Vec::new();
for (y, (prev_row, next_row)) in prev.rows.iter().zip(next.rows.iter()).enumerate() {
if prev_row != next_row {
// Move cursor to start of line
write!(output, "\x1b[{};1H", y + 1).unwrap();
// Write the new line content
output.extend_from_slice(next_row.render().as_bytes());
}
}
output
}What's Next
The editor is usable for editing config files and small programs. The remaining major features are:
- Undo/redo via a persistent rope data structure
- Tree-sitter integration for proper syntax highlighting
- LSP client for code intelligence
The source is MIT licensed on GitHub.