Add swap file journaling for crash recovery.

- Introduced `SwapManager` for buffering and writing incremental edits to sidecar `.kte.swp` files.
- Implemented basic operations: insertion, deletion, split, join, and checkpointing.
- Added recovery design doc (`docs/plans/swap-files.md`).
- Updated editor initialization to integrate `SwapManager` instance for crash recovery across buffers.
This commit is contained in:
2025-12-04 08:48:32 -08:00
parent 495183ebd2
commit 78b9345799
24 changed files with 1933 additions and 545 deletions

View File

@@ -16,6 +16,11 @@
#include "syntax/HighlighterEngine.h"
#include "Highlight.h"
// Forward declaration for swap journal integration
namespace kte {
class SwapRecorder;
}
class Buffer {
public:
@@ -423,6 +428,13 @@ public:
}
// Swap journal integration (set by Editor)
void SetSwapRecorder(kte::SwapRecorder *rec)
{
swap_rec_ = rec;
}
// Raw, low-level editing APIs used by UndoSystem apply().
// These must NOT trigger undo recording. They also do not move the cursor.
void insert_text(int row, int col, std::string_view text);
@@ -465,4 +477,6 @@ private:
bool syntax_enabled_ = true;
std::string filetype_;
std::unique_ptr<kte::HighlighterEngine> highlighter_;
// Non-owning pointer to swap recorder managed by Editor/SwapManager
kte::SwapRecorder *swap_rec_ = nullptr;
};

View File

@@ -118,6 +118,7 @@ set(COMMON_SOURCES
Command.cc
HelpText.cc
KKeymap.cc
Swap.cc
TerminalInputHandler.cc
TerminalRenderer.cc
TerminalFrontend.cc
@@ -203,6 +204,7 @@ set(COMMON_HEADERS
Command.h
HelpText.h
KKeymap.h
Swap.h
InputHandler.h
TerminalInputHandler.h
Renderer.h

View File

@@ -104,24 +104,24 @@ static bool
is_mutating_command(CommandId id)
{
switch (id) {
case CommandId::InsertText:
case CommandId::Newline:
case CommandId::Backspace:
case CommandId::DeleteChar:
case CommandId::KillToEOL:
case CommandId::KillLine:
case CommandId::Yank:
case CommandId::DeleteWordPrev:
case CommandId::DeleteWordNext:
case CommandId::IndentRegion:
case CommandId::UnindentRegion:
case CommandId::ReflowParagraph:
case CommandId::KillRegion:
case CommandId::Undo:
case CommandId::Redo:
return true;
default:
return false;
case CommandId::InsertText:
case CommandId::Newline:
case CommandId::Backspace:
case CommandId::DeleteChar:
case CommandId::KillToEOL:
case CommandId::KillLine:
case CommandId::Yank:
case CommandId::DeleteWordPrev:
case CommandId::DeleteWordNext:
case CommandId::IndentRegion:
case CommandId::UnindentRegion:
case CommandId::ReflowParagraph:
case CommandId::KillRegion:
case CommandId::Undo:
case CommandId::Redo:
return true;
default:
return false;
}
}
@@ -697,6 +697,10 @@ cmd_refresh(CommandContext &ctx)
ctx.editor.ClearSearchOrigin();
ctx.editor.SetSearchIndex(-1);
}
// Clear any pending close/overwrite state associated with prompts
ctx.editor.SetCloseConfirmPending(false);
ctx.editor.SetCloseAfterSave(false);
ctx.editor.ClearPendingOverwritePath();
ctx.editor.CancelPrompt();
ctx.editor.SetStatus("Canceled");
return true;
@@ -765,6 +769,15 @@ cmd_unknown_kcommand(CommandContext &ctx)
}
static bool
cmd_unknown_esc_command(CommandContext &ctx)
{
(void) ctx;
ctx.editor.SetStatus("invalid escape command");
return true;
}
// --- Syntax highlighting commands ---
static void
apply_filetype(Buffer &buf, const std::string &ft)
@@ -1374,6 +1387,14 @@ cmd_buffer_close(const CommandContext &ctx)
std::size_t idx = ctx.editor.CurrentBufferIndex();
Buffer *b = ctx.editor.CurrentBuffer();
std::string name = b ? buffer_display_name(*b) : std::string("");
// If buffer is dirty, prompt to save first (for both named and unnamed buffers)
if (b && b->Dirty()) {
ctx.editor.StartPrompt(Editor::PromptKind::Confirm, "Save", "");
ctx.editor.SetCloseConfirmPending(true);
ctx.editor.SetStatus(std::string("Save changes to ") + name + "? (y/N)");
return true;
}
// Otherwise close immediately
if (b && b->Undo())
b->Undo()->discard_pending();
ctx.editor.CloseBuffer(idx);
@@ -2191,6 +2212,28 @@ cmd_newline(CommandContext &ctx)
ctx.editor.SetStatus("Saved as " + value);
if (auto *u = buf->Undo())
u->mark_saved();
// If a close-after-save was requested (from closing a dirty, unnamed buffer),
// close the buffer now.
if (ctx.editor.CloseAfterSave()) {
ctx.editor.SetCloseAfterSave(false);
std::size_t idx_close = ctx.editor.CurrentBufferIndex();
std::string name_close = buffer_display_name(*buf);
if (buf->Undo())
buf->Undo()->discard_pending();
ctx.editor.CloseBuffer(idx_close);
if (ctx.editor.BufferCount() == 0) {
Buffer empty;
ctx.editor.AddBuffer(std::move(empty));
ctx.editor.SwitchTo(0);
}
const Buffer *cur = ctx.editor.CurrentBuffer();
ctx.editor.SetStatus(
std::string("Closed: ") + name_close +
std::string(" Now: ")
+ (cur
? buffer_display_name(*cur)
: std::string("")));
}
}
}
}
@@ -2214,11 +2257,86 @@ cmd_newline(CommandContext &ctx)
ctx.editor.SetStatus("Saved as " + target);
if (auto *u = buf->Undo())
u->mark_saved();
// If this overwrite confirm was part of a close-after-save flow, close now.
if (ctx.editor.CloseAfterSave()) {
ctx.editor.SetCloseAfterSave(false);
std::size_t idx_close = ctx.editor.CurrentBufferIndex();
std::string name_close = buffer_display_name(*buf);
if (buf->Undo())
buf->Undo()->discard_pending();
ctx.editor.CloseBuffer(idx_close);
if (ctx.editor.BufferCount() == 0) {
Buffer empty;
ctx.editor.AddBuffer(std::move(empty));
ctx.editor.SwitchTo(0);
}
const Buffer *cur = ctx.editor.CurrentBuffer();
ctx.editor.SetStatus(
std::string("Closed: ") + name_close + std::string(
" Now: ")
+ (cur ? buffer_display_name(*cur) : std::string("")));
}
}
} else {
ctx.editor.SetStatus("Save canceled");
}
ctx.editor.ClearPendingOverwritePath();
// Regardless of answer, end any close-after-save pending state for safety.
ctx.editor.SetCloseAfterSave(false);
} else if (ctx.editor.CloseConfirmPending() && buf) {
bool yes = false;
if (!value.empty()) {
char c = value[0];
yes = (c == 'y' || c == 'Y');
}
// Prepare close details
std::size_t idx_close = ctx.editor.CurrentBufferIndex();
std::string name_close = buffer_display_name(*buf);
bool proceed_to_close = true;
if (yes) {
std::string err;
if (buf->IsFileBacked()) {
if (!buf->Save(err)) {
ctx.editor.SetStatus(err);
proceed_to_close = false;
} else {
buf->SetDirty(false);
if (auto *u = buf->Undo())
u->mark_saved();
}
} else if (!buf->Filename().empty()) {
if (!buf->SaveAs(buf->Filename(), err)) {
ctx.editor.SetStatus(err);
proceed_to_close = false;
} else {
buf->SetDirty(false);
if (auto *u = buf->Undo())
u->mark_saved();
}
} else {
// No filename; fall back to Save As flow and set close-after-save
ctx.editor.StartPrompt(Editor::PromptKind::SaveAs, "Save as", "");
ctx.editor.SetCloseAfterSave(true);
ctx.editor.SetStatus("Save as: ");
ctx.editor.SetCloseConfirmPending(false);
return true;
}
}
if (proceed_to_close) {
if (buf->Undo())
buf->Undo()->discard_pending();
ctx.editor.CloseBuffer(idx_close);
if (ctx.editor.BufferCount() == 0) {
Buffer empty;
ctx.editor.AddBuffer(std::move(empty));
ctx.editor.SwitchTo(0);
}
const Buffer *cur = ctx.editor.CurrentBuffer();
ctx.editor.SetStatus(
std::string("Closed: ") + name_close + std::string(" Now: ")
+ (cur ? buffer_display_name(*cur) : std::string("")));
}
ctx.editor.SetCloseConfirmPending(false);
} else {
ctx.editor.SetStatus("Nothing to confirm");
}
@@ -3663,50 +3781,222 @@ cmd_reflow_paragraph(CommandContext &ctx)
++para_end;
if (para_start > para_end)
return false;
std::string text;
for (std::size_t i = para_start; i <= para_end; ++i) {
if (i > para_start && !text.empty() && text.back() != ' ')
text += ' ';
const auto &line = rows[i];
for (std::size_t j = 0; j < line.size(); ++j) {
char c = line[j];
if (c == '\t')
text += ' ';
else
text += c;
auto is_space = [](char c) {
return c == ' ' || c == '\t';
};
auto leading_ws = [&](const std::string &s) {
std::size_t i = 0;
while (i < s.size() && is_space(s[i]))
++i;
return s.substr(0, i);
};
auto starts_with = [](const std::string &s, const std::string &pfx) {
return s.size() >= pfx.size() && std::equal(pfx.begin(), pfx.end(), s.begin());
};
auto is_bullet_line = [&](const std::string &s, std::string &indent_out, char &marker_out,
std::size_t &after_prefix_idx) -> bool {
indent_out = leading_ws(s);
std::size_t i = indent_out.size();
if (i + 1 < s.size()) {
char m = s[i];
if ((m == '-' || m == '+' || m == '*') && s[i + 1] == ' ') {
marker_out = m;
after_prefix_idx = i + 2; // after marker + space
return true;
}
}
}
return false;
};
auto normalize_spaces = [](const std::string &in) {
std::string out;
out.reserve(in.size());
bool in_space = false;
for (char c: in) {
char cc = (c == '\t') ? ' ' : c;
if (cc == ' ') {
if (!in_space) {
out.push_back(' ');
in_space = true;
}
} else {
out.push_back(cc);
in_space = false;
}
}
// trim leading/trailing spaces
// leading
std::size_t start = 0;
while (start < out.size() && out[start] == ' ')
++start;
// trailing
std::size_t end = out.size();
while (end > start && out[end - 1] == ' ')
--end;
return out.substr(start, end - start);
};
auto wrap_with_prefixes = [&](const std::string &content,
const std::string &first_prefix,
const std::string &cont_prefix,
int w,
std::vector<std::string> &dst) {
// Tokenize by spaces
std::vector<std::string> words;
std::size_t pos = 0;
while (pos < content.size()) {
while (pos < content.size() && content[pos] == ' ')
++pos;
if (pos >= content.size())
break;
std::size_t ws = pos;
while (pos < content.size() && content[pos] != ' ')
++pos;
words.emplace_back(content.substr(ws, pos - ws));
}
std::string line = first_prefix;
std::size_t cur_len = line.size();
bool first_word_on_line = true;
auto flush_line = [&]() {
dst.emplace_back(line);
line = cont_prefix;
cur_len = line.size();
first_word_on_line = true;
};
if (words.empty()) {
// Still emit a line with just the prefix (e.g., empty bullet)
dst.emplace_back(line);
return;
}
for (std::size_t i = 0; i < words.size(); ++i) {
const std::string &wrd = words[i];
std::size_t needed = wrd.size() + (first_word_on_line ? 0 : 1);
if (static_cast<int>(cur_len + needed) > w) {
// wrap
flush_line();
}
if (!first_word_on_line) {
line.push_back(' ');
++cur_len;
}
line += wrd;
cur_len += wrd.size();
first_word_on_line = false;
}
if (!line.empty())
dst.emplace_back(line);
};
std::vector<std::string> new_lines;
std::string line;
std::size_t pos = 0;
while (pos < text.size()) {
while (pos < text.size() && text[pos] == ' ')
++pos;
if (pos >= text.size())
// Determine if this region looks like a list: any line starting with bullet
bool region_has_bullet = false;
for (std::size_t i = para_start; i <= para_end; ++i) {
std::string s = static_cast<std::string>(rows[i]);
std::string indent;
char marker;
std::size_t idx;
if (is_bullet_line(s, indent, marker, idx)) {
region_has_bullet = true;
break;
std::size_t word_start = pos;
while (pos < text.size() && text[pos] != ' ')
++pos;
std::string word = text.substr(word_start, pos - word_start);
if (line.empty()) {
line = word;
} else if (static_cast<int>(line.size() + 1 + word.size()) <= width) {
line += ' ';
line += word;
} else {
new_lines.push_back(line);
line = word;
}
}
if (!line.empty())
new_lines.push_back(line);
if (region_has_bullet) {
// Parse as list items; support hanging indent continuations
for (std::size_t i = para_start; i <= para_end; ++i) {
std::string s = static_cast<std::string>(rows[i]);
std::string indent;
char marker = 0;
std::size_t after_idx = 0;
if (is_bullet_line(s, indent, marker, after_idx)) {
std::string first_prefix = indent + std::string(1, marker) + " ";
std::string cont_prefix = indent + " ";
std::string content = s.substr(after_idx);
// consume continuation lines that are part of this bullet item
std::size_t j = i + 1;
while (j <= para_end) {
std::string ns = static_cast<std::string>(rows[j]);
if (starts_with(ns, indent + " ")) {
content += ' ';
content += ns.substr(indent.size() + 2);
++j;
continue;
}
// stop if next bullet at same indentation or different structure
std::string nindent;
char nmarker;
std::size_t nidx;
if (is_bullet_line(ns, nindent, nmarker, nidx)) {
break; // next item
}
// Not a continuation and not a bullet: stop (treat as separate paragraph chunk)
break;
}
content = normalize_spaces(content);
wrap_with_prefixes(content, first_prefix, cont_prefix, width, new_lines);
i = j - 1; // advance
} else {
// A non-bullet line within a list region; treat as its own wrapped paragraph preserving its indent
std::string base_indent = leading_ws(s);
std::string content = s.substr(base_indent.size());
std::size_t j = i + 1;
while (j <= para_end) {
std::string ns = static_cast<std::string>(rows[j]);
std::string nindent = leading_ws(ns);
std::string tmp_indent;
char tmp_marker;
std::size_t tmp_idx;
if (is_bullet_line(ns, tmp_indent, tmp_marker, tmp_idx)) {
break; // next bullet starts
}
if (nindent.size() >= base_indent.size()) {
content += ' ';
content += ns.substr(base_indent.size());
++j;
} else {
break;
}
}
content = normalize_spaces(content);
wrap_with_prefixes(content, base_indent, base_indent, width, new_lines);
i = j - 1;
}
}
} else {
// Normal paragraph: preserve indentation of first line
std::string s0 = static_cast<std::string>(rows[para_start]);
std::string pfx = leading_ws(s0);
std::string content;
for (std::size_t i = para_start; i <= para_end; ++i) {
std::string si = static_cast<std::string>(rows[i]);
// strip the same prefix length if present
if (si.size() >= pfx.size() && starts_with(si, pfx))
si.erase(0, pfx.size());
if (!content.empty())
content.push_back(' ');
content += si;
}
content = normalize_spaces(content);
wrap_with_prefixes(content, pfx, pfx, width, new_lines);
}
if (new_lines.empty())
new_lines.push_back("");
rows.erase(rows.begin() + static_cast<std::ptrdiff_t>(para_start),
rows.begin() + static_cast<std::ptrdiff_t>(para_end + 1));
rows.insert(rows.begin() + static_cast<std::ptrdiff_t>(para_start),
new_lines.begin(), new_lines.end());
buf->SetCursor(0, para_start);
// Place cursor at the end of the paragraph
std::size_t new_last_y = para_start + (new_lines.empty() ? 0 : new_lines.size() - 1);
std::size_t new_last_x = new_lines.empty() ? 0 : new_lines.back().size();
buf->SetCursor(new_last_x, new_last_y);
buf->SetDirty(true);
ensure_cursor_visible(ctx.editor, *buf);
return true;
@@ -3815,35 +4105,45 @@ InstallDefaultCommands()
CommandRegistry::Register({CommandId::SaveAndQuit, "save-quit", "Save and quit (request)", cmd_save_and_quit});
CommandRegistry::Register({CommandId::Refresh, "refresh", "Force redraw", cmd_refresh});
CommandRegistry::Register(
{CommandId::KPrefix, "k-prefix", "Entering k-command prefix (show hint)", cmd_kprefix});
{CommandId::KPrefix, "k-prefix", "Entering k-command prefix (show hint)", cmd_kprefix, false, false});
CommandRegistry::Register({
CommandId::UnknownKCommand, "unknown-k", "Unknown k-command (status)",
cmd_unknown_kcommand
});
CommandRegistry::Register({CommandId::FindStart, "find-start", "Begin incremental search", cmd_find_start});
CommandRegistry::Register({
CommandId::RegexFindStart, "regex-find-start", "Begin regex search", cmd_regex_find_start
cmd_unknown_kcommand, false, false
});
CommandRegistry::Register({
CommandId::RegexpReplace, "regex-replace", "Begin regex search & replace", cmd_regex_replace_start
CommandId::UnknownEscCommand, "unknown-esc", "Unknown ESC command (status)",
cmd_unknown_esc_command, false, false
});
CommandRegistry::Register({
CommandId::SearchReplace, "search-replace", "Begin search & replace", cmd_search_replace_start
CommandId::FindStart, "find-start", "Begin incremental search", cmd_find_start, false, false
});
CommandRegistry::Register({
CommandId::OpenFileStart, "open-file-start", "Begin open-file prompt", cmd_open_file_start
CommandId::RegexFindStart, "regex-find-start", "Begin regex search", cmd_regex_find_start, false, false
});
CommandRegistry::Register({
CommandId::RegexpReplace, "regex-replace", "Begin regex search & replace", cmd_regex_replace_start,
false, false
});
CommandRegistry::Register({
CommandId::SearchReplace, "search-replace", "Begin search & replace", cmd_search_replace_start, false,
false
});
CommandRegistry::Register({
CommandId::OpenFileStart, "open-file-start", "Begin open-file prompt", cmd_open_file_start, false, false
});
// Buffers
CommandRegistry::Register({
CommandId::BufferSwitchStart, "buffer-switch-start", "Begin buffer switch prompt",
cmd_buffer_switch_start
cmd_buffer_switch_start, false, false
});
CommandRegistry::Register({CommandId::BufferNext, "buffer-next", "Switch to next buffer", cmd_buffer_next});
CommandRegistry::Register({CommandId::BufferPrev, "buffer-prev", "Switch to previous buffer", cmd_buffer_prev});
CommandRegistry::Register({CommandId::BufferClose, "buffer-close", "Close current buffer", cmd_buffer_close});
CommandRegistry::Register({
CommandId::BufferClose, "buffer-close", "Close current buffer", cmd_buffer_close, false, false
});
// Editing
CommandRegistry::Register({
CommandId::InsertText, "insert", "Insert text at cursor (no newlines)", cmd_insert_text
CommandId::InsertText, "insert", "Insert text at cursor (no newlines)", cmd_insert_text, false, true
});
CommandRegistry::Register({CommandId::Newline, "newline", "Insert newline at cursor", cmd_newline});
CommandRegistry::Register({CommandId::Backspace, "backspace", "Delete char before cursor", cmd_backspace});
@@ -3885,15 +4185,15 @@ InstallDefaultCommands()
CommandId::DeleteWordNext, "delete-word-next", "Delete next word", cmd_delete_word_next
});
CommandRegistry::Register({
CommandId::MoveCursorTo, "move-cursor-to", "Move cursor to y:x", cmd_move_cursor_to
CommandId::MoveCursorTo, "move-cursor-to", "Move cursor to y:x", cmd_move_cursor_to, false, false
});
// Direct navigation by line number
CommandRegistry::Register({
CommandId::JumpToLine, "goto-line", "Prompt for line and jump", cmd_jump_to_line_start
CommandId::JumpToLine, "goto-line", "Prompt for line and jump", cmd_jump_to_line_start, false, false
});
// Undo/Redo
CommandRegistry::Register({CommandId::Undo, "undo", "Undo last edit", cmd_undo});
CommandRegistry::Register({CommandId::Redo, "redo", "Redo edit", cmd_redo});
CommandRegistry::Register({CommandId::Undo, "undo", "Undo last edit", cmd_undo, false, true});
CommandRegistry::Register({CommandId::Redo, "redo", "Redo edit", cmd_redo, false, true});
// Region formatting
CommandRegistry::Register({CommandId::IndentRegion, "indent-region", "Indent region", cmd_indent_region});
CommandRegistry::Register(
@@ -3910,32 +4210,32 @@ InstallDefaultCommands()
CommandRegistry::Register({CommandId::ThemePrev, "theme-prev", "Cycle to previous GUI theme", cmd_theme_prev});
// Theme by name (public in command prompt)
CommandRegistry::Register({
CommandId::ThemeSetByName, "theme", "Set GUI theme by name", cmd_theme_set_by_name, true
CommandId::ThemeSetByName, "theme", "Set GUI theme by name", cmd_theme_set_by_name, true, false
});
// Font by name (public)
CommandRegistry::Register({
CommandId::FontSetByName, "font", "Set GUI font by name", cmd_font_set_by_name, true
CommandId::FontSetByName, "font", "Set GUI font by name", cmd_font_set_by_name, true, false
});
// Font size (public)
CommandRegistry::Register({
CommandId::FontSetSize, "font-size", "Set GUI font size (pixels)", cmd_font_set_size, true
CommandId::FontSetSize, "font-size", "Set GUI font size (pixels)", cmd_font_set_size, true, false
});
// Background light/dark (public)
CommandRegistry::Register({
CommandId::BackgroundSet, "background", "Set GUI background light|dark", cmd_background_set, true
CommandId::BackgroundSet, "background", "Set GUI background light|dark", cmd_background_set, true, false
});
// Generic command prompt (C-k ;)
CommandRegistry::Register({
CommandId::CommandPromptStart, "command-prompt-start", "Start generic command prompt",
cmd_command_prompt_start
cmd_command_prompt_start, false, false
});
// Buffer operations
CommandRegistry::Register({
CommandId::ReloadBuffer, "reload-buffer", "Reload buffer from disk", cmd_reload_buffer
CommandId::ReloadBuffer, "reload-buffer", "Reload buffer from disk", cmd_reload_buffer, false, false
});
// Help
CommandRegistry::Register({
CommandId::ShowHelp, "help", "+HELP+ buffer with manual text", cmd_show_help
CommandId::ShowHelp, "help", "+HELP+ buffer with manual text", cmd_show_help, false, false
});
CommandRegistry::Register({
CommandId::MarkAllAndJumpEnd, "mark-all-jump-end", "Set mark at beginning and jump to end",
@@ -3944,20 +4244,20 @@ InstallDefaultCommands()
// GUI
CommandRegistry::Register({
CommandId::VisualFilePickerToggle, "file-picker-toggle", "Toggle visual file picker",
cmd_visual_file_picker_toggle
cmd_visual_file_picker_toggle, false, false
});
// Working directory
CommandRegistry::Register({
CommandId::ShowWorkingDirectory, "show-working-directory", "Show current working directory",
cmd_show_working_directory
cmd_show_working_directory, false, false
});
CommandRegistry::Register({
CommandId::ChangeWorkingDirectory, "change-working-directory", "Change current working directory",
cmd_change_working_directory_start
cmd_change_working_directory_start, false, false
});
// UI helpers
CommandRegistry::Register(
{CommandId::UArgStatus, "uarg-status", "Update universal-arg status", cmd_uarg_status});
{CommandId::UArgStatus, "uarg-status", "Update universal-arg status", cmd_uarg_status, false, false});
// Syntax highlighting (public commands)
CommandRegistry::Register({CommandId::Syntax, "syntax", "Syntax: on|off|reload", cmd_syntax, true});
CommandRegistry::Register({CommandId::SetOption, "set", "Set option: key=value", cmd_set_option, true});
@@ -3989,7 +4289,22 @@ Execute(Editor &ed, CommandId id, const std::string &arg, int count)
}
}
CommandContext ctx{ed, arg, count};
// Source repeat count from editor-level universal argument per new design.
int final_count = 0;
if (cmd->repeatable) {
final_count = ed.UArgGet(); // returns 1 if no active uarg
} else {
// Special-case non-repeatables that should NOT consume/clear uarg:
// - KPrefix: keeps uarg for the following k-suffix command.
// - UnknownKCommand / UnknownEscCommand: user mistyped; keep uarg for next try.
if (id != CommandId::KPrefix && id != CommandId::UnknownKCommand && id !=
CommandId::UnknownEscCommand) {
ed.UArgClear();
}
final_count = 0;
}
CommandContext ctx{ed, arg, final_count};
return cmd->handler ? cmd->handler(ctx) : false;
}

View File

@@ -90,6 +90,7 @@ enum class CommandId {
ShowHelp, // open +HELP+ buffer with manual text (C-k h)
// Meta
UnknownKCommand, // arg: single character that was not recognized after C-k
UnknownEscCommand, // invalid ESC (meta) command; show status and exit escape mode
// Generic command prompt
CommandPromptStart, // begin generic command prompt (C-k ;)
// Theme by name
@@ -128,6 +129,9 @@ struct Command {
CommandHandler handler;
// Public commands are exposed in the ": " prompt (C-k ;)
bool isPublic = false;
// Whether this command should consume and honor a universal argument repeat count.
// Default true per issue request; authors can turn off per-command.
bool repeatable = true;
};

View File

@@ -8,7 +8,10 @@
#include "syntax/NullHighlighter.h"
Editor::Editor() = default;
Editor::Editor()
{
swap_ = std::make_unique<kte::SwapManager>();
}
void
@@ -123,6 +126,11 @@ std::size_t
Editor::AddBuffer(const Buffer &buf)
{
buffers_.push_back(buf);
// Attach swap recorder
if (swap_) {
buffers_.back().SetSwapRecorder(swap_.get());
swap_->Attach(&buffers_.back());
}
if (buffers_.size() == 1) {
curbuf_ = 0;
}
@@ -134,6 +142,10 @@ std::size_t
Editor::AddBuffer(Buffer &&buf)
{
buffers_.push_back(std::move(buf));
if (swap_) {
buffers_.back().SetSwapRecorder(swap_.get());
swap_->Attach(&buffers_.back());
}
if (buffers_.size() == 1) {
curbuf_ = 0;
}
@@ -157,6 +169,12 @@ Editor::OpenFile(const std::string &path, std::string &err)
bool ok = cur.OpenFromFile(path, err);
if (!ok)
return false;
// Ensure swap recorder is attached for this buffer
if (swap_) {
cur.SetSwapRecorder(swap_.get());
swap_->Attach(&cur);
swap_->NotifyFilenameChanged(cur);
}
// Setup highlighting using registry (extension + shebang)
cur.EnsureHighlighter();
std::string first = "";
@@ -187,6 +205,12 @@ Editor::OpenFile(const std::string &path, std::string &err)
if (!b.OpenFromFile(path, err)) {
return false;
}
if (swap_) {
b.SetSwapRecorder(swap_.get());
// path is known, notify
swap_->Attach(&b);
swap_->NotifyFilenameChanged(b);
}
// Initialize syntax highlighting by extension + shebang via registry (v2)
b.EnsureHighlighter();
std::string first = "";
@@ -278,8 +302,67 @@ Editor::Reset()
msgtm_ = 0;
uarg_ = 0;
ucount_ = 0;
repeatable_ = false;
quit_requested_ = false;
quit_confirm_pending_ = false;
// Reset close-confirm/save state
close_confirm_pending_ = false;
close_after_save_ = false;
buffers_.clear();
curbuf_ = 0;
}
// --- Universal argument helpers ---
void
Editor::UArgStart()
{
// If not active, start fresh; else multiply by 4 per ke semantics
if (uarg_ == 0) {
ucount_ = 0;
} else {
if (ucount_ == 0) {
ucount_ = 1;
}
ucount_ *= 4;
}
uarg_ = 1;
char buf[64];
std::snprintf(buf, sizeof(buf), "C-u %d", ucount_);
SetStatus(buf);
}
void
Editor::UArgDigit(int d)
{
if (d < 0)
d = 0;
if (d > 9)
d = 9;
if (uarg_ == 0) {
uarg_ = 1;
ucount_ = 0;
}
ucount_ = ucount_ * 10 + d;
char buf[64];
std::snprintf(buf, sizeof(buf), "C-u %d", ucount_);
SetStatus(buf);
}
void
Editor::UArgClear()
{
uarg_ = 0;
ucount_ = 0;
}
int
Editor::UArgGet()
{
int n = (ucount_ > 0) ? ucount_ : 1;
UArgClear();
return n;
}

View File

@@ -8,6 +8,7 @@
#include <vector>
#include "Buffer.h"
#include "Swap.h"
class Editor {
@@ -156,6 +157,33 @@ public:
}
// --- Universal argument control (C-u) ---
// Begin or extend a universal argument (like ke's uarg_start)
void UArgStart();
// Add a digit 0..9 to the current universal argument (like ke's uarg_digit)
void UArgDigit(int d);
// Clear universal-argument state (like ke's uarg_clear)
void UArgClear();
// Consume the current universal argument, returning count >= 1.
// If no universal argument active, returns 1.
int UArgGet();
// Repeatable command flag: input layer can mark the next command as repeatable
void SetRepeatable(bool on)
{
repeatable_ = on;
}
[[nodiscard]] bool Repeatable() const
{
return repeatable_;
}
// Status message storage. Rendering is renderer-dependent; the editor
// merely stores the current message and its timestamp.
void SetStatus(const std::string &message);
@@ -192,6 +220,31 @@ public:
}
// --- Buffer close/save confirmation state ---
void SetCloseConfirmPending(bool on)
{
close_confirm_pending_ = on;
}
[[nodiscard]] bool CloseConfirmPending() const
{
return close_confirm_pending_;
}
void SetCloseAfterSave(bool on)
{
close_after_save_ = on;
}
[[nodiscard]] bool CloseAfterSave() const
{
return close_after_save_;
}
[[nodiscard]] std::time_t StatusTime() const
{
return msgtm_;
@@ -465,6 +518,13 @@ public:
}
// Swap manager access (for advanced integrations/tests)
[[nodiscard]] kte::SwapManager *Swap()
{
return swap_.get();
}
// --- GUI: Visual File Picker state ---
void SetFilePickerVisible(bool on)
{
@@ -498,17 +558,23 @@ private:
std::string msg_;
std::time_t msgtm_ = 0;
int uarg_ = 0, ucount_ = 0; // C-u support
bool repeatable_ = false; // whether the next command is repeatable
std::vector<Buffer> buffers_;
std::size_t curbuf_ = 0; // index into buffers_
// Swap journaling manager (lifetime = editor)
std::unique_ptr<kte::SwapManager> swap_;
// Kill ring (Emacs-like)
std::vector<std::string> kill_ring_;
std::size_t kill_ring_max_ = 60;
// Quit state
bool quit_requested_ = false;
bool quit_confirm_pending_ = false;
bool quit_requested_ = false;
bool quit_confirm_pending_ = false;
bool close_confirm_pending_ = false; // awaiting y/N to save-before-close
bool close_after_save_ = false; // if true, close buffer after successful Save/SaveAs
// Search state
bool search_active_ = false;

View File

@@ -31,7 +31,9 @@ static auto kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible)
bool
GUIFrontend::Init(Editor &ed)
{
(void) ed; // editor dimensions will be initialized during the first Step() frame
// Attach editor to input handler for editor-owned features (e.g., universal argument)
input_.Attach(&ed);
// editor dimensions will be initialized during the first Step() frame
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) {
return false;
}
@@ -222,17 +224,17 @@ GUIFrontend::Step(Editor &ed, bool &running)
while (SDL_PollEvent(&e)) {
ImGui_ImplSDL2_ProcessEvent(&e);
switch (e.type) {
case SDL_QUIT:
running = false;
break;
case SDL_WINDOWEVENT:
if (e.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
width_ = e.window.data1;
height_ = e.window.data2;
}
break;
default:
break;
case SDL_QUIT:
running = false;
break;
case SDL_WINDOWEVENT:
if (e.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
width_ = e.window.data1;
height_ = e.window.data2;
}
break;
default:
break;
}
// Map input to commands
input_.ProcessSDLEvent(e);

View File

@@ -7,6 +7,7 @@
#include "GUIInputHandler.h"
#include "KKeymap.h"
#include "Editor.h"
static bool
@@ -14,20 +15,17 @@ map_key(const SDL_Keycode key,
const SDL_Keymod mod,
bool &k_prefix,
bool &esc_meta,
// universal-argument state (by ref)
bool &uarg_active,
bool &uarg_collecting,
bool &uarg_negative,
bool &uarg_had_digits,
int &uarg_value,
std::string &uarg_text,
MappedInput &out)
bool &k_ctrl_pending,
Editor *ed,
MappedInput &out,
bool &suppress_textinput_once)
{
// Ctrl handling
const bool is_ctrl = (mod & KMOD_CTRL) != 0;
const bool is_alt = (mod & (KMOD_ALT | KMOD_LALT | KMOD_RALT)) != 0;
// If previous key was ESC, interpret this as Meta via ESC keymap
// If previous key was ESC, interpret this as Meta via ESC keymap.
// Prefer KEYDOWN when we can derive a printable ASCII; otherwise defer to TEXTINPUT.
if (esc_meta) {
int ascii_key = 0;
if (key == SDLK_BACKSPACE) {
@@ -45,17 +43,18 @@ map_key(const SDL_Keycode key,
ascii_key = '>';
}
if (ascii_key != 0) {
esc_meta = false; // consume if we can decide on KEYDOWN
ascii_key = KLowerAscii(ascii_key);
CommandId id;
if (KLookupEscCommand(ascii_key, id)) {
// Only consume the ESC-meta prefix if we actually mapped a command
esc_meta = false;
out = {true, id, "", 0};
out = {true, id, "", 0};
return true;
}
// Known printable but unmapped ESC sequence: report invalid
out = {true, CommandId::UnknownEscCommand, "", 0};
return true;
}
// Unhandled meta chord at KEYDOWN: do not clear esc_meta here.
// Leave it set so SDL_TEXTINPUT fallback can translate and suppress insertion.
// No usable ASCII from KEYDOWN → keep esc_meta set and let TEXTINPUT handle it
out.hasCommand = false;
return true;
}
@@ -65,43 +64,53 @@ map_key(const SDL_Keycode key,
switch (key) {
case SDLK_LEFT:
k_prefix = false;
out = {true, CommandId::MoveLeft, "", 0};
k_ctrl_pending = false;
out = {true, CommandId::MoveLeft, "", 0};
return true;
case SDLK_RIGHT:
k_prefix = false;
out = {true, CommandId::MoveRight, "", 0};
k_ctrl_pending = false;
out = {true, CommandId::MoveRight, "", 0};
return true;
case SDLK_UP:
k_prefix = false;
out = {true, CommandId::MoveUp, "", 0};
k_ctrl_pending = false;
out = {true, CommandId::MoveUp, "", 0};
return true;
case SDLK_DOWN:
k_prefix = false;
out = {true, CommandId::MoveDown, "", 0};
k_ctrl_pending = false;
out = {true, CommandId::MoveDown, "", 0};
return true;
case SDLK_HOME:
k_prefix = false;
out = {true, CommandId::MoveHome, "", 0};
k_ctrl_pending = false;
out = {true, CommandId::MoveHome, "", 0};
return true;
case SDLK_END:
k_prefix = false;
out = {true, CommandId::MoveEnd, "", 0};
k_ctrl_pending = false;
out = {true, CommandId::MoveEnd, "", 0};
return true;
case SDLK_PAGEUP:
k_prefix = false;
out = {true, CommandId::PageUp, "", 0};
k_ctrl_pending = false;
out = {true, CommandId::PageUp, "", 0};
return true;
case SDLK_PAGEDOWN:
k_prefix = false;
out = {true, CommandId::PageDown, "", 0};
k_ctrl_pending = false;
out = {true, CommandId::PageDown, "", 0};
return true;
case SDLK_DELETE:
k_prefix = false;
out = {true, CommandId::DeleteChar, "", 0};
k_ctrl_pending = false;
out = {true, CommandId::DeleteChar, "", 0};
return true;
case SDLK_BACKSPACE:
k_prefix = false;
out = {true, CommandId::Backspace, "", 0};
k_ctrl_pending = false;
out = {true, CommandId::Backspace, "", 0};
return true;
case SDLK_TAB:
// Insert a literal tab character when not interpreting a k-prefix suffix.
@@ -114,10 +123,13 @@ map_key(const SDL_Keycode key,
break; // fall through so k-prefix handler can process
case SDLK_RETURN:
case SDLK_KP_ENTER:
out = {true, CommandId::Newline, "", 0};
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::Newline, "", 0};
return true;
case SDLK_ESCAPE:
k_prefix = false;
k_ctrl_pending = false;
esc_meta = true; // next key will be treated as Meta
out.hasCommand = false; // no immediate command for bare ESC in GUI
return true;
@@ -127,7 +139,6 @@ map_key(const SDL_Keycode key,
// If we are in k-prefix, interpret the very next key via the C-k keymap immediately.
if (k_prefix) {
k_prefix = false;
esc_meta = false;
// Normalize to ASCII; preserve case for letters using Shift
int ascii_key = 0;
@@ -147,10 +158,23 @@ map_key(const SDL_Keycode key,
ascii_key = static_cast<int>(key);
}
bool ctrl2 = (mod & KMOD_CTRL) != 0;
// If user typed a literal 'C' (or '^') as a control qualifier, keep k-prefix active
if (ascii_key == 'C' || ascii_key == 'c' || ascii_key == '^') {
k_ctrl_pending = true;
// Keep waiting for the next suffix; show status and suppress ensuing TEXTINPUT
if (ed)
ed->SetStatus("C-k C _");
suppress_textinput_once = true;
out.hasCommand = false;
return true;
}
// Otherwise, consume the k-prefix now for the actual suffix
k_prefix = false;
if (ascii_key != 0) {
int lower = KLowerAscii(ascii_key);
bool ctrl_suffix_supported = (lower == 'd' || lower == 'x' || lower == 'q');
bool pass_ctrl = ctrl2 && ctrl_suffix_supported;
bool pass_ctrl = (ctrl2 || k_ctrl_pending) && ctrl_suffix_supported;
k_ctrl_pending = false;
CommandId id;
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
// Diagnostics for u/U
@@ -167,54 +191,40 @@ map_key(const SDL_Keycode key,
}
if (mapped) {
out = {true, id, "", 0};
if (ed)
ed->SetStatus(""); // clear "C-k _" hint after suffix
return true;
}
int shown = KLowerAscii(ascii_key);
char c = (shown >= 0x20 && shown <= 0x7e) ? static_cast<char>(shown) : '?';
std::string arg(1, c);
out = {true, CommandId::UnknownKCommand, arg, 0};
if (ed)
ed->SetStatus(""); // clear hint; handler will set unknown status
return true;
}
out.hasCommand = false;
// Non-printable/unmappable key as k-suffix (e.g., F-keys): report unknown and exit k-mode
out = {true, CommandId::UnknownKCommand, std::string("?"), 0};
if (ed)
ed->SetStatus("");
return true;
}
if (is_ctrl) {
// Universal argument: C-u
if (key == SDLK_u) {
if (!uarg_active) {
uarg_active = true;
uarg_collecting = true;
uarg_negative = false;
uarg_had_digits = false;
uarg_value = 4; // default
uarg_text.clear();
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true;
} else if (uarg_collecting && !uarg_had_digits && !uarg_negative) {
if (uarg_value <= 0)
uarg_value = 4;
else
uarg_value *= 4; // repeated C-u multiplies by 4
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true;
} else {
// End collection if already started with digits or '-'
uarg_collecting = false;
if (!uarg_had_digits && !uarg_negative && uarg_value <= 0)
uarg_value = 4;
}
if (ed)
ed->UArgStart();
out.hasCommand = false;
return true;
}
// Cancel universal arg on C-g as well (it maps to Refresh via ctrl map)
if (key == SDLK_g) {
uarg_active = false;
uarg_collecting = false;
uarg_negative = false;
uarg_had_digits = false;
uarg_value = 0;
uarg_text.clear();
if (ed)
ed->UArgClear();
// Also cancel any pending k-prefix qualifier
k_ctrl_pending = false;
k_prefix = false; // treat as cancel of prefix
}
if (key == SDLK_k || key == SDLK_KP_EQUALS) {
k_prefix = true;
@@ -258,29 +268,17 @@ map_key(const SDL_Keycode key,
}
}
// If collecting universal argument, allow digits/minus on KEYDOWN path too
if (uarg_active && uarg_collecting) {
// If collecting universal argument, allow digits on KEYDOWN path too
if (ed && ed->UArg() != 0) {
if ((key >= SDLK_0 && key <= SDLK_9) && !(mod & KMOD_SHIFT)) {
int d = static_cast<int>(key - SDLK_0);
if (!uarg_had_digits) {
uarg_value = 0;
uarg_had_digits = true;
}
if (uarg_value < 100000000) {
uarg_value = uarg_value * 10 + d;
}
uarg_text.push_back(static_cast<char>('0' + d));
out = {true, CommandId::UArgStatus, uarg_text, 0};
ed->UArgDigit(d);
out.hasCommand = false;
// We consumed a digit on KEYDOWN; SDL will often also emit TEXTINPUT for it.
// Request suppression of the very next TEXTINPUT to avoid double-counting.
suppress_textinput_once = true;
return true;
}
if (key == SDLK_MINUS && !uarg_had_digits && !uarg_negative) {
uarg_negative = true;
uarg_text = "-";
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true;
}
// Any other key will end collection; process it normally
uarg_collecting = false;
}
// k_prefix handled earlier
@@ -364,12 +362,19 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
}
}
produced = map_key(key, mods,
k_prefix_, esc_meta_,
uarg_active_, uarg_collecting_, uarg_negative_, uarg_had_digits_,
uarg_value_,
uarg_text_,
mi);
{
bool suppress_req = false;
produced = map_key(key, mods,
k_prefix_, esc_meta_,
k_ctrl_pending_,
ed_,
mi,
suppress_req);
if (suppress_req) {
// Prevent the corresponding TEXTINPUT from delivering the same digit again
suppress_text_input_once_ = true;
}
}
// Note: Do NOT suppress SDL_TEXTINPUT after inserting a TAB. Most platforms
// do not emit TEXTINPUT for Tab, and suppressing here would incorrectly
@@ -377,14 +382,7 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
// If we just consumed a universal-argument digit or '-' on KEYDOWN and emitted UArgStatus,
// suppress the subsequent SDL_TEXTINPUT for this keystroke to avoid duplicating the digit in status.
if (produced && mi.hasCommand && mi.id == CommandId::UArgStatus) {
// Digits without shift, or a plain '-'
const bool is_digit_key = (key >= SDLK_0 && key <= SDLK_9) && !(mods & KMOD_SHIFT);
const bool is_minus_key = (key == SDLK_MINUS);
if (uarg_active_ && uarg_collecting_ &&(is_digit_key || is_minus_key)) {
suppress_text_input_once_ = true;
}
}
// Additional suppression handled above when KEYDOWN consumed a uarg digit
// Suppress the immediate following SDL_TEXTINPUT when a printable KEYDOWN was used as a
// k-prefix suffix or Meta (Alt/ESC) chord, regardless of whether a command was produced.
@@ -430,35 +428,24 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
break;
}
// If universal argument collection is active, consume digit/minus TEXTINPUT
if (uarg_active_ && uarg_collecting_) {
// If editor universal argument is active, consume digit TEXTINPUT
if (ed_ &&ed_
->
UArg() != 0
)
{
const char *txt = e.text.text;
if (txt && *txt) {
unsigned char c0 = static_cast<unsigned char>(txt[0]);
if (c0 >= '0' && c0 <= '9') {
int d = c0 - '0';
if (!uarg_had_digits_) {
uarg_value_ = 0;
uarg_had_digits_ = true;
}
if (uarg_value_ < 100000000) {
uarg_value_ = uarg_value_ * 10 + d;
}
uarg_text_.push_back(static_cast<char>(c0));
mi = {true, CommandId::UArgStatus, uarg_text_, 0};
produced = true; // consumed and enqueued status update
break;
}
if (c0 == '-' && !uarg_had_digits_ && !uarg_negative_) {
uarg_negative_ = true;
uarg_text_ = "-";
mi = {true, CommandId::UArgStatus, uarg_text_, 0};
produced = true;
ed_->UArgDigit(d);
produced = true; // consumed to update status
break;
}
}
// End collection and allow this TEXTINPUT to be processed normally below
uarg_collecting_ = false;
// Non-digit ends collection; allow processing normally below
}
// If we are still in k-prefix and KEYDOWN path didn't handle the suffix,
@@ -474,9 +461,21 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
ascii_key = static_cast<int>(c0);
}
if (ascii_key != 0) {
// Qualifier via TEXTINPUT: 'C' or '^'
if (ascii_key == 'C' || ascii_key == 'c' || ascii_key == '^') {
k_ctrl_pending_ = true;
if (ed_)
ed_->SetStatus("C-k C _");
// Keep k-prefix active; do not emit a command
k_prefix_ = true;
produced = true;
break;
}
// Map via k-prefix table; do not pass Ctrl for TEXTINPUT case
CommandId id;
bool mapped = KLookupKCommand(ascii_key, false, id);
bool pass_ctrl = k_ctrl_pending_;
k_ctrl_pending_ = false;
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
// Diagnostics: log any k-prefix TEXTINPUT suffix mapping
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
? static_cast<char>(ascii_key)
@@ -487,7 +486,9 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
mapped ? static_cast<int>(id) : -1);
std::fflush(stderr);
if (mapped) {
mi = {true, id, "", 0};
mi = {true, id, "", 0};
if (ed_)
ed_->SetStatus(""); // clear "C-k _" hint after suffix
produced = true;
break; // handled; do not insert text
} else {
@@ -497,13 +498,18 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
? static_cast<char>(shown)
: '?';
std::string arg(1, c);
mi = {true, CommandId::UnknownKCommand, arg, 0};
mi = {true, CommandId::UnknownKCommand, arg, 0};
if (ed_)
ed_->SetStatus("");
produced = true;
break;
}
}
}
// Consume even if no usable ascii was found
// If no usable ASCII was found, still report an unknown k-command and exit k-mode
mi = {true, CommandId::UnknownKCommand, std::string("?"), 0};
if (ed_)
ed_->SetStatus("");
produced = true;
break;
}
@@ -543,7 +549,8 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
}
}
}
// If we get here, swallow the TEXTINPUT (do not insert stray char)
// If we get here, unmapped ESC sequence via TEXTINPUT: report invalid
mi = {true, CommandId::UnknownEscCommand, "", 0};
produced = true;
break;
}
@@ -573,33 +580,6 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
}
if (produced && mi.hasCommand) {
// Attach universal-argument count if present, then clear the state
if (uarg_active_ &&mi
.
id != CommandId::UArgStatus
)
{
int count = 0;
if (!uarg_had_digits_ && !uarg_negative_) {
// No explicit digits: use current value (default 4 or 4^n)
count = (uarg_value_ > 0) ? uarg_value_ : 4;
} else {
count = uarg_value_;
if (uarg_negative_)
count = -count;
}
mi.count = count;
// Clear universal-argument state after applying it
uarg_active_ = false;
uarg_collecting_ = false;
uarg_negative_ = false;
uarg_had_digits_ = false;
uarg_value_ = 0;
uarg_text_.clear();
}
std::lock_guard<std::mutex> lk(mu_);
q_.push(mi);
}

View File

@@ -16,6 +16,13 @@ public:
~GUIInputHandler() override = default;
void Attach(Editor *ed) override
{
ed_ = ed;
}
// Translate an SDL event to editor command and enqueue if applicable.
// Returns true if it produced a mapped command or consumed input.
bool ProcessSDLEvent(const SDL_Event &e);
@@ -25,18 +32,13 @@ public:
private:
std::mutex mu_;
std::queue<MappedInput> q_;
bool k_prefix_ = false;
bool k_prefix_ = false;
bool k_ctrl_pending_ = false; // if true, next k-suffix is treated as Ctrl- (qualifier via literal 'C' or '^')
// Treat ESC as a Meta prefix: next key is looked up via ESC keymap
bool esc_meta_ = false;
// When a printable keydown generated a non-text command, suppress the very next SDL_TEXTINPUT
// event produced by SDL for the same keystroke to avoid inserting stray characters.
bool suppress_text_input_once_ = false;
// Universal argument (C-u) state for GUI
bool uarg_active_ = false; // an argument is pending for the next command
bool uarg_collecting_ = false; // collecting digits / '-' right now
bool uarg_negative_ = false; // whether a leading '-' was supplied
bool uarg_had_digits_ = false; // whether any digits were supplied
int uarg_value_ = 0; // current absolute value (>=0)
std::string uarg_text_; // raw digits/minus typed for status display
Editor *ed_ = nullptr; // attached editor for editor-owned uarg handling
};

View File

@@ -369,8 +369,34 @@ GUIRenderer::Draw(Editor &ed)
// Draw syntax-colored runs (text above background highlights)
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
const kte::LineHighlight &lh = buf->Highlighter()->GetLine(
kte::LineHighlight lh = buf->Highlighter()->GetLine(
*buf, static_cast<int>(i), buf->Version());
// Sanitize spans defensively: clamp to [0, line.size()], ensure end>=start, drop empties
struct SSpan {
std::size_t s;
std::size_t e;
kte::TokenKind k;
};
std::vector<SSpan> spans;
spans.reserve(lh.spans.size());
const std::size_t line_len = line.size();
for (const auto &sp: lh.spans) {
int s_raw = sp.col_start;
int e_raw = sp.col_end;
if (e_raw < s_raw)
std::swap(e_raw, s_raw);
std::size_t s = static_cast<std::size_t>(std::max(
0, std::min(s_raw, static_cast<int>(line_len))));
std::size_t e = static_cast<std::size_t>(std::max(
static_cast<int>(s), std::min(e_raw, static_cast<int>(line_len))));
if (e <= s)
continue;
spans.push_back(SSpan{s, e, sp.kind});
}
std::sort(spans.begin(), spans.end(), [](const SSpan &a, const SSpan &b) {
return a.s < b.s;
});
// Helper to convert a src column to expanded rx position
auto src_to_rx_full = [&](std::size_t sidx) -> std::size_t {
std::size_t rx = 0;
@@ -379,24 +405,22 @@ GUIRenderer::Draw(Editor &ed)
}
return rx;
};
for (const auto &sp: lh.spans) {
std::size_t rx_s = src_to_rx_full(
static_cast<std::size_t>(std::max(0, sp.col_start)));
std::size_t rx_e = src_to_rx_full(
static_cast<std::size_t>(std::max(sp.col_start, sp.col_end)));
for (const auto &sp: spans) {
std::size_t rx_s = src_to_rx_full(sp.s);
std::size_t rx_e = src_to_rx_full(sp.e);
if (rx_e <= coloffs_now)
continue;
// Clamp rx_s/rx_e to the visible portion
continue; // fully left of viewport
// Clamp to visible portion and expanded length
std::size_t draw_start = (rx_s > coloffs_now) ? rx_s : coloffs_now;
std::size_t draw_end = rx_e;
if (draw_start >= expanded.size())
continue;
draw_end = std::min<std::size_t>(draw_end, expanded.size());
continue; // fully right of expanded text
std::size_t draw_end = std::min<std::size_t>(rx_e, expanded.size());
if (draw_end <= draw_start)
continue;
// Screen position is relative to coloffs_now
std::size_t screen_x = draw_start - coloffs_now;
ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.kind));
ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.k));
ImVec2 p = ImVec2(line_pos.x + static_cast<float>(screen_x) * space_w,
line_pos.y);
ImGui::GetWindowDrawList()->AddText(

View File

@@ -6,6 +6,8 @@
#include "Command.h"
class Editor; // fwd decl
// Result of translating raw input into an editor command.
struct MappedInput {
@@ -19,6 +21,10 @@ class InputHandler {
public:
virtual ~InputHandler() = default;
// Optional: attach current Editor so handlers can consult editor state (e.g., universal argument)
// Default implementation does nothing.
virtual void Attach(Editor *) {}
// Poll for input and translate it to a command. Non-blocking.
// Returns true if a command is available in 'out'. Returns false if no input.
virtual bool Poll(MappedInput &out) = 0;

View File

@@ -8,5 +8,6 @@ ROADMAP / TODO:
- [x] When the filename is longer than the message window, scoot left to
keep it in view
- [x] Syntax highlighting
- [ ] Swap files (crash recovery). See `docs/plans/swap-files.md`
- [ ] The undo system should actually work
- [ ] LSP integration

412
Swap.cc Normal file
View File

@@ -0,0 +1,412 @@
#include "Swap.h"
#include "Buffer.h"
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
namespace fs = std::filesystem;
namespace kte {
namespace {
constexpr std::uint8_t MAGIC[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'};
constexpr std::uint32_t VERSION = 1;
}
SwapManager::SwapManager()
{
running_.store(true);
worker_ = std::thread([this] {
this->writer_loop();
});
}
SwapManager::~SwapManager()
{
running_.store(false);
cv_.notify_all();
if (worker_.joinable())
worker_.join();
// Close all journals
for (auto &kv: journals_) {
close_ctx(kv.second);
}
}
void
SwapManager::Attach(Buffer * /*buf*/)
{
// Stage 1: lazy-open on first record; nothing to do here.
}
void
SwapManager::Detach(Buffer * /*buf*/)
{
// Stage 1: keep files open until manager destruction; future work can close per-buffer.
}
void
SwapManager::NotifyFilenameChanged(Buffer &buf)
{
std::lock_guard<std::mutex> lg(mtx_);
auto it = journals_.find(&buf);
if (it == journals_.end())
return;
JournalCtx &ctx = it->second;
// Close existing file handle, update path; lazily reopen on next write
close_ctx(ctx);
ctx.path = ComputeSidecarPath(buf);
}
void
SwapManager::SetSuspended(Buffer &buf, bool on)
{
std::lock_guard<std::mutex> lg(mtx_);
auto path = ComputeSidecarPath(buf);
// Create/update context for this buffer
JournalCtx &ctx = journals_[&buf];
ctx.path = path;
ctx.suspended = on;
}
SwapManager::SuspendGuard::SuspendGuard(SwapManager &m, Buffer *b)
: m_(m), buf_(b), prev_(false)
{
// Suspend recording while guard is alive
if (buf_)
m_.SetSuspended(*buf_, true);
}
SwapManager::SuspendGuard::~SuspendGuard()
{
if (buf_)
m_.SetSuspended(*buf_, false);
}
std::string
SwapManager::ComputeSidecarPath(const Buffer &buf)
{
if (buf.IsFileBacked() || !buf.Filename().empty()) {
fs::path p(buf.Filename());
fs::path dir = p.parent_path();
std::string base = p.filename().string();
std::string side = "." + base + ".kte.swp";
return (dir / side).string();
}
// unnamed: $TMPDIR/kte/unnamed-<ptr>.kte.swp (best-effort)
const char *tmp = std::getenv("TMPDIR");
fs::path t = tmp ? fs::path(tmp) : fs::temp_directory_path();
fs::path d = t / "kte";
char bufptr[32];
std::snprintf(bufptr, sizeof(bufptr), "%p", (const void *) &buf);
return (d / (std::string("unnamed-") + bufptr + ".kte.swp")).string();
}
std::uint64_t
SwapManager::now_ns()
{
using namespace std::chrono;
return duration_cast<nanoseconds>(steady_clock::now().time_since_epoch()).count();
}
bool
SwapManager::ensure_parent_dir(const std::string &path)
{
try {
fs::path p(path);
fs::path dir = p.parent_path();
if (dir.empty())
return true;
if (!fs::exists(dir))
fs::create_directories(dir);
return true;
} catch (...) {
return false;
}
}
bool
SwapManager::write_header(JournalCtx &ctx)
{
if (ctx.fd < 0)
return false;
// Write a simple 64-byte header
std::uint8_t hdr[64];
std::memset(hdr, 0, sizeof(hdr));
std::memcpy(hdr, MAGIC, 8);
std::uint32_t ver = VERSION;
std::memcpy(hdr + 8, &ver, sizeof(ver));
std::uint64_t ts = static_cast<std::uint64_t>(std::time(nullptr));
std::memcpy(hdr + 16, &ts, sizeof(ts));
ssize_t w = ::write(ctx.fd, hdr, sizeof(hdr));
return (w == (ssize_t) sizeof(hdr));
}
bool
SwapManager::open_ctx(JournalCtx &ctx)
{
if (ctx.fd >= 0)
return true;
if (!ensure_parent_dir(ctx.path))
return false;
// Create or open with 0600 perms
int fd = ::open(ctx.path.c_str(), O_CREAT | O_RDWR, 0600);
if (fd < 0)
return false;
// Detect if file is new/empty to write header
struct stat st{};
if (fstat(fd, &st) != 0) {
::close(fd);
return false;
}
ctx.fd = fd;
ctx.file = fdopen(fd, "ab");
if (!ctx.file) {
::close(fd);
ctx.fd = -1;
return false;
}
if (st.st_size == 0) {
ctx.header_ok = write_header(ctx);
} else {
ctx.header_ok = true; // trust existing file for stage 1
// Seek to end to append
::lseek(ctx.fd, 0, SEEK_END);
}
return ctx.header_ok;
}
void
SwapManager::close_ctx(JournalCtx &ctx)
{
if (ctx.file) {
std::fflush((FILE *) ctx.file);
::fsync(ctx.fd);
std::fclose((FILE *) ctx.file);
ctx.file = nullptr;
}
if (ctx.fd >= 0) {
::close(ctx.fd);
ctx.fd = -1;
}
}
std::uint32_t
SwapManager::crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed)
{
static std::uint32_t table[256];
static bool inited = false;
if (!inited) {
for (std::uint32_t i = 0; i < 256; ++i) {
std::uint32_t c = i;
for (int j = 0; j < 8; ++j)
c = (c & 1) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1);
table[i] = c;
}
inited = true;
}
std::uint32_t c = ~seed;
for (std::size_t i = 0; i < len; ++i)
c = table[(c ^ data[i]) & 0xFFu] ^ (c >> 8);
return ~c;
}
void
SwapManager::put_varu64(std::vector<std::uint8_t> &out, std::uint64_t v)
{
while (v >= 0x80) {
out.push_back(static_cast<std::uint8_t>(v) | 0x80);
v >>= 7;
}
out.push_back(static_cast<std::uint8_t>(v));
}
void
SwapManager::put_u24(std::uint8_t dst[3], std::uint32_t v)
{
dst[0] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
dst[1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
dst[2] = static_cast<std::uint8_t>(v & 0xFF);
}
void
SwapManager::enqueue(Pending &&p)
{
{
std::lock_guard<std::mutex> lg(mtx_);
queue_.emplace_back(std::move(p));
}
cv_.notify_one();
}
void
SwapManager::RecordInsert(Buffer &buf, int row, int col, std::string_view text)
{
{
std::lock_guard<std::mutex> lg(mtx_);
if (journals_[&buf].suspended)
return;
}
Pending p;
p.buf = &buf;
p.type = SwapRecType::INS;
// payload: varint row, varint col, varint len, bytes
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, row)));
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, col)));
put_varu64(p.payload, static_cast<std::uint64_t>(text.size()));
p.payload.insert(p.payload.end(), reinterpret_cast<const std::uint8_t *>(text.data()),
reinterpret_cast<const std::uint8_t *>(text.data()) + text.size());
enqueue(std::move(p));
}
void
SwapManager::RecordDelete(Buffer &buf, int row, int col, std::size_t len)
{
{
std::lock_guard<std::mutex> lg(mtx_);
if (journals_[&buf].suspended)
return;
}
Pending p;
p.buf = &buf;
p.type = SwapRecType::DEL;
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, row)));
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, col)));
put_varu64(p.payload, static_cast<std::uint64_t>(len));
enqueue(std::move(p));
}
void
SwapManager::RecordSplit(Buffer &buf, int row, int col)
{
{
std::lock_guard<std::mutex> lg(mtx_);
if (journals_[&buf].suspended)
return;
}
Pending p;
p.buf = &buf;
p.type = SwapRecType::SPLIT;
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, row)));
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, col)));
enqueue(std::move(p));
}
void
SwapManager::RecordJoin(Buffer &buf, int row)
{
{
std::lock_guard<std::mutex> lg(mtx_);
if (journals_[&buf].suspended)
return;
}
Pending p;
p.buf = &buf;
p.type = SwapRecType::JOIN;
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, row)));
enqueue(std::move(p));
}
void
SwapManager::writer_loop()
{
while (running_.load()) {
std::vector<Pending> batch;
{
std::unique_lock<std::mutex> lk(mtx_);
if (queue_.empty()) {
cv_.wait_for(lk, std::chrono::milliseconds(cfg_.flush_interval_ms));
}
if (!queue_.empty()) {
batch.swap(queue_);
}
}
if (batch.empty())
continue;
// Group by buffer path to minimize fsyncs
for (const Pending &p: batch) {
process_one(p);
}
// Throttled fsync: best-effort
// Iterate unique contexts and fsync if needed
// For stage 1, fsync all once per interval
std::uint64_t now = now_ns();
for (auto &kv: journals_) {
JournalCtx &ctx = kv.second;
if (ctx.fd >= 0) {
if (ctx.last_fsync_ns == 0 || (now - ctx.last_fsync_ns) / 1000000ULL >= cfg_.
fsync_interval_ms) {
::fsync(ctx.fd);
ctx.last_fsync_ns = now;
}
}
}
}
}
void
SwapManager::process_one(const Pending &p)
{
Buffer &buf = *p.buf;
// Resolve context by path derived from buffer
std::string path = ComputeSidecarPath(buf);
// Get or create context keyed by this buffer pointer (stage 1 simplification)
JournalCtx &ctx = journals_[p.buf];
if (ctx.path.empty())
ctx.path = path;
if (!open_ctx(ctx))
return;
// Build record: [type u8][len u24][payload][crc32 u32]
std::uint8_t len3[3];
put_u24(len3, static_cast<std::uint32_t>(p.payload.size()));
std::uint8_t head[4];
head[0] = static_cast<std::uint8_t>(p.type);
head[1] = len3[0];
head[2] = len3[1];
head[3] = len3[2];
std::uint32_t c = 0;
c = crc32(head, sizeof(head), c);
if (!p.payload.empty())
c = crc32(p.payload.data(), p.payload.size(), c);
// Write
(void) ::write(ctx.fd, head, sizeof(head));
if (!p.payload.empty())
(void) ::write(ctx.fd, p.payload.data(), p.payload.size());
(void) ::write(ctx.fd, &c, sizeof(c));
}
} // namespace kte

145
Swap.h Normal file
View File

@@ -0,0 +1,145 @@
// Swap.h - swap journal (crash recovery) writer/manager for kte
#pragma once
#include <cstdint>
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
#include <unordered_map>
#include <mutex>
#include <condition_variable>
#include <thread>
#include <atomic>
class Buffer;
namespace kte {
// Minimal record types for stage 1
enum class SwapRecType : std::uint8_t {
INS = 1,
DEL = 2,
SPLIT = 3,
JOIN = 4,
META = 0xF0,
CHKPT = 0xFE,
};
struct SwapConfig {
// Grouping and durability knobs (stage 1 defaults)
unsigned flush_interval_ms{200}; // group small writes
unsigned fsync_interval_ms{1000}; // at most once per second
};
// Lightweight interface that Buffer can call without depending on full manager impl
class SwapRecorder {
public:
virtual ~SwapRecorder() = default;
virtual void RecordInsert(Buffer &buf, int row, int col, std::string_view text) = 0;
virtual void RecordDelete(Buffer &buf, int row, int col, std::size_t len) = 0;
virtual void RecordSplit(Buffer &buf, int row, int col) = 0;
virtual void RecordJoin(Buffer &buf, int row) = 0;
virtual void NotifyFilenameChanged(Buffer &buf) = 0;
virtual void SetSuspended(Buffer &buf, bool on) = 0;
};
// SwapManager manages sidecar swap files and a single background writer thread.
class SwapManager final : public SwapRecorder {
public:
SwapManager();
~SwapManager() override;
// Attach a buffer to begin journaling. Safe to call multiple times; idempotent.
void Attach(Buffer *buf);
// Detach and close journal.
void Detach(Buffer *buf);
// SwapRecorder: Notify that the buffer's filename changed (e.g., SaveAs)
void NotifyFilenameChanged(Buffer &buf) override;
// SwapRecorder
void RecordInsert(Buffer &buf, int row, int col, std::string_view text) override;
void RecordDelete(Buffer &buf, int row, int col, std::size_t len) override;
void RecordSplit(Buffer &buf, int row, int col) override;
void RecordJoin(Buffer &buf, int row) override;
// RAII guard to suspend recording for internal operations
class SuspendGuard {
public:
SuspendGuard(SwapManager &m, Buffer *b);
~SuspendGuard();
private:
SwapManager &m_;
Buffer *buf_;
bool prev_;
};
// Per-buffer toggle
void SetSuspended(Buffer &buf, bool on) override;
private:
struct JournalCtx {
std::string path;
void *file{nullptr}; // FILE*
int fd{-1};
bool header_ok{false};
bool suspended{false};
std::uint64_t last_flush_ns{0};
std::uint64_t last_fsync_ns{0};
};
struct Pending {
Buffer *buf{nullptr};
SwapRecType type{SwapRecType::INS};
std::vector<std::uint8_t> payload; // framed payload only
bool urgent_flush{false};
};
// Helpers
static std::string ComputeSidecarPath(const Buffer &buf);
static std::uint64_t now_ns();
static bool ensure_parent_dir(const std::string &path);
static bool write_header(JournalCtx &ctx);
static bool open_ctx(JournalCtx &ctx);
static void close_ctx(JournalCtx &ctx);
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
static void put_varu64(std::vector<std::uint8_t> &out, std::uint64_t v);
static void put_u24(std::uint8_t dst[3], std::uint32_t v);
void enqueue(Pending &&p);
void writer_loop();
void process_one(const Pending &p);
// State
SwapConfig cfg_{};
std::unordered_map<Buffer *, JournalCtx> journals_;
std::mutex mtx_;
std::condition_variable cv_;
std::vector<Pending> queue_;
std::atomic<bool> running_{false};
std::thread worker_;
};
} // namespace kte

View File

@@ -55,6 +55,8 @@ TerminalFrontend::Init(Editor &ed)
prev_r_ = r;
prev_c_ = c;
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
// Attach editor to input handler for editor-owned features (e.g., universal argument)
input_.Attach(&ed);
return true;
}
@@ -100,4 +102,4 @@ TerminalFrontend::Shutdown()
have_orig_tio_ = false;
}
endwin();
}
}

View File

@@ -3,6 +3,7 @@
#include "TerminalInputHandler.h"
#include "KKeymap.h"
#include "Editor.h"
namespace {
constexpr int
@@ -21,96 +22,103 @@ static bool
map_key_to_command(const int ch,
bool &k_prefix,
bool &esc_meta,
// universal-argument state (by ref)
bool &uarg_active,
bool &uarg_collecting,
bool &uarg_negative,
bool &uarg_had_digits,
int &uarg_value,
std::string &uarg_text,
bool &k_ctrl_pending,
Editor *ed,
MappedInput &out)
{
// Handle special keys from ncurses
// These keys exit k-prefix mode if active (user pressed C-k then a special key).
switch (ch) {
case KEY_MOUSE: {
k_prefix = false;
MEVENT ev{};
if (getmouse(&ev) == OK) {
// Mouse wheel → scroll viewport without moving cursor
case KEY_MOUSE: {
k_prefix = false;
k_ctrl_pending = false;
MEVENT ev{};
if (getmouse(&ev) == OK) {
// Mouse wheel → scroll viewport without moving cursor
#ifdef BUTTON4_PRESSED
if (ev.bstate & (BUTTON4_PRESSED | BUTTON4_RELEASED | BUTTON4_CLICKED)) {
out = {true, CommandId::ScrollUp, "", 0};
return true;
}
if (ev.bstate & (BUTTON4_PRESSED | BUTTON4_RELEASED | BUTTON4_CLICKED)) {
out = {true, CommandId::ScrollUp, "", 0};
return true;
}
#endif
#ifdef BUTTON5_PRESSED
if (ev.bstate & (BUTTON5_PRESSED | BUTTON5_RELEASED | BUTTON5_CLICKED)) {
out = {true, CommandId::ScrollDown, "", 0};
return true;
}
if (ev.bstate & (BUTTON5_PRESSED | BUTTON5_RELEASED | BUTTON5_CLICKED)) {
out = {true, CommandId::ScrollDown, "", 0};
return true;
}
#endif
// React to left button click/press
if (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED)) {
char buf[64];
// Use screen coordinates; command handler will translate via offsets
std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x);
out = {true, CommandId::MoveCursorTo, std::string(buf), 0};
return true;
// React to left button click/press
if (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED)) {
char buf[64];
// Use screen coordinates; command handler will translate via offsets
std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x);
out = {true, CommandId::MoveCursorTo, std::string(buf), 0};
return true;
}
}
// No actionable mouse event
out.hasCommand = false;
return true;
}
// No actionable mouse event
out.hasCommand = false;
return true;
}
case KEY_LEFT:
k_prefix = false;
out = {true, CommandId::MoveLeft, "", 0};
return true;
case KEY_RIGHT:
k_prefix = false;
out = {true, CommandId::MoveRight, "", 0};
return true;
case KEY_UP:
k_prefix = false;
out = {true, CommandId::MoveUp, "", 0};
return true;
case KEY_DOWN:
k_prefix = false;
out = {true, CommandId::MoveDown, "", 0};
return true;
case KEY_HOME:
k_prefix = false;
out = {true, CommandId::MoveHome, "", 0};
return true;
case KEY_END:
k_prefix = false;
out = {true, CommandId::MoveEnd, "", 0};
return true;
case KEY_PPAGE:
k_prefix = false;
out = {true, CommandId::PageUp, "", 0};
return true;
case KEY_NPAGE:
k_prefix = false;
out = {true, CommandId::PageDown, "", 0};
return true;
case KEY_DC:
k_prefix = false;
out = {true, CommandId::DeleteChar, "", 0};
return true;
case KEY_RESIZE:
k_prefix = false;
out = {true, CommandId::Refresh, "", 0};
return true;
default:
break;
case KEY_LEFT:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveLeft, "", 0};
return true;
case KEY_RIGHT:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveRight, "", 0};
return true;
case KEY_UP:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveUp, "", 0};
return true;
case KEY_DOWN:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveDown, "", 0};
return true;
case KEY_HOME:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveHome, "", 0};
return true;
case KEY_END:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveEnd, "", 0};
return true;
case KEY_PPAGE:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::PageUp, "", 0};
return true;
case KEY_NPAGE:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::PageDown, "", 0};
return true;
case KEY_DC:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::DeleteChar, "", 0};
return true;
case KEY_RESIZE:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::Refresh, "", 0};
return true;
default:
break;
}
// ESC as cancel of prefix; many terminals send meta sequences as ESC+...
if (ch == 27) {
// ESC
k_prefix = false;
k_ctrl_pending = false;
esc_meta = true; // next key will be considered meta-modified
out.hasCommand = false; // no command yet
return true;
@@ -119,59 +127,33 @@ map_key_to_command(const int ch,
// Control keys
if (ch == CTRL('K')) {
// C-k prefix
k_prefix = true;
out = {true, CommandId::KPrefix, "", 0};
k_prefix = true;
k_ctrl_pending = false;
out = {true, CommandId::KPrefix, "", 0};
return true;
}
if (ch == CTRL('G')) {
// cancel
k_prefix = false;
esc_meta = false;
k_prefix = false;
k_ctrl_pending = false;
esc_meta = false;
// cancel universal argument as well
uarg_active = false;
uarg_collecting = false;
uarg_negative = false;
uarg_had_digits = false;
uarg_value = 0;
uarg_text.clear();
if (ed)
ed->UArgClear();
out = {true, CommandId::Refresh, "", 0};
return true;
}
// Universal argument: C-u
if (ch == CTRL('U')) {
// Start or extend universal argument
if (!uarg_active) {
uarg_active = true;
uarg_collecting = true;
uarg_negative = false;
uarg_had_digits = false;
uarg_value = 4; // default
// Reset collected text and emit status update
uarg_text.clear();
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true;
} else if (uarg_collecting && !uarg_had_digits && !uarg_negative) {
// Bare repeated C-u multiplies by 4
if (uarg_value <= 0)
uarg_value = 4;
else
uarg_value *= 4;
// Keep showing status (no digits yet)
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true;
} else {
// If digits or '-' have been entered, C-u ends the argument (ready for next command)
uarg_collecting = false;
if (!uarg_had_digits && !uarg_negative && uarg_value <= 0)
uarg_value = 4;
}
// No command produced by C-u itself
out.hasCommand = false;
if (ed)
ed->UArgStart();
out.hasCommand = false; // C-u itself doesn't issue a command
return true;
}
// Tab (note: terminals encode Tab and C-i as the same code 9)
if (ch == '\t') {
k_prefix = false;
k_ctrl_pending = false;
out.hasCommand = true;
out.id = CommandId::InsertText;
out.arg = "\t";
@@ -182,22 +164,39 @@ map_key_to_command(const int ch,
// IMPORTANT: if we're in k-prefix, the very next key must be interpreted
// via the C-k keymap first, even if it's a Control chord like C-d.
if (k_prefix) {
k_prefix = false; // consume the prefix for this one key
// In k-prefix: allow a control qualifier via literal 'C' or '^'
// Detect Control keycodes first
bool ctrl = false;
int ascii_key = ch;
if (ch >= 1 && ch <= 26) {
ctrl = true;
ascii_key = 'a' + (ch - 1);
}
// If user typed literal 'C'/'c' or '^' as a qualifier, keep k-prefix and set pending
if (ascii_key == 'C' || ascii_key == 'c' || ascii_key == '^') {
k_ctrl_pending = true;
if (ed)
ed->SetStatus("C-k C _");
out.hasCommand = false;
return true;
}
// For actual suffix, consume the k-prefix
k_prefix = false;
// Do NOT lowercase here; KLookupKCommand handles case-sensitive bindings
CommandId id;
if (KLookupKCommand(ascii_key, ctrl, id)) {
bool pass_ctrl = (ctrl || k_ctrl_pending);
k_ctrl_pending = false;
if (KLookupKCommand(ascii_key, pass_ctrl, id)) {
out = {true, id, "", 0};
if (ed)
ed->SetStatus(""); // clear "C-k _" hint after suffix
} else {
int shown = KLowerAscii(ascii_key);
char c = (shown >= 0x20 && shown <= 0x7e) ? static_cast<char>(shown) : '?';
std::string arg(1, c);
out = {true, CommandId::UnknownKCommand, arg, 0};
if (ed)
ed->SetStatus(""); // clear hint; handler will set unknown status
}
return true;
}
@@ -213,8 +212,9 @@ map_key_to_command(const int ch,
// Enter
if (ch == '\n' || ch == '\r') {
k_prefix = false;
out = {true, CommandId::Newline, "", 0};
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::Newline, "", 0};
return true;
}
// If previous key was ESC, interpret as meta and use ESC keymap
@@ -224,6 +224,12 @@ map_key_to_command(const int ch,
// Handle ESC + BACKSPACE (meta-backspace, Alt-Backspace)
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
ascii_key = KEY_BACKSPACE; // normalized value for lookup
} else if (ch == ',') {
// Some terminals emit ',' when Shift state is lost after ESC; treat as '<'
ascii_key = '<';
} else if (ch == '.') {
// Likewise, map '.' to '>'
ascii_key = '>';
} else if (ascii_key >= 'A' && ascii_key <= 'Z') {
ascii_key = ascii_key - 'A' + 'a';
}
@@ -232,48 +238,26 @@ map_key_to_command(const int ch,
out = {true, id, "", 0};
return true;
}
// Unhandled meta key: no command
out.hasCommand = false;
// Unhandled ESC sequence: exit escape mode and show status
out = {true, CommandId::UnknownEscCommand, "", 0};
return true;
}
// Backspace in ncurses can be KEY_BACKSPACE or 127
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
k_prefix = false;
out = {true, CommandId::Backspace, "", 0};
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::Backspace, "", 0};
return true;
}
// k_prefix handled earlier
// If collecting universal arg, handle digits and optional leading '-'
if (uarg_active && uarg_collecting) {
if (ch >= '0' && ch <= '9') {
int d = ch - '0';
if (!uarg_had_digits) {
// First digit overrides any 4^n default
uarg_value = 0;
uarg_had_digits = true;
}
if (uarg_value < 100000000) {
// avoid overflow
uarg_value = uarg_value * 10 + d;
}
// Update raw text and status to reflect collected digits
uarg_text.push_back(static_cast<char>(ch));
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true;
}
if (ch == '-' && !uarg_had_digits && !uarg_negative) {
uarg_negative = true;
// Show leading minus in status
uarg_text = "-";
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true;
}
// Any other key will be processed as a command; fall through to mapping below
// but mark collection finished so we apply the argument to that command
uarg_collecting = false;
// If universal argument is active at editor level and we get a digit, feed it
if (ed && ed->UArg() != 0 && ch >= '0' && ch <= '9') {
ed->UArgDigit(ch - '0');
out.hasCommand = false; // keep collecting, no command yet
return true;
}
// Printable ASCII
@@ -300,29 +284,11 @@ TerminalInputHandler::decode_(MappedInput &out)
bool consumed = map_key_to_command(
ch,
k_prefix_, esc_meta_,
uarg_active_, uarg_collecting_, uarg_negative_, uarg_had_digits_, uarg_value_, uarg_text_,
k_ctrl_pending_,
ed_,
out);
if (!consumed)
return false;
// If a command was produced and a universal argument is active, attach it and clear state
if (out.hasCommand && uarg_active_ && out.id != CommandId::UArgStatus) {
int count = 0;
if (!uarg_had_digits_ && !uarg_negative_) {
// No explicit digits: use current value (default 4 or 4^n)
count = (uarg_value_ > 0) ? uarg_value_ : 4;
} else {
count = uarg_value_;
if (uarg_negative_)
count = -count;
}
out.count = count;
// Clear state
uarg_active_ = false;
uarg_collecting_ = false;
uarg_negative_ = false;
uarg_had_digits_ = false;
uarg_value_ = 0;
}
return true;
}

View File

@@ -11,6 +11,13 @@ public:
~TerminalInputHandler() override;
void Attach(Editor *ed) override
{
ed_ = ed;
}
bool Poll(MappedInput &out) override;
private:
@@ -18,14 +25,10 @@ private:
// ke-style prefix state
bool k_prefix_ = false; // true after C-k until next key or ESC
// Optional control qualifier inside k-prefix (e.g., user typed literal 'C' or '^')
bool k_ctrl_pending_ = false;
// Simple meta (ESC) state for ESC sequences like ESC b/f
bool esc_meta_ = false;
// Universal argument (C-u) state
bool uarg_active_ = false; // an argument is pending for the next command
bool uarg_collecting_ = false; // collecting digits / '-' right now
bool uarg_negative_ = false; // whether a leading '-' was supplied
bool uarg_had_digits_ = false; // whether any digits were supplied
int uarg_value_ = 0; // current absolute value (>=0)
std::string uarg_text_; // raw digits/minus typed for status display
Editor *ed_ = nullptr; // attached editor for uarg handling
};

View File

@@ -111,19 +111,44 @@ TerminalRenderer::Draw(Editor &ed)
std::string line = static_cast<std::string>(lines[li]);
src_i = 0;
render_col = 0;
// Syntax highlighting: fetch per-line spans
const kte::LineHighlight *lh_ptr = nullptr;
// Syntax highlighting: fetch per-line spans (sanitized copy)
std::vector<kte::HighlightSpan> sane_spans;
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
HasHighlighter()) {
lh_ptr = &buf->Highlighter()->GetLine(
kte::LineHighlight lh_val = buf->Highlighter()->GetLine(
*buf, static_cast<int>(li), buf->Version());
// Sanitize defensively: clamp to [0, line.size()], ensure end>=start, drop empties
const std::size_t line_len = line.size();
sane_spans.reserve(lh_val.spans.size());
for (const auto &sp: lh_val.spans) {
int s_raw = sp.col_start;
int e_raw = sp.col_end;
if (e_raw < s_raw)
std::swap(e_raw, s_raw);
std::size_t s = static_cast<std::size_t>(std::max(
0, std::min(s_raw, static_cast<int>(line_len))));
std::size_t e = static_cast<std::size_t>(std::max(
static_cast<int>(s),
std::min(e_raw, static_cast<int>(line_len))));
if (e <= s)
continue;
sane_spans.push_back(kte::HighlightSpan{
static_cast<int>(s), static_cast<int>(e), sp.kind
});
}
std::sort(sane_spans.begin(), sane_spans.end(),
[](const kte::HighlightSpan &a, const kte::HighlightSpan &b) {
return a.col_start < b.col_start;
});
}
auto token_at = [&](std::size_t src_index) -> kte::TokenKind {
if (!lh_ptr)
if (sane_spans.empty())
return kte::TokenKind::Default;
for (const auto &sp: lh_ptr->spans) {
if (static_cast<int>(src_index) >= sp.col_start && static_cast<int>(
src_index) < sp.col_end)
int si = static_cast<int>(src_index);
for (const auto &sp: sane_spans) {
if (si < sp.col_start)
break;
if (si >= sp.col_start && si < sp.col_end)
return sp.kind;
}
return kte::TokenKind::Default;
@@ -132,23 +157,23 @@ TerminalRenderer::Draw(Editor &ed)
// Map to simple attributes; search highlight uses A_STANDOUT which takes precedence below
attrset(A_NORMAL);
switch (k) {
case kte::TokenKind::Keyword:
case kte::TokenKind::Type:
case kte::TokenKind::Constant:
case kte::TokenKind::Function:
attron(A_BOLD);
break;
case kte::TokenKind::Comment:
attron(A_DIM);
break;
case kte::TokenKind::String:
case kte::TokenKind::Char:
case kte::TokenKind::Number:
// standout a bit using A_UNDERLINE if available
attron(A_UNDERLINE);
break;
default:
break;
case kte::TokenKind::Keyword:
case kte::TokenKind::Type:
case kte::TokenKind::Constant:
case kte::TokenKind::Function:
attron(A_BOLD);
break;
case kte::TokenKind::Comment:
attron(A_DIM);
break;
case kte::TokenKind::String:
case kte::TokenKind::Char:
case kte::TokenKind::Number:
// standout a bit using A_UNDERLINE if available
attron(A_UNDERLINE);
break;
default:
break;
}
};
while (written < cols) {

View File

@@ -2,27 +2,43 @@
## Overview
`TestFrontend` is a headless implementation of the `Frontend` interface designed to facilitate programmatic testing of editor features. It allows you to queue commands and text input manually, execute them step-by-step, and inspect the editor/buffer state.
`TestFrontend` is a headless implementation of the `Frontend` interface
designed to facilitate programmatic testing of editor features. It
allows you to queue commands and text input manually, execute them
step-by-step, and inspect the editor/buffer state.
## Components
### TestInputHandler
A programmable input handler that uses a queue-based system:
- `QueueCommand(CommandId id, const std::string &arg = "", int count = 0)` - Queue a specific command
- `QueueText(const std::string &text)` - Queue text for insertion (character by character)
-
`QueueCommand(CommandId id, const std::string &arg = "", int count = 0)` -
Queue a specific command
- `QueueText(const std::string &text)` - Queue text for insertion (
character by character)
- `Poll(MappedInput &out)` - Returns queued commands one at a time
- `IsEmpty()` - Check if the input queue is empty
### TestRenderer
A minimal no-op renderer for testing:
- `Draw(Editor &ed)` - No-op implementation, just increments draw counter
- `Draw(Editor &ed)` - No-op implementation, just increments draw
counter
- `GetDrawCount()` - Returns the number of times Draw() was called
- `ResetDrawCount()` - Resets the draw counter
### TestFrontend
The main frontend class that integrates TestInputHandler and TestRenderer:
- `Init(Editor &ed)` - Initializes the frontend (sets editor dimensions to 24x80)
- `Step(Editor &ed, bool &running)` - Processes one command from the queue and renders
The main frontend class that integrates TestInputHandler and
TestRenderer:
- `Init(Editor &ed)` - Initializes the frontend (sets editor dimensions
to 24x80)
- `Step(Editor &ed, bool &running)` - Processes one command from the
queue and renders
- `Shutdown()` - Cleanup (no-op for TestFrontend)
- `Input()` - Access the TestInputHandler
- `Renderer()` - Access the TestRenderer
@@ -75,31 +91,55 @@ int main() {
## Key Features
1. **Programmable Input**: Queue any sequence of commands or text programmatically
1. **Programmable Input**: Queue any sequence of commands or text
programmatically
2. **Step-by-Step Execution**: Run the editor one command at a time
3. **State Inspection**: Access and verify editor/buffer state between commands
4. **No UI Dependencies**: Headless operation, no terminal or GUI required
5. **Integration Testing**: Test command sequences, undo/redo, multi-line editing, etc.
3. **State Inspection**: Access and verify editor/buffer state between
commands
4. **No UI Dependencies**: Headless operation, no terminal or GUI
required
5. **Integration Testing**: Test command sequences, undo/redo,
multi-line editing, etc.
## Available Commands
All commands from `CommandId` enum can be queued, including:
- `CommandId::InsertText` - Insert text (use `QueueText()` helper)
- `CommandId::Newline` - Insert newline
- `CommandId::Backspace` - Delete character before cursor
- `CommandId::Backspace` - Delete character before cursor
- `CommandId::DeleteChar` - Delete character at cursor
- `CommandId::MoveLeft`, `MoveRight`, `MoveUp`, `MoveDown` - Cursor movement
- `CommandId::MoveLeft`, `MoveRight`, `MoveUp`, `MoveDown` - Cursor
movement
- `CommandId::Undo`, `CommandId::Redo` - Undo/redo operations
- `CommandId::Save`, `CommandId::Quit` - File operations
- And many more (see Command.h)
## Integration
TestFrontend is built into both `kte` and `kge` executables as part of the common source files. You can create standalone test programs by linking against the same source files and ncurses.
TestFrontend is built into both `kte` and `kge` executables as part of
the common source files. You can create standalone test programs by
linking against the same source files and ncurses.
## Notes
- Always call `InstallDefaultCommands()` before using any commands
- Buffer must be initialized (via `OpenFile()` or `AddBuffer()`) before queuing edit commands
- Buffer must be initialized (via `OpenFile()` or `AddBuffer()`) before
queuing edit commands
- Undo/redo requires the buffer to have an UndoSystem attached
- The test frontend sets editor dimensions to 24x80 by default
## Highlighter stress harness
For renderer/highlighter race testing without a UI, `kte` provides a
lightweight stress mode:
```
kte --stress-highlighter=5
```
This runs a short synthetic workload (5 seconds by default) that edits
and scrolls a buffer while
exercising `HighlighterEngine::PrefetchViewport` and `GetLine`
concurrently. Use Debug builds with
AddressSanitizer enabled for best effect.

144
docs/plans/swap-files.md Normal file
View File

@@ -0,0 +1,144 @@
Swap files for kte — design plan
================================
Goals
-----
- Preserve user work across crashes, power failures, and OS kills.
- Keep the editor responsive; avoid blocking the UI on disk I/O.
- Bound recovery time and swap size.
- Favor simple, robust primitives that work well on POSIX and macOS;
keep Windows feasibility in mind.
Model overview
--------------
Per open buffer, maintain a sidecar swap journal next to the file:
- Path: `.<basename>.kte.swp` in the same directory as the file (for
unnamed/unsaved buffers, use a persession temp dir like
`$TMPDIR/kte/` with a random UUID).
- Format: appendonly journal of editing operations with periodic
checkpoints.
- Crash safety: only append, fsync as per policy; checkpoint via
writetotemp + fsync + atomic rename.
File format (v1)
----------------
Header (fixed 64 bytes):
- Magic: `KTE_SWP\0` (8 bytes)
- Version: 1 (u32)
- Flags: bitset (u32) — e.g., compression, checksums, endian.
- Created time (u64)
- Host info hash (u64) — optional, for telemetry/debug.
- File identity: hash of canonical path (u64) and original file
size+mtime (u64+u64) at start.
- Reserved/padding.
Records (stream after header):
- Each record: [type u8][len u24][payload][crc32 u32]
- Types:
- `CHKPT` — full snapshot checkpoint of entire buffer content and
minimal metadata (cursor pos, filetype). Payload optionally
compressed. Written occasionally to cap replay time.
- `INS` — insert at (row, col) text bytes (text may contain
newlines). Encoded with varints.
- `DEL` — delete length at (row, col). If spanning lines, semantics
defined as in Buffer::delete_text.
- `SPLIT`, `JOIN` — explicit structural ops (optional; can be
expressed via INS/DEL).
- `META` — update metadata (e.g., filetype, encoding hints).
Durability policy
-----------------
Configurable knobs (sane defaults in parentheses):
- Timebased flush: group edits and flush every 150300 ms (200 ms).
- Operation count flush: after N ops (200).
- Idle flush: on 500 ms idle lull, flush immediately.
- Checkpoint cadence: after M KB of journal (5122048 KB) or T seconds (
30120 s), whichever first.
- fsync policy:
- `always`: fsync every flush (safest, slowest).
- `grouped` (default): fsync at most every 12 s or on
idle/blur/quit.
- `never`: rely on OS flush (fastest, riskier).
- On POSIX, prefer `fdatasync` when available; fall back to `fsync`.
Performance & threading
-----------------------
- Background writer thread per editor instance (shared) with a bounded
MPSC queue of perbuffer records.
- Each Buffer has a small inmemory journal buffer; UI thread enqueues
ops (nonblocking) and may coalesce adjacent inserts/deletes.
- Writer batchwrites records to the swap file, computes CRCs, and
decides checkpoint boundaries.
- Backpressure: if the queue grows beyond a high watermark, signal the
UI to start coalescing more aggressively and slow enqueue (never block
hard editing path; at worst drop optional `META`).
Recovery flow
-------------
On opening a file:
1. Detect swap sidecar `.<basename>.kte.swp`.
2. Validate header, iterate records verifying CRCs.
3. Compare recorded original file identity against actual file; if
mismatch, warn user but allow recovery (content wins).
4. Reconstruct buffer: start from the last good `CHKPT` (if any), then
replay subsequent ops. If trailing partial record encountered (EOF
midrecord), truncate at last good offset.
5. Present a choice: Recover (load recovered buffer; keep the swap file
until user saves) or Discard (delete swap file and open clean file).
Stability & corruption mitigation
---------------------------------
- Appendonly with perrecord CRC32 guards against torn writes.
- Atomic checkpoint rotation: write `.<basename>.kte.swp.tmp`, fsync,
then rename over old `.swp`.
- Size caps: rotate or compact when `.swp` exceeds a threshold (e.g.,
64128 MB). Compaction creates a fresh file with a single checkpoint.
- Lowdiskspace behavior: on write failures, surface a nonmodal
warning and temporarily fall back to inmemory only; retry
opportunistically.
Security considerations
-----------------------
- Swap files mirror buffer content, which may be sensitive. Options:
- Configurable location (same dir vs. `$XDG_STATE_HOME/kte/swap`).
- Optional perfile encryption (future work) using OS keychain.
- Ensure permissions are 0600.
Interoperability & UX
---------------------
- Use a distinctive extension `.kte.swp` to avoid conflicts with other
editors.
- Status bar indicator when swap is active; commands to purge/compact.
- On save: do not delete swap immediately; keep until the buffer is
clean and idle for a short grace period (allows undo of accidental
external changes).
Implementation plan (staged)
----------------------------
1. Minimal journal writer (appendonly INS/DEL) with grouped fsync;
single pereditor writer thread.
2. Reader/recovery path with CRC validation and replay.
3. Checkpoints + atomic rotation; compaction path.
4. Config surface and UI prompts; telemetry counters.
5. Optional compression and advanced coalescing.
Defaults balancing performance and stability
-------------------------------------------
- Grouped flush with fsync every ~1 s or on idle/quit.
- Checkpoint every 1 MB or 60 s.
- Bounded queue and batch writes to minimize syscalls.
- Immediate flush on critical events (buffer close, app quit, power
source change on laptops if detectable).

View File

@@ -4,67 +4,118 @@ Syntax highlighting in kte
Overview
--------
kte provides lightweight syntax highlighting with a pluggable highlighter interface. The initial implementation targets C/C++ and focuses on speed and responsiveness.
kte provides lightweight syntax highlighting with a pluggable
highlighter interface. The initial implementation targets C/C++ and
focuses on speed and responsiveness.
Core types
----------
- `TokenKind` — token categories (keywords, types, strings, comments, numbers, preprocessor, operators, punctuation, identifiers, whitespace, etc.).
- `HighlightSpan` — a half-open column range `[col_start, col_end)` with a `TokenKind`.
- `LineHighlight` — a vector of `HighlightSpan` and the buffer `version` used to compute it.
- `TokenKind` — token categories (keywords, types, strings, comments,
numbers, preprocessor, operators, punctuation, identifiers,
whitespace, etc.).
- `HighlightSpan` — a half-open column range `[col_start, col_end)` with
a `TokenKind`.
- `LineHighlight` — a vector of `HighlightSpan` and the buffer `version`
used to compute it.
Engine and caching
------------------
- `HighlighterEngine` maintains a per-line cache of `LineHighlight` keyed by row and buffer version.
- Cache invalidation occurs when the buffer version changes or when the buffer calls `InvalidateFrom(row)`, which clears cached lines and line states from `row` downward.
- The engine supports both stateless and stateful highlighters. For stateful highlighters, it memoizes a simple per-line state and computes lines sequentially when necessary.
- `HighlighterEngine` maintains a per-line cache of `LineHighlight`
keyed by row and buffer version.
- Cache invalidation occurs when the buffer version changes or when the
buffer calls `InvalidateFrom(row)`, which clears cached lines and line
states from `row` downward.
- The engine supports both stateless and stateful highlighters. For
stateful highlighters, it memoizes a simple per-line state and
computes lines sequentially when necessary.
Stateful highlighters
---------------------
- `LanguageHighlighter` is the base interface for stateless per-line tokenization.
- `StatefulHighlighter` extends it with a `LineState` and the method `HighlightLineStateful(buf, row, prev_state, out)`.
- The engine detects `StatefulHighlighter` via dynamic_cast and feeds each line the previous lines state, caching the resulting state per line.
- `LanguageHighlighter` is the base interface for stateless per-line
tokenization.
- `StatefulHighlighter` extends it with a `LineState` and the method
`HighlightLineStateful(buf, row, prev_state, out)`.
- The engine detects `StatefulHighlighter` via dynamic_cast and feeds
each line the previous lines state, caching the resulting state per
line.
C/C++ highlighter
-----------------
- `CppHighlighter` implements `StatefulHighlighter`.
- Stateless constructs: line comments `//`, strings `"..."`, chars `'...'`, numbers, identifiers (keywords/types), preprocessor at beginning of line after leading whitespace, operators/punctuation, and whitespace.
- Stateless constructs: line comments `//`, strings `"..."`, chars
`'...'`, numbers, identifiers (keywords/types), preprocessor at
beginning of line after leading whitespace, operators/punctuation, and
whitespace.
- Stateful constructs (v2):
- Multi-line block comments `/* ... */` — the state records whether the next line continues a comment.
- Raw strings `R"delim(... )delim"` — the state tracks whether we are inside a raw string and its delimiter `delim` until the closing sequence appears.
- Multi-line block comments `/* ... */` — the state records whether
the next line continues a comment.
- Raw strings `R"delim(... )delim"` — the state tracks whether we
are inside a raw string and its delimiter `delim` until the
closing sequence appears.
Limitations and TODOs
---------------------
- Raw string detection is intentionally simple and does not handle all corner cases of the C++ standard.
- Preprocessor handling is line-based; continuation lines with `\\` are not yet tracked.
- No semantic analysis; identifiers are classified via small keyword/type sets.
- Additional languages (JSON, Markdown, Shell, Python, Go, Rust, Lisp, …) are planned.
- Terminal color mapping is conservative to support 8/16-color terminals. Rich color-pair themes can be added later.
- Raw string detection is intentionally simple and does not handle all
corner cases of the C++ standard.
- Preprocessor handling is line-based; continuation lines with `\\` are
not yet tracked.
- No semantic analysis; identifiers are classified via small
keyword/type sets.
- Additional languages (JSON, Markdown, Shell, Python, Go, Rust,
Lisp, …) are planned.
- Terminal color mapping is conservative to support 8/16-color
terminals. Rich color-pair themes can be added later.
Renderer integration
--------------------
- Terminal and GUI renderers request line spans via `Highlighter()->GetLine(buf, row, buf.Version())`.
- Search highlight and cursor overlays take precedence over syntax colors.
- Terminal and GUI renderers request line spans via
`Highlighter()->GetLine(buf, row, buf.Version())`.
- Search highlight and cursor overlays take precedence over syntax
colors.
Renderer-side robustness
------------------------
- Renderers defensively sanitize `HighlightSpan` data before use to
ensure stability even if a highlighter misbehaves:
- Clamp `col_start/col_end` to the line length and ensure
`end >= start`.
- Drop empty/invalid spans and sort by start.
- Clip drawing to the horizontally visible region and the
tab-expanded line length.
- The highlighter engine returns `LineHighlight` by value to avoid
cross-thread lifetime issues; renderers operate on a local copy for
each frame.
Extensibility (Phase 4)
-----------------------
- Public registration API: external code can register custom highlighters by filetype.
- Use `HighlighterRegistry::Register("mylang", []{ return std::make_unique<MyHighlighter>(); });`
- Registered factories are preferred over built-ins for the same filetype key.
- Filetype keys are normalized via `HighlighterRegistry::Normalize()`.
- Optional Tree-sitter adapter: disabled by default to keep dependencies minimal.
- Enable with CMake option `-DKTE_ENABLE_TREESITTER=ON` and provide
`-DTREESITTER_INCLUDE_DIR=...` and `-DTREESITTER_LIBRARY=...` if needed.
- Register a Tree-sitter-backed highlighter for a language (example assumes you link a grammar):
```c++
extern "C" const TSLanguage* tree_sitter_c();
kte::HighlighterRegistry::RegisterTreeSitter("c", &tree_sitter_c);
```
- Current adapter is a stub scaffold; it compiles and integrates cleanly when enabled, but
intentionally emits no spans until Tree-sitter node-to-token mapping is implemented.
- Public registration API: external code can register custom
highlighters by filetype.
- Use
`HighlighterRegistry::Register("mylang", []{ return std::make_unique<MyHighlighter>(); });`
- Registered factories are preferred over built-ins for the same
filetype key.
- Filetype keys are normalized via
`HighlighterRegistry::Normalize()`.
- Optional Tree-sitter adapter: disabled by default to keep dependencies
minimal.
- Enable with CMake option `-DKTE_ENABLE_TREESITTER=ON` and provide
`-DTREESITTER_INCLUDE_DIR=...` and `-DTREESITTER_LIBRARY=...` if
needed.
- Register a Tree-sitter-backed highlighter for a language (example
assumes you link a grammar):
```c++
extern "C" const TSLanguage* tree_sitter_c();
kte::HighlighterRegistry::RegisterTreeSitter("c", &tree_sitter_c);
```
- Current adapter is a stub scaffold; it compiles and integrates
cleanly when enabled, but
intentionally emits no spans until Tree-sitter node-to-token
mapping is implemented.

121
main.cc
View File

@@ -6,6 +6,10 @@
#include <iostream>
#include <limits>
#include <memory>
#include <algorithm>
#include <chrono>
#include <random>
#include <thread>
#include <signal.h>
#include <string>
#include <unistd.h>
@@ -34,7 +38,71 @@ PrintUsage(const char *prog)
<< " -g, --gui Use GUI frontend (if built)\n"
<< " -t, --term Use terminal (ncurses) frontend [default]\n"
<< " -h, --help Show this help and exit\n"
<< " -V, --version Show version and exit\n";
<< " -V, --version Show version and exit\n"
<< " --stress-highlighter[=SECONDS] Run a short highlighter stress harness (debug aid)\n";
}
static int
RunStressHighlighter(unsigned seconds)
{
// Build a synthetic buffer with code-like content
Buffer buf;
buf.SetFiletype("cpp");
buf.SetSyntaxEnabled(true);
buf.EnsureHighlighter();
// Seed with many lines
const int N = 1200;
for (int i = 0; i < N; ++i) {
std::string line = "int v" + std::to_string(i) + " = " + std::to_string(i) + "; // line\n";
buf.insert_row(i, line);
}
// Remove the extra last empty row if any artifacts
// Simulate a viewport of ~60 rows
const int viewport_rows = 60;
const auto start_ts = std::chrono::steady_clock::now();
std::mt19937 rng{1234567u};
std::uniform_int_distribution<int> row_d(0, N - 1);
std::uniform_int_distribution<int> op_d(0, 2);
std::uniform_int_distribution<int> sleep_d(0, 2);
// Loop performing edits and highlighter queries while background worker runs
while (std::chrono::duration_cast<std::chrono::seconds>(std::chrono::steady_clock::now() - start_ts).count() <
seconds) {
int fr = row_d(rng);
if (fr + viewport_rows >= N)
fr = std::max(0, N - viewport_rows - 1);
buf.SetOffsets(static_cast<std::size_t>(fr), 0);
if (buf.Highlighter()) {
buf.Highlighter()->PrefetchViewport(buf, fr, viewport_rows, buf.Version());
}
// Do a few direct GetLine calls over the viewport to shake the caches
if (buf.Highlighter()) {
for (int r = 0; r < viewport_rows; r += 7) {
(void) buf.Highlighter()->GetLine(buf, fr + r, buf.Version());
}
}
// Random simple edit
int op = op_d(rng);
int r = row_d(rng);
if (op == 0) {
buf.insert_text(r, 0, "/*X*/");
buf.SetDirty(true);
} else if (op == 1) {
buf.delete_text(r, 0, 1);
buf.SetDirty(true);
} else {
// split and join occasionally
buf.split_line(r, 0);
buf.join_lines(std::min(r + 1, N - 1));
buf.SetDirty(true);
}
// tiny sleep to allow background thread to interleave
if (sleep_d(rng) == 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
}
return 0;
}
@@ -54,29 +122,42 @@ main(int argc, const char *argv[])
{"term", no_argument, nullptr, 't'},
{"help", no_argument, nullptr, 'h'},
{"version", no_argument, nullptr, 'V'},
{"stress-highlighter", optional_argument, nullptr, 1000},
{nullptr, 0, nullptr, 0}
};
int opt;
int long_index = 0;
int long_index = 0;
unsigned stress_seconds = 0;
while ((opt = getopt_long(argc, const_cast<char *const *>(argv), "gthV", long_opts, &long_index)) != -1) {
switch (opt) {
case 'g':
req_gui = true;
break;
case 't':
req_term = true;
break;
case 'h':
show_help = true;
break;
case 'V':
show_version = true;
break;
case '?':
default:
PrintUsage(argv[0]);
return 2;
case 'g':
req_gui = true;
break;
case 't':
req_term = true;
break;
case 'h':
show_help = true;
break;
case 'V':
show_version = true;
break;
case 1000: {
stress_seconds = 5; // default
if (optarg && *optarg) {
try {
unsigned v = static_cast<unsigned>(std::stoul(optarg));
if (v > 0 && v < 36000)
stress_seconds = v;
} catch (...) {}
}
break;
}
case '?':
default:
PrintUsage(argv[0]);
return 2;
}
}
@@ -89,6 +170,10 @@ main(int argc, const char *argv[])
return 0;
}
if (stress_seconds > 0) {
return RunStressHighlighter(stress_seconds);
}
// Determine frontend
#if !defined(KTE_BUILD_GUI)
if (req_gui) {

View File

@@ -34,22 +34,24 @@ HighlighterEngine::SetHighlighter(std::unique_ptr<LanguageHighlighter> hl)
}
const LineHighlight &
LineHighlight
HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const
{
std::unique_lock<std::mutex> lock(mtx_);
auto it = cache_.find(row);
if (it != cache_.end() && it->second.version == buf_version) {
return it->second;
return it->second; // return by value (copy)
}
// Prepare destination slot to reuse its capacity and avoid allocations
LineHighlight &slot = cache_[row];
slot.version = buf_version;
slot.spans.clear();
// We'll compute into a local result to avoid exposing references to cache
LineHighlight result;
result.version = buf_version;
result.spans.clear();
if (!hl_) {
return slot;
// Cache empty result and return it
cache_[row] = result;
return result;
}
// Copy shared_ptr-like raw pointer for use outside critical sections
@@ -58,10 +60,12 @@ HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version
if (!is_stateful) {
// Stateless fast path: we can release the lock while computing to reduce contention
auto &out = slot.spans;
lock.unlock();
hl_ptr->HighlightLine(buf, row, out);
return cache_.at(row);
hl_ptr->HighlightLine(buf, row, result.spans);
// Update cache and return
std::lock_guard<std::mutex> gl(mtx_);
cache_[row] = result;
return result;
}
// Stateful path: we need to walk from a known previous state. Keep lock while consulting caches,
@@ -96,7 +100,7 @@ HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version
StatefulHighlighter::LineState cur_state = prev_state;
for (int r = start_row + 1; r <= row; ++r) {
std::vector<HighlightSpan> tmp;
std::vector<HighlightSpan> &out = (r == row) ? slot.spans : tmp;
std::vector<HighlightSpan> &out = (r == row) ? result.spans : tmp;
auto next_state = stateful->HighlightLineStateful(buf, r, cur_state, out);
// Update state cache for r
std::lock_guard<std::mutex> gl(mtx_);
@@ -107,9 +111,10 @@ HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version
cur_state = next_state;
}
// Return reference under lock to ensure slot's address stability in map
// Store in cache and return by value
lock.lock();
return cache_.at(row);
cache_[row] = result;
return result;
}
@@ -164,11 +169,15 @@ HighlighterEngine::worker_loop() const
// Copy locals then release lock while computing
lock.unlock();
if (req.buf) {
int start = std::max(0, req.start_row);
int end = std::max(start, req.end_row);
int start = std::max(0, req.start_row);
int end = std::max(start, req.end_row);
int skip_f = std::min(req.skip_first, req.skip_last);
int skip_l = std::max(req.skip_first, req.skip_last);
for (int r = start; r <= end; ++r) {
// Re-check version staleness quickly by peeking cache version; not strictly necessary
// Compute line; GetLine is thread-safe
// Avoid touching rows that the foreground just computed/drew.
if (r >= skip_f && r <= skip_l)
continue;
// Compute line; GetLine is thread-safe and will refresh caches.
(void) this->GetLine(*req.buf, r, req.version);
}
}
@@ -201,11 +210,13 @@ HighlighterEngine::PrefetchViewport(const Buffer &buf, int first_row, int row_co
int warm_end = std::min(max_rows - 1, end + warm_margin);
{
std::lock_guard<std::mutex> lock(mtx_);
pending_.buf = &buf;
pending_.version = buf_version;
pending_.start_row = warm_start;
pending_.end_row = warm_end;
has_request_ = true;
pending_.buf = &buf;
pending_.version = buf_version;
pending_.start_row = warm_start;
pending_.end_row = warm_end;
pending_.skip_first = start;
pending_.skip_last = end;
has_request_ = true;
}
ensure_worker_started();
cv_.notify_one();

View File

@@ -25,8 +25,9 @@ public:
void SetHighlighter(std::unique_ptr<LanguageHighlighter> hl);
// Retrieve highlights for a given line and buffer version.
// Returns a copy to avoid lifetime issues across threads/renderers.
// If cache is stale, recompute using the current highlighter.
const LineHighlight &GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const;
LineHighlight GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const;
// Invalidate cached lines from row (inclusive)
void InvalidateFrom(int row);
@@ -70,6 +71,10 @@ private:
std::uint64_t version{0};
int start_row{0};
int end_row{0}; // inclusive
// Visible rows to skip touching in the background (inclusive range).
// These are computed synchronously by PrefetchViewport.
int skip_first{0};
int skip_last{-1};
};
mutable std::condition_variable cv_;