Skip to content

How it is built

TurboVI is a turbodesk application, and turbodesk's model is that an app is a function (UI) -> View, re-run whenever a frame is dirty. Nothing survives between frames except the hook slots you asked for by name.

So the editor is a value, a function that transforms it, and a function that draws it.

The shape of the code

File
state.py the frozen State every keystroke transforms, and the cursor arithmetic
keymap.py the grammar. Pure functions from (State, key) to State
edits.py the text transforms an operator applies
session.py buffers and the window grid
screen.py the draw, straight from View combinators
grep.py what matches a pattern across the project
find.py the live find dialog over it
quickfix.py the list it fills, and :cdo
substitute.py :s
highlight.py syntax colouring, over Pygments
textfile.py encodings, line endings, final newlines
editorconfig.py .editorconfig
persist.py the config file and crash recovery
__main__.py the app function, the event wiring and main()

The buffer uses no widgets

This editor draws its buffer from View.text, hcat, vcat and zcat. It does not use turbodesk.widgets.editor, and that is deliberate: TurboVI is the evidence that turbodesk's core carries a real application. Reaching for the editor widget here would destroy the only test of that claim.

The claim is about the buffer, and stating it any wider would be false. Two widgets are imported, both dialogs: dialog.find_file for <C-p>, and border for the frame around the find box. A box with a title in its top edge is twenty lines nobody should write twice, and neither of them draws a line of the text you are editing.

grep.py used to make a third, importing files_under from turbodesk.widgets.dialog. That function is a directory walk with no View in it, and it now lives in turbodesk.files where the import list stops implying otherwise.

One value per keystroke

State is a frozen dataclass. Every edit returns a new one. That is what turns three features into storage instead of machinery:

  • Undo is a tuple of old states.
  • . repeat is the keystrokes of the last change, replayed.
  • Macros are the same thing under a name.

None of the three needs a transaction log, a diff, or a command object, because keeping the old value around is already all of it.

The keymap cannot see the session

keymap.py has no UI import and no access to the other buffers. It is pure over one buffer. So :e, :sp, the clipboard, the file picker and :grep cannot be done there. The keymap has no way to reach a second file, and no way to await anything.

Instead it records a request on the state:

case "e" | "edit":
    return _asked(state, "edit", argument, "")

session.py reads the request and performs it. The requests that also have to leave the render loop (a clipboard subprocess, a modal) are handed back by the session and spawned by __main__:

SPAWNED = ("clip", "find", "grep")

That channel is the reason those features are testable at all: :sp is a pure function producing a tuple, and asserting on it needs no terminal.

One list, not two

SPAWNED used to be a tuple in __main__ and matching case arms in session. :grep was added to one of them, and the session answered a command it had just been given with not an editor command: grep. It is one list now, read from both ends.

One render must not hold two versions of the state

A request that leads to a modal must be cleared at the top of the render, before anything is spawned, and everything downstream must read the cleared value.

The reason is that a spawned task does not run until the render returns, and a picker then waits on somebody typing at a modal. A request left in place is re-read by every frame in between and spawned again each time. The first attempt at this cleared the request and then let the scroll code write the old session back over the top: seventy-nine stacked modals, each revealed by closing the one on top of it.

A buffer and a window are different things

A buffer holds the text, the file, and the undo history. A window holds a cursor and a viewport. Two windows on one buffer share the text and keep their own cursors.

Five fields belong to the buffer and the rest of State belongs to the session, so moving focus means keeping the live state and swapping the text into it. _carried is where that happens, and getting it backwards is how an editor loses your registers on :bn.

Adding a field to State therefore means deciding which of the two it belongs to. A buffer field left out of _carried is a real bug: listing was, and closing the quickfix pane handed its flag to the surviving window, marking the file you were editing as a list.

The quickfix pane is an ordinary buffer

There is no second kind of window. :copen builds the pane's text with quickfix.lines() and shows it in a normal window on a normal buffer, marked with one flag. The only thing that buffer needs which its text cannot carry is which row belongs to which entry, and that is a parallel tuple recomputed from the list.

The list itself carries the root its paths are relative to, as one value. Re-deriving the root from whatever buffer is open resolved src/a.py against the wrong project the moment you edited out of the one you searched. A list without its root cannot be written down.

Drawing

screen.py turns a State into a View. Two things govern how:

Runs, not characters. One View per run of identical styling. A View is a padded grid with its own tags and handlers, so building 120 of them per row meant 4,500 objects a frame for a screen of plain text.

Columns are not character offsets. Everything upstream (the cursor, a search hit, a syntax span) counts characters, and a tab is one character across up to eight columns. _columns() builds the map once per drawn row and every span goes through it. Without that, a search hit after a tab colours the wrong word.

Long lines wrap, so vertical scrolling counts screen lines, one buffer row being several of them. Three constraints decide where the view starts, in order of who wins: hold position, do not run past the end of the file, and above both, keep the cursor visible.

Testing

About 530 tests, under a second. They name the behaviour (test_a_mark_survives_a_jump_and_comes_back_to_the_character), assert on rendered output where they can, and are mutation-checked before being trusted: break the code deliberately, confirm a test fails, put it back.

That last step is not ceremony. It has repeatedly found tests that passed either way, and twice it found code that did nothing: an is_dir() normalisation the loop already handled, and a guard on a row past the end of a file that substitute.apply already clamped. Both were deleted.

Use the editor by hand as well. A missing Ctrl+B, the seventy-nine modals, a file picker that walked its own virtualenv, and a :grep the session rejected were all found in minutes of typing and by no test in the suite.