Jack

Redox Devlog[1] - The MVP

0 views

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!

Checking In

Quick recap: Redox is my Vim-clone text editor project that I'm building for my university capstone/final project. It's written in Rust on top of MinUI, my TUI framework.

So... what's Redox looking like at this stage?

The original plan was always “build toward a Vim-like editor,” and this current minimum viable product (MVP) is the first version where the architecture and the editor in general finally feel real, not just like a prototype glued together by spit, duct tape, and caffeine.

The important part is this: it already has a solid split between editor logic and UI plumbing, and that logic has been painstakingly iterated upon to the point that I'm finally (mostly) satisfied with it. Still a lot of optimization to do for larger files, hence me being on an 'optimizations' branch in the demo below.

Speaking of which, let's have a look-see!

Demo

Basic demo of Redox in its current state

As you could hopefully tell from that quick little demo video, the editor has full file I/O capabilities, with a Ropey-backed text buffer and support for most of the basic Vim motions. Of course, you can quit Redox in the exact same way you quit Vim (:q, in case you forgot 😉).

Project Architecture

Probably the most important decision so far was to keep the workspace split into two crates:

  • editor_core (the brain): Ropey-backed text buffer, position logic, editing ops, and motion logic (w, b, e, gg, G, etc).
  • editor_tui (the body): MinUI app loop, event mapping, rendering, cursor projection, statusline.

Remember, editor_core does not know anything about MinUI or terminal widgets. Similarly, editor_tui does not own text semantics. That separation has already made changes easier, and extension and testing way less painful.

The Workspace Layout

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

The Data Model: Why Rope + Position Types

Internally, Redox uses logical positions (Pos { line, col }) and char-based indexing for core operations. This helps keep Unicode handling predictable and avoids the byte-index trap where visual columns and byte offsets drift apart.

For the text model, I’m using ropey::Rope, not one giant String.

Why?

  • Frequent insert/delete operations stay practical as files grow
  • Line and char indexing are much cleaner for editor workflows
  • It scales better to “real file” scenarios (logs, generated files, long lines)

NOTE: I'm currently noticing very significant slowdown when opening any file over ~ 1MB. A rope should be able to eat this for breakfast, so I have to do some troubleshooting to figure out what's causing this. This could be a bottleneck in MinUI's rendering loop, an abstraction layer, or maybe even my terminal emulator itself. While 1MB is significantly larger than a typical source code file, I'd love for the architecture to be robust enough to handle it.

Input Pipeline: Events -> Actions -> State Changes

The TUI input path is deliberately structured as follows:

  1. MinUI events come in (key strokes, etc).
  2. editor_tui maps them into high-level InputActions.
  3. EditorState::apply_input applies those actions against the core buffer/cursor state.

That mapper currently handles modal behavior plus prefixes:

  • Modes: Normal / Insert / Command (Visual mode coming soon)
  • Count prefixes (eg. 3w to advance 3 words)
  • Multi-key sequences (eg. gg to jump to the top of a file)
// 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,
        }
    }
}

So instead of every keypress directly mutating text in random places, the code processes explicit intents like:

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

This makes behavior easier to reason about, and easier to test.

Cursor + Viewport: The “Feel” Layer

In my opinion, this is the make-or-break piece for whether an editor feels good.

Redox’s cursor controller is doing more than just (x, y):

  • Cursor positions are clamped to real buffer bounds, and have preferred column positioning
  • Viewport follows cursor with scrolloff-style margins
  • Margins are adjusted near EOF to avoid fake bottom-space behavior
  • Horizontal movement/scrolling is done in terminal cell width, not naive char count

That last point matters a lot in terminals. Wide glyphs and tabs can break simplistic math fast. Right now, Redox uses grapheme segmentation and cell-width calculation to keep cursor projection and rendering aligned.

Rendering: Grapheme-Aware + Cached + Fast Path

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();
    
    ...

Some cool MVP optimizations already in place:

  • Grapheme cache keyed by line index + hash, so unchanged lines avoid re-segmentation every frame
  • Long-line fast path for very large lines to avoid expensive full-line allocations (still some work to do here)
  • Clipping by terminal cell width so wide characters don’t overflow layout

Intentionally no soft-wrap, and horizontal scrolling is explicit. That keeps behavior closer to classic modal editor expectations for now.

Command Mode + Statusline

Current command mode supports:

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

There is dirty-state tracking, so :q warns when there are unsaved changes.

The statusline is a custom segmented widget showing:

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

What Works in the MVP

  • Launch with file input (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 follow + scrolling
  • Write/quit command workflow

Tradeoffs (On Purpose)

A few things are intentionally still simple:

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

These are sequencing choices, not forgotten features. I wanted a stable core/edit/render loop first, then layer in bigger functionality.

Roadmap Snapshot

StageCurrent StatusMain FocusHighlights
Core MVPDone ✓Editor foundationRope buffer, motions, core modal system, write/quit, statusline
Next StepIn progressEditing depthRicher motion/command set, visual mode, improved performance, multi-buffer workflows
LaterPlannedPower featuresUndo/redo model, polishing ergonomics, search, possibly syntax highlighting and LSP support

If you’ve ever built CLI tools, you know this is the hard part: getting the internal boundaries correct early on. Redox is finally at the point where adding features feels like extension work, not teardown work.