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:
14
Buffer.h
14
Buffer.h
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
473
Command.cc
473
Command.cc
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
|
||||
85
Editor.cc
85
Editor.cc
@@ -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;
|
||||
}
|
||||
70
Editor.h
70
Editor.h
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
412
Swap.cc
Normal 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
145
Swap.h
Normal 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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
144
docs/plans/swap-files.md
Normal 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 per‑session temp dir like
|
||||
`$TMPDIR/kte/` with a random UUID).
|
||||
- Format: append‑only journal of editing operations with periodic
|
||||
checkpoints.
|
||||
- Crash safety: only append, fsync as per policy; checkpoint via
|
||||
write‑to‑temp + 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):
|
||||
|
||||
- Time‑based flush: group edits and flush every 150–300 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 (512–2048 KB) or T seconds (
|
||||
30–120 s), whichever first.
|
||||
- fsync policy:
|
||||
- `always`: fsync every flush (safest, slowest).
|
||||
- `grouped` (default): fsync at most every 1–2 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 per‑buffer records.
|
||||
- Each Buffer has a small in‑memory journal buffer; UI thread enqueues
|
||||
ops (non‑blocking) and may coalesce adjacent inserts/deletes.
|
||||
- Writer batch‑writes 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
|
||||
mid‑record), 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
|
||||
---------------------------------
|
||||
|
||||
- Append‑only with per‑record 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.,
|
||||
64–128 MB). Compaction creates a fresh file with a single checkpoint.
|
||||
- Low‑disk‑space behavior: on write failures, surface a non‑modal
|
||||
warning and temporarily fall back to in‑memory 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 per‑file 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 (append‑only INS/DEL) with grouped fsync;
|
||||
single per‑editor 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).
|
||||
119
docs/syntax.md
119
docs/syntax.md
@@ -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 line’s 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 line’s 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
121
main.cc
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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_;
|
||||
|
||||
Reference in New Issue
Block a user