JackJack Derksen

Redox devlog 1: the MVP

Once again, here's a link to the repo.


Redox now has a proper logo. I spent way longer on this than you'd think!
Redox now has a proper logo.
I spent way longer on this than you'd think!

Checking in

Redox is the Vim-like text editor I'm building for my university capstone. It is written in Rust on top of MinUI, my TUI framework.

So, what does it look like now?

This minimum viable product is the first version that feels like an editor instead of a prototype held together by spit and caffeine.

The editor logic and UI code now live in separate crates. I'm mostly happy with that boundary, though large files still expose performance problems. The demo below runs from an optimizations branch for a reason.

Demo

Basic demo of Redox in its current state

Redox can open and save files, stores text in a Ropey buffer, and supports most basic Vim motions. You can even quit it with :q, so at least I got the important part right.

Project architecture

The workspace remains split into two crates:

  • editor_core owns the Ropey buffer, positions, editing operations, and motions such as w, b, e, gg, and G.
  • editor_tui owns the MinUI loop, input mapping, rendering, cursor projection, and statusline.

editor_core knows nothing about MinUI or terminal widgets. editor_tui does not define how editing works. That separation has already made changes and tests less painful.

The workspace layout

redox/
├── crates/
│   ├── editor_core/    # Buffer logic + motions + core editing primitives
│   └── editor_tui/     # MinUI loop + input mapping + rendering/widgets

Why Redox uses a rope and position types

Redox uses logical positions such as Pos { line, col } and character-based indexing for core operations. This keeps Unicode handling predictable and avoids confusing byte offsets with visual columns.

The text lives in ropey::Rope, not one giant String.

That keeps frequent insertions and deletions practical as files grow. It also provides line and character indexing, which an editor needs constantly.

Redox currently slows down on files larger than about 1 MB. Ropey should handle that size without trouble, so the bottleneck probably sits in MinUI's rendering loop, one of my abstractions, or the terminal itself. One megabyte is larger than most source files, but an editor should still cope with it.

Input pipeline

Input follows three steps:

  1. MinUI receives an event such as a keystroke.
  2. editor_tui maps them into high-level InputActions.
  3. EditorState::apply_input applies the action to the buffer and cursor state.

The mapper handles Normal, Insert, and Command modes. Visual mode comes later. It also handles count prefixes such as 3w and multi-key sequences such as gg.

// Tiny prefix state machine that enables gg and count prefixes without
// bloating the key handling.

#[derive(Debug, Default, Clone)]
pub struct InputState {
    pending_g: bool,
    pending_count: Option<usize>,
}

impl InputState {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn reset_prefixes(&mut self) {
        self.pending_g = false;
        self.pending_count = None;
    }

    fn push_count_digit(&mut self, d: u8) {
        debug_assert!(d <= 9);
        let current = self.pending_count.unwrap_or(0);
        let next = current.saturating_mul(10).saturating_add(d as usize);
        self.pending_count = Some(next);
    }

    fn take_count_or_1(&mut self) -> usize {
        match self.pending_count.take() {
            Some(0) | None => 1,
            Some(n) => n,
        }
    }
}

Each keypress becomes an explicit action:

  • Motion { motion, count }
  • EnterInsert(InsertKind::AppendLineEnd)
  • CommandEnter
  • Paste(String)

This gives editing behaviour one path through the code and makes it easier to test.

Cursor and viewport

Cursor behaviour can make or break a text editor.

Redox clamps cursor positions to the buffer and remembers the preferred column during vertical movement. The viewport follows the cursor with scrolloff-style margins, then adjusts those margins near the end of the file. Horizontal movement and scrolling use terminal cell widths instead of character counts.

Wide glyphs and tabs break simple column math. Redox segments graphemes and calculates their cell widths so the cursor and rendered text stay aligned.

Rendering and caching

The renderer builds a viewport snapshot from the core buffer and draws only visible rows.

// To avoid re-segmenting text every frame, rendered lines are cached
// by (line, hash).
// Cache hits are cheap, and misses pay segmentation cost only once.

pub fn graphemes_for_line<'a>(
    &'a mut self,
    line_idx: usize,
    line_text: &str,
) -> &'a [Box<str>] {
    self.tick = self.tick.wrapping_add(1);
    let h = hash64(line_text);

    if let Some(pos) = self
        .entries
        .iter()
        .position(|e| e.line_idx == line_idx && e.hash == h)
    {
        // Bump usage
        self.entries[pos].last_used_tick = self.tick;
        return &self.entries[pos].graphemes;
    }

    // Miss: segment and insert.
    let graphemes: Vec<Box<str>> = line_text
        .graphemes(true)
        .map(|g| g.to_owned().into_boxed_str())
        .collect();

    ...

The MVP caches graphemes by line index and hash, so unchanged lines skip segmentation on later frames. A separate path avoids full-line allocations for unusually long lines, though it still needs work. The renderer also clips by terminal cell width so wide characters do not overflow the layout.

Redox does not soft-wrap yet. It uses horizontal scrolling, which matches the modal editors I am copying.

Command mode and statusline

Command mode supports:

  • :w
  • :q
  • :q!
  • :wq

Redox tracks unsaved changes, so :q warns before discarding them.

The statusline shows:

  • Current mode (NORMAL, INSERT, COMMAND)
  • Active file path, status message, or the command as appropriate
  • Cursor line/column

What works in the MVP

  • Opening a file with redox <file_name/location>
  • Modal editing flow (Normal/Insert/Command)
  • Basic editing (insert, backspace, newline, bracketed paste)
  • Vim-style motions and counts (h/j/k/l, w/b/e, gg, G)
  • Viewport tracking and scrolling
  • Write/quit command workflow

Deliberate tradeoffs

Several parts are still simple:

  • UTF-8 file handling only
  • Whole-file read/write path
  • Single-buffer model
  • No undo tree yet

I want a stable loop for input, editing, and rendering before I add larger features.

Roadmap

Stage Current status Main focus Highlights
Core MVP Done Editor foundation Rope buffer, motions, core modal system, write/quit, statusline
Next step In progress Editing depth Richer motion/command set, visual mode, improved performance, multi-buffer workflows
Later Planned Power features Undo/redo model, polishing ergonomics, search, possibly syntax highlighting and LSP support

The internal boundaries are the hardest part to change later. Redox has reached the point where I can add features without first tearing apart the editor core. That is a modest milestone, but it feels good.