Commit Graph
84 Commits
Author SHA1 Message Date
Claude 7039c14507 Command.cc: share the word scans, search reset and completion prefix
Word motion (M-b/M-f) and word deletion (M-Backspace/M-d) each carried a
copy of the same backward and forward scans; word_back and word_forward
now serve both. Deleting N words backwards built the kill text by
prepending each piece (quadratic in N); the pieces are joined once. The
five-call search reset appeared nine times (end_search), and the
completion longest-common-prefix loop twice (keep_common_prefix).

A new test pins word motion and deletion with counts across line ends
and punctuation; it gives the same results on the code before this
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 12:19:59 -07:00
Claude 927accbca4 One place to pick a buffer's highlighter
Filetype detection (extension or shebang) was written out three times
(two open paths in Editor and the ImGui frontend's per-frame check) and
"highlighter for this filetype, else the null one" five times (Buffer's
copy constructor and assignment, Editor::SwitchTo, :set filetype).
Buffer::ApplyDetectedFiletype and InstallFiletypeHighlighter replace
them. The InvalidateFrom(0) calls after SetHighlighter are dropped:
SetHighlighter already clears the caches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 12:19:59 -07:00
Claude 323e2377fe Renderers: linear per-row measuring in ImGui; one tab width; Qt drawing fixes
ImGui measured every span, highlight and the cursor from column 0 of the
line (and rescanned the line for each column conversion), which is
quadratic in line length on minified files. Columns now come from one
table per line and pixel offsets from the nearest position already
measured. Output is pixel-identical to before (compared under Xvfb, with
syntax colouring, tabs, UTF-8 and search highlights).

kte::kTabWidth replaces the literal 8s, and Qt's 4: Qt expanded tabs to
4 columns while the command layer scrolled by 8, misplacing the cursor
and horizontal scroll on lines with tabs. MeasureChar and
DisplayColumns (TermWidth.h) are the command layer's column model, now
shared, and Qt draws by it instead of one column per byte, which
shifted everything after a multi-byte character.

Qt also:
- drew operators in the dark-mode background colour, so they vanished;
- computed the editor's column count three ways (different glyph,
  padding and status-row accounting) from painting, and positioned text
  with a rounded cell width, so the cursor drifted off the glyphs on long
  lines. One TextLayout now serves painting, resizing, font changes and
  wheel scrolling, with a fractional cell width; runs are drawn only
  where visible;
- drew each non-ASCII character at its cell, since a fallback-font glyph
  wider than a cell pushed the rest of the run out of place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 12:19:59 -07:00
Claude 7dae9cc305 Load into the piece table's original store; save and copy without materializing
A loaded file's bytes are adopted as the piece table's original storage
instead of being appended to the add buffer: the first keystroke no
longer reallocates and copies the whole file (140 ms at 200 MB), and the
read buffer is not copied again.

Save writes the pieces in order instead of materializing the buffer (a
second full copy that then stayed resident). Buffer::Bytes() assembles a
copy from the pieces and replaces four hand-rolled copies (swap
snapshots, recovery comparison, Command.cc's per-row join, the tests'
accessor). A single-piece table (an unedited file) is viewed in place;
GetLineView serves a line from its piece when it lies within one, so it
no longer copies the whole buffer after every edit. Code that needed the
whole text contiguous says so with ContentView().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 12:19:59 -07:00
Claude 306a337516 PieceTable: newline test from the line index, memchr rebuild; remove dead code
GetLine and LineColToByteOffset fetched each line's last byte (a piece
lookup, an allocation and a cache write) to see whether it was a newline;
the line index already says so: every line but the last ends at one.
LineColToByteOffset runs on every insert. RebuildLineIndex scans with
memchr instead of byte by byte.

Removed, having no callers: OptimizedSearch (built only into its own
test), UndoNodePool.h, SwapManager::RecordSplit/RecordJoin (replay still
reads SPLIT/JOIN), PieceTable::Reserve/Capacity and its capacity
constructor, Buffer's write-only render column, Editor's mode, no-kill,
dirtyex and repeatable flags and the UCount getter, and
RetryPolicy::Conservative.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 12:19:59 -07:00
Claude 6bc3a6e0af Share search-match highlighting across renderers; engine owns its stack policy
SearchHighlight compiles the search pattern once per frame and yields each
line's match ranges; the terminal, ImGui and Qt renderers had three copies
of this, and Qt compiled a std::regex for every visible line on every
frame. Qt now uses kte::Regex like the others.

kte::Regex::RunGuarded replaces Command.cc's run_regex_work, which chose
the large stack by comparing engine names. ForEachRegexMatch had no
callers left and is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 12:19:59 -07:00
Claude 41e28a8ab7 Fix review findings in the regex engine, search count and journal v2
- PCRE2: compile with the ANYCRLF newline convention and DOLLAR_ENDONLY,
  as ECMAScript reads '.' and '$'. With PCRE2's default (LF only) '.'
  matched the CR of a CRLF line, so regex replace-all deleted CRs.
- Replacement $0 is the whole match, two digits are read greedily and a
  group the pattern lacks expands to nothing, as std::regex_replace
  (libstdc++) does; these cases are now compared in the tests.
- Plain search visits overlapping matches going forward too, and counts
  them, as the backward search found them ('aa' in 'aaa' read 2/1).
- The recovery prompt's replayed content is used on 'y' only if the
  journal is unchanged since; otherwise it is replayed again.
- A version 1 journal continued after recovery is rewritten as a
  compacted version 2 journal when it needs a chunked checkpoint,
  instead of receiving records an older kte would skip. (The version is
  read through a separate descriptor: the journal fd is write-only.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude e782d24e32 Regex: optional PCRE2 engine behind kte::Regex
RegexEngine.h/.cc wrap the regex engine for search, regex replace-all and
the terminal and ImGui renderers' match highlighting:

- With KTE_USE_PCRE2 (default ON; used when pkg-config finds libpcre2-8,
  linked as -lpcre2-8) patterns are compiled by PCRE2 with its JIT and
  matched under a match limit: catastrophic backtracking stops with a
  status note instead of hanging the editor, and matching does not
  recurse on the C stack (no large-stack thread; the per-keystroke and
  per-frame line limit is 1 MiB instead of 2,000 bytes). Otherwise
  std::regex is used as before.
- Replacement strings follow std::regex_replace (ECMAScript as libstdc++
  reads it) with either engine: the PCRE2 path expands $&, $n, $`, $',
  $$ itself and steps over empty matches the same way.
- Regex search walks lines over the materialized text instead of calling
  GetLineView per row. The terminal renderer compiles the pattern once
  per frame (it compiled it for every row).
- Only one checkpoint per journal is queued at a time: whole-file edits
  in a row on a large buffer each queued a full copy of the buffer.
- default.nix and the Dockerfile add pcre2 and pkg-config.

On 26 MB: regex keystroke 1.6 -> 0.14 ms, next 62 -> 6 ms, replace-all
929 -> 438 ms; a rarely matching regex over 105 MB: 1.55 s -> 0.10 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude fc15a58a2b Search: stop at the first match from the cursor; capped count
run_search() replaces collecting every match in the buffer (and building
the full list again for every next/previous):

- Plain search finds forward from the search origin (then the current
  match) in the materialized text, or backwards through 1 MiB blocks
  searched forward (string_view::rfind scans a byte at a time: 511 ms
  per previous-match on 105 MB, now 6 ms). Regex search walks lines
  from the position with early exit.
- The i/N count stops at 1,000 matches, and for regex after 4 MiB; the
  status says 'N+' when capped.
- The first match is the first at or after the cursor (it was the first
  in the buffer). Extending the query keeps the current match while it
  still matches. Left/Right also step through matches in the regex
  replace prompt; Up/Down in a regex prompt use the regex.

On a 26 MB file: plain keystroke 30 ms -> 0.1 ms, next 24 -> 3 ms, regex
keystroke 434 -> 1.6 ms, regex next 420 -> 66 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude bba7d34539 Large files: bound highlighter cache, scan search once, row ranges as one edit
- HighlighterEngine caches spans only for rows near the requested row
  and trims the cache around it past 16k entries; per-row end states are
  still kept to resume lexing. Jumping to the end of a 78 MB C++ file
  cached every row's spans (854 MB RSS, now 197 MB), and the first edit
  near the top spent 270 ms freeing them.
- Plain and regex search read lines as views into the materialized
  buffer instead of Buffer::Rows(), which built (and kept) a string per
  line, O(lines x pieces) after edits. Plain search scans the whole text
  once and tracks rows by newline.
- transform_rows() applies a per-row rewrite of a row range as one edit
  (replace_rows_text). Indent/unindent region and visual-line insert,
  backspace, delete, newline and yank used one piece-table edit per row,
  each linear in the buffer: 1.1 s per keystroke with 5,000 rows selected
  in a 26 MB file (now 4 ms), and minutes to indent a large region.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude b9dffd8645 Reflow: per-line CR handling; do not wrap before a marker-like word
- The CRLF handling applied only when every line of the paragraph ended
  in CR, so the last paragraph of a CRLF file without a final newline
  (and mixed CRLF/LF paragraphs) still had CRs joined into the middle of
  lines. Each line's CR is now stripped; if any line had one, every new
  line but the last ends in CR, and the last keeps what the paragraph's
  last line had.
- Wrapping never starts a line with a word that reads as a list marker
  ('-', '*', '+', '1.', '2)'); the next reflow turned such a paragraph
  into a list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude 790fbc90e1 Region and paragraph commands: indent, reflow, counted deletes
- Indent-region skips empty lines. With the whole buffer selected, the
  empty row after the final newline became a tab-only last line.
- Reflow no longer wraps before a line's first word: a first word longer
  than the width (a URL) got a prefix-only line before it, a blank line
  or a bullet marker split from its text, and another on every reflow.
- A whitespace-only line (spaces, tabs, a CR) separates paragraphs for
  reflow; such paragraphs were merged.
- Reflow of CRLF lines strips the CR before wrapping and restores it on
  every output line; the CRs ended up in the middle of joined lines.
- A counted backspace or delete-char is one undo step even when it joins
  lines.
- The C++ highlighter stops looking for a raw-string '(' after 16
  delimiter characters; scanning the whole line at every R" was
  quadratic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude 868ee3fe2f Terminal: decode UTF-8 input in kte; visual-line edits keep characters whole
- TerminalInputHandler reads bytes with getch() and assembles UTF-8
  itself, waiting up to a second for a character's continuation bytes.
  The frontend reads the rest of an input batch with a zero timeout, and
  get_wch() then gave up on a character whose bytes had not all arrived;
  ncurses discarded the rest as an invalid sequence, so text pasted over
  a slow link silently lost characters and what followed them (typing
  'ab\xc3', then '\xa9cd' 50 ms later saved 'ab').
- Visual-line insert, delete, backspace and newline carried the cursor's
  byte column to the other selected lines, splitting multibyte
  characters there (the saved file became invalid UTF-8). The column is
  now carried as a character count.
- Visual-line delete-char left the cursor on the last selected line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude 8cb688c574 Regex replace prompt: say that Enter searches skipped long lines
The note added for search-as-you-type told the regex replace prompt to
use Left/Right, which only step through matches in the search prompts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude 853416f65a Journal insert_row as one record; keep the cursor in the buffer; fix ENABLE_ASAN
- Buffer::insert_row recorded its text and newline as two swap records
  after inserting both. A checkpoint triggered by the first (1 MiB of
  edits, or the checkpoint timer) snapshotted the newline already, and
  the second added it again on replay: recovery after undoing C-k C-k
  silently gained a blank line. It is now one record.
- Visual-line yank placed the cursor relative to its row assuming that
  row was inside the selection; after C-k a or Enter it was not, and the
  cursor ended past the end of a line or the buffer, where typed text was
  recorded for undo at a position it was not inserted at and could not
  be undone. The formula uses the nearest selected row, and run_handler
  now clamps the cursor to the buffer before and after every command.
- ENABLE_ASAN added -fsanitize=address to CMAKE_C_FLAGS only; the
  project is C++, so nothing was instrumented. Add it to
  CMAKE_CXX_FLAGS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude b1b0b3de57 Fix round-four regressions: save fallback, replace memory, regex skip
- Save's in-place fallbacks (no temp file; no hard-link copy) now apply
  only when the directory refuses a new name (EACCES, EPERM,
  ENAMETOOLONG). On ENOSPC, EDQUOT or EIO the in-place write truncated
  the file and then failed, leaving it corrupt; now the save fails and
  the file is untouched (checked on a full tmpfs).
- replace_rows_text/replace_buffer_text delete and insert only the span
  between the old and new text's common prefix and suffix (kept on
  UTF-8 boundaries). Replace-all recorded the whole buffer twice in the
  undo tree per run.
- Regex search skips long lines only while typing (limit lowered to
  2,000 bytes, as for renderer highlighting; '.*q' on a 20 KB line took
  12 s), reports how many were skipped, and next/previous match search
  every line. Previously matches on long lines could not be found.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude 93901928da Force a UTF-8 ctype, stable buffer ids, redraw after deferred opens
- main: when the environment's locale is not UTF-8 (LANG unset, C/POSIX),
  switch LC_CTYPE to a UTF-8 locale so ncurses delivers and draws
  multibyte characters instead of dropping them.
- Buffer::Id(): a process-unique id that survives moves and is fresh for
  copies. The terminal renderer's skip and cursor caches and the reload
  confirmation now key on it rather than the buffer's address, which is
  reused after a buffer is closed.
- The reload confirmation is cancelled by any other command, like the
  quit confirmation.
- TerminalFrontend redraws when a deferred open starts a prompt or sets a
  status (e.g. the swap recovery question), not only when a buffer opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude 91ceb9bb01 Perf: replace-all, yank and repeat counts as single edits
Each piece-table edit costs time linear in the buffer (piece lookup,
line-index update), so operations issuing one edit per match, line or
repetition were quadratic:

- Plain and regex replace-all build the new text in one pass and apply
  it as one delete + one insert (one undo group, one chunked journal
  record). 12 MB with 229k matches: minutes -> 0.14 s.
- Yank inserts the whole kill in one edit (it split and re-trimmed line
  by line): 200k lines in 0.02 s.
- InsertText with a repeat count inserts the repeated text once; the
  total is capped at 256 MiB (also across visual-line broadcast).
- Incremental regex search skips lines over 20,000 bytes and renderer
  match highlighting over 10,000 (the previous change raised the render
  limit to 1 MB): std::regex can be quadratic in line length even for
  ordinary patterns such as '.*q', uninterruptibly, and these run on
  every keystroke or frame. Explicit regex replace still processes all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude 4dbefcc41d Undo/dirty correctness from the stress-testing review
- Redoing an insert and undoing a delete put the cursor at col + size
  on the start row even when the text spans lines, i.e. past the end of
  the line. Later edits and their undo records then went to the wrong
  place (a join recorded at column 2 of 'a' re-split the text wrongly)
  and delete-word-prev read past the line (heap-buffer-overflow under
  ASan). The cursor now goes after the text's last newline, and undo and
  redo clamp it to the buffer.
- A recovered buffer stays modified in every undo state
  (mark_base_unsaved): one edit and its undo made it look clean, and
  closing it then discarded the recovered text and its journal.
- Save-and-quit records the saved undo state.
- Visual-line selections are clamped to the buffer when read; the
  visual newline counts the cursor's own line only if it was selected.
  A stale selection put the cursor rows past the end, where typed text
  could not be undone.
- The yank size cap accounts for visual-line yanks (once per line).
- After an exception escapes a command, open undo groups are closed so
  later edits do not join them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude 61a7338f88 Terminal loop, virtual buffers, yank bound
- kte refuses to start when stdin is not a terminal: piped bytes were
  executed as editor commands (C-k s plus a path writes a file), and
  after EOF the loop spun at full CPU with no way to quit.
- The terminal frontend handles all pending input before drawing (a
  paste used to cost a frame and a full redraw per character) and
  redraws only after input, a resize or deferred opens, else once a
  second. The renderer caches, per row and buffer version, where the
  horizontally scrolled part of a line starts and the cursor's render
  column, instead of rescanning the line every frame. Idle CPU with the
  cursor at the end of a 20 MB line: one full core -> ~1%.
- Buffers with a virtual name (+HELP+) are not journaled and saving one
  asks for a file name; it used to write ./+HELP+ and journal under
  that path.
- Yank with a repeat count is capped at 256 MiB in total instead of
  reserving count x size bytes (C-u 1000000 C-y of a large kill).
- Remove an overload clang reports as unused (-Werror on macOS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude 6c2727789e Guard data-loss paths: quit, reload, close, recovery answers, save-as
- Quit checks every buffer for unsaved changes and names them; it used
  to look only at the current buffer and exit, dropping the rest (an
  unnamed buffer's text was unrecoverable). Save-and-quit does not quit
  while other buffers are dirty.
- Reload of a dirty buffer needs a second C-k l with no edit in between
  (it discarded edits, undo history and the journal at once), and a file
  deleted on disk is not 'reloaded' into an empty buffer.
- At the destructive y/n prompts ("Recover swap edits?" and "Save
  changes to X?"), only an explicit 'n' discards; Enter or any other key
  cancels. Keys typed while the startup recovery prompt appeared used to
  delete the crash journal. SwapRecoveryPrompt_Discard now answers 'n'.
- Saving from the close prompt refuses to overwrite a file changed on
  disk, like save.
- Save-as onto a file open in another buffer is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude fb50a02f6a Swap ownership, fsync race, RowsView memory, renderer regex stack
- A session never unlinks a journal it does not own: Detach, ResetJournal
  and NotifyFilenameChanged skip removal when this session was locked
  out or another session holds the lock, and PruneSwapDir skips held
  journals. A second session used to delete the first session's live
  journal on save or close.
- The writer thread's periodic fsync now runs under mtx_. It fsynced fds
  that the main thread could close concurrently (or that had been
  reused); an in-flight fsync also kept a just-closed journal's flock
  alive, so the owner's own cleanup saw its journal as held.
- Compaction locks the compacted file before renaming it into place and
  keeps using it, so the journal is never unlocked in between; a failed
  lock on the truncating reopen marks the session locked out.
- Unnamed-buffer journals include the pid, so sessions no longer share
  (and delete) each other's unnamed-N.swp.
- RowsView keeps only the previous generation of cached rows: keeping
  all of them made C-u 2000 M-d on a 1 MiB line use ~2 GB.
- Renderers run regex match highlighting through ForEachRegexMatch,
  which uses the large-stack worker for lines over 512 bytes; some
  patterns overflowed the main stack well under the previous 10,000
  byte limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude e279945136 Crash safety: non-regular files, filesystem errors, command exceptions
- OpenFromFile opens non-blocking, fstat()s and accepts regular files
  only. A directory made tellg() report a huge size and bad_alloc exit
  the editor (reachable by Tab-completing a directory in the open
  prompt); a FIFO blocked open() forever with SIGINT ignored; a device
  node opened as an empty, file-backed buffer that saving then replaced
  with a regular file. Files are read to EOF in chunks; a file too large
  to load is reported, not fatal.
- Saving refuses to replace an existing non-regular file.
- Exceptions escaping a command handler are reported in the status line
  and error log instead of propagating to main() and exiting, which lost
  every unsaved buffer (unnamed buffers' journals are never recovered).
- Commands use non-throwing filesystem queries for existence checks,
  the current directory and Tab completion (ENAMETOOLONG, EACCES or a
  deleted working directory used to throw).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude 7e16680a26 Terminal: draw control characters as ^X; bound GUI config values
- C0 controls and DEL were passed to ncurses, which acted on them: a
  carriage return moved to column 0 and the line padding then erased
  the text, so every line of a CRLF file drew blank, and a backspace
  ate the previous character. They are now drawn in caret notation
  (^M, ^H, ^?) and counted as two cells. C1 controls (and stray bytes
  0x80-0x9F, which decode to them; U+009B is CSI) are drawn as '?'.
- The width rule lives in TermWidth.h and is shared by the renderer's
  drawing loop, its cursor placement and the command layer's
  horizontal scrolling and mouse mapping, which also disagreed on NUL
  and invalid bytes.
- GUI config: columns/rows must be 1..1000 and the font size a finite
  4..200 (TOML 'size = inf' and huge values were accepted, leading to
  UB in float-to-int conversion and int overflow downstream).

Verified in tmux: a CRLF file now shows 'first line^M' where the
baseline showed a blank line.

Audit findings B9 (and the CR/BS case from the follow-up review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude ec8159ea2e Save: don't lie about fsync; keep file metadata; confirm overwrites
- fsync is never retried. After a writeback error Linux may mark the
  pages clean, so a retried fsync could succeed although the data was
  lost, and the rename then replaced a good file with a bad one.
- New files get 0666 & ~umask instead of mkstemp's 0600. Replaced files
  keep their mode (file-type bits masked) and, best effort, their owner
  and group.
- Saving through a symlink writes the link's target instead of
  replacing the link with a regular file (SaveAs did not resolve it).
- A file with several hard links is rewritten in place, so the other
  names see the new content (as vim does by default).
- Save-as (prompt and ':save-as') asks before replacing any existing
  file other than the buffer's own; it used to ask only for unnamed
  buffers. Save-and-quit refuses to overwrite a file changed on disk,
  like save. The first save of a new file records the saved undo state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude 061e836fa4 Swap/recovery: second-pass fixes from the follow-up review
- Recovery works when another named buffer is current. On that path
  OpenFile's NotifyFilenameChanged deleted the journal (old path ==
  new path) that was about to be replayed; the swap was lost and the
  file opened without the edits (pre-existing).
- After recovering from a journal with a torn final record, the torn
  bytes are truncated before the session appends to it (ReplayFile now
  reports the valid prefix length). A journal that recovers nothing is
  removed instead of appended to.
- Reload restarts the journal; recovery used to replay the edits the
  reload discarded.
- Opening a file that is already open switches to its buffer (in
  OpenFile and before the recovery check). Two buffers for one file
  interleaved records in one journal, and closing either unlinked it.
- Swap file names escape '!' and '%', so distinct paths no longer
  share a journal ('/x/a!b' vs '/x/a/b'). Swap files for paths that
  contain those characters get new names.
- The gap flag is cleared when the journal restarts, and a gap that
  cannot be closed (buffer too large to checkpoint) is reported once.
- crc32's table is initialised thread-safely; writer-thread error
  reports identify the buffer by journal path instead of racing on
  Buffer::Filename(); Close() no longer retries on EINTR (the fd is
  already released and may have been reused).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude 9f3da72765 Regex: don't overflow the stack on long lines
libstdc++'s std::regex matcher recurses about once per matched
character, so '.*', 'a+' or '(a|b)*' on a line of a few tens of
thousands of characters (minified JS/JSON) overflowed the 8 MiB stack
and crashed the editor from the regex search prompt or regex replace.

Regex search and regex replace now run their matching on a worker
thread with a 1 GiB reserved stack (RunWithLargeStack; the caller
waits, so nothing is shared concurrently). Renderers skip regex match
highlighting on lines over 10,000 bytes. Catastrophic backtracking
(e.g. (a*)*b) is still possible: std::regex has no time limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude 812cde090f Commands: second-pass fixes from the follow-up review
- Motion and delete commands read rows through RowsView (on-demand,
  version-cached) instead of Buffer::Rows(), which rebuilt every line of
  the file after each edit. On a 500k-line buffer after an edit:
  backspace/delete/motions 80-90 ms -> ~0.1 ms; C-u 3000 C-d 273 s ->
  0.25 s. Whole-buffer search scans keep Rows() (cached between edits).
- While a prompt is open only prompt-aware and view-only commands run.
  Others edited the buffer behind the prompt (bypassing the read-only
  check) or switched/closed buffers under a pending confirmation, which
  then acted on the wrong buffer. The ':' command path now applies the
  read-only check too.
- Pasting into a prompt (ImGui) flattens line breaks to spaces instead
  of emitting Newline, which accepted the prompt and sent the rest of
  the paste to the buffer (or ran replace-all).
- The mark region clamps both ends; a stale mark past the end of the
  buffer is treated as end of buffer. This fixes kill-region deleting
  the wrong text and leaving the cursor past end-of-line.
- Kill-line with a count stops at end of buffer instead of killing
  lines above the cursor (regression from the D3 change).
- Undo groups nest, so SmartNewline in visual-line mode is one step.
- Word motions and word deletion treat non-ASCII bytes as word
  characters and so never split a UTF-8 character.
- The universal-argument count saturates at 1,000,000 instead of
  overflowing int.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude 2a1c49a0f0 Commands: UTF-8 aware editing, motion and horizontal scrolling
- Backspace and delete-char remove a whole UTF-8 character (they
  removed one byte, leaving an invalid lead byte in the file), and
  record the full character for undo.
- Left/right step over whole characters; up/down snap the column to a
  character boundary and clamp the row, which also removes an unsigned
  underflow in move-down when the cursor was past the last row.
- Horizontal scroll offsets are computed in display cells, the way the
  terminal renderer draws (tabs, wide characters), instead of bytes, so
  long non-ASCII lines no longer scroll to blank with the cursor off
  screen. The mouse column mapping uses the same measure.

Audit findings B5, B6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude a69664037d Commands: keep line structure intact at end of buffer; undo visual newline
- Regex replace-all, reflow and kill-region replaced or deleted whole
  rows, which added or left a stray newline when the text ended on a
  last line without one. They now edit the byte range in place.
- Regex replace-all no longer matches the empty position after a final
  newline. Before, a zero-width pattern such as ^ matched it, added a
  line, and matched the new empty last row again: the editor hung.
- Kill-line on the last row kills an unterminated line with the newline
  before it, and does nothing on the empty row after a final newline
  (it used to record an undo step that added a line).
- The visual-line newline records its splits as one undo step, and
  places the cursor correctly for a repeat count.
- Buffer::delete_text computes its byte range directly instead of
  materializing every line it spans.

Audit findings D3, D4, D5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
Claude ae5193eb5f Commands: fix smart-newline undo, clamp cursor, group replace-all
- SmartNewline recorded its indent insert after moving the cursor, so
  undo deleted the wrong text; it is now recorded at the insertion
  column and grouped with the newline. With a prompt open it now just
  accepts the prompt (D1, B11).
- Scroll up/down, jump-to-mark and replace-all clamp the cursor to the
  buffer, so edits are never made past end-of-line or end-of-buffer
  (D2, M5).
- Replace-all is one undo step and no longer rebuilds Rows() per match
  (D5, P4).
- Reflow on a blank line no longer merges adjacent paragraphs (D6).
- ensure_cursor_visible uses Nrows()/GetLineString instead of Rows() and
  GetLineView, avoiding a whole-file rebuild per command (P1, P2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00
kyleandClaude Sonnet 5 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
kyleandClaude Opus 4.6 6413e14455 Fix writing mode: prevent per-frame override and disable syntax highlighting
apply_syntax_to_buffer() was called every frame and unconditionally reset
edit mode from the file extension, making it impossible to toggle out of
writing mode for .txt/.md files. Add edit_mode_detected_ flag to Buffer so
auto-detection runs once per buffer. Writing mode now also disables syntax
highlighting as intended. Propagate edit_mode_ through Buffer copy/move ops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:03:25 -07:00
kyleandClaude Opus 4.6 23f04e4357 Add proportional fonts, edit modes, and TOML config
- Add three proportional serif fonts: Crimson Pro, ET Book, Spectral
- Fix text rendering for variable-width fonts: selection, cursor,
  mouse click mapping, search highlights, and syntax-colored text
  now use pixel-accurate measurement via ImGui::CalcTextSize()
- Add per-buffer edit mode (code/writing) with auto-detection from
  file extension (.txt, .md, .rst, .org, .tex default to writing)
- Add C-k m keybinding and :mode command to toggle edit modes
- Switch config format from INI to TOML (kge.toml), with legacy
  INI fallback; vendor toml++ v3.4.0
- New config keys: font.code and font.writing for per-mode defaults
- Add font tab completion for ImGui builds
- Add tab completion for :mode command
- Update help text, themes.md, and add CONFIG.md
- Bump version to 1.10.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:05:56 -07:00
kyleandClaude Opus 4.6 8712ea673d Add leuchtturm theme, font zoom, syntax palette fixes
Themes:
- Add leuchtturm theme (fountain pen ink on cream paper, brass/leather dark)
- Add per-theme syntax palettes for leuchtturm, tufte, and everforest
- Fix static inline globals giving each TU its own copy of gCurrentTheme
  and gBackgroundMode (changed to inline for proper C++17 linkage)
- :background with no args now shows current mode

Font zoom:
- CMD-=/CMD--/CMD-0 to increase/decrease/reset font size

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:17:55 -07:00
kyleandClaude Opus 4.6 3148e16cf8 Fix multi-window architecture and swap file cleanup
Multi-window:
- Per-window ImGui contexts (fixes input, scroll, and rendering isolation)
- Per-instance scroll and mouse state in ImGuiRenderer (no more statics)
- Proper GL context activation during window destruction
- ValidateBufferIndex guards against stale curbuf_ across shared buffers
- Editor methods (CurrentBuffer, SwitchTo, CloseBuffer, etc.) use Buffers()
  accessor to respect shared buffer lists
- New windows open with an untitled buffer
- Scratch buffer reuse works in secondary windows
- CMD-w on macOS closes only the focused window
- Deferred new-window creation to avoid mid-frame ImGui context corruption

Swap file cleanup:
- SaveAs prompt handler now calls ResetJournal
- cmd_save_and_quit now calls ResetJournal
- Editor::Reset detaches all buffers before clearing
- Tests for save-and-quit and editor-reset swap cleanup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:48:34 -07:00
kyle d768e56727 Add multi-window support to GUI with shared buffer list and improved input handling
- Introduced support for multiple windows, sharing the primary editor's buffer list.
- Added `GUIFrontend::OpenNewWindow_` for creating secondary windows with independent dimensions and input handlers.
- Redesigned `WindowState` to encapsulate per-window attributes (dimensions, renderer, input, etc.).
- Updated input processing and command execution to route events based on active window, preserving window-level states.
- Enhanced SDL2 and ImGui integration for proper context management across multiple windows.
- Increased robustness by handling window closing, resizing, and cleanup of secondary windows without affecting the primary editor.
- Updated documentation and key bindings for multi-window operations (e.g., Cmd+N / Ctrl+Shift+N).
- Version updated to 1.8.0 to reflect the major GUI enhancement.
2026-03-15 13:19:04 -07:00
kyle 27dcb41857 Add ReflowUndo tests and integrate InsertRow undo support
- Added `test_reflow_undo.cc` to validate undo/redo workflows for reflow operations.
- Introduced `UndoType::InsertRow` in `UndoSystem` for tracking row insertion changes in undo history.
- Updated `UndoNode.h` and `UndoSystem.cc` to support row insertion as a standalone undo step.
- Enhanced reflow paragraph functionality to properly record undo/redo actions for both row deletion and insertion.
- Enabled legacy/extended undo tests in `test_undo.cc` for comprehensive validation.
- Updated `CMakeLists.txt` to include new test file in the build target.
2026-02-26 13:21:07 -08:00
kyle bc3433e988 Add SmartNewline command with tests and editor integration
- Introduced `CommandId::SmartNewline` for auto-indented newlines, enhancing text editing workflows.
- Added `cmd_smart_newline` to implement indentation-aware newline logic.
- Integrated SmartNewline with keymaps, mouse/keyboard input handlers, and terminal/editor commands.
- Wrote comprehensive tests in `test_smart_newline.cc` to validate behavior for spaces, tabs, and no-indentation cases.
- Updated `Command.h` and `CMakeLists.txt` to register and build the new command.
2026-02-26 13:08:56 -08:00
kyle 337b585ba0 Reformat code. 2026-02-17 13:44:36 -08:00
kyle 199d7a20f7 Add indented bullet reflow test, improve undo edge cases, and bump version
- Added `test_reflow_indented_bullets.cc` to verify correct reflow handling for indented bullet points.
- Enhanced undo system with additional tests for cursor adjacency, explicit grouping, branching, newline independence, and dirty-state tracking.
- Introduced external modification detection for files and required confirmation before overwrites.
- Refactored buffer save logic to use atomic writes and track on-disk identity.
- Updated CMake to include new test files and bumped version to 1.6.4.
2026-02-16 12:44:08 -08:00
kyle 44827fe53f Add mark-clearing behavior to refresh command and related test.
- Updated `Refresh` command to clear the mark when no active prompt, search, or visual-line mode is present.
- Added a new unit test verifying mark-clearing behavior for `Ctrl-G` (mapped to `Refresh`).
- Bumped version to 1.6.3 in `CMakeLists.txt`.
2026-02-14 23:05:44 -08:00
kyle 2a6ff2a862 Introduce swap journaling crash recovery system with tests.
- Added detailed journaling system (`SwapManager`) for crash recovery, including edit recording and replay.
- Integrated recovery prompts for handling swap files during file open flows.
- Implemented swap file cleanup, checkpointing, and compaction mechanisms.
- Added extensive unit tests for swap-related behaviors such as recovery prompts, file pruning, and corruption handling.
- Updated CMake to include new test files.
2026-02-13 08:45:27 -08:00
kyle 895e4ccb1e Add swap journaling and group undo/redo with extensive tests.
- Introduced SwapManager for sidecar journaling of buffer mutations, with a safe recovery mechanism.
- Added group undo/redo functionality, allowing atomic grouping of related edits.
- Implemented `SwapRecorder` and integrated it as a callback interface for mutations.
- Added unit tests for swap journaling (save/load/replay) and undo grouping.
- Refactored undo to support group tracking and ID management.
- Updated CMake to include the new tests and swap journaling logic.
2026-02-11 20:47:18 -08:00
kyle cc8df36bdf Implement branching undo system with tests and updates.
- Added branching model for undo/redo, enabling multiple redo paths and branch selection.
- Updated `UndoNode` to include `parent` and refined hierarchical navigation.
- Extended `UndoSystem` with branching logic for redo operations, supporting sibling branch selection.
- Overhauled tests to validate branching behavior and tree invariants.
- Refined editor command logic for undo/redo with repeat counts and branch selection.
- Enabled test-only introspection hooks for undo tree validation.
- Updated CMake to include test definitions (`KTE_TESTS` flag).
2026-02-10 23:13:00 -08:00
kyle ac0eadc345 Add undo system with coalescing logic and comprehensive tests.
- Implemented robust undo system supporting coalescing of text operations (insert, backspace, delete).
- Added `UndoSystem` integration into the editor/commands pipeline.
- Wrote extensive unit tests for various undo/redo scenarios, including multiline operations, cursor preservation, and history management.
- Refactored to ensure consistent cursor behavior during undo/redo actions.
- Updated CMake to include new tests.
2026-02-10 22:39:55 -08:00
kyle f3bdced3d4 Add visual-line mode support with tests and UI integration.
- Introduced visual-line mode for multi-line selection and edits.
- Implemented commands, rendering, and keyboard shortcuts.
- Added tests for broadcast operations in visual-line mode.
2026-02-10 22:07:13 -08:00
kyle 2551388420 Support numbered lists in reflow-paragraph.
Add `reflow-paragraph` tests for numbered lists with hanging indents and extend support for numbered list parsing and wrapping logic.
2026-02-10 21:23:20 -08:00
kyle 2542690eca updating jump to line 2026-01-01 19:12:46 -08:00
kyle e1f9a9eb6a Preserve cursor position on buffer reload.
- Remember and restore the cursor's position after reloading a buffer, clamping if necessary.
- Improve user experience by maintaining editing context.
2025-12-07 15:25:40 -08:00