← articles
RustSystemsTUI

Writing a Vim-style Text Editor in Rust: Gap Buffers, Raw Terminal I/O, and Modal Editing

How I built a terminal text editor in Rust from scratch — covering raw terminal mode, efficient text storage with a gap buffer, and implementing vim's modal editing model.

September 22, 2024/4 min read

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:

src/terminal.rs
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).

src/buffer.rs
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:

src/editor.rs
#[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.

src/renderer.rs
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.