// lesson: the-text-buffer

The Text Buffer

Enough plumbing; time for the data structure at the heart of the editor. How should a file being edited live in memory? This question has a fifty-year literature (Crowley's Data Structures for Text Sequences, in the extended reading, is the best survey), and every serious answer is a different point on the same tradeoff curve:

  • One flat std::string. Reading and saving are trivial; rendering needs a scan to find line starts; and inserting a character at position i moves every byte after i. For a 10 MB file with the cursor at the top, that's 10 MB of memmove per keystroke. Fine for a config-file editor, embarrassing beyond that.
  • A vector of lines (std::vector<std::string>). What vi, kilo, and a large fraction of real editors use. Inserting a character moves only the tail of one line (~40 bytes, not megabytes); inserting or deleting a whole line shifts only the vector's pointers-to-lines, not the text. Rendering is natural โ€” the viewport asks for lines rโ‚..rโ‚‚ โ€” and per-line syntax highlighting falls out for free. Weaknesses: a pathological single-line file (a minified 5 MB bundle.js) degrades to the flat-string case, and line joins/splits churn allocations.
  • A gap buffer. The Emacs answer; next lesson, in full.
  • A rope โ€” a balanced tree of string chunks: everything is O(log n), 10 GB files open instantly, and the implementation is 10ร— the code and every simple question ("what's at line 12?") becomes a tree walk. The choice of xi and helix.
  • A piece table โ€” the file is never modified; edits are a list of descriptors pointing into the original bytes plus an append-only "add" buffer. Undo is nearly free (the old pieces still exist), which is why VS Code and, going back further, Word use it.

The honest engineering call for a terminal editor aimed at source files: vector of lines, and that's what our editor core uses. The costs it doesn't handle (giant single lines) are real but rare; the costs it avoids (complexity, cache-hostile tree walks for the common case) are paid on every keystroke. Data structure choice is about which operation you make cheap, and an editor's hot operations are: insert/delete a char near the cursor, split/join a line, and read a screenful of consecutive lines.

Positions, and the operations that edit

A position in the buffer is a row/column pair โ€” a tiny value type, rule-of-zero, with C++20's = default comparison:

struct Pos {
    std::size_t row = 0;
    std::size_t col = 0;
    bool operator==(const Pos&) const = default;
};

col counts characters into the line's string โ€” the chars index, cx in kilo's terminology. (How that differs from the screen column once tabs enter the picture is the whole next-next lesson.) The buffer's editing API is three operations, and each returns the cursor position the editor should move to โ€” pinning down, in the type signature, a question every editor must answer ("after Enter, where is the cursor?"):

  • insert_char(p, c) โ†’ cursor after the inserted char: {row, col+1}.
  • insert_newline(p) โ€” split the line at p: the current line keeps [0, col), a new line below receives [col, end). Cursor: start of the new line. Pressing Enter at the end of a line inserts an empty line below; at column 0 it pushes the whole line down.
  • backspace(p) โ€” delete the character before p. At col == 0 the line joins: the current line's text is appended to the previous line, and the cursor lands at the join seam. Backspace at {0, 0} does nothing. This join is why Backspace at the start of a line pulls it up โ€” one operation, two behaviors, both fall out of "delete the boundary before the cursor".

One invariant makes every downstream component simpler: the buffer always contains at least one line (possibly empty). "Empty buffer" is {""}, never {} โ€” so there is always a line for the cursor to sit on, and no code ever checks "is there a line 0?".

โ€บ A Vector-of-Lines Buffer

20 pts

Implement TextBuffer per the operations above. Details the tests pin down:

  • The default constructor yields one empty line; constructing from an empty vector must also normalize to {""}.
  • line(row) returns the line's text by value; out-of-range rows return "". (Returning const std::string& would be faster โ€” and would dangle the moment a caller kept the reference across an edit that reallocates the vector. Return-by-value is the value-semantics default; optimize only the proven hot path.)
  • Positions passed in are trusted to be valid: row < line_count(), col <= line(row).size(). (The editor core maintains that invariant; the buffer doesn't re-police it.)
  • to_string() joins lines with '\n' โ€” {"a", "b"} โ†’ "a\nb", and {""} โ†’ "".

Log in to submit a solution and earn points.