diff --git a/Buffer.h b/Buffer.h index 4e65236..0c5fc36 100644 --- a/Buffer.h +++ b/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 highlighter_; + // Non-owning pointer to swap recorder managed by Editor/SwapManager + kte::SwapRecorder *swap_rec_ = nullptr; }; \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 213a6bf..8edb625 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/Command.cc b/Command.cc index f903860..1aef5bf 100644 --- a/Command.cc +++ b/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 &dst) { + // Tokenize by spaces + std::vector 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(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 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(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(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(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(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(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(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(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(para_start), rows.begin() + static_cast(para_end + 1)); rows.insert(rows.begin() + static_cast(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; } diff --git a/Command.h b/Command.h index 93d55d9..3c1ac46 100644 --- a/Command.h +++ b/Command.h @@ -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; }; diff --git a/Editor.cc b/Editor.cc index 8644dd2..87b4362 100644 --- a/Editor.cc +++ b/Editor.cc @@ -8,7 +8,10 @@ #include "syntax/NullHighlighter.h" -Editor::Editor() = default; +Editor::Editor() +{ + swap_ = std::make_unique(); +} 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; +} \ No newline at end of file diff --git a/Editor.h b/Editor.h index d788c98..57d1215 100644 --- a/Editor.h +++ b/Editor.h @@ -8,6 +8,7 @@ #include #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 buffers_; std::size_t curbuf_ = 0; // index into buffers_ + // Swap journaling manager (lifetime = editor) + std::unique_ptr swap_; + // Kill ring (Emacs-like) std::vector 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; diff --git a/GUIFrontend.cc b/GUIFrontend.cc index 01f69bb..eeb386f 100644 --- a/GUIFrontend.cc +++ b/GUIFrontend.cc @@ -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); diff --git a/GUIInputHandler.cc b/GUIInputHandler.cc index 689084f..f2aaf76 100644 --- a/GUIInputHandler.cc +++ b/GUIInputHandler.cc @@ -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(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(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(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('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(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(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(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(ascii_key) @@ -487,7 +486,9 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e) mapped ? static_cast(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(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 lk(mu_); q_.push(mi); } diff --git a/GUIInputHandler.h b/GUIInputHandler.h index da8d5fb..084e3fd 100644 --- a/GUIInputHandler.h +++ b/GUIInputHandler.h @@ -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 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 }; \ No newline at end of file diff --git a/GUIRenderer.cc b/GUIRenderer.cc index a0dacf7..fd54814 100644 --- a/GUIRenderer.cc +++ b/GUIRenderer.cc @@ -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(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 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::max( + 0, std::min(s_raw, static_cast(line_len)))); + std::size_t e = static_cast(std::max( + static_cast(s), std::min(e_raw, static_cast(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::max(0, sp.col_start))); - std::size_t rx_e = src_to_rx_full( - static_cast(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(draw_end, expanded.size()); + continue; // fully right of expanded text + std::size_t draw_end = std::min(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(screen_x) * space_w, line_pos.y); ImGui::GetWindowDrawList()->AddText( diff --git a/InputHandler.h b/InputHandler.h index 6bfb50e..a166aa1 100644 --- a/InputHandler.h +++ b/InputHandler.h @@ -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; diff --git a/ROADMAP.md b/ROADMAP.md index 3dae596..bdea0a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/Swap.cc b/Swap.cc new file mode 100644 index 0000000..73a69db --- /dev/null +++ b/Swap.cc @@ -0,0 +1,412 @@ +#include "Swap.h" +#include "Buffer.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +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 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 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-.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(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::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 &out, std::uint64_t v) +{ + while (v >= 0x80) { + out.push_back(static_cast(v) | 0x80); + v >>= 7; + } + out.push_back(static_cast(v)); +} + + +void +SwapManager::put_u24(std::uint8_t dst[3], std::uint32_t v) +{ + dst[0] = static_cast((v >> 16) & 0xFF); + dst[1] = static_cast((v >> 8) & 0xFF); + dst[2] = static_cast(v & 0xFF); +} + + +void +SwapManager::enqueue(Pending &&p) +{ + { + std::lock_guard 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 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::max(0, row))); + put_varu64(p.payload, static_cast(std::max(0, col))); + put_varu64(p.payload, static_cast(text.size())); + p.payload.insert(p.payload.end(), reinterpret_cast(text.data()), + reinterpret_cast(text.data()) + text.size()); + enqueue(std::move(p)); +} + + +void +SwapManager::RecordDelete(Buffer &buf, int row, int col, std::size_t len) +{ + { + std::lock_guard lg(mtx_); + if (journals_[&buf].suspended) + return; + } + Pending p; + p.buf = &buf; + p.type = SwapRecType::DEL; + put_varu64(p.payload, static_cast(std::max(0, row))); + put_varu64(p.payload, static_cast(std::max(0, col))); + put_varu64(p.payload, static_cast(len)); + enqueue(std::move(p)); +} + + +void +SwapManager::RecordSplit(Buffer &buf, int row, int col) +{ + { + std::lock_guard lg(mtx_); + if (journals_[&buf].suspended) + return; + } + Pending p; + p.buf = &buf; + p.type = SwapRecType::SPLIT; + put_varu64(p.payload, static_cast(std::max(0, row))); + put_varu64(p.payload, static_cast(std::max(0, col))); + enqueue(std::move(p)); +} + + +void +SwapManager::RecordJoin(Buffer &buf, int row) +{ + { + std::lock_guard lg(mtx_); + if (journals_[&buf].suspended) + return; + } + Pending p; + p.buf = &buf; + p.type = SwapRecType::JOIN; + put_varu64(p.payload, static_cast(std::max(0, row))); + enqueue(std::move(p)); +} + + +void +SwapManager::writer_loop() +{ + while (running_.load()) { + std::vector batch; + { + std::unique_lock 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(p.payload.size())); + + std::uint8_t head[4]; + head[0] = static_cast(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 \ No newline at end of file diff --git a/Swap.h b/Swap.h new file mode 100644 index 0000000..194b106 --- /dev/null +++ b/Swap.h @@ -0,0 +1,145 @@ +// Swap.h - swap journal (crash recovery) writer/manager for kte +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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 &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 journals_; + std::mutex mtx_; + std::condition_variable cv_; + std::vector queue_; + std::atomic running_{false}; + std::thread worker_; +}; +} // namespace kte \ No newline at end of file diff --git a/TerminalFrontend.cc b/TerminalFrontend.cc index db6c037..f60bb60 100644 --- a/TerminalFrontend.cc +++ b/TerminalFrontend.cc @@ -55,6 +55,8 @@ TerminalFrontend::Init(Editor &ed) prev_r_ = r; prev_c_ = c; ed.SetDimensions(static_cast(r), static_cast(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(); -} +} \ No newline at end of file diff --git a/TerminalInputHandler.cc b/TerminalInputHandler.cc index 6d83da6..0280f2f 100644 --- a/TerminalInputHandler.cc +++ b/TerminalInputHandler.cc @@ -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(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(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; } diff --git a/TerminalInputHandler.h b/TerminalInputHandler.h index bbc5096..7215202 100644 --- a/TerminalInputHandler.h +++ b/TerminalInputHandler.h @@ -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 }; \ No newline at end of file diff --git a/TerminalRenderer.cc b/TerminalRenderer.cc index 16a39a6..0dee769 100644 --- a/TerminalRenderer.cc +++ b/TerminalRenderer.cc @@ -111,19 +111,44 @@ TerminalRenderer::Draw(Editor &ed) std::string line = static_cast(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 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(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::max( + 0, std::min(s_raw, static_cast(line_len)))); + std::size_t e = static_cast(std::max( + static_cast(s), + std::min(e_raw, static_cast(line_len)))); + if (e <= s) + continue; + sane_spans.push_back(kte::HighlightSpan{ + static_cast(s), static_cast(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(src_index) >= sp.col_start && static_cast( - src_index) < sp.col_end) + int si = static_cast(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) { diff --git a/docs/TestFrontend.md b/docs/TestFrontend.md index dace794..a4ff40b 100644 --- a/docs/TestFrontend.md +++ b/docs/TestFrontend.md @@ -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. diff --git a/docs/plans/swap-files.md b/docs/plans/swap-files.md new file mode 100644 index 0000000..1d70c12 --- /dev/null +++ b/docs/plans/swap-files.md @@ -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: `..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 `..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 `..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). diff --git a/docs/syntax.md b/docs/syntax.md index bc1a98b..0e317d7 100644 --- a/docs/syntax.md +++ b/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(); });` - - 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(); });` + - 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. diff --git a/main.cc b/main.cc index fee3bed..ee3775d 100644 --- a/main.cc +++ b/main.cc @@ -6,6 +6,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include @@ -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 row_d(0, N - 1); + std::uniform_int_distribution op_d(0, 2); + std::uniform_int_distribution sleep_d(0, 2); + + // Loop performing edits and highlighter queries while background worker runs + while (std::chrono::duration_cast(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(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(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(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) { diff --git a/syntax/HighlighterEngine.cc b/syntax/HighlighterEngine.cc index 636269f..e245f6a 100644 --- a/syntax/HighlighterEngine.cc +++ b/syntax/HighlighterEngine.cc @@ -34,22 +34,24 @@ HighlighterEngine::SetHighlighter(std::unique_ptr hl) } -const LineHighlight & +LineHighlight HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const { std::unique_lock 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 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 tmp; - std::vector &out = (r == row) ? slot.spans : tmp; + std::vector &out = (r == row) ? result.spans : tmp; auto next_state = stateful->HighlightLineStateful(buf, r, cur_state, out); // Update state cache for r std::lock_guard 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 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(); diff --git a/syntax/HighlighterEngine.h b/syntax/HighlighterEngine.h index 654036d..642bdd7 100644 --- a/syntax/HighlighterEngine.h +++ b/syntax/HighlighterEngine.h @@ -25,8 +25,9 @@ public: void SetHighlighter(std::unique_ptr 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_;