Files
kte/TerminalInputHandler.cc
Kyle Isom 3126a5e523 Fix undo correctness, crash-recovery, and syntax highlighting bugs from full codebase review
Undo/redo:
- cmd_newline recorded the undo node's position after the cursor moved past
  the split point, so undoing Enter joined the wrong pair of lines.
- Backspace/Delete line-joins recorded UndoType::Newline, whose apply()
  semantics are inverted for a join (forward=split); add UndoType::JoinLines
  with correct forward/backward behavior.
- Regex replace-all, indent/unindent region, kill-to-eol, kill-line,
  kill-region, and delete-word-prev/next mutated the buffer via the
  no-undo-recording raw APIs; they now record undo, grouped atomically via a
  shared UndoGroupGuard.
- Plain-text replace-all with an empty replacement advanced the scan
  position one character too far, skipping adjacent/overlapping matches.

Crash recovery / memory safety:
- Buffer's move ctor/assignment never carried over swap_rec_,
  on_disk_identity_, or the visual-line-mode fields, so the crash-recovery
  journal silently stopped tracking a buffer whenever Editor's
  std::vector<Buffer> reallocated or shifted (e.g. opening/closing files).
  Added SwapManager::Rehome() plus Flush()-before-mutate in
  Editor::AddBuffer/CloseBuffer so the journal's Buffer* key and the
  background writer thread never reference a stale address.
- UndoTree leaked its entire node graph (including all edit text) on every
  buffer close/reload; added ~UndoTree() to free it.
- main.cc didn't restore the terminal if an exception escaped the run loop;
  added an RAII guard around Frontend::Shutdown().

Input handling:
- Terminal frontend used getch() instead of get_wch(), silently dropping
  non-ASCII keyboard input despite linking wide-char ncurses.
- KKeymap's C-k lookup switches on an already-lowercased key, making
  `case 'E'` unreachable dead code; C-k Shift-E silently aliased to C-k e.
- ImGui file picker wasn't modal: keystrokes typed while it was open still
  reached the buffer underneath as edit commands, and Escape didn't close it.
- Gated ImGuiInputHandler's unconditional fprintf/fflush diagnostics behind
  an IMGUI_IH_DEBUG macro, matching QtInputHandler's existing convention.

Syntax highlighting:
- Go/Rust/SQL highlighters weren't stateful, so multi-line /* */ comments
  mis-highlighted as code starting on the second line.
- Python's triple-quoted-string handler didn't re-scan the remainder of a
  line after a string closed mid-line, missing a same-line reopen.
- HighlighterEngine's state_last_contig_ field was declared but unused,
  forcing an O(n) scan of state_cache_ on every stateful lookup; wired it up
  as a real fast path and fixed InvalidateFrom() to keep it in sync.
- kge's :syntax off was silently undone the next frame because
  apply_syntax_to_buffer() re-applied the config default unconditionally
  every frame; added a user-override flag mirroring the existing
  edit-mode-detection guard.

Added regression tests for all of the above (test_undo.cc, test_kkeymap.cc,
test_command_semantics.cc, test_search_replace_flow.cc, and a new
test_syntax_highlighting.cc). Full suite (163 tests) passes, including under
AddressSanitizer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 14:19:15 -07:00

374 lines
10 KiB
C++

#include <cstdio>
#include <climits>
#include <cwchar>
#include <ncurses.h>
#include "TerminalInputHandler.h"
#include "KKeymap.h"
#include "Command.h"
#include "Editor.h"
namespace {
constexpr int
CTRL(char c)
{
return c & 0x1F;
}
// Encode a single wide character in the process locale's multibyte encoding
// (UTF-8, given main.cc's setlocale(LC_ALL, "")). Returns false if the
// codepoint can't be represented, leaving `out` untouched.
bool
wchar_to_mb(wchar_t wc, std::string &out)
{
std::mbstate_t state{};
char buf[MB_LEN_MAX];
std::size_t n = std::wcrtomb(buf, wc, &state);
if (n == static_cast<std::size_t>(-1))
return false;
out.assign(buf, n);
return true;
}
}
TerminalInputHandler::TerminalInputHandler() = default;
TerminalInputHandler::~TerminalInputHandler() = default;
static bool
map_key_to_command(const int ch,
const bool is_keycode,
bool &k_prefix,
bool &esc_meta,
bool &k_ctrl_pending,
bool &mouse_selecting,
Editor *ed,
MappedInput &out)
{
// Handle special keys from ncurses. These are only meaningful when
// get_wch() reported KEY_CODE_YES: a regular (possibly non-ASCII) wide
// character can numerically collide with a KEY_* constant otherwise
// (e.g. U+0107 'ć' equals KEY_BACKSPACE's value), which would wrongly
// swallow it as a special key instead of inserting it.
// These keys exit k-prefix mode if active (user pressed C-k then a special key).
switch (is_keycode ? ch : -1) {
case KEY_ENTER:
// Some terminals send KEY_ENTER distinct from '\n'/'\r'
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::Newline, "", 0};
return true;
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;
}
#endif
#ifdef BUTTON5_PRESSED
if (ev.bstate & (BUTTON5_PRESSED | BUTTON5_RELEASED | BUTTON5_CLICKED)) {
out = {true, CommandId::ScrollDown, "", 0};
return true;
}
#endif
// React to left button click/press
if (ed && (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED |
REPORT_MOUSE_POSITION))) {
char buf[64];
// Use screen coordinates; command handler will translate via offsets
std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x);
const bool pressed = (ev.bstate & (BUTTON1_PRESSED | BUTTON1_CLICKED)) != 0;
const bool released = (ev.bstate & BUTTON1_RELEASED) != 0;
const bool moved = (ev.bstate & REPORT_MOUSE_POSITION) != 0;
if (pressed) {
mouse_selecting = true;
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
// We don't set the mark on simple click anymore in ncurses either,
// to be consistent. ncurses doesn't easily support double-click
// or drag-threshold in a platform-independent way here,
// but we can at least only set mark on MOVED.
out.hasCommand = false;
return true;
}
if (mouse_selecting && moved) {
if (Buffer *b = ed->CurrentBuffer()) {
if (!b->MarkSet()) {
// Set mark at CURRENT cursor position (which is where we were before this move)
b->SetMark(b->Curx(), b->Cury());
}
}
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
out.hasCommand = false;
return true;
}
if (released) {
mouse_selecting = false;
out.hasCommand = false;
return true;
}
}
}
// No actionable mouse event
out.hasCommand = false;
return true;
}
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;
}
// Control keys
if (ch == CTRL('K')) {
// C-k prefix
k_prefix = true;
k_ctrl_pending = false;
out = {true, CommandId::KPrefix, "", 0};
return true;
}
if (ch == CTRL('G')) {
// cancel
k_prefix = false;
k_ctrl_pending = false;
esc_meta = false;
// cancel universal argument as well
if (ed)
ed->UArgClear();
out = {true, CommandId::Refresh, "", 0};
return true;
}
// Universal argument: C-u
if (ch == CTRL('U')) {
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";
out.count = 0;
return true;
}
// Generic Control-chord lookup (after handling special prefixes/cancel)
// 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) {
// 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' or '^' as a qualifier, keep k-prefix and set pending
// Note: Do NOT treat lowercase 'c' as a qualifier, since 'c' is a valid C-k command (BufferClose).
if (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;
bool pass_ctrl = (ctrl || k_ctrl_pending);
k_ctrl_pending = false;
if (KLookupKCommand(ascii_key, pass_ctrl, id)) {
out = {true, id, "", 0};
if (ed)
ed->SetStatus(""); // clear "C-k _" hint after suffix
} else {
int shown = KLowerAscii(ascii_key);
char c = (shown >= 0x20 && shown <= 0x7e) ? static_cast<char>(shown) : '?';
std::string arg(1, c);
out = {true, CommandId::UnknownKCommand, arg, 0};
if (ed)
ed->SetStatus(""); // clear hint; handler will set unknown status
}
return true;
}
if (ch >= 1 && ch <= 26) {
int ascii_key = 'a' + (ch - 1);
CommandId id;
if (KLookupCtrlCommand(ascii_key, id)) {
out = {true, id, "", 0};
return true;
}
}
// Enter
if (ch == '\n' || ch == '\r') {
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
if (esc_meta) {
esc_meta = false;
int ascii_key = ch;
// Handle ESC + BACKSPACE (meta-backspace, Alt-Backspace)
if ((is_keycode && 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';
}
CommandId id;
if (KLookupEscCommand(ascii_key, id)) {
out = {true, id, "", 0};
return true;
}
// 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 ((is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H')) {
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::Backspace, "", 0};
return true;
}
// k_prefix handled earlier
// 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 character: ASCII, or (for a regular decoded wide character,
// not a keycode) any other printable Unicode codepoint - e.g. accented
// Latin, Cyrillic, CJK, etc.
if (!is_keycode && ch >= 0x20 && ch != 0x7F) {
std::string mb;
if (ch <= 0x7E) {
mb.assign(1, static_cast<char>(ch));
} else if (!wchar_to_mb(static_cast<wchar_t>(ch), mb)) {
out.hasCommand = false;
return true;
}
out.hasCommand = true;
out.id = CommandId::InsertText;
out.arg = mb;
out.count = 0;
return true;
}
out.hasCommand = false;
return true; // consumed a key
}
bool
TerminalInputHandler::decode_(MappedInput &out)
{
wint_t wch;
int ret = get_wch(&wch);
if (ret == ERR) {
return false; // no input
}
const bool is_keycode = (ret == KEY_CODE_YES);
const int ch = static_cast<int>(wch);
bool consumed = map_key_to_command(
ch, is_keycode,
k_prefix_, esc_meta_,
k_ctrl_pending_,
mouse_selecting_,
ed_,
out);
if (!consumed)
return false;
return true;
}
bool
TerminalInputHandler::Poll(MappedInput &out)
{
out = {};
return decode_(out) && out.hasCommand;
}