Honeymoon Architecture


Version: 0.6 (Massacre)
Standard: C++23
Size: 81K uncompressed / 37K packed
Heap allocs in render loop: 0

Honeymoon Architecture

Honeymoon is a terminal editor written in C++23. It bypasses libraries like ncurses to interact directly with the terminal driver via termios and ANSI escape codes. The render loop does zero heap allocations. File I/O uses raw POSIX syscalls. Key dispatch is an enum + jump table. Everything unnecessary has been removed.


1. Event Loop

The core is a synchronous blocking loop. It renders the state to a memory buffer, flushes it to the terminal, and waits for a single keypress.

// src/editor.hpp
void run() {
    while (!should_quit) {
        refresh_screen();
        process_keypress();
    }
    terminal.write_raw("\x1b[2J\x1b[H"); // Clear on exit
}

2. Raw Mode & Termios

We disable Canonical Mode (line buffering) and Echo to take full control of the terminal. This allows us to process every keystroke immediately.

No <iostream> is used anywhere in the editor. Terminal I/O uses raw read() / write() syscalls directly. Error messages go to stderr via fprintf. This avoids pulling in the entire iostream locale machinery at startup.

// src/terminal.hpp
bool enable_raw_mode() {
    tcgetattr(STDIN_FILENO, &orig_termios);
    struct termios raw = orig_termios;

    // Disable signals (Ctrl-C/Z), canonical mode, and echo
    raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG);

    // Disable software flow control
    raw.c_iflag &= ~(IXON);

    // Turn off output processing (e.g. \n -> \r\n translation)
    raw.c_oflag &= ~(OPOST);

    return tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) != -1;
}
Note: If the process crashes without resetting these flags, the terminal remains broken. Use `reset` to restore it.

3. Gap Buffer

Text is stored in a Gap Buffer. This is a std::vector that maintains a "hole" at the cursor position.

[ 'H', 'E',  _, _, _,  'L', 'L', 'O' ]
          ^ Cursor (Gap Start)   

Insertion is O(1) because we write into the gap. Moving the cursor is O(N) because we must shift the gap using memmove (via std::copy).

// src/buffer.hpp
void move_gap(size_type position) {
    if (position < gap_start) {
        size_type move = gap_start - position;
        std::copy(buffer.begin() + position,
                 buffer.begin() + gap_start,
                 buffer.begin() + gap_end - move);
        gap_start -= move; gap_end -= move;
    }
    // ...
}

File I/O (Raw POSIX Syscalls)

Loading and saving files uses raw POSIX syscalls instead of <fstream>. No basic_filebuf vtables, no buffering layer, no template bloat.

// src/buffer.hpp
void load_from_file(const std::string& filename) {
    int fd = open(filename.c_str(), O_RDONLY);
    if (fd < 0) return;
    struct stat st;
    fstat(fd, &st);
    buffer.resize(st.st_size + DEFAULT_GAP_SIZE);
    read(fd, buffer.data(), st.st_size);
    close(fd);
    gap_start = st.st_size;
    gap_end = buffer.size();
}

void save_to_file(const std::string& filename) {
    int fd = open(filename.c_str(),
                  O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd < 0) return;
    if (gap_start > 0)
        write(fd, buffer.data(), gap_start);
    if (gap_end < buffer.size())
        write(fd, buffer.data() + gap_end,
              buffer.size() - gap_end);
    close(fd);
    dirty = false;
}

4. Render Buffer

The entire frame is assembled into a RenderBuf and flushed in a single write() syscall. The buffer uses std::string internally so it grows to fit any terminal size without truncation.

// src/editor.hpp
struct RenderBuf {
    std::string buf;
    void clear() { buf.clear(); }
    RenderBuf& append(const char* s)           { buf += s;            return *this; }
    RenderBuf& append(char c)                  { buf += c;            return *this; }
    RenderBuf& append(int n, char c)           { buf.append(n, c);    return *this; }
    RenderBuf& append(const char* s, size_t n) { buf.append(s, n);    return *this; }
    RenderBuf& append(const std::string& s)    { buf += s;            return *this; }
};

void refresh_screen() {
    output_buffer.clear();
    output_buffer.append("\x1b[?25l\x1b[H"); // Hide cursor, go home

    draw_rows();       // Render text content
    draw_status_bar(); // Render UI
    place_cursor();    // Move real terminal cursor

    output_buffer.append("\x1b[?25h"); // Show cursor
    terminal.write_raw(output_buffer.buf.data(), output_buffer.buf.size()); // Atomic flush
}
The original v0.6 buffer was char[4096] with silent truncation. A 200-col × 50-row terminal with ANSI codes can easily exceed that. Fixed in v0.7.

5. Input Parsing

Escape sequences (like Arrow Keys) are parsed manually. We detect the Escape byte (27) and look ahead.

// src/terminal.hpp
Key read_key() {
    char c;
    if (read(STDIN_FILENO, &c, 1) != 1) return Key::None;

    if (c == 27) { // Escape sequence start
        char seq[3];
        if (read(STDIN_FILENO, &seq[0], 1) != 1) return Key::Esc;
        if (read(STDIN_FILENO, &seq[1], 1) != 1) return Key::Esc;

        if (seq[0] == '[') {
            switch (seq[1]) {
                case 'A': return Key::ArrowUp;
                case 'B': return Key::ArrowDown;
                // ...
            }
        }
        return Key::Esc;
    }
    return static_cast<Key>(c);
}

6. Keybinding System

Honeymoon uses a trie (prefix tree) to handle multi-key chords like C-x C-s. All keybindings are loaded from keybinds.moon, making them fully customizable without recompilation.

Trie-Based Key Dispatch

Each node in the trie represents a key in the sequence. Leaf nodes store action names that map to lambdas.

// src/editor.hpp
struct KeyNode {
    Key key;
    KeyNode* next_sibling = nullptr;  // Linked list of children
    KeyNode* first_child = nullptr;   // First child (head of list)
    std::string action;               // Action name at leaf nodes
};

// Linear scan over a short linked list beats a red-black tree
KeyNode* find_child(KeyNode* parent, Key k) {
    for (KeyNode* c = parent->first_child; c; c = c->next_sibling)
        if (c->key == k) return c;
    return nullptr;
}

// Action dispatch via enum + jump table (no std::function, no std::map)
enum ActionId : uint8_t {
    ACT_QUIT, ACT_SAVE_FILE, ACT_CUT, ACT_YANK, /* ... */
};
static constexpr ActionEntry action_table[] = {
    {"quit", ACT_QUIT}, {"save_file", ACT_SAVE_FILE}, /* ... */
};

void execute_action(ActionId id) {
    switch (id) {
        case ACT_QUIT: should_quit = true; break;
        case ACT_SAVE_FILE: buffer.save_to_file(current_filename); break;
        // ... jump table dispatch
    }
}

void handle_input(EditorState &, Key k) {
    KeyNode* next = find_child(current_node, k);
    if (next) {
        current_node = next;
        if (!current_node->action.empty() && !current_node->first_child) {
            ActionId aid = lookup_action(current_node->action);
            current_node = root_node;
            execute_action(aid);
        }
    }
}

M- Prefix Expansion

The config parser expands M-x (Meta key) into [Esc, x] automatically. This provides clean Emacs-style syntax in the config file.

// src/keybinder.hpp
for (size_t i = 0; i < words.size() - 1; ++i) {
    std::string token = words[i];

    // Handle M- prefix (Meta key = Esc + key)
    if (token.size() >= 3 && token[0] == 'M' && token[1] == '-') {
        b.keys.push_back(Key::Esc);
        token = token.substr(2); // Remove "M-"
    }

    Key k = key_from_string(token);
    if (k != Key::None)
        b.keys.push_back(k);
}

Config File Format

keybinds.moon uses a simple whitespace-separated format:

# Comment lines start with #
M-w copy                  # Meta-w copies selection
C-x C-s save_file        # Chord: Ctrl-x then Ctrl-s
C-g cancel               # Single key binding

Supported key formats:

All default keybindings are now in keybinds.moon. Edit the file and restart the editor to apply changes.

7. State Machine (std::variant)

Instead of an enum + if-else spaghetti, Honeymoon uses std::variant to represent the editor's current mode. Each mode is a distinct struct with its own state, and std::visit dispatches rendering and input handling to the right overload.

// src/editor.hpp
struct HomeState       { int selection = 0; };
struct EditorState     { std::string filename; };
struct FileSearchState { std::string query; };
struct TextSearchState { std::string query; size_t start_idx; bool forward; };
struct GotoLineState   { std::string query; };
struct SettingsState   { int selection = 0; };
struct AboutState      {};

using EditorMode =
    std::variant<HomeState, EditorState, FileSearchState,
                 TextSearchState, GotoLineState, SettingsState,
                 HelpState, AboutState>;

Rendering is dispatched via a generic visitor:

void refresh_screen() {
    auto render_visitor = [this](auto &state) {
        using T = std::decay_t<decltype(state)>;
        if constexpr (std::is_same_v<T, EditorState> ||
                      std::is_same_v<T, TextSearchState>) {
            draw_rows();
            draw_status_bar();
        } else {
            draw_centered_view();
        }
    };
    std::visit(render_visitor, mode);
}

Each mode has its own handle_input() overload, keeping concerns isolated:

void handle_input(EditorState &state, Key k) { /* trie dispatch */ }
void handle_input(HomeState &state, Key k)  { /* menu navigation */ }
void handle_input(TextSearchState &state, Key k) { /* incremental search */ }
void handle_input(GotoLineState &state, Key k)  { /* line number input */ }

8. Tree-Sitter Syntax Highlighting

Syntax highlighting is provided by Tree-sitter, loaded at runtime via dlopen. This avoids a compile-time dependency - if the libraries aren't installed, the editor falls back to a simple built-in highlighter (digits in cyan, strings in green).

Dynamic Loading

The TreeSitterHighlighter class dlopen's three shared libraries:

All 12+ Tree-sitter API functions are resolved via dlsym into a function-pointer struct.

// src/treesitter.hpp
struct Api {
    ts_parser_new_fn              ts_parser_new;
    ts_parser_delete_fn           ts_parser_delete;
    ts_parser_set_language_fn     ts_parser_set_language;
    ts_parser_parse_string_fn     ts_parser_parse_string;
    ts_tree_delete_fn             ts_tree_delete;
    ts_tree_root_node_fn          ts_tree_root_node;
    ts_node_type_fn               ts_node_type;
    // ...
} api_;

bool load_api() {
    lib_tree_sitter_ = dlopen("libtree-sitter.so", RTLD_NOW | RTLD_LOCAL);
    api_.ts_parser_new = dlsym(lib_tree_sitter_, "ts_parser_new");
    // ...
}

Highlight Classification

Tree-sitter node types are mapped to a HighlightKind enum using pattern matching (exact match, prefix, substring):

enum class HighlightKind {
    None, Comment, String, Number,
    Keyword, Type, Function, Preprocessor,
};

static HighlightKind classify_node(std::string_view node_type) {
    static constexpr Rule rules[] = {
        {"comment",            HighlightKind::Comment,      Rule::Substring},
        {"string",             HighlightKind::String,       Rule::Substring},
        {"number_literal",     HighlightKind::Number,       Rule::Substring},
        {"if_statement",       HighlightKind::Keyword,      Rule::Exact},
        {"type_identifier",    HighlightKind::Type,         Rule::Substring},
        // ...
    };
    // ...
}

Per-Byte Style Map

After parsing, the syntax tree is flattened into a std::vector<HighlightKind> with one entry per byte. When spans overlap, a priority system picks the most specific kind. The renderer then queries kind_at(byte_index) for each character.

void collect_styles() {
    TSNode root = api_.ts_tree_root_node(tree_);
    // DFS over syntax tree, collect non-None spans
    for (auto &[start, end, kind] : spans) {
        for (size_t i = start; i < end; ++i) {
            if (priority(kind) >= priority(style_map_[i]))
                style_map_[i] = kind;
        }
    }
}

HighlightKind kind_at(size_t byte_index) const noexcept {
    if (byte_index >= style_map_.size()) return HighlightKind::None;
    return style_map_[byte_index];
}
Tree-sitter is optional. If the shared libraries aren't found, the editor silently falls back to a naive highlighter that colors digits and string quotes.

9. Build Flags & Binary Size

Honeymoon is 81K uncompressed (37K with UPX LZMA). This is achieved through aggressive compiler and linker flags that strip everything unnecessary from the binary.

# .bazelrc (release config)
-O3 -DNDEBUG -flto -fvisibility=hidden
-fno-exceptions -fno-rtti
-fno-plt                     # Eliminate .plt / .got.plt
-fno-unwind-tables           # Remove .eh_frame
-fmerge-all-constants        # Merge identical strings
-fno-unroll-loops            # No loop bloat
-ffunction-sections -fdata-sections
-Wl,--gc-sections            # Garbage-collect unused sections
-Wl,--icf=all                # Identical code folding
-Wl,--hash-style=sysv        # Smaller symbol hash
-Wl,--build-id=none          # Remove build ID note
Section Before After
.text135K67K
.plt62420
.got.plt32820
.rela.plt9120
.eh_frame + .eh_frame_hdr6.6K48
.gcc_except_table3.4K0
Total 138K 81K

To pack with UPX (37K): upx --lzma --best bazel-bin/honeymoon