Version: 0.6 (Massacre)
Standard: C++23
Size: 81K uncompressed / 37K packed
Heap allocs in render loop: 0
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.
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
}
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;
}
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;
}
// ...
}
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;
}
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
}
char[4096] with silent truncation.
A 200-col × 50-row terminal with ANSI codes can easily exceed that.
Fixed in v0.7.
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);
}
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.
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);
}
}
}
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);
}
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:
C-x → Ctrl+xM-x → Meta/Alt+x (expands to Esc then x)Enter, Tab,
Backspace, Del, Esc
Up, Down, Left,
Right
a, Z,
/
keybinds.moon.
Edit the file and restart the editor to apply changes.
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 */ }
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).
The TreeSitterHighlighter class dlopen's three
shared libraries:
libtree-sitter.so - core parser APIlibtree-sitter-c.so - C grammarlibtree-sitter-cpp.so - C++ grammar
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");
// ...
}
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},
// ...
};
// ...
}
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];
}
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 |
| .text | 135K | 67K |
| .plt | 624 | 20 |
| .got.plt | 328 | 20 |
| .rela.plt | 912 | 0 |
| .eh_frame + .eh_frame_hdr | 6.6K | 48 |
| .gcc_except_table | 3.4K | 0 |
| Total | 138K | 81K |
To pack with UPX (37K): upx --lzma --best bazel-bin/honeymoon