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!
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
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:
- MinUI events come in (key strokes, etc).
editor_tuimaps them into high-levelInputActions.EditorState::apply_inputapplies 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.
3wto advance 3 words) - Multi-key sequences (eg.
ggto 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)CommandEnterPaste(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
| 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 |
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.